docs: README 拆出四篇主题文档,本文件留总览与操作

拆出软件名录(67 行)、清单语法(114 行)、归档布局与迁移(47 行)、安全描述符(69 行),共 297 行;README 从 653 降到 360 行,每节原位留一句摘要加链接。

为什么拆:这四块是「要查的资料」,README 剩下的是「要读的流程」。混在一起时,想查清单语法的人得先滚过一百多行总览;拆开后每篇也能被单独引用与单独评审。

搬运按标题抽取原文、不重打,并在写之前断言正文长度、写之后再读回断言一次(第一次尝试就是栽在没有断言上:@(a, b, $arr) 不会展开数组,而是把 $arr 拼成一行,结果四篇各只有 5 行、正文却已从 README 删除。那次已回滚)。
This commit is contained in:
Shuery committed 2026-09-27 10:20:08 +08:00
1 parent fb0d93a25f
commit 60ecbc2933
5 files changed
+322 -301

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
# 安全描述符(属主 / ACL)
> 本文从 [README.md](../README.md) 拆出,单独成篇是为了让它能被单独引用与单独评审。
**问题**:归档格式装不下 NTFS 安全描述符 —— 7-Zip 的 `-sni`(Store NT security information)
官方文档写明「当前版本只能写进 WIM 归档」,`.7z` 里一个字节的 ACL 都没有。
于是"备份 → 恢复"之后,每个对象的安全描述符都是**新建对象的默认值**:属主是跑恢复脚本的
那个进程,DACL 是从目标父目录继承来的那一套。
**为什么这对 `C:\ProgramData` 是致命的**:那里的目录 ACL 里有
```text
(A;OICIIO;GA;;;CO) CREATOR OWNER + inherit-only + GENERIC_ALL
```
`CREATOR OWNER`(`S-1-3-0`)不是账户,是**访问检查时才替换的占位符** —— 替换成
"被检查对象的属主"。所以这句话的真实含义是「谁创建的东西谁有全权」。只回放 ACE 文本、
不恢复属主,等于把里面的"谁"换成了跑恢复脚本的账户,**原程序(服务账户 / 专用用户)
反而没了读写权限**。真机实测(`tools\lab\Lab.ps1 acl-test`):
```text
原属主 = S-1-5-18 (NT AUTHORITY\SYSTEM)
恢复后属主 = S-1-5-18 ← 正确恢复(要靠显式启用的 SeRestorePrivilege)
负对照属主 = S-1-5-32-544 ← 只搬文件、不回放安全描述符时,属主落到"跑脚本的账户"
```
**怎么做**:
- 备份时把每个对象的 SDDL(`Get-Acl` 的原文,含 `O:` / `G:` / `D:`)写进旁挂文件
`Backups/<归档名>.acl.json`,键是**归档内相对路径**(目标机器上 `%UserProfile%` 和
名录的前缀补全都会变,只有归档内路径两端同源)。
- SDDL 里的 SID 是**数值形式**,`CO` / `OW` 这类占位符原样保留。全程**不做账户名解析**
—— 名字解析会把占位符映射成当前用户,或者直接抛 `IdentityNotMappedException`,
那正是"权限落到脚本头上"的另一种成因。
- 恢复时在**解压之后**、对真实目标路径**自顶向下**回放:父目录先写,子对象的继承才收敛。
原本不 `protected` 的 DACL 只写显式 ACE,其余交给父目录重新继承(保住活继承语义);
`protected` 的原样写。
- 写属主要 `SeRestorePrivilege`,而且**必须显式启用**:管理员的过滤令牌里它默认是 disabled,
`Set-Acl` / `SetAccessControl` 都不会替你打开。所以**恢复要在管理员(或 SYSTEM)下跑**,
脚本启动时会明确告警"属主将无法恢复,只能恢复 DACL"。
- 写失败有三级回退:`属主+属组+DACL` → `属主+DACL` → `仅 DACL`(属组常常是最先失败的那个,
而它对访问判定几乎没影响,不能因为它把属主一起丢掉)。
- 对象的安全描述符读不到(系统目录里很常见)时**带错误记账**、写进 sidecar 并计入 manifest
的 `security.errors`,恢复时跳过它并告警 —— 而不是当成"这个对象没有特殊权限"。
配置在 `BackupConfig.psd1`:
```powershell
Security = @{
Mode = 'Full' # Off | Full | Smart | Roots
IncludeSacl = $false # 连审计规则(SACL)一起存取,需要 SeSecurityPrivilege
SidMap = @{} # 跨机恢复的 SID 映射:@{ 'S-1-5-21-旧' = 'S-1-5-21-新' }
FailOnError = $false # sidecar 写不出来时,是否把该条目算作失败
}
```
- `Full`(默认):每个对象都存。**正确性优先**,几万文件的树 sidecar 几 MB。
- `Smart`:只存"继承复现不出来"的对象(protected / 有显式 ACE / 属主属组与父目录不同 /
继承链已脱节)。判据偏保守,但终究是启发式,所以不是默认。
- `Roots`:只存每个归档项的根,最省。
- `Off`:完全不采集,恢复出来的就是新建对象的默认值。
`Restore.ps1` 另有 `-SkipSecurity` 可以只恢复文件内容。
**已知取舍(有意为之)**:
- 归档旁边没有 `acl.json` 的旧归档照常恢复,只是打一行告警说明"属主/ACL 是默认值"。
- **陈旧继承 ACE 会被"冻结"**:如果某个对象的 DACL 里留着已经没有任何出处的继承 ACE
(父目录改过权限、Windows 自己也不会再传播它),那它靠继承复现不出来,只能整套冻结成
显式 ACE **并置 protected** —— 这是唯一"既不丢 ACE、也不产生重复 ACE"的做法(实测:
目标上原本就留着那条陈旧 ACE,再补一条显式 ACE 会让同一条 ACE 出现两次)。
代价是这个对象从此不跟随父目录,而它本来就已经跟父目录脱节了。
- ACL 只跟着归档旁边的 `acl.json` 走:**搬归档时要把同名的 `.acl.json` 一起搬**。