window-sand-box 0.1.1

Windows 沙盒终端执行工具 — 使用受限令牌、ACL 和私有桌面隔离进程权限,提供安全的命令执行环境
//! 沙盒配置管理
//!
//! 管理全局黑白名单、平台只读根目录等配置。

use serde::{Deserialize, Serialize};
use std::path::PathBuf;

/// 沙盒全局配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxConfig {
    /// 全局黑名单 — AI 完全不能访问这些路径(读+写均拒绝)
    #[serde(default = "default_blacklist")]
    pub blacklist: Vec<String>,

    /// 全局白名单 — AI 可以读写删除这些路径
    #[serde(default)]
    pub whitelist: Vec<String>,

    /// 工作目录内的敏感子路径(即使在工作目录内也拒绝写入)
    #[serde(default = "default_sensitive_patterns")]
    pub sensitive_patterns: Vec<String>,
}

fn default_blacklist() -> Vec<String> {
    vec![
        r"C:\Windows\System32\config".to_string(),
        r"C:\Windows\System32\SAM".to_string(),
        r"C:\Windows\System32\SECURITY".to_string(),
        r"C:\Windows\System32\Registry".to_string(),
        r"C:\Windows\System32\drivers\etc".to_string(),
    ]
}

fn default_sensitive_patterns() -> Vec<String> {
    vec![
        ".git".to_string(),
        ".env".to_string(),
        ".ssh".to_string(),
        ".gnupg".to_string(),
        "node_modules".to_string(),
        ".aws".to_string(),
        ".azure".to_string(),
        ".kube".to_string(),
        ".config".to_string(),
        ".docker".to_string(),
    ]
}

impl Default for SandboxConfig {
    fn default() -> Self {
        Self {
            blacklist: default_blacklist(),
            whitelist: vec![],
            sensitive_patterns: default_sensitive_patterns(),
        }
    }
}

impl SandboxConfig {
    /// 从 JSON 文件加载配置
    pub fn from_file(path: impl AsRef<std::path::Path>) -> anyhow::Result<Self> {
        let content = std::fs::read_to_string(path.as_ref())?;
        let config: SandboxConfig = serde_json::from_str(&content)?;
        Ok(config)
    }

    /// 保存配置到 JSON 文件
    pub fn save_to_file(&self, path: impl AsRef<std::path::Path>) -> anyhow::Result<()> {
        let content = serde_json::to_string_pretty(self)?;
        if let Some(parent) = path.as_ref().parent() {
            std::fs::create_dir_all(parent)?;
        }
        std::fs::write(path.as_ref(), content)?;
        Ok(())
    }

    /// 检查路径是否与列表中的任一路径匹配(支持子路径关系)
    fn path_matches_list(path: &std::path::Path, list: &[String]) -> bool {
        let path_key = crate::policy::canonical_path_key(path);
        list.iter().any(|entry| {
            let entry_path = std::path::Path::new(entry);
            let entry_key = crate::policy::canonical_path_key(entry_path);
            // 使用规范化(全小写+反斜杠)后的字符串比较,确保大小写不敏感
            path_key.starts_with(&entry_key) || entry_key.starts_with(&path_key)
        })
    }

    /// 检查路径是否在黑名单中
    pub fn is_blacklisted(&self, path: impl AsRef<std::path::Path>) -> bool {
        Self::path_matches_list(path.as_ref(), &self.blacklist)
    }

    /// 检查路径是否在白名单中
    pub fn is_whitelisted(&self, path: impl AsRef<std::path::Path>) -> bool {
        Self::path_matches_list(path.as_ref(), &self.whitelist)
    }

