From 60ecbc2933ce576f455c7ccce0ef9c6e615eec06 Mon Sep 17 00:00:00 2001 From: Shuery <2463253700@qq.com> Date: Sun, 27 Sep 2026 10:20:08 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20README=20=E6=8B=86=E5=87=BA=E5=9B=9B?= =?UTF-8?q?=E7=AF=87=E4=B8=BB=E9=A2=98=E6=96=87=E6=A1=A3=EF=BC=8C=E6=9C=AC?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E7=95=99=E6=80=BB=E8=A7=88=E4=B8=8E=E6=93=8D?= =?UTF-8?q?=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拆出软件名录(67 行)、清单语法(114 行)、归档布局与迁移(47 行)、安全描述符(69 行),共 297 行;README 从 653 降到 360 行,每节原位留一句摘要加链接。 为什么拆:这四块是「要查的资料」,README 剩下的是「要读的流程」。混在一起时,想查清单语法的人得先滚过一百多行总览;拆开后每篇也能被单独引用与单独评审。 搬运按标题抽取原文、不重打,并在写之前断言正文长度、写之后再读回断言一次(第一次尝试就是栽在没有断言上:@(a, b, $arr) 不会展开数组,而是把 $arr 拼成一行,结果四篇各只有 5 行、正文却已从 README 删除。那次已回滚)。 --- README.md | 310 ++---------------------------------- docs/archive-layout.md | 51 ++++++ docs/backup-list-syntax.md | 118 ++++++++++++++ docs/security-descriptor.md | 73 +++++++++ docs/software-catalog.md | 71 +++++++++ 5 files changed, 322 insertions(+), 301 deletions(-) create mode 100644 docs/archive-layout.md create mode 100644 docs/backup-list-syntax.md create mode 100644 docs/security-descriptor.md create mode 100644 docs/software-catalog.md diff --git a/README.md b/README.md index f45069a..a0cac1b 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ | `CONTEXT.md` | 术语表(本项目里每个概念只有一个叫法) | | `CHANGELOG.md` | 变更日志 | | `docs/adr/` | 决策记录(8 条:为什么这么设计、拒绝了什么) | +| `docs/*.md` | 主题文档:软件名录、清单语法、归档布局、安全描述符(从本文件拆出,便于单独引用与评审) | | `tools/Build-BakNRetModule.ps1` | 把模块的多个源文件按加载器顺序合回单文件(发布形态、代码签名时需要) | | `tools/Invoke-Analyzer.ps1` | 静态分析门禁(默认规则 + 格式规则) | | `Backups/` | 归档与 `manifest.json`(已 gitignore) | @@ -69,315 +70,22 @@ | `tools/Rename-Archives.ps1` | 把归档名对齐到当前清单规则(默认试运行) | | `tools/Install-TestDependencies.ps1` | 把 Pester 与 PSScriptAnalyzer 装到仓库内的 `.tools/`(不动机器上的全局模块) | +## SoftwareCatalog.psd1 —— 软件名 → Slot 组 ## SoftwareCatalog.psd1 —— 软件名 → Slot 组 -```powershell -@{ - Edge = @{ - # Slot = 归档内的一层目录:内容进 DefaultData\,恢复时整棵回到这个 Path - DefaultData = @{ - Path = '%LocalAppData%\Microsoft\Edge\User Data' - Exclude = '!*Cache,!Crashpad,Default\Extensions,Default\Service Worker' - Description = 'Edge 用户数据:书签/密码/偏好/历史,以及站点数据' - } - } - - Scoop = @{ - # 一个软件可以有多个 Slot;两个都叫 persist 的目录因此不再冲突 - DefaultConfig = @{ - Path = '%UserProfile%\.config\scoop' - Encrypt = $true - Description = 'scoop 自身的配置' - } - UserPersist = @{ - Path = '$(if ($env:SCOOP) { $env:SCOOP } else { Join-Path $env:USERPROFILE "scoop" })\persist' - Encrypt = $true - Description = 'scoop 各应用的持久化数据' - } - } - - WindowsTerminal = @{ - # Path 指向文件时,归档里就是一个名为 DefaultData 的文件(没有扩展名) - DefaultData = @{ - Path = '%LocalAppData%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json' - Encrypt = $true - Description = 'Windows Terminal 的设置文件' - } - } -} -``` - -字段: - -| 字段 | 说明 | -| --- | --- | -| Slot 名 | **归档内的一层目录**。内容进 `\`;Path 是文件时就是名为 `` 的文件。同一软件里不能重名 | -| `Path` | 宿主机上的绝对路径。支持 `%变量%` 与 `$( ... )` 子表达式 | -| `Exclude` | 排除模式,相对本 Slot 的根,逗号分隔。`!` 打头 = 任意层级(7z 通配符),`!re:<正则>` = 正则 | -| `Include` | 追加项,`<归档内相对路径>:<宿主机绝对路径>`,逗号分隔 | -| `Encrypt` | 该归档是否加密,默认 `$false`。同一软件里若各 Slot 不一致,整个归档按**加密**处理 | -| `Description` | 这个 Slot 是干什么的;运行时逐条打印 | - -要点: - -- **`Path` 支持 `$( ... )`**:`$(if ($env:SCOOP) { $env:SCOOP } else { Join-Path $env:USERPROFILE "scoop" })` - 会按 PowerShell 求值(求值结果会缓存,不会每个条目重复起进程)。 - 这类写法用了 `+` 拼接字符串时,`Import-PowerShellDataFile` 会拒绝,脚本会自动改用 - PowerShell 求值——名录与配置是仓库里的本地文件,和脚本同级,信任级别相同。 -- **目录当前不存在也不会被丢掉**:备份时跳过并记 `missing-source`,但恢复时仍然知道 - "这块内容原本该回到哪个位置",这正是恢复要用的。 -- **前缀补全**:写 `D:\Programs\legendary`,实际目录是 `legendary_2.0.4` 时会自动匹配 - (只认 `<名>_*` 与 `<名>-*`)。一个 Slot 只能对应一个目录,补全出多个会明确报错并让你拆 Slot。 -- **一个软件里不能有两个同名 Slot**,否则归档内会混成一棵树;脚本会明确报错。 - -**分文件维护**:用 `Includes` 引入其它名录文件(路径相对本文件): - -```powershell -@{ - Includes = @('SoftwareCatalog.games.psd1') - ... -} -``` - +软件名录的语法:一个软件由若干 Slot 组成,每个 Slot 能写路径、排除、追加、加密与说明。详见 [software-catalog.md](./docs/software-catalog.md)。 +## BackupList.txt 语法 ## BackupList.txt 语法 -```text -[+|-] <软件名 或 绝对路径> [ :: <绝对路径> ] [ :- <模式>[,<模式>...] ] [ :+ <追加项>[,<追加项>...] ] - [ :encrypt | :!encrypt ] [ @ ='<值>' ] [ # 说明 ] -``` - -修饰符必须是**独立的、前后带空白的记号**,所以路径里出现的 `:-`、`C:\a#b` 之类不会被误切。 - -### 目标(二选一) - -| 写法 | 说明 | -| --- | --- | -| `Edge` | **软件名**:去 `SoftwareCatalog.psd1` 查 Slot 组,**归档名 = 软件名** | -| `C:\Programs\MiFlash` | **手写路径**:含 `\` `/` 或 `%` 就按路径处理,归档名 = `<末级名>_from_<上级路径用 + 连接>` | -| `Edge @pathname` | 软件名 + 强制用路径命名(想换到名录体系但暂时不想改归档名时用) | - -### 行首方向标记 - -| 标记 | 作用 | -| --- | --- | -| `+` | **仅备份,不恢复**(`Restore.ps1` 会跳过它;归档名照旧算"有主"的,不会被报成孤儿) | -| `-` | **仅恢复,不备份**(`Backup.ps1` 会跳过它;适合放在别处、必要时才还原的目录) | -| 无 | 既能备份也能恢复(默认) | - -### 修饰符 - -| 修饰符 | 等价写法 | 作用 | -| --- | --- | --- | -| `:: <绝对路径>` | `@ Path='<绝对路径>'` | 覆盖 Path(软件名条目只有一个 Slot 时可用) | -| `:- <模式>[,...]` | `@ Exclude='<模式>'` | 排除模式(`,` `;` 都当分隔符),**覆盖**名录里各 Slot 的 Exclude | -| `:+ <追加项>[,...]` | `@ Include='<追加项>'` | 追加项,语法 `<归档内相对路径>:<宿主机绝对路径>`,**覆盖**名录里的 Include | -| `:encrypt` | `@ Encrypt='$true'` | 该条目加密 | -| `:!encrypt` | `@ Encrypt='$false'` | 该条目不加密 | -| `@ ='<值>'` | — | 覆盖名录里的默认字段(目前支持 Path / Exclude / Include / Encrypt) | - -兼容的历史写法仍然认:`@encrypt` / `@!encrypt` / `@pathname` / `@root=<名>`(`root=` 已废弃,只会打印告警)。 - -```text -# 软件名:用名录里的 Slot 与排除;再把额外目录放进包内 Modules\ 位置 -Scoop :- GlobalPersist\steam\steamapps - -# 手写目录 + 排除 -C:\Programs\MiFlash :- MiFlash\logs\ - -# 追加映射:把宿主机的 D:\extra\ps-modules 放到包内 Modules\ 下 -PowerShell :+ Modules:D:\extra\ps-modules - -# 覆盖加密(名录里默认加密时特别有用) -PowerShell @ Encrypt='$false' -WindowsPowerShell :!encrypt -``` - -### 模式(排除 / 追加)怎么写 - -模式匹配的是**归档内的相对路径**,而且**相对本 Slot 的根**(也就是 `\` 里面那一层): - -| 形态 | 展开成 | 说明 | -| --- | --- | --- | -| `<相对路径>` | `-x!\<相对路径>` | 锚定在归档根下这一份 | -| `!<通配>` | `-xr!<通配>` | **任意层级**按组件名匹配,`*` `?` 是 7z 通配符(不是正则) | -| `!re:<正则>` | 若干 `-x!<完整路径>` | **正则**:脚本自己遍历源目录把命中的路径展开成精确排除项 | -| `GlobalPersist\steam` | `-x!GlobalPersist\steam` | 第一段是 Slot 名时,只作用在那一个 Slot 上 | - -- `!*Cache` 一次覆盖 `Cache` / `Code Cache` / `GPUCache` / `DaemonCache` 等一批以 Cache 结尾的组件名。 -- 模式里**不要自己写引号**;模式里的空格会被自动转成 `?`(7z 的 `-x!` 不接受带空格的模式)。 -- 想把 `.log` 之类按正则排除就写 `!re:.*\.log$`;命中的目录会整棵剪掉,命中数超过 300 条会明确报错 - (命令行长度有限),这时应该改用更粗的通配模式。 -- 不带 `!` 的模式是**锚定**的:Edge 的 `OneAuth\WebView2\EBWebView\` 里还有一整套自己的 - `Crashpad` / `BrowserMetrics` / `ProvenanceData` / `optimization_guide`,锚定模式碰不到它们, - 这些可再生的东西一律用 `!<组件名>` 才会在任意层级命中。 - -实测效果(本机真实 Edge 配置,源 4619.9 MB): - -| Edge 归档 | 大小 | 条目数 | -| --- | --- | --- | -| 排除规则生效前 | 1781 MB | 27961 | -| 排除规则生效后 | 72 MB | 2294 | - -书签、密码(`Login Data`)、`Cookies`、偏好、历史、`IndexedDB`、`Local Storage` 全部保留; -缓存、组件缓存、Service Worker、扩展本体、遥测与优化数据全部排除。 - -### 行尾可以写"为什么" - -行尾的 ` # 说明` 会被解析出来,运行时和 Slot 介绍一起打印: - -```text -Edge :- !*Cache,!Crashpad # 缓存与崩溃转储都可再生,不进归档 -``` - -`#` 必须前面有空白才算注释,所以路径里的 `C:\a#b` 不受影响。 - -### 运行时会把每个条目的归档项逐条介绍出来 - -说明来自 `SoftwareCatalog.psd1` 的 Slot,追加/排除的**来源**来自清单: - -```text -[INFO] 条目:Scoop -[INFO] 归档:Scoop.7z;方向:备份 + 恢复;加密:是 -[INFO] 归档项 1/3:DefaultConfig <- C:\Users\Shuery\.config\scoop -[INFO] 来源:软件名录;存在,会打包;目录 -[INFO] 介绍:Scoop 配置。 -[INFO] 归档项 2/3:GlobalPersist <- C:\ProgramData\scoop\persist -[INFO] 来源:软件名录;存在,会打包;目录 -[INFO] 排除 1 条(来自清单的 :- / @ Exclude):GlobalPersist\steam\steamapps -[INFO] 排除 2 条(来自 BackupConfig.psd1 的 DefaultExcludes):!Thumbs.db、!desktop.ini -``` - -`Restore.ps1` 也会打印"哪一项还原到哪个目录、是文件还是目录、会新建还是覆盖"。 - -### 几个必须知道的约束 - -- **归档名重复会直接报错。** 归档名 = 软件名字,所以同一个软件写两遍会让两个条目互相覆盖。 -- **同一软件里的 Slot 名不能重复**,追加项的归档内路径也不能和 Slot 撞;脚本会在打包前明确报错。 -- **一个条目挂多个归档项时,每一项只还原自己那棵子树**,不会把兄弟项也复制到别的父目录下。 -- **`::` 现在是"覆盖 Path"**,不再是 `:-` 的历史别名;排除一律写 `:-`。 - +清单每一行的完整语法:方向标记、修饰符、引号与记号边界。详见 [backup-list-syntax.md](./docs/backup-list-syntax.md)。 +## 归档布局、命名与迁移 ## 归档布局、命名与迁移 -### 包内长什么样 - -| 条目类型 | 归档内部 | -| --- | --- | -| 软件名 + Slot 目录 | `\<该 Path 的内容>` | -| 软件名 + Slot 文件 | 一个名为 `` 的文件(没有扩展名,恢复时还原成 Path 里的原名) | -| 手写路径(目录) | `<路径末级名>\...`(与历史归档一致) | -| 手写路径(文件) | 一个名为 `<路径末级名>` 的文件 | -| `:+` / `Include` 追加项 | 你写的那个 `<归档内相对路径>`(目录就是目录,文件就是那个文件) | - -7z 没有"入库时改名"的能力,所以打包前会建一个**暂存目录**:目录项用 junction、 -文件项用硬链接(不可用时退回复制)按归档内的名字挂进去,打完立刻拆掉。 -建不出连接点时会**明确报错**,不会悄悄换成另一种布局——布局一变恢复就对不上了。 - -### 归档名 - -| 条目类型 | 归档名 | -| --- | --- | -| 软件名 | `<软件名>.7z` | -| 字面路径 | `<末级名>_from_<上级路径用 + 连接>.7z`(`:` 归一化成 `_`) | -| 软件名 + `@pathname` | 同字面路径 | - -> `::` / `@ Path=` 只改**从哪儿读**,不改归档名:软件名条目仍然叫 `<软件名>.7z`。 -> 想换归档名就用 `@pathname`,或者干脆把条目写成绝对路径。 - -### 从旧版迁移(重要) - -1. **包内布局变了。** 重构前生成的归档,包内顶层是源目录名;现在软件名条目多了一层 Slot。 - `Restore.ps1` 会识别这种情况(归档里没有该 Slot 时打印告警并按旧布局解), - 所以**旧归档仍然恢复得出来**;但要让包内结构统一,跑一次 `.\Backup.ps1 -Force` 重打即可 - (`-Force` 会忽略"源未更新"判断)。 -2. **手写路径条目的归档名可能变了。** 清单里把原来的软件名改成绝对路径之后, - 归档名会从 `<软件名>` 变成 `<末级名>_from_<...>`。用重命名工具对齐(**默认试运行**、 - 逐份大小校验、重建 manifest,只改名不搬数据): - - ```powershell - .\tools\Rename-Archives.ps1 # 先看计划 - .\tools\Rename-Archives.ps1 -Apply # 确认后执行 - ``` - - 它会先用当前规则算出目标名,再从"路径命名算法 / 名录里的软件名 / manifest 里记录过的归档名" - 里找磁盘上真实存在的旧文件。 -3. **名录里的 `Encrypt` 现在生效。** 如果某个 Slot 写了 `Encrypt = $true`(或清单里写了 - `:encrypt`),但运行时取不到口令,该条目会**明确失败**,绝不会退化成明文归档。 - 先准备好 `$env:BAKNRET_PASSWORD` 或用 `-KeyFile` 指定密码文件再跑。 -4. **孤儿归档审计**会在每次备份后点名"磁盘上有、但清单里没有任何条目指向"的归档 - (旧名字没迁移、条目被删掉或改名都会这样)。确认新归档校验通过之后再删旧文件。 - +归档内的层级、归档名怎么来、以及改名与迁移时要注意什么。详见 [archive-layout.md](./docs/archive-layout.md)。 +## 安全描述符(属主 / ACL) ## 安全描述符(属主 / ACL) -**问题**:归档格式装不下 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` 一起搬**。 - +属主与 ACL 为什么不是放进归档、而是旁挂一份 sidecar,以及恢复时怎么回放。详见 [security-descriptor.md](./docs/security-descriptor.md)。 ## 恢复语义 - 每一项只解出**它自己那棵子树**(`` / `<末级名>`),不会把兄弟项也复制到别的父目录下。 diff --git a/docs/archive-layout.md b/docs/archive-layout.md new file mode 100644 index 0000000..541743a --- /dev/null +++ b/docs/archive-layout.md @@ -0,0 +1,51 @@ +# 归档布局、命名与迁移 + +> 本文从 [README.md](../README.md) 拆出,单独成篇是为了让它能被单独引用与单独评审。 + +### 包内长什么样 + +| 条目类型 | 归档内部 | +| --- | --- | +| 软件名 + Slot 目录 | `\<该 Path 的内容>` | +| 软件名 + Slot 文件 | 一个名为 `` 的文件(没有扩展名,恢复时还原成 Path 里的原名) | +| 手写路径(目录) | `<路径末级名>\...`(与历史归档一致) | +| 手写路径(文件) | 一个名为 `<路径末级名>` 的文件 | +| `:+` / `Include` 追加项 | 你写的那个 `<归档内相对路径>`(目录就是目录,文件就是那个文件) | + +7z 没有"入库时改名"的能力,所以打包前会建一个**暂存目录**:目录项用 junction、 +文件项用硬链接(不可用时退回复制)按归档内的名字挂进去,打完立刻拆掉。 +建不出连接点时会**明确报错**,不会悄悄换成另一种布局——布局一变恢复就对不上了。 + +### 归档名 + +| 条目类型 | 归档名 | +| --- | --- | +| 软件名 | `<软件名>.7z` | +| 字面路径 | `<末级名>_from_<上级路径用 + 连接>.7z`(`:` 归一化成 `_`) | +| 软件名 + `@pathname` | 同字面路径 | + +> `::` / `@ Path=` 只改**从哪儿读**,不改归档名:软件名条目仍然叫 `<软件名>.7z`。 +> 想换归档名就用 `@pathname`,或者干脆把条目写成绝对路径。 + +### 从旧版迁移(重要) + +1. **包内布局变了。** 重构前生成的归档,包内顶层是源目录名;现在软件名条目多了一层 Slot。 + `Restore.ps1` 会识别这种情况(归档里没有该 Slot 时打印告警并按旧布局解), + 所以**旧归档仍然恢复得出来**;但要让包内结构统一,跑一次 `.\Backup.ps1 -Force` 重打即可 + (`-Force` 会忽略"源未更新"判断)。 +2. **手写路径条目的归档名可能变了。** 清单里把原来的软件名改成绝对路径之后, + 归档名会从 `<软件名>` 变成 `<末级名>_from_<...>`。用重命名工具对齐(**默认试运行**、 + 逐份大小校验、重建 manifest,只改名不搬数据): + + ```powershell + .\tools\Rename-Archives.ps1 # 先看计划 + .\tools\Rename-Archives.ps1 -Apply # 确认后执行 + ``` + + 它会先用当前规则算出目标名,再从"路径命名算法 / 名录里的软件名 / manifest 里记录过的归档名" + 里找磁盘上真实存在的旧文件。 +3. **名录里的 `Encrypt` 现在生效。** 如果某个 Slot 写了 `Encrypt = $true`(或清单里写了 + `:encrypt`),但运行时取不到口令,该条目会**明确失败**,绝不会退化成明文归档。 + 先准备好 `$env:BAKNRET_PASSWORD` 或用 `-KeyFile` 指定密码文件再跑。 +4. **孤儿归档审计**会在每次备份后点名"磁盘上有、但清单里没有任何条目指向"的归档 + (旧名字没迁移、条目被删掉或改名都会这样)。确认新归档校验通过之后再删旧文件。 diff --git a/docs/backup-list-syntax.md b/docs/backup-list-syntax.md new file mode 100644 index 0000000..304a7cf --- /dev/null +++ b/docs/backup-list-syntax.md @@ -0,0 +1,118 @@ +# BackupList.txt 语法 + +> 本文从 [README.md](../README.md) 拆出,单独成篇是为了让它能被单独引用与单独评审。 + +```text +[+|-] <软件名 或 绝对路径> [ :: <绝对路径> ] [ :- <模式>[,<模式>...] ] [ :+ <追加项>[,<追加项>...] ] + [ :encrypt | :!encrypt ] [ @ ='<值>' ] [ # 说明 ] +``` + +修饰符必须是**独立的、前后带空白的记号**,所以路径里出现的 `:-`、`C:\a#b` 之类不会被误切。 + +### 目标(二选一) + +| 写法 | 说明 | +| --- | --- | +| `Edge` | **软件名**:去 `SoftwareCatalog.psd1` 查 Slot 组,**归档名 = 软件名** | +| `C:\Programs\MiFlash` | **手写路径**:含 `\` `/` 或 `%` 就按路径处理,归档名 = `<末级名>_from_<上级路径用 + 连接>` | +| `Edge @pathname` | 软件名 + 强制用路径命名(想换到名录体系但暂时不想改归档名时用) | + +### 行首方向标记 + +| 标记 | 作用 | +| --- | --- | +| `+` | **仅备份,不恢复**(`Restore.ps1` 会跳过它;归档名照旧算"有主"的,不会被报成孤儿) | +| `-` | **仅恢复,不备份**(`Backup.ps1` 会跳过它;适合放在别处、必要时才还原的目录) | +| 无 | 既能备份也能恢复(默认) | + +### 修饰符 + +| 修饰符 | 等价写法 | 作用 | +| --- | --- | --- | +| `:: <绝对路径>` | `@ Path='<绝对路径>'` | 覆盖 Path(软件名条目只有一个 Slot 时可用) | +| `:- <模式>[,...]` | `@ Exclude='<模式>'` | 排除模式(`,` `;` 都当分隔符),**覆盖**名录里各 Slot 的 Exclude | +| `:+ <追加项>[,...]` | `@ Include='<追加项>'` | 追加项,语法 `<归档内相对路径>:<宿主机绝对路径>`,**覆盖**名录里的 Include | +| `:encrypt` | `@ Encrypt='$true'` | 该条目加密 | +| `:!encrypt` | `@ Encrypt='$false'` | 该条目不加密 | +| `@ ='<值>'` | — | 覆盖名录里的默认字段(目前支持 Path / Exclude / Include / Encrypt) | + +兼容的历史写法仍然认:`@encrypt` / `@!encrypt` / `@pathname` / `@root=<名>`(`root=` 已废弃,只会打印告警)。 + +```text +# 软件名:用名录里的 Slot 与排除;再把额外目录放进包内 Modules\ 位置 +Scoop :- GlobalPersist\steam\steamapps + +# 手写目录 + 排除 +C:\Programs\MiFlash :- MiFlash\logs\ + +# 追加映射:把宿主机的 D:\extra\ps-modules 放到包内 Modules\ 下 +PowerShell :+ Modules:D:\extra\ps-modules + +# 覆盖加密(名录里默认加密时特别有用) +PowerShell @ Encrypt='$false' +WindowsPowerShell :!encrypt +``` + +### 模式(排除 / 追加)怎么写 + +模式匹配的是**归档内的相对路径**,而且**相对本 Slot 的根**(也就是 `\` 里面那一层): + +| 形态 | 展开成 | 说明 | +| --- | --- | --- | +| `<相对路径>` | `-x!\<相对路径>` | 锚定在归档根下这一份 | +| `!<通配>` | `-xr!<通配>` | **任意层级**按组件名匹配,`*` `?` 是 7z 通配符(不是正则) | +| `!re:<正则>` | 若干 `-x!<完整路径>` | **正则**:脚本自己遍历源目录把命中的路径展开成精确排除项 | +| `GlobalPersist\steam` | `-x!GlobalPersist\steam` | 第一段是 Slot 名时,只作用在那一个 Slot 上 | + +- `!*Cache` 一次覆盖 `Cache` / `Code Cache` / `GPUCache` / `DaemonCache` 等一批以 Cache 结尾的组件名。 +- 模式里**不要自己写引号**;模式里的空格会被自动转成 `?`(7z 的 `-x!` 不接受带空格的模式)。 +- 想把 `.log` 之类按正则排除就写 `!re:.*\.log$`;命中的目录会整棵剪掉,命中数超过 300 条会明确报错 + (命令行长度有限),这时应该改用更粗的通配模式。 +- 不带 `!` 的模式是**锚定**的:Edge 的 `OneAuth\WebView2\EBWebView\` 里还有一整套自己的 + `Crashpad` / `BrowserMetrics` / `ProvenanceData` / `optimization_guide`,锚定模式碰不到它们, + 这些可再生的东西一律用 `!<组件名>` 才会在任意层级命中。 + +实测效果(本机真实 Edge 配置,源 4619.9 MB): + +| Edge 归档 | 大小 | 条目数 | +| --- | --- | --- | +| 排除规则生效前 | 1781 MB | 27961 | +| 排除规则生效后 | 72 MB | 2294 | + +书签、密码(`Login Data`)、`Cookies`、偏好、历史、`IndexedDB`、`Local Storage` 全部保留; +缓存、组件缓存、Service Worker、扩展本体、遥测与优化数据全部排除。 + +### 行尾可以写"为什么" + +行尾的 ` # 说明` 会被解析出来,运行时和 Slot 介绍一起打印: + +```text +Edge :- !*Cache,!Crashpad # 缓存与崩溃转储都可再生,不进归档 +``` + +`#` 必须前面有空白才算注释,所以路径里的 `C:\a#b` 不受影响。 + +### 运行时会把每个条目的归档项逐条介绍出来 + +说明来自 `SoftwareCatalog.psd1` 的 Slot,追加/排除的**来源**来自清单: + +```text +[INFO] 条目:Scoop +[INFO] 归档:Scoop.7z;方向:备份 + 恢复;加密:是 +[INFO] 归档项 1/3:DefaultConfig <- C:\Users\Shuery\.config\scoop +[INFO] 来源:软件名录;存在,会打包;目录 +[INFO] 介绍:Scoop 配置。 +[INFO] 归档项 2/3:GlobalPersist <- C:\ProgramData\scoop\persist +[INFO] 来源:软件名录;存在,会打包;目录 +[INFO] 排除 1 条(来自清单的 :- / @ Exclude):GlobalPersist\steam\steamapps +[INFO] 排除 2 条(来自 BackupConfig.psd1 的 DefaultExcludes):!Thumbs.db、!desktop.ini +``` + +`Restore.ps1` 也会打印"哪一项还原到哪个目录、是文件还是目录、会新建还是覆盖"。 + +### 几个必须知道的约束 + +- **归档名重复会直接报错。** 归档名 = 软件名字,所以同一个软件写两遍会让两个条目互相覆盖。 +- **同一软件里的 Slot 名不能重复**,追加项的归档内路径也不能和 Slot 撞;脚本会在打包前明确报错。 +- **一个条目挂多个归档项时,每一项只还原自己那棵子树**,不会把兄弟项也复制到别的父目录下。 +- **`::` 现在是"覆盖 Path"**,不再是 `:-` 的历史别名;排除一律写 `:-`。 diff --git a/docs/security-descriptor.md b/docs/security-descriptor.md new file mode 100644 index 0000000..117b553 --- /dev/null +++ b/docs/security-descriptor.md @@ -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` 一起搬**。 diff --git a/docs/software-catalog.md b/docs/software-catalog.md new file mode 100644 index 0000000..38eba57 --- /dev/null +++ b/docs/software-catalog.md @@ -0,0 +1,71 @@ +# SoftwareCatalog.psd1 —— 软件名 → Slot 组 + +> 本文从 [README.md](../README.md) 拆出,单独成篇是为了让它能被单独引用与单独评审。 + +```powershell +@{ + Edge = @{ + # Slot = 归档内的一层目录:内容进 DefaultData\,恢复时整棵回到这个 Path + DefaultData = @{ + Path = '%LocalAppData%\Microsoft\Edge\User Data' + Exclude = '!*Cache,!Crashpad,Default\Extensions,Default\Service Worker' + Description = 'Edge 用户数据:书签/密码/偏好/历史,以及站点数据' + } + } + + Scoop = @{ + # 一个软件可以有多个 Slot;两个都叫 persist 的目录因此不再冲突 + DefaultConfig = @{ + Path = '%UserProfile%\.config\scoop' + Encrypt = $true + Description = 'scoop 自身的配置' + } + UserPersist = @{ + Path = '$(if ($env:SCOOP) { $env:SCOOP } else { Join-Path $env:USERPROFILE "scoop" })\persist' + Encrypt = $true + Description = 'scoop 各应用的持久化数据' + } + } + + WindowsTerminal = @{ + # Path 指向文件时,归档里就是一个名为 DefaultData 的文件(没有扩展名) + DefaultData = @{ + Path = '%LocalAppData%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json' + Encrypt = $true + Description = 'Windows Terminal 的设置文件' + } + } +} +``` + +字段: + +| 字段 | 说明 | +| --- | --- | +| Slot 名 | **归档内的一层目录**。内容进 `\`;Path 是文件时就是名为 `` 的文件。同一软件里不能重名 | +| `Path` | 宿主机上的绝对路径。支持 `%变量%` 与 `$( ... )` 子表达式 | +| `Exclude` | 排除模式,相对本 Slot 的根,逗号分隔。`!` 打头 = 任意层级(7z 通配符),`!re:<正则>` = 正则 | +| `Include` | 追加项,`<归档内相对路径>:<宿主机绝对路径>`,逗号分隔 | +| `Encrypt` | 该归档是否加密,默认 `$false`。同一软件里若各 Slot 不一致,整个归档按**加密**处理 | +| `Description` | 这个 Slot 是干什么的;运行时逐条打印 | + +要点: + +- **`Path` 支持 `$( ... )`**:`$(if ($env:SCOOP) { $env:SCOOP } else { Join-Path $env:USERPROFILE "scoop" })` + 会按 PowerShell 求值(求值结果会缓存,不会每个条目重复起进程)。 + 这类写法用了 `+` 拼接字符串时,`Import-PowerShellDataFile` 会拒绝,脚本会自动改用 + PowerShell 求值——名录与配置是仓库里的本地文件,和脚本同级,信任级别相同。 +- **目录当前不存在也不会被丢掉**:备份时跳过并记 `missing-source`,但恢复时仍然知道 + "这块内容原本该回到哪个位置",这正是恢复要用的。 +- **前缀补全**:写 `D:\Programs\legendary`,实际目录是 `legendary_2.0.4` 时会自动匹配 + (只认 `<名>_*` 与 `<名>-*`)。一个 Slot 只能对应一个目录,补全出多个会明确报错并让你拆 Slot。 +- **一个软件里不能有两个同名 Slot**,否则归档内会混成一棵树;脚本会明确报错。 + +**分文件维护**:用 `Includes` 引入其它名录文件(路径相对本文件): + +```powershell +@{ + Includes = @('SoftwareCatalog.games.psd1') + ... +} +```