优化: aichat效率剩余(压缩后台化防阻塞/审计批量事务/只读缓存轮内去重/流式增量渲染/AiCommandOutput合批/双渲染合并) + 跨端加固(df-project路径保留大小写/tunnel文档更正supervisor重连/relay固定时间比较与帧上限/启动校验) + 销账

This commit is contained in:
lxy
2026-08-09 21:35:59 +08:00
parent fbd8fae44b
commit 11f4978ec1
15 changed files with 841 additions and 193 deletions
+45 -3
View File
@@ -61,10 +61,10 @@ pub(super) fn truncate_chars(s: &str, max: usize) -> String {
format!("{}…(已截断)", truncated)
}
/// 规范化路径用于比较:canonicalize 解析绝对规范路径(失败降级),
/// 规范化路径用于比较/查重:canonicalize 解析绝对规范路径(失败降级),
/// 统一正斜杠 + 小写。防 `C:\a\b` vs `C:/a/b/` 绕过重复检查。
/// 注:仅用于比较,存库保留用户输入的原始可读路径
pub fn normalize_path(p: &str) -> String {
/// 注:仅用于比较,存库请用 [`canonicalize_for_store`](保留大小写)
pub fn normalize_for_compare(p: &str) -> String {
match Path::new(p).canonicalize() {
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
Err(_) => p
@@ -74,6 +74,22 @@ pub fn normalize_path(p: &str) -> String {
}
}
/// 规范化路径用于存库:canonicalize 解析绝对规范路径(失败降级为 trim + 正斜杠),
/// **保留大小写**(大小写敏感系统如 Linux/容器文件系统路径解析需原样大小写,
/// 小写化会导致路径解析失败)。比较/查重请用 [`normalize_for_compare`]。
pub fn canonicalize_for_store(p: &str) -> String {
match Path::new(p).canonicalize() {
Ok(abs) => abs.to_string_lossy().replace('\\', "/"),
Err(_) => p.trim_end_matches(['\\', '/']).replace('\\', "/"),
}
}
/// 兼容别名:小写比较版(等价 [`normalize_for_compare`])。
/// 旧调用方沿用;新代码按用途选 `normalize_for_compare`(比较)/ `canonicalize_for_store`(存库)。
pub fn normalize_path(p: &str) -> String {
normalize_for_compare(p)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -111,4 +127,30 @@ mod tests {
assert!(!n.contains('\\'), "反斜杠未归一: {n}");
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
}
#[test]
fn canonicalize_for_store_preserves_case() {
// 存库版保留大小写(降级分支:不存在的路径走 trim + replace,大小写原样保留)
let n = canonicalize_for_store(r"C:\Foo\Bar\");
assert!(!n.contains('\\'), "反斜杠未归一: {n}");
assert!(
!n.ends_with('/') && !n.ends_with('\\'),
"尾部分隔符未裁剪: {n}"
);
assert!(
n.contains("Foo") && n.contains("Bar"),
"存库版不应小写化(大小写敏感系统路径解析依赖): {n}"
);
}
#[test]
fn compare_lowercases_but_store_keeps_case() {
// compare 版与 store 版语义分离:比较小写、存库保留大小写
let p = r"C:\Foo\Bar";
let comp = normalize_for_compare(p);
let store = canonicalize_for_store(p);
assert_eq!(comp, comp.to_lowercase(), "比较版应小写: {comp}");
assert_ne!(comp, store, "小写化仅限比较版,存库版应保留大小写: {store}");
assert!(!store.contains('\\'), "存库版应正斜杠: {store}");
}
}
+10 -3
View File
@@ -4,15 +4,22 @@
//! - `/ws/device`:桌面端 df-tunnel 连入(Hello device 鉴权)
//! - `/ws/miniapp`:小程序连入(按 device_id 配对路由)
//!
//! 纯转发中继,无业务逻辑。token 校验对齐 `DEFAULT_TOKEN`(relay.rs,可 `DF_RELAY_TOKEN` env 覆盖)。
//! 纯转发中继,无业务逻辑。token 校验对齐 `DF_RELAY_TOKEN`(relay.rs,必须设置)。
//!
//! 联调启动:`cargo run -p df-relay`(或 `DF_RELAY_ADDR=0.0.0.0:8080 cargo run -p df-relay`)。
//! 联调启动:`DF_RELAY_TOKEN=<token> cargo run -p df-relay`(未设 token 时启动即失败,exit 1)。
use df_relay::{DefaultRelayServer, RelayServer};
use df_relay::{DefaultRelayServer, RelayError, RelayServer};
#[tokio::main]
async fn main() -> df_relay::Result<()> {
tracing_subscriber::fmt::init();
// RLY-5:token 未设置 → 启动即返回错误(exit code 1 + 明确消息),而非运行期 panic。
// relay.rs expected_token() 已改为返回 Option 兜底,但 fail-fast 比慢速拒连更早暴露配置错误。
if std::env::var("DF_RELAY_TOKEN").is_err() {
return Err(RelayError::Start(
"必须设置环境变量 DF_RELAY_TOKEN(未设置时拒绝启动)".into(),
));
}
// 监听地址:env 覆盖,默认 0.0.0.0:8080(对齐 df-miniapp config.ts relayHost + df-tunnel src-tauri 集成)。
let addr = std::env::var("DF_RELAY_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string());
tracing::info!(%addr, "[df-relay] 启动跨端中继 axum WS Server(/ws/device + /ws/miniapp)");
+112 -6
View File
@@ -10,6 +10,14 @@
//! 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;
@@ -32,12 +40,39 @@ 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` 必须设置,未设置时 panic)。
/// 读取期望 token(必需:env `DF_RELAY_TOKEN` 必须设置)。
/// RLY-5:不再 panic,改为返回 Option——缺失时由连接层显式拒绝握手(main 启动时也已校验)。
/// 生产级鉴权(每 device 独立 token + 过期刷新)留 Phase3。
fn expected_token() -> String {
std::env::var("DF_RELAY_TOKEN").unwrap_or_else(|_| {
panic!("必须设置环境变量 DF_RELAY_TOKEN")
})
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())
}
/// 客户端首消息:身份宣告(简单协议)
@@ -222,7 +257,34 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien
let _ = socket_tx.close().await;
return;
}
if hello.token != expected_token() {
// 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 校验不通过"
@@ -270,6 +332,15 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien
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, "入站消息处理失败,忽略");
}
@@ -331,6 +402,13 @@ async fn recv_hello(rx: &mut futures_util::stream::SplitStream<WebSocket>) -> Re
}
_ => 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)
@@ -440,3 +518,31 @@ fn now_ms() -> i64 {
.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);
}
}
@@ -243,6 +243,56 @@ impl_repo!(
// 通用 query 宏硬编码 ORDER BY created_at 会触发 "no such column" → 调用方 unwrap_or_default 吞错。
// 故为此表提供专用查询,绕过通用 query。详见 ai.rs audit_finalize。
impl AiToolExecutionRepo {
/// 批量插入审计记录(单事务多行 INSERT,砍 N 次串行 INSERT 尾巴)。
///
/// 对比 [`insert`](`impl_repo!` 生成,每次 spawn_blocking + 单行 execute):
/// 本方法单次 `spawn_blocking` + 单事务,`prepare` 一次 INSERT stmt 循环 bind N 行,
/// 一次 `COMMIT`(原子性:全插或全不插,审计留痕可追溯)。空 `records` 直接返回(无操作)。
///
/// **用途**:audit/mod.rs `process_tool_calls` 低风险工具 join_all 并行执行后的回填循环
/// (每工具一条审计),把 N 次串行 INSERT 合并为一次事务批量(治 aichat 效率 AC-EFF-T1-1)。
///
/// 安全:全部值走参数绑定(同 `insert` 宏体),无 SQL 拼接注入面;单连接 Mutex 持锁整段,
/// 与单行 insert 的锁粒度相同(一次持锁换 N 次持锁)。
pub async fn insert_batch(&self, records: Vec<AiToolExecutionRecord>) -> Result<()> {
if records.is_empty() {
return Ok(());
}
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
guard.execute_batch("BEGIN").map_err(storage_err)?;
let result = (|| -> std::result::Result<(), rusqlite::Error> {
let mut stmt = guard.prepare(
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
)?;
for rec in &records {
stmt.execute(params![
rec.id, rec.conversation_id, rec.message_id, rec.tool_call_id, rec.tool_name,
rec.arguments, rec.result, rec.status, rec.risk_level,
rec.requested_at, rec.executed_at, rec.decided_by
])?;
}
Ok(())
})();
match result {
Ok(()) => {
guard.execute_batch("COMMIT").map_err(storage_err)?;
}
Err(e) => {
// 回滚失败静默(尽量保一致性;rollback 失败通常是连接已坏,交给上层)
let _ = guard.execute_batch("ROLLBACK");
return Err(storage_err(e));
}
}
Ok(())
})
.await
.map_err(storage_err)??;
Ok(())
}
/// 按 tool_call_id 查最新一条审计记录(审批回填定位用)。
pub async fn find_by_tool_call_id(
&self,
+3 -1
View File
@@ -9,7 +9,9 @@
//!
//! ## 边界
//! - 不依赖 src-tauri(TunnelEvent/TunnelCommand 独立定义,避免跨 crate path 引用二进制)
//! - 不含业务逻辑(Phase2 填充 WS 握手/鉴权/重连/路由)
//! - 不含业务逻辑(Phase2 填充 WS 握手/鉴权/收发循环;断线**自动重连由调用方 supervisor
//! 负责**,src-tauri 已实现指数退避重连,本 crate 提供 connect/disconnect/is_connected
//! 原语 + `backoff_delay` 退避工具)
//!
//! ## 模块
//! - [`events`]:隧道透传事件/指令/消息包装类型
+15 -10
View File
@@ -1,7 +1,9 @@
//! df-tunnel 隧道客户端(Phase2 完整实现)
//!
//! 设计依据:设计文档「Layer1」—— 桌面端主动出站连云后端 `wss://host/ws/device`,
//! 穿 NAT 无需端口映射。tokio-tungstenite WS + Hello 握手 + 收发循环 + 指数退避重连
//! 穿 NAT 无需端口映射。tokio-tungstenite WS + Hello 握手 + 收发循环。
//! 断线重连由**调用方 supervisor** 负责(src-tauri 已实现指数退避重连,见其 tunnel 初始化处):
//! 本 crate 提供 connect/disconnect/is_connected 原语 + [`reconnect_params`] 参数复用 + [`backoff_delay`] 退避工具。
//!
//! ## 协议对齐(df-relay Phase2 commit 2b8b30e)
//! - 桌面端连 `/ws/device`,首帧发 `Hello { kind:"device", device_id, token }`
@@ -130,9 +132,9 @@ enum OutFrame {
/// 默认 WS 隧道客户端(完整实现)
pub struct WsTunnelClient {
/// 目标服务器 URL(connect 后保存,重连用;字段供未来 auto-reconnect 读)
/// 注:当前 connect 一次性建立,未实现自动后台重连(手动 connect 即重连),
/// 该字段保留以备 Phase3「断线后台自动重连」消费
/// 目标服务器 URL(connect 后保存,供调用方 supervisor 经 [`reconnect_params`] 复用)。
/// 注:本 crate 不内置后台自动重连(见模块文档);重连由调用方 supervisor 轮询
/// is_connected + 再 connect 完成(src-tauri 已实现,指数退避)
server_url: Mutex<Option<String>>,
device_id: Mutex<Option<String>>,
token: Mutex<Option<String>>,
@@ -155,7 +157,7 @@ impl WsTunnelClient {
}
}
/// 当前已连接时取一份重连参数(未来自动重连用)
/// 当前已连接时取一份重连参数(URL/device_id/token,供调用方 supervisor 断线重连用)
pub async fn reconnect_params(&self) -> Option<(String, String, String)> {
let url = self.server_url.lock().await.clone()?;
let device_id = self.device_id.lock().await.clone()?;
@@ -344,8 +346,9 @@ impl WsTunnelClient {
/// 3. 心跳定时器:每 HEARTBEAT_INTERVAL 发一次 Ping
/// 4. 任一端断开 → 退出 task,置 connected=false
///
/// 注:本任务范围不含「自动后台重连」(disconnect 即退出)。Phase3 可在外层包一层
/// supervisor task,检测 task 退出后用指数退避重新 connect(参数从 reconnect_params 取)。
/// 注:本任务范围不含「自动后台重连」(断开即退出)。自动重连由**调用方 supervisor** 承担:
/// 轮询 is_connected → 用 [`reconnect_params`](WsTunnelClient::reconnect_params)参数
/// + [`backoff_delay`] 退避 → 再 connect。src-tauri 已实现该 supervisor。
async fn run_loop(
mut sink: futures_util::stream::SplitSink<
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
@@ -497,9 +500,11 @@ fn parse_relay_error(msg: &WsMessage) -> Option<RelayControlError> {
}
}
/// 计算指数退避延迟(供未来 supervisor 重连使用,本任务未直接调用,但属完整重连语义的一部分)
#[allow(dead_code)]
fn backoff_delay(attempt: u32) -> Duration {
/// 计算指数退避延迟(1s, 2s, 4s, 8s, 16s, 30s, 30s, ...)
///
/// 供调用方 supervisor 断线重连使用(本 crate 不内置自动重连,见模块文档)。
/// 暴露为 pub 使外部(supervisor / src-tauri)可直接复用,不再属死代码。
pub fn backoff_delay(attempt: u32) -> Duration {
// 1s, 2s, 4s, 8s, 16s, 30s, 30s, ...
let secs = RECONNECT_BASE
.as_secs()