    /// 规范化路径列表,过滤不存在的路径并输出警告
    fn normalize_existing_paths(list: &[String], label: &str) -> Vec<PathBuf> {
        list.iter()
            .filter_map(|p| {
                let path = std::path::Path::new(p);
                if !path.exists() {
                    eprintln!("{label}: 路径不存在,已跳过: {}", path.display());
                    return None;
                }
                Some(dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()))
            })
            .collect()
    }

    /// 获取所有需要 allow write 的路径(白名单路径)
    pub fn allowed_write_paths(&self) -> Vec<PathBuf> {
        Self::normalize_existing_paths(&self.whitelist, "白名单")
    }

    /// 获取黑名单路径(规范化后)
    ///
    /// # 安全说明
    ///
    /// **已知限制**:如果黑名单路径当前不存在(如外部存储未挂载),
    /// 则无法为其添加 Deny ACE,该路径将临时不受保护。
    /// 当路径被创建后,需要重新运行 `wsbx setup` 以应用 ACL。
    /// 对于默认黑名单路径(如 `C:\Windows\System32\config`),在正常
    /// Windows 系统上始终存在,此限制仅影响自定义黑名单中的非常规路径。
    pub fn blacklist_paths(&self) -> Vec<PathBuf> {
        let paths = Self::normalize_existing_paths(&self.blacklist, "黑名单");
        // 检查是否有不存在的黑名单路径,发出强警告
        for entry in &self.blacklist {
            let path = std::path::Path::new(entry);
            if !path.exists() {
                eprintln!(
                    "⚠ WARNING: 黑名单路径不存在,无法添加 Deny ACE: {}",
                    path.display()
                );
                eprintln!("  当此路径被创建后,请重新运行 `wsbx setup` 以应用 ACL 保护");
            }
        }
        paths
    }
}

/// 沙盒家目录(存储 SID、配置等)
///
/// 优先级:
/// 1. `WSBX_HOME` 环境变量(如果设置)
/// 2. `%USERPROFILE%\.wsbx\`(默认)
pub fn sandbox_home() -> PathBuf {
    if let Ok(home) = std::env::var("WSBX_HOME") {
        return PathBuf::from(home);
    }
    let home = std::env::var("USERPROFILE")
        .map(PathBuf::from)
        .unwrap_or_else(|_| PathBuf::from("."));
    home.join(".wsbx")
}

/// 默认配置文件路径
pub fn default_config_path() -> PathBuf {
    sandbox_home().join("config.json")
}

/// 默认 SID 存储路径
pub fn default_cap_sid_path() -> PathBuf {
    sandbox_home().join("cap_sid.json")
}

// ==================== 单元测试 ====================

#[cfg(test)]
mod tests {
    use super::*;

    // ---- Default config ----

    #[test]
    fn test_default_blacklist_has_critical_paths() {
        let cfg = SandboxConfig::default();
        assert!(cfg
            .blacklist
            .contains(&r"C:\Windows\System32\config".to_string()));
        assert!(cfg
            .blacklist
            .contains(&r"C:\Windows\System32\SAM".to_string()));
        assert!(cfg
            .blacklist
            .contains(&r"C:\Windows\System32\SECURITY".to_string()));
    }

    #[test]
    fn test_default_whitelist_is_empty() {
        let cfg = SandboxConfig::default();
        assert!(cfg.whitelist.is_empty());
    }

    #[test]
    fn test_default_sensitive_patterns() {
        let cfg = SandboxConfig::default();
        for pattern in &[".git", ".env", ".ssh", "node_modules"] {
            assert!(
                cfg.sensitive_patterns.contains(&pattern.to_string()),
                "敏感路径模式 '{pattern}' 应存在于默认配置中"
            );
        }
    }

    // ---- is_blacklisted ----

    #[test]
    fn test_blacklist_exact_match() {
        let cfg = SandboxConfig::default();
        assert!(cfg.is_blacklisted(r"C:\Windows\System32\config"));
    }

    #[test]
    fn test_blacklist_subpath_match() {
        let cfg = SandboxConfig::default();
        // config 的子目录也应被视为黑名单
        assert!(cfg.is_blacklisted(r"C:\Windows\System32\config\sub\path"));
    }

    #[test]
    fn test_blacklist_no_match() {
        let cfg = SandboxConfig::default();
        assert!(!cfg.is_blacklisted(r"C:\Users\Public"));
        assert!(!cfg.is_blacklisted(r"D:\safe\path"));
    }

    #[test]
    fn test_blacklist_case_insensitive() {
        let cfg = SandboxConfig::default();
        // Windows 路径不区分大小写
        assert!(cfg.is_blacklisted(r"c:\windows\system32\CONFIG"));
    }

    // ---- is_whitelisted ----

    #[test]
    fn test_whitelist_positive() {
        let mut cfg = SandboxConfig::default();
        cfg.whitelist.push(r"D:\workspace".to_string());
        assert!(cfg.is_whitelisted(r"D:\workspace"));
    }

