//! F-260619-03 Phase A/B/C: AI 工具文件访问授权目录白名单模块 //! //! 从 `state.rs` 抽离的授权相关类型/函数:`AllowedDirs` 三档白名单 //! (persistent / session / once)、`PathAuthDecision` 三态决策、 //! `check_path_authorization` 词法层预校验、`is_in_system_blacklist` //! 系统敏感目录黑名单,以及 `strip_verbatim` / `best_effort_canonicalize` //! / `paths_eq` 辅助函数。 //! //! 注:`workspace_root_path` 仍保留在 `state.rs`(旧 `.trash` 迁移仍需); //! 本模块测试内定义本地副本避免跨文件耦合。 use std::collections::HashSet; use std::path::{Path, PathBuf}; /// F-260619-03 Phase A/B/C: AI 工具文件访问授权目录白名单 /// /// - Phase A:`persistent`(持久化白名单,从 Settings KV `allowed_dirs` 加载, /// JSON 数组 `["E:/wk-lab/u-abc"]`)。`resolve_workspace_path` 校验时: /// - 任一 persistent 或 session 目录 starts_with 命中即放行 /// - 无任何授权时全部拒绝(引导用户绑定项目或授权目录) /// - Phase B:`session`(进程级会话临时授权,弹窗"仅本次"写入;切换/新建/删除会话清空)。 /// 单用户桌面应用 active_conversation_id 单全局模型,session 字段随 active 切换清空, /// 行为等价"当前活跃会话的临时授权"。handler 闭包(read lock 取快照)与 /// process_tool_calls 预校验均读此字段,两端一致。 /// - Phase C:黑名单(is_authorized 内,黑名单优先于白名单 — 命中即拒)。 #[derive(Debug, Clone, Default)] pub struct AllowedDirs { /// 持久化授权目录(Settings KV `allowed_dirs`,JSON 字符串数组)。 /// 已规范化(canonicalize 失败回退原字面量),便于 starts_with 精确比对。 pub persistent: HashSet, /// F-260619-03 Phase B: 会话级临时授权目录(弹窗"当前会话"选项写入,进程级内存)。 /// 仅当前活跃会话生效,切换/新建/删除会话由 clear_session_allowed_dirs 清空(不落库)。 /// handler 闭包与 process_tool_calls 预校验均读此字段,确保两端授权判定一致。 pub session: HashSet, /// 本次单次授权目录(弹窗"本次"选项写入)。单次工具执行放行后由 clear_once_allowed_dirs /// 清空(用完即弃,下次同路径再访问仍弹窗)。区别于 session(整会话有效)。 /// 三档授权语义:once=本次单次 / session=当前会话 / persistent=始终落 KV。 pub once: HashSet, } impl AllowedDirs { /// Settings KV key:F-260619-03 Phase A 持久化白名单(JSON 字符串数组) pub const SETTINGS_KEY: &'static str = "allowed_dirs"; /// 空白名单(方案①弱化 workspace_root:不再编译期硬塞开发机 CARGO_MANIFEST_DIR)。 /// 分发后该路径指向编译机不存在的目录,硬塞反成脏白名单。 /// 当前仅测试使用,生产环境由 reload_allowed_dirs 覆盖。 #[cfg_attr(not(test), allow(dead_code))] pub fn default_with_root() -> Self { Self { persistent: HashSet::new(), session: HashSet::new(), once: HashSet::new() } } /// 取首个 persistent 授权目录,作为相对路径锚定基准。 /// 无任何持久授权时返回 None,调用方应引导用户绑定项目。 pub fn first_persistent_dir(&self) -> Option<&PathBuf> { self.persistent.iter().next() } /// 路径是否被授权:persistent 或 session 白名单任一 starts_with 命中即放行。 /// /// **方案①弱化后 workspace_root 不再默认授权**(default_with_root 空,reload_allowed_dirs /// KV 未配时也不塞),首次访问工程根走弹窗三档授权。用户授权(always/session/once)后 /// 落白名单,后续命中放行(动态白名单完整语义,F-260619-03 收尾)。 /// /// **Phase C 黑名单优先**:即使白名单命中,若路径落入系统敏感目录(Windows /// `\Windows\System32` / `\Program Files\`;Unix `/etc /usr /bin /sbin /boot /// /dev /proc /sys`)仍拒。黑名单优先于白名单,防用户误授权系统目录。 /// /// 输入 `candidate` 理想为 canonicalize 后的真实路径(防 symlink 逃逸);调用方 /// (resolve_workspace_path_with_allowed)负责 canonicalize。但词法层预校验 /// (`check_path_authorization`)**故意不 canonicalize**(路径可能不存在,如 write_file 新建), /// 会传入未归一的词法路径 → 与白名单(canonicalize 后)形态不对齐 → 已授权路径反复误弹窗。 /// 故本函数内部 best-effort canonicalize 兜底(失败回退词法),消除所有调用方形态差异。 /// 白名单仍是唯一真相源,canonicalize 不引入新放行,顺带解析 symlink 增强(非削弱)防逃逸。 pub fn is_authorized(&self, candidate: &Path) -> bool { // 授权总开关开时不再逐目录询问,黑名单与白名单外一并放行,体验优先用户知情选择;开关关行为不变 if crate::commands::ai::audit::path_auth::is_blacklist_bypassed() { return true; } // Phase C: 黑名单优先(白名单命中也拒)。validate_path 已挡 .ssh/.aws 等, // 此处补系统核心目录(用户可能误把 C:\ 加入 persistent,黑名单兜底拒 System32)。 if is_in_system_blacklist(candidate) { return false; } // F-260620 比对侧收口(误弹窗核心根因):白名单 persistent/session 是 canonicalize 后形态, // candidate 来自两类调用方形态不一(handler canonicalize 后 / 预校验词法未 canonicalize)。 // 仅 strip_verbatim(去 \\?\ 前缀)不够——盘符大小写/.. /symlink/分隔符差异仍让 starts_with // 失败。best_effort_canonicalize 把 candidate 归一到真实路径(失败回退词法,不阻断合法访问), // 再 strip_verbatim 去前缀,使比对双侧形态完全对齐。e2ece2d 只收口 verbatim 前缀漏了这步。 let cand = strip_verbatim(best_effort_canonicalize(candidate)); self.persistent.iter().any(|d| cand.starts_with(d)) || self.session.iter().any(|d| cand.starts_with(d)) || self.once.iter().any(|d| cand.starts_with(d)) } } /// F-260619-03 Phase C: 路径授权决策(预校验用,process_tool_calls 分类前调)。 /// /// `check_path_authorization` 返回本枚举,process_tool_calls 据此决定: /// - `Authorized`:路径已授权 → 走原 Low/Med/High 流程 /// - `NeedsAuthorization`:路径未授权但非黑名单 → Phase B 挂起弹窗(emit AiDirAuthRequired) /// - `Denied`:路径命中黑名单 → 硬拒(工具返 Err tool_result,不挂起) #[derive(Debug, Clone, PartialEq, Eq)] pub enum PathAuthDecision { /// 已授权(workspace_root / persistent / session 命中且非黑名单) Authorized, /// 未授权且非黑名单:需 Phase B 弹窗询问用户。附带规范化后的待授权目录(父目录, /// 对齐 session_trust 目录粒度),供"仅本次/未来都允许"写入白名单。 NeedsAuthorization { dir: PathBuf }, /// 命中系统黑名单(Phase C):硬拒,工具返 Err,不弹窗。 Denied { reason: String }, } /// F-260619-03 Phase C: 系统敏感目录黑名单判定(跨平台)。 /// /// 白名单命中但黑名单命中 → 拒(防用户误把整个盘符如 `C:\` 加入 persistent 致 /// System32 被放行)。validate_path(tool_registry.rs)已挡 .ssh/.aws/AppData 等, /// 此处补系统核心目录。 /// /// - Windows(不区分大小写,按路径分隔符分段):`\Windows\System32`、`\Program Files\`、 /// `\Program Files (x86)\`、`\Windows\` 根下 System/SysWOW64 /// - Unix:`/etc`、`/usr`、`/bin`、`/sbin`、`/boot`、`/dev`、`/proc`、`/sys` pub fn is_in_system_blacklist(path: &Path) -> bool { let s = path.to_string_lossy().to_lowercase(); let seps = ['/', '\\']; // Windows:按分隔符分段判定(避免 contains 误伤 "my program files backup" 这类目录名) let segs: Vec<&str> = s.split(seps).filter(|s| !s.is_empty()).collect(); for (i, seg) in segs.iter().enumerate() { // Windows 系统目录:C:\Windows(根本身及所有子目录 win.ini/explorer.exe/hosts 等)/ // C:\Program Files / C:\Program Files (x86) / C:\ProgramData if cfg!(windows) { // windows 段命中即拒(含根本身,不再限定 system32 子目录 — 防 win.ini/hosts 等) if *seg == "windows" { return true; } // C:\Program Files / C:\Program Files (x86) if *seg == "program files" || *seg == "program files (x86)" { return true; } // C:\ProgramData(系统级应用数据) if *seg == "programdata" { return true; } } // 用户级凭据目录(.ssh/.aws/.gnupg):任意路径段命中即拒(跨平台,从 validate_path 迁移统一, // 消除 validate_path contains 与 is_in_system_blacklist 分段两套黑名单不一致) if matches!(*seg, ".ssh" | ".aws" | ".gnupg") { return true; } // Unix 系统目录(路径首段为这些即拒;Windows 上也防 Unix 风格绝对路径,防御性) if i == 0 && matches!(*seg, "etc" | "usr" | "bin" | "sbin" | "boot" | "dev" | "proc" | "sys") { return true; } } false } /// F-260619-03 Phase B/C: 路径授权预校验(供 process_tool_calls 分类前调)。 /// /// 词法层判定(不 canonicalize,因路径可能不存在 — write_file 新建)。返回三态决策: /// - 路径规范化(去 .. / 锚定首个持久授权目录)后,若命中黑名单 → `Denied` /// - 否则若 `is_authorized(规范化路径)`(persistent + session) → `Authorized` /// - 否则 → `NeedsAuthorization { dir: 父目录规范化 }`(目录粒度,对齐 session_trust) /// /// 注意:本函数只做词法层预判(防不存在路径兜底);实际执行时 handler 内 /// resolve_workspace_path_with_allowed 仍做完整校验(canonicalize symlink 防逃逸)。 /// 黑名单在两处都判(is_authorized 内 + 此处独立判),双保险。 pub fn check_path_authorization( raw_path: &str, allowed: &AllowedDirs, ) -> PathAuthDecision { // Phase C: 黑名单优先独立判定(is_authorized 内也判,此处先判便于 NeedsAuthorization // 不误把黑名单路径推到弹窗 — 黑名单路径直接硬拒不让用户"授权")。 // // 黑名单检查必须在路径规范化(is_absolute / root.join)之前对**原始路径** // 也判一次。原因:Windows 上 "/home/user/.ssh/config" 这类 Unix 风格路径 is_absolute() 返 false // (Windows 绝对路径需盘符或 UNC),会被当相对路径走 first_persistent_dir 分支;若 persistent 为空, // 旧逻辑直接 return NeedsAuthorization,**跳过黑名单检查** → .ssh/.aws 等敏感路径在 Windows 上绕过黑名单。 // 修复:先用原始路径判黑名单(segment 匹配,任意路径段命中 .ssh 即拒,与路径形态无关),命中直接 Denied。 let raw_resolved = Path::new(raw_path); if is_in_system_blacklist(raw_resolved) { return PathAuthDecision::Denied { reason: format!("路径命中系统敏感目录黑名单: {}", raw_path), }; } // 规范化:绝对路径原样,相对路径锚定首个持久授权目录(与 resolve_workspace_path_impl 一致) let resolved = if raw_resolved.is_absolute() { PathBuf::from(raw_path) } else if let Some(root) = allowed.first_persistent_dir() { root.join(raw_path) } else { // 无授权目录时直接返 NeedsAuthorization(让引导流程处理),避免锚定到编译机路径。 return PathAuthDecision::NeedsAuthorization { dir: PathBuf::from("."), }; }; // 规范化后再判一次黑名单(锚定后路径形态可能变化,双保险) if is_in_system_blacklist(&resolved) { return PathAuthDecision::Denied { reason: format!("路径命中系统敏感目录黑名单: {}", raw_path), }; } if allowed.is_authorized(&resolved) { return PathAuthDecision::Authorized; } // 未授权 → 取父目录作待授权目录(目录粒度,对齐 session_trust 的 dir 语义) let dir = resolved .parent() .map(|p| p.to_path_buf()) .unwrap_or_else(|| PathBuf::from(".")); PathAuthDecision::NeedsAuthorization { dir } } /// Windows canonicalize 返回 `\\?\E:\...` 扩展路径(verbatim 前缀),与词法层(不 canonicalize) /// 的 starts_with 比对不一致 → 白名单含但工具路径不匹配 → 误弹窗。strip 前缀统一为 `E:\...`。 pub fn strip_verbatim(p: PathBuf) -> PathBuf { let s = p.to_string_lossy().to_string(); // Windows verbatim/设备路径前缀:\\?\ (Volume canonicalize)、\\.\ (设备命名空间)、 // \??\ (对象管理器命名空间)。统一 strip 后与词法层 starts_with 比对一致。 for prefix in [r"\\?\", r"\\.\", r"\??\"] { if let Some(stripped) = s.strip_prefix(prefix) { return PathBuf::from(stripped); } } p } /// best-effort canonicalize:把任意形态路径(词法/正斜杠/含../大小写不一)归一到真实路径, /// 供 `is_authorized` 与白名单(canonicalize 后)比对侧形态对齐(误弹窗根因修复)。 /// /// - 路径存在 → `canonicalize` 成功,返回真实路径(大小写归一/.. 解析/symlink 解析/去冗余分隔符) /// - 路径不存在(write_file 新建文件)→ canonicalize 其**父目录**(通常存在)+ 拼回文件名, /// 父目录也不存在 → 回退原词法路径(交由 strip_verbatim + starts_with 尽力匹配,不阻断) /// /// 失败一律回退,绝不返回 Err——授权比对宁可降级匹配不可 panic/阻断合法访问。 pub fn best_effort_canonicalize(p: &Path) -> PathBuf { if let Ok(real) = std::fs::canonicalize(p) { return real; } if let Some(parent) = p.parent() { if let Ok(real_parent) = std::fs::canonicalize(parent) { if let Some(file_name) = p.file_name() { return real_parent.join(file_name); } return real_parent; } } p.to_path_buf() } /// F-260619-03 Phase B: 路径字符串等价比较(canonicalize 后比对,失败回退小写比对)。 pub fn paths_eq(a: &str, b: &str) -> bool { let pa = std::fs::canonicalize(a).map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| a.to_string()); let pb = std::fs::canonicalize(b).map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| b.to_string()); pa.eq_ignore_ascii_case(&pb) } #[cfg(test)] mod tests { use super::*; // ============================================================ // F-260619-03 Phase A: AllowedDirs 授权语义测试 // 锁定:persistent 命中放行 + 未命中拒绝。 // 注:workspace_root 不再自动塞白名单(分发适配方案①), // 下方测试用显式 set.insert 模拟用户手动授权场景。 // ============================================================ /// workspace 根目录本地副本(原 `state.rs::workspace_root_path` 留在原文件 /// 供旧 `.trash` 迁移使用;本模块测试需独立定义避免跨文件耦合)。 fn workspace_root_path() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() .and_then(|p| p.parent()) .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) } /// 途经 is_authorized 的用例统一持此串行闸:is_authorized 首行读进程级 bypass 标志, /// 与写标志的测试并行运行会互踩误判(path_auth::BLACKLIST_BYPASS_TEST_LOCK 全局单例)。 fn bypass_test_lock() -> std::sync::MutexGuard<'static, ()> { crate::commands::ai::audit::path_auth::BLACKLIST_BYPASS_TEST_LOCK .lock() .unwrap_or_else(|e| e.into_inner()) } /// 显式授权目录后命中(模拟用户在 Settings 页添加目录) #[test] fn test_allowed_dirs_workspace_root_authorized() { let _guard = bypass_test_lock(); let mut set = HashSet::new(); set.insert(workspace_root_path()); let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() }; let root = workspace_root_path(); assert!(allowed.is_authorized(&root), "显式授权的 workspace_root 应命中"); // workspace 内子路径也应授权(starts_with workspace_root) let child = root.join("src").join("main.rs"); assert!(allowed.is_authorized(&child), "workspace_root 子路径应被授权"); } /// 自定义授权目录命中放行(模拟用户授权 E:/some/external/dir) #[test] fn test_allowed_dirs_custom_authorized() { let _guard = bypass_test_lock(); let mut set = HashSet::new(); let custom = PathBuf::from("E:/wk-test-external-dir"); set.insert(custom.clone()); let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() }; // 精确命中 + 子路径 starts_with 命中 assert!(allowed.is_authorized(&custom)); assert!(allowed.is_authorized(&custom.join("sub").join("file.txt"))); } /// 未授权目录被拒绝 #[test] fn test_allowed_dirs_unauthorized_rejected() { let _guard = bypass_test_lock(); let mut set = HashSet::new(); set.insert(PathBuf::from("E:/wk-test-authorized")); let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() }; let outside = PathBuf::from("E:/wk-test-unauthorized/file.txt"); assert!(!allowed.is_authorized(&outside), "未授权目录应被拒绝"); } /// 默认(空 persistent)只授权 workspace_root #[test] fn test_allowed_dirs_default_empty_persistent() { let _guard = bypass_test_lock(); let allowed = AllowedDirs::default(); // 空 persistent,无 workspace_root → 任何路径都不授权 // (default_with_root 才含 workspace_root;Default 不含,用于边界测试) assert!(!allowed.is_authorized(&PathBuf::from("E:/anything"))); } /// SETTINGS_KEY 常量稳定(防 rename 致持久化数据丢失) #[test] fn test_allowed_dirs_settings_key_stable() { assert_eq!(AllowedDirs::SETTINGS_KEY, "allowed_dirs"); } // ============================================================ // F-260619-03 Phase B: session 临时授权语义测试 // ============================================================ /// Phase B: session 命中放行(persistent 未命中但 session 命中) #[test] fn test_allowed_dirs_session_authorized() { let _guard = bypass_test_lock(); let mut session = HashSet::new(); session.insert(PathBuf::from("E:/wk-temp-session")); let allowed = AllowedDirs { persistent: HashSet::new(), session, once: HashSet::new() }; assert!(allowed.is_authorized(&PathBuf::from("E:/wk-temp-session/file.txt"))); } /// Phase B: persistent + session 任一命中即放行 #[test] fn test_allowed_dirs_persistent_or_session() { let _guard = bypass_test_lock(); let mut persistent = HashSet::new(); persistent.insert(PathBuf::from("E:/wk-persist")); let mut session = HashSet::new(); session.insert(PathBuf::from("E:/wk-session")); let allowed = AllowedDirs { persistent, session, once: HashSet::new() }; assert!(allowed.is_authorized(&PathBuf::from("E:/wk-persist/a"))); assert!(allowed.is_authorized(&PathBuf::from("E:/wk-session/b"))); assert!(!allowed.is_authorized(&PathBuf::from("E:/wk-other/c"))); } // ============================================================ // F-260619-03 Phase C: 系统目录黑名单测试 // ============================================================ /// Phase C: Windows System32 黑名单命中(即使在白名单内也拒) #[test] fn test_blacklist_windows_system32() { let _guard = bypass_test_lock(); let mut persistent = HashSet::new(); // 用户误把整个 C:\ 加入白名单 persistent.insert(PathBuf::from("C:\\")); let allowed = AllowedDirs { persistent, session: HashSet::new(), once: HashSet::new() }; // System32 应被黑名单拒(尽管 C:\ 在白名单) assert!(!allowed.is_authorized(&PathBuf::from("C:\\Windows\\System32\\config\\sam"))); } /// Phase C: Windows Program Files 黑名单命中 #[test] fn test_blacklist_windows_program_files() { let _guard = bypass_test_lock(); let mut persistent = HashSet::new(); persistent.insert(PathBuf::from("C:\\")); let allowed = AllowedDirs { persistent, session: HashSet::new(), once: HashSet::new() }; assert!(!allowed.is_authorized(&PathBuf::from("C:\\Program Files\\SomeApp\\app.exe"))); } /// Phase C: 黑名单不误伤合法目录(含 "program files" 子串的自定义目录名) #[test] fn test_blacklist_no_false_positive() { // "my program files backup" 不应被拒(分段匹配,非精确段) assert!(!is_in_system_blacklist(&PathBuf::from("E:/my program files backup/x"))); // 普通工作目录不拒 assert!(!is_in_system_blacklist(&PathBuf::from("E:/wk-lab/devflow/src"))); } /// Phase C: Unix 系统目录黑名单(/etc /usr /bin 等) #[test] fn test_blacklist_unix_system_dirs() { assert!(is_in_system_blacklist(&PathBuf::from("/etc/passwd"))); assert!(is_in_system_blacklist(&PathBuf::from("/usr/bin/python"))); assert!(is_in_system_blacklist(&PathBuf::from("/proc/self/environ"))); assert!(is_in_system_blacklist(&PathBuf::from("/sys/kernel"))); // 普通用户目录不拒 assert!(!is_in_system_blacklist(&PathBuf::from("/home/user/project"))); } // ============================================================ // F-260619-03 Phase B/C: check_path_authorization 三态决策测试 // ============================================================ /// Phase B/C: 显式授权 workspace_root 后,内路径 → Authorized(方案①:不再默认) #[test] fn test_check_path_authorized_workspace() { let _guard = bypass_test_lock(); let mut set = HashSet::new(); set.insert(workspace_root_path()); let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() }; let rel = "src/main.rs"; match check_path_authorization(rel, &allowed) { PathAuthDecision::Authorized => {} other => panic!("workspace_root 内路径应 Authorized, got {:?}", other), } } /// Phase B/C: 未授权路径 → NeedsAuthorization(附父目录) #[test] fn test_check_path_needs_auth() { let _guard = bypass_test_lock(); let allowed = AllowedDirs::default(); // 空,无 workspace_root match check_path_authorization("E:/wk-external/file.txt", &allowed) { PathAuthDecision::NeedsAuthorization { dir } => { assert_eq!(dir, PathBuf::from("E:/wk-external")); } other => panic!("未授权路径应 NeedsAuthorization, got {:?}", other), } } /// Phase B: session 命中 → Authorized(check_path_authorization 读 session 字段) #[test] fn test_check_path_session_hit() { let _guard = bypass_test_lock(); let mut session = HashSet::new(); session.insert(PathBuf::from("E:/wk-session")); let allowed = AllowedDirs { persistent: HashSet::new(), session, once: HashSet::new() }; match check_path_authorization("E:/wk-session/sub/file.txt", &allowed) { PathAuthDecision::Authorized => {} other => panic!("session 命中应 Authorized, got {:?}", other), } } /// Phase C: 黑名单路径 → Denied(不弹窗直接拒) #[test] fn test_check_path_denied_blacklist() { let _guard = bypass_test_lock(); let allowed = AllowedDirs::default_with_root(); match check_path_authorization("C:/Windows/System32/config/sam", &allowed) { PathAuthDecision::Denied { .. } => {} other => panic!("System32 应 Denied, got {:?}", other), } } /// Windows 上 Unix 风格 .ssh 路径必须被黑名单拒(不能绕过)。 /// /// 背景:Windows 上 `/home/user/.ssh/config` 的 is_absolute() 返 false(无盘符), /// 旧逻辑在 persistent 为空时直接返 NeedsAuthorization,**跳过黑名单检查**, /// 致 .ssh/.aws 等敏感路径在 Windows 上绕过黑名单。修复后原始路径也判黑名单。 /// 此测试锁死该修复:空 persistent + Unix 风格 .ssh 路径 → 必 Denied。 #[test] fn test_check_path_blacklist_unix_style_ssh_on_windows() { let _guard = bypass_test_lock(); let allowed = AllowedDirs::default_with_root(); match check_path_authorization("/home/user/.ssh/config", &allowed) { PathAuthDecision::Denied { .. } => {} other => panic!("Unix 风格 .ssh 路径应 Denied(黑名单), got {:?}", other), } // 同理 .aws / .gnupg match check_path_authorization("/home/user/.aws/credentials", &allowed) { PathAuthDecision::Denied { .. } => {} other => panic!("Unix 风格 .aws 路径应 Denied(黑名单), got {:?}", other), } } /// 回归:合法相对路径(非黑名单)在空 persistent 下仍应返 NeedsAuthorization(引导授权)。 /// 修复黑名单提前后,需确认非黑名单相对路径行为不变。 #[test] fn test_check_path_non_blacklist_relative_still_needs_auth() { let _guard = bypass_test_lock(); let allowed = AllowedDirs::default_with_root(); match check_path_authorization("some/regular/path", &allowed) { PathAuthDecision::NeedsAuthorization { .. } => {} other => panic!("非黑名单相对路径 + 空 persistent 应 NeedsAuthorization, got {:?}", other), } } // ============================================================ // strip_verbatim 比对侧收口 + 黑名单增强测试 // 三方审查(安全/UX/跨端)交叉印证:strip_verbatim 仅写入侧调用,比对侧遗漏致误弹窗。 // ============================================================ /// is_authorized 比对侧 strip_verbatim — candidate 带 \\?\ 前缀也命中白名单 /// (handler canonicalize 后路径 vs persistent 词法路径,形态不一致曾致误弹窗)。 /// persistent 用动态 workspace_root_path(非硬编码开发机路径),candidate 拼 verbatim 前缀。 #[test] fn test_is_authorized_strips_verbatim_prefix() { let _guard = bypass_test_lock(); let mut set = HashSet::new(); set.insert(workspace_root_path()); let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() }; let child = workspace_root_path().join("src").join("main.rs"); // candidate 带 verbatim 前缀(handler canonicalize 后形态)应命中白名单 let verbatim = PathBuf::from(format!("{}{}", r"\\?\", child.to_string_lossy())); assert!(allowed.is_authorized(&verbatim), "verbatim 前缀路径应命中白名单"); // 设备命名空间前缀(\\.\) let dev = PathBuf::from(format!("{}{}", r"\\.\", child.to_string_lossy())); assert!(allowed.is_authorized(&dev), "设备命名空间前缀路径应命中白名单"); } /// strip_verbatim 三前缀(\\?\ / \\.\ / \??\)统一去除 #[test] fn test_strip_verbatim_three_prefixes() { assert_eq!(strip_verbatim(PathBuf::from(r"\\?\E:\foo")), PathBuf::from("E:\\foo")); assert_eq!(strip_verbatim(PathBuf::from(r"\\.\E:\foo")), PathBuf::from("E:\\foo")); assert_eq!(strip_verbatim(PathBuf::from(r"\??\E:\foo")), PathBuf::from("E:\\foo")); // 无前缀原样返回 assert_eq!(strip_verbatim(PathBuf::from("E:\\foo")), PathBuf::from("E:\\foo")); } /// 黑名单增强 — Windows 根本身 / ProgramData / 用户凭据目录(.ssh/.aws/.gnupg) #[test] fn test_blacklist_windows_root_programdata_creds() { // C:\Windows 根本身(不再限定 system32 子目录 — 防 win.ini/hosts 等) assert!(is_in_system_blacklist(&PathBuf::from("C:\\Windows\\win.ini"))); assert!(is_in_system_blacklist(&PathBuf::from("C:\\Windows\\explorer.exe"))); // ProgramData assert!(is_in_system_blacklist(&PathBuf::from("C:\\ProgramData\\App\\config"))); // 用户凭据目录(从 validate_path 迁移统一,任意路径段命中即拒) assert!(is_in_system_blacklist(&PathBuf::from("C:\\Users\\user\\.ssh\\id_rsa"))); assert!(is_in_system_blacklist(&PathBuf::from("/home/user/.aws/credentials"))); assert!(is_in_system_blacklist(&PathBuf::from("/home/user/.gnupg/pubring.gpg"))); // 普通目录不误伤(devflow 自身 / 含 appdata 段的合法备份目录) assert!(!is_in_system_blacklist(&PathBuf::from("E:\\wk-lab\\devflow\\src"))); assert!(!is_in_system_blacklist(&PathBuf::from("D:\\backup\\appdata\\data"))); } // 授权总开关(黑名单放行)接入 is_authorized 的语义测试。 // 进程级标志全进程单例,改写或依赖其结果的用例持 path_auth 串行闸防并行互踩。 /// 总开关开:白名单外路径与 .ssh 黑名单路径一律放行(is_authorized 首位短路)。 /// 先取结果再复位再断言,防 panic 泄漏标志污染并行测试。 #[test] fn test_bypass_on_is_authorized_allows_all() { let _guard = bypass_test_lock(); crate::commands::ai::audit::path_auth::set_blacklist_bypass(true); let allowed = AllowedDirs::default(); let outside = PathBuf::from("E:/wk-test-bypass-outside/file.txt"); let ssh = PathBuf::from("/home/user/.ssh/id_rsa"); let outside_ok = allowed.is_authorized(&outside); let ssh_ok = allowed.is_authorized(&ssh); crate::commands::ai::audit::path_auth::set_blacklist_bypass(false); assert!(outside_ok, "总开关开时白名单外路径应放行"); assert!(ssh_ok, "总开关开时 .ssh 黑名单路径应放行"); } /// 总开关关:黑名单拒且白名单外拒,与接入旁路前行为一致。 #[test] fn test_bypass_off_is_authorized_unchanged() { let _guard = bypass_test_lock(); crate::commands::ai::audit::path_auth::set_blacklist_bypass(false); let allowed = AllowedDirs::default(); let outside_ok = allowed.is_authorized(&PathBuf::from("E:/wk-test-bypass-outside/file.txt")); let ssh_ok = allowed.is_authorized(&PathBuf::from("/home/user/.ssh/id_rsa")); assert!(!outside_ok, "总开关关时白名单外路径应拒绝"); assert!(!ssh_ok, "总开关关时 .ssh 黑名单路径应拒绝"); } }