549 lines
20 KiB
Rust
549 lines
20 KiB
Rust
//! df-relay 中继服务核心实现
|
|
//!
|
|
//! 设计依据:设计文档「Layer2」—— axum WS Server + 广播中继。
|
|
//! 接受两类连接:小程序(device_id 鉴权)+ 桌面端(token 配对),按 device_id
|
|
//! 配对转发(非全局广播),纯转发无业务逻辑。
|
|
//!
|
|
//! 协议(简单握手):
|
|
//! 1. 客户端建立 WS 后,首条消息发 JSON `Hello { kind, device_id, token }` 宣告身份。
|
|
//! 2. relay 校验 token(MVP:env `DF_RELAY_TOKEN` 或硬编码常量;生产级鉴权留 Phase3)。
|
|
//! 3. 校验通过 → 注册连接、进入收发循环;失败 → 发 Error 帧 + Close。
|
|
//! 4. 后续消息按 kind 路由:Event(device→miniapp)/ Command(miniapp→device)/ Control。
|
|
//!
|
|
//! ## 鉴权模型(MC-4 现状与风险)
|
|
//! MVP 单共享 token(`DF_RELAY_TOKEN`),无 per-device 绑定:**任何持 token 客户端可声明任意
|
|
//! `device_id` 并冒充该设备收发指令**。生产级 per-device token(配对时按 device 颁发独立 token
|
|
//! 并存储)涉及配对流程改造(需 df-miniapp/df-tunnel 配合),当前批次不实施,仅做最小加固:
|
|
//! - 连接建立时校验 `device_id` 格式(非空 + 长度上限,见 `validate_device_id`)。
|
|
//! - token 比较用常量时间比较(RLY-4),防时序侧信道。
|
|
//! 完整 per-device 鉴权留设计文档 Phase3。
|
|
//!
|
|
//! AiChatEvent JSON 透传:relay 不解析 payload,只按 device_id + 方向转发。
|
|
|
|
use std::net::SocketAddr;
|
|
|
|
use async_trait::async_trait;
|
|
use axum::{
|
|
extract::{
|
|
ws::{Message, WebSocket, WebSocketUpgrade},
|
|
State,
|
|
},
|
|
response::IntoResponse,
|
|
routing::get,
|
|
Router,
|
|
};
|
|
use futures_util::{SinkExt, StreamExt};
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::sync::mpsc;
|
|
|
|
use crate::broadcast::{BroadcastMessage, ClientKind, MessageKind};
|
|
use crate::conn::{next_conn_id, ConnHandle, ConnId, RelayState};
|
|
use crate::error::{RelayError, Result};
|
|
|
|
/// 读取期望 token(必需:env `DF_RELAY_TOKEN` 必须设置)。
|
|
/// RLY-5:不再 panic,改为返回 Option——缺失时由连接层显式拒绝握手(main 启动时也已校验)。
|
|
/// 生产级鉴权(每 device 独立 token + 过期刷新)留 Phase3。
|
|
fn expected_token() -> Option<String> {
|
|
std::env::var("DF_RELAY_TOKEN").ok()
|
|
}
|
|
|
|
/// RLY-3:入站文本帧大小上限(1 MiB)。
|
|
/// 防恶意/异常客户端发超大帧耗尽内存与转发带宽;超限直接断开连接。
|
|
const MAX_FRAME_BYTES: usize = 1 * 1024 * 1024;
|
|
|
|
/// 常量时间字符串比较(RLY-4,防时序侧信道)。
|
|
///
|
|
/// 没有引入 subtle 依赖;用等长逐字节异或累加 + 尾随等量循环掩蔽长度差,
|
|
/// 比较时长不随首字节差异提前返回。长度本身不保密(可被包长度观测),故长度不等直接返回。
|
|
fn constant_time_eq(a: &str, b: &str) -> bool {
|
|
let a = a.as_bytes();
|
|
let b = b.as_bytes();
|
|
// 用最大长度循环,短者补 0 参与异或,时长恒等于较长者,不泄露首字节命中点
|
|
let len = a.len().max(b.len());
|
|
let mut diff: u8 = 0;
|
|
for i in 0..len {
|
|
let av = a.get(i).copied().unwrap_or(0);
|
|
let bv = b.get(i).copied().unwrap_or(0);
|
|
diff |= av ^ bv;
|
|
}
|
|
diff == 0
|
|
}
|
|
|
|
/// MC-4 最小加固:校验 device_id 格式。
|
|
/// 拒绝空串与超长(防内存/日志注入),仅做基础防御;per-device token 绑定留 Phase3。
|
|
fn validate_device_id(id: &str) -> bool {
|
|
!id.is_empty() && id.len() <= 128 && id.chars().all(|c| !c.is_control())
|
|
}
|
|
|
|
/// 客户端首消息:身份宣告(简单协议)
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Hello {
|
|
/// 客户端类型("device" / "miniapp")
|
|
pub kind: ClientKindWire,
|
|
/// 配对绑定的设备 ID
|
|
pub device_id: String,
|
|
/// 配对 token
|
|
pub token: String,
|
|
}
|
|
|
|
/// Hello.kind 的传输表示(serde 字符串,与 ClientKind 解耦避免 rename 歧义)
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ClientKindWire {
|
|
Device,
|
|
Miniapp,
|
|
}
|
|
|
|
impl From<ClientKindWire> for ClientKind {
|
|
fn from(w: ClientKindWire) -> Self {
|
|
match w {
|
|
ClientKindWire::Device => ClientKind::Device,
|
|
ClientKindWire::Miniapp => ClientKind::Miniapp,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 中继服务抽象
|
|
///
|
|
/// 设计为 trait 便于测试 mock + 未来替换实现(如换 tonic gRPC 网关)。
|
|
#[async_trait]
|
|
pub trait RelayServer: Send + Sync {
|
|
/// 启动 HTTP/WS 服务监听指定地址
|
|
async fn start(&self, addr: &str) -> Result<()>;
|
|
|
|
/// 广播消息给指定 device_id 绑定的对端
|
|
async fn broadcast(&self, msg: BroadcastMessage) -> Result<()>;
|
|
|
|
/// 查询 device 是否有在线连接(离线降级判断用)
|
|
fn is_device_online(&self, device_id: &str) -> bool;
|
|
}
|
|
|
|
/// 默认中继服务(持有共享 RelayState)
|
|
pub struct DefaultRelayServer {
|
|
state: RelayState,
|
|
}
|
|
|
|
impl DefaultRelayServer {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
state: RelayState::new(),
|
|
}
|
|
}
|
|
|
|
/// 从既有 RelayState 构造(测试 / 外部复用)
|
|
pub fn with_state(state: RelayState) -> Self {
|
|
Self { state }
|
|
}
|
|
|
|
/// 暴露共享状态(外部可读连接数等)
|
|
pub fn state(&self) -> RelayState {
|
|
self.state.clone()
|
|
}
|
|
}
|
|
|
|
impl Default for DefaultRelayServer {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl RelayServer for DefaultRelayServer {
|
|
async fn start(&self, addr: &str) -> Result<()> {
|
|
let socket_addr: SocketAddr = addr
|
|
.parse()
|
|
.map_err(|e| RelayError::Start(format!("地址解析失败 {addr}: {e}")))?;
|
|
let app = build_router(self.state.clone());
|
|
let listener = tokio::net::TcpListener::bind(&socket_addr)
|
|
.await
|
|
.map_err(|e| RelayError::Start(format!("监听绑定失败 {addr}: {e}")))?;
|
|
tracing::info!(%addr, "df-relay WS 服务已启动");
|
|
axum::serve(listener, app)
|
|
.await
|
|
.map_err(|e| RelayError::Start(format!("axum::serve 失败: {e}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn broadcast(&self, msg: BroadcastMessage) -> Result<()> {
|
|
let delivered = self.state.route(&msg).await;
|
|
if delivered == 0 {
|
|
// 对端离线不算硬错误(MVP 返回 Ok,离线降级由调用方据 is_device_online 判断)
|
|
tracing::debug!(
|
|
device_id = %msg.device_id,
|
|
kind = ?msg.kind,
|
|
"广播无对端在线(消息丢弃)"
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn is_device_online(&self, device_id: &str) -> bool {
|
|
// trait 同步签名:tokio Mutex 用 try_lock 快照,失败保守返回 false
|
|
match self.state.inner().try_lock() {
|
|
Ok(g) => g.is_device_online(device_id),
|
|
Err(_) => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// axum WS 路由构造
|
|
///
|
|
/// 暴露 `/ws/device`(桌面端连入)与 `/ws/miniapp`(小程序连入)两个端点,
|
|
/// 共享 RelayState。端点仅决定「期望的客户端类型」,真正的身份宣告在首消息
|
|
/// Hello 中再次校验(防误连/误用)。
|
|
pub fn build_router(state: RelayState) -> Router {
|
|
Router::new()
|
|
.route("/ws/device", get(device_ws_handler))
|
|
.route("/ws/miniapp", get(miniapp_ws_handler))
|
|
.with_state(state)
|
|
}
|
|
|
|
/// 桌面端 WS upgrade handler
|
|
async fn device_ws_handler(
|
|
ws: WebSocketUpgrade,
|
|
State(state): State<RelayState>,
|
|
) -> impl IntoResponse {
|
|
tracing::debug!("桌面端 WS 连接接入");
|
|
ws.on_upgrade(move |socket| handle_connection(socket, state, ClientKindWire::Device))
|
|
}
|
|
|
|
/// 小程序 WS upgrade handler
|
|
async fn miniapp_ws_handler(
|
|
ws: WebSocketUpgrade,
|
|
State(state): State<RelayState>,
|
|
) -> impl IntoResponse {
|
|
tracing::debug!("小程序 WS 连接接入");
|
|
ws.on_upgrade(move |socket| handle_connection(socket, state, ClientKindWire::Miniapp))
|
|
}
|
|
|
|
/// WS 连接生命周期(握手 → 收发循环 → 注销)
|
|
///
|
|
/// 步骤:
|
|
/// 1. 等待首条 Hello 文本帧,校验 kind 与 token。
|
|
/// 2. 校验通过:分配 conn_id + mpsc,注册 ConnHandle,派发广播读取任务。
|
|
/// 3. 主循环:从 socket recv 文本帧 → 构造 BroadcastMessage → route 投递。
|
|
/// 4. 同时读取 mpsc 广播队列 → 写回 socket(双任务用 split sink/stream)。
|
|
/// 5. 任一端断开 → 注销连接、关闭 mpsc。
|
|
async fn handle_connection(socket: WebSocket, state: RelayState, expected: ClientKindWire) {
|
|
// 握手阶段:等待首条 Hello
|
|
let (mut socket_tx, mut socket_rx) = socket.split();
|
|
|
|
let hello = match recv_hello(&mut socket_rx).await {
|
|
Ok(h) => h,
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "握手失败:未收到合法 Hello");
|
|
let _ = send_text(
|
|
&mut socket_tx,
|
|
r#"{"kind":"control","error":"handshake_failed"}"#,
|
|
)
|
|
.await;
|
|
let _ = socket_tx.close().await;
|
|
return;
|
|
}
|
|
};
|
|
|
|
// 身份 + token 双因子校验
|
|
if hello.kind != expected {
|
|
tracing::warn!(
|
|
?hello.kind,
|
|
?expected,
|
|
"握手失败:客户端类型与端点不匹配"
|
|
);
|
|
let _ = send_text(
|
|
&mut socket_tx,
|
|
r#"{"kind":"control","error":"kind_mismatch"}"#,
|
|
)
|
|
.await;
|
|
let _ = socket_tx.close().await;
|
|
return;
|
|
}
|
|
// MC-4 最小加固:device_id 格式校验(空/超长/含控制字符一律拒)。
|
|
// 单共享 token 下 device_id 无强绑定,但格式校验可挡最基础的注入/异常输入。
|
|
if !validate_device_id(&hello.device_id) {
|
|
tracing::warn!(
|
|
device_id = %hello.device_id,
|
|
"握手失败:device_id 格式非法"
|
|
);
|
|
let _ = send_text(
|
|
&mut socket_tx,
|
|
r#"{"kind":"control","error":"invalid_device_id"}"#,
|
|
)
|
|
.await;
|
|
let _ = socket_tx.close().await;
|
|
return;
|
|
}
|
|
// RLY-5:token 未配置(服务启动时应已由 main 校验)→ 拒绝握手而非 panic
|
|
let Some(expected) = expected_token() else {
|
|
tracing::error!("DF_RELAY_TOKEN 未设置,拒绝握手");
|
|
let _ = send_text(
|
|
&mut socket_tx,
|
|
r#"{"kind":"control","error":"server_misconfigured"}"#,
|
|
)
|
|
.await;
|
|
let _ = socket_tx.close().await;
|
|
return;
|
|
};
|
|
// RLY-4:常量时间比较防时序侧信道
|
|
if !constant_time_eq(&hello.token, &expected) {
|
|
tracing::warn!(
|
|
device_id = %hello.device_id,
|
|
"握手失败:token 校验不通过"
|
|
);
|
|
let _ = send_text(
|
|
&mut socket_tx,
|
|
r#"{"kind":"control","error":"auth_failed"}"#,
|
|
)
|
|
.await;
|
|
let _ = socket_tx.close().await;
|
|
return;
|
|
}
|
|
|
|
let conn_id = next_conn_id();
|
|
let kind: ClientKind = hello.kind.into();
|
|
let device_id = hello.device_id.clone();
|
|
tracing::info!(
|
|
conn_id = conn_id.0,
|
|
?kind,
|
|
device_id = %device_id,
|
|
"连接握手通过,进入收发循环"
|
|
);
|
|
|
|
// 握手通过:立即发 ack 控制帧给客户端。
|
|
// 客户端据此判定握手成功(首条非 error 消息即 handshaked),不依赖等待对端首条业务消息 ——
|
|
// 否则单端连入时(device 离线)relay 静默,客户端永卡 handshaking,send 被 handshaked 守卫拦截。
|
|
let _ = send_text(
|
|
&mut socket_tx,
|
|
r#"{"kind":"control","payload":{"control_kind":"hello_ack"}}"#,
|
|
)
|
|
.await;
|
|
|
|
// 建立广播投递 mpsc(连接读取任务消费 → 写回 socket)
|
|
let (bc_tx, bc_rx) = mpsc::unbounded_channel::<BroadcastMessage>();
|
|
let handle = ConnHandle::new(conn_id, kind, device_id.clone(), bc_tx);
|
|
state.add_conn(handle).await;
|
|
|
|
// 派发广播读取任务:从 bc_rx 取消息 → 序列化 → 写 socket
|
|
let mut bc_task = tokio::spawn(broadcast_pump(bc_rx, socket_tx));
|
|
|
|
// 主循环:从 socket recv → 构造 BroadcastMessage → route
|
|
loop {
|
|
tokio::select! {
|
|
// socket 入帧
|
|
maybe_msg = socket_rx.next() => {
|
|
match maybe_msg {
|
|
Some(Ok(Message::Text(text))) => {
|
|
// RLY-3:入站帧超限(>1MiB)直接断开,防大帧耗尽内存/带宽
|
|
if text.len() > MAX_FRAME_BYTES {
|
|
tracing::warn!(
|
|
conn_id = conn_id.0,
|
|
len = text.len(),
|
|
"入站帧超限,断开连接"
|
|
);
|
|
break;
|
|
}
|
|
if let Err(e) = handle_inbound_text(&state, conn_id, kind, &device_id, &text).await {
|
|
tracing::warn!(conn_id = conn_id.0, error = %e, "入站消息处理失败,忽略");
|
|
}
|
|
}
|
|
Some(Ok(Message::Binary(_))) => {
|
|
// MVP 仅支持文本帧;二进制帧忽略(协议层可后续扩展)
|
|
tracing::debug!(conn_id = conn_id.0, "收到二进制帧,忽略");
|
|
}
|
|
Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => {
|
|
// axum/tungstenite 协议层 Ping/Pong 自动处理,这里仅记录
|
|
tracing::trace!(conn_id = conn_id.0, "协议层 Ping/Pong");
|
|
}
|
|
Some(Ok(Message::Close(_))) | None => {
|
|
tracing::info!(conn_id = conn_id.0, "客户端主动关闭连接");
|
|
break;
|
|
}
|
|
Some(Err(e)) => {
|
|
tracing::warn!(conn_id = conn_id.0, error = %e, "socket 接收错误,断开");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// 广播 pump 任务结束(socket_tx 关闭或 mpsc 关闭)
|
|
res = &mut bc_task => {
|
|
match res {
|
|
Ok(()) => {
|
|
tracing::debug!(conn_id = conn_id.0, "广播 pump 任务正常结束");
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(conn_id = conn_id.0, error = %e, "广播 pump 任务 panic");
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 注销连接
|
|
if let Some(d) = state.remove_conn(conn_id).await {
|
|
tracing::info!(conn_id = conn_id.0, device_id = %d, "连接已注销");
|
|
}
|
|
// 结束 pump 任务(若仍在运行)
|
|
bc_task.abort();
|
|
}
|
|
|
|
/// 接收并解析首条 Hello 文本帧
|
|
async fn recv_hello(rx: &mut futures_util::stream::SplitStream<WebSocket>) -> Result<Hello> {
|
|
let deadline = tokio::time::Duration::from_secs(10);
|
|
let next = tokio::time::timeout(deadline, rx.next())
|
|
.await
|
|
.map_err(|_| RelayError::Client("握手超时(10s 未收到 Hello)".into()))?;
|
|
let msg = next
|
|
.ok_or_else(|| RelayError::Client("握手阶段连接关闭".into()))?
|
|
.map_err(|e| RelayError::WebSocket(format!("握手 recv 失败: {e}")))?;
|
|
let text = match msg {
|
|
Message::Text(t) => t,
|
|
Message::Binary(_) => {
|
|
return Err(RelayError::Client("握手首帧必须为文本".into()));
|
|
}
|
|
_ => return Err(RelayError::Client("握手首帧类型非法".into())),
|
|
};
|
|
// RLY-3:Hello 帧同样限长(Hello 结构很小,超限视为异常/恶意)
|
|
if text.len() > MAX_FRAME_BYTES {
|
|
return Err(RelayError::Client(format!(
|
|
"Hello 帧超限({} B > {MAX_FRAME_BYTES} B)",
|
|
text.len()
|
|
)));
|
|
}
|
|
let hello: Hello =
|
|
serde_json::from_str(&text).map_err(|e| RelayError::Client(format!("Hello 解析失败: {e}")))?;
|
|
Ok(hello)
|
|
}
|
|
|
|
/// 处理入站文本帧(构造 BroadcastMessage → route)
|
|
async fn handle_inbound_text(
|
|
state: &RelayState,
|
|
conn_id: ConnId,
|
|
kind: ClientKind,
|
|
device_id: &str,
|
|
raw: &str,
|
|
) -> Result<()> {
|
|
// 入站文本即业务 payload(relay 不解析),包成 BroadcastMessage
|
|
// payload 直接用原始 JSON 值;若客户端发非 JSON 文本,则包成字符串值
|
|
let payload: serde_json::Value =
|
|
serde_json::from_str(raw).unwrap_or(serde_json::Value::String(raw.to_string()));
|
|
|
|
// 心跳协议:miniapp 发 {control_kind: "ping"} → relay 直接回 pong(不经 device 透传)。
|
|
// miniapp 用 pong 更新 lastPongTime 看门狗(防移动网络 TCP 半连接挂死)。
|
|
// device 端收到 control 消息也仅 console.log,不影响业务。
|
|
if let Some(control_kind) = payload
|
|
.get("control_kind")
|
|
.and_then(|v| v.as_str())
|
|
{
|
|
if control_kind == "ping" && kind == ClientKind::Miniapp {
|
|
let pong_payload = serde_json::json!({"control_kind": "pong"});
|
|
let pong_msg = BroadcastMessage {
|
|
device_id: device_id.to_string(),
|
|
kind: crate::broadcast::MessageKind::Control,
|
|
source: conn_id,
|
|
from: ClientKind::Device, // pong 来自 relay(代理 device),让 miniapp 识别为合法响应
|
|
payload: pong_payload,
|
|
ts: now_ms(),
|
|
};
|
|
let _ = state.route(&pong_msg).await;
|
|
tracing::trace!(
|
|
conn_id = conn_id.0,
|
|
device_id = %device_id,
|
|
"miniapp ping → relay pong(本地响应)"
|
|
);
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
let now = now_ms();
|
|
let (msg_kind, from) = match kind {
|
|
ClientKind::Device => (MessageKind::Event, ClientKind::Device),
|
|
ClientKind::Miniapp => (MessageKind::Command, ClientKind::Miniapp),
|
|
};
|
|
let msg = BroadcastMessage {
|
|
device_id: device_id.to_string(),
|
|
kind: msg_kind,
|
|
source: conn_id,
|
|
from,
|
|
payload,
|
|
ts: now,
|
|
};
|
|
let delivered = state.route(&msg).await;
|
|
tracing::debug!(
|
|
conn_id = conn_id.0,
|
|
?msg_kind,
|
|
device_id = %device_id,
|
|
delivered,
|
|
"入站消息已路由"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 广播 pump:从 mpsc 取消息,序列化后写回 socket sink
|
|
///
|
|
/// 任务退出条件:bc_rx 关闭(对端 handle 全部 drop)/ socket_tx 关闭出错。
|
|
async fn broadcast_pump(
|
|
mut bc_rx: mpsc::UnboundedReceiver<BroadcastMessage>,
|
|
mut socket_tx: futures_util::stream::SplitSink<WebSocket, Message>,
|
|
) {
|
|
while let Some(msg) = bc_rx.recv().await {
|
|
let text = match serde_json::to_string(&msg) {
|
|
Ok(t) => t,
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "广播消息序列化失败,跳过");
|
|
continue;
|
|
}
|
|
};
|
|
if let Err(e) = socket_tx.send(Message::Text(text)).await {
|
|
tracing::warn!(error = %e, "广播写回 socket 失败,pump 退出");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 便捷发送文本帧
|
|
async fn send_text(
|
|
tx: &mut futures_util::stream::SplitSink<WebSocket, Message>,
|
|
text: &str,
|
|
) -> Result<()> {
|
|
tx.send(Message::Text(text.to_string()))
|
|
.await
|
|
.map_err(|e| RelayError::WebSocket(format!("发送失败: {e}")))
|
|
}
|
|
|
|
/// 当前毫秒时间戳(避开 chrono workspace 依赖,直接用 std + SystemTime)
|
|
fn now_ms() -> i64 {
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_millis() as i64)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn constant_time_eq_equality_and_inequality() {
|
|
assert!(constant_time_eq("abc", "abc"));
|
|
assert!(!constant_time_eq("abc", "abd"));
|
|
assert!(!constant_time_eq("abc", "ab"));
|
|
assert!(!constant_time_eq("", "a"));
|
|
assert!(constant_time_eq("", ""));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_device_id_basic() {
|
|
assert!(validate_device_id("dev-1"));
|
|
assert!(validate_device_id("a".repeat(128).as_str()), "上限 128 应放行");
|
|
assert!(!validate_device_id(""), "空串拒绝");
|
|
assert!(!validate_device_id("a".repeat(129).as_str()), "超长拒绝");
|
|
assert!(!validate_device_id("dev\u{0001}"), "控制字符拒绝");
|
|
}
|
|
|
|
#[test]
|
|
fn max_frame_is_one_mib() {
|
|
assert_eq!(MAX_FRAME_BYTES, 1024 * 1024);
|
|
}
|
|
}
|