Files
BakNRet/README.md
T
Shuery f4729baca7 docs: 重写 README(徽章 / 目录 / Mermaid 架构图 / GitHub 提示块)
结构改成开源社区通行的形态:徽章区(PowerShell 双版本、Windows、7-Zip、零运行时依赖、
89 个对外函数、Pester 条数、UTF-8 BOM)、目录、带 emoji 的章节标题、13 处
> [!NOTE] / [!TIP] / [!WARNING] / [!CAUTION] 提示块,以及 3 张 Mermaid 图
(备份数据流 / 恢复数据流 / 产物布局,含"两个都叫 persist 的目录为何不冲突")。

3 张图不是"看着像能渲染"就交:用 headless Edge + CDP 在真实浏览器里以 Mermaid 11 渲染
验证过(能解析 != 能读,所以看的是渲染结果),截图留在 .scratch/_verify/mermaid.png。

顺带修正两处事实错误 —— 都是本次 CI/CD 流水线的文档核对暴露出来的:
  * 零依赖套件条数 111 -> 128(实跑 Run-Tests.ps1 得到)
  * Pester 条数 183 -> 192(本轮补测后)

校验:Prettier 3.9.9 通过;markdownlint 只剩 10 条 MD051,那是假阳性 —— markdownlint
的锚点生成器不做 emoji 剥离,而 GitHub 会(`## 🚀 快速开始` -> `#-快速开始`)。用独立
脚本按 GitHub 规则复算,63 个标题锚点全部可解析,故保留 emoji 标题。外部链接 11/11 返回
200,内部相对链接全部存在。
2026-10-02 01:16:39 +08:00

65 KiB
Raw Blame History

BakNRet

把 BackupList.txt 里列出的软件与目录用 7-Zip 打包进 Backups/,并且能用 Restore-Data.ps1 原样恢复的 Windows 备份工具。

Note

清单里直接写软件名(如 Edge)即可,软件名到真实路径的映射维护在 SoftwareCatalog.psd1 里。 一个软件一个归档,归档名就是软件名;归档内按名录里的 Slot 分层,所以同一个软件里两个都叫 persist 的目录不会再撞在一起。

PowerShell 5.1 | 7.x Platform: Windows 7-Zip: required Runtime deps: 0 Public functions: 89 Pester: 183 passed Encoding: UTF--8 with BOM License: none