    #[test]
    fn test_whitelist_subpath() {
        let mut cfg = SandboxConfig::default();
        cfg.whitelist.push(r"D:\workspace".to_string());
        assert!(cfg.is_whitelisted(r"D:\workspace\sub\project"));
    }

    #[test]
    fn test_whitelist_no_match() {
        let mut cfg = SandboxConfig::default();
        cfg.whitelist.push(r"D:\workspace".to_string());
        assert!(!cfg.is_whitelisted(r"D:\other"));
    }

    // ---- allowed_write_paths ----

    #[test]
    fn test_allowed_write_paths_filters_nonexistent() {
        let mut cfg = SandboxConfig::default();
        cfg.whitelist
            .push(r"Z:\nonexistent\path\for\test".to_string());
        // 不存在的路径应被过滤掉(仅 stderr 警告)
        let paths = cfg.allowed_write_paths();
        assert!(
            paths.is_empty()
                || !paths
                    .iter()
                    .any(|p| p.to_string_lossy().contains("nonexistent"))
        );
    }

    // ---- path_matches_list (via is_blacklisted/is_whitelisted) ----

    #[test]
    fn test_blacklist_prefix_not_match_similar() {
        // 确保 "C:\Windows" 不会错误匹配 "C:\WindowsSomething"
        let mut cfg = SandboxConfig::default();
        cfg.blacklist.clear();
        cfg.blacklist.push(r"C:\Windows".to_string());
        // starts_with 语义:C:\WindowsSomething 不以 C:\Windows\ 开头,但以 C:\Windows 开头
        // 实际因为 canonicalize 会规范化路径,这里只测试基本逻辑
        assert!(cfg.is_blacklisted(r"C:\Windows\System32"));
    }

    // ---- sensitive_patterns ----

    #[test]
    fn test_sensitive_patterns_list() {
        let cfg = SandboxConfig::default();
        let expected = vec![
            ".git",
            ".env",
            ".ssh",
            ".gnupg",
            "node_modules",
            ".aws",
            ".azure",
            ".kube",
            ".config",
            ".docker",
        ];
        for p in &expected {
            assert!(
                cfg.sensitive_patterns.contains(&p.to_string()),
                "缺少敏感路径模式: {p}"
            );
        }
        assert_eq!(cfg.sensitive_patterns.len(), expected.len());
    }

    // ---- Serialization roundtrip ----

    #[test]
    fn test_config_serde_roundtrip() -> anyhow::Result<()> {
        let cfg = SandboxConfig {
            blacklist: vec!["C:\\custom\\black".to_string()],
            whitelist: vec!["C:\\custom\\white".to_string()],
            sensitive_patterns: vec![".custom".to_string()],
        };
        let json = serde_json::to_string_pretty(&cfg)?;
        let deserialized: SandboxConfig = serde_json::from_str(&json)?;
        assert_eq!(deserialized.blacklist, cfg.blacklist);
        assert_eq!(deserialized.whitelist, cfg.whitelist);
        assert_eq!(deserialized.sensitive_patterns, cfg.sensitive_patterns);
        Ok(())
    }

    #[test]
    fn test_config_serde_defaults_for_missing_fields() {
        // 测试缺少字段时使用默认值
        let json = r#"{}"#;
        let cfg: SandboxConfig = serde_json::from_str(json).unwrap();
        assert!(cfg.whitelist.is_empty());
        assert!(!cfg.blacklist.is_empty());
        assert!(cfg.sensitive_patterns.contains(&".git".to_string()));
    }

    // ---- helper functions ----

    #[test]
    fn test_sandbox_home_uses_userprofile() {
        let home = sandbox_home();
        assert!(home.to_string_lossy().ends_with("\\.wsbx"));
    }

    #[test]
    fn test_default_config_path_ends_correctly() {
        let path = default_config_path();
        assert_eq!(path.file_name().unwrap().to_str().unwrap(), "config.json");
    }

    #[test]
    fn test_default_cap_sid_path_ends_correctly() {
        let path = default_cap_sid_path();
        assert_eq!(path.file_name().unwrap().to_str().unwrap(), "cap_sid.json");
    }
}