use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxConfig {
#[serde(default = "default_blacklist")]
pub blacklist: Vec<String>,
#[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 {
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)
}
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()
}
pub fn allowed_write_paths(&self) -> Vec<PathBuf> {
Self::normalize_existing_paths(&self.whitelist, "白名单")
}
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
}
}
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")
}
pub fn default_cap_sid_path() -> PathBuf {
sandbox_home().join("cap_sid.json")
}
#[cfg(test)]
mod tests {
use super::*;
#[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}' 应存在于默认配置中"
);
}
}
#[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();
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();
assert!(cfg.is_blacklisted(r"c:\windows\system32\CONFIG"));
}
#[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"));
}
#[test]
fn test_allowed_write_paths_filters_nonexistent() {
let mut cfg = SandboxConfig::default();
cfg.whitelist
.push(r"Z:\nonexistent\path\for\test".to_string());
let paths = cfg.allowed_write_paths();
assert!(
paths.is_empty()
|| !paths
.iter()
.any(|p| p.to_string_lossy().contains("nonexistent"))
);
}
#[test]
fn test_blacklist_prefix_not_match_similar() {
let mut cfg = SandboxConfig::default();
cfg.blacklist.clear();
cfg.blacklist.push(r"C:\Windows".to_string());
assert!(cfg.is_blacklisted(r"C:\Windows\System32"));
}
#[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());
}
#[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()));
}
#[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");
}
}