✨ 为什么是它

  • 🧩 清单里直接写软件名(如 Edge),目录映射维护在 SoftwareCatalog.psd1 里。
  • 📦 一个软件一个归档:归档名 = 软件名(Edge.7z),归档内按名录里的 Slot 分层 (<Slot>\<该路径的内容>),所以同一个软件里两个都叫 persist 的目录不会再撞在一起。
  • 🔀 清单行首 + = 仅备份、- = 仅恢复;两条路径共用同一份清单。
  • 🎯 排除 / 追加 / 加密都能写在 SoftwareCatalog.psd1 的 Slot 上,清单行里可以按条目覆盖。
  • 🪶 只依赖 PowerShell(5.1 或 7.x)与 7-Zip —— 运行备份/恢复不需要任何模块 (只有跑 Pester 测试才需要 Pester 5)。
  • ✅ 每个归档写完后做 7z t 内容校验,先写临时文件、校验通过再原子替换。
  • 📋 每次运行产出可核对的 Backups/manifest.json 与 logs/*.log。
  • 🔐 归档之外还保存 NTFS 安全描述符(属主 / 属组 / DACL):每个归档旁边一份 <归档名>.acl.json,恢复时按它回放。这是「恢复之后原程序还能不能读写」的关键 (C:\ProgramData 下那些靠 CREATOR OWNER 授权的目录,见安全描述符一节)。
  • 🚦 退出码可靠:有失败就返回 1,计划任务能正确判断成败。
  • 🧹 备份结束做孤儿归档审计:磁盘上有、但没有任何清单条目指向的归档会被点名(它们恢复不到,别误删)。
  • 🛡️ 恢复支持 -WhatIf / -DryRun / -VerifyOnly / -Only / -Skip;其中三种「只看不写」的模式 (-WhatIf / -DryRun / -VerifyOnly)一个字节都不写。
  • 📈 动手之前先预估本次所需空间并直接判断目标卷够不够(不够只告警、不中断)。

📖 目录

🚀 快速开始

# 1. 先试运行:只打印计划,不写任何文件
.\Backup-Data.ps1 -DryRun

# 2. 正式备份
.\Backup-Data.ps1

# 3. 强制重打(忽略「源未更新」判断)
.\Backup-Data.ps1 -Force

# 3b. 确认可以接受「有文件被占用而没打进归档」时,允许覆盖完整归档
.\Backup-Data.ps1 -Force -AcceptWarnings

# 4. 只备份 / 只恢复某几项(通配符匹配清单条目或归档名)
.\Backup-Data.ps1  -Only 'Edge','OpenSSH'
.\Restore-Data.ps1 -Only 'Edge' -Force

# 5. 恢复前先看计划(恢复会覆盖真实目录,务必先看一眼)
.\Restore-Data.ps1 -DryRun

# 6. 只校验所有归档完整性,不解压(只读,安全)
.\Restore-Data.ps1 -VerifyOnly

Tip

全新上手推荐按这个顺序走:-DryRun 看计划 → -Only 先备份一项 → -VerifyOnly 校验 → 最后再放开整份清单。

🔧 环境要求

项目 要求
操作系统 Windows(用到 junction 与 NTFS 安全描述符)
PowerShell Windows PowerShell 5.1 或 PowerShell 7.x(两套都验过)
压缩工具 7-Zip(7z.exe,装在默认路径或塞进 PATH)
测试依赖 只有跑 Pester 套件才需要 Pester 5.0+
权限 备份不需要提权;恢复安全描述符需要管理员(或 SYSTEM)

📁 文件说明

路径 作用
SoftwareCatalog.psd1 软件名 → Slot 组的映射:每个 Slot 是一个目录/文件,以及它的排除、追加、加密、说明
BackupList.txt 备份 / 恢复共用的清单,唯一的「要处理什么」来源
BackupConfig.psd1 目录、空间阈值、校验、加密等配置
Manage-Backup.ps1 主入口:不带参数进 TUI 菜单;带 -Action Backup|Restore|Config 直接做该动作(配 -Quiet 走无头)
Backup-Data.ps1 / Restore-Data.ps1 备份 / 恢复动作本身(无头,可在计划任务里直接调)
Edit-Config.ps1 配置管理:清单 / 设置 / 名录三个界面(见交互界面)
Backup.ps1 / Restore.ps1 旧名字,转发用的垫片:内部实现已改名为上面两个,这层只留一轮,删它的时机是大家都改用新名字之后
BakNRet/ 模块:BakNRet.psd1(清单,FunctionsToExport 是显式白名单)+ BakNRet.psm1(薄加载器,点源顺序只在这里出现一次)+ Public/(89 个对外函数,一函数一文件)+ Private/(内部函数与模块级状态)
test.ps1 唯一验收入口:Encode + Parse + Unit + Smoke + E2E,在 7 与 5.1 上各跑一遍
PSScriptAnalyzerSettings.psd1 静态分析配置(三条有意排除与 160 字符行长,理由见 ADR-0008)
CONTEXT.md 术语表(本项目里每个概念只有一个叫法)
CHANGELOG.md 变更日志
docs/adr/ 决策记录(13 条:为什么这么设计、拒绝了什么)
docs/*.md 主题文档:软件名录、清单语法、归档布局、安全描述符(从本文件拆出,便于单独引用与评审)
tools/Build-BakNRetModule.ps1 把模块的多个源文件按加载器顺序合回单文件(发布形态、代码签名时需要)
tools/Invoke-Analyzer.ps1 静态分析门禁(默认规则 + 格式规则)
tools/Register-BackupTask.ps1 注册 / 移除计划任务
tools/Rename-Archives.ps1 把归档名对齐到当前清单规则(默认试运行)
tools/Install-TestDependencies.ps1 把 Pester 与 PSScriptAnalyzer 装到仓库内的 .tools/(不动机器上的全局模块)
tools/lab/ Hyper-V 干净系统实验台(见在虚拟机里验证)
Backups/ 归档与 manifest.json(已 gitignore)
logs/ 每次运行的日志(已 gitignore)
tests/ 测试:Pester 套件(*.Tests.ps1)、零依赖套件、端到端验收、真实归档恢复演练

🤖 软件名录:软件名 → Slot 组

@{
    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 名 归档内的一层目录。内容进 <Slot>\;Path 是文件时就是名为 <Slot> 的文件。同一软件里不能重名
Path 宿主机上的绝对路径。支持 %变量% 与 $( ... ) 子表达式
Exclude 排除模式,相对本 Slot 的根,逗号分隔。! 打头 = 任意层级(7z 通配符),!re:<正则> = 正则
Include 追加项,<归档内相对路径>:<宿主机绝对路径>,逗号分隔
Encrypt 该归档是否加密,默认 $false。同一软件里若各 Slot 不一致,整个归档按加密处理
Description 这个 Slot 是干什么的;运行时逐条打印

Important

Path 支持 $( ... ):会按 PowerShell 求值(求值结果会缓存,不会每个条目重复起进程)。 这类写法用了 + 拼接字符串时,Import-PowerShellDataFile 会拒绝,脚本会自动改用 PowerShell 求值 —— 名录与配置是仓库里的本地文件,和脚本同级,信任级别相同。

要点:

  • 目录当前不存在也不会被丢掉:备份时跳过并记 missing-source,但恢复时仍然知道「这块内容原本该回到哪个位置」,这正是恢复要用的。
  • 前缀补全:写 D:\Programs\legendary,实际目录是 legendary_2.0.4 时会自动匹配(只认 <名>_* 与 <名>-*)。一个 Slot 只能对应一个目录,补全出多个会明确报错并让你拆 Slot。
  • 一个软件里不能有两个同名 Slot,否则归档内会混成一棵树;脚本会明确报错。

分文件维护:用 Includes 引入其它名录文件(路径相对本文件):

@{
    Includes = @('SoftwareCatalog.games.psd1')
    ...
}

Note

完整说明见 software-catalog.md。

📝 清单语法

[+|-] <软件名 或 绝对路径> [ :: <绝对路径> ] [ :- <模式>[,<模式>...] ] [ :+ <追加项>[,<追加项>...] ]
                            [ :encrypt | :!encrypt ] [ @ <Key>='<值>' ] [ # 说明 ]

修饰符必须是独立的、前后带空白的记号,所以路径里出现的 :-、C:\a#b 之类不会被误切。

# 软件名:用名录里的 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 介绍一起打印
Edge :- !*Cache,!Crashpad  # 缓存与崩溃转储都可再生,不进归档
写法 说明
Edge 软件名:去 SoftwareCatalog.psd1 查 Slot 组,归档名 = 软件名
C:\Programs\MiFlash 手写路径:含 \ / 或 % 就按路径处理,归档名 = <末级名>_from_<上级路径用 + 连接>
Edge @pathname 软件名 + 强制用路径命名(想换到名录体系但暂时不想改归档名时用)
+ 仅备份,不恢复(Restore-Data.ps1 会跳过它;归档名照旧算「有主」的,不会被报成孤儿)
- 仅恢复,不备份(Backup-Data.ps1 会跳过它;适合放在别处、必要时才还原的目录)

排除模式怎么写

模式匹配的是归档内的相对路径,而且相对本 Slot 的根(也就是 <Slot>\ 里面那一层):

形态 展开成 说明
<相对路径> -x!<Slot>\<相对路径> 锚定在归档根下这一份
!<通配> -xr!<通配> 任意层级按组件名匹配,* ? 是 7z 通配符(不是正则)
!re:<正则> 若干 -x!<完整路径> 正则:脚本自己遍历源目录把命中的路径展开成精确排除项
GlobalPersist\steam -x!GlobalPersist\steam 第一段是 Slot 名时,只作用在那一个 Slot 上

Warning

  • !*Cache 一次覆盖 Cache / Code Cache / GPUCache / DaemonCache 等一批以 Cache 结尾的组件名。
  • 模式里不要自己写引号;模式里的空格会被自动转成 ?(7z 的 -x! 不接受带空格的模式)。
  • 想按正则排除 .log 之类就写 !re:.*\.log$;命中的目录会整棵剪掉,命中数超过 300 条会明确报错(命令行长度有限),这时应改用更粗的通配模式。

实测效果(本机真实 Edge 配置,源 4619.9 MB):

Edge 归档 大小 条目数
排除规则生效前 1781 MB 27961
排除规则生效后 72 MB 2294

书签、密码(Login Data)、Cookies、偏好、历史、IndexedDB、Local Storage 全部保留; 缓存、组件缓存、Service Worker、扩展本体、遥测与优化数据全部排除。

{
  "title": "Edge 归档:排除规则的效果",
  "gap": 12,
  "items": [
    {
      "type": "chart",
      "kind": "bars",
      "data": [
        { "label": "排除生效前", "value": 1781 },
        { "label": "排除生效后", "value": 72 }
      ]
    },
    {
      "type": "text",
      "content": "源 4619.9 MB → 归档 1781 MB → **72 MB**;条目数 27961 → 2294。保留书签 / 密码 / Cookies / 偏好 / 历史,排除缓存、Service Worker、扩展本体与遥测。"
    }
  ]
}

🗜️ 归档布局、命名与迁移

包内长什么样

条目类型 归档内部
软件名 + Slot 目录 <Slot>\<该 Path 的内容>
软件名 + Slot 文件 一个名为 <Slot> 的文件(没有扩展名,恢复时还原成 Path 里的原名)
手写路径(目录) <路径末级名>\...(与历史归档一致)
手写路径(文件) 一个名为 <路径末级名> 的文件
:+ / Include 追加项 你写的那个 <归档内相对路径>(目录就是目录,文件就是那个文件)

7z 没有「入库时改名」的能力,所以打包前会建一个暂存目录:目录项用 junction、文件项用硬链接 (不可用时退回复制)按归档内的名字挂进去,打完立刻拆掉。建不出连接点时会明确报错, 不会悄悄换成另一种布局 —— 布局一变恢复就对不上了。

归档名

条目类型 归档名
软件名 <软件名>.7z
字面路径 <末级名>_from_<上级路径用 + 连接>.7z(: 归一化成 _)
软件名 + @pathname 同字面路径

Note

:: / @ Path= 只改从哪儿读,不改归档名:软件名条目仍然叫 <软件名>.7z。 想换归档名就用 @pathname,或者干脆把条目写成绝对路径。

从旧版迁移(重要)

  1. 包内布局变了。 重构前生成的归档,包内顶层是源目录名;现在软件名条目多了一层 Slot。 Restore-Data.ps1 会识别这种情况(归档里没有该 Slot 时打印告警并按旧布局解), 所以旧归档仍然恢复得出来;但要让包内结构统一,跑一次 .\Backup-Data.ps1 -Force 重打即可 (-Force 会忽略「源未更新」判断)。

  2. 手写路径条目的归档名可能变了。 清单里把原来的软件名改成绝对路径之后,归档名会从 <软件名> 变成 <末级名>_from_<...>。用重命名工具对齐(默认试运行、逐份大小校验、 重建 manifest,只改名不搬数据):

    .\tools\Rename-Archives.ps1            # 先看计划
    .\tools\Rename-Archives.ps1 -Apply     # 确认后执行
    
  3. 名录里的 Encrypt 现在生效。 如果某个 Slot 写了 Encrypt = $true(或清单里写了 :encrypt),但运行时取不到口令,该条目会明确失败,绝不会退化成明文归档。 先准备好 $env:BAKNRET_PASSWORD 或用 -KeyFile 指定密码文件再跑。

  4. 孤儿归档审计会在每次备份后点名「磁盘上有、但清单里没有任何条目指向」的归档 (旧名字没迁移、条目被删掉或改名都会这样)。确认新归档校验通过之后再删旧文件。

Note

完整说明见 archive-layout.md 与 backup-list-syntax.md。

🔐 安全描述符(属主 / ACL)

问题:归档格式装不下 NTFS 安全描述符 —— 7-Zip 的 -sni(Store NT security information) 官方文档写明「当前版本只能写进 WIM 归档」,.7z 里一个字节的 ACL 都没有。于是「备份 → 恢复」 之后,每个对象的安全描述符都是新建对象的默认值:属主是跑恢复脚本的那个进程,DACL 是从 目标父目录继承来的那一套。

为什么这对 C:\ProgramData 是致命的:那里的目录 ACL 里有

(A;OICIIO;GA;;;CO)        CREATOR OWNER + inherit-only + GENERIC_ALL

CREATOR OWNER(S-1-3-0)不是账户,是访问检查时才替换的占位符 —— 替换成「被检查对象的 属主」。所以这句话的真实含义是「谁创建的东西谁有全权」。只回放 ACE 文本、不恢复属主,等于把里面的 「谁」换成了跑恢复脚本的账户,原程序(服务账户 / 专用用户)反而没了读写权限。

真机实测(tools\lab\Lab.ps1 acl-test):

原属主       = 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,恢复时跳过它并告警 —— 而不是当成「这个对象没有特殊权限」。
Security = @{
    Mode        = 'Full'     # Off | Full | Smart | Roots
    IncludeSacl = $false     # 连审计规则(SACL)一起存取,需要 SeSecurityPrivilege
    SidMap      = @{}        # 跨机恢复的 SID 映射:@{ 'S-1-5-21-旧' = 'S-1-5-21-新' }
    FailOnError = $false     # sidecar 写不出来时,是否把该条目算作失败
}
Mode 采集范围 取舍
Full(默认) 每个对象都存 正确性优先,几万文件的树 sidecar 几 MB
Smart 只存「继承复现不出来」的对象(protected / 有显式 ACE / 属主属组与父目录不同 / 继承链已脱节) 判据偏保守,但终究是启发式,所以不是默认
Roots 只存每个归档项的根 最省,适合权限只在根上的场景
Off 完全不采集 恢复出来的就是新建对象的默认值

Restore-Data.ps1 另有 -SkipSecurity 可以只恢复文件内容。

Note

完整说明见 security-descriptor.md。

已知取舍(有意为之):

  • 归档旁边没有 acl.json 的旧归档照常恢复,只是打一行告警说明「属主/ACL 是默认值」。
  • 陈旧继承 ACE 会被「冻结」:如果某个对象的 DACL 里留着已经没有任何出处的继承 ACE (父目录改过权限、Windows 自己也不会再传播它),那它靠继承复现不出来,只能整套冻结成显式 ACE 并置 protected —— 这是唯一「既不丢 ACE、也不产生重复 ACE」的做法(实测:目标上原本 就留着那条陈旧 ACE,再补一条显式 ACE 会让同一条 ACE 出现两次)。代价是这个对象从此不跟随 父目录,而它本来就已经跟父目录脱节了。
  • ACL 只跟着归档旁边的 acl.json 走:搬归档时要把同名的 .acl.json 一起搬。

♻️ 恢复语义

  • 每一项只解出它自己那棵子树(<Slot> / <末级名>),不会把兄弟项也复制到别的父目录下。
  • 目录项:在目标的父目录下建一个指向目标目录的 junction,让 7z 直接写穿它落地(零拷贝), 解完立刻拆掉连接点。建不出连接点(父目录里已有同名实体、目标卷不支持等)时, 退回「先解到临时目录再逐项合并」—— 只慢不错。
  • 文件项:解到临时目录后把文件搬到 Path 指定的位置(恢复原名)。
  • 旧布局兜底:归档里没有该 Slot 时(重构前的归档)会打印告警,退回到旧布局 (把目标的末级名直接解到目标的父目录),与重构前的恢复语义一致。
  • 不做镜像同步:目标目录里多出来的文件不会被删除。想得到「完全等于归档」的目录,请先清空目标。
  • 行首 +(仅备份)的条目不恢复;行首 -(仅恢复)的条目照常恢复。
  • 目标目录比归档新时默认跳过,需要覆盖就加 -Force。
  • -WhatIf / -DryRun 只打印计划;-VerifyOnly 只跑 7z t。 这三种模式一个字节都不写(manifest.json 也不会被碰)。
  • 加密归档取不到口令时直接失败,不会让 7z 停在控制台等输入(在计划任务里那会静默挂起)。
  • 排除规则只在下一份归档里生效:已经生成的归档不会因为改了排除表而「变干净」。

📋 manifest.json

以归档基础名为键记录每个条目:

字段 含义
source 清单里的原始写法(软件名或路径)
resolvedSource 展开后的路径
roots 归档内真实的顶层条目名(就是 Slot 名 / 源目录名 / 追加项的归档内路径;只统计真实存在的项)。每次重新处理该条目时刷新
layouts 每个归档项的 { name, kind }(dir / file),恢复端在目标还不存在时靠它判断「该还原成目录还是文件」
catalog 名录里记录的路径(便于追溯软件名到底指向哪)
archive 归档文件名
action backed-up / skip-unchanged / missing-source / invalid-path / failed / planned
reason 跳过或失败的原因
exitCode / verified / warnings 压缩工具退出码、是否通过 7z t、当前在位归档是否有警告
attemptWarnings 本次尝试是否报了警告(与 warnings 区分:保留旧归档时前者为 true、后者仍为 false)
sourceFiles / sourceBytes / archiveBytes 源文件数、源大小、归档大小
lastSuccessAt / successCount / failCount / lastRestoreAt / encrypted 历史与安全标记

Restore-Data.ps1 优先用 manifest 定位归档,查不到才退回「从文件名反推路径」。 如果 BackupList.txt 丢了,Restore-Data.ps1 会优先用 manifest 里的 source 自动重建。

📈 备份前空间预估

每次备份在动手之前先按清单顺序模拟一遍,把「这次要写多少、盘够不够」直接打出来:

[INFO] ==== 备份前空间预估(只读)====
[INFO]   目标卷可用空间:7.37 GB
[INFO]   本次要重打 8 个条目(另有 7 个源未更新会跳过、9 个源不存在)
[INFO]   新归档合计约 2.44 GB;其中会替换掉的旧归档 1.89 GB
[INFO]     - Edge                   源  2,303.8 MB /  11354 文件   现有 1,781.3 MB   预估 2,303.8 MB
[INFO]     - MiFlash_Unlock         源    231.3 MB /    153 文件   现有    70.0 MB   预估    91.0 MB
[INFO]   ...
[INFO]   预计峰值新增占用:2.25 GB(全程净增量 0.55 GB)
[INFO]   结论:空间足够(预计用 2.25 GB / 可用 7.37 GB)
[INFO] ============================
  • 估算模型:临时归档写完时旧归档还在,那一刻占用「当前累计净增量 + 本次预估」, 原子替换之后本次净增量 = 预估 − 旧归档大小。峰值取整个过程的最大值。
  • 预估归档大小:有历史归档时取 $\min(\text{源大小},\ \text{旧归档} \times 1.3)$; 没有历史归档时按「完全不压缩」的悲观值估 —— 宁可报多不报少。
  • 结论只有两种:空间足够,或者「空间可能不够!预计需要 X GB,可用 Y GB,差 Z GB」。 不够时只告警、不中断 —— 真正放不下的条目会被逐条目守卫跳过;想稳妥就腾空间或加 -Only / -Skip 分批。
  • 这一步是只读的,不改任何文件;-DryRun 也照跑。

🪵 日志

logs/backup-<时间戳>.log / logs/restore-<时间戳>.log,与控制台内容一致。 压缩工具自身的实时输出直接进控制台,不进日志(见设计取舍)。

🛠️ 配置(BackupConfig.psd1)

@{
    BackupDir        = 'Backups'      # 相对路径按脚本所在目录解析
    LogDir           = 'logs'
    SnapshotDir      = 'Backups\snapshots'
    SoftwareCatalog  = 'SoftwareCatalog.psd1'
    MinFreeSpaceGB   = 5
    VerifyArchive    = $true          # 归档后跑 7z t
    ComputeHash      = $false         # 是否额外算 SHA256(大归档很慢)
    CompressionLevel = 9
    ToolOutput       = 'live'         # live | quiet
    Snapshot         = @{ Enabled = $false; KeepCount = 3; KeepDays = 30 }
    Encryption       = @{ Enabled = $false; PasswordFile = 'baknret.key'; EncryptHeaders = $true }
    DefaultExcludes  = @('!Thumbs.db', '!desktop.ini')
}

优先级:命令行参数 > BackupConfig.psd1 > 代码内置默认值,也可以用 -ConfigPath 指定其它配置文件。

🔑 加密与口令

默认关闭 —— 一旦开启而口令丢失,备份就再也解不开。

# 方式一:给某个 Slot 加密(私钥、浏览器数据这类最典型)
#   SoftwareCatalog.psd1:
#       OpenSSH = @{ DefaultData = @{ Path = '%UserProfile%\.ssh'; Encrypt = $true } }
#   或在 BackupList.txt 的条目上写:
#       Edge :encrypt
#       PowerShell @ Encrypt='$false'      # 反过来,关掉名录里的默认加密

# 方式二:全部加密,改配置
#   Encryption = @{ Enabled = $true; PasswordFile = 'D:\secret\baknret.key' }

# 口令来源(二者取其一)
$env:BAKNRET_PASSWORD = '...'      # 或
.\Backup-Data.ps1 -KeyFile 'D:\secret\baknret.key'   # 文件首行即口令

一个软件一个归档:名录里各 Slot 的 Encrypt 不一致时,整个归档按加密处理(宁可多加密, 不可漏加密),并打印告警。要求加密但取不到口令时,该条目会明确失败,绝不会退化成明文归档。 恢复加密归档时同理:取不到口令就直接失败,不会让 7z 停在控制台等待输入(在计划任务里那会静默挂起)。

Caution

7-Zip 只接受命令行口令,口令在本机进程列表里会短暂可见。这是 7z 本身的限制,请自行权衡。 另外:-Verbose / -Debug 打印命令行时,-p 参数会被换成占位符,口令不会落进日志。

口令放在哪里

口令文件的出厂默认值是仓库根的 baknret.key,靠 .gitignore 的 *.key 兜住「不被提交」。 这是一次取舍:留在仓库根最省事(口令与配置在一起,搬家时不会丢),代价是「不提交」这件事依赖 一个规则文件 —— 谁写了 git add -f、或把整个目录复制到别处再 git init,口令就会跟着走。

相对路径按仓库根解析,不按当前工作目录。 计划任务的工作目录通常是 C:\Windows\System32, 在那里 Test-Path baknret.key 为假 —— 如果按工作目录解析,加密条目会以「拿不到口令」失败, 而配置看上去毫无问题。

三种给它口令的方式(优先级见上一节):

# A. 环境变量(计划任务用这个最省事,也最不怕仓库被整体复制)
$env:BAKNRET_PASSWORD = '...'

# B. 配置文件里指到一个仓库外的文件 —— 想更稳就走这条
Encryption = @{ Enabled = $false; PasswordFile = (Join-Path $env:USERPROFILE '.baknret.key'); EncryptHeaders = $true }

# C. 单次指定
.\Backup-Data.ps1 -KeyFile (Join-Path $env:USERPROFILE '.baknret.key')

取不到口令时,加密条目明确失败,绝不退化成明文归档 —— 这条行为没有放宽。

Tip

从旧版本迁移:如果你的口令文件已经在仓库根(baknret.key),什么都不用做。 想改用仓库外的位置,把它移走后按上面 B 或 C 指过去,并先用 pwsh -File .\Restore-Data.ps1 -VerifyOnly -Only "WindowsTerminal" -KeyFile <新路径> 验一下 口令对不对(那份归档是加密的,口令不对会报错)。

⏰ 计划任务

.\tools\Register-BackupTask.ps1 -At '21:30' -DryRun   # 先看将要注册什么
.\tools\Register-BackupTask.ps1 -At '21:30'            # 注册
.\tools\Register-BackupTask.ps1 -Remove                # 移除

任务调用 Backup-Data.ps1,脚本自身写日志并按失败数返回退出码,所以「上次运行结果」在任务计划 程序里可读(Get-ScheduledTaskInfo -TaskName 'BakNRet Backup')。

🧪 测试

四套,按「需要多少依赖」分层:

套件 命令 需要什么 覆盖
Pester 套件(推荐) .\tests\Run-Pester.ps1 Pester 5.0+ 与 7z 192 项(含安全描述符套件):清单语法(方向 / :: / :- / :+ / :encrypt / @ Key='值' / 整行引号与记号边界)、Slot 结构名录、归档命名、排除翻译(-x! / -xr! / !re:)、Slot 前缀分配、暂存、manifest / 配置 / 名录,外加用子进程真正跑 Backup-Data.ps1 / Restore-Data.ps1 的端到端与回归
零依赖套件 .\tests\Run-Tests.ps1 只要 PowerShell + 7z 128 项:同样的单元面,适合没装 Pester 的机器
端到端验收 .\tests\Run-E2E.ps1 只要 PowerShell + 7z 36 项:备份 → 确认排除生效 → 删源 → 恢复 → 逐字节对拍,含 <Slot>\ 布局、文件 Slot、方向标记与旧布局回退
真实归档恢复演练 .\tests\Restore-Drill.ps1 只要 PowerShell + 7z 12 个真实归档:解到临时目录再和活源逐字节对拍(全程不碰真实目录)
安全描述符套件 .\tests\Run-Pester.ps1(内含 BakNRet.Security.Tests.ps1) Pester 5 + 7z 含在上面 192 项里:排除判定与 7z -x!/-xr! 语义对齐、SID 映射边界(前缀 SID 不被误伤)、采集与 sidecar 往返、回放(CREATOR OWNER + 孤儿 SID + protected 逐字节一致)、以及真的用子进程跑 Backup-Data.ps1/Restore-Data.ps1 做端到端

演练会把「源在备份之后变过」和「归档/解压有问题」分开:内容不一致时看活源文件的修改时间, 晚于归档时间就算「源变了」(只提示),不晚于归档时间却内容不同才算失败。真实机器上的归档常常是 几周前的,不这样区分就天天报假失败。

Pester 套件要求 5.0+。系统自带的是 3.4.0,没有 Should -Be,套件会直接语法错误, 所以 Run-Pester.ps1 会先查版本,查不到就以退出码 2 结束并打印安装命令。两种装法:

.\tools\Install-TestDependencies.ps1                  # 只装进仓库内的 .tools/(推荐,不动机器上的全局模块)
# 或者
Install-Module Pester -Scope CurrentUser -MinimumVersion 5.0.0

Run-Pester.ps1 优先使用 .tools/ 里的本地副本,其次是机器上已装的 5.x;.tools/ 已进 .gitignore。

Pester 套件里的端到端用例是用子进程跑 Backup-Data.ps1 / Restore-Data.ps1 的,原因有二: 两个脚本结尾都会 exit,同进程 & 调用会把 Pester 宿主一起带走;而且子进程给出的是真正的进程 退出码,正好独立验证「退出码取法」这条修复。

🔍 验收与静态分析

一条命令跑完全部验收层次,两个 PowerShell 版本各跑一遍:

.\test.ps1                       # Encode + Parse + Unit + Smoke + E2E
.\test.ps1 -Suite Parse          # 只跑一层
.\test.ps1 -Suite E2E -PSVersion 5.1
.\tests\Run-RealSmoke.ps1        # 拿真实清单与真实归档做只读冒烟
层次 内容 依赖
Encode 受管文件的 BOM / 行尾 / 制表符 —— 丢了 BOM 只在 5.1 上出错,所以这条必须单独检查 无
Parse 全仓 .ps1 / .psm1 / .psd1 在 5.1 与 7 上解析零错 无
Unit Pester 套件(单元面) Pester 5+
Smoke 零依赖套件(关键冒烟) PowerShell + 7z
E2E 真的调用 7z 打包 → 删源 → 恢复 → 逐字节对拍 7z
Drill 真实归档恢复演练(只读,要显式点名) 本机真实归档

Run-RealSmoke.ps1 刻意独立于上面几层:其它套件都在临时目录里自造夹具,跑得快、可重复; 它专门跑真实清单,用来挡住「夹具全绿、真实数据全废」。它检查四件事:方向标记全部被剥掉、 没有软件名退化成「名录里没有」、两个只读模式退出 0、manifest.json 的 SHA256 前后不变。

静态分析是独立门禁,不塞进上面几层(套件跑一次二十多秒,混进去会让「测试红了」这句话 失去分辨力):

.\tools\Invoke-Analyzer.ps1          # 默认规则 + 格式规则
.\tools\Invoke-Analyzer.ps1 -Quiet   # 只看按规则汇总

那 6 条格式规则(括号、缩进、空格、对齐、大小写)在 PSScriptAnalyzer 里默认是 Disabled —— 不带 -Settings 的 Invoke-ScriptAnalyzer -Severity Warning,Error 会静默漏掉全部排版问题。 三条有意排除与 160 字符行长上限的理由见 ADR-0008。

在 Hyper-V 虚拟机里验证

上面几层都在本机跑。真正值得单独跑一遍的是 tools/lab/:它把仓库同步进一台干净系统的 Hyper-V 虚拟机(gsudo pwsh -File .\tools\lab\Lab.ps1 ...),在那里跑完整流程。本机跑不到的 路径只有它能覆盖 —— 最典型的是安全描述符回放:需要把某个目录的属主改成 NT AUTHORITY\SYSTEM、再靠 CREATOR OWNER 的继承规则判断恢复后归谁,而这件事只有在真 VM 里 才敢做。

步骤 它验证什么 最近一次结果
Lab.ps1 test -Suite all 三套仓库测试在干净系统上能不能跑 Pester 183 / 零依赖 128 / 端到端 36,全部通过(这是该次 VM 实测的快照;随后套件增至 192 项)
Lab.ps1 backup 在 VM 内真跑 Backup-Data.ps1(沙盒清单 + 配置) 退出码 0
Lab.ps1 restore 用真实归档做恢复演练,逐字节对拍 通过 6 / 失败 0(含连接点场景 4/4)
Lab.ps1 acl-test 安全描述符:属主 / CREATOR OWNER / 安全指纹 全部通过 19 项(含负对照)

acl-test 里有一个负对照值得留意:只搬文件不回放安全描述符时,属主会变成「跑脚本的账户」 而不是原账户 —— 那条用例能证明这个演练分辨得出对错,而不是一路绿灯。

🖥️ 交互界面(TUI)

不带参数运行主入口就会进菜单:

.\Manage-Backup.ps1            # 主菜单:备份 / 恢复 / 配置
.\Edit-Config.ps1              # 配置菜单:清单 / 设置 / 名录
.\Edit-Config.ps1 -Target List # 直接进某个编辑器

三个编辑器改配置的方式是外科式改写:只动你改的那一行(注释、对齐、$( ) 表达式、 跨行拼接一字节不动),先校验再落盘,落盘前把原文件按时间戳复制到 logs\config-backups\。 所以改坏了随时能翻回去。

界面 改什么 说明
清单 BackupList.txt 改方向(both / backup / restore),保留你原来的写法风格(贴着写与留空格都原样)
设置 BackupConfig.psd1 单行标量设置;值跨行或本身是集合的(如 SidMap、DefaultExcludes)不列出,因为「改一行」对它们没有明确含义
名录 SoftwareCatalog.psd1 只有单行的 Encrypt 与 Description 可改;Path 常带动态表达式、Exclude 可能是跨行拼接,一律只读

无头与自动化

  • -Quiet:不画界面,把被调动作的输出捕获后原样转发出来(计划任务与管道用这个)。
  • -InputScript:用按键序列驱动界面,门禁就是靠它把「打开菜单 → 选一项 → 改一个值 → 保存」 整条流程跑通的。序列用尽而界面还在等输入时会报错退出,绝不退回去读真终端 —— 挂起比失败糟得多。
  • 只有真终端才画界面:检测到输出被重定向且没给按键序列时,明确报错并给退出码 2,不会卡住。

设计取舍(为什么零依赖、为什么不做全屏、为什么异常不改退出码)见 ADR-0010 ~ ADR-0013。

🏗️ 架构

BakNRet/ 是 PowerShell 模块:既能被 Import-Module 直接用,也是四个入口脚本背后的实现层。 BakNRet.psm1 是薄加载器 —— 点源顺序只在这里出现一次;BakNRet.psd1 的 FunctionsToExport 是显式白名单,名字少写一个对应函数就不会被导出(宁可导入方报「找不到命令」,也不要静默少一个函数)。

一次备份的数据流

flowchart TD
    subgraph repo["仓库(真相)"]
        BL["BackupList.txt<br/>要处理什么"]
        SC["SoftwareCatalog.psd1<br/>软件名 → Slot 组"]
        BC["BackupConfig.psd1<br/>目录 / 校验 / 加密"]
    end

    BL --> P["解析清单<br/>方向 · 排除 · 追加 · 覆盖"]
    SC --> P
    BC --> P
    P --> PLAN["打印计划 + 空间预估<br/>(只读,-DryRun 到此为止)"]
    PLAN --> STAGE

    subgraph stage["暂存目录(%TEMP%)"]
        STAGE["按归档内的名字挂载<br/>目录走 junction · 文件走硬链接"]
    end

    STAGE --> SEVENZIP["7z 打包(每次从零,不用更新模式)"]
    SEVENZIP --> TMP["写临时归档 .tmp"]
    TMP --> VERIFY{"7z t 校验通过?"}
    VERIFY -- 否 --> DISCARD["丢弃临时文件<br/>旧归档原封不动"]
    VERIFY -- 是 --> SWAP["原子替换<br/>File.Replace"]
    SWAP --> DONE["归档 + manifest.json<br/>+ 同名 .acl.json"]
    STAGE -. 打完立刻拆掉 .-> DONE

一次恢复的数据流

flowchart TD
    M["BackupList.txt"] --> LOOKUP{"manifest.json 里有?"}
    LOOKUP -- 有 --> ARCH["按 manifest 定位归档"]
    LOOKUP -- 没有 --> FALLBACK["退回从文件名反推路径"]
    ARCH --> KIND{"条目是目录还是文件?"}
    FALLBACK --> KIND

    KIND -- 目录 --> JUNC["在目标父目录建 junction<br/>7z 直接写穿它(零拷贝)"]
    JUNC --> UNPACK["解出这一棵子树<br/>(没有该 Slot 时退回旧布局)"]
    UNPACK --> RMDIR["拆掉 junction"]
    KIND -- 文件 --> TMPDIR["解到临时目录<br/>再搬到 Path 指定的位置"]

    RMDIR --> ACL["按 &lt;归档名&gt;.acl.json 回放<br/>属主 / 属组 / DACL(自顶向下)"]
    TMPDIR --> ACL
    ACL --> REPORT["打印逐项结果<br/>按失败数 exit"]

产物布局

flowchart LR
    subgraph source["宿主机"]
        EDGE["Edge\\User Data"]
        SCOOP1["Scoop\\persist"]
        SCOOP2["ProgramData\\scoop\\persist"]
    end

    subgraph backupdir["Backups 目录"]
        A["Edge.7z"]
        B["Scoop.7z"]
        ACLS["同名 .acl.json<br/>属主 / ACL"]
        MAN["manifest.json<br/>登记簿"]
    end

    EDGE --> A
    SCOOP1 --> B
    SCOOP2 --> B
    B --- ACLS
    A --- ACLS
    MAN -.-> A
    MAN -.-> B

两个都叫 persist 的目录之所以不再冲突,是因为归档内多了一层 Slot:

Scoop.7z
├── DefaultConfig\        <- Slot 名,恢复时回到 %UserProfile%\.config\scoop
├── GlobalPersist\        <- Slot 名,恢复时回到 C:\ProgramData\scoop\persist
└── UserPersist\          <- Slot 名,恢复时回到 %UserProfile%\scoop\persist

仓库结构

BakNRet/
├── Manage-Backup.ps1          主入口(TUI 菜单 / -Action)
├── Backup-Data.ps1            备份动作(无头)
├── Restore-Data.ps1           恢复动作(无头)
├── Edit-Config.ps1            配置编辑器入口
├── Backup.ps1 / Restore.ps1   旧名字垫片(只留一轮)
├── BackupList.txt             清单:要处理什么
├── SoftwareCatalog.psd1       软件名 → Slot 组
├── BackupConfig.psd1          目录 / 阈值 / 校验 / 加密 / 安全描述符
├── test.ps1                   唯一验收入口(Encode+Parse+Unit+Smoke+E2E)
├── PSScriptAnalyzerSettings.psd1
├── CONTEXT.md                 术语表 ·  CHANGELOG.md 变更日志
├── BakNRet/                   模块
│   ├── BakNRet.psd1           模块清单(导出白名单)
│   ├── BakNRet.psm1           薄加载器(点源顺序唯一声明点)
│   ├── Public/                89 个对外函数,一函数一文件
│   └── Private/               内部映射/读取辅助 + State.ps1(模块状态)
├── docs/                      主题文档 + adr/(13 条决策记录)
├── tests/                     Pester · 零依赖 · 端到端 · 真实归档演练
├── tools/                     构建 · 分析门禁 · 计划任务 · 归档改名 · lab/
├── Backups/                   归档 + manifest.json(gitignore)
└── logs/                      运行日志 + config-backups/(gitignore)

Note

编排逻辑目前仍在 Backup-Data.ps1 / Restore-Data.ps1 里(Invoke-BackupItem 等), 把它们下沉进模块是已被记录、尚未实施的下一步:做完之后一份编排就能同时服务 TUI 与无头两条路, TUI 也不必再起子进程(见 ADR-0012)。

⚖️ 设计取舍(有意为之,不是遗漏)

  • 放弃 7z 的更新模式(u)。 7z 默认固实压缩,u 本来就要重压大部分数据,收益很小,却让 「排除规则改动」和「源里删掉的文件」永远进不了归档。
  • 包内用 Slot 分层,靠暂存目录改名。 7z 没有「入库时改名」的能力,所以打包前建一个暂存目录, 把每个归档项按包内名字挂进去(目录走 junction、文件走硬链接/复制),打完立刻拆掉。 代价是每份归档多一次 junction 开销;收益是一个软件可以有多个目录而不怕重名 (scoop 的用户 persist 与全局 persist 就属于这种),恢复时也能精确地「只解这一棵子树」。 建不出连接点时明确报错,不悄悄退化成另一种布局。顺带一提,7z 的 -spf 不是干这个的 (它是 use fully qualified file paths)。
  • 恢复用 junction 零拷贝落地。 目标父目录下建一个指向目标的 junction,让 7z 直接写穿它, 解完立刻拆掉;建不出来就退回「先解到临时目录再合并」。这样不必把大归档整体搬两遍。
  • 不捕获压缩工具的输出。 结构化记录交给日志与 manifest.json;捕获子进程 stdio 需要额外管道, 在受限环境里会直接失败。
  • 有警告(退出码 1)时不覆盖完整的归档。 被占用的文件会让 7z 返回 1,此时新归档是不完整的。 实测 Edge 运行时打包,118 个文件读不到,其中包含 Login Data(密码)、Cookies、History、 Web Data。所以在位归档完整时脚本保留它、报失败、退出码 1,确认可以接受再显式加 -AcceptWarnings。
  • 名录里的路径不存在时,恢复仍然可用。 源被删掉正是要恢复的场景,所以解析器照旧给出 Items, 备份端则据此跳过。
  • 源路径不存在只算「跳过」,不算失败。 会以 missing-source 记进 manifest。失败只统计真正打不开的条目。
  • @ Path= 覆盖只允许单 Slot 条目。 多 Slot 时「覆盖」根本没有唯一含义,直接报错比猜一个 Slot 好。
  • 旧归档用「旧布局兜底」而不是拒绝恢复。 重构前的归档包内没有 Slot 层,恢复时按 Slot 解会失败, 脚本捕获后按旧布局(目标的末级名)再试一次,并在日志里说清楚——旧备份仍然救得回来。

⚠️ 已知限制

  • 改软件名 / 改 Slot 名等于换归档结构。 改名后旧归档不会被自动迁移,用 tools/Rename-Archives.ps1 或手动改名,并注意 manifest 里会留下旧键;Slot 名变了则需要重打(-Force)。
  • 路径里本来就含 + 或 _from_ 时,仅靠文件名无法可靠反推路径,此时依赖 manifest.json。
  • -Snapshot 目前是「复制一份带时间戳的副本」,不做自动轮转清理(KeepCount / KeepDays 尚未实现)。
  • 加密归档的常规备份/恢复不依赖 RAR;RAR 与内置 ZIP 分支仅作降级,未做加密支持(ZIP 明确拒绝加密请求)。 内置 ZIP 分支也不支持排除规则(Compress-Archive 没有对应开关),只保证内容完整。
  • 暂存改名需要能建目录连接点(junction)。 暂存目录在 %TEMP%(NTFS 即可),目标源目录跨盘也没问题; 建不出连接点时该条目会明确失败,而不会静默换成别的布局。恢复时的 junction 建不出来会自动退回 「临时目录 + 合并」。
  • 一个 Slot 只能对应一个目录。 前缀补全命中多个候选(同一个父目录下既有 X 又有 X_后缀)时会报错 并让你拆成多个 Slot,而不是任选一个。
  • !re: 有量级上限。 正则命中的路径超过 300 条、或排除参数超过命令行安全长度时会明确失败; 这种场景应改用更粗的通配模式。
  • root=<名> 标记已废弃。 包内的一层目录现在由 Slot 决定;写了该标记只会打印告警。
  • 空间只做「预估 + 提示」,不做全局拦截。 备份前会打印预计峰值新增和「够不够」的结论; 不够时只告警不中断,真正放不下的条目交给逐条目守卫跳过。MinFreeSpaceGB 是告警阈值。 想稳妥跑完就先腾空间,或用 -Only / -Skip 分批。
  • 恢复安全描述符需要管理员(或 SYSTEM)。 非提权时属主写不进去(SeRestorePrivilege 不在令牌里), 脚本会退化到「只恢复 DACL」并明确告警 —— 那不是失败,但 CREATOR OWNER 会判给「当前属主」, 所以依赖它的程序可能仍然没权限。
  • acl.json 要跟归档一起搬。 它不在归档里(7z 装不下),改名 / 迁移归档时要用 tools\Rename-Archives.ps1 或手工把同名旁挂文件一起改。
  • 7z 会跟随 junction(不是存成链接,因为 -snl 只对 WIM/TAR 生效):所以 scoop 那种 apps\<app>\current 的连接点,备份时会把目标内容一并收进归档(体积翻倍),恢复后 current 变成真实目录。功能上仍然可用(current\bin\... 路径还在),但要心里有数。
  • 跨机恢复要配 Security.SidMap:本机不存在的 SID 写进 DACL 是安全的(那条 ACE 只是永不匹配), 但写进属主会让谁都没有合理所有权 —— 换域 / 换机时请给映射,或接受「属主未恢复」的告警。 服务账户(NT SERVICE\X)的 SID 是按名字算出来的,跨机一致。

🧬 编码与风格

约定 落地为 为什么
源码 UTF-8 with BOM .editorconfig 的 charset = utf-8-bom 没有 BOM 时 5.1 按 ANSI 解码源码:中文变乱码,全角字符会吃掉引号,整块语法失效(实测全仓 6/6 文件在 5.1 上解析失败)
行尾一律 LF .gitattributes 的 * text=auto eol=lf 本机 core.autocrlf = true 会制造「除了行尾什么都没改」的巨大伪 diff
缩进 4 空格、不用制表符、结尾留空行 .editorconfig + Encode 层门禁 这些东西只能在门禁里检查,靠人盯必漏
函数命名带 BakNRet 前缀 模块导出白名单 静态分析拓出 Write-Log 与本机某个已装模块重名,后果是导入两个模块时一方的命令被静默遮蔽
模块源码一函数一文件 BakNRet/{Public,Private} + 薄加载器 3152 行、66 个函数的 Common.psm1 已经改不动了;要发布形态就用 tools/Build-BakNRetModule.ps1 合回单文件

术语(归档、条目、方向、Slot、覆盖、归档项、旧布局、manifest、孤儿归档……)见 CONTEXT.md: 每个概念在本仓库里只有一个叫法,写作与命名都照那里的词来。

🤝 贡献

  1. 动手前先看约定:CONTEXT.md 的术语表、docs/adr/ 里相关的决策记录 —— 很多「奇怪」的写法背后有一条实测结论,别当成可以顺手清理的遗留。

  2. 改完跑门禁:

    .\test.ps1                    # Encode + Parse + Unit + Smoke + E2E,7 与 5.1 各一遍
    .\tools\Invoke-Analyzer.ps1   # 静态分析(默认规则 + 格式规则)
    
  3. 提改动时说明实测证据:这个仓库的习惯是「修了什么」配一条可复现的判据,而不是「看起来更规范了」。

  4. 别把 Backups/、logs/、.tools/ 或任何 *.key 提交进来(都已在 .gitignore 里)。

Important

提交前请确认没有把口令带进仓库:BackupConfig.psd1 的 PasswordFile 默认值为空, 口令应当来自 -Password / 环境变量 / 仓库外的文件 —— 改这一块时尤其要小心。

📄 许可证

本仓库目前没有 LICENSE 文件,因此尚未声明开源许可证 —— 按默认著作权保留处理。 如果打算公开分发,请补一份许可证(例如 MIT / Apache-2.0)并在这里指向它。

🙏 致谢

  • 7-Zip —— 归档与校验的实际执行者。
  • Pester —— 单元与集成测试框架。
  • PSScriptAnalyzer —— 静态分析与格式规则门禁。

变更记录见 CHANGELOG.md;决策记录见 docs/adr/;术语表见 CONTEXT.md。