use crate::config::SandboxConfig;
use crate::sandbox::token::LocalSid;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub struct WhitelistSyncResult {
pub to_add: Vec<PathBuf>,
pub to_remove: Vec<PathBuf>,
}
pub struct BlacklistSyncResult {
pub to_add: Vec<PathBuf>,
pub to_remove: Vec<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapSidStore {
#[serde(default)]
pub workspace_by_path: HashMap<String, String>,
#[serde(default)]
pub whitelist_sid: Option<String>,
#[serde(default)]
pub blacklist_sid: Option<String>,
#[serde(default)]
pub applied_whitelist: Vec<String>,
#[serde(default)]
pub applied_blacklist: Vec<String>,
}
impl CapSidStore {
pub fn new() -> Self {
Self {
workspace_by_path: HashMap::new(),
whitelist_sid: None,
blacklist_sid: None,
applied_whitelist: Vec::new(),
applied_blacklist: Vec::new(),
}
}
pub fn load_or_create(path: impl AsRef<Path>) -> anyhow::Result<Self> {
let path = path.as_ref();
if path.exists() {
let content = std::fs::read_to_string(path)?;
if let Ok(store) = serde_json::from_str::<CapSidStore>(&content) {
return Ok(store);
}
}
let store = Self::new();
store.save(path)?;
Ok(store)
}
pub fn save(&self, path: impl AsRef<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(())
}
pub fn workspace_sid(&mut self, cwd: &Path, store_path: impl AsRef<Path>) -> anyhow::Result<String> {
let key = canonical_path_key(cwd);
if let Some(sid) = self.workspace_by_path.get(&key) {
return Ok(sid.clone());
}
let sid = compute_deterministic_sid(&key)?;
self.workspace_by_path.insert(key, sid.clone());
self.save(store_path)?;
Ok(sid)
}
pub fn get_or_create_whitelist_sid(&mut self, store_path: impl AsRef<Path>) -> anyhow::Result<String> {
if let Some(ref sid) = self.whitelist_sid {
return Ok(sid.clone());
}
let sid = Self::generate_random_sid();
self.whitelist_sid = Some(sid.clone());
self.save(store_path)?;
Ok(sid)
}
pub fn get_or_create_blacklist_sid(&mut self, store_path: impl AsRef<Path>) -> anyhow::Result<String> {
if let Some(ref sid) = self.blacklist_sid {
return Ok(sid.clone());
}
let sid = Self::generate_random_sid();
self.blacklist_sid = Some(sid.clone());
self.save(store_path)?;
Ok(sid)
}
pub fn sync_whitelist(
&mut self,
current_paths: &[PathBuf],
store_path: impl AsRef<Path>,
) -> Result<WhitelistSyncResult> {
let current_keys: Vec<String> = current_paths.iter().map(|p| canonical_path_key(p)).collect();
let old_keys = std::mem::take(&mut self.applied_whitelist);
self.get_or_create_whitelist_sid(&store_path)?;
let to_remove: Vec<PathBuf> = old_keys.iter()
.filter(|k| !current_keys.contains(k))
.map(|k| PathBuf::from(k))
.collect();
let to_add: Vec<PathBuf> = current_paths.iter()
.zip(current_keys.iter())
.filter(|(_, k)| !old_keys.contains(k))
.map(|(p, _)| p.clone())
.collect();
self.applied_whitelist = current_keys;
self.save(store_path)?;
Ok(WhitelistSyncResult { to_add, to_remove })
}
pub fn sync_blacklist(
&mut self,
current_paths: &[PathBuf],
store_path: impl AsRef<Path>,
) -> Result<BlacklistSyncResult> {
let current_keys: Vec<String> = current_paths.iter().map(|p| canonical_path_key(p)).collect();
let old_keys = std::mem::take(&mut self.applied_blacklist);
let to_remove: Vec<PathBuf> = old_keys.iter()
.filter(|k| !current_keys.contains(k))
.map(|k| PathBuf::from(k))
.collect();
let to_add: Vec<PathBuf> = current_paths.iter()
.zip(current_keys.iter())
.filter(|(_, k)| !old_keys.contains(k))
.map(|(p, _)| p.clone())
.collect();
self.applied_blacklist = current_keys;
self.save(store_path)?;
Ok(BlacklistSyncResult { to_add, to_remove })
}
pub fn collect_all_whitelist_sids(&self, current_write_sids: &[String]) -> Vec<String> {
let mut all_sids = current_write_sids.to_vec();
if let Some(ref sid) = self.whitelist_sid {
if !all_sids.contains(sid) {
all_sids.push(sid.clone());
}
}
all_sids
}
pub fn collect_all_cleanup_paths(&self, current_paths: &[PathBuf], applied_keys: &[String]) -> Vec<PathBuf> {
let mut all = current_paths.to_vec();
for key in applied_keys {
let p = PathBuf::from(key);
if p.exists() && !all.contains(&p) {
all.push(p);
}
}
all
}
fn generate_random_sid() -> String {
use rand::RngCore;
use rand::rngs::OsRng;
let mut rng = OsRng;
let a = rng.next_u32();
let b = rng.next_u32();
let c = rng.next_u32();
let d = rng.next_u32();
format!("S-1-5-21-{a}-{b}-{c}-{d}")
}
}
fn compute_deterministic_sid(canonical_path_key: &str) -> anyhow::Result<String> {
let user_sid = crate::winutil::get_current_user_sid()?;
let input = format!("{}|{}", user_sid, canonical_path_key);
let hash = Sha256::digest(input.as_bytes());
let a = u32::from_le_bytes([hash[0], hash[1], hash[2], hash[3]]);
let b = u32::from_le_bytes([hash[4], hash[5], hash[6], hash[7]]);
let c = u32::from_le_bytes([hash[8], hash[9], hash[10], hash[11]]);
let d = u32::from_le_bytes([hash[12], hash[13], hash[14], hash[15]]);
Ok(format!("S-1-5-21-{a}-{b}-{c}-{d}"))
}
pub fn canonical_path_key(path: &Path) -> String {
let normalized = dunce::canonicalize(path).unwrap_or_else(|_| {
path.parent()
.and_then(|parent| {
let canon_parent = dunce::canonicalize(parent).ok()?;
let name = path.file_name()?;
Some(canon_parent.join(name))
})
.unwrap_or_else(|| path.to_path_buf())
});
normalized
.to_string_lossy()
.to_ascii_lowercase()
.replace('/', "\\")
}
#[derive(Debug, Clone)]
pub struct SessionContext {
pub session_id: String,
pub work_dir: PathBuf,
pub capability_sid: String,
pub whitelist_sids: Vec<String>,
pub all_write_sids: Vec<String>,
pub blacklist_sid: String,
pub config: SandboxConfig,
}
impl SessionContext {
pub fn new(
work_dir: PathBuf,
config: SandboxConfig,
cap_store: &mut CapSidStore,
cap_store_path: impl AsRef<Path>,
) -> anyhow::Result<Self> {
let session_id = uuid::Uuid::new_v4().to_string();
std::fs::create_dir_all(&work_dir)?;
let work_dir = dunce::canonicalize(&work_dir)?;
let capability_sid = cap_store.workspace_sid(&work_dir, &cap_store_path)?;
let whitelist_sid = cap_store.get_or_create_whitelist_sid(&cap_store_path)?;
let whitelist_sids = vec![whitelist_sid.clone()];
let mut all_write_sids = vec![capability_sid.clone()];
all_write_sids.extend(whitelist_sids.clone());
let blacklist_sid = cap_store.get_or_create_blacklist_sid(&cap_store_path)?;
Ok(Self {
session_id,
work_dir,
capability_sid,
whitelist_sids,
all_write_sids,
blacklist_sid,
config,
})
}
pub fn sensitive_deny_paths(&self) -> Vec<PathBuf> {
let mut paths = Vec::new();
for pattern in &self.config.sensitive_patterns {
let p = self.work_dir.join(pattern);
if p.exists() {
paths.push(p);
}
}
paths
}
pub fn resolve_write_sids(&self) -> anyhow::Result<Vec<LocalSid>> {
self.all_write_sids
.iter()
.map(|s| LocalSid::from_string(s))
.collect()
}
pub fn resolve_blacklist_sid(&self) -> anyhow::Result<LocalSid> {
LocalSid::from_string(&self.blacklist_sid)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_canonical_path_key_preserves_case() {
let cwd = std::env::current_dir().expect("获取当前目录失败");
let key = canonical_path_key(&cwd);
assert!(!key.is_empty());
assert_eq!(key, key.to_ascii_lowercase(), "key 应全小写");
}
#[test]
fn test_canonical_path_key_uses_backslash() {
let cwd = std::env::current_dir().expect("获取当前目录失败");
let key = canonical_path_key(&cwd);
assert!(!key.contains('/'), "key 不应包含正斜杠: {key}");
}
#[test]
fn test_canonical_path_key_nonexistent_with_existing_parent() {
let parent = std::env::temp_dir();
let nonexistent = parent.join("__wsbx_test_nonexistent_dir__");
let key = canonical_path_key(&nonexistent);
assert!(key.contains("__wsbx_test_nonexistent_dir__"),
"不存在的路径应保留文件名: {key}");
assert_eq!(key, key.to_ascii_lowercase(), "key 应全小写");
}
#[test]
fn test_canonical_path_key_forward_slash_normalized() {
let cwd = std::env::current_dir().expect("获取当前目录失败");
let with_forward = cwd.to_string_lossy().replace('\\', "/");
let key = canonical_path_key(std::path::Path::new(&with_forward));
assert!(!key.contains('/'), "正斜杠应被转换为反斜杠: {key}");
}
#[test]
fn test_cap_sid_store_new_is_empty() {
let store = CapSidStore::new();
assert!(store.workspace_by_path.is_empty());
assert!(store.whitelist_sid.is_none());
assert!(store.blacklist_sid.is_none());
assert!(store.applied_whitelist.is_empty());
assert!(store.applied_blacklist.is_empty());
}
#[test]
fn test_cap_sid_store_save_and_load() -> anyhow::Result<()> {
let dir = std::env::temp_dir().join("wsbx_test_cap_sid");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir)?;
let path = dir.join("test_cap_sid.json");
let mut store = CapSidStore::new();
store.workspace_by_path.insert("c:\\test".to_string(), "S-1-5-21-100-200-300-400".to_string());
store.whitelist_sid = Some("S-1-5-21-111-222-333-444".to_string());
store.blacklist_sid = Some("S-1-5-21-999-999-999-999".to_string());
store.save(&path)?;
let loaded = CapSidStore::load_or_create(&path)?;
assert_eq!(loaded.workspace_by_path.get("c:\\test").unwrap(), "S-1-5-21-100-200-300-400");
assert_eq!(loaded.whitelist_sid.as_deref(), Some("S-1-5-21-111-222-333-444"));
assert_eq!(loaded.blacklist_sid.unwrap(), "S-1-5-21-999-999-999-999");
let _ = fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn test_cap_sid_store_load_or_create_creates_new() -> anyhow::Result<()> {
let dir = std::env::temp_dir().join("wsbx_test_cap_sid_new");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir)?;
let path = dir.join("new_cap_sid.json");
let store = CapSidStore::load_or_create(&path)?;
assert!(store.workspace_by_path.is_empty());
assert!(path.exists(), "文件应被创建");
let _ = fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn test_workspace_sid_creates_and_caches() -> anyhow::Result<()> {
let dir = std::env::temp_dir().join("wsbx_test_ws_sid");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir)?;
let store_path = dir.join("cap_sid.json");
let mut store = CapSidStore::new();
let sid1 = store.workspace_sid(&dir, &store_path)?;
assert!(!sid1.is_empty());
assert!(sid1.starts_with("S-1-5-21-"), "SID 格式不正确: {sid1}");
let sid2 = store.workspace_sid(&dir, &store_path)?;
assert_eq!(sid1, sid2, "同一路径应返回相同 SID");
let _ = fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn test_get_or_create_whitelist_sid() -> anyhow::Result<()> {
let dir = std::env::temp_dir().join("wsbx_test_wl_sid");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir)?;
let store_path = dir.join("cap_sid.json");
let mut store = CapSidStore::new();
assert!(store.whitelist_sid.is_none());
let sid1 = store.get_or_create_whitelist_sid(&store_path)?;
assert!(sid1.starts_with("S-1-5-21-"), "SID 格式不正确");
let sid2 = store.get_or_create_whitelist_sid(&store_path)?;
assert_eq!(sid1, sid2, "全局白名单 SID 应只创建一次");
let _ = fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn test_get_or_create_blacklist_sid() -> anyhow::Result<()> {
let dir = std::env::temp_dir().join("wsbx_test_bl_sid");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir)?;
let store_path = dir.join("cap_sid.json");
let mut store = CapSidStore::new();
assert!(store.blacklist_sid.is_none());
let sid1 = store.get_or_create_blacklist_sid(&store_path)?;
assert!(sid1.starts_with("S-1-5-21-"));
let sid2 = store.get_or_create_blacklist_sid(&store_path)?;
assert_eq!(sid1, sid2, "全局黑名单 SID 应只创建一次");
let _ = fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn test_different_paths_get_different_sids() -> anyhow::Result<()> {
let dir = std::env::temp_dir().join("wsbx_test_diff_sid");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir)?;
let store_path = dir.join("cap_sid.json");
let mut store = CapSidStore::new();
let path_a = dir.join("path_a");
let path_b = dir.join("path_b");
fs::create_dir_all(&path_a)?;
fs::create_dir_all(&path_b)?;
let sid_a = store.workspace_sid(&path_a, &store_path)?;
let sid_b = store.workspace_sid(&path_b, &store_path)?;
assert_ne!(sid_a, sid_b, "不同路径应获得不同 SID");
let _ = fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn test_generate_random_sid_format() {
let sid = CapSidStore::generate_random_sid();
assert!(sid.starts_with("S-1-5-21-"), "SID 格式无效: {sid}");
let parts: Vec<&str> = sid.split('-').collect();
assert_eq!(parts.len(), 8, "SID 应有 8 段 (S-1-5-21-a-b-c-d): {sid}");
for i in 4..8 {
assert!(parts[i].parse::<u32>().is_ok(), "SID 段 '{}' 不是数字", parts[i]);
}
}
#[test]
fn test_consecutive_sids_are_different() {
let sid1 = CapSidStore::generate_random_sid();
let sid2 = CapSidStore::generate_random_sid();
assert_ne!(sid1, sid2, "连续生成的 SID 应不同");
}
#[test]
fn test_sync_whitelist_all_new() -> anyhow::Result<()> {
let dir = std::env::temp_dir().join("wsbx_test_sync_wl");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir)?;
let store_path = dir.join("cap_sid.json");
let mut store = CapSidStore::new();
let current = vec![dir.clone()];
let result = store.sync_whitelist(¤t, &store_path)?;
assert_eq!(result.to_add.len(), 1, "新路径应全部为 to_add");
assert!(result.to_remove.is_empty(), "没有旧路径应移除");
assert_eq!(store.applied_whitelist.len(), 1);
let _ = fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn test_sync_whitelist_all_removed() -> anyhow::Result<()> {
let dir = std::env::temp_dir().join("wsbx_test_sync_wl2");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir)?;
let store_path = dir.join("cap_sid.json");
let mut store = CapSidStore::new();
let current = vec![dir.clone()];
let first = store.sync_whitelist(¤t, &store_path)?;
assert_eq!(first.to_add.len(), 1);
let empty: Vec<PathBuf> = vec![];
let result = store.sync_whitelist(&empty, &store_path)?;
assert!(result.to_add.is_empty());
assert_eq!(result.to_remove.len(), 1, "旧路径应被标记移除");
let _ = fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn test_sync_blacklist_add_and_remove() -> anyhow::Result<()> {
let dir = std::env::temp_dir().join("wsbx_test_sync_bl");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir)?;
let store_path = dir.join("cap_sid.json");
let mut store = CapSidStore::new();
let path_a = dir.join("bl_a");
let path_b = dir.join("bl_b");
fs::create_dir_all(&path_a)?;
fs::create_dir_all(&path_b)?;
let current = vec![path_a.clone()];
let r1 = store.sync_blacklist(¤t, &store_path)?;
assert_eq!(r1.to_add.len(), 1);
assert!(r1.to_remove.is_empty());
let current2 = vec![path_b.clone()];
let r2 = store.sync_blacklist(¤t2, &store_path)?;
assert_eq!(r2.to_add.len(), 1, "path_b 应添加");
assert_eq!(r2.to_remove.len(), 1, "path_a 应移除");
let _ = fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn test_session_context_creation_creates_work_dir() -> anyhow::Result<()> {
let dir = std::env::temp_dir().join("wsbx_test_session_ctx");
let _ = fs::remove_dir_all(&dir);
let config = SandboxConfig::default();
let store_path = dir.join("cap_sid.json");
let mut store = CapSidStore::new();
let session = SessionContext::new(dir.clone(), config, &mut store, &store_path)?;
assert!(session.work_dir.exists(), "工作目录应被创建");
assert!(!session.session_id.is_empty());
assert!(session.capability_sid.starts_with("S-1-5-21-"));
assert!(session.blacklist_sid.starts_with("S-1-5-21-"));
let _ = fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn test_sensitive_deny_paths_returns_only_existing() -> anyhow::Result<()> {
let dir = std::env::temp_dir().join("wsbx_test_sensitive_deny");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir)?;
let git_dir = dir.join(".git");
fs::create_dir_all(&git_dir)?;
let config = SandboxConfig::default();
let store_path = dir.join("cap_sid.json");
let mut store = CapSidStore::new();
let session = SessionContext::new(dir.clone(), config, &mut store, &store_path)?;
let deny_paths = session.sensitive_deny_paths();
assert!(deny_paths.iter().any(|p| p.ends_with(".git")),
"存在的 .git 应在 deny 列表中");
assert!(!deny_paths.iter().any(|p| p.ends_with(".env")),
"不存在的 .env 不应在 deny 列表中");
let _ = fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn test_session_id_is_unique() -> anyhow::Result<()> {
let dir = std::env::temp_dir().join("wsbx_test_session_id");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir)?;
let store_path = dir.join("cap_sid.json");
let config = SandboxConfig::default();
let mut store = CapSidStore::new();
let s1 = SessionContext::new(dir.join("s1"), config.clone(), &mut store, &store_path)?;
let s2 = SessionContext::new(dir.join("s2"), config, &mut store, &store_path)?;
assert_ne!(s1.session_id, s2.session_id, "会话 ID 应唯一");
let _ = fs::remove_dir_all(&dir);
Ok(())
}
}