修复: 安全加固+DRY 收敛+文档同步+测试补齐
安全: - ScriptNode 默认黑名单兜底(rm/del/format/shutdown/mkfs/dd) - bind_directory 分段 .. 检测替代 contains 子串(对齐 tool_registry) - ai_providers 白名单移除 api_key(防 update_field 旁路写明文) DRY: - useAiEvents 抽 cleanupTerminatedConversation 统一三分支收尾 - 新增 useStoreAction 工具,4 个 store 替换 38 处 try/catch 样板 文档: - df-core → df-types 批量替换(ARCHITECTURE/PROGRESS/SQLite-CRUD) - INDEX 补齐 9 漏列文档(单对话并行多轮/跑题试验/工程系统设计等) - Agent架构说明 死链修复(../构想审查/) - AI对话引擎工具清单改为数量+按风险分组(不再用固定数字) - ARCH 状态标签 设计阶段 → Phase 2 验证 测试: - df-relay 新增 registry_test: ConnRegistry 路由 + RelayState + 16 项单测
This commit is contained in:
@@ -324,9 +324,11 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
|
||||
medium_audit("bind_directory", &format!("{id} <- {path}"));
|
||||
Box::pin(async move {
|
||||
let repo = ProjectRepo::new(&db);
|
||||
// 拒绝原始路径含 `..`(防穿越)
|
||||
if path.contains("..") {
|
||||
return CallToolResult::error(format!("路径不得包含 '..': {}", path));
|
||||
// 分段检测 `..`(防穿越)——纯子串 contains("..") 会误伤 my..file 这类合法名,
|
||||
// 改用逐段判断对齐 tool_registry.rs:validate_path 的分段检测逻辑。
|
||||
let has_traversal = path.split(|c| c == '\\' || c == '/').any(|seg| seg == "..");
|
||||
if has_traversal {
|
||||
return CallToolResult::error(format!("路径不得包含 '..' 段: {}", path));
|
||||
}
|
||||
let norm = normalize_path(&path);
|
||||
// 路径冲突检测
|
||||
|
||||
@@ -120,27 +120,35 @@ impl Node for ScriptNode {
|
||||
}
|
||||
}
|
||||
|
||||
/// 默认黑名单:即使环境变量未设置也生效,挡最危险的破坏性命令。
|
||||
/// 用户设置 `DF_SCRIPT_BLACKLIST` 时覆盖默认(不合并,用户显式控制)。
|
||||
const DEFAULT_BLACKLIST: &[&str] = &["rm", "del", "format", "shutdown", "mkfs", "dd"];
|
||||
|
||||
/// 命令执行策略校验:从环境变量读取白/黑名单,判定给定命令名是否允许执行。
|
||||
///
|
||||
/// 优先级:黑名单优先于白名单(黑名单匹配总是拒绝,即便同时在白名单)。
|
||||
///
|
||||
/// - `DF_SCRIPT_WHITELIST`:逗号分隔命令名(如 `git,npm,cargo`);非空时命令名不在其中即拒绝
|
||||
/// - `DF_SCRIPT_BLACKLIST`:逗号分隔命令名(如 `rm,format,shutdown`);匹配即拒绝
|
||||
/// - `DF_SCRIPT_BLACKLIST`:逗号分隔命令名(如 `rm,format,shutdown`);匹配即拒绝。
|
||||
/// 未设置时使用 `DEFAULT_BLACKLIST`(rm/del/format/shutdown/mkfs/dd)兜底。
|
||||
///
|
||||
/// 命令名比较前 trim + ASCII 小写规范化;空段被忽略。
|
||||
///
|
||||
/// 返回 `Some(reason)` 表示拒绝(reason 为拒绝原因,用于日志/错误信息);返回 `None` 表示放行。
|
||||
fn check_command_policy(cmd_name: &str) -> Option<&'static str> {
|
||||
// 黑名单优先:即便同时在白名单,只要命中黑名单就拒绝(防止白名单失误放过危险命令)
|
||||
if let Ok(blacklist_raw) = std::env::var("DF_SCRIPT_BLACKLIST") {
|
||||
let blacklist: Vec<String> = blacklist_raw
|
||||
// 未设置 DF_SCRIPT_BLACKLIST 时使用 DEFAULT_BLACKLIST 兜底(防误用零防护)
|
||||
let env_blacklist = std::env::var("DF_SCRIPT_BLACKLIST").ok();
|
||||
let blacklist: Vec<String> = match env_blacklist {
|
||||
Some(raw) => raw
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if blacklist.iter().any(|c| c == cmd_name) {
|
||||
return Some("命令在黑名单中 (DF_SCRIPT_BLACKLIST)");
|
||||
}
|
||||
.collect(),
|
||||
None => DEFAULT_BLACKLIST.iter().map(|s| s.to_string()).collect(),
|
||||
};
|
||||
if blacklist.iter().any(|c| c == cmd_name) {
|
||||
return Some("命令在黑名单中 (DF_SCRIPT_BLACKLIST 或默认)");
|
||||
}
|
||||
|
||||
// 白名单:非空时命令名必须在白名单中才放行
|
||||
|
||||
233
crates/df-relay/tests/registry_test.rs
Normal file
233
crates/df-relay/tests/registry_test.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
//! df-relay 单元测试(任务 #12)
|
||||
//!
|
||||
//! 覆盖纯逻辑(不依赖 axum server):
|
||||
//! - ConnRegistry: add/remove/is_online/route(四种路由规则)
|
||||
//! - BroadcastMessage 构造器
|
||||
//! - RelayState async 包装
|
||||
//! - next_conn_id 单调递增
|
||||
|
||||
#![cfg(test)]
|
||||
|
||||
use df_relay::{
|
||||
next_conn_id, BroadcastMessage, ClientKind, ConnHandle, ConnId, ConnRegistry, MessageKind,
|
||||
RelayState,
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
// ---------- ConnId 分配 ----------
|
||||
|
||||
#[test]
|
||||
fn next_conn_id_monotonic() {
|
||||
let a = next_conn_id();
|
||||
let b = next_conn_id();
|
||||
let c = next_conn_id();
|
||||
assert!(b.0 > a.0, "next_conn_id 应单调递增");
|
||||
assert!(c.0 > b.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conn_id_nil_is_zero() {
|
||||
assert_eq!(ConnId::NIL.0, 0);
|
||||
assert_ne!(next_conn_id(), ConnId::NIL, "分配的 id 不应为 NIL(0)");
|
||||
}
|
||||
|
||||
// ---------- ConnRegistry 基础操作 ----------
|
||||
|
||||
fn make_handle(id: ConnId, kind: ClientKind, device_id: &str) -> (ConnHandle, mpsc::UnboundedReceiver<BroadcastMessage>) {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
(ConnHandle::new(id, kind, device_id.to_string(), tx), rx)
|
||||
}
|
||||
|
||||
/// 注册连接并返回 receiver(保活防 channel 关闭)。
|
||||
fn register(reg: &mut ConnRegistry, id: ConnId, kind: ClientKind, device_id: &str) -> mpsc::UnboundedReceiver<BroadcastMessage> {
|
||||
let (handle, rx) = make_handle(id, kind, device_id);
|
||||
reg.add(handle);
|
||||
rx
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_add_and_lookup() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
let id = next_conn_id();
|
||||
let _rx = register(&mut reg, id, ClientKind::Device, "dev-1");
|
||||
|
||||
assert_eq!(reg.len(), 1);
|
||||
assert!(reg.is_device_online("dev-1"));
|
||||
assert!(!reg.is_device_online("dev-2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_remove_returns_device_id() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
let id = next_conn_id();
|
||||
let _rx = register(&mut reg, id, ClientKind::Device, "dev-1");
|
||||
|
||||
let removed = reg.remove(id);
|
||||
assert_eq!(removed.as_deref(), Some("dev-1"));
|
||||
assert_eq!(reg.len(), 0);
|
||||
assert!(!reg.is_device_online("dev-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_remove_unknown_returns_none() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
assert_eq!(reg.remove(ConnId(9999)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_supports_multi_conn_per_device() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
let d1 = next_conn_id();
|
||||
let m1 = next_conn_id();
|
||||
let _r1 = register(&mut reg, d1, ClientKind::Device, "dev-1");
|
||||
let _r2 = register(&mut reg, m1, ClientKind::Miniapp, "dev-1");
|
||||
|
||||
assert_eq!(reg.len(), 2);
|
||||
assert!(reg.is_device_online("dev-1"));
|
||||
|
||||
reg.remove(d1);
|
||||
assert!(reg.is_device_online("dev-1"));
|
||||
|
||||
reg.remove(m1);
|
||||
assert!(!reg.is_device_online("dev-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_empty_default() {
|
||||
let reg = ConnRegistry::new();
|
||||
assert!(reg.is_empty());
|
||||
assert_eq!(reg.len(), 0);
|
||||
}
|
||||
|
||||
// ---------- 路由规则 ----------
|
||||
|
||||
#[test]
|
||||
fn route_event_device_to_miniapp_only() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
let dev = next_conn_id();
|
||||
let mini = next_conn_id();
|
||||
let other_mini = next_conn_id();
|
||||
let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1");
|
||||
let _r2 = register(&mut reg, mini, ClientKind::Miniapp, "dev-1");
|
||||
let _r3 = register(&mut reg, other_mini, ClientKind::Miniapp, "dev-2");
|
||||
|
||||
let msg = BroadcastMessage::from_device("dev-1", dev, serde_json::json!({}), 0);
|
||||
let delivered = reg.route(&msg);
|
||||
assert_eq!(delivered, 1, "Event 应只投递给 dev-1 的小程序");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_command_miniapp_to_device_only() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
let dev = next_conn_id();
|
||||
let mini = next_conn_id();
|
||||
let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1");
|
||||
let _r2 = register(&mut reg, mini, ClientKind::Miniapp, "dev-1");
|
||||
|
||||
let msg = BroadcastMessage {
|
||||
device_id: "dev-1".into(),
|
||||
kind: MessageKind::Command,
|
||||
source: mini,
|
||||
from: ClientKind::Miniapp,
|
||||
payload: serde_json::json!({}),
|
||||
ts: 0,
|
||||
};
|
||||
let delivered = reg.route(&msg);
|
||||
assert_eq!(delivered, 1, "Command 应只投递给 dev-1 的桌面端");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_does_not_loop_back_to_source() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
let dev = next_conn_id();
|
||||
let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1");
|
||||
|
||||
let msg = BroadcastMessage::from_device("dev-1", dev, serde_json::json!({}), 0);
|
||||
let delivered = reg.route(&msg);
|
||||
assert_eq!(delivered, 0, "不应回环给来源");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_control_to_all_except_source() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
let dev = next_conn_id();
|
||||
let mini1 = next_conn_id();
|
||||
let mini2 = next_conn_id();
|
||||
let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1");
|
||||
let _r2 = register(&mut reg, mini1, ClientKind::Miniapp, "dev-1");
|
||||
let _r3 = register(&mut reg, mini2, ClientKind::Miniapp, "dev-1");
|
||||
|
||||
let msg = BroadcastMessage {
|
||||
device_id: "dev-1".into(),
|
||||
kind: MessageKind::Control,
|
||||
source: dev,
|
||||
from: ClientKind::Device,
|
||||
payload: serde_json::json!({}),
|
||||
ts: 0,
|
||||
};
|
||||
let delivered = reg.route(&msg);
|
||||
assert_eq!(delivered, 2, "Control 应投递给除来源外的所有同 device 连接");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_unknown_device_zero() {
|
||||
let reg = ConnRegistry::new();
|
||||
let msg = BroadcastMessage::from_device("nope", ConnId::NIL, serde_json::json!({}), 0);
|
||||
assert_eq!(reg.route(&msg), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_event_not_delivered_to_device() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
let dev1 = next_conn_id();
|
||||
let dev2 = next_conn_id();
|
||||
let _r1 = register(&mut reg, dev1, ClientKind::Device, "dev-1");
|
||||
let _r2 = register(&mut reg, dev2, ClientKind::Device, "dev-1");
|
||||
|
||||
let msg = BroadcastMessage::from_device("dev-1", dev1, serde_json::json!({}), 0);
|
||||
assert_eq!(reg.route(&msg), 0, "两个 Device 互发 Event 不应投递");
|
||||
}
|
||||
|
||||
// ---------- BroadcastMessage 构造器 ----------
|
||||
|
||||
#[test]
|
||||
fn from_device_sets_event_kind_and_from() {
|
||||
let msg = BroadcastMessage::from_device("dev-1", ConnId(42), serde_json::json!({"a": 1}), 12345);
|
||||
assert_eq!(msg.device_id, "dev-1");
|
||||
assert_eq!(msg.kind, MessageKind::Event);
|
||||
assert_eq!(msg.from, ClientKind::Device);
|
||||
assert_eq!(msg.source, ConnId(42));
|
||||
assert_eq!(msg.ts, 12345);
|
||||
assert_eq!(msg.payload, serde_json::json!({"a": 1}));
|
||||
}
|
||||
|
||||
// ---------- RelayState async 包装 ----------
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_state_add_and_remove_conn() {
|
||||
let state = RelayState::new();
|
||||
let id = next_conn_id();
|
||||
let (handle, _rx) = make_handle(id, ClientKind::Device, "dev-1");
|
||||
state.add_conn(handle).await;
|
||||
|
||||
assert!(state.is_device_online("dev-1").await);
|
||||
|
||||
let removed = state.remove_conn(id).await;
|
||||
assert_eq!(removed.as_deref(), Some("dev-1"));
|
||||
assert!(!state.is_device_online("dev-1").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_state_route_async() {
|
||||
let state = RelayState::new();
|
||||
let dev = next_conn_id();
|
||||
let mini = next_conn_id();
|
||||
let (h1, _r1) = make_handle(dev, ClientKind::Device, "dev-1");
|
||||
let (h2, _r2) = make_handle(mini, ClientKind::Miniapp, "dev-1");
|
||||
state.add_conn(h1).await;
|
||||
state.add_conn(h2).await;
|
||||
|
||||
let msg = BroadcastMessage::from_device("dev-1", dev, serde_json::json!({}), 0);
|
||||
let n = state.route(&msg).await;
|
||||
assert_eq!(n, 1);
|
||||
}
|
||||
@@ -180,8 +180,10 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
|
||||
"id", "workflow_id", "node_id", "node_type", "status", "input_json", "output_json",
|
||||
"error_message", "started_at", "completed_at",
|
||||
],
|
||||
// api_key 不列入白名单:密钥写入应只走 update_full/insert + keyring 配套,
|
||||
// 禁止 update_field 旁路绕过 keyring(防明文写回 DB)。
|
||||
"ai_providers" => &[
|
||||
"id", "name", "provider_type", "api_key", "base_url", "default_model", "models",
|
||||
"id", "name", "provider_type", "base_url", "default_model", "models",
|
||||
"is_default", "config", "created_at", "updated_at",
|
||||
],
|
||||
"ai_conversations" => &[
|
||||
|
||||
Reference in New Issue
Block a user