window-sand-box 0.1.0

Windows 沙盒终端执行工具 — 使用受限令牌、ACL 和私有桌面隔离进程权限,提供安全的命令执行环境
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
//! 权限策略与会话管理
//!
//! 管理每个会话的工作目录、Capability SID,以及权限策略。

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};

/// 白名单同步结果
///
/// 白名单使用全局统一的 whitelist_sid,返回结果只含路径列表。
pub struct WhitelistSyncResult {
    /// 需要添加 Allow ACE 的路径
    pub to_add: Vec<PathBuf>,
    /// 需要移除 Allow ACE 的路径
    pub to_remove: Vec<PathBuf>,
}

/// 黑名单同步结果
pub struct BlacklistSyncResult {
    /// 需要添加 Deny ALL ACE 的路径
    pub to_add: Vec<PathBuf>,
    /// 需要移除 Deny ALL ACE 的路径
    pub to_remove: Vec<PathBuf>,
}

/// Capability SID 持久化存储
///
/// 每个工作目录有一个唯一的随机工作目录 SID(`workspace_by_path`)。
/// 白名单和黑名单各自使用一个全局统一的 SID(`whitelist_sid` / `blacklist_sid`)。
/// `applied_*` 记录上一次实际写入磁盘的路径,用于增量同步。
/// SID 会持久化到磁盘,确保同一路径多次运行使用相同的 SID。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapSidStore {
    /// 按规范化路径索引的工作目录 SID
    #[serde(default)]
    pub workspace_by_path: HashMap<String, String>,
    /// 全局白名单 SID(用于在白名单路径上加 Allow ACE)
    #[serde(default)]
    pub whitelist_sid: Option<String>,
    /// 全局黑名单 SID(用于在黑名单路径上加 Deny ALL ACE)
    #[serde(default)]
    pub blacklist_sid: Option<String>,
    /// 上一次实际应用了白名单 Allow ACE 的路径(规范化的 key)
    #[serde(default)]
    pub applied_whitelist: Vec<String>,
    /// 上一次实际应用了黑名单 Deny ALL ACE 的路径(规范化的 key)
    #[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(())
    }

    /// 获取或创建工作目录的 Capability SID
    ///
    /// SID 是**可计算**的确定性值:基于 Windows 用户 SID + 规范化路径通过 SHA256 计算得出。
    /// 同一用户同一路径始终得到相同的 SID,即使 `cap_sid.json` 丢失也能恢复。
    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());
        }
        // 生成可计算的确定性 SID(基于 Windows 用户 SID + 路径)
        let sid = compute_deterministic_sid(&key)?;
        self.workspace_by_path.insert(key, sid.clone());
        self.save(store_path)?;
        Ok(sid)
    }

    /// 获取或创建全局白名单 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)
    }

    /// 获取或创建全局黑名单 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)
    }

    /// 白名单增量同步:对比当前路径与已应用路径,计算增删
    ///
    /// 白名单使用全局统一的 whitelist_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);

        // 确保全局白名单 SID 存在
        self.get_or_create_whitelist_sid(&store_path)?;

        // 已移除的路径
        //
        // 注意:old_key 已经是 canonical_path_key() 规范化后的路径字符串(全小写 + 反斜杠),
        // Windows 文件系统不区分大小写,所以直接用 PathBuf::from(old_key) 即可用于 ACL 操作,
        // 无需再次 canonicalize(重 canonicalize 会还原为原始大小写,导致 key 不匹配)。
        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 })
    }

    /// 黑名单增量同步:对比当前路径与已应用路径,计算增删
    ///
    /// 黑名单使用全局统一的 blacklist_sid,返回结果只含路径列表。
    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 })
    }

    /// 收集清理时需要的所有白名单 SID 字符串
    ///
    /// 合并当前 SID 与全局白名单 SID,确保清理时能移除所有残留 ACE。
    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
    }

    /// 收集清理时需要的所有路径(当前 + 历史已应用)
    ///
    /// 合并当前配置路径与 `applied_keys` 中的历史路径,确保能清理所有残留 ACE。
    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
    }

    /// 生成随机 SID 字符串。
    ///
    /// 使用 `OsRng`(密码学安全 RNG)生成不可预测的 SID。
    /// 用于白名单 SID 和黑名单 SID(需要不可预测性以防止绕过)。
    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}")
    }
}

/// 计算工作目录的确定性 SID
///
/// 基于 Windows 用户 SID + 规范化路径通过 SHA256 哈希得出。
/// 同一 Windows 用户对同一路径始终得到相同的 SID,
/// 即使 `cap_sid.json` 被删除也能重新计算出相同的值。
///
/// 格式:`S-1-5-21-{a}-{b}-{c}-{d}`,其中 a/b/c/d 来自 SHA256 的前 16 字节。
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}"))
}

/// 路径规范化 key,用于 HashMap 索引
///
/// 优先使用 `dunce::canonicalize` 获取真实路径。
/// 如果路径不存在(如白名单中配置但尚未创建的路径),
/// 尝试规范化其父目录再拼接文件名,确保同一路径在不同
/// 生命周期内(创建前 vs 创建后)生成一致的 key。
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 {
    /// 会话 ID
    pub session_id: String,
    /// 会话工作目录(AI 可读写删除)
    pub work_dir: PathBuf,
    /// 此工作目录的 Capability SID 字符串
    pub capability_sid: String,
    /// 白名单路径的 Capability SID 列表
    pub whitelist_sids: Vec<String>,
    /// 所有需要写入权限的 SID(工作目录 SID + 白名单 SID)
    pub all_write_sids: Vec<String>,
    /// 全局黑名单 SID(用于拒绝访问黑名单路径)
    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)?;

        // 获取工作目录的 Capability SID
        let capability_sid = cap_store.workspace_sid(&work_dir, &cap_store_path)?;

        // 获取全局白名单 Capability SID(所有白名单路径共用同一个 SID)
        let whitelist_sid = cap_store.get_or_create_whitelist_sid(&cap_store_path)?;
        let whitelist_sids = vec![whitelist_sid.clone()];

        // 所有写入 SID = 工作目录 SID + 白名单 SID
        let mut all_write_sids = vec![capability_sid.clone()];
        all_write_sids.extend(whitelist_sids.clone());

        // 获取全局黑名单 SID
        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,
        })
    }

    /// 获取所有工作目录内需要 deny write 的敏感路径
    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
    }

    /// 将所有 Capability SID 字符串转为 LocalSid 对象
    pub fn resolve_write_sids(&self) -> anyhow::Result<Vec<LocalSid>> {
        self.all_write_sids
            .iter()
            .map(|s| LocalSid::from_string(s))
            .collect()
    }

    /// 将黑名单 SID 字符串转为 LocalSid 对象
    pub fn resolve_blacklist_sid(&self) -> anyhow::Result<LocalSid> {
        LocalSid::from_string(&self.blacklist_sid)
    }
}

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

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

    // ---- canonical_path_key ----

    #[test]
    fn test_canonical_path_key_preserves_case() {
        // canonical_path_key 返回规范化后的全小写路径
        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}");
    }

    // ---- CapSidStore ----

    #[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");

        // 文件不存在,应创建新 store
        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();

        // 第一次获取应创建新 SID
        let sid1 = store.workspace_sid(&dir, &store_path)?;
        assert!(!sid1.is_empty());
        assert!(sid1.starts_with("S-1-5-21-"), "SID 格式不正确: {sid1}");

        // 第二次获取应返回相同的 SID
        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();
        // 格式:S-1-5-21-<a>-<b>-<c>-<d> → 8 个 segments
        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}");
        // 检查后 4 段都是数字
        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 应不同");
    }

    // ---- sync_whitelist ----

    #[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(&current, &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(&current, &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(())
    }

    // ---- sync_blacklist ----

    #[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)?;

        // 第一次同步:添加 path_a
        let current = vec![path_a.clone()];
        let r1 = store.sync_blacklist(&current, &store_path)?;
        assert_eq!(r1.to_add.len(), 1);
        assert!(r1.to_remove.is_empty());

        // 第二次同步:将 path_a 替换为 path_b
        let current2 = vec![path_b.clone()];
        let r2 = store.sync_blacklist(&current2, &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(())
    }

    // ---- SessionContext ----

    #[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)?;

        // 在工作目录中创建 .git 目录
        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();

        // .git 存在所以应返回
        assert!(deny_paths.iter().any(|p| p.ends_with(".git")),
            "存在的 .git 应在 deny 列表中");

        // .env 不存在所以不应返回
        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(())
    }
}