Compare commits

..
53 Commits
Author SHA1 Message Date
Shuery 2cbcaaee48 chore(license): 补 Apache License 2.0
许可证正文**逐字采用** ASF 的 LICENSE-2.0.txt,只把 APPENDIX 里的版权占位行填成
"Copyright 2026 Shuery" —— 正文一个字节都没改。

为此先下载规范文本再打补丁,而不是凭记忆抄一遍:许可证抄错一个词就不再是那个许可证了。
校验方式是逐行对比本仓 LICENSE 与规范文本,结果只差版权那一行(规范文本 11358 字节、
SHA256 cfc7749b…;本仓 11337 字节,差额 21 字节正好等于两行文本的长度差)。

选 Apache-2.0 而非 MIT,主要差别在第 3 节的**明示专利授权**:贡献者授予专利许可,
而不只是版权许可。

同时:
  * README 的许可证章节从「尚未声明」改成实际条款摘要 + 徽章(License: Apache-2.0)
  * BakNRet.psd1 的 PrivateData.PSData 登记 LicenseUri 与 Copyright,Import-Module
    之后可直接读到许可信息
  * 不加 NOTICE:第 4(d) 节只在作品本身带 NOTICE 时才要求向下传递,而本仓库不分发第三方
    代码(Pester / PSScriptAnalyzer 只放在 .tools/ 供本地测试,既进 gitignore 也不进分发产物)
  * .scratch/ci-cd 的两份报告补了带日期的「后续更新」注记,关掉许可证合规的 L-1 与依赖
    安全扫描的 R-1,避免后来的人读到「主许可证未声明」这个已经过期的结论

验收:test.ps1 9/9 全绿(5.1 与 7);静态分析 80 条、0 Error(与改动前持平);
README 相对链接 0 失效、新增外部链接 2/2 返回 200;双宿主解析 psd1 零错、89 个导出不变。
2026-10-02 01:34:45 +08:00
Shuery 45bbd66815 docs(report): 新增 PROJECT_REPORT.md 与 CI/CD 流水线记录
PROJECT_REPORT.md:论文式项目报告(摘要 / 目录 / 7 章 / 参考文献 / 致谢 / 附录 A),
4 张 Mermaid 图同样经真实浏览器渲染验证。所有数字都与证据文件逐个核对过。

.scratch/ci-cd/:本次流水线的全部证据与可重跑脚本
  * 阶段报告:依赖安全扫描 / 许可证合规 / 阶段 0 静态分析 / 阶段 3 测试与性能
  * 证据:分析器原始输出(修复前 84 条、修复后 80 条)、Pester 详细输出、
    性能基线 JSON、黑盒用例结果(阶段 1 的 11 例与阶段 2 回归的 12 例)
  * 可重跑:blackbox-tests.ps1 / blackbox-regression.ps1 / perf-baseline.ps1

两条边界必须写在明处,不能含糊:

  * **VM 内验证只取到修复前的快照**(Pester 183 项全绿)。随后会话审批策略改为 never,
    gsudo 提权被自动拒绝(退出码 999),而 Hyper-V 与 PowerShell Direct 都需要管理员,
    于是修复后的三套件在 VM 内**没有跑成**。报告里明确标注了范围,没有把"宿主跑绿了"
    说成"VM 也跑绿了"。
  * **性能基线是首次建立**,无历史可比,故无退化可判;指标只在同机同宿主下对比,
    跨机比数字没有意义。

另外记一笔:静态分析的口径是"仓库自己的门禁"。剩余 80 条全是风格类(0 Error),且逐条
有依据 —— 行长 160 是配置里写明的有意偏离,PSPlaceCloseBrace 等集中在测试夹具字符串内
(改了会改变断言语义)。严格模式的"零容忍"在这里与仓库自身约定相抵,选择尊重仓库约定
并在报告里登记,而不是制造一个横跨 12 个文件的纯排版大 diff。
2026-10-02 01:17:40 +08:00
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
Shuery 7149ea691e fix(backup,restore): 显式指定的清单不存在时报失败,不再静默成功
传 -BackupListPath 指向一个不存在的文件时,原实现把它当成"首次运行"处理:备份端会在
那个位置凭空建一份模板,恢复端则"从备份内容生成清单",然后两者都 exit 0。

后果在计划任务里最明显:任务计划程序读到的是"上次运行结果 = 成功",而实际上一个条目
都没处理。这与本仓库已经修过的"27 条被静默跳过、退出码仍是 0"是同一类缺陷 —— 退出码
是自动化唯一能读到的信号,它必须对得上"到底干了什么"。

现在按 $PSBoundParameters.ContainsKey('BackupListPath') 把两条语义分开:

  * 显式指定了清单却不存在 -> 报 ERROR 并 exit 1。路径写错、或相对路径按了别的工作
    目录解析(计划任务的工作目录通常是 C:\Windows\System32),都是调用方的错误,
    不能伪装成"已经帮你建好模板了"。
  * 没指定(首次运行引导)-> 保持原行为:备份端建模板、恢复端从 manifest 生成清单,
    仍然 exit 0。这是"开箱即用",不是失败。

顺带补 2 处运算符前的空格(Backup.ps1 / Restore.ps1 的 PSUseConsistentWhitespace)。

测试:
  * 2 条成对回归用例(显式缺失报失败 / 首次运行仍建模板)。必须成对 —— 只测一条的话,
    "把所有缺失都改成 exit 1"这种错误实现也能骗过测试。
  * 7 条断言覆盖归档原子替换与路径解析基准。其中 3 条用 AST 判定,因为文本匹配会被
    函数自己的注释绊倒(注释里正当地提到了 $PSScriptRoot,解释为什么不许用)。

验收:test.ps1 9/9 全绿(5.1 与 7);Pester 183 -> 192,0 失败;静态分析 80 条、
0 Error(与改造前持平)。
2026-10-02 01:16:25 +08:00
Shuery d72fe63c02 docs: 跟上 TUI 与入口重组(README 入口名 + TUI 一节 + CHANGELOG + CONTEXT 模块与入口)
README 里 24 处过期入口名(13 处 Backup.ps1、11 处 Restore.ps1)全部更新为新名字 —— 这正是 ADR-0012 里垫片存在的理由:内部实现改名断了会当场报错,而 README 里的入口名过期是**静默没用**,所以垫片留一轮等文档跟上。现在文档跟上了,删垫片的时机也就到了。

README 新增「交互界面(TUI)」一节:怎么进三个编辑器、每个编辑器能改什么(以及为什么有些字段刻意不列 —— 值跨行或本身是集合时"改一行"没有明确含义)、-Quiet 与 -InputScript 的用途、以及"只有真终端才画界面,否则明确报错并给退出码 2"。入口表从"两个入口"扩成四个(主入口 / 动作 / 配置 / 垫片)。

CHANGELOG 增加「新增:交互界面与入口重组」一节,含三项编辑器共用的外科式改写保证与那条往返断言;并写明旧命令照旧可用。

CONTEXT.md 增加「模块与入口」一节:BakNRet/ 四层的分工与规矩(清单是显式白名单、加载器是唯一点源顺序声明处、Public 一个函数一个文件、Private 放内部实现与 State),以及四个入口脚本的职责与"编排尚未下沉"这个已知的下一步。

验收:test.ps1 9/9 全绿(5.1 与 7)。
2026-09-27 22:40:54 +08:00
Shuery 400f2ad9a5 docs(probe): 给 TUI 选型探针加索引(它们证明了什么、结论落在哪条 ADR)
探针保留作实测证据:ADR-0010~0013 的结论与数字在正文里,这些脚本是能复现它们的东西 —— 将来想确认"某个库现在还能不能用",重跑对应探针即可,不必重新摸索。

索引按探针标注了来源与结论去向,并写明重跑注意:多数探针需要真终端(重定向下会走退化分支);load-probe 与 settle-probe 依赖已删除的 _probe/load 与 _probe/nuget(当时一次 git add 误把从 NuGet 拉的包扫进提交,已删除并 gitignore),重跑前要先自己拉包;其余可直接跑。

同时说明这些是一次性脚本:不参与构建、不受 test.ps1 的 Encode/Parse 层管辖;真正的 TUI 实现在 BakNRet/Public/ 且跑在两个 PowerShell 版本上。
2026-09-27 22:25:39 +08:00
Shuery effc38fdd3 feat(tui): 名录编辑器循环 + 挂进 Edit-Config(第三轮第二块·完成)
装配:Get-BakNRetCatalogField → 菜单选字段 → Read-BakNRetLine → Set-BakNRetCatalogField → Save-BakNRetConfigFile(校验通过才原子替换 + 时间戳备份)。

菜单里**只列可编辑的字段**(单行的 Encrypt / Description):把只读的 Path / Exclude 也列出来再拒绝,会让每个只读项浪费用户一次回车。

校验用模块自己的 Import-BaknretDataFile:这份名录有 $( ) 动态表达式与跨行拼接,普通数据文件读取器读不了 —— 而正是它保证"改完整份文件还能被程序读回来"(值能读回来才算改成功)。定位用 (软件名, Slot, 字段名) 三元组,因为同一软件可以有多个 Slot。

Edit-Config 的三个界面(清单 / 设置 / 名录)至此全部完成。

判据 9 条:改一个值后只动一行、注释行数不变、改完能读回新值、留下时间戳备份且备份里是原文;取消时不写盘也不产生备份。

验收:test.ps1 9/9 全绿(5.1 与 7);真实清单只读冒烟 4/4。
2026-09-27 21:30:14 +08:00
Shuery 8c018533c9 feat(tui): 名录字段的读改核心(第三轮第一块)
先侦察事实(232 行、71 个 Slot 级字段):**可编辑面比预想的还窄,边界落在一条可判定的规则上** —— Encrypt 19 个全是单行(可编辑),Description 24 个单行 + 1 个跨行(Edge 那条多行说明,只读),Path / Exclude 一律只读(Scoop 三条带 $(if ($env:SCOOP_GLOBAL){...}) 表达式、Edge 的 Exclude 是跨行拼接)。

规则:**只有单行的 Encrypt / Description 可编辑** —— 与 ADR-0013 的"按能不能安全往返来定"一致:"改一行"对动态表达式与跨行拼接没有明确含义。

扫描时跳过块注释:文件头部模板里有 SoftWareName = @{ ... } 示例,纯文本扫描会把它当成一个软件(实测踩到)。定位靠 (软件名, Slot, 字段名) 三元组且要求唯一命中,命中 0 个或多个都抛错。

实测五个事实:认出 70 个字段、可编辑 43 个(19+24)、往返**逐字节相同 = True**、真改一个只动一行、Edge 的跨行 Description 正确地只读。

过程里两处自己的坑都被门禁逮住:插入的测试代码带过一行手滑垃圾(Parse 层当场报),以及函数文档注释里写了块注释的闭合符号 —— 它提前结束了那段注释,后半句变成代码,而它是**合法语法**所以解析层抓不到、跑到才炸(与"左边空的赋值"同族)。

验收:test.ps1 9/9 全绿(5.1 与 7)。
2026-09-27 21:25:36 +08:00
Shuery 4aa44e8965 feat(tui): 配置编辑器循环 + 挂进 Edit-Config(第二轮第三块·完成)
装配:Get-BakNRetConfigSetting(读)→ 菜单选设置 → Read-BakNRetLine(输入新值)→ Set-BakNRetConfigSetting(只改那一行)→ Save-BakNRetConfigFile(校验通过才原子替换 + 时间戳备份)。

输入行**从空开始**、当前值只作提示:预填当前值看着贴心,实际等于逼用户先删一遍(对 'Backups' 这种带引号的原文尤其别扭)。

校验用 Import-PowerShellDataFile:这份配置里没有动态表达式,能被它读回来是个**强校验** —— 比"语法能不能过"更强,它还能抓出"值是合法语法、但结构不对"。它只接受文件路径,所以先把候选文本写到临时文件再读。

Edit-Config 的 -Target Settings 与菜单里的"设置"都挂上了同一个编辑器;-Target Catalog 仍明确报"还没做(第三轮)"。

判据 9 条:改一个值后只动一行、注释行数不变、新值落盘、留下时间戳备份且备份里是原文;取消时不写盘也不产生备份。

验收:test.ps1 9/9 全绿(5.1 与 7)。
2026-09-27 21:10:15 +08:00
Shuery 92bbc995c4 feat(tui): 输入行控件(第二轮第二块)
TUI 里唯一缺的控件:读一行文本(字符追加 / Backspace 删除 / Enter 确认 / Esc 取消)。

为什么不用 Read-Host:TUI 的所有输入都走同一个 -Driver,门禁才能用 -InputScript 把"输入一个值"这一步也驱动起来 —— Read-Host 无法脚本驱动,会在门禁里挂住,而挂起比变红糟得多。

为什么 Esc 返回 $null 而不是空串:空串是"把值改成空"这个**合法意图**,取消是另一个意思,两者必须能区分,否则调用方会把"用户取消"当成"用户要清空它"。

方向键等非字符键一律忽略(在输入行里按方向键不该有副作用);字母按小写存(与键名归一化一致)。判据 8 条,全部按键序列驱动。

验收:test.ps1 9/9 全绿(5.1 与 7)。
2026-09-27 21:05:14 +08:00
Shuery 7733b1bf6d feat(tui): 配置标量的读改核心(第二轮第一块)
先侦察事实:BackupConfig.psd1 共 99 行 —— 52 行注释、18 行单行标量、3 处嵌套哈希、1 处数组、1 处行内空哈希(SidMap = @{})。所以"外科式改一个值"的边界很明确:**只认单行标量**。值跨行或本身是集合时,"改一行"这个动作没有明确含义 —— 那种就不该在界面上提供(如实显示为只读)。

路径用点号拼(Snapshot.Enabled):同名键在不同嵌套里会出现(Enabled 有两处),只用键名会把它们混成一个。非标量(行内哈希、数组)显式排除。读出来的是**含引号的原文** —— 原样写回才逐字节等价。

判据 12 条,核心是往返:**把每个标量设成它当前的值,文本必须逐字节相同**(含键名对齐与全部注释);真改一个时只有那一行不同、注释行数不变、改完还能读出新值;找不到路径或不是标量必须抛错而不是静默不动。

真实配置上的实测:认出 18 个标量(含 3 组嵌套的点号路径,排除了 SidMap 与 DefaultExcludes),**往返后逐字节相同 = True**。

过程里插入的测试代码带过一行手滑的垃圾(被 Parse 层当场抓住 —— 它是这个仓库最便宜的一道闸),以及一条期望值写错(忘了 Value 含引号)。

验收:test.ps1 9/9 全绿(5.1 与 7);真实清单只读冒烟 4/4。
2026-09-27 21:00:07 +08:00
Shuery c2bae35506 feat(tui): 主入口 Manage-Backup 与配置入口 Edit-Config(第一轮第六块·完成)
Manage-Backup.ps1:不带参数进 TUI 主菜单(备份 / 恢复 / 配置);带 -Action 直接做那个动作,可配 -Quiet 走无头。退出码原样传出(计划任务靠它);只有两种情况由它自己给码:进不了交互界面(2)与菜单里按 Esc(0)。

动作目前仍是**子进程**:一份编排、两个前端是终局目标(ADR-0012),但那要求先把编排从入口脚本下沉进模块。先让 TUI 能用起来,等下沉做完把子进程调用换成进程内直调,界面层不受影响。

-Quiet 与交互两条路的输出处理**故意不同**:-Quiet 时调用方要拿输出(管道 / 日志 / 计划任务),所以捕获并转发;交互时让子进程继承控制台,输出实时、顺序正确、还能接键盘。这套转发收进模块函数 Invoke-BakNRetEntryScript(垫片与主入口共用同一处逻辑)。

Edit-Config.ps1:把已做好的清单编辑器挂上(外科式改写 + 校验通过才落盘 + 时间戳备份);设置与名录两个界面明确报"还没做"并返回非零,而不是假装成功。

判据 7 条。过程中夹具又踩了"& 不传退出码"这个事实(我在垫片那层修过,却在测试夹具这一层又犯)—— 所以断言里改用 -File 调入口;输出检查走**文件重定向**而不是管道捕获(与 E2E 同一个理由:沙箱下给子进程建管道可能失败,写文件不会)。

验收:test.ps1 9/9 全绿(5.1 与 7);真实清单只读冒烟 4/4。
2026-09-27 20:41:39 +08:00
Shuery 4e3c0461c0 refactor: 入口改名(Backup/Restore -> Backup-Data/Restore-Data)+ 只留一轮的垫片
git mv 保留历史。旧名字留薄垫片:入口脚本是**外部接口**(README 二十多处引用、使用者的肌肉记忆、注册脚本里的路径),内部实现改名断了会当场报错,外部接口改名断了是静默没用 —— 后者对备份工具尤其不能接受(ADR-0012)。

垫片踩到四个坑,全部由门禁报出(E2E 与 Pester 集成用例本来就是通过子进程调这两个入口的,于是它们原封不动成了垫片的验收):① `& script.ps1` 里子脚本的 exit 不会把退出码传到父脚本的 $LASTEXITCODE —— 会把失败变成成功,而计划任务靠退出码判断成败;② 调用方传 -Verbose 时垫片里的 Import-Module 会多打一行加载信息,顶掉测试的输出断言;③ 不重定向时子进程的输出到不了调用方(父进程的 stdout 常常是管道,而 .NET 起的进程默认只继承控制台)—— 改成显式重定向 + 异步读转发(同步先读 stdout 再读 stderr 会在管道写满时死锁);④ **`[CmdletBinding()]` 会把 -Verbose 当通用参数绑走,它不会落进 $Rest,于是没被转发给子进程** —— 而测试正是靠 -Verbose 拿那条 VERBOSE 级日志的。现在显式把 -Verbose / -Debug 加进转发参数。

参数引号化用模块自己的 ConvertTo-BakNRetNativeArgumentString(5.1 上 ProcessStartInfo.ArgumentList 不存在)。tools\Register-BackupTask.ps1 已同步改指 Backup-Data.ps1(4 处)。

门禁新增一条机械检查:**源码里不得出现"左边空的赋值"** —— 它是"用双引号拼代码导致 $变量 被插值成空"那个坑的指纹(合法语法、Parse 层抓不到、只有跑到才炸,同一块代码里出现过五次)。垫片全部用单引号 here-string 生成,零插值。

验收:test.ps1 9/9 全绿(5.1 与 7);真实清单只读冒烟 4/4。
2026-09-27 19:51:24 +08:00
Shuery 5df40d3341 feat(tui): 清单编辑器循环(第一轮第五块·装配完成)
装配:选行 → 选新方向 → 逐行校验 → 校验后原子保存 + 时间戳备份。输入走 -Driver,所以门禁里能用 -InputScript @('Enter','Down','Enter') 走完整流程,断言看返回值与**文件内容**,不需要捕获控制台输出。

四条"不猜":取消时什么都不写也不产生备份;方向菜单**预选当前值**(不预选的话"连按两次回车"会把字段改成菜单第一项 —— 不是没改,是改错了);方向没变直接返回"没有变化";保存前逐行校验(每行都要能被 ConvertFrom-BackupListLine 解析)—— 这正是 TUI 编辑器比手改安全的地方。

这个函数我试了两次才落地,两次都栽在同一件事上:**用脚本做多行字符串替换时,换行被折成空格**,于是注释与赋值落进同一行、被整段注释掉(症状是"对象没有属性"/"流程莫名走取消分支")。所以这条现在当硬约束执行:多行代码一律整份写文件,不做插入+删除式的拼接。

判据 12 条,覆盖保存 / 取消 / 无变化三条路径,并逐条断言注释行与未编辑行一字不动、备份里存原文、取消时不新增备份。

验收:test.ps1 9/9 全绿(5.1 与 7)。
2026-09-27 16:40:38 +08:00
Shuery e1fe99671b feat(tui): 改方向(第一轮第五块·编辑动作)
清单上最常改的就是方向,而它**能在文本层精确完成**:加/去一个行首标记即可,不需要把整行拆成字段再拼回去。后者要在 Overrides / Flags / 引号 / 转义之间做逆向,任何一处不精确都会把用户的清单改花 —— 外科式改写的底线是"只动我要动的那一处"(ADR-0013)。

**保留用户的写法风格**:仓库的真实清单里同时存在贴着写(+WindowsTerminal)与留空格(+ WindowsTerminal)两种,统一成一种意味着顺带改动了本不该碰的行。这就是"改一个字段却多出几十行 diff"的来源。断言里两种风格都钉住了。

注释行没有方向可改,直接抛错:静默返回原样会制造"改了但没变"。

判据 12 条,其中往返那条最强:**把一行设成它当前的方向,必须原样返回**(含带注释与修饰符的行)。

验收:test.ps1 9/9 全绿(5.1 与 7)。
2026-09-27 16:25:38 +08:00
Shuery 59ec5a21d4 feat(tui): 校验后原子保存 + 时间戳备份(第一轮第五块·地基)
三个配置编辑器共用的这块地基(ADR-0013)。顺序是刻意的:① 先校验(调用方给的 -Validate 回调);② **只有校验通过才备份与写盘** —— 校验没过还留下备份会让人以为"动过了";③ 原子替换走 Write-BaknretAtomicText。返回结果对象而不是抛异常:TUI 要把错误显示出来让人继续改,而不是把界面炸掉("TUI 异常不改退出码"这条同样适用)。

给 Write-BaknretAtomicText 加了一个可选 -Encoding(不传时沿用原行为,现有调用点不受影响)。原因:它原先固定用日志编码(无 BOM),而三个配置文件都是带 BOM 的 —— 丢掉 BOM 会让 5.1 把整个文件按 GBK 读,中文全成乱码。这类"编码悄悄变了"的错不会报错,只会让文件在某一个 PowerShell 版本上读出来是乱码。

判据(断言 10 条):校验不过时不写盘、不产生备份、错误原文能带回去;校验通过后新内容落盘、备份里是**原文**、且写回**保留 UTF-8 BOM**(逐字节验 EF BB BF)。

验收:test.ps1 9/9 全绿(5.1 与 7);真实清单只读冒烟 4/4。
2026-09-27 16:22:21 +08:00
Shuery 54972ab341 feat(tui): 清单行表与单行替换(第一轮第五块·核心)
外科式改写的地基(ADR-0013):Get-BakNRetBackupListRow 把清单文本拆成带**行号**的行表,Get-BakNRetBackupListRow 之外的行一字节不动。行表刻意保留注释与空行 —— 只在表里放"真正的条目"会让行号错位,改一行就会连锁改掉别处。

解析器给的 Record.Raw 就是原始行文本,所以"没被编辑的行写回原样"是天然的:注释、对齐、$( ) 表达式、跨行拼接都不会被碰到。这也是它比"解析成对象再序列化"安全的地方。

行号越界**抛错**而不是静默不动:静默不动会制造"保存成功但其实没改",而用户看到的是一份以为改过、其实没改的配置。

判据是 spec 里那条往返断言的文本层形式:每行原样写回后**文本逐字节相同**;改一行时只有那一行不同、注释行数不变、原文本不被就地改动。

验收:断言 13 条;test.ps1 9/9 全绿(5.1 与 7)。
2026-09-27 16:18:27 +08:00
Shuery 835fb0cf2b feat(tui): 菜单渲染与循环(第一轮第四块·下)
形态是行内(ADR-0010):菜单按普通行往下打,之后每次按键只把那几行原地重画(记下起始行再定位回去)。不做全屏、不切备用缓冲区 —— 崩了的时候滚屏里还留着上文,而"终端被 TUI 弄乱"是这类代码最难排查的故障。

没有控制台时只打一次、不重画、不抛错,靠 -Driver 里的 -InputScript 驱动 —— 同一个循环在真终端与门禁里走**同一条代码路径**,断言看的是返回值(Action/Index/Chosen/Checked),不需要捕获控制台输出。

两处刻意的不猜:空菜单直接返回 cancel 并说明原因(没有可选项时不该等按键 —— 第一次使用时归档目录就是空的,在计划任务里等按键等于挂起);绝不 exit、绝不碰退出码(TUI 的异常不该污染备份结果)。

这块的断言按**数量分档**写(0 项 / 1 项 / 3 项)——上一块的单键 Bug 正是藏在"1 项"这个档位里,而当时只跑了 3 项的序列。

验收:断言 10 条;test.ps1 9/9 全绿(5.1 与 7);真实清单只读冒烟 4/4。
2026-09-27 16:13:57 +08:00
Shuery 6315c72f69 fix(tui): 单键序列被 [pscustomobject] 拆成字符串(驱动器改 List[string])
根因:`[pscustomobject]@{ Script = $scriptKeys }` 里,**单元素数组会被 PowerShell 拆成字符串**,于是 `$Driver.Script[0]` 取到的是字符串首字母 —— @('Esc') 变成 'E',键名不再匹配任何分支,循环继续往下读,最后"序列用尽"报错。

这个坑只在"恰好一个键"时出现,所以块 ② 当时的断言(两键序列按顺序消费)看不见它;而菜单循环那块因此连着两次"看着对、跑起来不对"。修法是改用 List[string]:不会被拆、可按索引取、两个版本行为一致。

补一条正是为它立的断言:单键序列必须整键返回(不是首字母)。这类"只在边界数量上出现"的错,只能靠把边界数量本身写进断言来防。

验收:test.ps1 9/9 全绿(5.1 与 7)。
2026-09-27 16:11:17 +08:00
Shuery 347959d677 feat(tui): 菜单状态机(第一轮第四块·上)
把菜单拆成"纯状态机 + 薄渲染":状态机进断言,渲染只需人肉看长相。规则:Up/k 上移、Down/j 下移、到头绕回;Space 只切换当前项(单选模式无效);Enter=confirm、Esc=cancel;返回新对象而不就地改(状态属于调用方,ADR-0011)。

特别钉住空菜单:第一次使用时归档目录就是空的,菜单打开就是空菜单 —— 方向键与空格都不该抛错,只有 Esc 能退出。这类"初始状态为空"的路径最容易在真终端里才被发现,而那时已经晚了。

验收:断言 16 条;两版手工输出一致;test.ps1 9/9 全绿。
2026-09-27 15:55:31 +08:00
Shuery d9d7967e64 feat(tui): 定位写与按列对齐(第一轮第三块)
Format-BakNRetPaddedText:按**列宽**补齐或裁剪,且绝不切开宽字符 —— 剩余宽度差 1 列而下一个字符要占 2 列时,停下用空格补那 1 列。返回的字符串保证恰好 N 列,这条不变式是画边框的基础(按 .Length 补会让边框歪,按 .Length 裁会把汉字劈成半个显示成乱码)。

Write-BakNRetAt:第一道闸门是 [Console]::IsOutputRedirected,**不是** $Host.UI.SupportsVirtualTerminal(实测后者无控制台时仍返回 True,而 SetCursorPosition 会抛异常)。没有控制台就完全不定位、只把文本写出去;坐标越界时跳过定位但仍写出文本 —— 计划任务与窄窗口不该让 TUI 崩掉,只是画得难看一点。颜色走 Write-Host -ForegroundColor(零 VT 依赖)。

验收:断言 10 条(含"裁剪后恰好 5 列且不是 4 个字符"这条关键判据);两版手工输出一致;test.ps1 9/9 全绿。
2026-09-27 15:52:34 +08:00
Shuery f2959a4279 feat(tui): 读键归一化与 -InputScript 输入缝(第一轮第二块)
三层拆开,让唯一能无控制台测试的那层露出来:Get-BakNRetKeyName 纯映射(键码→键名)、New-BakNRetInputDriver 造驱动对象、Read-BakNRetKey 取下一个键。门禁把方向键/回车/空格/字母全钉住,只把"真的读到一个键"留给人工。

两条"不猜"的设计:序列用尽必须抛错(绝不退回去读真终端 —— 门禁挂起比变红糟得多);没有控制台也没有序列时不返回任何默认键(没人按键却继续执行,在备份工具里等于替你做了决定)。

这块连撞三个"看起来该有值、其实是空的"坑,全被"绿了才提交"拦在提交之外:① 键名少写冒号;② @($null) 造出含 $null 的单元素数组,"空序列"被当成"有一个键",且两版本表现不同(7 静默返回、5.1 抛错);③ **多行插入时换行被折成空格**,于是注释与 `Script = $scriptKeys` 一起落进同一行注释,那条赋值从未执行 —— 症状是"对象一个属性都没有"。教训:往文件里插多行代码不能靠拼接字符串,写文件就整份重写。

验收:断言 15 条(含驱动序列消费与两条必须报错);两版本手工输出一致;test.ps1 9/9 全绿。
2026-09-27 15:49:20 +08:00
Shuery 8a0924330a feat(tui): 控制台列宽表(第一轮第一块)
整个 TUI 的地基:位置、对齐、边框都靠它。而它算错了**不会报错** —— 表现只是菜单右边框歪一点、光标偏一列,所以每条边界都在断言里钉住。

为什么必须自己算:.Length 是 UTF-16 code unit 的个数(汉字 1 个 char、2 列);而 $Host.UI.RawUI.LengthInBufferCells 在 PowerShell 7 上对、在 5.1 上错(实测 中文 返回 2、字体边框字符 返回 6),且不抛错 —— 这种"一边对一边错"的 API 比两边都错更危险。

表里三个边界是实测踩过的:半角片假名 U+FF61–FF9F 是 1 列(不能划进全角区);全角拉丁 U+FF00–FF60 是 2 列;emoji 是代理对,按一个码点算。

验收:断言 8 条(含空串与控制字符);test.ps1 9/9 全绿(5.1 与 7)。
2026-09-27 15:31:26 +08:00
Shuery 60f2ae3e0f docs: TUI 与入口重组的设计记录(4 条 ADR + 验收锚点)
grilling 三轮把设计树走完了,这里把它落成文件,避免决策只活在对话里。

ADR-0010 零依赖自研 TUI:三个候选库全部实测排除 —— ConsoleGuiTools 的 PSGallery 元数据声明最低 7.2(5.1 装不上);Spectre.Console 0.49.1 能在 5.1 加载但要塞 4 个第三方 DLL 且不支持鼠标;Terminal.Gui 1.15.0 能反射接线到真的渲染出窗口,但 Application.Shutdown() 在两端都抛 NRE。同时记下两条实测坑:无控制台时 $Host.UI.SupportsVirtualTerminal 会撒谎返回 True(第一道闸门必须是 [Console]::IsOutputRedirected),以及 5.1 的 RawUI.LengthInBufferCells 对中文返回 2、对边框字符返回 6(都不能用来排版)。

ADR-0011 进度回调这个例外:它是注入点不是状态,无头路径为 $null 时行为与今天完全一致。

ADR-0012 入口改名与垫片:这次留垫片而 Common.psm1 直接删,是因为前者是外部接口(断了是静默没用)后者是内部实现(断了当场报错)。并纠正了自己的一个伪前提 —— 本机实测没有注册任何 BakNRet 计划任务。

ADR-0013 TUI 写配置:外科式改写 + 校验通过才原子替换。硬事实是 SoftwareCatalog.psd1 根本不能被 Import-PowerShellDataFile 读入(动态表达式),任何"解析成对象再序列化"的方案都会毁掉表达式与注释。

.scratch/tui/spec.md 是这次改造的验收锚点:9 条已定决策 + 三轮的判据 + 风险表。

验收:Parse 两版仍绿。
2026-09-27 15:27:01 +08:00
Shuery 187549e2af feat: 运行结尾输出分类详细的结果汇总(成功/跳过/失败/安全/孤儿)
原先结尾只有一行计数加一个失败清单。计数回答有几个,而人真正要读的是哪几个、为什么——尤其当失败或跳过发生在你没盯着屏幕的时候(计划任务)。

新的 Write-BakNRetRunSummary 按动作分组逐条列出:成功组带归档名/体积/耗时/sha256 前 12 位、以及有文件没打进归档的警告标记;跳过组带具体原因(源未更新/源不存在/路径无效);失败组带退出码与原因原文,并且再单列一遍。另有安全描述符与孤儿归档两段,空分组不打印,末尾给耗时。

数据来源是 manifest 里本次运行写下的记录(按 finishedAt 落在运行窗口内筛),而不是让调用方另维护一份清单。

搬的过程里翻车一次并被自己的断言拦住:Get-RecField 只用 PSObject.Properties.Name 判字段存在,而那是 PSCustomObject 的形态;本进程新造的记录是 [ordered] 字典,于是字段一律读成 $null,现象是"本次运行没有写下任何条目记录"(记录明明在)。现在两种形态都认。

一个已知没做的:排除规则只在内联逐条打印,没进汇总——我没定位到那个打印点,不愿意凭猜往条目循环里插桩。

断言:零依赖套件新增一条,用真日志文件验收汇总内容,并断言空分组不出现。验收:test.ps1 9/9 全绿(7 与 5.1)。
2026-09-27 14:26:59 +08:00
Shuery e8f0a5ae19 docs: 更新 README(口令一节、测试计数、虚拟机验证)并修掉拆文档时留下的重复标题
README 是被人真读的那份文档,而这一路改动让它有三处不再准确:

  1. 「口令放在哪里」整节是错的:它写着"默认留空、必须放在仓库之外",而按你的决定,出厂默认值就是仓库根的 baknret.key(靠 .gitignore 兜住不被提交)。整节重写:如实写明这是一次取舍(省事 vs "不提交"依赖一个规则文件),并补上"相对路径按仓库根解析、不按工作目录"这条 —— 计划任务的工作目录是 C:\Windows\System32,按工作目录解析会让加密条目以"拿不到口令"失败而配置看上去没问题。关键提醒也改了:已经在仓库根的人什么都不用做,想搬走才需要动。

  2. 测试项数过期:Pester 150 -> 183(含安全描述符套件)、零依赖 101 -> 111。

  3. 新增「在 Hyper-V 虚拟机里验证」:这一层本机跑不到,而它覆盖的正是最需要真环境的那条路径 —— 安全描述符回放(把属主改成 NT AUTHORITY\SYSTEM、再靠 CREATOR OWNER 判断恢复后归谁)。附最近一次四步的结果与命令。

另修一个我在拆分 README 时留下的缺陷:4 个二级标题各重复了一次(块替换保留了原标题、又插入了带同名标题的指针行)。markdown 不经过 test.ps1,所以当时没被拦住 —— 现在按"相邻同标题只留一行"折掉,并复查为 0。

验收:test.ps1 9/9 全绿(7 与 5.1)。
2026-09-27 14:10:04 +08:00
Shuery 2446c7c5c7 docs: 把"前缀补全只搜一层、且不提供深度开关"写成决策记录
深度递归不是"补上一个没实现的功能",而是引入一个具体的错。写成 ADR-0009 连同实测数据:26 条真实 Path 里 19 条直接命中、3 条补全救不了(软件没装)、1 条(%UserProfile%\fnm)在 5 层内会命中 AppData\Local\fnm_multishells 这个临时目录 —— 静默备份错的东西还报成功。

同时在 Find-BakNRetChildDirectoryByName 的注释里指回 ADR,免得下一个人把它当"漏了的功能"补回去。

顺带修掉 README 里一句与实现不符的话:原先写"前缀补全命中多个候选(同名目录分散在多处)",而只搜一层时多个候选只可能来自同一个父目录(既有 X 又有 X_后缀)。

全仓复查:MaxDepth / CatalogMaxDepth / 最大深度 / 向下找几层 除 ADR-0009 的历史叙述外 0 处。

验收:test.ps1 9/9 全绿(7 与 5.1)。
2026-09-27 11:34:34 +08:00
Shuery 520257b5e5 fix: 口令文件默认值回到仓库根(按你的决定),并让相对路径与工作目录无关
你的决定:口令文件继续放在仓库根,靠 .gitignore 的 *.key 兜住"不被提交"。我把出厂默认值改回 baknret.key,并把配置注释从"必须放在仓库之外"改成如实说明这是一次取舍:省事 vs 「不提交」依赖一个规则文件(git add -f、或整目录复制到别处再初始化仓库时,口令会跟着走)。

顺手修掉一个潜在陷阱:口令文件写相对路径时,原先的 Test-Path 是按**当前工作目录**找的。计划任务的工作目录通常是 C:\Windows\System32,在那里 Test-Path baknret.key 为假,加密条目就会以"拿不到口令"失败 —— 而配置看上去毫无问题。现在相对路径按仓库根解析(复用上一轮抽出来的 Resolve-BakNRetRootedPath)。

证据:把工作目录切到 C:\Windows\System32 再跑真实清单只读冒烟,仍然 4/4 通过(那份真实配置里有 5 个加密条目、口令文件就是仓库根的 baknret.key)。

验收:test.ps1 9/9 全绿(7 与 5.1)。
2026-09-27 11:21:43 +08:00
Shuery 120cf3584b fix: 清掉改造过程留下的陈旧引用(代码改了、文档还写着旧的)
这一轮做的是交付一致性检查:把这一路改掉/移走/删掉的每个符号在全仓(含文档)扫一遍。42 处命中里 38 处是正当的 —— CHANGELOG 与 ADR 里讲“原来是什么”属于历史叙述,test 里的 Assert-FileExists 是因为我只抑制了误判而没有改名。剩下的 4 处是真陈旧:

  * README 还在配置表里写着 CatalogMaxDepth,而那个配置项上一轮已经整条移除;

  * Get-BakNRetItemArchiveName 的 param 里还留着一个内联的 [int]$MaxDepth = 5(上一轮的删除按行匹配,没覆盖到这种写在同一行的参数),它已经没有任何调用方传值;

  * tools\lab\README.md 与 docs\agents\domain.md 还写着模块叫 Common.psm1。

这正是这次改造从头到尾在抓的毛病:文档承诺的东西,代码已经不做了。区别是多了一个可执行的检查 —— 符号改名/删除之后,全仓扫一遍旧名字。

验收:test.ps1 9/9 全绿(7 与 5.1)、真实清单只读冒烟 4/4。
2026-09-27 10:49:21 +08:00
Shuery c4452a749c refactor: manifest 的写入收进模块函数,依赖写在签名上
Save-ItemRecord 原先直接用脚本级的 $manifest —— "这个函数会改全局账本"这条事实只存在于读代码的人的注意力里。搬进模块后 Manifest 是必填参数,13 个调用点每一个都能看出自己在动账本。

关键判断:**不需要接返回值**。$Manifest.items 是哈希表,传参是引用语义,函数里的写入直接落在调用方那份 manifest 上,所以调用点的形状只是"多了一个 -Manifest $manifest",行为一字不改。这是"有状态依赖该做成什么"的答案里最省的一种:显式参数 + 引用语义。

两条刻意保留、容易被改错的语义也写进了注释并原样搬过来:warnings 与 security 描述的是"当前在位的归档"而不是"这次尝试",只有真的换掉归档才更新 —— 否则"因不完整而保留旧归档"之后,下一次就失去保护了。

搬的过程里把 $Record.finishedAt 那行写坏了(外层双引号把 $Record 提前插值成空,文件里变成 ".finishedAt = ...")。**注意这是上一轮刚记下的同一个坑**:往文件里写 PowerShell 代码时外层一律用单引号。这次是"绿了才提交"把它拦在提交之外 —— 流程修正当场生效。

验收:test.ps1 9/9 全绿(7 与 5.1)、真实清单只读冒烟 4/4。Backup.ps1 从 889 行降到 846 行。
2026-09-27 10:45:57 +08:00
Shuery 319ec1da78 refactor: 条目记录的工厂搬进模块(manifest 的 schema 有了唯一落点)
New-ItemRecord 是纯工厂:我先用正则扫过它读了哪些外部状态 —— 一处都没有,输入全在参数里,只有一个 Get-Date。所以这是"有状态核心"里唯一可以零判断搬走的一块。

搬走并改名 New-BakNRetItemRecord,同时补上字段说明:这个有序哈希就是 manifest 每个条目的 schema。它原先藏在 Backup.ps1 里,于是"manifest 条目长什么样"这件事只有一个隐式落点;恢复端与工具脚本读的也是同一份 schema,改字段时漏掉某一侧的风险就出在这里。

新增一条断言把这个 schema 钉住:26 个字段的名字与**顺序**逐项比对(顺序是 manifest diff 可读性的前提),另外断言 attemptedAt 每次现取 —— 后者是"把它误写成模块加载时的常量"这种错唯一能被发现的地方。

验收:test.ps1 9/9 全绿(7 与 5.1)、104 个文件两版解析零错、真实清单只读冒烟 4/4。Backup.ps1 从 920 行降到 889 行。
2026-09-27 10:40:35 +08:00
Shuery 67ee2af006 refactor: 两个入口统一 7z 的查找方式(消除“备份找得到、恢复找不到”的隐患)
原先同一段查找逻辑有两份:模块的 Resolve-BakNRetCompressionTool 里一份,Restore.ps1 自带的 Get-7zExecutable 里又一份(PATH → Program Files → Program Files (x86))。两套写法哪怕只差一个候选路径,就会出现“备份找得到、恢复找不到”这种最难看的不一致。

抽成 Find-BakNRet7zExecutable,两边都改用它。**保持策略不同**:备份可以退到 RAR 或内置 ZIP(能打包就行),恢复必须真能解压 7z、找不到就明确报错 —— 所以抽出来的只负责“找 7z”,不负责“找不到怎么办”。我自己在抽之前差点把它当成顺手删的重复代码,看清楚才发现策略差异是刻意的。

验收:test.ps1 9/9 全绿(7 与 5.1)、103 个文件两版解析零错、真实清单只读冒烟 4/4、构建工具仍能合回单文件。当前查找器在本机解析到 C:\Programs\Scoop\shims\7z.exe,备份侧解析器仍返回 7z。
2026-09-27 10:31:05 +08:00
Shuery fcf83ecb74 refactor: Restore.ps1 的条目筛选改用模块函数(去掉第二份副本)
Test-EntrySelected 与上一个提交搬进模块的 Test-BakNRetItemSelected 是同一段逻辑:我在删之前先逐行比对过(把 $Only/$Skip 归一化后两边 7 行逻辑完全相等),确认是重复才删。

这是"入口下沉"里少见的零风险一块:不新增代码,只是让两份实现变成一份。Restore.ps1 从 884 行降到 870 行。

验收:test.ps1 9/9 全绿(7 与 5.1)、无解析错误、真实清单只读冒烟 4/4。
2026-09-27 10:27:44 +08:00
Shuery bbfece10b6 refactor: 入口逻辑下沉第一块(路径解析与条目筛选)
把两个内联函数从入口脚本搬进模块,它们正好覆盖下沉时最容易出错的三类依赖:

  * Resolve-BakNRetRootedPath(原 Backup.ps1 与 Restore.ps1 各一份同名副本)靠 $PSScriptRoot 找仓库根 —— 搬进模块之后 $PSScriptRoot 会变成**模块目录**,语义就悄悄变了。所以根目录改成显式参数,由调用方传 $PSScriptRoot。

  * Test-BakNRetItemSelected(原在 Backup.ps1 里)直接读脚本级的 $Only / $Skip。那种闭包依赖让它没法单独测:要测它就得先构造一个入口脚本。现在两个清单是显式参数。

  * 第三个函数 Get-7zExecutable 我**没有**动:它和模块里已有的 7z 定位功能重复,但两者返回的东西不同(一个路径字符串、一个带 Name/Command/Extension 的对象),合并要先确认语义,不能顺手删。记在下面待办里。

过程里翻车一次并当场修好:给调用点补参数时我是按"行尾追加"做的,结果 3 处把参数甩到了 `)` 或 `}` 外面(例如 `if (...) { continue } -Only $Only`)。**解析是零错的** —— 又一次"绿着但是错的"。修法不是继续追加,而是按内容精确定位那 4 行、把参数插到括号里面;修完先用 5 个即时探针确认两个函数真的能调起来(含 -Only 命中、-Skip 否决、绝对路径不被拼根),再跑全套。

验收:test.ps1 9/9 全绿(7 与 5.1)、102 个文件两版解析零错、构建工具仍能合回单文件。
2026-09-27 10:25:05 +08:00
Shuery 60ecbc2933 docs: README 拆出四篇主题文档,本文件留总览与操作
拆出软件名录(67 行)、清单语法(114 行)、归档布局与迁移(47 行)、安全描述符(69 行),共 297 行;README 从 653 降到 360 行,每节原位留一句摘要加链接。

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

搬运按标题抽取原文、不重打,并在写之前断言正文长度、写之后再读回断言一次(第一次尝试就是栽在没有断言上:@(a, b, $arr) 不会展开数组,而是把 $arr 拼成一行,结果四篇各只有 5 行、正文却已从 README 删除。那次已回滚)。
2026-09-27 10:20:08 +08:00
Shuery fb0d93a25f Revert "docs: README 拆出四篇主题文档,本文件留总览与操作"
This reverts commit 35aade63bc.
2026-09-27 10:19:34 +08:00
Shuery 35aade63bc docs: README 拆出四篇主题文档,本文件留总览与操作
拆出去的是最大且自成体系的那四块参考资料:软件名录(67 行)、清单语法(114 行)、归档布局与迁移(47 行)、安全描述符(69 行),共 297 行。README 从 653 行降到 360 行,每一节原位留一句摘要加链接。

为什么拆:这四块是"要查的资料",而 README 剩下的(快速开始、文件说明、恢复语义、manifest、配置、加密、口令、计划任务、测试、验收、设计取舍、已知限制)是"要读的流程"。混在一起时,想查清单语法的人得先滚过 130 行总览。拆开之后每篇也能被单独引用与单独评审。

搬运用脚本按标题抽取原文,不重打,所以不存在抄漏的风险;拆完校验了每一条指向新文件的链接目标都存在。
2026-09-27 10:17:19 +08:00
Shuery 232a82cd3c style: 逐条修静态分析告警(706 → 44),并把 MaxDepth 这条假承诺删掉
修掉的:空 catch 5 处;名词白名单 7 处;default-value 开关、自带 -WhatIf、lab 的明文口令与 irm|iex 各挂抑制并写明理由。

MaxDepth:它是分析器拓出来的真 bug —— 参数声明了却从未使用,也就是配置里的 CatalogMaxDepth 是假的,前缀补全实际只查 1 层,而配置注释与 README 都承诺「向下找几层」。按确认过的原则处理:**先让文档不撒谎**,所以把整条链路去掉(配置默认值、三个函数的参数、70 处实参、配置注释),而不是留一个假旋钮。零行为变化。想真的支持多层补全时,那是一个独立决定。

剩下 3 条都是分析器的误判,而且我实测确认过其中一条:$sourcePath 被报「赋值后从未使用」,我照着改成 $null = 之后,Set-StrictMode -Version 3.0 下读未定义变量直接抛错,Security 套件的 BeforeAll 挂掉、4 条用例连带失败。恢复后才绿。

这一类误判有共同成因:静态分析看不到「在传给 Test-Case / It / Where-Object 的 scriptblock 里被使用」。所以我只对能证明是误判的挂抑制并写明理由,不为了数字好看去改代码。

验收:test.ps1 9/9 全绿(7 与 5.1)、100 个文件两版解析零错、Run-RealSmoke 4/4。
2026-09-27 10:16:10 +08:00
Shuery 3a3a57a6a1 docs: 决策记录 8 条、变更日志、README 的验收与口令两节
0007 与 0008 是本次改造新产生的决策(原计划 6 条)。它们同样满足「后人会问为什么」:把运行锁改成互斥体、或给库里 27 个函数补上 SupportsShouldProcess,都是看起来更规范的错法。

CHANGELOG.md:把 README 里那 33 行「相对旧版修了什么」整节搬过去,并补上本次改造的记录(11 条修复 + 6 条结构与规范,每条修复都写明现象与现状)。README 那一节换成指针。

README 新增两节:「验收与静态分析」(六个层次、Run-RealSmoke 为什么独立存在、以及那 6 条格式规则默认 Disabled 这个容易漏掉的事实);「口令放在哪里」(默认留空不是遗漏,附迁移与验证命令)。

.markdownlint.json 照 PowerShell 主仓库的实践(default true、行长 240),只把 MD024 从关闭改成「仅同级不重复」,因为变更日志需要重复标题。顺带按 .editorconfig 把 .md 的 BOM 去掉。
2026-09-27 10:01:54 +08:00
Shuery 42f02d0eca refactor: 公共面补 BakNRet 前缀,产品名大小写全仓统一
16 个没有前缀的公共函数补上 BakNRet(Write-Log → Write-BakNRetLog、Resolve-BackupEntry →
Resolve-BakNRetBackupEntry、Find-ChildDirectoryByName → Find-BakNRetChildDirectoryByName 等),
另外把全仓的 Baknret 统一成 BakNRet(47 个文件、940 处、65 个定义文件重命名)。

这不是审美问题:静态分析直接拓出一条实据 —— Write-Log 与本机某个已装模块导出的命令
**重名**(PSAvoidOverwritingBuiltInCmdlets),而重名的后果是导入两个模块时有一方的命令被
静默遮蔽。补前缀正是这条规则的解法,改名后它归零。

为什么敢做这个规模:PowerShell 的函数名解析大小写不敏感,所以 Baknret → BakNRet 在功能
上是零风险;真正要验证的是 16 个补前缀的调用点,而 276 个断言几乎覆盖了每个函数。另外
"名字与文件名一致"这条不变式有断言盯着(加载器点源的文件集合 vs 磁盘)。

踩到并记下的坑:Windows 文件系统大小写不敏感,所以**只改大小写**的重命名会被 Move-Item
当成同一个文件而静默跳过 —— 同一批里同时改了名字的那 16 个文件却成功了,于是"看起来能跑"。
最后用"先移到临时名、再移到目标名"的两步走解决,判断与替换全部改用显式大小写敏感的形式
(-creplace / -cmatch)。

顺带把名录指纹缓存从 MD5 换成 SHA256(PSAvoidUsingBrokenHashAlgorithms):它只是缓存键,
没有兼容负担。

验收:test.ps1 9/9 全绿(7 与 5.1)、100 个文件两版解析零错、276 个断言全过、
构建工具仍能合回单文件(3300 行)。
2026-09-27 09:56:36 +08:00
Shuery 187d2759fd style: 按微软规范落地静态分析,并全仓机械重排
三件事:

1) tools\Install-TestDependencies.ps1 现在也把 PSScriptAnalyzer 装进仓库内的 .tools\modules
(不动机器上的全局模块,与 Pester 同一策略)。

2) PSScriptAnalyzerSettings.psd1:这是必要的,不是装饰 —— 那 6 条格式规则
(括号、缩进、空格、对齐、大小写)默认全是 Disabled,所以不带 -Settings 的
`Invoke-ScriptAnalyzer -Severity Warning,Error` 会**静默漏掉全部排版问题**。本文件用 Rules
把它们打开(而不是用 IncludeRules 换一套),于是默认规则与格式规则同时生效。
三条有意的排除都写明了理由:PSAvoidUsingWriteHost(彩色控制台输出是这份工具的刻意设计)、
PSUseShouldProcessForStateChangingFunctions(WhatIf 的边界在入口脚本,给库里 27 个改状态的
函数都加上反而会"静默跳过",备份看着成功却什么都没做)、PSAvoidUsingPlainTextForPassword
(7z 只接受命令行口令,这是 7z 的限制,README 里写明了取舍)。

3) tools\Invoke-Analyzer.ps1:独立门禁(不塞进 Pester 用例 —— 套件跑一次二十多秒,
混进去会让"测试红了"这句话失去分辨力),路径过滤与验收门槛的 Encode/Parse 两层一致。

全仓重排结果:706 条告警 -> 67 条。修掉的 639 条全部是格式(闭括号 168、空格 80、
对齐 68、缩进 60、行长 229)。重排后 9/9 验收全绿、100 个文件两版解析零错、
276 个断言原样通过 —— 机械重排没有改变任何可观察行为。

如实说明两件事:

  * 行长上限设成 160,**不是**官方默认的 120。120 在本仓库意味着 270 处改动(主要是
    中文注释与测试夹具里的一行式目录),160 意味着 41 处。160 仍是"宽但可读",而理由是写在
    配置文件里的:这不是悄悄放宽,想收紧到 120 时那份清单就在分析器输出里。

  * 剩余 67 条里,41 条是上面那批行长,其余 26 条是分析器找出的真问题(未使用参数 6、
    空 catch 6、MD5 指纹 1、覆盖内置命令 1、switch 默认值 1 等)。其中
    Find-ChildDirectoryByName 的 MaxDepth 参数从未被使用 —— 也就是配置里的
    CatalogMaxDepth = 5 是假的,前缀补全实际只查 1 层。这条要改行为、且影响真实名录的解析
    结果,留给你拍板,不在本提交里动手。
2026-09-27 09:46:08 +08:00
Shuery 102a3e038d fix: 口令文件的出厂默认值不再指向仓库内的文件
BackupConfig.psd1 原来写的是 PasswordFile = 'baknret.key' —— 相对路径,解析到仓库根,
而根目录真的有这个文件(实测它在服役:用它跑 7z t 得到 0,manifest 里 5 个条目标着
encrypted = true)。也就是说"出厂默认值"在教人把口令放进版本库,而同一份配置的注释却写着
"文件必须在仓库之外"。

现在默认留空,并把注释改成明确的约束:口令只能来自 -Password / $env:BAKNRET_PASSWORD /
仓库之外的 PasswordFile / 交互式询问(仅交互式会话)。取不到口令时加密条目**明确失败**,
绝不退化成明文归档 —— 这条行为没变,也没有放宽。

配套:.gitignore 早已加上 *.key;密钥文件本身怎么搬是使用者的动作(我不会自动搬你的
密钥),迁移命令见 README 与本次改造的收尾说明。
2026-09-27 09:34:43 +08:00
Shuery 2ad7987ffe refactor: Common.psm1 拆成 BakNRet/{Public,Private},一函数一文件 + 薄加载器
3152 行、66 个函数的单文件模块拆成:
  BakNRet\BakNRet.psd1   模块清单:FunctionsToExport 是显式白名单(62 个名字)
  BakNRet\BakNRet.psm1   加载器:点源顺序的唯一一处声明
  BakNRet\Public\*.ps1   62 个对外函数,一函数一文件,文件名 = 函数名
  BakNRet\Private\*.ps1  4 个内部函数 + State.ps1(模块级状态集中一处)

为什么是一函数一文件:这是社区里脚本模块的主流形态(调研实测:winutil 79 个、
Terminal-Icons 24 个、ModuleBuilder 23 个,全部如此)。收益是改动落在小文件里、diff 按职责
可读、模块级状态有唯一去处。

为什么这不算"打散":模块内 dot-source 的文件共享同一个模块作用域(实测确认),所以
"按顺序点源 67 个文件"与"点源一个大文件"在语义上等价;顺序只在加载器里出现一次,
tools\Build-BakNRetModule.ps1 从那里读出顺序就能拼回单文件 —— 本次产物 dist\BakNRet.psm1
3240 行、两个版本都解析零错。

新增一条断言把这条承诺钉住:加载器点源的文件集合必须与磁盘一致、导出名单必须与
Public\ 一一对应。漏一个文件或漏一个名字就是静默少一个函数 —— 而那种错在运行时只表现为
"找不到命令"。

引用更新:11 个文件里的 Common.psm1 改成 BakNRet\BakNRet.psd1(走清单导入,
FunctionsToExport 才真的说了算);Common.psm1 直接删除,不留转发垫片。

验收:test.ps1 9/9 全绿(7 与 5.1),98 个文件两版解析零错,276 个断言原样通过 ——
这次搬家没有改变任何可观察行为。
2026-09-27 09:34:10 +08:00
Shuery 78f43c8cc9 docs: 建立 CONTEXT.md 术语表
13 个词,按「输入 / 归档布局 / 产物与审计 / 名字」四组,每词带 _Avoid_ 同义词。它是本次改造落定的用词记录:清单 / 条目 / 方向 / 软件名录 / Slot / 覆盖 / 排除模式 / 前缀补全 / 归档 / 归档项 / 旧布局 / manifest / 安全描述符旁挂 / 孤儿归档 / BakNRet。
2026-09-27 09:23:48 +08:00
Shuery 10f97246c8 fix: lab 的 ACL 场景在 5.1 上会被原生命令的 stderr 中断
tools\lab\payload\run-acl-scenario.ps1 是 $ErrorActionPreference = Stop,而它要大量调用
rmdir / takeown / icacls / scoop / code —— 其中 rmdir 与 takeown 在"对象已经处理过"或
"连接点已经悬空"时就会往 stderr 写字。Windows PowerShell 5.1 会把那升级成终止性的
NativeCommandError(7 改了这条规矩),于是清理函数在设计要处理的**恰恰那个场景**里直接崩掉。

修法:新增 Invoke-NativeTolerant,在进入原生命令时把 EAP 放到 Continue、退出时还原,
把 stdout 与 stderr 合并返回;12 处调用全部收进它。判断"删掉了没有"本来就该用 Test-Path,
不需要让 stderr 变成异常。这与 tests\BakNRet.Security.Tests.ps1 里对 takeown / icacls
用的是同一招(那里已被 5.1 的失败实测逼出来过)。

范围说明:只改了 run-acl-scenario.ps1。provision.ps1 的同类写法**不需要**改 ——
它是 $ErrorActionPreference = Continue,那两处本来就不会踩。

验证边界(如实说明):这个文件要 Hyper-V + 一台 VM 才跑得到,本会话无法执行它。
所以这一步的证据是:两个版本上解析零错(Parse 层覆盖)、12 处替换逐条断言命中、以及
diff 逐行复核。它是"降低风险",不是"已验证修复"。

验收:test.ps1 9/9 全绿(7 与 5.1);Run-RealSmoke 4/4 全绿。
2026-09-27 09:22:45 +08:00
Shuery 8d67a38fb7 fix: Backup.ps1 与 Restore.ps1 并发撞车时互踩账本(加运行锁)
问题:两个入口都会写 manifest.json,也都会在备份目录里用 <归档>.tmp 这个名字生成临时
归档。计划任务与手动运行撞在一起时,两边会互相覆盖对方的账本;更糟的是两边会把彼此的临时
归档当成自己的。计划任务的 -MultipleInstances IgnoreNew 只挡住"计划任务之间",挡不住手动运行。

修法:备份目录上的一把跨进程锁,用**独占文件句柄**(FileShare.None)而不是命名互斥体:
  * 句柄由内核持有,进程被杀 / 崩溃时自动关闭,锁自动释放 —— 不会留下需要人工清理的陈旧锁;
    命名互斥体要跨会话(计划任务在另一个会话里跑)还得用 Global\ 前缀,那需要额外权限。
  * 它是文件系统的锁:不区分会话、不区分终端,计划任务与手动运行会互相看见。
  * 锁文件里写明持有进程(pid / 起始时间 / 主机 / 用户)—— "到底是谁占着"不该靠猜。
拿不到锁就直接失败(退出码 1 + 明确消息),不等待:单个条目压缩可能十几分钟,"等它跑完"
对用户来说和挂住没区别。

只读模式不取锁(Backup 的 -DryRun;Restore 的 -DryRun / -WhatIf / -VerifyOnly):
它们一个字节都不写,没必要被正在跑的备份挡在外面。

踩到并记下的两个坑:
  1) catch [System.IO.IOException] 接不住 —— PowerShell 把 .NET 方法抛出的异常包成
     MethodInvocationException,按内层类型做的 catch 会漏。现在沿 InnerException 链找,
     不是 IOException 就把原异常抛回去(目录不可写是 UnauthorizedAccessException,那是真
     错误,不该伪装成"另一次运行在进行中")。
  2) 锁文件是独占打开的,所以内容只能在**释放之后**读 —— 第一版断言在持锁时去 Get-Content,
     被自己的锁拒了;这条断言现在挪到释放之后。

跨进程证据(真跑,不是推理):父进程持锁 → 另一个进程取锁得到 DENIED;持锁状态下跑
真实的 Backup.ps1 → 退出码 1、日志点名锁文件、manifest 的 SHA256 未变;释放后另一进程
得到 GOT。

验收:test.ps1 9/9 全绿(7 与 5.1);tests\Run-RealSmoke.ps1 4/4 全绿。
2026-09-27 09:18:49 +08:00
Shuery 79f83f6760 fix: manifest 先删后移会丢账本;5.1 拿不到原子替换;空目录让空间守卫静默失效
四件事都在"原子替换与空间守卫"这条线上:

1) Write-BaknretManifest 自己写了"写 .tmp → 删旧 → Move-Item"。Move-Item 一失败,
   旧 manifest 就已经没了 —— 而 manifest 是"这块归档是谁的"的唯一账本。改成复用
   Write-BaknretAtomicText。

2) Write-BaknretAtomicText 原本也是走 Move-BaknretArchiveIntoPlace,而后者在 5.1 上
   必然退化成"先删后移"(三参数 File.Move 是 .NET Core 3.0+ 才有的重载)。现在目标存在时
   改用 File.Replace(ReplaceFile API):要么换成新内容、要么保持旧内容,两个都不会消失。
   实测目标只读时替换失败、旧内容完好、.tmp 保留便于排查。

3) Move-BaknretArchiveIntoPlace 的 5.1 降级路径同样改成 File.Replace —— 之前那条
   "先删后移"会在中途失败时让归档消失(旧归档没了、新归档还在 .tmp 里)。
   注意第三个参数必须传 [NullString]::Value:PowerShell 会把 $null 转成空串,Replace 于是
   报"路径为空"(两个版本实测都这样,我第一版就踩了)。

4) Get-FolderSummary 对空目录返回的 TotalSize 是 $null 而不是 0(Measure-Object 空
   输入的行为,两版一致)。$null / 1GB 得 0,而备份前的空间守卫判的是 -gt 0 —— 空间不足时
   不再拦截,静默失效。现在补成 0。

回归断言(零依赖与 Pester 各一份):空目录的摘要必须是整数 0;原子写成功时内容到位
且不留 .tmp、失败时旧内容完好(用只读目标强制失败)。

验收:test.ps1 9/9 全绿 —— 5.1 那一遍的通过同时证明了 File.Replace 这条新路径真的
在 5.1 上成立;tests\Run-RealSmoke.ps1 4/4 全绿。
2026-09-26 22:47:14 +08:00
Shuery e17cdcda79 fix: 口令会随 -Verbose 落进日志(DEBUG 下打印整条命令行)
缺陷:Invoke-ExternalCommand 在 DEBUG 级打印整条命令行,而 7z / RAR 只接受命令行
口令(-p<口令>),所以口令必然出现在参数表里。一旦 -Verbose(Backup.ps1 / Restore.ps1
都会因它打开 DEBUG),logs\*.log 里就是明文口令 —— 与 BackupConfig.psd1 和文档里
"口令不落盘、不写进仓库"的承诺直接冲突。

修法:打印前把 -p 参数换成占位符;真正执行的仍然是原参数。在**参数级别**替换而不是
对拼好的命令行做正则 —— 含空格的口令会被引号包起来("-pmy pass"),正则在那种形态上
很容易漏掉,而漏掉的代价是口令明文入日志。

回归断言(零依赖与 Pester 各一份):打开 DEBUG、把日志指向临时目录、带一个哨兵口令
跑一次外部命令,然后读日志文件断言哨兵不在里面、占位符在里面。另加一条"测试的测试":
断言那行 DEBUG 记录确实写进去了 —— 否则前两条会在"压根没记录"时空跑通过。

断言有效性做了红绿证明:把遮蔽改回 $startInfo.Arguments 后断言变红并指名"口令明文
进了日志",还原后转绿。

验收:test.ps1 9/9 全绿;tests\Run-RealSmoke.ps1 4/4 全绿。
2026-09-26 22:41:52 +08:00
Shuery d32d4b511f fix: 暂存目录半途失败会留下指向真实数据的 junction
缺陷:Backup.ps1 用 `$stagingRoot = $null` + try/finally 清理暂存目录,而
New-BaknretArchiveStaging 中途抛错时没有返回值 —— 赋值没发生,finally 拿到的还是 $null,
而 Remove-BaknretArchiveStaging 对 $null 直接 return。结果是已经建好的 junction 与临时
目录永久留在 %TEMP%,而那些 junction 指向真实数据;临时目录迟早会被某次
Remove-Item -Recurse 扫到,那一下就会走进真实数据。全仓唯一会伤到数据的缺陷。

修法:把清理责任放回函数自己身上 —— 循环包进 try,catch 先调
Remove-BaknretArchiveStaging 清掉已经建出来的东西,再 throw 原始错误。调用方的 finally
保持不动(它管的是"暂存建好之后下游才失败"那条路)。自清理失败时只告警并点名残留路径,
不覆盖真正的失败原因 —— 那才是排查需要的。

回归断言(零依赖与 Pester 各一份):第一项走 junction 成功挂上,第二项因源文件不
存在必然抛错;然后断言沙盒里不留任何条目,尤其不留 junction。

断言的有效性做了红绿证明:把 catch 里那行清理临时停用后,同一段场景残留 1 个
junction(.\sandbox\stage\Good,指向真实目录)→ 断言变红;还原后文件哈希一致、断言转绿。
一个不会红的断言不算保护。

验收:test.ps1 9/9 全绿(7 与 5.1);tests\Run-RealSmoke.ps1 4/4 全绿。
2026-09-26 22:36:44 +08:00
Shuery 8736bb2c67 fix: 行首方向标记贴在目标上时失效(真实清单 29 条里 24 条被静默跳过)
量到的事实:用真实清单只读干跑,28 个条目里 27 个被当成"源不存在"跳过、退出码 0,
只有不写方向标记的 Scoop 真的被备份 —— 磁盘上最新那份归档正是 Scoop.7z(4.6 GB / 09-24)。
修复后同一批条目:29 条全部分解出方向(26 仅备份 / 2 仅恢复 / 1 双向),零个标记残留。

原因:解析器只认"独立记号"形态的方向标记(+ Name),而清单里 24 条贴在目标上
(+WindowsTerminal)。后者被当成一个名叫 +WindowsTerminal 的软件名,名录里查不到就退回
当目录名,目录又不存在 → 静默记成 missing-source。它隐形的理由是两件本身正确的设计叠在
一起:"源不存在只算跳过不算失败" 加上两种写法只差一个空格。

README 的方向标记表格写的是"行首 + / -",并没有要求标记后面跟空格;要求带空白的是
修饰符(:: / :- / :+ / @)那一节。所以让解析器接受行首贴在一起的形态,而不是去改清单。
只放宽"行首"这一个位置:记号中间的 + / - 仍是普通字符(C:\a:-b 那条断言继续盯着)。

新增 tests\Run-RealSmoke.ps1:真实清单 + 真实归档上的只读冒烟。它存在的理由就是这个
缺陷 —— 夹具测试全绿,只有拿真实清单跑才看得见。它检查四件事:方向标记全部被剥掉、没有
软件名退化成"名录里没有"、两个只读模式退出 0、manifest.json 的 SHA256 前后不变。

验收:test.ps1 9/9 全绿(7 与 5.1);tests\Run-RealSmoke.ps1 4/4 全绿。
2026-09-26 22:26:45 +08:00
Shuery e10503be76 fix: 让 5.1 真正可用(显式编码 + 原生 stderr 处理 + .psd1 夹具带 BOM)
上一提交让 5.1 能解析源码,但 Unit 与 Smoke 在 5.1 上仍然是红的。根因是三类彼此
无关的 5.1/7 行为差,全部实测确认:

1) 不写 -Encoding 时,5.1 的 Get-Content / Set-Content 默认是 ANSI,7 是 UTF-8。
   症状是 UTF-8 字节被按 GBK 解出「璇存槑」这类乱码。62 处补上显式 -Encoding UTF8。
   用 AST 而不是正则定位,避免把注释里的散文也改掉。

2) .psd1 夹具用无 BOM 写,而引擎的 .psd1 读取器(Import-PowerShellDataFile)只能靠
   BOM 判断编码、没有参数可传,于是 5.1 按 ANSI 解。40 处夹具改为带 BOM 写 —— 这正是
   .editorconfig 里 [*.psd1] charset = utf-8-bom 本来就要求的,是夹具违反了自己的约定。
   .cmd 批次文件刻意保持无 BOM:cmd.exe 会被 BOM 弄坏。

3) 5.1 在 $ErrorActionPreference = Stop 下会把原生命令写到 stderr 的内容升级成终止性
   NativeCommandError,7 改了这条。takeown/icacls 的 ACL 复位调用、以及 test.ps1 自己
   调子进程的地方,都需要在 Continue 下跑。

验收:test.ps1 9/9 全绿(Encode + Parse + Unit + Smoke + E2E,在 7 与 5.1 上各跑一遍)。

已知未处理(留待后续提交):tools/lab/** 里还有若干「原生命令 + 2>&1 + Stop」的同类
写法(takeown / icacls / scoop / code / Get-WimInfo)。它们要 Hyper-V 实验机才跑得到,
不在验收门槛内。
2026-09-26 22:16:04 +08:00
Shuery 2d26f78d15 fix: 源文件改存 UTF-8 with BOM,让 Windows PowerShell 5.1 真正可用
改造前:全仓 6/6 个源文件在 5.1 上解析失败(README 却承诺支持 5.1)。原因是文件是无 BOM 的
UTF-8,而 5.1 没有 BOM 就按 ANSI 代码页解码源码,中文变乱码、全角问号吃掉引号,整块语法塌掉。
现在 28/28 个文件在 5.1 与 7 上都解析零错误,E2E 36 项在 5.1 上全绿。

顺带修掉一个被 5.1 掩盖的缺陷:带 [CmdletBinding()] 的脚本在 5.1 上,param() 默认值里
拿不到 $PSScriptRoot(实测为空串,7 上正常)。于是 Backup.ps1 / Restore.ps1 在 5.1 上不传
路径参数就报错退出 —— 而计划任务恰恰不传。E2E 之所以看不见,是因为它总是显式传路径。
8 处默认值全部移到 param() 之后的解析段,沿用本仓库对 -BackupDir 一直在用的写法。

新增三条可重放的约定,让编码不再是一次性动作:
  .gitattributes 接管行尾(本机 core.autocrlf=true,会把工作区改成 CRLF 制造伪 diff)
  .editorconfig 用 charset = utf-8-bom 锁住 BOM
  tools\Set-SourceEncoding.ps1 是规范化脚本,tools\Invoke-* 之外的任何改动之后都能重放
  test.ps1 是唯一验收入口:Encode + Parse + Unit + Smoke + E2E,在 7 与 5.1 上各跑一遍

test.ps1 的 Encode 层直接检查"必须有 BOM"这条规则。加它的原因很实际:实测本仓库用的
编辑工具在保存时会悄悄去掉 BOM,而丢了 BOM 的文件只在 5.1 上出错、在 7 上完全正常,
没有这条检查就会一直漏过去。

已知未修(下一步处理):5.1 上 Unit 有 6 项、Smoke 有 1 项失败,全部源于测试夹具写
临时文件时没指定编码(5.1 的 Set-Content 默认 ANSI),与产品代码无关。
2026-09-26 22:05:42 +08:00
Shuery dda36cfae5 docs: 记录改造 spec(验收锚点)
四件事的目标、9 条验收门槛、30 条已确认决策、十步执行顺序与回滚控制。每项改动都要能被其中某一条判成通过或失败。
2026-09-26 21:47:29 +08:00
197 changed files with 16167 additions and 5853 deletions

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
# 编辑器与工具的统一约定。见 docs/adr/ 里关于编码的决定。
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
# PowerShell 源码必须带 BOM。
# 没有 BOM 时,Windows PowerShell 5.1 会按 ANSI 代码页解码源码文件:中文变乱码,
# 而全角问号之类的字节序列会吃掉字符串引号,整块语法随之失效
# (实测:全仓 6/6 文件在 5.1 上解析失败)。UTF-8 with BOM 是同时满足 5.1 与 7 的唯一编码。
[*.{ps1,psm1,psd1}]
charset = utf-8-bom
# 清单是纯文本,且被脚本以 Get-Content 读取;带 BOM 让两个版本都能正确识别编码。
[BackupList.txt]
charset = utf-8-bom
# YAML / JSON / 配置数据不带 BOM
[*.{json,yml,yaml}]
charset = utf-8
# Markdown 不做尾随空格裁剪:行尾两个空格是合法且常用的硬换行。
[*.md]
trim_trailing_whitespace = false
+28
View File
@@ -0,0 +1,28 @@
# 行尾与文本判定统一交给本文件,不依赖 core.autocrlf。
# 本机的 core.autocrlf = true,会让每次 checkout 把工作区改成 CRLF,
# 从而制造一个「除了行尾什么都没改」的巨大伪 diff。这里钉成 LF。
#
# 为什么用 LF 而不是 CRLF:本仓库的源文件一直是 LF,PS 5.1 与 7 都能正确执行 LF 文件;
# 而与仓库同级的 PowerShell/PSScriptAnalyzer 用的也是这一行。
* text=auto eol=lf
*.ps1 text
*.psm1 text
*.psd1 text
*.ps1xml text
*.md text
*.txt text
*.xml text
# 归档与二进制资源:不做任何行尾转换
*.7z binary
*.zip binary
*.rar binary
*.tar binary
*.exe binary
*.dll binary
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
+7 -1
View File
@@ -1,4 +1,4 @@
# 归档本体不进版本库(数 GB,且是随时可重建的产物) # 归档本体不进版本库(数 GB,且是随时可重建的产物)
Backups/ Backups/
# 运行日志 # 运行日志
@@ -23,3 +23,9 @@ logs/
*.swp *.swp
Thumbs.db Thumbs.db
desktop.ini desktop.ini
# 单文件构建产物(tools\Build-BakNRetModule.ps1 可随时重建)
dist/
# 调研探针的解包产物(NuGet 解包件与 .nupkg):结论进 ADR,二进制不进仓库
.scratch/_probe/load/
.scratch/_probe/nuget/
+14
View File
@@ -0,0 +1,14 @@
{
"default": true,
"MD004": false,
"MD007": { "indent": 4 },
"MD013": { "line_length": 240, "code_blocks": false, "tables": false },
"MD024": { "siblings_only": true },
"MD026": { "punctuation": ".,;:!" },
"MD029": { "style": "one" },
"MD033": false,
"MD034": false,
"MD038": false,
"MD042": false,
"no-hard-tabs": true
}
+28
View File
@@ -0,0 +1,28 @@
# TUI 选型探针(保留作实测证据)
这些脚本是 `docs/adr/0010~0013` 里那些实测结论的**来源**。结论与数字已经写进 ADR 正文;这里留的是
"能复现它们的东西" —— 将来想确认某个库现在还能不能用,重跑对应探针即可,不必重新摸索一遍。
## 它们证明了什么(结论见 ADR,这里只标注来源)
| 探针 | 证明的事 | 结论落在 |
| --- | --- | --- |
| `console-probe*.ps1`、`vt-probe.ps1` | 无控制台时 `$Host.UI.SupportsVirtualTerminal` **会撒谎**(返回 `True`),而 `[Console]::SetCursorPosition` 直接抛异常;VT 在本机默认已开 | ADR-0010(第一道闸门必须是 `IsOutputRedirected`) |
| `noconsole-probe.ps1` | 没有控制台时 `RawUI.WindowSize` 悄悄返回假的 `160x40`、`KeyAvailable` 返回 `True` | ADR-0010 |
| `tg-probe.ps1`、`tgwin*.ps1` | `Terminal.Gui` 1.15.0 的两个硬伤:要给内部 `NetMainLoop` 做反射、`Application.Shutdown()` 在两个版本上都抛 NRE | ADR-0010(否决) |
| `settle-probe.ps1`、`settle2-probe.ps1` | `Spectre.Console` 0.49.1 在 5.1 上能加载,但要额外带 4 个 DLL,且没有鼠标支持 | ADR-0010(否决) |
| `load-probe*.ps1` | `ConsoleGuiTools` 要求 PS 7.2(5.1 上不可能);本机原本没装任何 TUI 模块 | ADR-0010(否决) |
| `cells-probe.ps1`、`width-probe*.ps1` | 中文按列宽算(CJK 1 字符 = 2 列)、半角片假名 1 列、全角拉丁 2 列、框线字符 1 列、emoji 按码点 2 列;`RawUI.LengthInBufferCells` 在 5.1 上是错的、7 上是对的 | ADR-0010、第一轮 ① |
| `final-probe*.ps1`、`diag-probe.ps1` | 收口时的对照验证(把各家的实测结果并排跑一遍) | ADR-0010 |
| `TuiKit.ps1` | 零依赖方案的可运行原型(行内绘制 + 定位写 + 菜单),用来证明"不用任何库也能做出要的效果" | ADR-0010 |
## 重跑注意
- 需要**真终端**(不是重定向的管道):多数探针会读 `[Console]::WindowWidth`,重定向下会走退化分支。
- `load-probe*.ps1` 与 `settle*-probe.ps1` 依赖 `_probe/load/` 与 `_probe/nuget/` 两个目录,它们装的是
从 NuGet 拉来的 DLL/模块,**已删除并加进 `.gitignore`**(当时一次 `git add` 误把它们全扫进了提交)。
所以要重跑这几个,得先自己把包拉回来;**其余探针不依赖它们,可以直接跑**。
- 探针是**一次性脚本**:不参与构建,不受 `test.ps1` 的 Encode/Parse 层管辖(它们不在受管文件清单里)。
真正的 TUI 实现在 `BakNRet/Public/`(`Get-BakNRetCellWidth`、`Read-BakNRetKey`、`Write-BakNRetAt`、
`Invoke-BakNRetMenu` 等),有断言、跑在两个 PowerShell 版本上。
+200
View File
@@ -0,0 +1,200 @@
# ============================================================================
# ZERO-DEPENDENCY PowerShell TUI PROTOTYPE (works on 5.1 and 7.x)
# ASCII-only source so 5.1 (which reads non-BOM files as ANSI) parses it.
# ============================================================================
param([string]$OutFile, [switch]$Headless)
$script:Log = New-Object System.Collections.Generic.List[string]
function Note($s) { $script:Log.Add($s) }
# ---------------------------------------------------------------------------
# 1. CAPABILITY DETECTION -- never assume VT, never assume a console
# ---------------------------------------------------------------------------
$script:HasRealConsole = $false
try { $script:HasRealConsole = -not [Console]::IsOutputRedirected } catch { $script:HasRealConsole = $false }
$script:HasVT = $false
if ($script:HasRealConsole) {
try { $script:HasVT = [bool]$Host.UI.SupportsVirtualTerminal } catch { $script:HasVT = $false }
}
$script:Cols = 80; $script:Rows = 24
function Get-ConsoleSize {
$w = 0; $h = 0
try { $w = [Console]::WindowWidth; $h = [Console]::WindowHeight } catch { }
if ($w -le 0 -or $h -le 0) {
try { $s = $Host.UI.RawUI.WindowSize; $w = $s.Width; $h = $s.Height } catch { }
}
if ($w -le 0) { $w = 80 }
if ($h -le 0) { $h = 24 }
return @($w, $h)
}
# ---------------------------------------------------------------------------
# 2. DISPLAY WIDTH -- MUST be computed by hand. Measured facts:
# * ".Length" counts UTF-16 code units, NOT columns (CJK = 1 unit, 2 cols)
# * RawUI.LengthInBufferCells() is UNRELIABLE and INCONSISTENT:
# - 5.1: returns the char COUNT for CJK, and 2 for box-drawing chars
# - 7.x: returns 2 for CJK, 1 for box-drawing (correct)
# * Measure-Object -Character == .Length (useless for layout)
# So: own East-Asian-Wide table.
# ---------------------------------------------------------------------------
$script:WideRanges = @(
@(0x1100, 0x115F), @(0x2E80, 0x303E), @(0x3041, 0x33FF),
@(0x3400, 0x4DBF), @(0x4E00, 0x9FFF), @(0xA000, 0xA4CF),
@(0xAC00, 0xD7A3), @(0xF900, 0xFAFF), @(0xFE30, 0xFE6F),
@(0xFF00, 0xFF60), @(0xFFE0, 0xFFE6),
@(0x1F300, 0x1F64F), @(0x1F900, 0x1F9FF), @(0x20000, 0x2FFFD), @(0x30000, 0x3FFFD)
)
function Get-CellWidth {
param([string]$Text)
$w = 0
$i = 0
while ($i -lt $Text.Length) {
$cp = [int][char]$Text[$i]
if ($cp -ge 0xD800 -and $cp -le 0xDBFF -and ($i + 1) -lt $Text.Length) {
$lo = [int][char]$Text[$i + 1]
if ($lo -ge 0xDC00 -and $lo -le 0xDFFF) {
$cp = 0x10000 + (($cp - 0xD800) * 0x400) + ($lo - 0xDC00)
$i++
}
}
$cw = 1
foreach ($r in $script:WideRanges) { if ($cp -ge $r[0] -and $cp -le $r[1]) { $cw = 2; break } }
if ($cp -lt 0x20) { $cw = 0 }
if ($cp -eq 0x7F) { $cw = 0 }
$w += $cw
$i++
}
return $w
}
function Pad-ToWidth {
param([string]$Text, [int]$Width)
$cur = Get-CellWidth $Text
if ($cur -ge $Width) { return $Text }
return $Text + (' ' * ($Width - $cur))
}
# ---------------------------------------------------------------------------
# 3. LOW-LEVEL DRAW PRIMITIVES
# ---------------------------------------------------------------------------
function Move-To {
param([int]$X, [int]$Y)
if ($script:HasRealConsole) {
try { [Console]::SetCursorPosition($X, $Y); return } catch { }
}
if ($script:HasVT) { [Console]::Out.Write("$([char]27)[$($Y + 1);$($X + 1)H"); [Console]::Out.Flush() }
}
function Write-At {
param([int]$X, [int]$Y, [string]$Text, [string]$Color)
Move-To $X $Y
$useAnsi = ($script:HasVT) -and ((Get-CellWidth $Text) -eq 0 -or $Text -notmatch "[$([char]27)]")
if ($Color -and $useAnsi) {
$code = switch ($Color) { 'Red' { 91 } 'Green' { 92 } 'Yellow' { 93 } 'Cyan' { 96 } 'Gray' { 90 } default { 0 } }
if ($code) { Write-Host -NoNewline "$([char]27)[${code}m$Text$([char]27)[0m"; return }
}
if ($Color) { Write-Host -NoNewline -ForegroundColor $Color $Text } else { Write-Host -NoNewline $Text }
}
function New-Frame {
param([string]$Title, [int]$X, [int]$Y, [int]$W, [int]$H)
$tl = [char]0x250C; $tr = [char]0x2510; $bl = [char]0x2514; $br = [char]0x2518
$hz = [char]0x2500; $vt = [char]0x2502
Write-At $X $Y ($tl + ($hz.ToString() * ($W - 2)) + $tr) 'Cyan'
for ($r = 1; $r -lt ($H - 1); $r++) {
Write-At $X ($Y + $r) $vt 'Cyan'
Write-At ($X + $W - 1) ($Y + $r) $vt 'Cyan'
}
Write-At $X ($Y + $H - 1) ($bl + ($hz.ToString() * ($W - 2)) + $br) 'Cyan'
if ($Title) { Write-At ($X + 2) $Y (" $Title ") 'Yellow' }
}
# ---------------------------------------------------------------------------
# 4. MENU with multi-select: own key loop via RawUI.ReadKey
# ---------------------------------------------------------------------------
function Read-KeyRaw {
# Returns @{ Key; Char } -- 'NoEcho' keeps the console clean
try {
$k = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')
return @{ Key = $k.VirtualKeyCode; Char = $k.Character }
} catch {
Note ("ReadKey unavailable: " + $_.Exception.Message)
return $null
}
}
function Show-Menu {
param([string[]]$Items, [string[]]$Checked, [string]$Title)
$sel = 0; $top = 0; $visible = 10
while ($true) {
# ---- render ----
Write-Host ""
New-Frame $Title 0 0 60 ($visible + 4)
for ($i = 0; $i -lt [Math]::Min($visible, $Items.Count); $i++) {
$idx = $top + $i
$mark = if ($Checked[$idx] -eq 'x') { '[x]' } else { '[ ]' }
$line = "$mark $($Items[$idx])"
$col = if ($idx -eq $sel) { 'Yellow' } else { 'Gray' }
Write-At 2 ($i + 2) (Pad-ToWidth $line (56)) $col
}
Write-At 2 ($visible + 2) (Pad-ToWidth 'Space=toggle Enter=confirm Esc=cancel Up/Down=move' 56) 'Gray'
$k = Read-KeyRaw
if (-not $k) { Note 'No raw key input available -> returning current state.'; break }
switch ($k.Key) {
38 { if ($sel -gt 0) { $sel--; if ($sel -lt $top) { $top-- } } } # Up
40 { if ($sel -lt $Items.Count - 1) { $sel++; if ($sel -ge ($top + $visible)) { $top++ } } } # Down
32 { if ($Checked[$sel] -eq 'x') { $Checked[$sel] = ' ' } else { $Checked[$sel] = 'x' } } # Space
13 { return $Checked } # Enter
27 { return $null } # Esc
}
}
return $Checked
}
# ---------------------------------------------------------------------------
# 5. PROGRESS -- two strategies, pick by capability
# ---------------------------------------------------------------------------
function Show-Progress {
param([int]$Percent, [string]$Label, [int]$Y)
$width = 40
$filled = [int]([Math]::Round($width * $Percent / 100.0))
$bar = ('#' * $filled) + ('.' * ($width - $filled))
Write-At 2 $Y (Pad-ToWidth ("$Label [$bar] $Percent%") 76) 'Cyan'
}
function Show-Spinner {
param([int]$Frame, [string]$Label, [int]$Y)
$s = @('|', '/', '-', '\')[$Frame % 4]
Write-At 2 $Y (Pad-ToWidth "$s $Label" 76) 'Yellow'
}
# ---------------------------------------------------------------------------
# RUN
# ---------------------------------------------------------------------------
Note ("PSVersion : " + $PSVersionTable.PSVersion)
Note ("PSEdition : " + $PSVersionTable.PSEdition)
Note ("HasRealConsole : " + $script:HasRealConsole)
Note ("HasVT : " + $script:HasVT)
$sz = Get-ConsoleSize
Note ("Console size : " + $sz[0] + "x" + $sz[1])
Note ("ReadKey class : " + [System.Management.Automation.Host.KeyInfo].FullName)
Note ("Measure width demo: 'AB' -> " + (Get-CellWidth 'AB') + " cells, 2 CJK chars -> " + (Get-CellWidth ([string]::Concat([char]0x4E2D, [char]0x6587))) + " cells")
Note ("Pad-ToWidth('中文', 10) length = " + (Pad-ToWidth ([string]::Concat([char]0x4E2D, [char]0x6587)) 10).Length + " chars / " + (Get-CellWidth (Pad-ToWidth ([string]::Concat([char]0x4E2D, [char]0x6587)) 10)) + " cells")
if ($Headless -or (-not $script:HasRealConsole)) {
Note 'HEADLESS MODE: skipping interactive menu + drawing (no console attached).'
Note 'This is exactly the branch a scheduled task must take.'
} else {
try { [Console]::CursorVisible = $false } catch { }
try { [Console]::Clear() } catch { }
Show-Progress 0 'Probe' 12
for ($p = 10; $p -le 100; $p += 30) { Show-Progress $p 'Probe' 12; Start-Sleep -Milliseconds 250 }
$items = @('alpha-backup', 'beta-backup', 'gamma-backup', 'delta-backup', 'epsilon-backup')
$checked = @('x', ' ', ' ', ' ', ' ')
$res = Show-Menu -Items $items -Checked $checked -Title 'Select backup sets'
if ($res) { Note ('USER SELECTED: ' + (($items | Where-Object { $res[$items.IndexOf($_)] -eq 'x' }) -join ', ')) }
else { Note 'USER CANCELLED' }
try { [Console]::Clear() } catch { }
try { [Console]::CursorVisible = $true } catch { }
}
if ($OutFile) { [System.IO.File]::WriteAllLines($OutFile, $script:Log, (New-Object System.Text.UTF8Encoding($false))) }
else { $script:Log | ForEach-Object { Write-Host $_ } }
+55
View File
@@ -0,0 +1,55 @@
param([Parameter(Mandatory=$true)][string]$OutFile)
$lines = New-Object System.Collections.Generic.List[string]
function L($s) { $lines.Add($s) }
function MK([int[]]$cp) { -join ($cp | ForEach-Object { [char]$_ }) }
$raw = $Host.UI.RawUI
$cases = @(
,@('8 ASCII ', 'A1b2C3d4')
,@('2 CJK ', (MK @(0x4E2D, 0x6587)))
,@('4 CJK ', (MK @(0x4E2D, 0x6587, 0x5907, 0x4EFD)))
,@('3 boxdraw ', (MK @(0x250C, 0x2500, 0x2510)))
,@('1 emoji ', [char]::ConvertFromUtf32(0x1F600))
,@('2 fullwidth', (MK @(0xFF21, 0xFF22)))
,@('2 halfkata ', (MK @(0xFF71, 0xFF72)))
,@('CJK+colon ', (MK @(0x4E2D, 0x6587, 0xFF1A, 0x0020)) + 'X')
,@('punctuation', (MK @(0x3002, 0x3001, 0xFF0C)))
,@('hangul ', (MK @(0xD55C, 0xAE00)))
,@('kana ', (MK @(0x3042, 0x3044)))
,@('cyrillic ', (MK @(0x0416, 0x0418)))
,@('accented ', (MK @(0x00E9, 0x00E8)))
,@('tabs+ctrl ', "a`tb")
)
L ("PSVersion: " + $PSVersionTable.PSVersion + " PSEdition: " + $PSVersionTable.PSEdition)
L ("IsOutputRedirected: " + [Console]::IsOutputRedirected)
L ""
L "### `$Host.UI.RawUI.LengthInBufferCells() -- BUILT-IN cell-width API ###"
L (" method present? " + [bool]($raw | Get-Member -Name LengthInBufferCells -MemberType Method))
$mi = $raw | Get-Member -Name LengthInBufferCells -MemberType Method
if ($mi) { foreach ($d in $mi) { L (" overload: " + $d.Definition) } }
L ""
L " {0,-12} {1,8} {2,14} {3,10}" -f 'case', '.Length', 'LengthInBuffer', 'expected~'
foreach ($c in $cases) {
$nm = $c[0]; $s = [string]$c[1]
$w = 'n/a'
try { $w = $raw.LengthInBufferCells($s) } catch { $w = 'THREW: ' + $_.Exception.Message }
L (" {0,-12} {1,8} {2,14} {3,10}" -f $nm, $s.Length, $w, '')
}
L ""
L "### per-character version: LengthInBufferCells(char, offset) ###"
foreach ($c in $cases) {
$nm = $c[0]; $s = [string]$c[1]
$sum = 0; $eb = ''
foreach ($ch in $s.ToCharArray()) {
try { $u = $raw.LengthInBufferCells($ch); $eb += $u } catch { $eb += '?'; $sum = -999 }
}
L (" {0,-12} sum(char overload)={1} perchar={2}" -f $nm, $sum, $eb)
}
L ""
L "### Other RawUI helpers that exist in BOTH hosts ###"
L (" NewBufferCellArray overloads: " + (((($raw | Get-Member -Name NewBufferCellArray -MemberType Method).Definition) -join ' | ')))
L (" SetBufferContents overloads : " + (((($raw | Get-Member -Name SetBufferContents -MemberType Method).Definition) -join ' | ')))
L (" GetBufferContents overloads : " + (((($raw | Get-Member -Name GetBufferContents -MemberType Method).Definition) -join ' | ')))
L (" ScrollBufferContents overloads: " + (((($raw | Get-Member -Name ScrollBufferContents -MemberType Method).Definition) -join ' | ')))
L (" ReadKey overloads : " + (((($raw | Get-Member -Name ReadKey -MemberType Method).Definition) -join ' | ')))
L (" FlushInputBuffer overloads : " + (((($raw | Get-Member -Name FlushInputBuffer -MemberType Method).Definition) -join ' | ')))
[System.IO.File]::WriteAllLines($OutFile, $lines, (New-Object System.Text.UTF8Encoding($false)))
+59
View File
@@ -0,0 +1,59 @@
$out = Join-Path $env:TEMP 'console-probe.txt'
$lines = New-Object System.Collections.Generic.List[string]
function L($s) { $lines.Add($s) }
$src = @"
using System;
using System.Runtime.InteropServices;
public static class K32 {
[DllImport("kernel32.dll", SetLastError=true)] public static extern bool AllocConsole();
[DllImport("kernel32.dll", SetLastError=true)] public static extern bool FreeConsole();
[DllImport("kernel32.dll")] public static extern IntPtr GetStdHandle(int n);
[DllImport("kernel32.dll", SetLastError=true)] public static extern bool GetConsoleMode(IntPtr h, out uint m);
[DllImport("kernel32.dll", SetLastError=true)] public static extern bool SetConsoleMode(IntPtr h, uint m);
[DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow();
}
"@
Add-Type -TypeDefinition $src -ErrorAction Stop
$hwndBefore = [K32]::GetConsoleWindow()
L ("PSVersion : " + $PSVersionTable.PSVersion)
L ("CLR : " + [System.Environment]::Version)
L ("GetConsoleWindow(before AllocConsole) : " + $hwndBefore)
$ok = [K32]::AllocConsole()
L ("AllocConsole() : " + $ok)
$hOut = [K32]::GetStdHandle(-11); $hIn = [K32]::GetStdHandle(-10)
$mOut = 0; $mIn = 0
$okOut = [K32]::GetConsoleMode($hOut, [ref]$mOut)
$okIn = [K32]::GetConsoleMode($hIn, [ref]$mIn)
L ("STDOUT GetConsoleMode: " + $okOut + " mode=0x" + ('{0:X}' -f $mOut))
L ("STDIN GetConsoleMode: " + $okIn + " mode=0x" + ('{0:X}' -f $mIn))
L (" STDOUT ENABLE_VIRTUAL_TERMINAL_PROCESSING (0x0004) : " + (($mOut -band 4) -eq 4))
L (" STDOUT ENABLE_PROCESSED_OUTPUT (0x0001) : " + (($mOut -band 1) -eq 1))
L (" STDOUT ENABLE_WRAP_AT_EOL_OUTPUT (0x0002) : " + (($mOut -band 2) -eq 2))
L (" STDIN ENABLE_VIRTUAL_TERMINAL_INPUT (0x0200) : " + (($mIn -band 0x200) -eq 0x200))
$raw = $Host.UI.RawUI
$vtProp = $raw | Get-Member -Name SupportsVirtualTerminal -ErrorAction SilentlyContinue
$rawMembers = ($raw | Get-Member -MemberType Property | Select-Object -ExpandProperty Name) -join ','
L ("RawUI property list : " + $rawMembers)
L ("RawUI has SupportsVirtualTerminal member? " + [bool]$vtProp)
try { L ("RawUI.SupportsVirtualTerminal value : [" + $raw.SupportsVirtualTerminal + "]") } catch { L ("RawUI.SupportsVirtualTerminal THREW: " + $_.Exception.Message) }
try { L ("`$Host.UI.SupportsVirtualTerminal : [" + $Host.UI.SupportsVirtualTerminal + "]") } catch { L ("`$Host.UI.SupportsVirtualTerminal THREW: " + $_.Exception.Message) }
L ("[Console]::OutputEncoding : " + [Console]::OutputEncoding.WebName)
L ("[Console]::IsOutputRedirected : " + [Console]::IsOutputRedirected)
L ("[Console] ReinitCheck : skipped")
$w = $raw.WindowSize; $b = $raw.BufferSize
L ("RawUI.WindowSize : " + $w.Width + "x" + $w.Height)
L ("RawUI.BufferSize : " + $b.Width + "x" + $b.Height)
# activate VT and re-read
$okSet = [K32]::SetConsoleMode($hOut, $mOut -bor 4)
$mOut2 = 0
[void][K32]::GetConsoleMode($hOut, [ref]$mOut2)
L ("SetConsoleMode(out, mode|0x4) : " + $okSet + " -> new mode=0x" + ('{0:X}' -f $mOut2))
$tmp = join-path $env:TEMP 'vt-write-test.txt'
$lines | Set-Content -Path $tmp -Encoding UTF8
Write-Host ""
Write-Host "=== PROBE OUTPUT ON ATTACHED CONSOLE ===" -ForegroundColor Cyan
Write-Host "Try ANSI: [31mRED[0m [32mGREEN[0m"
$lines | ForEach-Object { Write-Host $_ }
Write-Host "=== END ===" -ForegroundColor Cyan
Start-Sleep -Seconds 2
[void][K32]::FreeConsole()
+66
View File
@@ -0,0 +1,66 @@
param([Parameter(Mandatory=$true)][string]$HostLabel, [Parameter(Mandatory=$true)][string]$OutFile)
$o = New-Object System.Collections.Specialized.OrderedDictionary
$src = @"
using System;
using System.Runtime.InteropServices;
public static class K32 {
[DllImport("kernel32.dll", SetLastError=true)] public static extern IntPtr GetStdHandle(int n);
[DllImport("kernel32.dll", SetLastError=true)] public static extern bool GetConsoleMode(IntPtr h, out uint m);
[DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow();
[DllImport("kernel32.dll")] public static extern uint GetConsoleOutputCP();
[DllImport("kernel32.dll")] public static extern uint GetACP();
}
"@
Add-Type -TypeDefinition $src -ErrorAction Stop
$hOut = [K32]::GetStdHandle(-11); $hIn = [K32]::GetStdHandle(-10)
$mOut = 0; $mIn = 0
$okOut = [K32]::GetConsoleMode($hOut, [ref]$mOut)
$okIn = [K32]::GetConsoleMode($hIn, [ref]$mIn)
$o['HostLabel'] = $HostLabel
$o['PSVersion'] = $PSVersionTable.PSVersion.ToString()
$o['CLR'] = [System.Environment]::Version.ToString()
$o['IsOutputRedirected'] = [Console]::IsOutputRedirected
$o['IsInputRedirected'] = [Console]::IsInputRedirected
$o['IsErrorRedirected'] = [Console]::IsErrorRedirected
$o['GetConsoleWindow'] = ('0x{0:X}' -f ([K32]::GetConsoleWindow().ToInt64()))
$o['GetConsoleOutputCP'] = [K32]::GetConsoleOutputCP()
$o['GetACP'] = [K32]::GetACP()
$o['OutputEncoding'] = [Console]::OutputEncoding.WebName
$o['GetConsoleMode_STDOUT_ok'] = $okOut
$o['STDOUT_mode_hex'] = ('0x{0:X}' -f $mOut)
$o['STDOUT_VT_PROCESSING_0x4'] = (($mOut -band 4) -eq 4)
$o['STDOUT_PROCESSED_OUTPUT_0x1'] = (($mOut -band 1) -eq 1)
$o['STDOUT_WRAP_EOL_0x2'] = (($mOut -band 2) -eq 2)
$o['STDIN_GetConsoleMode_ok'] = $okIn
$o['STDIN_mode_hex'] = ('0x{0:X}' -f $mIn)
$o['STDIN_VT_INPUT_0x200'] = (($mIn -band 0x200) -eq 0x200)
$raw = $Host.UI.RawUI
$o['RawUI_TypeName'] = $raw.GetType().FullName
$o['RawUI_members'] = (($raw | Get-Member -MemberType Property | Select-Object -ExpandProperty Name) -join ',')
$uiProp = $Host.UI | Get-Member -Name SupportsVirtualTerminal -MemberType Property -ErrorAction SilentlyContinue
$o['HostUI_has_SupportsVirtualTerminal_member'] = [bool]$uiProp
$o['Value_Host_UI_SupportsVirtualTerminal'] = [string]$Host.UI.SupportsVirtualTerminal
$o['RawUI_has_SupportsVirtualTerminal'] = [bool]($raw | Get-Member -Name SupportsVirtualTerminal -ErrorAction SilentlyContinue)
try { $o['Value_RawUI_SupportsVirtualTerminal'] = [string]$raw.SupportsVirtualTerminal } catch { $o['Value_RawUI_SupportsVirtualTerminal'] = 'THREW: ' + $_.Exception.Message }
try { $w = $raw.WindowSize; $o['RawUI_WindowSize'] = "$($w.Width)x$($w.Height)" } catch { $o['RawUI_WindowSize'] = 'THREW: ' + $_.Exception.Message }
try { $b = $raw.BufferSize; $o['RawUI_BufferSize'] = "$($b.Width)x$($b.Height)" } catch { $o['RawUI_BufferSize'] = 'THREW: ' + $_.Exception.Message }
try { $o['RawUI_MaxPhysicalWindowSize'] = (($raw.MaxPhysicalWindowSize).Width.ToString() + 'x' + ($raw.MaxPhysicalWindowSize).Height.ToString()) } catch { $o['RawUI_MaxPhysicalWindowSize'] = 'THREW: ' + $_.Exception.Message }
try { $raw.CursorPosition = (New-Object System.Management.Automation.Host.Coordinates 3,3); $o['SetCursorPosition_ok'] = $true; $o['CursorPosition_readback'] = "$($raw.CursorPosition.X),$($raw.CursorPosition.Y)" } catch { $o['SetCursorPosition_ok'] = 'THREW: ' + $_.Exception.Message }
try { $o['KeyAvailable_read_ok'] = 'ok: ' + $raw.KeyAvailable } catch { $o['KeyAvailable_read_ok'] = 'THREW: ' + $_.Exception.Message }
# Chinese width probe, ASCII-source via code points
$zh = [string]::Concat([char]0x4E2D, [char]0x6587) # Zhong Wen
$ascii = 'AB'
$o['zh_string'] = $zh
$o['zh_Length'] = $zh.Length
$o['ascii_Length'] = $ascii.Length
$o['GetConsoleWindowTitle'] = [Console]::Title
$json = $o | ConvertTo-Json -Compress
[System.IO.File]::WriteAllText($OutFile, $json, (New-Object System.Text.UTF8Encoding($false)))
$raw.CursorPosition = (New-Object System.Management.Automation.Host.Coordinates 0,0)
Write-Host ''
Write-Host ("--- console probe [{0}] PS {1} VT={2} mode={3} ---" -f $HostLabel, $PSVersionTable.PSVersion, $Host.UI.SupportsVirtualTerminal, ('0x{0:X}' -f $mOut)) -ForegroundColor Yellow
Write-Host ("ANSI test: " + [char]27 + "[31mRED-ANSI" + [char]27 + "[0m " + [char]27 + "[42mBG-GREEN" + [char]27 + "[0m " + [char]27 + "[1;36mBOLD-CYAN" + [char]27 + "[0m")
Write-Host ("Unicode box: " + [char]0x250C + [char]0x2500 + [char]0x2510)
Write-Host ("Chinese: " + $zh + " Length=" + $zh.Length)
Write-Host '--- end ---'
Start-Sleep -Seconds 4
+57
View File
@@ -0,0 +1,57 @@
param([Parameter(Mandatory=$true)][string]$HostLabel, [Parameter(Mandatory=$true)][string]$OutFile, [int]$WatchSeconds = 3)
$o = New-Object System.Collections.Specialized.OrderedDictionary
$src = @"
using System;
using System.Runtime.InteropServices;
public static class K32 {
[DllImport("kernel32.dll")] public static extern IntPtr GetStdHandle(int n);
[DllImport("kernel32.dll", SetLastError=true)] public static extern bool GetConsoleMode(IntPtr h, out uint m);
[DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow();
[DllImport("kernel32.dll")] public static extern uint GetConsoleOutputCP();
}
"@
Add-Type -TypeDefinition $src -ErrorAction Stop
$mOut = 0; $ok = [K32]::GetConsoleMode([K32]::GetStdHandle(-11), [ref]$mOut)
$o['HostLabel'] = $HostLabel
$o['PSVersion'] = $PSVersionTable.PSVersion.ToString()
$o['IsOutputRedirected'] = [Console]::IsOutputRedirected
$o['HWND'] = ('0x{0:X}' -f ([K32]::GetConsoleWindow().ToInt64()))
$o['ConsoleTitle'] = [Console]::Title
$o['GetConsoleOutputCP'] = [K32]::GetConsoleOutputCP()
$o['OutputEncoding'] = [Console]::OutputEncoding.WebName
$o['STDOUT_mode_hex'] = ('0x{0:X}' -f $mOut)
$o['STDOUT_VT_PROCESSING_0x4'] = (($mOut -band 4) -eq 4)
$o['Host_UI_SupportsVirtualTerminal'] = [string]$Host.UI.SupportsVirtualTerminal
$o['RawUI_SupportsVirtualTerminal_member'] = [bool]($Host.UI.RawUI | Get-Member -Name SupportsVirtualTerminal -ErrorAction SilentlyContinue)
$o['WT_SESSION'] = [string]$env:WT_SESSION
$o['WT_PROFILE_ID'] = [string]$env:WT_PROFILE_ID
$o['TERM_PROGRAM'] = [string]$env:TERM_PROGRAM
$o['TERM'] = [string]$env:TERM
$o['ConEmuANSI'] = [string]$env:ConEmuANSI
$o['PSEdition'] = $PSVersionTable.PSEdition
try { $o['RawUI_KeyAvailable'] = 'ok:' + $Host.UI.RawUI.KeyAvailable } catch { $o['RawUI_KeyAvailable'] = 'THREW: ' + $_.Exception.Message }
try { $o['Console_KeyAvailable'] = 'ok:' + [Console]::KeyAvailable } catch { $o['Console_KeyAvailable'] = 'THREW: ' + $_.Exception.Message }
try {
[Console]::SetCursorPosition(5, 5)
$o['Console_SetCursorPosition_5_5'] = 'ok -> CursorLeft=' + [Console]::CursorLeft + ' CursorTop=' + [Console]::CursorTop
} catch { $o['Console_SetCursorPosition_5_5'] = 'THREW: ' + $_.Exception.Message }
try {
$c = New-Object System.Management.Automation.Host.Coordinates 7, 7
$Host.UI.RawUI.CursorPosition = $c
$o['RawUI_SetCursorPosition'] = 'ok -> ' + $Host.UI.RawUI.CursorPosition.X + ',' + $Host.UI.RawUI.CursorPosition.Y
} catch { $o['RawUI_SetCursorPosition'] = 'THREW: ' + $_.Exception.Message }
try { $o['RawUI_WindowSize'] = "$($Host.UI.RawUI.WindowSize.Width)x$($Host.UI.RawUI.WindowSize.Height)" } catch { $o['RawUI_WindowSize'] = 'THREW: ' + $_.Exception.Message }
try { $o['RawUI_BufferSize'] = "$($Host.UI.RawUI.BufferSize.Width)x$($Host.UI.RawUI.BufferSize.Height)" } catch { $o['RawUI_BufferSize'] = 'THREW: ' + $_.Exception.Message }
try { $o['RawUI_MaxWindowSize'] = "$($Host.UI.RawUI.MaxWindowSize.Width)x$($Host.UI.RawUI.MaxWindowSize.Height)" } catch { $o['RawUI_MaxWindowSize'] = 'THREW: ' + $_.Exception.Message }
try { $o['RawUI_MaxPhysicalWindowSize'] = "$($Host.UI.RawUI.MaxPhysicalWindowSize.Width)x$($Host.UI.RawUI.MaxPhysicalWindowSize.Height)" } catch { $o['RawUI_MaxPhysicalWindowSize'] = 'THREW: ' + $_.Exception.Message }
try { $o['CursorSize'] = [string]$Host.UI.RawUI.CursorSize } catch { $o['CursorSize'] = 'THREW' }
try { $o['ForegroundColor'] = [string]$Host.UI.RawUI.ForegroundColor } catch { $o['ForegroundColor'] = 'THREW' }
try { $o['WindowTitle'] = [string]$Host.UI.RawUI.WindowTitle } catch { $o['WindowTitle'] = 'THREW' }
$o['ProgressPreference'] = [string]$ProgressPreference
$o['HostName'] = $Host.Name
$json = $o | ConvertTo-Json -Compress
[System.IO.File]::WriteAllText($OutFile, $json, (New-Object System.Text.UTF8Encoding($false)))
Write-Host ("[{0}] PS {1} VT={2} redirected={3} WT_SESSION={4}" -f $HostLabel, $PSVersionTable.PSVersion, $Host.UI.SupportsVirtualTerminal, [Console]::IsOutputRedirected, $env:WT_SESSION) -ForegroundColor Yellow
Write-Host (" ANSI: " + [char]27 + "[31mRED" + [char]27 + "[0m " + [char]27 + "[104mBG-BRIGHT-BLUE" + [char]27 + "[0m " + [char]27 + "[38;2;255;128;0mTRUECOLOR" + [char]27 + "[0m")
Write-Host (" Positioned: " + [char]27 + "[2;10HAT-ROW2-COL10")
if ($WatchSeconds -gt 0) { Start-Sleep -Seconds $WatchSeconds }
+77
View File
@@ -0,0 +1,77 @@
param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work)
$lines = New-Object System.Collections.Generic.List[string]
function L($s) { $lines.Add($s); [System.IO.File]::WriteAllLines($OutFile, $lines, (New-Object System.Text.UTF8Encoding($false))) }
L ("PSVersion: " + $PSVersionTable.PSVersion + " IsOutputRedirected=" + [Console]::IsOutputRedirected)
$nstack = Join-Path $Work 'nstack111\lib\netstandard2.0\NStack.dll'
$tg = Join-Path $Work 'tg115\lib\net472\Terminal.Gui.dll'
$sc = Join-Path $Work 'sc049\lib\netstandard2.0\Spectre.Console.dll'
function Dump($asm, $tag) {
L (" [" + $tag + "] FullName = " + $asm.FullName)
try { L (" [$tag] Location = " + $asm.Location) } catch { L (" [$tag] Location: dynamic") }
try {
$ts = $asm.GetTypes()
L (" [$tag] GetTypes() = " + $ts.Count + " types")
$pub = $ts | Where-Object { $_.IsPublic } | Select-Object -First 4
L (" [$tag] sample public: " + (($pub | ForEach-Object { $_.FullName }) -join ' | '))
$app = $ts | Where-Object { $_.FullName -eq 'Terminal.Gui.Application' }
L (" [$tag] type named Terminal.Gui.Application in GetTypes(): " + $(if ($app) { 'YES -> ' + $app.AssemblyQualifiedName } else { 'NO' }))
} catch { L (" [$tag] GetTypes() THREW: " + $_.Exception.Message) }
}
L ""
L "=== STEP 1: Add-Type -Path (no prior load) ==="
Add-Type -Path $nstack
L " NStack Add-Type OK"
Add-Type -Path $tg
L " Terminal.Gui Add-Type OK"
$loaded = [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -in @('NStack','Terminal.Gui') }
foreach ($a in $loaded) { Dump $a $a.GetName().Name }
L ""
L "=== STEP 2: type resolution methods ==="
$tgAsm = $loaded | Where-Object { $_.GetName().Name -eq 'Terminal.Gui' }
L (" asm.GetType('Terminal.Gui.Application') -> " + $(try { $r = $tgAsm.GetType('Terminal.Gui.Application'); if ($r) { 'FOUND ' + $r.FullName } else { 'null' } } catch { 'THREW ' + $_.Exception.Message }))
L (" [Type]::GetType('Terminal.Gui.Application, Terminal.Gui') -> " + $(try { $r = [Type]::GetType('Terminal.Gui.Application, Terminal.Gui'); if ($r) { 'FOUND ' + $r.FullName } else { 'null' } } catch { 'THREW ' + $_.Exception.Message }))
try {
$aq = $tgAsm.GetName().FullName
L (" asm fullname = " + $aq)
$r2 = [Type]::GetType('Terminal.Gui.Application, ' + $aq)
L (" [Type]::GetType(full qualified) -> " + $(if ($r2) { 'FOUND' } else { 'null' }))
} catch { L (" full-qualified Type.GetType THREW: " + $_.Exception.Message) }
L ""
L "=== STEP 3: Application static method overloads (real API) ==="
$appType = $tgAsm.GetType('Terminal.Gui.Application')
if ($appType) {
foreach ($m in ($appType.GetMethods([System.Reflection.BindingFlags]'Public,Static') | Where-Object { $_.Name -in @('Init','Shutdown','Run','Refresh','Begin','End') })) {
L (" " + $m.ReturnType.Name + " " + $m.Name + "(" + (($m.GetParameters() | ForEach-Object { $_.ParameterType.Name + ' ' + $_.Name }) -join ', ') + ")")
}
}
L ""
L "=== STEP 4: Spectre.Console type resolution ==="
Add-Type -Path $sc
L " Spectre.Console Add-Type OK"
$scAsm = [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq 'Spectre.Console' }
Dump $scAsm 'Spectre.Console'
$ansiT = $scAsm.GetType('Spectre.Console.AnsiConsole')
L (" scAsm.GetType('Spectre.Console.AnsiConsole') -> " + $(if ($ansiT) { 'FOUND' } else { 'null' }))
if ($ansiT) {
L (" static methods of AnsiConsole: " + (($ansiT.GetMethods([System.Reflection.BindingFlags]'Public,Static') | Select-Object -First 25 | ForEach-Object { $_.Name }) -join ', '))
}
L ""
L "=== STEP 5: is Table.AddRow an EXTENSION method on IGridColumn/Table? ==="
$tableT = $scAsm.GetType('Spectre.Console.Table')
L (" Table type: " + [bool]$tableT)
if ($tableT) {
L (" Table instance methods containing 'Add': " + (($tableT.GetMethods() | Where-Object { $_.Name -like '*Add*' } | ForEach-Object { $_.Name + '(' + (($_.GetParameters() | ForEach-Object { $_.ParameterType.Name }) -join ',') + ')' }) -join ' | '))
L (" Table properties: " + (($tableT.GetProperties() | ForEach-Object { $_.PropertyType.Name + ' ' + $_.Name }) -join ', '))
}
$extT = $scAsm.GetType('Spectre.Console.TableExtensions')
L (" TableExtensions type: " + [bool]$extT)
if ($extT) {
$em = $extT.GetMethods([System.Reflection.BindingFlags]'Public,Static') | Where-Object { $_.Name -like '*Row*' -or $_.Name -like '*Column*' }
foreach ($m in $em) { L (" ext: " + $m.ReturnType.Name + ' ' + $m.Name + '(' + (($m.GetParameters() | ForEach-Object { $_.ParameterType.Name + ' ' + $_.Name }) -join ', ') + ')') }
}
L ""
L "=== STEP 6: Spectre prompts / progress classes available in netstandard2.0 build ==="
foreach ($n in @('Spectre.Console.ConfirmationPrompt','Spectre.Console.SelectionPrompt`1','Spectre.Console.MultiSelectionPrompt`1','Spectre.Console.TextPrompt`1','Spectre.Console.Progress','Spectre.Console.Status','Spectre.Console.Live','Spectre.Console.Layout','Spectre.Console.Panel','Spectre.Console.Rule','Spectre.Console.Tree','Spectre.Console.BarChart','Spectre.Console.BreakdownChart','Spectre.Console.ProgressBar','Spectre.Console.ProgressColumn','Spectre.Console.SpinnerColumn','Spectre.Console.Columns','Spectre.Console.Rows','Spectre.Console.Grid','Spectre.Console.Padder','Spectre.Console.Align','Spectre.Console.Canvas','Spectre.Console.Json','Spectre.Console.Calendar','Spectre.Console.ExceptionFormats','Spectre.Console.AnsiConsoleSettings','Spectre.Console.AnsiConsoleFactory','Spectre.Console.IAnsiConsole')) {
$tt = $scAsm.GetType($n)
L (" " + $n.PadRight(44) + " -> " + $(if ($tt) { 'AVAILABLE' } else { 'MISSING' }))
}
+109
View File
@@ -0,0 +1,109 @@
param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work, [int]$HoldSeconds = 5)
$lines = New-Object System.Collections.Generic.List[string]
function L($s) { $lines.Add($s); [System.IO.File]::WriteAllLines($OutFile, $lines, (New-Object System.Text.UTF8Encoding($false))) }
function Braino([string]$m) { return $null }
L ("PSVersion: " + $PSVersionTable.PSVersion + " CLR " + [System.Environment]::Version + " Redirected=" + [Console]::IsOutputRedirected)
$nstack = Join-Path $Work 'nstack111\lib\netstandard2.0\NStack.dll'
$tg = Join-Path $Work 'tg115\lib\net472\Terminal.Gui.dll'
$sc = Join-Path $Work 'sc049\lib\netstandard2.0\Spectre.Console.dll'
function A([string]$fullName, [string]$asmName) { return ([AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq $asmName }).GetType($fullName) }
function GetT([string]$fullName) { return ([AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq 'Terminal.Gui' }).GetType($fullName) }
function GetS([string]$fullName) { return ([AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq 'Spectre.Console' }).GetType($fullName) }
L ""
L "############ QUESTION: after Add-Type -Path, do PS type literals work? ############"
Add-Type -Path $nstack -ErrorAction Stop
Add-Type -Path $tg -ErrorAction Stop
L (" [bool]([Type]::GetType('Terminal.Gui.Application, Terminal.Gui')) = " + [bool]([Type]::GetType('Terminal.Gui.Application, Terminal.Gui')))
L (" [bool]([Type]::GetType('Terminal.Gui.Application, ' + full)) = " + [bool]([Type]::GetType('Terminal.Gui.Application, ' + (GetT 'Terminal.Gui.Application').AssemblyQualifiedName)))
L (" script GetT('Terminal.Gui.Application') = " + [bool](GetT 'Terminal.Gui.Application') + " <-- reliable in BOTH hosts")
$litOk = $false
try { $litOk = [bool]([Terminal.Gui.Application] -is [Type]) } catch { $litOk = "THREW: " + $_.Exception.Message }
L (" PS type literal [Terminal.Gui.Application] = " + $litOk)
L ""
L "############ A) Terminal.Gui 1.15.0 (net472) DRIVER-WIRED RUN ############"
$tgResult = 'not-attempted'
try {
$appT = GetT 'Terminal.Gui.Application'
$netT = GetT 'Terminal.Gui.NetDriver'
L (" Application type : " + [bool]$appT + " NetDriver type: " + [bool]$netT)
$initM = $appT.GetMethod('Init', [Type[]]@($netT, ($netT.GetInterfaces() | Where-Object { $_.FullName -eq 'Terminal.Gui.IMainLoopDriver' })[0]))
L (" Init(NetDriver, IMainLoopDriver) overload: " + [bool]$initM)
if ([Console]::IsOutputRedirected) {
L " stdout redirected -> Terminal.Gui CANNOT paint; skipping the live run (documented limitation)"
try { $initM.Invoke($null, @([Activator]::CreateInstance($netT), $null)) | Out-Null; L " unexpectedly Init'ed" } catch { L (" expected failure: " + $_.Exception.InnerException.Message.Split([char]10)[0]) }
$tgResult = 'blocked-by-redirect'
} else {
$drv = [Activator]::CreateInstance($netT)
$initM.Invoke($null, @($drv, $null)) | Out-Null
L " Application.Init(NetDriver) -> SUCCEEDED *** Terminal.Gui 1.15.0 is LIVE inside PowerShell " + $PSVersionTable.PSVersion + " ***"
$winT = GetT 'Terminal.Gui.Window'; $lblT = GetT 'Terminal.Gui.Label'; $btnT = GetT 'Terminal.Gui.Button'
$w = [Activator]::CreateInstance($winT, [object[]]@('BakNRet / Terminal.Gui - PS ' + $PSVersionTable.PSVersion))
$l1 = [Activator]::CreateInstance($lblT, [object[]]@('Terminal.Gui 1.15.0 net472 - running on .NET Framework ' + [System.Environment]::Version))
$l2 = [Activator]::CreateInstance($lblT, [object[]]@('CJK cell-width check:'))
$l3 = [Activator]::CreateInstance($lblT, [object[]]@(' 2 CJK chars -> 4 columns'))
$b1 = [Activator]::CreateInstance($btnT, [object[]]@('OK'))
$w.Add($l1); $w.Add($l2); $w.Add($l3); $w.Add($b1)
$w.X = 1; $w.Y = 1; $w.Width = 70; $w.Height = 9
$l1.Y = 1; $l2.Y = 3; $l3.Y = 4; $b1.Y = 6; $b1.X = 2
$homeT = GetT 'Terminal.Gui.Toplevel'
($appT.GetProperty('TopLevel')).SetValue($null, $w)
$w.SetFocus()
$w.Redraw($w.Bounds)
$appT.GetMethod('Refresh', [Type[]]@()).Invoke($null, @()) | Out-Null
L " Window(70x9) + 3 Labels + Button painted. Holding " + $HoldSeconds + "s..."
Start-Sleep -Seconds $HoldSeconds
$appT.GetMethod('Shutdown', [Type[]]@()).Invoke($null, @()) | Out-Null
L " Application.Shutdown() -> clean"
$tgResult = 'WORKS'
}
} catch {
L (" Terminal.Gui FAILED: " + $_.Exception.GetType().Name + ': ' + $_.Exception.Message)
$ie = $_.Exception.InnerException; while ($ie) { L (" inner: " + $ie.GetType().Name + ': ' + ($ie.Message -split "`n")[0]); $ie = $ie.InnerException }
$tgResult = 'FAILED'
}
L ""
L "############ B) Spectre.Console 0.49.1 (netstandard2.0) REAL RENDERING ############"
$scResult = 'not-attempted'
try {
Add-Type -Path $sc -ErrorAction Stop
$ansiT = GetS 'Spectre.Console.AnsiConsole'
$tableT = GetS 'Spectre.Console.Table'
$extT = GetS 'Spectre.Console.TableExtensions'
L (" AnsiConsole/Table/TableExtensions types: " + [bool]$ansiT + "/" + [bool]$tableT + "/" + [bool]$extT)
# Build a table WITHOUT AddRow extension methods (instance API only) to isolate extension-call support
$t = [Activator]::CreateInstance($tableT)
$colT = GetS 'Spectre.Console.TableColumn'
$t.AddColumn([Activator]::CreateInstance($colT, [object[]]@('项目 Item')))
$t.AddColumn([Activator]::CreateInstance($colT, [object[]]@('大小 Size')))
L " 2 columns added via INSTANCE AddColumn(TableColumn) -> OK"
# extension method called the PowerShell way
$addRow = $extT.GetMethod('AddRow', [Type[]]@($tableT, [string[]]))
L (" found extension TableExtensions.AddRow(Table, string[]): " + [bool]$addRow)
$addRow.Invoke($null, [object[]]@($t, [string[]]@('中文备份 alpha', '1.2 GB'))) | Out-Null
$addRow.Invoke($null, [object[]]@($t, [string[]]@('beta', 'OK'))) | Out-Null
L " 2 rows added via reflection-invoked EXTENSION method -> OK"
$ansiT::Write($t)
L " >>> AnsiConsole.Write(Table) with mixed-width CJK content rendered above <<<"
try { $ansiT::Markup('[red]red[/] [green]green[/] [bold]bold[/] [yellow]yellow[/]') ; L " AnsiConsole.Markup color test -> OK" } catch { L (" Markup FAILED: " + $_.Exception.Message) }
Write-Host ""
Write-Host " -- Spectre.Console table rendered from PowerShell $($PSVersionTable.PSVersion) --" -ForegroundColor Cyan
$ansiT::Write($t)
Start-Sleep -Seconds 3
# Progress demo (bounded)
try {
$progT = GetS 'Spectre.Console.Progress'
L (" Progress type: " + [bool]$progT)
$act = { }
L " (Progress/Status need a script-block -> Action`1 delegate; skipped live run to stay bounded)"
} catch { L (" progress probe failed: " + $_.Exception.Message) }
$scResult = 'WORKS'
} catch {
L (" Spectre FAILED: " + $_.Exception.GetType().Name + ': ' + $_.Exception.Message)
$ie = $_.Exception.InnerException; while ($ie) { L (" inner: " + $ie.GetType().Name + ': ' + ($ie.Message -split "`n")[0]); $ie = $ie.InnerException }
$scResult = 'FAILED'
}
L ""
L "RESULT: TerminalGui=$tgResult SpectreConsole=$scResult"
+116
View File
@@ -0,0 +1,116 @@
param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work, [int]$HoldSeconds = 5)
$lines = New-Object System.Collections.Generic.List[string]
function L($s) { $lines.Add($s); [System.IO.File]::WriteAllLines($OutFile, $lines, (New-Object System.Text.UTF8Encoding($false))) }
L ("PSVersion: " + $PSVersionTable.PSVersion + " CLR " + [System.Environment]::Version + " Redirected=" + [Console]::IsOutputRedirected)
$nstack = Join-Path $Work 'nstack111\lib\netstandard2.0\NStack.dll'
$tg = Join-Path $Work 'tg115\lib\net472\Terminal.Gui.dll'
$sc = Join-Path $Work 'sc049\lib\netstandard2.0\Spectre.Console.dll'
$mem461 = Join-Path $Work 'system.memory-4.5.5\lib\net461\System.Memory.dll'
$memNs = Join-Path $Work 'system.memory-4.5.5\lib\netstandard2.0\System.Memory.dll'
function Asm([string]$n) { return [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq $n } | Select-Object -First 1 }
function T([string]$n, [string]$a) { $x = Asm $a; if ($x) { return $x.GetType($n) } else { return $null } }
L ""
L "############ STEP 0: side-load the ONE dependency Spectre.Console needs ############"
$mem = if ($PSVersionTable.PSEdition -eq 'Desktop') { $mem461 } else { $memNs }
L (" chosen System.Memory for this host: " + $mem)
try {
Add-Type -Path $mem -ErrorAction Stop
$m = Asm 'System.Memory'
L (" Add-Type -Path System.Memory -> OK: " + $m.GetName().FullName)
L (" [System.Span[int]] resolves now: " + [bool]([Type]::GetType('System.Span`1')) )
} catch { L (" System.Memory load FAILED: " + $_.Exception.Message) }
L ""
L "############ A) Terminal.Gui 1.15.0 net472 - CORRECTED driver wiring ############"
$tgResult = 'not-attempted'
try {
Add-Type -Path $nstack -ErrorAction Stop
Add-Type -Path $tg -ErrorAction Stop
$appT = T 'Terminal.Gui.Application' 'Terminal.Gui'
$netT = T 'Terminal.Gui.NetDriver' 'Terminal.Gui'
L (" Application: " + [bool]$appT + " NetDriver: " + [bool]$netT)
L (" NetDriver interfaces: " + (($netT.GetInterfaces() | ForEach-Object { $_.FullName }) -join ', '))
$mlT = T 'Terminal.Gui.IMainLoopDriver' 'Terminal.Gui'
L (" IMainLoopDriver found as type: " + [bool]$mlT)
$initM = $appT.GetMethod('Init', [Type[]]@($netT, $mlT))
L (" Init(NetDriver, IMainLoopDriver) overload: " + [bool]$initM)
if ([Console]::IsOutputRedirected) {
L " stdout redirected -> Terminal.Gui cannot paint. Expected failure follows:"
try { $initM.Invoke($null, @([Activator]::CreateInstance($netT), $null)) | Out-Null; L " unexpectedly Init'ed" }
catch { L (" expected: " + ($_.Exception.InnerException.Message -split "`n")[0]) }
$tgResult = 'works-only-with-a-real-console'
} else {
$drv = [Activator]::CreateInstance($netT)
L (" NetDriver instance: " + $drv.GetType().FullName + " is IMainLoopDriver: " + [bool]($mlT.IsInstanceOfType($drv)))
$initM.Invoke($null, @($drv, $drv)) | Out-Null
L " Application.Init(driver, driver) -> SUCCEEDED *** Terminal.Gui is LIVE on PowerShell " + $PSVersionTable.PSVersion + " ***"
IEX "0"
$winT = T 'Terminal.Gui.Window' 'Terminal.Gui'; $lblT = T 'Terminal.Gui.Label' 'Terminal.Gui'; $btnT = T 'Terminal.Gui.Button' 'Terminal.Gui'
$w = [Activator]::CreateInstance($winT, [object[]]@('BakNRet - Terminal.Gui on PS ' + $PSVersionTable.PSVersion))
$l1 = [Activator]::CreateInstance($lblT, [object[]]@('Terminal.Gui 1.15.0 (net472) on ' + $(if ($PSVersionTable.PSEdition -eq 'Desktop') { '.NET Framework ' } else { '.NET ' }) + [System.Environment]::Version))
$l2 = [Activator]::CreateInstance($lblT, [object[]]@('CJK test: 2 CJK chars occupy 4 screen columns'))
$l3 = [Activator]::CreateInstance($lblT, [object[]]@('Box drawing: expected to align'))
$b1 = [Activator]::CreateInstance($btnT, [object[]]@('OK'))
$w.Add($l1); $w.Add($l2); $w.Add($l3); $w.Add($b1)
$w.X = 1; $w.Y = 1; $w.Width = 72; $w.Height = 9
$l1.Y = 1; $l2.Y = 3; $l3.Y = 4; $b1.Y = 6; $b1.X = 2
($appT.GetProperty('TopLevel')).SetValue($null, $w)
$w.SetFocus(); $w.Redraw($w.Bounds)
$appT.GetMethod('Refresh', [Type[]]@()).Invoke($null, @()) | Out-Null
L " Painted Window(72x9)+3 Labels+Button. Holding " + $HoldSeconds + "s for visual confirmation..."
Start-Sleep -Seconds $HoldSeconds
$appT.GetMethod('Shutdown', [Type[]]@()).Invoke($null, @()) | Out-Null
L " Application.Shutdown() -> clean"
$tgResult = 'WORKS'
}
} catch {
L (" Terminal.Gui FAILED: " + $_.Exception.GetType().Name + ': ' + $_.Exception.Message)
$ie = $_.Exception.InnerException; while ($ie) { L (" inner: " + $ie.GetType().Name + ': ' + ($ie.Message -split "`n")[0]); $ie = $ie.InnerException }
$tgResult = 'FAILED'
}
L ""
L "############ B) Spectre.Console 0.49.1 netstandard2.0 - with System.Memory present ############"
$scResult = 'not-attempted'
try {
Add-Type -Path $sc -ErrorAction Stop
$ansiT = T 'Spectre.Console.AnsiConsole' 'Spectre.Console'
$tableT = T 'Spectre.Console.Table' 'Spectre.Console'
$colT = T 'Spectre.Console.TableColumn' 'Spectre.Console'
$extT = T 'Spectre.Console.TableExtensions' 'Spectre.Console'
$t = [Activator]::CreateInstance($tableT)
L " new Table() -> OK *** THIS FAILED BEFORE System.Memory WAS SIDE-LOADED ***"
$t.AddColumn([Activator]::CreateInstance($colT, [object[]]@('项目 / Item')))
$t.AddColumn([Activator]::CreateInstance($colT, [object[]]@('大小 / Size')))
$addRow = $extT.GetMethod('AddRow', [Type[]]@($tableT, [string[]]))
$addRow.Invoke($null, [object[]]@($t, [string[]]@('中文备份归档 alpha', '1.2 GB'))) | Out-Null
$addRow.Invoke($null, [object[]]@($t, [string[]]@('beta', 'OK'))) | Out-Null
Write-Host ""
Write-Host (" === Spectre.Console table, rendered by PowerShell $($PSVersionTable.PSVersion) ===") -ForegroundColor Cyan
$ansiT::Write($t)
Write-Host ""
$ansiT::Markup('[red]red[/] [green]green[/] [yellow]yellow[/] [bold]bold[/] [blue on white]reverse[/]')
L " AnsiConsole.Write(Table + CJK) + Markup colors -> INVOKED"
# Now confirm extension methods callable with PowerShell's native :: syntax
try {
$t2 = [Activator]::CreateInstance($tableT)
$ok1 = $false
try { [Spectre.Console.TableExtensions]::AddColumn($t2, 'ColA') | Out-Null; $ok1 = $true } catch { $ok1 = 'THREW: ' + $_.Exception.Message }
L (" PowerShell-native [Spectre.Console.TableExtensions]::AddColumn(`$table,'ColA') -> " + $ok1)
$t3 = [Activator]::CreateInstance($tableT)
[Spectre.Console.TableExtensions]::AddColumns($t3, [string[]]@('A','B')) | Out-Null
[Spectre.Console.TableExtensions]::AddRow($t3, [string[]]@('x','y')) | Out-Null
L " [Spectre.Console.TableExtensions]::AddColumns/AddRow via PS-native static call -> OK"
$t4 = [Activator]::CreateInstance($tableT)
try { $t4.AddColumns('A','B'); L " INSTANCE SYNTAX `$t4.AddColumns('A','B') -> OK (PS resolves extension methods)" } catch { L (" INSTANCE SYNTAX `$t4.AddColumns -> THREW: " + $_.Exception.Message) }
} catch { L (" native-syntax probe failed: " + $_.Exception.Message) }
$scResult = 'WORKS'
} catch {
L (" Spectre FAILED: " + $_.Exception.GetType().Name + ': ' + $_.Exception.Message)
$ie = $_.Exception.InnerException; while ($ie) { L (" inner: " + $ie.GetType().Name + ': ' + ($ie.Message -split "`n")[0]); $ie = $ie.InnerException }
$scResult = 'FAILED'
}
L ""
L "RESULT: TerminalGui=$tgResult SpectreConsole=$scResult"
if (-not [Console]::IsOutputRedirected) { Write-Host ""; Write-Host ("[final PS " + $PSVersionTable.PSVersion + "] TerminalGui=$tgResult Spectre=$scResult (closing in $HoldSeconds s)") -ForegroundColor Yellow; Start-Sleep -Seconds $HoldSeconds }
+93
View File
@@ -0,0 +1,93 @@
param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work)
$lines = New-Object System.Collections.Generic.List[string]
function L($s) { $lines.Add($s) }
L ("PSVersion: " + $PSVersionTable.PSVersion + " CLR " + [System.Environment]::Version)
L ("Work: " + $Work)
$tgNet472 = Join-Path $Work 'tg115\lib\net472\Terminal.Gui.dll'
$tgNs21 = Join-Path $Work 'tg115\lib\netstandard2.1\Terminal.Gui.dll'
$tgNs20 = Join-Path $Work 'tg115\lib\netstandard2.0\Terminal.Gui.dll'
$nstack = Join-Path $Work 'nstack\lib\netstandard2.0\NStack.dll'
$scNs20 = Join-Path $Work 'sc049\lib\netstandard2.0\Spectre.Console.dll'
L ""
L "### STEP 1: try [Reflection.Assembly]::LoadFrom on Terminal.Gui net472 (5.1's native TFM) WITHOUT dependencies ###"
try { $a = [System.Reflection.Assembly]::LoadFrom($tgNet472); L (" LoadFrom tg/net472 -> OK: " + $a.FullName) } catch { L (" LoadFrom tg/net472 FAILED: " + $_.Exception.GetType().Name + ': ' + $_.Exception.Message) }
L ""
L "### STEP 2: try GetTypes() -- this forces dependency resolution ###"
if ($a) {
try { $ts = $a.GetTypes(); L (" GetTypes() -> OK, " + $ts.Count + " types. Sample: " + (($ts | Select-Object -First 5 | ForEach-Object { $_.FullName }) -join ', ')) }
catch {
L (" GetTypes() FAILED: " + $_.Exception.GetType().Name + ': ' + $_.Exception.Message)
$ie = $_.Exception.InnerException
while ($ie) { L (" inner: " + $ie.GetType().Name + ': ' + $ie.Message); $ie = $ie.InnerException }
$le = $_.Exception.LoaderExceptions
if ($le) { foreach ($x in $le) { L (" loaderEx: " + $x.Message) } }
}
try { $appType = $a.GetType('Terminal.Gui.Application'); L (" GetType('Terminal.Gui.Application') -> " + $(if ($appType) { 'FOUND: ' + $appType.FullName } else { 'null' })) } catch { L (" GetType(Application) THREW: " + $_.Exception.Message) }
}
L ""
L "### STEP 3: load NStack.Core first (the declared dependency), then Terminal.Gui ###"
try { $ns = [System.Reflection.Assembly]::LoadFrom($nstack); L (" LoadFrom NStack.Core/netstandard2.0 -> " + $ns.FullName) } catch { L (" NStack load FAILED: " + $_.Exception.Message) }
try {
$a2 = [System.Reflection.Assembly]::LoadFrom($tgNet472)
$ts2 = $a2.GetTypes()
L (" after NStack preload: GetTypes() -> OK, " + $ts2.Count + " types")
$appType = $a2.GetType('Terminal.Gui.Application')
L (" Terminal.Gui.Application -> " + $(if ($appType) { 'FOUND' } else { 'null' }))
} catch {
L (" after NStack preload: FAILED " + $_.Exception.GetType().Name + ': ' + $_.Exception.Message)
$le = $_.Exception.LoaderExceptions; if ($le) { foreach ($x in $le) { L (" loaderEx: " + $x.Message) } }
$ie = $_.Exception.InnerException; while ($ie) { L (" inner: " + $ie.GetType().Name + ': ' + $ie.Message); $ie = $ie.InnerException }
}
L ""
L "### STEP 4: ACTUAL INSTANTIATION + a real TUI win. Does Application.Init() work in 5.1? ###"
$didInit = $false
try {
$a3 = [System.Reflection.Assembly]::LoadFrom($tgNet472)
$t = $a3.GetType('Terminal.Gui.Application')
L (" Application type: " + $(if ($t) { $t.FullName } else { 'null' }))
if ($t) {
$init = $t.GetMethod('Init', [Type[]]@())
L (" Application.Init() method found: " + [bool]$init)
if ($init) {
if ([Console]::IsOutputRedirected) {
L " SKIPPING Init() because stdout is redirected (it would hang / corrupt output)"
} else {
$init.Invoke($null, @()) | Out-Null
$didInit = $true
L " Application.Init() -> SUCCEEDED on this host"
}
}
}
} catch {
L (" Application.Init probe FAILED: " + $_.Exception.GetType().Name + ': ' + $_.Exception.Message)
$ie = $_.Exception.InnerException; while ($ie) { L (" inner: " + $ie.GetType().Name + ': ' + $ie.Message); $ie = $ie.InnerException }
}
if ($didInit) {
try {
$t = ([System.Reflection.Assembly]::LoadFrom($tgNet472)).GetType('Terminal.Gui.Application')
$shutdown = $t.GetMethod('Shutdown', [Type[]]@())
if ($shutdown) { $shutdown.Invoke($null, @()) | Out-Null; L " Application.Shutdown() -> called" }
} catch { L (" Shutdown failed: " + $_.Exception.Message) }
}
L ""
L "### STEP 5: Spectre.Console netstandard2.0 in this host ###"
try {
$sc = [System.Reflection.Assembly]::LoadFrom($scNs20)
L (" LoadFrom Spectre.Console/netstandard2.0 -> " + $sc.FullName)
try { $t2 = $sc.GetTypes(); L (" GetTypes() -> OK, " + $t2.Count + " types") } catch { L (" GetTypes() FAILED: " + $_.Exception.Message); $ie = $_.Exception.InnerException; while ($ie) { L (" inner: " + $ie.GetType().Name + ': ' + $ie.Message); $ie = $ie.InnerException } }
try {
$ansiType = $sc.GetType('Spectre.Console.AnsiConsole')
L (" Spectre.Console.AnsiConsole -> " + $(if ($ansiType) { 'FOUND' } else { 'null' }))
if ($ansiType) {
$w = $ansiType.GetMethod('Write', [Type[]]@([string]))
L (" AnsiConsole.Write(string) overload: " + [bool]$w)
}
} catch { L (" AnsiConsole probe FAILED: " + $_.Exception.Message) }
} catch { L (" LoadFrom Spectre.Console FAILED: " + $_.Exception.GetType().Name + ': ' + $_.Exception.Message) }
L ""
L "### STEP 6: Add-Type -Path variant (what a user would literally type) ###"
try { Add-Type -Path $tgNet472 -ErrorAction Stop; L " Add-Type -Path Terminal.Gui(net472) -> OK" } catch { L (" Add-Type -Path Terminal.Gui(net472) FAILED: " + $_.Exception.Message) }
try { Add-Type -Path $scNs20 -ErrorAction Stop; L " Add-Type -Path Spectre.Console(netstandard2.0) -> OK" } catch { L (" Add-Type -Path Spectre.Console(ns2.0) FAILED: " + $_.Exception.Message) }
try { Add-Type -Path $tgNs21 -ErrorAction Stop; L " Add-Type -Path Terminal.Gui(netstandard2.1) -> OK" } catch { L (" Add-Type -Path Terminal.Gui(ns2.1) FAILED: " + $_.Exception.Message) }
L (" (tg netstandard2.0 path exists? " + (Test-Path $tgNs20) + ")")
[System.IO.File]::WriteAllLines($OutFile, $lines, (New-Object System.Text.UTF8Encoding($false)))
+96
View File
@@ -0,0 +1,96 @@
param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work, [int]$HoldSeconds = 4)
$lines = New-Object System.Collections.Generic.List[string]
function L($s) { $lines.Add($s); [System.IO.File]::WriteAllLines($OutFile, $lines, (New-Object System.Text.UTF8Encoding($false))) }
L ("PSVersion: " + $PSVersionTable.PSVersion + " CLR " + [System.Environment]::Version)
L ("IsOutputRedirected: " + [Console]::IsOutputRedirected)
L ""
$nstack = Join-Path $Work 'nstack111\lib\netstandard2.0\NStack.dll'
$tg = Join-Path $Work 'tg115\lib\net472\Terminal.Gui.dll'
$sc = Join-Path $Work 'sc049\lib\netstandard2.0\Spectre.Console.dll'
L "############ A) Terminal.Gui 1.15.0 (lib/net472) ############"
$tgOk = $false
try {
Add-Type -Path $nstack -ErrorAction Stop
L " Add-Type -Path NStack.Core 1.1.1 (netstandard2.0) : OK"
Add-Type -Path $tg -ErrorAction Stop
L " Add-Type -Path Terminal.Gui 1.15.0 (net472) : OK"
foreach ($a in ([AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -in @('NStack', 'Terminal.Gui', 'System.Management') })) {
L (" loaded assembly: " + $a.GetName().Name + " v" + $a.GetName().Version + " <- " + $(try { $a.Location } catch { 'dynamic' }))
}
$appType = [Type]::GetType('Terminal.Gui.Application, Terminal.Gui')
L (" [Type]::GetType('Terminal.Gui.Application, Terminal.Gui') -> " + $(if ($appType) { 'FOUND ' + $appType.AssemblyQualifiedName.Split(',')[0] } else { 'NULL' }))
if ($appType) {
if ([Console]::IsOutputRedirected) { L " SKIP Init()/paint: stdout is redirected (no real console)" }
else {
$initM = $appType.GetMethod('Init', [Type[]]@())
L (" Application.Init() method: " + [bool]$initM)
$initM.Invoke($null, @()) | Out-Null
$tgOk = $true
L " Application.Init() -> SUCCEEDED *** Terminal.Gui is LIVE inside this PowerShell ***"
$winT = [Type]::GetType('Terminal.Gui.Window, Terminal.Gui')
$lblT = [Type]::GetType('Terminal.Gui.Label, Terminal.Gui')
$btnT = [Type]::GetType('Terminal.Gui.Button, Terminal.Gui')
$lbl2T = [Type]::GetType('Terminal.Gui.Label, Terminal.Gui')
L (" types: Window=$([bool]$winT) Label=$([bool]$lblT) Button=$([bool]$btnT)")
$w = [Activator]::CreateInstance($winT, [object[]]@("BakNRet - Terminal.Gui on PS $($PSVersionTable.PSVersion.Major).$($PSVersionTable.PSVersion.Minor)"))
$l1 = [Activator]::CreateInstance($lblT, [object[]]@('Window drawn by Terminal.Gui 1.15.0 (net472)'))
$l2 = [Activator]::CreateInstance($lbl2T, [object[]]@('CJK check: Chinese chars, box-drawing'))
$b1 = [Activator]::CreateInstance($btnT, [object[]]@('OK'))
$w.Add($l1); $w.Add($l2); $w.Add($b1)
$w.X = 1; $w.Y = 1; $w.Width = 62; $w.Height = 9
$l1.Y = 1; $l2.Y = 2; $b1.Y = 4
$appType::TopLevel = $w
$w.SetFocus()
$w.Redraw($w.Bounds)
$appType::Refresh()
L " Window + 2 Labels + Button added, TopLevel set, Redraw+Refresh called (look at the window)"
Start-Sleep -Seconds $HoldSeconds
$shut = $appType.GetMethod('Shutdown', [Type[]]@())
$shut.Invoke($null, @()) | Out-Null
L " Application.Shutdown() -> called cleanly"
}
}
} catch {
L (" FAILED: " + $_.Exception.GetType().Name + ': ' + $_.Exception.Message)
$ie = $_.Exception.InnerException; while ($ie) { L (" inner: " + $ie.GetType().Name + ': ' + $ie.Message); $ie = $ie.InnerException }
$le = $_.Exception.LoaderExceptions; if ($le) { foreach ($x in $le) { L (" loaderEx: " + $x.Message) } }
$tgOk = $false
}
L ""
L "############ B) Spectre.Console 0.49.1 (lib/netstandard2.0) ############"
$scOk = $false
try {
Add-Type -Path $sc -ErrorAction Stop
L " Add-Type -Path Spectre.Console 0.49.1 (netstandard2.0) -> OK"
$ansiT = [Type]::GetType('Spectre.Console.AnsiConsole, Spectre.Console')
L (" Spectre.Console.AnsiConsole -> " + $(if ($ansiT) { 'FOUND' } else { 'NULL' }))
if ($ansiT) {
$m = $ansiT.GetMethod('Markup', [Type[]]@([string]))
L (" AnsiConsole.Markup(string) : " + [bool]$m)
$m.Invoke($null, [object[]]@('[red]red markup[/] [green]green[/] [bold]bold[/]')) | Out-Null
L " Markup(red/green/bold) INVOKED"
$tableT = [Type]::GetType('Spectre.Console.Table, Spectre.Console')
$t = [Activator]::CreateInstance($tableT)
$t.AddColumn('Item'); $t.AddColumn('Size')
$t.AddRow('alpha', '1.2 GB'); $t.AddRow('beta', 'OK')
$ansiT::Write($t)
L " AnsiConsole.Write(Table with 2 cols, 2 rows) INVOKED"
$ansiT::Write($t)
L " (table drawn a second time)"
foreach ($n in @('Spectre.Console.ConfirmationPrompt', 'Spectre.Console.SelectionPrompt`1', 'Spectre.Console.MultiSelectionPrompt`1', 'Spectre.Console.Progress', 'Spectre.Console.Status', 'Spectre.Console.Live', 'Spectre.Console.TextPrompt`1', 'Spectre.Console.Layout', 'Spectre.Console.Panel', 'Spectre.Console.BarChart', 'Spectre.Console.BreakdownChart', 'Spectre.Console.Tree', 'Spectre.Console.Rule', 'Spectre.Console.Exception`1')) {
$tt = [Type]::GetType("$n, Spectre.Console")
L (" primitive " + $n.PadRight(42) + " -> " + $(if ($tt) { 'AVAILABLE' } else { 'not found' }))
}
$scOk = $true
}
} catch {
L (" FAILED: " + $_.Exception.GetType().Name + ': ' + $_.Exception.Message)
$ie = $_.Exception.InnerException; while ($ie) { L (" inner: " + $ie.GetType().Name + ': ' + $ie.Message); $ie = $ie.InnerException }
}
L ""
L "RESULT: TerminalGuiInitOK=$tgOk SpectreConsoleOK=$scOk"
if (-not [Console]::IsOutputRedirected) {
Write-Host ""
Write-Host ("[loadtest PS {0}] TerminalGui={1} Spectre.Console={2} -- window closes in {3}s" -f $PSVersionTable.PSVersion, $tgOk, $scOk, $HoldSeconds) -ForegroundColor Yellow
Start-Sleep -Seconds $HoldSeconds
}
+58
View File
@@ -0,0 +1,58 @@
$outFile = $args[0]
if (-not $outFile) { $outFile = Join-Path $env:TEMP 'noconsole-probe.txt' }
$lines = New-Object System.Collections.Generic.List[string]
function L($s) { $lines.Add($s); Write-Host $s }
$src = @"
using System;
using System.Runtime.InteropServices;
public static class K32b {
[DllImport("kernel32.dll", SetLastError=true)] public static extern bool AllocConsole();
[DllImport("kernel32.dll", SetLastError=true)] public static extern bool FreeConsole();
[DllImport("kernel32.dll")] public static extern IntPtr GetStdHandle(int n);
[DllImport("kernel32.dll", SetLastError=true)] public static extern bool GetConsoleMode(IntPtr h, out uint m);
[DllImport("kernel32.dll", SetLastError=true)] public static extern bool SetConsoleMode(IntPtr h, uint m);
[DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow();
[DllImport("kernel32.dll")] public static extern uint GetConsoleOutputCP();
}
"@
Add-Type -TypeDefinition $src -ErrorAction Stop
L "##### PART A: no console attached (stdout redirected) #####"
L ("PSVersion : " + $PSVersionTable.PSVersion)
L ("IsOutputRedirected : " + [Console]::IsOutputRedirected)
L ("GetConsoleWindow : 0x{0:X}" -f ([K32b]::GetConsoleWindow().ToInt64()))
L ("Host.UI.SupportsVirtualTerminal (property exists? " + [bool]($Host.UI | Get-Member -Name SupportsVirtualTerminal -ErrorAction SilentlyContinue) + ") = " + [string]$Host.UI.SupportsVirtualTerminal)
L ("RawUI has SupportsVirtualTerminal? " + [bool]($Host.UI.RawUI | Get-Member -Name SupportsVirtualTerminal -ErrorAction SilentlyContinue))
$m = 0; $ok = [K32b]::GetConsoleMode([K32b]::GetStdHandle(-11), [ref]$m); $err = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
L ("GetConsoleMode(STDOUT) : $ok mode=0x{0:X} lastErr=$err" -f $m)
L ("--- try to wrap POSIX bytes (what Set-Item Function:Write-Output would do) ---")
try { $b = [System.Text.Encoding]::UTF8.GetBytes("x")[0]; $os = [Console]::OpenStandardOutput(); $os.WriteByte(0x1B); $os.Flush(); L "OpenStandardOutput().WriteByte(0x1B): OK (wrote raw ESC into redirected stdout)" } catch { L ("OpenStandardOutput().WriteByte THREW: " + $_.Exception.Message) }
try { [Console]::SetCursorPosition(3,3); L ("[Console]::SetCursorPosition(3,3) : OK -> " + [Console]::CursorLeft + "," + [Console]::CursorTop) } catch { L ("[Console]::SetCursorPosition(3,3) THREW: " + $_.Exception.Message) }
try { $w = $Host.UI.RawUI.WindowSize; L ("RawUI.WindowSize : " + $w.Width + 'x' + $w.Height) } catch { L ("RawUI.WindowSize THREW: " + $_.Exception.Message) }
try { L ("RawUI.ForegroundColor : " + [string]$Host.UI.RawUI.ForegroundColor) } catch { L ("RawUI.ForegroundColor THREW: " + $_.Exception.Message) }
try { [Console]::ForegroundColor = 'Red'; L ("[Console]::ForegroundColor setter : OK (no throw)") } catch { L ("[Console]::ForegroundColor setter THREW: " + $_.Exception.Message) }
try { [Console]::Title = 'probe'; L "[Console]::Title setter : OK (no throw)" } catch { L ("[Console]::Title setter THREW: " + $_.Exception.Message) }
L ("[Console]::OutputEncoding : " + [Console]::OutputEncoding.WebName + " (CodePage " + [Console]::OutputEncoding.CodePage + ")")
L ("`$OutputEncoding : " + $OutputEncoding.WebName)
L ("chcp (GetConsoleOutputCP) : " + [K32b]::GetConsoleOutputCP())
try { L ("`$TypingCompletion / TabExpansion2 n/a") } catch {}
L ""
L "##### PART B: AllocConsole() -- now attached to a brand new console #####"
$alloc = [K32b]::AllocConsole()
L ("AllocConsole() returned : " + $alloc)
[Console]::SetOut([System.IO.StreamWriter]::new([Console]::OpenStandardOutput(), [Console]::OutputEncoding)) 2>$null
L ("GetConsoleWindow : 0x{0:X}" -f ([K32b]::GetConsoleWindow().ToInt64()))
$m2 = 0; $ok2 = [K32b]::GetConsoleMode([K32b]::GetStdHandle(-11), [ref]$m2)
L ("GetConsoleMode(STDOUT) : $ok2 mode=0x{0:X}" -f $m2)
L (" ENABLE_VIRTUAL_TERMINAL_PROCESSING (0x4) : " + (($m2 -band 4) -eq 4))
L (" ENABLE_PROCESSED_OUTPUT (0x1) : " + (($m2 -band 1) -eq 1))
$ok3 = [K32b]::SetConsoleMode([K32b]::GetStdHandle(-11), $m2 -band (-bnot 4))
$m3 = 0; [void][K32b]::GetConsoleMode([K32b]::GetStdHandle(-11), [ref]$m3)
L ("After SetConsoleMode(mode AND NOT 0x4) -> " + $ok3 + " mode=0x{0:X}" -f $m3)
L (" VT now off? " + ((($m3 -band 4) -eq 0)))
[void][K32b]::SetConsoleMode([K32b]::GetStdHandle(-11), $m3 -bor 4)
$m4 = 0; [void][K32b]::GetConsoleMode([K32b]::GetStdHandle(-11), [ref]$m4)
L ("After re-enabling 0x4 -> mode=0x{0:X}" -f $m4)
L ("GetConsoleOutputCP now : " + [K32b]::GetConsoleOutputCP())
try { L ("RawUI.WindowSize now : " + $Host.UI.RawUI.WindowSize.Width + 'x' + $Host.UI.RawUI.WindowSize.Height) } catch { L ("RawUI.WindowSize THREW: " + $_.Exception.Message) }
[System.IO.File]::WriteAllLines($outFile, $lines, (New-Object System.Text.UTF8Encoding($false)))
[void][K32b]::FreeConsole()
+106
View File
@@ -0,0 +1,106 @@
param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work, [int]$HoldSeconds = 5)
$lines = New-Object System.Collections.Generic.List[string]
function L($s) { $lines.Add($s); [System.IO.File]::WriteAllLines($OutFile, $lines, (New-Object System.Text.UTF8Encoding($false))) }
L ("PSVersion: " + $PSVersionTable.PSVersion + " CLR " + [System.Environment]::Version + " Redirected=" + [Console]::IsOutputRedirected)
function Asm([string]$n) { return [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq $n } | Select-Object -First 1 }
function T([string]$n, [string]$a) { $x = Asm $a; if ($x) { return $x.GetType($n) } else { return $null } }
$isDesktop = ($PSVersionTable.PSEdition -eq 'Desktop')
$fx = if ($isDesktop) { 'net461' } else { 'netstandard2.0' }
L ""
L "############ A) Spectre.Console 0.49.1 with the COMPLETE System.Memory closure side-loaded ############"
$scOk = $false
try {
$order = @(
(Join-Path $Work "system.runtime.compilerservices.unsafe-4.5.3\lib\net461\System.Runtime.CompilerServices.Unsafe.dll"),
(Join-Path $Work "system.buffers-4.5.1\lib\net461\System.Buffers.dll"),
(Join-Path $Work "system.numerics.vectors-4.5.0\lib\net46\System.Numerics.Vectors.dll"),
(Join-Path $Work "system.memory-4.5.5\lib\$fx\System.Memory.dll"),
(Join-Path $Work "sc049\lib\netstandard2.0\Spectre.Console.dll")
)
L (" host=$($PSVersionTable.PSEdition) using System.Memory lib/$fx")
foreach ($p in $order) {
if (-not (Test-Path $p)) { throw "missing file: $p" }
Add-Type -Path $p -ErrorAction Stop
L (" loaded $([System.IO.Path]::GetFileName($p)) v$(([System.Reflection.AssemblyName]::GetAssemblyName($p)).Version) [$([System.IO.Path]::GetFileName([System.IO.Path]::GetDirectoryName($p)))]")
}
L " ALL dependencies loaded."
$ansiT = T 'Spectre.Console.AnsiConsole' 'Spectre.Console'
$tableT = T 'Spectre.Console.Table' 'Spectre.Console'
$colT = T 'Spectre.Console.TableColumn' 'Spectre.Console'
$extT = T 'Spectre.Console.TableExtensions' 'Spectre.Console'
$t = [Activator]::CreateInstance($tableT)
L " new Spectre.Console.Table() -> OK"
$t.AddColumn([Activator]::CreateInstance($colT, [object[]]@('项目 / Item')))
$t.AddColumn([Activator]::CreateInstance($colT, [object[]]@('大小 / Size')))
$t.AddColumn([Activator]::CreateInstance($colT, [object[]]@('状态 / Status')))
$m = $extT.GetMethod('AddRow', [Type[]]@($tableT, [string[]]))
$m.Invoke($null, [object[]]@($t, [string[]]@('中文备份归档 alpha', '1.2 GB', 'OK'))) | Out-Null
$m.Invoke($null, [object[]]@($t, [string[]]@('beta', '340 MB', 'WARN'))) | Out-Null
$m.Invoke($null, [object[]]@($t, [string[]]@('gamma-ascii', '2.0 GB', 'OK'))) | Out-Null
Write-Host ""
Write-Host (" ===== Spectre.Console 0.49.1 (netstandard2.0) on PowerShell " + $PSVersionTable.PSVersion + " =====") -ForegroundColor Cyan
$ansiT::Write($t)
Write-Host ""
$ansiT::Markup('[red]red[/] [green]green[/] [yellow]yellow[/] [bold]bold[/] [blue on white]reverse[/]')
$ansiT::Markup('[grey]grey[/] ' + [char]0x2713 + ' check ' + [char]0x26A0 + ' warn')
L " AnsiConsole.Write(Table with CJK) + Markup colors + unicode symbols -> INVOKED, no exception"
L (" Table rendered width (SafeBorder default): rows=" + ($t.Rows.Count) + " cols=" + ($t.Columns.Count))
$scOk = $true
L "RESULT-A: SpectreConsole=WORKS"
} catch {
L (" FAILED: " + $_.Exception.GetType().Name + ': ' + $_.Exception.Message)
$ie = $_.Exception.InnerException; while ($ie) { L (" inner: " + $ie.GetType().Name + ': ' + ($ie.Message -split "`n")[0]); $ie = $ie.InnerException }
L "RESULT-A: SpectreConsole=FAILED"
}
L ""
L "############ B) Terminal.Gui 1.15.0 - can the driver be hand-wired from PowerShell at all? ############"
try {
Add-Type -Path (Join-Path $Work 'nstack111\lib\netstandard2.0\NStack.dll') -ErrorAction Stop
Add-Type -Path (Join-Path $Work 'tg115\lib\net472\Terminal.Gui.dll') -ErrorAction Stop
$tgAsm = Asm 'Terminal.Gui'
$appT = $tgAsm.GetType('Terminal.Gui.Application')
$drvT = $tgAsm.GetType('Terminal.Gui.NetDriver')
$mlT = $tgAsm.GetType('Terminal.Gui.IMainLoopDriver')
$loopT = $tgAsm.GetType('Terminal.Gui.NetMainLoop')
L (" Application=" + [bool]$appT + " NetDriver=" + [bool]$drvT + " NetMainLoop=" + [bool]$loopT)
L (" Is NetMainLoop public? " + $loopT.IsPublic + " (a PowerShell user cannot 'new' a non-public type without reflection)")
foreach ($c in $loopT.GetConstructors([System.Reflection.BindingFlags]'Public,NonPublic,Instance')) {
L (" ctor " + $(if ($c.IsPublic) { 'PUBLIC' } else { 'internal' }) + ": NetMainLoop(" + (($c.GetParameters() | ForEach-Object { $_.ParameterType.Name + ' ' + $_.Name }) -join ', ') + ")")
}
$initM = $appT.GetMethod('Init', [Type[]]@($drvT, $mlT))
$drv = [Activator]::CreateInstance($drvT)
# build NetMainLoop via reflection on the internal ctor
$ctor = $loopT.GetConstructors([System.Reflection.BindingFlags]'Public,NonPublic,Instance') | Select-Object -First 1
$args = @()
foreach ($p in $ctor.GetParameters()) {
if ($p.ParameterType -eq $drvT) { $args += , $drv } elseif ($p.ParameterType -eq [bool]) { $args += , $true } else { $args += , $null }
}
$loop = $ctor.Invoke($args)
L (" NetMainLoop constructed via reflection: " + [bool]$loop)
$initM.Invoke($null, @($drv, $loop)) | Out-Null
L " Application.Init(NetDriver, NetMainLoop) -> SUCCEEDED *** Terminal.Gui IS live on PowerShell " + $PSVersionTable.PSVersion + " ***"
$winT = $tgAsm.GetType('Terminal.Gui.Window'); $lblT = $tgAsm.GetType('Terminal.Gui.Label'); $btnT = $tgAsm.GetType('Terminal.Gui.Button')
$w = [Activator]::CreateInstance($winT, [object[]]@('BakNRet - Terminal.Gui on PS ' + $PSVersionTable.PSVersion))
$l1 = [Activator]::CreateInstance($lblT, [object[]]@('Terminal.Gui 1.15.0 (net472)'))
$l2 = [Activator]::CreateInstance($lblT, [object[]]@('CJK: 2 Chinese chars = 4 columns'))
$b1 = [Activator]::CreateInstance($btnT, [object[]]@('OK'))
$w.Add($l1); $w.Add($l2); $w.Add($b1)
$w.X = 1; $w.Y = 1; $w.Width = 78; $w.Height = 9
$l1.Y = 1; $l2.Y = 3; $b1.Y = 5; $b1.X = 2
($appT.GetProperty('Top')).SetValue($null, $w)
($appT.GetProperty('Current')).SetValue($null, $w)
$w.SetFocus(); $w.Redraw($w.Bounds)
$appT.GetMethod('Refresh', [Type[]]@()).Invoke($null, @()) | Out-Null
L " painted Window(78x9)+2 Labels+Button, holding " + $HoldSeconds + "s"
Start-Sleep -Seconds $HoldSeconds
$appT.GetMethod('Shutdown', [Type[]]@()).Invoke($null, @()) | Out-Null
L " Application.Shutdown() -> clean"
L "RESULT-B: TerminalGui=WORKS"
} catch {
L (" FAILED: " + $_.Exception.GetType().Name + ': ' + $_.Exception.Message)
$ie = $_.Exception.InnerException; while ($ie) { L (" inner: " + $ie.GetType().Name + ': ' + ($ie.Message -split "`n")[0]); $ie = $ie.InnerException }
L "RESULT-B: TerminalGui=FAILED"
}
if (-not [Console]::IsOutputRedirected) { Write-Host ""; Write-Host ("[done PS " + $PSVersionTable.PSVersion + "] closing in $HoldSeconds s") -ForegroundColor Yellow; Start-Sleep -Seconds $HoldSeconds }
+95
View File
@@ -0,0 +1,95 @@
param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work, [int]$HoldSeconds = 6)
$lines = New-Object System.Collections.Generic.List[string]
function L($s) { $lines.Add($s); [System.IO.File]::WriteAllLines($OutFile, $lines, (New-Object System.Text.UTF8Encoding($false))) }
function Asm([string]$n) { return [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq $n } | Select-Object -First 1 }
L ("PSVersion: " + $PSVersionTable.PSVersion + " CLR " + [System.Environment]::Version)
$isDesktop = ($PSVersionTable.PSEdition -eq 'Desktop')
$fx = if ($isDesktop) { 'net461' } else { 'netstandard2.0' }
L ""
L "############ A) Spectre.Console on PowerShell $($PSVersionTable.PSVersion) - CJK ALIGNMENT CHECK ############"
$scOk = $false
try {
foreach ($p in @(
(Join-Path $Work "system.runtime.compilerservices.unsafe-4.5.3\lib\net461\System.Runtime.CompilerServices.Unsafe.dll"),
(Join-Path $Work "system.buffers-4.5.1\lib\net461\System.Buffers.dll"),
(Join-Path $Work "system.numerics.vectors-4.5.0\lib\net46\System.Numerics.Vectors.dll"),
(Join-Path $Work "system.memory-4.5.5\lib\$fx\System.Memory.dll"),
(Join-Path $Work "sc049\lib\netstandard2.0\Spectre.Console.dll")
)) { Add-Type -Path $p -ErrorAction Stop }
$ansiT = (Asm 'Spectre.Console').GetType('Spectre.Console.AnsiConsole')
$tableT = (Asm 'Spectre.Console').GetType('Spectre.Console.Table')
$colT = (Asm 'Spectre.Console').GetType('Spectre.Console.TableColumn')
$extT = (Asm 'Spectre.Console').GetType('Spectre.Console.TableExtensions')
$t = [Activator]::CreateInstance($tableT)
$t.AddColumn([Activator]::CreateInstance($colT, [object[]]@('Description')))
$t.AddColumn([Activator]::CreateInstance($colT, [object[]]@('Size')))
$t.AddColumn([Activator]::CreateInstance($colT, [object[]]@('State')))
$m = $extT.GetMethod('AddRow', [Type[]]@($tableT, [string[]]))
$m.Invoke($null, [object[]]@($t, [string[]]@('alpha-8c', '1.2 GB', 'OK'))) | Out-Null
$m.Invoke($null, [object[]]@($t, [string[]]@('中文备份', '1.2 GB', 'OK'))) | Out-Null
$m.Invoke($null, [object[]]@($t, [string[]]@('z', '1.2 GB', 'OK'))) | Out-Null
$m.Invoke($null, [object[]]@($t, [string[]]@('混合 mixed 文本', '340 MB', 'WARN'))) | Out-Null
Write-Host ""
Write-Host (" ===== Spectre.Console 0.49.1 / PS $($PSVersionTable.PSVersion) : is the CJK row's LAST column aligned? =====") -ForegroundColor Cyan
$ansiT::Write($t)
Write-Host ""
$ansiT::Markup('[red]red[/] [green]green[/] [yellow]yellow[/] [bold]bold[/]')
Write-Host ""
$scOk = $true
L " Table with a 2-CJK-char row and an 8-ASCII-char row rendered. Visually compare whether the"
L " 'Size' column starts at the same screen column for 'alpha-8c' (8 chars) and '中文备份' (4 chars / 8 columns)."
L "RESULT-A: SpectreConsole=WORKS on PS $($PSVersionTable.PSVersion)"
} catch {
L (" FAILED: " + $_.Exception.GetType().Name + ': ' + $_.Exception.Message)
$ie = $_.Exception.InnerException; while ($ie) { L (" inner: " + $ie.GetType().Name + ': ' + ($ie.Message -split "`n")[0]); $ie = $ie.InnerException }
L "RESULT-A: SpectreConsole=FAILED"
}
L ""
L "############ B) Terminal.Gui 1.15.0 - corrected NetMainLoop construction ############"
try {
Add-Type -Path (Join-Path $Work 'nstack111\lib\netstandard2.0\NStack.dll') -ErrorAction Stop
Add-Type -Path (Join-Path $Work 'tg115\lib\net472\Terminal.Gui.dll') -ErrorAction Stop
$tgAsm = Asm 'Terminal.Gui'
$appT = $tgAsm.GetType('Terminal.Gui.Application')
$drvT = $tgAsm.GetType('Terminal.Gui.NetDriver')
$mlT = $tgAsm.GetType('Terminal.Gui.IMainLoopDriver')
$loopT = $tgAsm.GetType('Terminal.Gui.NetMainLoop')
$drv = [Activator]::CreateInstance($drvT)
L (" NetDriver instance created: " + $drv.GetType().FullName)
$ctor = $loopT.GetConstructors([System.Reflection.BindingFlags]'Public,NonPublic,Instance') | Select-Object -First 1
$args = @()
foreach ($p in $ctor.GetParameters()) {
if ($p.ParameterType.IsAssignableFrom($drv.GetType())) { $args += , $drv }
elseif ($p.ParameterType -eq [bool]) { $args += , $true }
else { $args += , $null }
}
L (" NetMainLoop ctor args: " + (($args | ForEach-Object { if ($_ -eq $null) { 'null' } else { $_.GetType().Name } }) -join ', '))
$loop = $ctor.Invoke($args)
L (" NetMainLoop constructed: " + ($loop.GetType().FullName))
$initM = $appT.GetMethod('Init', [Type[]]@($drvT, $mlT))
$initM.Invoke($null, @($drv, $loop)) | Out-Null
L " Application.Init(NetDriver, NetMainLoop) -> SUCCEEDED *** Terminal.Gui 1.15.0 IS LIVE on PowerShell $($PSVersionTable.PSVersion) ***"
$winT = $tgAsm.GetType('Terminal.Gui.Window'); $lblT = $tgAsm.GetType('Terminal.Gui.Label'); $btnT = $tgAsm.GetType('Terminal.Gui.Button')
$w = [Activator]::CreateInstance($winT, [object[]]@("BakNRet / Terminal.Gui - PowerShell $($PSVersionTable.PSVersion)"))
$l1 = [Activator]::CreateInstance($lblT, [object[]]@("Terminal.Gui 1.15.0 (net472) on $(if ($isDesktop) { '.NET Framework ' } else { '.NET ' })" + [System.Environment]::Version))
$l2 = [Activator]::CreateInstance($lblT, [object[]]@('CJK cell test: 2 CJK chars -> 4 columns'))
$l3 = [Activator]::CreateInstance($lblT, [object[]]@('Box drawing'))
$b1 = [Activator]::CreateInstance($btnT, [object[]]@('OK'))
$w.Add($l1); $w.Add($l2); $w.Add($l3); $w.Add($b1)
$w.X = 1; $w.Y = 1; $w.Width = 78; $w.Height = 10
$l1.Y = 1; $l2.Y = 3; $l3.Y = 4; $b1.Y = 6; $b1.X = 2
($appT.GetProperty('Top')).SetValue($null, $w)
($appT.GetProperty('Current')).SetValue($null, $w)
$w.SetFocus(); $w.Redraw($w.Bounds)
$appT.GetMethod('Refresh', [Type[]]@()).Invoke($null, @()) | Out-Null
L " painted Window(78x10) + 3 Labels + Button; holding $HoldSeconds s"
Start-Sleep -Seconds $HoldSeconds
$appT.GetMethod('Shutdown', [Type[]]@()).Invoke($null, @()) | Out-Null
L " Application.Shutdown() -> clean"
L "RESULT-B: TerminalGui=WORKS on PS $($PSVersionTable.PSVersion)"
} catch {
L (" FAILED: " + $_.Exception.GetType().Name + ': ' + $_.Exception.Message)
$ie = $_.Exception.InnerException; while ($ie) { L (" inner: " + $ie.GetType().Name + ': ' + ($ie.Message -split "`n")[0]); $ie = $ie.InnerException }
L "RESULT-B: TerminalGui=FAILED"
}
if (-not [Console]::IsOutputRedirected) { Write-Host ""; Write-Host ("[settled PS " + $PSVersionTable.PSVersion + "] closing in $HoldSeconds s") -ForegroundColor Yellow; Start-Sleep -Seconds $HoldSeconds }
+60
View File
@@ -0,0 +1,60 @@
param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work)
$lines = New-Object System.Collections.Generic.List[string]
function L($s) { $lines.Add($s); [System.IO.File]::WriteAllLines($OutFile, $lines, (New-Object System.Text.UTF8Encoding($false))) }
L ("PSVersion: " + $PSVersionTable.PSVersion)
$nstack = Join-Path $Work 'nstack111\lib\netstandard2.0\NStack.dll'
$tg = Join-Path $Work 'tg115\lib\net472\Terminal.Gui.dll'
Add-Type -Path $nstack -ErrorAction Stop
Add-Type -Path $tg -ErrorAction Stop
$asm = [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq 'Terminal.Gui' } | Select-Object -First 1
L "=== WHO implements IMainLoopDriver? ==="
$mlT = $asm.GetType('Terminal.Gui.IMainLoopDriver')
L (" IMainLoopDriver: " + [bool]$mlT + " members: " + (($mlT.GetMembers() | ForEach-Object { $_.Name }) -join ', '))
foreach ($t in $asm.GetTypes()) {
if ($t.IsClass -and -not $t.IsAbstract -and $mlT.IsAssignableFrom($t)) { L (" IMPLEMENTOR: " + $t.FullName + " public ctor: " + [bool]($t.GetConstructor([Type[]]@()))) }
}
L ""
L "=== also list Net* driver types ==="
foreach ($t in ($asm.GetTypes() | Where-Object { $_.Name -like 'Net*' -or $_.Name -like '*MainLoop*' })) { L (" " + $t.FullName + " interfaces: " + (($t.GetInterfaces() | ForEach-Object { $_.Name }) -join ',')) }
L ""
L "=== ALL static Init/Run overloads on Application (exhaustive) ==="
$appT = $asm.GetType('Terminal.Gui.Application')
foreach ($m in ($appT.GetMethods([System.Reflection.BindingFlags]'Public,Static') | Where-Object { $_.Name -in @('Init','Run','Shutdown','Refresh','Begin','End','RunState') })) {
L (" " + $m.ReturnType.Name + " " + $m.Name + "(" + (($m.GetParameters() | ForEach-Object { $_.ParameterType.Name + " " + $_.Name }) -join ', ') + ")")
}
L ""
L "=== Application static PROPERTIES ==="
foreach ($p in ($appT.GetProperties([System.Reflection.BindingFlags]'Public,Static'))) { L (" " + $p.PropertyType.Name + " " + $p.Name + " CanWrite=" + $p.CanWrite) }
L ""
L "=== does Terminal.Gui touch the console only at Init() time? test with redirected stdout ==="
L (" IsOutputRedirected = " + [Console]::IsOutputRedirected)
try {
$drvT = $asm.GetType('Terminal.Gui.NetDriver')
$loopT = $asm.GetTypes() | Where-Object { $_.FullName -and $mlT.IsAssignableFrom($_) -and -not $_.IsAbstract -and $_.IsClass } | Select-Object -First 1
L (" using driver=" + $drvT.FullName + " mainloop=" + $loopT.FullName)
$initM = $appT.GetMethod('Init', [Type[]]@($drvT, $mlT))
$drv = [Activator]::CreateInstance($drvT)
$loop = [Activator]::CreateInstance($loopT)
$initM.Invoke($null, @($drv, $loop)) | Out-Null
L " Application.Init(NetDriver, NetMainLoop) -> SUCCEEDED *** Terminal.Gui 1.15.0 is LIVE on PowerShell " + $PSVersionTable.PSVersion + " ***"
$winT = $asm.GetType('Terminal.Gui.Window'); $lblT = $asm.GetType('Terminal.Gui.Label'); $btnT = $asm.GetType('Terminal.Gui.Button')
$w = [Activator]::CreateInstance($winT, [object[]]@('BakNRet - Terminal.Gui on PowerShell ' + $PSVersionTable.PSVersion))
$l1 = [Activator]::CreateInstance($lblT, [object[]]@('Terminal.Gui 1.15.0 (net472) on ' + $(if ($PSVersionTable.PSEdition -eq 'Desktop') { '.NET Framework ' } else { '.NET ' }) + [System.Environment]::Version))
$l2 = [Activator]::CreateInstance($lblT, [object[]]@('CJK: 2 Chinese chars = 4 columns'))
$b1 = [Activator]::CreateInstance($btnT, [object[]]@('OK'))
$w.Add($l1); $w.Add($l2); $w.Add($b1)
$w.X = 1; $w.Y = 1; $w.Width = 76; $w.Height = 8
$l1.Y = 1; $l2.Y = 3; $b1.Y = 5; $b1.X = 2
($appT.GetProperty('TopLevel')).SetValue($null, $w)
$w.SetFocus(); $w.Redraw($w.Bounds)
$appT.GetMethod('Refresh', [Type[]]@()).Invoke($null, @()) | Out-Null
L " painted Window(76x8) + 2 Labels + Button"
Start-Sleep -Seconds 5
$appT.GetMethod('Shutdown', [Type[]]@()).Invoke($null, @()) | Out-Null
L " Application.Shutdown() -> clean"
L "RESULT: TerminalGui=WORKS"
} catch {
L (" FAILED: " + $_.Exception.GetType().Name + ': ' + $_.Exception.Message)
$ie = $_.Exception.InnerException; while ($ie) { L (" inner: " + $ie.GetType().Name + ': ' + ($ie.Message -split "`n")[0]); $ie = $ie.InnerException }
L "RESULT: TerminalGui=FAILED"
}
+57
View File
@@ -0,0 +1,57 @@
param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work, [int]$HoldSeconds = 6)
$lines = New-Object System.Collections.Generic.List[string]
function L($s) { $lines.Add($s); [System.IO.File]::WriteAllLines($OutFile, $lines, (New-Object System.Text.UTF8Encoding($false))) }
function Asm([string]$n) { return [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq $n } | Select-Object -First 1 }
L ("PSVersion: " + $PSVersionTable.PSVersion)
try {
Add-Type -Path (Join-Path $Work 'nstack111\lib\netstandard2.0\NStack.dll') -ErrorAction Stop
Add-Type -Path (Join-Path $Work 'tg115\lib\net472\Terminal.Gui.dll') -ErrorAction Stop
$tgAsm = Asm 'Terminal.Gui'
$usT = (Asm 'NStack').GetType('NStack.ustring')
L (" NStack.ustring type: " + [bool]$usT)
$winT = $tgAsm.GetType('Terminal.Gui.Window')
L " Window constructors:"
foreach ($c in $winT.GetConstructors()) { L (" (" + (($c.GetParameters() | ForEach-Object { $_.ParameterType.Name + ' ' + $_.Name }) -join ', ') + ")") }
L " Note: title parameters are NStack.ustring, not System.String -> PowerShell cannot bind a string literal."
# convert
$implicit = $usT.GetMethod('op_Implicit', [Type[]]@([string]))
L (" ustring implicit conversion from string: " + [bool]$implicit)
$us = $implicit.Invoke($null, @('BakNRet - Terminal.Gui'))
L (" converted title object: " + $us.GetType().FullName)
$appT = $tgAsm.GetType('Terminal.Gui.Application')
$drvT = $tgAsm.GetType('Terminal.Gui.NetDriver')
$mlT = $tgAsm.GetType('Terminal.Gui.IMainLoopDriver')
$loopT = $tgAsm.GetType('Terminal.Gui.NetMainLoop')
$drv = [Activator]::CreateInstance($drvT)
$ctor = $loopT.GetConstructors([System.Reflection.BindingFlags]'Public,NonPublic,Instance') | Select-Object -First 1
$loop = $ctor.Invoke(@($drv))
$appT.GetMethod('Init', [Type[]]@($drvT, $mlT)).Invoke($null, @($drv, $loop)) | Out-Null
L " Application.Init -> OK"
$lblT = $tgAsm.GetType('Terminal.Gui.Label'); $btnT = $tgAsm.GetType('Terminal.Gui.Button')
$w = [Activator]::CreateInstance($winT, [object[]]@($us))
L (" Window created with ustring title: " + ($w.GetType().FullName))
# Labels also take ustring; use implicit conversion
$l1 = [Activator]::CreateInstance($lblT, [object[]]@($implicit.Invoke($null, @('Terminal.Gui 1.15.0 / Label via ustring conversion'))))
$l2 = [Activator]::CreateInstance($lblT, [object[]]@($implicit.Invoke($null, @('CJK cell test: 2 CJK chars -> 4 columns'))))
$b1 = [Activator]::CreateInstance($btnT, [object[]]@($implicit.Invoke($null, @('OK'))))
$w.Add($l1); $w.Add($l2); $w.Add($b1)
$w.X = 1; $w.Y = 1; $w.Width = 78; $w.Height = 9
$l1.Y = 1; $l2.Y = 3
$b1.Y = 5; $b1.X = 2
$appT.GetProperty('Top').SetValue($null, $w)
$appT.GetProperty('Current').SetValue($null, $w)
$w.SetFocus(); $w.Redraw($w.Bounds)
$appT.GetMethod('Refresh', [Type[]]@()).Invoke($null, @()) | Out-Null
L " *** FULLY WORKING Terminal.Gui TUI painted: Window(78x9) + 2 Labels + Button ***"
L "RESULT: TerminalGui=FULL-WIN via reflection + ustring conversion"
Write-Host ""
Write-Host (" ===== Terminal.Gui 1.15.0 live on PowerShell $($PSVersionTable.PSVersion) =====") -ForegroundColor Cyan
Start-Sleep -Seconds $HoldSeconds
$appT.GetMethod('Shutdown', [Type[]]@()).Invoke($null, @()) | Out-Null
L " Shutdown clean"
} catch {
L (" FAILED: " + $_.Exception.GetType().Name + ': ' + $_.Exception.Message)
$ie = $_.Exception.InnerException; while ($ie) { L (" inner: " + $ie.GetType().Name + ': ' + ($ie.Message -split "`n")[0]); $ie = $ie.InnerException }
L "RESULT: TerminalGui=FAILED"
}
if (-not [Console]::IsOutputRedirected) { Start-Sleep -Seconds 2 }
+55
View File
@@ -0,0 +1,55 @@
param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work, [int]$HoldSeconds = 6)
$lines = New-Object System.Collections.Generic.List[string]
function L($s) { $lines.Add($s); [System.IO.File]::WriteAllLines($OutFile, $lines, (New-Object System.Text.UTF8Encoding($false))) }
function Asm([string]$n) { return [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq $n } | Select-Object -First 1 }
L ("PSVersion: " + $PSVersionTable.PSVersion)
try {
Add-Type -Path (Join-Path $Work 'nstack111\lib\netstandard2.0\NStack.dll') -ErrorAction Stop
Add-Type -Path (Join-Path $Work 'tg115\lib\net472\Terminal.Gui.dll') -ErrorAction Stop
$tgAsm = Asm 'Terminal.Gui'
$drvT = $tgAsm.GetType('Terminal.Gui.NetDriver')
$mlT = $tgAsm.GetType('Terminal.Gui.IMainLoopDriver')
$appT = $tgAsm.GetType('Terminal.Gui.Application')
$loopT = $tgAsm.GetType('Terminal.Gui.NetMainLoop')
$drv = [Activator]::CreateInstance($drvT)
$loop = ($loopT.GetConstructors([System.Reflection.BindingFlags]'Public,NonPublic,Instance') | Select-Object -First 1).Invoke(@($drv))
$appT.GetMethod('Init', [Type[]]@($drvT, $mlT)).Invoke($null, @($drv, $loop)) | Out-Null
L " Application.Init(NetDriver, NetMainLoop) -> SUCCEEDED"
$winT = $tgAsm.GetType('Terminal.Gui.Window')
$lblT = $tgAsm.GetType('Terminal.Gui.Label')
$btnT = $tgAsm.GetType('Terminal.Gui.Button')
# ALL parameterless ctors -> the interop-safe path
$w = [Activator]::CreateInstance($winT); L (" Window() parameterless -> " + ($w -ne $null))
$l1 = [Activator]::CreateInstance($lblT); L (" Label() parameterless -> " + ($l1 -ne $null))
$b1 = [Activator]::CreateInstance($btnT); L (" Button() parameterless -> " + ($b1 -ne $null))
# set text via .Text property
$textProp = $lblT.GetProperty('Text')
L (" Label.Text property type: " + $textProp.PropertyType.FullName)
$usT = (Asm 'NStack').GetType('NStack.ustring')
$conv = $usT.GetMethod('op_Implicit', [Type[]]@([string]))
$textProp.SetValue($l1, $conv.Invoke($null, @("Terminal.Gui 1.15.0 on PowerShell $($PSVersionTable.PSVersion)")), $null)
L (" Label.Text set via ustring implicit conversion -> OK: " + $l1.Text.ToString())
$titleProp = $winT.GetProperty('Title')
if ($titleProp) { $titleProp.SetValue($w, $conv.Invoke($null, @('BakNRet TUI')), $null); L (" Window.Title set -> OK: " + $w.Title.ToString()) }
$l2 = [Activator]::CreateInstance($lblT)
$textProp.SetValue($l2, $conv.Invoke($null, @('CJK cell test: 2 CJK chars occupy 4 columns')), $null)
$w.Add($l1); $w.Add($l2); $w.Add($b1)
$w.X = 1; $w.Y = 1; $w.Width = 80; $w.Height = 9
$l1.Y = 1; $l2.Y = 3; $b1.Y = 5; $b1.X = 2
$appT.GetProperty('Top').SetValue($null, $w)
$appT.GetProperty('Current').SetValue($null, $w)
$w.SetFocus(); $w.Redraw($w.Bounds)
$appT.GetMethod('Refresh', [Type[]]@()).Invoke($null, @()) | Out-Null
L " *** WORKING Terminal.Gui TUI: Window(80x9) + 2 Labels (via ustring) + Button ***"
L "RESULT: TerminalGui=WORKS on PowerShell $($PSVersionTable.PSVersion)"
Write-Host ""
Write-Host (" ==== Terminal.Gui 1.15.0 rendered by PowerShell $($PSVersionTable.PSVersion) ====") -ForegroundColor Cyan
Start-Sleep -Seconds $HoldSeconds
$appT.GetMethod('Shutdown', [Type[]]@()).Invoke($null, @()) | Out-Null
L " Application.Shutdown() -> clean"
} catch {
L (" FAILED: " + $_.Exception.GetType().Name + ': ' + $_.Exception.Message)
$ie = $_.Exception.InnerException; while ($ie) { L (" inner: " + $ie.GetType().Name + ': ' + ($ie.Message -split "`n")[0]); $ie = $ie.InnerException }
L "RESULT: TerminalGui=FAILED"
}
if (-not [Console]::IsOutputRedirected) { Start-Sleep -Seconds 3 }
+37
View File
@@ -0,0 +1,37 @@
param([Parameter(Mandatory=$true)][string]$OutFile)
$lines = New-Object System.Collections.Generic.List[string]
function L($s) { $lines.Add($s) }
L ("PSVersion: " + $PSVersionTable.PSVersion + " PSEdition: " + $PSVersionTable.PSEdition)
L ""
L "### (1) can the host be asked whether it wants VT? ###"
$p = $Host.UI | Get-Member -Name SupportsVirtualTerminal -MemberType Property -ErrorAction SilentlyContinue
L (" [bool](`$Host.UI | Get-Member -Name SupportsVirtualTerminal) = " + [bool]$p + " (a REAL property, not a stub)")
$rw = $Host.UI.RawUI | Get-Member -Name SupportsVirtualTerminal -MemberType Property -ErrorAction SilentlyContinue
L (" [bool](`$Host.UI.RawUI | Get-Member -Name SupportsVirtualTerminal) = " + [bool]$rw + " (does NOT exist on RawUI)")
L (" value of `$Host.UI.SupportsVirtualTerminal = " + [string]$Host.UI.SupportsVirtualTerminal)
L (" property TypeName: " + $(if ($p) { $p.Definition } else { 'n/a' }))
L ""
L "### (2) is it WRITABLE? (the documented way to turn VT off: `$Host.UI.SupportsVirtualTerminal = `$false) ###"
$before = [string]$Host.UI.SupportsVirtualTerminal
try { $Host.UI.SupportsVirtualTerminal = $false; $setOk = $true } catch { $setOk = $false; L (" setter THREW: " + $_.Exception.Message) }
if ($setOk) { L (" set `$false -> value is now [" + [string]$Host.UI.SupportsVirtualTerminal + "] (before [$before])") }
try { $Host.UI.SupportsVirtualTerminal = $true } catch {}
L (" restored -> value is now [" + [string]$Host.UI.SupportsVirtualTerminal + "]")
L ""
L "### (3) WIDTH / HEIGHT REPORTING WHEN THERE IS NO CONSOLE (scheduled-task-like) ###"
L (" [Console]::IsOutputRedirected = " + [Console]::IsOutputRedirected)
try { L (" [Console]::WindowWidth = " + [Console]::WindowWidth) } catch { L (" [Console]::WindowWidth THREW: " + $_.Exception.Message) }
try { L (" [Console]::BufferWidth = " + [Console]::BufferWidth) } catch { L (" [Console]::BufferWidth THREW: " + $_.Exception.Message) }
try { L (" `$Host.UI.RawUI.WindowSize = " + $Host.UI.RawUI.WindowSize.Width + 'x' + $Host.UI.RawUI.WindowSize.Height + " <-- WRONG/hidden console, does not throw") } catch { L (" RawUI.WindowSize THREW: " + $_.Exception.Message) }
try { [Console]::SetCursorPosition(2, 2); L " [Console]::SetCursorPosition = OK" } catch { L (" [Console]::SetCursorPosition THREW: " + $_.Exception.Message) }
try { $c = New-Object System.Management.Automation.Host.Coordinates 2, 2; $Host.UI.RawUI.CursorPosition = $c; L (" `$Host.UI.RawUI.CursorPosition = OK -> " + $Host.UI.RawUI.CursorPosition.X + ',' + $Host.UI.RawUI.CursorPosition.Y) } catch { L (" RawUI.CursorPosition setter THREW: " + $_.Exception.Message) }
L ""
L "### (4) EXIT CODE PATH (the two entry scripts end with exit <code>) ###"
L (" `$LASTEXITCODE before = " + $LASTEXITCODE)
L " (run: powershell -File x.ps1 ; echo %ERRORLEVEL%)"
L "### (5) [Console]::ReadKey / RawUI.ReadKey availability ###"
try { L (" `$Host.UI.RawUI.KeyAvailable = " + $Host.UI.RawUI.KeyAvailable) } catch { L (" RawUI.KeyAvailable THREW: " + $_.Exception.Message) }
try { L (" [Console]::KeyAvailable = " + [Console]::KeyAvailable) } catch { L (" [Console]::KeyAvailable THREW: " + $_.Exception.Message) }
L (" RawUI members: " + (($Host.UI.RawUI | Get-Member -MemberType Property,Method | Select-Object -ExpandProperty Name) -join ','))
[System.IO.File]::WriteAllLines($OutFile, $lines, (New-Object System.Text.UTF8Encoding($false)))
Write-Host ("[vt-probe {0}] wrote {1}" -f $PSVersionTable.PSVersion, $OutFile) -ForegroundColor Yellow
+60
View File
@@ -0,0 +1,60 @@
param([Parameter(Mandatory=$true)][string]$HostLabel, [Parameter(Mandatory=$true)][string]$OutFile, [int]$WatchSeconds = 2)
$lines = New-Object System.Collections.Generic.List[string]
function L($s) { $lines.Add($s) }
$zh = [string]::Concat([char]0x4E2D, [char]0x6587)
$zh2 = [string]::Concat([char]0x4E2D, [char]0x6587, [char]0x5907, [char]0x4EFD)
$ascii8 = '12345678'
$emoji = [string]::Concat([char]::ConvertFromUtf32(0x1F600))
$box = [string]::Concat([char]0x250C, [char]0x2500, [char]0x2510)
$boxOdd = [string]::Concat([char]0x250C, [char]0x2502, [char]0x2510)
L "===== Chinese / fullwidth width measurement [$HostLabel] ====="
L ("PSVersion : " + $PSVersionTable.PSVersion)
L ("Console.WindowWidth : " + [Console]::WindowWidth)
L ("Console.BufferWidth : " + [Console]::BufferWidth)
try { L ("RawUI.WindowSize : " + $Host.UI.RawUI.WindowSize.Width + 'x' + $Host.UI.RawUI.WindowSize.Height) } catch { L ("RawUI.WindowSize THREW: " + $_.Exception.Message) }
L ("Console.OutputEncoding : " + [Console]::OutputEncoding.WebName)
L ("IsOutputRedirected : " + [Console]::IsOutputRedirected)
L ""
L ".Length (CHARACTER COUNT) vs REAL COLUMNS:"
foreach ($item in @(@('ascii 8 chars ', $ascii8), @('Chinese 2 chars', $zh), @('Chinese 4 chars', $zh2), @('box-drawing 3 ', $box), @('emoji 1 ', $emoji))) {
$name = $item[0]; $s = $item[1]
$chars = $s.ToCharArray()
$codes = ($chars | ForEach-Object { 'U+{0:X4}' -f [int]$_ }) -join ' '
$surr = if ($s.Length -gt 0) { ($s.ToCharArray() | Where-Object { [int]$_ -ge 0xD800 -and [int]$_ -le 0xDFFF }).Count } else { 0 }
L (" $name : .Length=$($s.Length) UTF16-surrogates=$surr Measure-Object -Character=$(($s | Measure-Object -Character).Characters) codeunits=$codes")
}
$ms = [string]::Concat($zh, ':', [System.IO.File]::ReadAllText((Join-Path $env:TEMP 'nowhere.txt')) ) 2>$null
L ""
L "WHICH BUILT-IN CAN MEASURE DISPLAY WIDTH? (probe of plausible names)"
$candidates = @('Get-DisplayWidth','Get-StringWidth','Measure-String','Get-VisualLength','Get-ConsoleStringWidth','Format-WideString','Get-WCWidth','Get-CharWidth','Get-TextWidth','Measure-Text','Get-EastAsianWidth')
foreach ($c in $candidates) {
$cmd = Get-Command $c -ErrorAction SilentlyContinue
L (" Get-Command $c -> " + $(if ($cmd) { $cmd.CommandType.ToString() + ' ' + $cmd.Source } else { 'NOT FOUND' }))
}
L (" [System.Globalization.StringInfo]::ParseCombiningCharacters('$zh').Count = " + [System.Globalization.StringInfo]::ParseCombiningCharacters($zh).Count + " (text-element count, NOT width)")
L (" TextInfo: CurrentCulture = " + [System.Globalization.CultureInfo]::CurrentCulture.Name)
L (" IsInputRedirected/IsOutputRedirected : " + [Console]::IsInputRedirected + '/' + [Console]::IsOutputRedirected)
L ""
L "CURSOR POSITION TEST (needs a real console; skipped if redirected)"
$skip = $false
try { if ([Console]::IsOutputRedirected) { L " SKIPPED: stdout is redirected, [Console]::SetCursorPosition would throw 'The handle is invalid'"; $skip = $true } } catch { $skip = $true }
if (-not $skip) {
try {
[Console]::CursorVisible = $false
foreach ($pair in @(@('8 ASCII chars ', $ascii8), @('2 CJK chars ', $zh), @('4 CJK chars ', $zh2), @('3 box-drawing ', $box), @('1 emoji ', $emoji))) {
$name = $pair[0]; $s = $pair[1]
[Console]::BufferWidth = [Console]::BufferWidth
[Console]::SetCursorPosition(0, 10)
[Console]::Write($s)
$after = [Console]::CursorLeft
L (" write '$name' from column 0 -> CursorLeft = $after (implied display columns = $after)")
}
[Console]::SetCursorPosition(0, 10); [Console]::Write('.' * 40)
[Console]::SetCursorPosition(0, 10); [Console]::Write($zh); [Console]::Write('X')
L " visual check: expect 'ZhongWenX' where X starts at column 4 (2 CJK = 4 columns)"
[Console]::CursorVisible = $true
} catch { L (" CURSOR TEST THREW: " + $_.Exception.Message) }
}
[System.IO.File]::WriteAllLines($OutFile, $lines, (New-Object System.Text.UTF8Encoding($false)))
Write-Host ("[width-probe {0}] done, wrote {1}" -f $HostLabel, $OutFile) -ForegroundColor Yellow
if ($WatchSeconds -gt 0) { Start-Sleep -Seconds $WatchSeconds }
+70
View File
@@ -0,0 +1,70 @@
param([Parameter(Mandatory=$true)][string]$HostLabel, [Parameter(Mandatory=$true)][string]$OutFile, [int]$WatchSeconds = 2)
$lines = New-Object System.Collections.Generic.List[string]
function L($s) { $lines.Add($s) }
function MK([int[]]$cp) { -join ($cp | ForEach-Object { [char]$_ }) }
$zh2 = MK @(0x4E2D, 0x6587) # 2 CJK
$zh4 = MK @(0x4E2D, 0x6587, 0x5907, 0x4EFD) # 4 CJK
$ascii8 = '12345678'
$emoji = [char]::ConvertFromUtf32(0x1F600)
$box3 = MK @(0x250C, 0x2500, 0x2510) # box drawing light
$fullw = MK @(0xFF21, 0xFF22) # fullwidth latin A B
$halfk = MK @(0xFF71, 0xFF72) # halfwidth katakana
L "===== DISPLAY-WIDTH PROBE [$HostLabel] ====="
L ("PSVersion : " + $PSVersionTable.PSVersion)
L ("PSEdition : " + $PSVersionTable.PSEdition)
L ("IsOutputRedirected : " + [Console]::IsOutputRedirected)
L ("Console.OutputEncoding : " + [Console]::OutputEncoding.WebName + " (CP " + [Console]::OutputEncoding.CodePage + ")")
L ("`$OutputEncoding : " + $OutputEncoding.WebName)
try { L ("Console.WindowWidth : " + [Console]::WindowWidth) } catch { L ("Console.WindowWidth THREW") }
try { L ("RawUI.WindowSize : " + $Host.UI.RawUI.WindowSize.Width + 'x' + $Host.UI.RawUI.WindowSize.Height) } catch { L ("RawUI.WindowSize THREW") }
L ""
L "--- .Length / Measure-Object -Character / CODEPOINTS ---"
$cases = @(
,@('8 ASCII ', $ascii8)
,@('2 CJK ', $zh2)
,@('4 CJK ', $zh4)
,@('3 boxdraw ', $box3)
,@('1 emoji ', $emoji)
,@('2 fullwidth', $fullw)
,@('2 halfkana ', $halfk)
)
foreach ($c in $cases) {
$nm = $c[0]; $s = $c[1]
$s = [string]$s
$cps = ($s.ToCharArray() | ForEach-Object { 'U+{0:X4}' -f [int]$_ }) -join ' '
L (" $nm .Length=" + $s.Length + " Measure-Object-Character=" + (($s | Measure-Object -Character).Characters) + " cps=[$cps]")
}
L ""
L "--- BUILT-IN DISPLAY-WIDTH FUNCTION? ---"
$found = @()
foreach ($c in (Get-Command -CommandType Cmdlet,Function -ErrorAction SilentlyContinue)) {
if ($c.Name -match 'Width|Wide') { $found += ($c.Name + '(' + $c.CommandType + '/' + $c.Source + ')') }
}
L (" commands matching *Width*|*Wide* : " + $(if ($found.Count) { $found -join ', ' } else { 'NONE' }))
L (" [System.Globalization.StringInfo]::ParseCombiningCharacters(2CJK).Count = " + [System.Globalization.StringInfo]::ParseCombiningCharacters($zh2).Count + " (text elements, not columns)")
L ""
L "--- MEASURED COLUMNS (cursor teleport to column 0 of row 12, write, read CursorLeft) ---"
$skip = $false
try { if ([Console]::IsOutputRedirected) { L " SKIPPED (redirected)"; $skip = $true } } catch { $skip = $true }
if (-not $skip) {
$row = 12
if ([Console]::BufferWidth -lt 40) { $row = 5 }
$vis = [Console]::CursorVisible
try { [Console]::CursorVisible = $false } catch {}
foreach ($c in $cases) {
$nm = $c[0]; $s = [string]$c[1]
try {
[Console]::SetCursorPosition(0, $row)
[Console]::Write((' ' * 60))
[Console]::SetCursorPosition(0, $row)
[Console]::Write($s)
$col = [Console]::CursorLeft
L (" write [$nm] at col 0 -> CursorLeft=" + $col + " => title column width = " + $col + " (.Length=" + $s.Length + ")")
} catch { L (" write [$nm] THREW: " + $_.Exception.Message) }
}
try { [Console]::SetCursorPosition(0, $row); [Console]::Write((' ' * 60)); [Console]::SetCursorPosition(0, $row); [Console]::Write($zh2 + 'X'); [Console]::SetCursorPosition(0, $row + 1) } catch {}
try { [Console]::CursorVisible = $vis } catch {}
}
[System.IO.File]::WriteAllLines($OutFile, $lines, (New-Object System.Text.UTF8Encoding($false)))
Write-Host ("[width-probe2 {0}] wrote {1}" -f $HostLabel, $OutFile) -ForegroundColor Yellow
if ($WatchSeconds -gt 0) { Start-Sleep -Seconds $WatchSeconds }
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

@@ -0,0 +1,14 @@
PSScriptAnalyzer 1.25.0(D:\Workspace\Temp\BakNRet\.tools\modules\PSScriptAnalyzer\1.25.0)
分析 131 个文件,配置 D:\Workspace\Temp\BakNRet\PSScriptAnalyzerSettings.psd1
共 80 条,按规则汇总:
54 PSAvoidLongLines
7 PSPlaceCloseBrace
4 PSAlignAssignmentStatement
4 PSReviewUnusedParameter
4 PSUseConsistentIndentation
4 PSUseSupportsShouldProcess
2 PSAvoidUsingEmptyCatchBlock
1 PSUseDeclaredVarsMoreThanAssignments
@@ -0,0 +1,101 @@
PSScriptAnalyzer 1.25.0(D:\Workspace\Temp\BakNRet\.tools\modules\PSScriptAnalyzer\1.25.0)
分析 131 个文件,配置 D:\Workspace\Temp\BakNRet\PSScriptAnalyzerSettings.psd1
共 84 条,按规则汇总:
54 PSAvoidLongLines
7 PSPlaceCloseBrace
4 PSAlignAssignmentStatement
4 PSReviewUnusedParameter
4 PSUseConsistentIndentation
4 PSUseConsistentWhitespace
4 PSUseSupportsShouldProcess
2 PSAvoidUsingEmptyCatchBlock
1 PSUseDeclaredVarsMoreThanAssignments
逐条:
Backup-Data.ps1:293 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Backup-Data.ps1:299 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Backup-Data.ps1:602 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Backup-Data.ps1:822 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Backup-Data.ps1:832 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Backup.ps1:42 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Backup.ps1:42 [PSUseConsistentWhitespace] Use space before and after binary and assignment operators.
Backup.ps1:42 [PSUseConsistentWhitespace] Use space before and after binary and assignment operators.
BakNRet.Formats.Tests.ps1:384 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
BakNRet.Security.Tests.ps1:171 [PSReviewUnusedParameter] The parameter 'Root' has been declared but not used.
BakNRet.Security.Tests.ps1:355 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
BakNRet.Security.Tests.ps1:373 [PSUseDeclaredVarsMoreThanAssignments] The variable 'sourcePath' is assigned but never used.
BakNRet.Tests.ps1:633 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
BakNRet.Tests.ps1:937 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
BakNRet.Tests.ps1:980 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
BakNRet.Tests.ps1:1030 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
BakNRet.Tests.ps1:1285 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
BakNRet.Tests.ps1:1359 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Edit-Config.ps1:63 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Edit-Config.ps1:73 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Get-BakNRetSoftwareCatalog.ps1:87 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Invoke-BakNRetBackupListEditor.ps1:24 [PSUseSupportsShouldProcess] WhatIf and/or Confirm manually defined in function Invoke-BakNRetBackupListEditor. Instead, please use SupportsShouldProcess attribute.
Invoke-BakNRetBackupListEditor.ps1:69 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Invoke-BakNRetCatalogEditor.ps1:22 [PSUseSupportsShouldProcess] WhatIf and/or Confirm manually defined in function Invoke-BakNRetCatalogEditor. Instead, please use SupportsShouldProcess attribute.
Invoke-BakNRetCatalogEditor.ps1:55 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Invoke-BakNRetConfigEditor.ps1:23 [PSUseSupportsShouldProcess] WhatIf and/or Confirm manually defined in function Invoke-BakNRetConfigEditor. Instead, please use SupportsShouldProcess attribute.
Invoke-BakNRetMenu.ps1:46 [PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.
Invoke-BakNRetMenu.ps1:86 [PSAlignAssignmentStatement] Assignment statements are not aligned
Invoke-BakNRetMenu.ps1:87 [PSAlignAssignmentStatement] Assignment statements are not aligned
Invoke-BakNRetMenu.ps1:89 [PSAlignAssignmentStatement] Assignment statements are not aligned
Invoke-BakNRetMenu.ps1:90 [PSAlignAssignmentStatement] Assignment statements are not aligned
Lab-Common.ps1:60 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Lab.ps1:41 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Lab.ps1:208 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Lab.ps1:226 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Lab.ps1:227 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Lab.ps1:389 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
New-BakNRetLab.ps1:111 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
provision.ps1:69 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
provision.ps1:89 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Register-BackupTask.ps1:94 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Restore-Data.ps1:141 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Restore-Data.ps1:753 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Restore-Data.ps1:850 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Restore.ps1:42 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Restore.ps1:42 [PSUseConsistentWhitespace] Use space before and after binary and assignment operators.
Restore.ps1:42 [PSUseConsistentWhitespace] Use space before and after binary and assignment operators.
Run-E2E.ps1:408 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-E2E.ps1:458 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-E2E.ps1:502 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-E2E.ps1:575 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:433 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:731 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:744 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:757 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:790 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:802 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:828 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:870 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:931 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:987 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:1012 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:1057 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:1169 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:1417 [PSPlaceCloseBrace] Close brace does not follow a new line.
Run-Tests.ps1:1450 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:1465 [PSPlaceCloseBrace] Close brace does not follow a new line.
Run-Tests.ps1:1608 [PSReviewUnusedParameter] The parameter 't' has been declared but not used.
Run-Tests.ps1:1615 [PSReviewUnusedParameter] The parameter 't' has been declared but not used.
Run-Tests.ps1:1622 [PSPlaceCloseBrace] Close brace does not follow a new line.
Run-Tests.ps1:1683 [PSPlaceCloseBrace] Close brace does not follow a new line.
Run-Tests.ps1:1734 [PSPlaceCloseBrace] Close brace does not follow a new line.
Run-Tests.ps1:1804 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:1816 [PSPlaceCloseBrace] Close brace does not follow a new line.
Run-Tests.ps1:1858 [PSUseConsistentIndentation] Indentation not consistent
Run-Tests.ps1:1859 [PSUseConsistentIndentation] Indentation not consistent
Run-Tests.ps1:1860 [PSUseConsistentIndentation] Indentation not consistent
Run-Tests.ps1:1861 [PSUseConsistentIndentation] Indentation not consistent
Run-Tests.ps1:1883 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:1884 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:1896 [PSPlaceCloseBrace] Close brace does not follow a new line.
Save-BakNRetConfigFile.ps1:25 [PSUseSupportsShouldProcess] WhatIf and/or Confirm manually defined in function Save-BakNRetConfigFile. Instead, please use SupportsShouldProcess attribute.
Write-BakNRetAt.ps1:39 [PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.
Write-BakNRetRunSummary.ps1:16 [PSReviewUnusedParameter] The parameter 'Mode' has been declared but not used.
@@ -0,0 +1,64 @@
# 依赖安全扫描报告(dependency-security-scanner)
- **项目**:BakNRet(Windows 备份 / 恢复工具,PowerShell)
- **扫描时间**:2026-09-28
- **扫描范围**:仓库根目录及全部子目录(排除 `.git`)
- **工具**:`npm audit` / `pip-audit` / `govulncheck` 等均**不适用**(见下)
## 1. 项目概况
| 项目 | 结论 |
| --- | --- |
| 语言 | PowerShell(`.ps1` / `.psm1` / `.psd1`) |
| 依赖管理文件 | **不存在** —— 全仓无 `package.json`、`package-lock.json`、`requirements.txt`、`go.mod`、`pom.xml`、`Cargo.toml` 等 |
| 运行时依赖 | **0 个模块**。运行备份 / 恢复只需要 PowerShell 5.1 或 7.x + 7-Zip |
| 开发期依赖 | Pester 5.9.1、PSScriptAnalyzer 1.25.0 —— 装在 `.tools/`,已 gitignore,**不随发布产物分发** |
| 外部可执行文件 | `7z.exe`(7-Zip 26.03,经 scoop 安装) |
> [!NOTE]
>
> 依赖扫描技能面向「有包管理器的项目」。本项目**没有任何包管理器依赖**,因此
> `npm audit` / `safety` / `govulncheck` 一类工具无对象可扫。下面改为对本项目**真实的供应链面**
> 逐项核对,而不是输出一份空报告。
## 2. 供应链面核对
| 组件 | 来源 | 版本 | 是否随分发 | 风险 |
| --- | --- | --- | --- | --- |
| PowerShell 引擎 | 操作系统 / Microsoft | 5.1.26100.9502、7.7.0-preview.5 | 否(宿主环境) | 由宿主维护;预览版仅供本机验收使用 |
| 7-Zip | scoop(`C:\Programs\Scoop\apps\7zip\26.03`) | 26.03 | 否(用户自备) | 归档/解压的执行者,**必须由用户保持更新** |
| Pester | 仓库内 `.tools/modules/Pester/5.9.1` | 5.9.1 | 否(gitignore) | 仅测试期使用,不接触用户数据 |
| PSScriptAnalyzer | 仓库内 `.tools/modules/PSScriptAnalyzer/1.25.0` | 1.25.0 | 否(gitignore) | 仅静态分析使用 |
| 其余第三方 | 无 | — | — | — |
**已知 CVE**:无可报。本项目的依赖面里没有任何「被本项目固定版本」的第三方库
(Pester 与 PSScriptAnalyzer 是测试工具且不进分发,7-Zip 与 PowerShell 由用户环境提供)。
若要追查这两个工具的 CVE,应查上游公告,而不是本仓库。
## 3. 与安全相关的项目事实(实跑核对)
| 检查项 | 结果 |
| --- | --- |
| 仓库是否跟踪任何密钥文件 | ✅ 否(`git ls-files` 中无 `*.key` / `*.pfx`) |
| `baknret.key` 是否被 gitignore | ✅ 是(`.gitignore:18: *.key`) |
| 工作区是否存在口令文件 `baknret.key` | ⚠️ **是** —— 见风险 R-2 |
| 口令是否可能落进日志 | ✅ 已处理:打印前把 `-p` 参数换成占位符(`tests/BakNRet.Tests.ps1` 有专门断言) |
| 日志中是否出现明文口令 | ✅ 无 |
| `.tools/` 是否可能进分发产物 | ✅ 否(已 gitignore,且构建脚本只读 `BakNRet/` 与 `tools/`) |
## 4. 风险清单
| ID | 严重程度 | 风险 | 证据 | 建议 |
| --- | --- | --- | --- | --- |
| R-1 | **一般** | 仓库没有 `LICENSE` 文件,许可证状态未声明 | 根目录无 `LICENSE*` | 补一份许可证(见 `license_check_report`) |
| R-2 | **一般** | 工作区存在口令文件 `baknret.key`(未被跟踪,但确实在仓库目录里) | `Test-Path baknret.key` = True | 本项目自己的 CHANGELOG 已把「口令文件的出厂默认值指向仓库内」定为待修问题,`BackupConfig.psd1` 的注释也推荐放到仓库外。建议移到 `%USERPROFILE%\.baknret.key` 并用 `-KeyFile` 指过去。**本报告不读取、不记录其内容** |
| R-3 | **建议** | 7-Zip 版本由用户环境决定,脚本不检查版本 | `Find-BakNRet7zExecutable` 只找路径 | 可选:在启动时打印 `7z` 版本,便于排障 |
| R-4 | **建议** | 口令经命令行传给 7z,本机进程列表可见 | 上游限制,README 已用 CAUTION 声明 | 无技术解法,保持文档披露即可 |
**致命 / 严重漏洞:0 个。** 流水线可继续。
## 5. 结论
未发现已知安全漏洞。本项目**零运行时依赖**,是当前最重要的一条供应链优势;
唯一两条「一般」级风险都属于**卫生问题**(缺许可证、口令文件放在仓库目录里),
不影响流水线继续执行。
@@ -0,0 +1,67 @@
# 许可证合规检查报告(license-compliance-checker)
> [!WARNING]
>
> **免责声明**:本报告由自动化工具基于仓库内的文件生成,仅为工程参考,**不构成法律建议**。
> 涉及对外分发、商业使用或二次许可时,请咨询法务。
- **项目**:BakNRet
- **检查时间**:2026-09-28
- **主许可证**:**未声明** —— 仓库根目录不存在 `LICENSE` / `LICENSE.md` / `LICENSE.txt`
> [!NOTE]
>
> **后续更新(2026-10-02):L-1 已关闭。** 仓库已补 `LICENSE`(Apache License 2.0,正文逐字采用
> ASF 的 LICENSE-2.0.txt,仅把 APPENDIX 的版权占位行填成 `Copyright 2026 Shuery`),
> README 的许可证章节与 `BakNRet/BakNRet.psd1` 的 `LicenseUri` / `Copyright` 同步登记。
> 下方「主许可证:未声明」与 L-1 条目保留为**检查当时**的发现,不再代表当前状态。
## 1. 依赖与组件许可证清单
`license-checker` / `pip-licenses` / `go-licenses` 等工具**不适用**(本项目无包管理器依赖)。
下列清单通过读取 `.tools/modules` 下的模块清单与许可证文件、以及七项外部组件的官方许可条款得出。
| 组件 | 版本 | 许可证 | 来源依据 | 与主许可证关系 |
| --- | --- | --- | --- | --- |
| BakNRet(本项目) | 1.0.0(模块清单) | **未声明** | 无 `LICENSE` 文件 | — |
| Pester | 5.9.1 | **Apache-2.0** | `Pester.psd1` 的 `Copyright` + `LicenseUri` | 测试期工具,未声明主许可证时无冲突可判 |
| PSScriptAnalyzer | 1.25.0 | **MIT** | `.tools/modules/PSScriptAnalyzer/1.25.0/LICENSE` | 同上 |
| Newtonsoft.Json(PSScriptAnalyzer 内置依赖) | 随包 | **MIT** | 同目录 `ThirdPartyNotices.txt` | 同上 |
| 7-Zip | 26.03 | **LGPL-2.1-or-later + BSD-3-Clause + unRAR 限制** | 上游许可(用户自备,不随本仓库分发) | 未分发,仅本地调用 |
| PowerShell | 5.1 / 7.7 | **MIT**(宿主环境) | — | 未分发 |
| .NET 运行时 | — | **MIT**(宿主环境) | — | 未分发 |
## 2. 许可证分布
```text
MIT ████████████████████ 3 项(PSScriptAnalyzer、Newtonsoft.Json、PowerShell/.NET)
Apache-2.0 ███████ 1 项(Pester)
LGPL + BSD + unRAR ███████ 1 项(7-Zip)
未声明 ███████ 1 项(本项目自身) <-- 需要处理
```
全部第三方许可证均为**宽松型**(MIT / Apache-2.0 / BSD),无 copyleft 传染风险。
## 3. 冲突与注意事项
| ID | 级别 | 对象 | 说明 | 建议 |
| --- | --- | --- | --- | --- |
| L-1 | ❌ **需处理** | 本项目自身 | 没有 `LICENSE` 文件 = 默认「保留所有权利」。他人**无权**复制、修改、分发;GitHub 上也会显示为无许可证项目 | 明确选一个:想宽松就 MIT,想带专利授权就 Apache-2.0 |
| L-2 | ⚠️ 需注意 | 7-Zip 的 unRAR 限制 | 7-Zip 许可证禁止用其 unRAR 代码**还原 RAR 压缩算法**。本项目只用 7z 解压 / 压缩,不实现 RAR 压缩 | 无需动作;当前用法不触发该限制 |
| L-3 | ⚠️ 需注意 | Pester 5.9.1 的版权年份 | 模块清单里 `Copyright` 写的是 `(c) 2026 by Pester Team`(上游清单原文) | 无需动作;仅记录,勿在文档中改写上游声明 |
| L-4 | ✅ 兼容 | PSScriptAnalyzer(MIT)+ Newtonsoft.Json(MIT) | MIT 与 MIT/Apache 混合无冲突 | 仅在本仓库内作为测试工具使用,未再分发 |
## 4. 合规建议
1. **先补主许可证**(L-1)。这是本仓库唯一的合规缺口,且成本最低。
2. `.tools/` 下的模块**不要**提交进版本库(已 gitignore)——它们是第三方代码,
提交会牵出「再分发」与版权声明保留义务。
3. 若将来把 BakNRet 公开发布,请在 README / 发布产物里保留 7-Zip 的许可与免责声明引用;
当前仓库并未捆绑 7-Zip 二进制,所以现在无需附带其许可证全文。
4. 若决定以 MIT 发布,注意 MIT 要求保留版权与许可声明:README「致谢」一节已引用上游项目,
可再补一份 `THIRD-PARTY-NOTICES.md` 收纳 Pester / PSScriptAnalyzer 的声明(可选)。
---
**免责声明(重申)**:以上判断基于文件名、模块清单字段与上游公开条款的自动比对,
存在识别错误的可能;**不构成法律建议**。
@@ -0,0 +1,55 @@
{
"measuredAt": "2026-09-28T12:24:11",
"machine": "STRIX-X870A",
"os": "Microsoft Windows NT 10.0.26340.0",
"psi": "7.7.0-preview.5",
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor ",
"logicalCpu": 16,
"workdir": "D:\\Workspace\\Temp\\BakNRet",
"metrics": {
"dryRunFullListMs": {
"min": 9839.9,
"median": 9869.2,
"max": 12900.8,
"samples": 5
},
"sevenZipVersion": "7-Zip 26.03 (x64) : Copyright (c) 1999-2026 Igor Pavlov : 2026-09-03",
"folderSummary200Files3xMs": 18.2,
"backupListParse50xMs": {
"min": 2535.9,
"median": 2546.9,
"max": 2691.5,
"samples": 5
},
"sevenZipBench": [
{
"level": 0,
"ms": 46.7,
"archiveBytes": 6555514
},
{
"level": 5,
"ms": 224.2,
"archiveBytes": 6555913
},
{
"level": 9,
"ms": 225.2,
"archiveBytes": 6555913
}
],
"moduleImportMs": {
"min": 628.3,
"median": 631.0,
"max": 2052.8,
"samples": 5
}
},
"notes": [],
"fixture": {
"files": 200,
"bytes": 6553600,
"createMs": 237.6,
"compressionInputBytes": 6553600
}
}
@@ -0,0 +1,333 @@
Pester 5.9.1 (D:\Workspace\Temp\BakNRet\.tools\modules\Pester\5.9.1)
测试文件:D:\Workspace\Temp\BakNRet\tests
Pester v5.9.1
Starting discovery in 3 files.
Discovery found 185 tests in 226ms.
Running tests.
Running tests from 'D:\Workspace\Temp\BakNRet\tests\BakNRet.Formats.Tests.ps1'
Describing 软件名录:Slot 形状(新契约)
[2026-09-28 12:42:05] [ERROR] 名录条目 'legacystr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:05] [ERROR] 名录条目 'legacyarr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:05] [ERROR] 名录条目 legacydirs 有问题:Slot Dirs 的写法不对,应写成 @{ Path = '...' }
[+] 一个软件多个 Slot:Kind=Multi,Slot 按名排序且说明被保留 163ms (131ms|32ms)
[2026-09-28 12:42:05] [ERROR] 名录条目 'legacystr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:05] [ERROR] 名录条目 'legacyarr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:05] [ERROR] 名录条目 legacydirs 有问题:Slot Dirs 的写法不对,应写成 @{ Path = '...' }
[+] 每个 Slot 都是一个独立的归档项来源(Kind=slot / Origin=catalog) 82ms (81ms|1ms)
[2026-09-28 12:42:05] [ERROR] 名录条目 'legacystr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:05] [ERROR] 名录条目 'legacyarr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:05] [ERROR] 名录条目 legacydirs 有问题:Slot Dirs 的写法不对,应写成 @{ Path = '...' }
[+] Slot 级排除写在 Slot 自己身上(相对本 Slot 的归档根) 32ms (30ms|1ms)
[2026-09-28 12:42:05] [ERROR] 名录条目 'legacystr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:05] [ERROR] 名录条目 'legacyarr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:05] [ERROR] 名录条目 legacydirs 有问题:Slot Dirs 的写法不对,应写成 @{ Path = '...' }
[+] 数组里"当前不存在"的 Slot 仍然产出归档项(恢复要靠它还原回原位) 64ms (64ms|1ms)
[+] 文件 Slot:归档项是文件项(归档里就是名为 Slot 的文件) 8ms (7ms|1ms)
[2026-09-28 12:42:05] [ERROR] 名录条目 'legacystr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:05] [ERROR] 名录条目 'legacyarr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:05] [ERROR] 名录条目 legacydirs 有问题:Slot Dirs 的写法不对,应写成 @{ Path = '...' }
[+] 旧的裸字符串 / 字符串数组写法被拒绝(ERROR + 跳过) 22ms (22ms|1ms)
[2026-09-28 12:42:05] [ERROR] 名录条目 'legacystr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:05] [ERROR] 名录条目 'legacyarr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:05] [ERROR] 名录条目 legacydirs 有问题:Slot Dirs 的写法不对,应写成 @{ Path = '...' }
[+] 旧的 @{ Dirs = @(...) } 写法不再展开,留下 Invalid 与原因 24ms (23ms|1ms)
[+] 多 Slot 的名录条目在清单里仍然按软件名命名归档 3ms (3ms|0ms)
Describing 清单修饰符:新契约格式
[+] :: 覆盖 Path:单 Slot 条目直接生效 13ms (12ms|2ms)
[+] :: 覆盖遇到多 Slot 条目 -> Blocking(不知道给哪一个,绝不猜) 6ms (6ms|0ms)
[+] :- 排除与 :+ 包含并存,顺序任意 15ms (15ms|1ms)
[+] @ Exclude / @ Include / @ Path 与记号写法等价 7ms (6ms|0ms)
[+] :encrypt / :!encrypt 覆盖名录里的加密默认值 11ms (11ms|0ms)
[+] 遗留写法 @encrypt / @pathname / @root= 仍可解析 18ms (17ms|1ms)
[+] 同一行里重复写同类记号会累积(不静默丢掉前一条规则) 12ms (11ms|1ms)
[2026-09-28 12:42:05] [WARN] 清单行缺少目标,已忽略::- logs\
[+] 行尾说明与缺少目标的行 6ms (6ms|1ms)
Describing 集成:Slot 布局的打包与恢复
[+] 备份退出码 0,归档名就是软件名 9ms (7ms|2ms)
[+] 归档顶层就是各个 Slot 名(目录 Slot + 文件 Slot + Include 项) 42ms (41ms|1ms)
[+] manifest.layouts 记下每个归档项是目录还是文件 8ms (7ms|1ms)
[+] 归档内容:\ 布局,文件 Slot 是名为 Slot 的文件,Slot 排除生效 40ms (39ms|1ms)
[+] 真实恢复:目录 Slot、文件 Slot 与 Include 都落回各自的原位 2.37s (2.37s|1ms)
[+] 文件 Slot 在目标不存在时靠 manifest.layouts 恢复成文件(而不是目录) 3ms (2ms|0ms)
[+] 恢复之后 manifest 记下 lastRestoreAt 4ms (4ms|0ms)
Describing 集成:旧布局归档的回退恢复
[+] 归档确实是旧布局:顶层是源目录名而不是 Slot 名 30ms (29ms|1ms)
[+] 归档里缺 Slot 层(真实旧归档)时按旧布局回退,把内容还原回原位 1.75s (1.75s|0ms)
[+] 归档里既没有 Slot 层、也没有旧布局名字时明确失败(不再"成功地什么都没恢复") 1.72s (1.72s|0ms)
Describing 集成:归档内路径冲突会被拒绝执行
[+] 退出码 1,且给出"归档内路径冲突"的原因,不生成归档 4ms (3ms|1ms)
[+] manifest 里记下这次是 failed,并带上原因 3ms (3ms|0ms)
Describing 条目从清单里消失后,旧归档必须被点名为孤儿
[+] 归档确实还在磁盘上,manifest 里也还留着历史记录 5ms (4ms|1ms)
[+] 第二次运行把 my-app.7z 点名成孤儿,并说明 manifest 里还有历史记录 2ms (2ms|0ms)
Describing manifest 一致性:archive 字段只在文件真的存在时才写
[+] 存在的归档保留 archive,不存在的被清空 9ms (8ms|1ms)
[+] 清空 archive 时保留条目本身的历史(source / action 不动) 3ms (3ms|0ms)
[+] 人工删掉归档之后再同步一次,记录会被纠正过来 4ms (3ms|0ms)
Running tests from 'D:\Workspace\Temp\BakNRet\tests\BakNRet.Security.Tests.ps1'
Describing 排除判定与 7z 的 -x! / -xr! 语义对齐
[+] 锚定模式只命中它自己那棵子树 8ms (7ms|1ms)
[+] ! 通配按任意层级的组件名匹配(* 不是正则) 2ms (1ms|0ms)
[+] !re: 走正则,且组件名与整条相对路径都算命中 2ms (2ms|0ms)
[+] 没有模式时一律不排除 1ms (1ms|0ms)
[+] 模式里的空格按 7z 的规矩当 ? 处理 1ms (1ms|0ms)
Describing SID 映射(跨机恢复)
[+] 整 SID 精确替换 5ms (5ms|1ms)
[+] 不会误伤以它为前缀的更长的 SID 1ms (1ms|0ms)
[+] 空映射表时原样返回 1ms (1ms|0ms)
Describing 安全描述符采集
[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL
[+] Full:每个对象一条记录,键是归档内相对路径 340ms (340ms|1ms)
[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL
[+] 根记录的 SDDL 保留了 CREATOR OWNER、IO 标志、孤儿 SID 和 protected 位 12ms (12ms|0ms)
[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL
[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL
[+] Smart 比 Full 少,但根永远保留 24ms (23ms|0ms)
[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL
[+] Roots 只存归档项的根,不再往下走 4ms (4ms|0ms)
[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL
[+] sidecar 往返:条数与 SDDL 原样保留 25ms (24ms|0ms)
[+] 旁挂文件不存在时读出 $null(调用方据此打告警,而不是静默当没事) 1ms (1ms|0ms)
[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL
[+] 排除模式在采集时同样生效(采集树 == 归档树) 9ms (9ms|0ms)
[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL
Describing 安全描述符回放
[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeRestorePrivilege、SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL
[+] 回放后根对象的安全描述符与源逐字节一致(protected / CO / 孤儿 SID 全在) 40ms (39ms|1ms)
[+] 全部对象的安全指纹与源一致(属主/属组/ACE 集合) 5ms (5ms|0ms)
[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeRestorePrivilege、SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL
[+] 目标不存在或不是普通对象时记 Skipped,不记 Failed 3ms (3ms|0ms)
[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeRestorePrivilege、SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL
[+] 归档根名对不上时一条都不回放(不会把兄弟项的 ACL 倒过来) 1ms (1ms|0ms)
[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeRestorePrivilege、SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL
[+] 属组写不进去时不会连累 DACL:回退到底也要把 ACL 落下去 7ms (7ms|0ms)
[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeRestorePrivilege、SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL
[+] 对象的安全描述符读不到时带 e 记账,回放时跳过而不是写坏 2ms (2ms|0ms)
Describing 与 Backup.ps1 / Restore.ps1 的集成
[+] 备份会写出 .acl.json,并在 manifest 里记下它 2.34s (2.34s|1ms)
[+] 恢复会把安全描述符回放回去(删源之后仍然逐对象与备份前一致) 2.14s (2.14s|0ms)
[+] -SkipSecurity 时不回放(目标保持新建对象的默认 ACL) 1.77s (1.77s|0ms)
[+] 归档旁边没有 acl.json 时打告警、不算失败(旧归档照样恢复得出来) 1.76s (1.76s|0ms)
Running tests from 'D:\Workspace\Temp\BakNRet\tests\BakNRet.Tests.ps1'
Describing BackupList.txt 解析
[+] 注释行与空行返回 $null 2ms (2ms|1ms)
[+] 裸路径:Path 原样、方向 both、不是软件名、没有任何覆盖 6ms (6ms|0ms)
[+] 软件名:IsName 为真 2ms (1ms|0ms)
[+] IsName 判定:含分隔符或 % 就算字面路径 3ms (2ms|0ms)
[+] 标记必须是独立记号:C:\a:-b 仍然只是一个路径 2ms (2ms|0ms)
[+] 行首 + 仅备份、- 仅恢复,方向标记不进入目标 4ms (4ms|0ms)
[+] 只有方向标记、没有目标 -> 忽略该行 2ms (2ms|0ms)
[+] 行首标记贴在目标上也算(+Name / -Path),且标记不进入目标 4ms (4ms|0ms)
[+] 非行首的 + / - 不是方向标记 22ms (22ms|0ms)
[+] :: 现在表示"覆盖 Path",与 :- 彻底分开 8ms (5ms|3ms)
[+] :: 的值可以带空格(一直取到下一个标记之前) 2ms (2ms|0ms)
[+] :- 的逗号 / 分号列表拆成多个模式 7ms (6ms|0ms)
[+] 排除模式的引号只保护空白,不保护逗号 3ms (2ms|0ms)
[+] :+ 的包含项是 : 2ms (2ms|0ms)
[+] :encrypt 与 :!encrypt 控制该条目的加密开关 2ms (2ms|0ms)
[+] @ Key='Value' 覆盖 Path / Exclude / Include / Encrypt 4ms (4ms|0ms)
[+] @ 的键名大小写不敏感,且支持紧跟 @ 的写法 2ms (2ms|0ms)
[+] 历史写法 @encrypt / @!encrypt 仍然被识别成加密覆盖 2ms (2ms|0ms)
[+] 历史写法 @pathname / @root= 进入 Flags 3ms (2ms|0ms)
[2026-09-28 12:42:30] [WARN] 清单里的 @ 字段 'UnknownKey' 不是已知字段(Path / Exclude / Include / Encrypt),已忽略:Foo @ UnknownKey='v' :- logs\
[+] 未知的 @ 字段进入 UnknownKeys,且不影响其余字段解析 2ms (2ms|0ms)
[+] 行尾的 # 说明会成为 Comment 2ms (2ms|0ms)
[+] 路径里紧贴的 # 不会被当成注释 1ms (1ms|0ms)
[+] 没有说明时 Comment 为空 1ms (1ms|0ms)
[+] 目标可以带空格:第一个标记之前整段都是目标 3ms (2ms|0ms)
[2026-09-28 12:42:30] [WARN] 清单行缺少目标,已忽略::- logs
[2026-09-28 12:42:30] [WARN] 清单行缺少目标,已忽略:@ Exclude='x'
[+] 缺少目标(标记出现在第一个位置)会被忽略并告警 2ms (2ms|0ms)
[+] 记号切分:引号内的空白不切分,引号本身留在记号里 2ms (1ms|0ms)
[+] 记号识别只认完整记号 2ms (2ms|0ms)
[+] 去引号:成对才去,不成对原样返回 2ms (1ms|0ms)
Describing 归档命名
[+] 基础命名规则:末级名_from_上级路径用加号连接 2ms (1ms|1ms)
[+] / 与 \ 以及重复分隔符结果一致 2ms (1ms|0ms)
[+] 命名与路径往返 6ms (6ms|0ms)
[+] 归档名里不含非法文件名字符 4ms (4ms|0ms)
[+] %变量% 写法里的 % 会保留在归档名里 1ms (1ms|0ms)
[+] 软件名条目:默认用软件名做归档名 5ms (4ms|0ms)
[+] 字面路径条目:仍用路径命名算法(现有清单无需改写) 2ms (2ms|0ms)
[+] @pathname 用名录里的真实路径命名,而不是软件名 4ms (3ms|0ms)
[2026-09-28 12:42:30] [WARN] 名录里没有 'no-such-thing',按目录名处理
[2026-09-28 12:42:30] [WARN] 名录里没有 'no-such-thing',按目录名处理
[+] 名录里没有该名字:归档名退回可读目录名,并给出 Error 5ms (4ms|0ms)
Describing 7z 排除参数翻译
[+] 相对模式自动补上归档根目录名 5ms (5ms|1ms)
[+] 模式已带根名前缀时,Split-BakNRetPatternScope 先摘掉前缀,结果不重复 5ms (4ms|0ms)
[+] ! 前缀翻译成递归组件匹配(-xr!) 1ms (1ms|0ms)
[+] 模式里的空格转成 ?(归档项自己的名字按原样保留) 2ms (1ms|0ms)
[+] 生成的参数里绝不出现引号(旧实现 -x!"路径" 让排除全部失效) 1ms (1ms|0ms)
[+] 空模式被忽略 2ms (1ms|0ms)
[+] !re: 会把正则展开成精确的 -x! 参数 25ms (25ms|0ms)
[+] 非法正则给出 Error,而不是抛异常 6ms (6ms|0ms)
[+] 正则命中数超过上限时明确报错 3ms (3ms|0ms)
[+] 参数总长超过安全上限时明确报错 1ms (1ms|0ms)
[+] Split-BakNRetPatternScope:指名 Slot 的模式只分给那个 Slot 2ms (1ms|0ms)
[+] Split-BakNRetPatternScope:没点名任何 Slot 的模式广播给每一项 1ms (1ms|0ms)
[+] Split-BakNRetPatternScope:! 与 !re: 模式广播给每一项 1ms (1ms|0ms)
[+] Split-BakNRetPatternScope:每个归档项都有键(没有模式时是空数组) 2ms (2ms|0ms)
[+] Merge-BakNRetExcludeArgument 去重且保持顺序 3ms (2ms|0ms)
[+] 多 Slot 条目:逐项分配 + 翻译 + 去重合成一份参数 3ms (3ms|0ms)
Describing 归档项与暂存目录
[+] New-BakNRetArchiveItem 规范化归档内路径并算出 TopName 3ms (3ms|1ms)
[+] Get-BakNRetArchiveTopName 取归档内相对路径的第一段 1ms (1ms|0ms)
[+] 暂存半途失败会自己清干净(不留指向真实数据的 junction) 33ms (32ms|0ms)
[+] 目录项用 junction 挂进暂存目录,文件项用硬链接(名字就是归档内路径) 18ms (17ms|0ms)
[+] Remove-BakNRetArchiveStaging 只删连接点,不顺着走进真实目录 8ms (8ms|0ms)
Describing 命令行参数拼接
[+] 无空格参数原样输出 2ms (1ms|1ms)
[+] 含空格参数加引号 3ms (2ms|0ms)
[+] 引号内的结尾反斜杠翻倍(否则会被当成转义引号) 2ms (1ms|0ms)
[+] 内部引号被转义 1ms (1ms|0ms)
[+] 空参数输出一对空引号 2ms (1ms|1ms)
Describing manifest 与配置
[+] manifest 读写往返 10ms (9ms|1ms)
[+] manifest 原样保存 layouts(name/kind),全新恢复时靠它判断目录还是文件 7ms (7ms|0ms)
[2026-09-28 12:42:30] [WARN] manifest 解析失败(将重新建立):C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\broken.json —— Conversion from JSON failed with error: Invalid character after parsing property name. Expected ':' but got: i. Path '', line 1, position 7.
[+] manifest 损坏时不抛异常,而是重建空清单 9ms (8ms|0ms)
[+] 配置缺失时返回默认值 4ms (4ms|0ms)
[+] 配置嵌套段落合并且不丢默认键 6ms (6ms|0ms)
[+] 口令:环境变量可读取,取不到时返回 $null(绝不退化成明文) 3ms (3ms|0ms)
Describing 软件名录
[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path
[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot
[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' }
[+] 名录解析:条目与 Slot 的字段集合就是新契约 54ms (53ms|1ms)
[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path
[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot
[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' }
[+] 一个软件多个 Slot:Kind=Multi,Slot 按名字排序 33ms (33ms|0ms)
[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path
[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot
[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' }
[+] Kind:Single / Multi / Partial / Unresolved / Invalid 34ms (34ms|0ms)
[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path
[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot
[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' }
[+] 前缀补全:_ 与 - 都会被补全 28ms (27ms|0ms)
[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path
[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot
[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' }
[+] 不会把 Legendary 误配成 LegendarySomething 28ms (27ms|0ms)
[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path
[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot
[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' }
[+] 一个 Slot 命中多个候选目录:报 Error,绝不悄悄挑一棵树 31ms (31ms|0ms)
[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path
[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot
[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' }
[+] 文件 Slot:Path 指向文件时 IsFile 为真,Encrypt 也带上 25ms (25ms|0ms)
[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path
[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot
[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' }
[+] %变量% 会在名录路径里展开 27ms (27ms|0ms)
[+] $( ... ) 子表达式会被求值(含嵌套) 6ms (6ms|0ms)
[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path
[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot
[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' }
[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path
[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot
[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' }
[+] 读取结果按"路径 + 时间戳 + 长度 + 内容 MD5"缓存 63ms (62ms|0ms)
[+] 名录文件内容变了以后缓存自动失效 17ms (16ms|0ms)
[+] Includes:分文件维护的名录会被合并 12ms (11ms|0ms)
[2026-09-28 12:42:30] [ERROR] 名录条目 'arr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:30] [ERROR] 名录条目 'objarr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:30] [ERROR] 名录条目 'bare' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:30] [ERROR] 名录条目 dirstyle 有问题:Slot Dirs 的写法不对,应写成 @{ Path = '...' }
[+] 旧的裸字符串 / 字符串数组 / 对象数组写法都会报 ERROR 并被跳过 3ms (3ms|0ms)
[2026-09-28 12:42:30] [ERROR] 名录条目 'arr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:30] [ERROR] 名录条目 'objarr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:30] [ERROR] 名录条目 'bare' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:30] [ERROR] 名录条目 dirstyle 有问题:Slot Dirs 的写法不对,应写成 @{ Path = '...' }
[+] 旧的 @{ Dirs = @(...) } 写法不再展开:留下 Invalid 条目和原因 4ms (3ms|0ms)
[+] 软件名会被规范化成合法文件名 2ms (1ms|0ms)
Describing Resolve-BakNRetBackupEntry:条目解析
[+] resolve 与归档项的字段集合就是新契约(旧字段已删除) 4ms (4ms|1ms)
[+] 软件名条目:isName/CatalogEntry/BaseName/ArchiveFlavor/Source 7ms (6ms|0ms)
[+] 软件名条目:Items 来自 Slot(ArchivePath=Slot 名,Kind=slot,Origin=catalog) 6ms (6ms|0ms)
[+] 字面路径条目:一个 path 项(ArchivePath 是末级名) 3ms (3ms|0ms)
[+] 字面路径条目上的 @ Path= 覆盖目标路径,但归档名仍按原路径 2ms (2ms|0ms)
[+] 名录里的路径不存在时仍给出 Items(恢复要靠它还原回原位) 5ms (5ms|0ms)
[+] :: 覆盖 Path:单 Slot 条目直接生效 5ms (5ms|0ms)
[+] :: / @ Path= 覆盖遇到多 Slot 条目 -> Blocking(不猜是哪一个) 5ms (5ms|0ms)
[+] 条目级 :- 覆盖名录里的排除:HasExcludeOverride 为真 3ms (3ms|0ms)
[+] 名录 Slot 的 Include 会追加成 include 项 5ms (5ms|0ms)
[+] 条目级 :+ / @ Include= 覆盖名录里的 Include 11ms (11ms|0ms)
[2026-09-28 12:42:30] [WARN] encapp:名录里各 Slot 的 Encrypt 不一致,整个归档按加密处理
[+] 加密:名录里各 Slot 取或;条目级 :encrypt / :!encrypt 覆盖 9ms (9ms|0ms)
[+] 方向标记会传递到 Resolve-BakNRetBackupEntry.Direction 5ms (5ms|0ms)
[+] 归档内路径冲突(include 与 Slot 同名)-> Blocking 5ms (4ms|0ms)
[+] 归档内路径冲突(父子关系)-> Blocking 5ms (5ms|0ms)
[2026-09-28 12:42:30] [WARN] 名录里没有 'no-such-thing',按目录名处理
[+] 名录里没有该名字:Error 给出,Items 为空 3ms (2ms|0ms)
Describing 外部命令退出码(旧实现的核心缺陷)
[2026-09-28 12:42:30] [DEBUG] 执行: cmd.exe /c "exit 0" -p<口令已隐藏>
[+] 口令不会进日志:DEBUG 下打印的命令行要遮蔽 -p 参数 31ms (30ms|1ms)
[+] 运行锁:同一份备份目录同时只能有一个持有者 27ms (27ms|0ms)
[+] 运行锁:释放之后可以重新取得 8ms (7ms|0ms)
[+] 空目录的摘要给 0 而不是 $null(否则空间守卫会静默失效) 9ms (9ms|0ms)
[+] 原子替换:成功时新内容到位且不留 .tmp;失败时旧内容完好 14ms (14ms|0ms)
[+] Invoke-ExternalCommand 能拿到真实退出码 13ms (12ms|0ms)
[+] 成功时拿到 0 12ms (12ms|0ms)
Describing 集成:真实 7z 压缩与排除规则
[+] 排除规则与空格处理在真实归档上生效 55ms (54ms|1ms)
[+] 对照组:不加排除时被排除的文件确实在归档里(证明上一条不是空归档) 58ms (57ms|0ms)
ERROR: C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\integration\corrupt.7z
C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\integration\corrupt.7z
Open ERROR: Cannot open the file as [7z] archive
ERRORS:
Headers Error
[+] 7z t 对完好归档返回 0,对损坏归档返回非 0(归档后校验的依据) 52ms (52ms|0ms)
Describing 集成:Backup.ps1 / Restore.ps1 端到端(字面路径条目)
[+] 备份退出码为 0(旧实现会把成功的压缩判成失败) 2ms (1ms|1ms)
[+] 归档已生成且 manifest 记录了条目、动作、校验结果与 layouts 5ms (5ms|0ms)
[+] 备份过程写了日志文件 3ms (2ms|0ms)
[+] 字面路径条目沿用 布局;归档里保留应保留内容、不含被排除项 32ms (32ms|0ms)
[+] [回归] manifest.roots 记录的是归档内真实的顶层条目名 32ms (31ms|0ms)
[+] Restore -DryRun 退出码 0,且一个字节都不写(manifest SHA256 不变) 1.57s (1.57s|0ms)
[+] Restore -WhatIf 同样不写盘 1.6s (1.6s|0ms)
[+] 真实恢复:退出码 0,文件逐字节一致,被排除项没有被恢复出来 2.12s (2.12s|0ms)
[+] 真实恢复之后 manifest 才被更新(lastRestoreAt) 2ms (2ms|0ms)
[+] 孤儿归档会被点名报告,但不影响退出码 2.37s (2.37s|0ms)
[+] 带 -Only 时不做孤儿审计(避免把未选中的归档误报成孤儿) 1.74s (1.74s|0ms)
[+] 源路径不存在时记为 missing-source,退出码仍为 0(跳过不算失败) 1.76s (1.76s|0ms)
[+] 归档名重复时直接报失败(退出码 1),不静默互相覆盖 2.37s (2.37s|0ms)
[+] [回归] 显式指定的清单不存在时报失败(退出码 1),且不在错误位置建模板 1.43s (1.43s|0ms)
[+] [回归] 未显式指定清单时的首次运行仍建模板并退出 0(引导不能被误伤) 1.44s (1.44s|0ms)
Describing 集成:方向标记与孤儿审计
[+] 行首 - 的条目:备份跳过它,但仍把它算作"有主"(不报成孤儿) 4ms (3ms|1ms)
[+] 行首 + 的条目:恢复跳过它、不写回目标,同时登记归档名 2ms (2ms|0ms)
Tests completed in 50.99s
Tests Passed: 185, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0
Pester 测试全部通过:185 项(跳过 0 项)
Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

@@ -0,0 +1,135 @@
# 阶段 0 报告:静态分析与修复
- **项目**:BakNRet(PowerShell 5.1 / 7.x,Windows 备份恢复工具)
- **分支**:`refactor/ms-conventions`(HEAD 之前 `d72fe63`)
- **执行时间**:2026-09-28
- **结论**:✅ **通过**(0 个致命 / 严重问题;2 处真实缺陷已修复并回归;1 处自伤已发现并修复)
---
## 0.1 依赖安全扫描
**执行器**:`/dependency-security-scanner`
| 项目 | 结论 |
| --- | --- |
| 依赖管理文件 | 不存在(无 `package.json` / `requirements.txt` / `go.mod` / `pom.xml` / `Cargo.toml`) |
| 运行时依赖 | **0 个模块** —— 只需 PowerShell 5.1 或 7.x + 7-Zip |
| CVE | 无可报(没有任何被本仓库固定版本的第三方库) |
| 致命 / 严重漏洞 | **0** |
**风险登记**
| ID | 级别 | 内容 | 处置 |
| --- | --- | --- | --- |
| R-1 | 一般 | 无 `LICENSE`,许可证未声明 | 移交阶段 0.2;**建议补一份** |
| R-2 | 一般 | 工作区存在口令文件 `baknret.key`(`.gitignore:18 *.key` 已忽略,`git ls-files` 确认**未被跟踪**) | 建议移到仓库外(`%USERPROFILE%\.baknret.key`)+ `-KeyFile`;**本次全程未读取其内容** |
| R-3 | 建议 | 脚本不检查 7-Zip 版本 | 可选增强 |
| R-4 | 建议 | 口令经命令行传给 7z,进程列表短暂可见 | 7z 上游限制,README 已 CAUTION 披露,无技术解 |
完整性证据:`dependency_security_report.md`。
---
## 0.2 许可证合规检查
**执行器**:`/license-compliance-checker`
| 组件 | 版本 | 许可证 | 判断 |
| --- | --- | --- | --- |
| 本项目 | 1.0.0 | **未声明**(无 `LICENSE`) | ❌ **L-1 需处理** |
| Pester | 5.9.1 | Apache-2.0 | ✅ 宽松,未分发 |
| PSScriptAnalyzer | 1.25.0 | MIT | ✅ 宽松,未分发 |
| Newtonsoft.Json | 随包 | MIT | ✅ 宽松,未分发 |
| 7-Zip | 26.03 | LGPL-2.1 + BSD-3 + unRAR 限制 | ⚠️ L-2:本项目只做解压/压缩,**不实现 RAR 压缩**,不触发该限制;且未捆绑分发 |
| PowerShell / .NET | 宿主 | MIT | ✅ |
**冲突**:无 copyleft 传染。**唯一缺口 = 主许可证未声明**(L-1)。
完整性证据:`license_check_report.md`(含「非法律建议」免责声明)。
---
## 0.3 语法检查与自动修复(严格模式)
**执行器**:`/syntax-fixer`(内含 `/syntax-checker` 语义)
### 检查面
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 编码门禁 | `test.ps1` Encode 层 | ✅ 受管 **133** 个文件:缺 BOM 0、CRLF 0、制表符 0 |
| 解析(PS 7.7.0-preview.5) | `test.ps1` Parse 层 | ✅ **131/131** 解析零错 |
| 解析(5.1.26100.9502) | 同上 | ✅ **131/131** 解析零错 |
| 静态分析 | `tools/Invoke-Analyzer.ps1` | **80 条**,全部 `Warning` 级、**0 条 Error** |
### 已修复的真实缺陷
| # | 文件:行 | 规则 | 修复 | 语义 |
| --- | --- | --- | --- | --- |
| 1 | `Backup.ps1:42` | `PSUseConsistentWhitespace` | `@($Rest)+ @(…)` → `@($Rest) + @(…)`(补 2 处空格) | **无行为改变**,仅排版 |
| 2 | `Restore.ps1:42` | `PSUseConsistentWhitespace` | 同上(补 2 处空格) | **无行为改变**,仅排版 |
### ⚠️ 修复过程中的自伤与恢复(值得记录)
修改 `Backup.ps1` / `Restore.ps1` 后,两个文件的 **UTF-8 BOM 被编辑器抹掉**,导致:
```text
7 : parse_bad=0 <- PowerShell 7 正常
5.1 : ERR Backup.ps1 : The string is missing the terminator: '.
ERR Restore.ps1 : The string is missing the terminator: '.
```
根因正是本仓库 `.editorconfig` 写明的那条:**5.1 没有 BOM 就按 ANSI 解码源码**,中文注释与全角字符的字节序列吃掉了字符串引号。
处置:用 `UTF8Encoding($true)` 重写并复核 → BOM=True、CRLF=False → 双宿主 `parse_bad=0` → `test.ps1 -Suite Parse` 恢复全绿。
**这条恰好证明了本仓库 Encode 层的价值**,也说明「编辑 PowerShell 源文件必须保留 BOM」是一条硬约束。
### 未修复项:按仓库已声明的偏离登记(用户已确认此处置)
| 规则 | 条数 | 为什么不改 | 依据 |
| --- | --- | --- | --- |
| `PSAvoidLongLines` | 54 | 仓库**有意**把上限设为 160 而非官方 120(120 意味着 270 处改动) | `PSScriptAnalyzerSettings.psd1` 第 22–25 行;[ADR-0008](../../../docs/adr/0008-analyzer-deviations.md) |
| `PSPlaceCloseBrace` | 7 | 全在 `Run-Tests.ps1` **测试夹具字符串**内,缩进/换行是被断言的文本 | 改了会改变断言语义 |
| `PSUseConsistentIndentation` | 4 | 同上 | 同上 |
| `PSAlignAssignmentStatement` | 4 | `Invoke-BakNRetMenu.ps1` 的表格字面量,对齐影响 TUI 列宽 | 属于刻意排版 |
| `PSUseSupportsShouldProcess` | 4 | 该规则**已全局排除**于配置,属已知噪声 | `PSScriptAnalyzerSettings.psd1` 第 44 行 |
| `PSAvoidUsingEmptyCatchBlock` | 2 | `Write-BakNRetAt.ps1:39` 有意空 catch(注释已写明理由:定位失败不影响写文本);加输出会破坏 TUI | 源码注释 |
| `PSReviewUnusedParameter` | 4 | 3 条在测试 helper;1 条**可能真有价值** → 见下 | — |
| `PSUseDeclaredVarsMoreThanAssignments` | 1 | `BakNRet.Security.Tests.ps1:373` 的 `$sourcePath` 已赋值未使用 | 疑似残留,非功能缺陷 |
### 🔎 待人工确认的观察项(未改动)
```text
BakNRet/Public/Write-BakNRetRunSummary.ps1:16
[Parameter(Mandatory = $true)][ValidateSet('backup','restore','verify')][string]$Mode
```
- `$Mode` 声明了 `ValidateSet` 与 `Mandatory`,但函数体内**从未读取**(全文仅第 16 行出现)。
- 该函数也**从未被仓库内任何代码调用**(`grep` 全仓仅命中定义处),只通过 `FunctionsToExport` 对外导出。
- 判断:像是「按运行类型分组」的未完工实现,**不是**能安全自动删改的东西(删除会破坏公共 API 参数契约)。
- 处置:登记为观察项,**留待仓库作者决定**。
---
## 0.4 回归验证
| 层次 | PS 7.7.0-preview.5 | 5.1.26100.9502 |
| --- | --- | --- |
| Encode(宿主无关,跑一次) | ✅ 133 文件 0 问题 | — |
| Parse | ✅ 131/131 | ✅ 131/131 |
| Unit(Pester) | ✅ 通过 | ✅ 通过 |
| Smoke(零依赖) | ✅ 通过 | ✅ 通过 |
| E2E | ✅ 通过 | ✅ 通过 |
| **合计** | **9/9 PASS,退出码 0** | |
修复前后各跑一次,结论一致 → **无回归**。
分析器:`84 → 80` 条(4 条 `PSUseConsistentWhitespace` 全部归零,其余不变)。
证据:`analyzer_raw.txt`(修复前)、`analyzer_after.txt`(修复后)。
---
## 0.5 阶段结论
- ✅ 无致命 / 严重问题,**流水线可继续**。
- 🔧 2 处真实排版缺陷已修复;1 处 BOM 自伤已发现并恢复(这条本身是「Encode 层有效」的实证)。
- ⚠️ 3 项待办移交下游:补 `LICENSE`(L-1)、迁移 `baknret.key`(R-2)、确认 `$Mode` 意图(观察项)。
- 严格模式「零容忍」与仓库**已声明偏离**冲突的部分,按用户决定:**登记而不改**,理由逐条留档(上表)。
@@ -0,0 +1,90 @@
# 阶段 3 报告:测试增强评估与性能基线
- **执行时间**:2026-09-28
- **结论**:性能基线已建立(首次,无退化可判);单元测试增强**建议但未执行**(理由见下)
---
## 3.1 单元测试增强评估(`/unit-test-generator`,可选步骤)
### 现状盘点
| 指标 | 数值 |
| --- | --- |
| 对外函数总数 | **89**(`BakNRet/Public/*.ps1`,与 `FunctionsToExport` 白名单逐一核对一致) |
| 测试中被**直接点名**的函数 | 70(78.7%) |
| 未被直接点名 | 19(21.3%) |
| Pester 用例 | **185** 条(`BakNRet.Tests.ps1` 127 + `Formats` 33 + `Security` 25),0 失败 0 跳过 |
| 另有 | 零依赖套件 128 项、端到端 36 项、真实归档演练 12 项 |
### 未被直接点名的 19 个函数——逐条判断
> 「未被直接点名」≠「未被覆盖」:其中不少是通过集成路径间接执行到的(如 `Write-BakNRetLog` 被所有用例调用、`ConvertTo-BakNRetWildcardPattern` 由 `Test-BakNRetPathExcluded` 间接驱动)。
| 函数 | 是否值得补测试 | 理由 |
| --- | --- | --- |
| `Move-BakNRetArchiveIntoPlace` | **值得(最高优先)** | 「临时文件 → 校验 → 原子替换」是本仓库的核心安全承诺之一,且 CHANGELOG 记录过它在 5.1 上退化成「先删后移」的缺陷。目前只有端到端间接覆盖 |
| `Resolve-BakNRetRootedPath` | 值得 | 「相对路径按仓库根解析,不按工作目录」是计划任务场景的关键约定(README 专门写了这一节) |
| `Test-BakNRetItemSelected` | 值得 | `-Only` / `-Skip` 的通配匹配语义,边界(大小写、通配符)值得钉住 |
| `ConvertFrom-BakNRetPatternList` | 值得 | `,` 与 `;` 双分隔符是历史缺陷的修复点(CHANGELOG:解析器用 `;` 而清单里写 `,`) |
| `Get-Optimized7zArgument` | 一般 | 参数拼接优化,间接覆盖已足够 |
| `Save-BakNRetItemRecord` | 一般 | 与 `Write-BakNRetManifest` 组合使用,集成覆盖 |
| `Find-BakNRet7zExecutable` / `Resolve-BakNRetCompressionTool` | 一般 | 依赖机器状态,单元测试价值低(真实冒烟更合适) |
| `Get-BakNRetFreeSpaceGB` / `Test-BakNRetAdministrator` | 低 | 环境查询,测试易受机器状态影响 |
| `Enable-BakNRetPrivilege` / `Set-BakNRetObjectSecurity` / `Get-BakNRetAceSignatureList` / `Test-BakNRetSecurityRecordNeeded` | 低(**已由 VM 演练覆盖**) | 这几条需要真实提权与真实 NTFS,单元测试造不出可信夹具;仓库已经用 `tools/lab/Lab.ps1 acl-test`(含负对照)在真 VM 里覆盖,这是更正确的层次 |
| `Write-BakNRetBackupEntryPlan` | 低 | 纯输出,间接覆盖 |
| `Invoke-BakNRetEntryScript` | 低 | 子进程封装,端到端已覆盖 |
| TUI 相关(`Write-BakNRetAt` 等) | 低 | 需要真终端;仓库已用 `-InputScript` 驱动做门禁 |
### 为什么本次**不执行**自动补测
1. 该步骤在流水线里标注为**可选**。
2. 用户本轮选择的处置姿态是「最小侵入、不制造大 diff」;补 4~6 条新用例属于**新增工作**而非修复缺陷,应先取得确认。
3. 上述「值得」的 4 条若要补,需要配套夹具(原子替换需要真归档、`-Only` 需要真清单),属于小规模但真实的工作量。
**建议**:单独一轮补 `Move-BakNRetArchiveIntoPlace` 与 `Resolve-BakNRetRootedPath` 两条(这两条对应的是历史上真出过问题的语义),其余保持现状。
---
## 3.2 性能基线(`/performance-baseline-tester`)
**工具适配说明**:该技能面向 HTTP 服务(k6 / RPS / P95)。BakNRet 是**本地 CLI 工具**,
没有 HTTP 端点,因此把「核心 API」映射为**四条关键路径**:模块导入、清单解析、只读干跑(真实清单)、
7z 压缩吞吐。指标是同一台机器上的可复现绝对耗时,用于**同环境前后对比**,不跨机器比较。
**环境**:宿主 `STRIX-X870A`,PowerShell 7.7.0-preview.5,7-Zip 26.03 (x64)
| 指标 | 采样 | min | **中位数** | max |
| --- | --- | --- | --- | --- |
| 模块导入(`Import-Module` 冷启) | 5 | 628.3 ms | **631.0 ms** | 2052.8 ms |
| 清单解析 ×50 轮 | 5 | 2535.9 ms | **2546.9 ms** | 2691.5 ms |
| **只读干跑(真实 28 条清单)** | 5 | 9839.9 ms | **9869.2 ms** | 12900.8 ms |
| 源树扫描(200 文件 ×3 轮) | 1 | — | **18.2 ms** | — |
**7z 压缩基准**(固定夹具:200 × 32 KB = 6.25 MB **不可压缩随机数据**)
| 压缩级别 | 耗时 | 归档体积 |
| --- | --- | --- |
| `-mx=0` | 46.7 ms | 6,555,514 B |
| `-mx=5` | 224.2 ms | 6,555,913 B |
| `-mx=9` | 225.2 ms | 6,555,913 B |
> 夹具是随机数据,所以体积几乎不随级别变化 —— 这正好说明**该夹具测的是吞吐与 I/O,不是压缩率**。
> 若要看压缩率,应换成真实可压缩语料。
### 基线判定
- **基线文件**:`.scratch/ci-cd/20260928-001722/perf_baseline.json`(首次建立,**无历史基线可比,故无退化可判**)。
- **观察**:只读干跑约 9.9 秒是最大单项。它包含了**对 28 个条目的真实目录扫描与空间预估**,
属于设计内的开销(README 承诺「动手之前先预估空间」)。`max 12.9 s` 的离群值出现在首次运行,
与文件系统缓存冷启动一致。
- **告警**:无退化(首次基线)。
- **建议**:若将来要监控退化,把 `perf_baseline.json` 固定成仓库内基线,
并在同机同宿主下对比;**不要在 VM 与宿主之间直接比数字**(两者 I/O 特性不同)。
---
## 3.3 结论
- ✅ 性能基线已建立并可复现,**无退化**。
- ⏸️ 单元测试增强:已给出逐条优先级,**建议单独一轮补 2 条**,本轮不擅自新增。
+171
View File
@@ -0,0 +1,171 @@
# 阶段 2 回归:把阶段 1 的 11 条黑盒用例按**修正后的前置条件**重跑一遍。
# 修正点:
# * BB-11 原用 -DryRun,而孤儿审计只在真实运行里报告 -> 改成真实运行(原用例的夹具错误,不是产品缺陷)
# * BB-06 的期望不变(显式清单不存在必须非 0),验证修复是否生效
param([string]$OutJson = "$PSScriptRoot\blackbox_regression.json")
$ErrorActionPreference = 'Continue'
$repo = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$host7 = (Get-Command pwsh).Source
$host51 = (Get-Command powershell).Source
$zip = (Get-Command 7z -ErrorAction SilentlyContinue).Source
if (-not $zip) { foreach ($p in 'C:\Programs\Scoop\shims\7z.exe') { if (Test-Path $p) { $zip = $p } } }
$sandbox = Join-Path $env:TEMP ('baknret-reg-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
$src = Join-Path $sandbox 'srcdocs'
New-Item -ItemType Directory -Path (Join-Path $src 'nested') -Force | Out-Null
Set-Content -LiteralPath (Join-Path $src 'a.txt') -Value 'alpha' -Encoding utf8
Set-Content -LiteralPath (Join-Path $src 'nested\b.txt') -Value 'beta' -Encoding utf8
Set-Content -LiteralPath (Join-Path $src 'nested\skipme.log') -Value 'noise' -Encoding utf8
$listPath = Join-Path $sandbox 'BackupList.txt'
Set-Content -LiteralPath $listPath -Encoding utf8 -Value @(
"$src :- 'nested\skipme.log'",
'+ ' + (Join-Path $sandbox 'nonexistent')
)
$backupDir = Join-Path $sandbox 'Backups'
$cases = [System.Collections.Generic.List[object]]::new()
function Add-Case {
param([string]$Id, [string]$Title, [string]$Expected, [string]$Actual, [bool]$Pass, [string]$Note = '')
$cases.Add([pscustomobject]@{ Id = $Id; Title = $Title; Expected = $Expected; Actual = $Actual
Status = $(if ($Pass) { 'PASS' } else { 'FAIL' }); Note = $Note })
}
function Invoke-Bak {
param([string]$HostName, [string[]]$Arguments, [string]$Script = 'Backup-Data.ps1')
$exe = if ($HostName -eq '7') { $host7 } else { $host51 }
$all = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $repo $Script)) + $Arguments
$out = & $exe @all 2>&1
return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = @($out) }
}
# ---------------------------------------------------------------- BB-01 真实备份
$r = Invoke-Bak -HostName 7 -Arguments @('-BackupListPath', $listPath, '-BackupDir', $backupDir)
$archives = @(Get-ChildItem -LiteralPath $backupDir -Filter '*.7z' -ErrorAction SilentlyContinue)
Add-Case -Id 'BB-01' -Title '真实备份:退出码 0 + 产出归档 + manifest' `
-Expected '退出码 0;≥1 个 .7z;manifest.json 存在' `
-Actual ("退出码={0};归档={1}" -f $r.ExitCode, $archives.Count) `
-Pass ($r.ExitCode -eq 0 -and $archives.Count -ge 1 -and (Test-Path (Join-Path $backupDir 'manifest.json')))
# ---------------------------------------------------------------- BB-02 排除生效
$listing = if ($archives.Count -gt 0 -and $zip) { @(& $zip l -ba $archives[0].FullName 2>&1) } else { @() }
$hasSkip = @($listing | Where-Object { $_ -match 'skipme\.log' }).Count -gt 0
$hasKeep = @($listing | Where-Object { $_ -match 'a\.txt|b\.txt' }).Count -gt 0
Add-Case -Id 'BB-02' -Title '排除模式真的把内容挡在归档之外' `
-Expected '归档内无 skipme.log,有 a.txt/b.txt' `
-Actual ("skipme={0};keep={1}" -f $hasSkip, $hasKeep) -Pass ($hasKeep -and -not $hasSkip)
# ---------------------------------------------------------------- BB-03 干跑不写盘
$h1 = (Get-FileHash -LiteralPath (Join-Path $backupDir 'manifest.json') -Algorithm SHA256).Hash
$r = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir)
$h2 = (Get-FileHash -LiteralPath (Join-Path $backupDir 'manifest.json') -Algorithm SHA256).Hash
Add-Case -Id 'BB-03' -Title '-DryRun 一个字节都不写' `
-Expected '退出码 0;manifest SHA256 前后一致' `
-Actual ("退出码={0};哈希一致={1}" -f $r.ExitCode, ($h1 -eq $h2)) -Pass ($r.ExitCode -eq 0 -and $h1 -eq $h2)
# ---------------------------------------------------------------- BB-04 missing-source
$actions = @((Get-Content (Join-Path $backupDir 'manifest.json') -Raw | ConvertFrom-Json).items.PSObject.Properties.Value.action)
Add-Case -Id 'BB-04' -Title '源不存在记 missing-source,不算失败' `
-Expected '含 missing-source;退出码 0' `
-Actual ("actions={0};退出码={1}" -f ($actions -join ','), $r.ExitCode) `
-Pass ($actions -contains 'missing-source' -and $r.ExitCode -eq 0)
# ---------------------------------------------------------------- BB-05 -Only
$r = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-Only', 'srcdocs', '-BackupListPath', $listPath, '-BackupDir', $backupDir)
$hitMissing = @($r.Output | Where-Object { $_ -match 'nonexistent' }).Count -gt 0
Add-Case -Id 'BB-05' -Title '-Only 只处理匹配的条目' `
-Expected '退出码 0;输出不提未选中的 nonexistent' `
-Actual ("退出码={0};提到 nonexistent={1}" -f $r.ExitCode, $hitMissing) -Pass ($r.ExitCode -eq 0 -and -not $hitMissing)
# ---------------------------------------------------------------- BB-06 ★ 修复验证
$ghost = Join-Path $sandbox 'typo-in-path.txt'
$r = Invoke-Bak -HostName 7 -Arguments @('-BackupListPath', $ghost, '-BackupDir', $backupDir)
$created = Test-Path -LiteralPath $ghost
Add-Case -Id 'BB-06' -Title '★显式指定的清单不存在 -> 必须报失败(本次修复点)' `
-Expected '退出码 ≠ 0;不创建模板;输出含"指定的清单不存在"' `
-Actual ("退出码={0};误建模板={1};提示={2}" -f $r.ExitCode, $created, (@($r.Output | Where-Object { $_ -match '指定的清单不存在' }).Count -gt 0)) `
-Pass ($r.ExitCode -ne 0 -and -not $created -and (@($r.Output | Where-Object { $_ -match '指定的清单不存在' }).Count -gt 0)) `
-Note '阶段 1 原为 FAIL(退出码 0 且建了模板),本次修复后应转为 PASS'
# ---------------------------------------------------------------- BB-06b 对照:首次运行引导仍在
$realList = Join-Path $repo 'BackupList.txt'
$parked = Join-Path $sandbox 'BackupList.parked'
$guideDir = Join-Path $sandbox 'guide'
New-Item -ItemType Directory -Path $guideDir -Force | Out-Null
$r = $null
try {
Move-Item -LiteralPath $realList -Destination $parked -Force
$r = Invoke-Bak -HostName 7 -Arguments @('-BackupDir', (Join-Path $guideDir 'Backups'))
$regenerated = Test-Path -LiteralPath $realList
}
finally {
if (Test-Path -LiteralPath $parked) { Move-Item -LiteralPath $parked -Destination $realList -Force }
}
Add-Case -Id 'BB-06b' -Title '对照:未显式指定时首次运行仍建模板并退出 0' `
-Expected '退出码 0;模板被创建;随后仓库原文件已还原' `
-Actual ("退出码={0};模板创建={1};仓库还原={2}" -f $r.ExitCode, $regenerated, (Test-Path -LiteralPath $realList)) `
-Pass ($r.ExitCode -eq 0 -and $regenerated -and (Test-Path -LiteralPath $realList))
# ---------------------------------------------------------------- BB-07 垫片转发
$r = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir) -Script 'Backup.ps1'
$said = @($r.Output | Where-Object { $_ -match '已改名为' }).Count -gt 0
Add-Case -Id 'BB-07' -Title '旧名字垫片转发且退出码原样传递' `
-Expected '退出码 0;有改名提示' `
-Actual ("退出码={0};提示={1}" -f $r.ExitCode, $said) -Pass ($r.ExitCode -eq 0 -and $said)
# ---------------------------------------------------------------- BB-08 双宿主
$r7 = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir)
$r51 = Invoke-Bak -HostName '5.1' -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir)
Add-Case -Id 'BB-08' -Title '5.1 与 7.x 行为一致' `
-Expected '两个宿主退出码都是 0' `
-Actual ("7={0};5.1={1}" -f $r7.ExitCode, $r51.ExitCode) -Pass ($r7.ExitCode -eq 0 -and $r51.ExitCode -eq 0)
# ---------------------------------------------------------------- BB-09 特殊字符
$sp = Join-Path $sandbox 'sp&chars#dir'
New-Item -ItemType Directory -Path $sp -Force | Out-Null
Set-Content -LiteralPath (Join-Path $sp 'x.txt') -Value 'special' -Encoding utf8
$list2 = Join-Path $sandbox 'BackupList2.txt'
Set-Content -LiteralPath $list2 -Encoding utf8 -Value "`"$sp`""
$bd2 = Join-Path $sandbox 'Backups2'
$r = Invoke-Bak -HostName 7 -Arguments @('-BackupListPath', $list2, '-BackupDir', $bd2)
$n = @(Get-ChildItem -LiteralPath $bd2 -Filter '*.7z' -ErrorAction SilentlyContinue).Count
Add-Case -Id 'BB-09' -Title '含 & 与 # 的路径(整行引号)' `
-Expected '退出码 0 且产出归档' `
-Actual ("退出码={0};归档={1}" -f $r.ExitCode, $n) -Pass ($r.ExitCode -eq 0 -and $n -ge 1)
# ---------------------------------------------------------------- BB-10 无控制台不挂起
$pinfo = New-Object System.Diagnostics.ProcessStartInfo
$pinfo.FileName = $host7
$pinfo.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$repo\Manage-Backup.ps1`""
$pinfo.RedirectStandardOutput = $true; $pinfo.RedirectStandardError = $true
$pinfo.UseShellExecute = $false; $pinfo.CreateNoWindow = $true
$proc = [System.Diagnostics.Process]::Start($pinfo)
$finished = $proc.WaitForExit(60000)
if (-not $finished) { try { $proc.Kill() } catch {} }
Add-Case -Id 'BB-10' -Title '无控制台且无 -InputScript 时报错退出,绝不挂起' `
-Expected '60 秒内退出且退出码 2' `
-Actual ("已退出={0};退出码={1}" -f $finished, $proc.ExitCode) -Pass ($finished -and $proc.ExitCode -eq 2)
# ---------------------------------------------------------------- BB-11 ★ 修正夹具:真实运行
$orphanDir = Join-Path $sandbox 'Backups3'
New-Item -ItemType Directory -Path $orphanDir -Force | Out-Null
if ($archives.Count -gt 0) { Copy-Item -LiteralPath $archives[0].FullName -Destination (Join-Path $orphanDir 'GhostSoftware.7z') }
$r = Invoke-Bak -HostName 7 -Arguments @('-BackupListPath', $listPath, '-BackupDir', $orphanDir)
$named = @($r.Output | Where-Object { $_ -match 'GhostSoftware' }).Count -gt 0
Add-Case -Id 'BB-11' -Title '★孤儿归档审计(改用真实运行)' `
-Expected '输出点名 GhostSoftware.7z' `
-Actual ("退出码={0};点名={1}" -f $r.ExitCode, $named) -Pass $named `
-Note '阶段 1 的 FAIL 系夹具错误:孤儿审计只在真实运行里报告,干跑不报'
$report = [ordered]@{
testedAt = (Get-Date).ToString('s')
total = $cases.Count
passed = @($cases | Where-Object Status -eq 'PASS').Count
failed = @($cases | Where-Object Status -eq 'FAIL').Count
cases = $cases
}
$report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutJson -Encoding utf8
$cases | Format-Table Id, Status, Title -AutoSize
Write-Host ("回归合计 {0};通过 {1};失败 {2}" -f $report.total, $report.passed, $report.failed)
Write-Host ("沙盒: " + $sandbox)
+209
View File
@@ -0,0 +1,209 @@
# 黑盒测试:只依据 README 记录的对外契约,在**沙盒副本**里驱动 CLI。
# 不读实现细节;每个用例都断言"文档承诺的行为 vs 实际行为"。
param(
[string]$OutJson = "$PSScriptRoot\blackbox_results.json"
)
$ErrorActionPreference = 'Continue'
$repo = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$host7 = (Get-Command pwsh).Source
$host51 = (Get-Command powershell).Source
# ---------------------------------------------------------------- 沙盒(绝不碰真实 Backups/ logs/)
$sandbox = Join-Path $env:TEMP ('baknret-bb-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
$src = Join-Path $sandbox 'srcdocs'
New-Item -ItemType Directory -Path (Join-Path $src 'nested') -Force | Out-Null
Set-Content -LiteralPath (Join-Path $src 'a.txt') -Value 'alpha' -Encoding utf8
Set-Content -LiteralPath (Join-Path $src 'nested\b.txt') -Value 'beta' -Encoding utf8
Set-Content -LiteralPath (Join-Path $src 'nested\skipme.log') -Value 'noise' -Encoding utf8
$listPath = Join-Path $sandbox 'BackupList.txt'
Set-Content -LiteralPath $listPath -Encoding utf8 -Value @(
"$src :- 'nested\skipme.log'",
'+ ' + (Join-Path $sandbox 'nonexistent')
)
$backupDir = Join-Path $sandbox 'Backups'
$cases = [System.Collections.Generic.List[object]]::new()
function Add-Case {
param([string]$Id, [string]$Module, [string]$Title, [string]$Priority,
[string]$Precondition, [string]$Steps, [string]$Expected,
[string]$Actual, [bool]$Pass, [string]$Severity = '')
$cases.Add([pscustomobject]@{
Id = $Id; Module = $Module; Title = $Title; Priority = $Priority
Precondition = $Precondition; Steps = $Steps; Expected = $Expected
Actual = $Actual; Status = $(if ($Pass) { 'PASS' } else { 'FAIL' })
Severity = $(if ($Pass) { '' } else { $Severity })
})
}
function Invoke-Bak {
param([string]$HostName, [string[]]$Arguments, [string]$Script = 'Backup-Data.ps1')
$exe = if ($HostName -eq '7') { $host7 } else { $host51 }
$all = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $repo $Script)) + $Arguments
$out = & $exe @all 2>&1
return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = @($out) }
}
function Get-ManifestHash {
$p = Join-Path $backupDir 'manifest.json'
if (-not (Test-Path -LiteralPath $p)) { return '<absent>' }
return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash
}
# ================================================================ 用例
# BB-01 首次真实备份(核心流)
$before = Get-ManifestHash
$r = Invoke-Bak -HostName 7 -Arguments @('-BackupListPath', $listPath, '-BackupDir', $backupDir)
$archives = @(Get-ChildItem -LiteralPath $backupDir -Filter '*.7z' -ErrorAction SilentlyContinue)
Add-Case -Id 'BB-01' -Module '备份' -Title '文档承诺:备份成功返回 0,并产出 .7z 归档' -Priority 'P0' `
-Precondition '沙盒清单:1 个目录(含排除)+ 1 个不存在的源' `
-Steps 'Backup-Data.ps1(无 -DryRun)' `
-Expected '退出码 0;Backups\ 下出现 1 个 .7z;manifest.json 存在' `
-Actual ("退出码={0};归档={1} 个({2})" -f $r.ExitCode, $archives.Count, ($archives.Name -join ',')) `
-Pass ($r.ExitCode -eq 0 -and $archives.Count -ge 1 -and (Test-Path (Join-Path $backupDir 'manifest.json'))) `
-Severity '严重'
# BB-02 排除规则真的生效(逐个归档内容核对)
$zip = (Get-Command 7z -ErrorAction SilentlyContinue).Source
if (-not $zip) { foreach ($p in 'C:\Programs\Scoop\shims\7z.exe') { if (Test-Path $p) { $zip = $p } } }
$listing = if ($archives.Count -gt 0 -and $zip) { @(& $zip l -ba $archives[0].FullName 2>&1) } else { @() }
$hasSkip = @($listing | Where-Object { $_ -match 'skipme\.log' }).Count -gt 0
$hasKeep = @($listing | Where-Object { $_ -match 'a\.txt|b\.txt' }).Count -gt 0
Add-Case -Id 'BB-02' -Module '排除' -Title '文档承诺::- 排除模式把内容挡在归档之外' -Priority 'P0' `
-Precondition '清单里对源目录写了 :- ''nested\skipme.log''' `
-Steps '7z l 列出归档内容' `
-Expected '归档里**没有** skipme.log,但有 a.txt 与 b.txt' `
-Actual ("skipme.log 命中={0};a/b.txt 命中={1}" -f $hasSkip, $hasKeep) `
-Pass ($hasKeep -and -not $hasSkip) -Severity '严重'
# BB-03 只读模式不写盘(README 的强承诺)
$h1 = Get-ManifestHash
$r = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir)
$h2 = Get-ManifestHash
Add-Case -Id 'BB-03' -Module '干跑' -Title '文档承诺:-DryRun 一个字节都不写(含 manifest.json)' -Priority 'P0' `
-Precondition '已有 1 份归档与 manifest.json' `
-Steps '记录 SHA256 → 跑 -DryRun → 再记录 SHA256' `
-Expected '退出码 0;manifest.json 的 SHA256 前后完全一致' `
-Actual ("退出码={0};哈希 {1} → {2}" -f $r.ExitCode, $h1.Substring(0, 12), $h2.Substring(0, 12)) `
-Pass ($r.ExitCode -eq 0 -and $h1 -eq $h2) -Severity '致命'
# BB-04 源不存在只算跳过、不算失败
Add-Case -Id 'BB-04' -Module '异常流' -Title '文档承诺:源路径不存在记 missing-source,不算失败' -Priority 'P1' `
-Precondition '清单第 2 行指向不存在的目录' `
-Steps '跑备份后读 manifest.json 的 action 字段' `
-Expected '该条目 action=missing-source,且整体退出码仍为 0' `
-Actual ("退出码={0};manifest action 分布={1}" -f $r.ExitCode,
(@((Get-Content (Join-Path $backupDir 'manifest.json') -Raw | ConvertFrom-Json).items.PSObject.Properties.Value.action) -join ',')) `
-Pass ($r.ExitCode -eq 0 -and (@((Get-Content (Join-Path $backupDir 'manifest.json') -Raw | ConvertFrom-Json).items.PSObject.Properties.Value.action) -contains 'missing-source')) `
-Severity '一般'
# BB-05 -Only 过滤
$r = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-Only', 'srcdocs', '-BackupListPath', $listPath, '-BackupDir', $backupDir)
$hitMissing = @($r.Output | Where-Object { $_ -match 'nonexistent' }).Count -gt 0
Add-Case -Id 'BB-05' -Module '干跑' -Title '文档承诺:-Only 只处理匹配的条目' -Priority 'P1' `
-Precondition '清单 2 条:srcdocs(目录)、nonexistent(不存在)' `
-Steps 'Backup-Data.ps1 -DryRun -Only ''srcdocs''' `
-Expected '退出码 0;输出里不出现未选中的 nonexistent 条目' `
-Actual ("退出码={0};输出提到 nonexistent={1}" -f $r.ExitCode, $hitMissing) `
-Pass ($r.ExitCode -eq 0 -and -not $hitMissing) -Severity '一般'
# BB-06 非法参数(错误推测)
$r = Invoke-Bak -HostName 7 -Arguments @('-BackupListPath', (Join-Path $sandbox 'no-such-list.txt'), '-BackupDir', $backupDir)
Add-Case -Id 'BB-06' -Module '异常流' -Title '边界值:清单文件不存在时的行为' -Priority 'P1' `
-Precondition '传入一个不存在的 -BackupListPath' `
-Steps 'Backup-Data.ps1 -BackupListPath <不存在>' `
-Expected '明确报错(非 0 退出码)或给出可读提示,**不得静默返回 0**' `
-Actual ("退出码={0};末行={1}" -f $r.ExitCode, (@($r.Output) | Select-Object -Last 1)) `
-Pass ($r.ExitCode -ne 0) -Severity '一般'
# BB-07 旧名字垫片仍可用(文档承诺"只留一轮")
$r = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir) -Script 'Backup.ps1'
$saidRename = @($r.Output | Where-Object { $_ -match '已改名为' }).Count -gt 0
Add-Case -Id 'BB-07' -Module '兼容' -Title '文档承诺:旧名字 Backup.ps1 是转发垫片,退出码原样传递' -Priority 'P1' `
-Precondition '实现已改名为 Backup-Data.ps1' `
-Steps 'Backup.ps1 -DryRun(旧名字)' `
-Expected '打印改名提示;退出码与直接调用一致(0)' `
-Actual ("退出码={0};有改名提示={1}" -f $r.ExitCode, $saidRename) `
-Pass ($r.ExitCode -eq 0 -and $saidRename) -Severity '一般'
# BB-08 双宿主一致性(5.1 是文档承诺支持的一半)
$r7 = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir)
$r51 = Invoke-Bak -HostName '5.1' -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir)
Add-Case -Id 'BB-08' -Module '跨端一致性' -Title '文档承诺:Windows PowerShell 5.1 与 7.x 行为一致' -Priority 'P0' `
-Precondition '同一沙盒、同一清单' `
-Steps '两个宿主各跑一次 -DryRun,比较退出码' `
-Expected '两者退出码都是 0(GBK 控制台下的中文输出不崩)' `
-Actual ("7 退出码={0};5.1 退出码={1}" -f $r7.ExitCode, $r51.ExitCode) `
-Pass ($r7.ExitCode -eq 0 -and $r51.ExitCode -eq 0) -Severity '致命'
# BB-09 特殊字符路径(错误推测)
$sp = Join-Path $sandbox 'sp&chars#dir'
New-Item -ItemType Directory -Path $sp -Force | Out-Null
Set-Content -LiteralPath (Join-Path $sp 'x.txt') -Value 'special' -Encoding utf8
$list2 = Join-Path $sandbox 'BackupList2.txt'
Set-Content -LiteralPath $list2 -Encoding utf8 -Value "`"$sp`""
$bd2 = Join-Path $sandbox 'Backups2'
$r = Invoke-Bak -HostName 7 -Arguments @('-BackupListPath', $list2, '-BackupDir', $bd2)
$ok9 = $r.ExitCode -eq 0 -and @(Get-ChildItem -LiteralPath $bd2 -Filter '*.7z' -ErrorAction SilentlyContinue).Count -ge 1
Add-Case -Id 'BB-09' -Module '边界值' -Title '特殊字符:含 & 与 # 的路径(整行引号写法)' -Priority 'P2' `
-Precondition '源目录名含 & 与 #;清单行整体加引号' `
-Steps '备份该条目' `
-Expected '退出码 0 且真的产出归档(# 不被当注释吃掉)' `
-Actual ("退出码={0};归档数={1}" -f $r.ExitCode, @(Get-ChildItem -LiteralPath $bd2 -Filter '*.7z' -ErrorAction SilentlyContinue).Count) `
-Pass $ok9 -Severity '一般'
# BB-10 无控制台时的明确失败(README:绝不挂起)
$pinfo = New-Object System.Diagnostics.ProcessStartInfo
$pinfo.FileName = $host7
$pinfo.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$repo\Manage-Backup.ps1`""
$pinfo.RedirectStandardOutput = $true
$pinfo.RedirectStandardError = $true
$pinfo.UseShellExecute = $false
$pinfo.CreateNoWindow = $true
$proc = [System.Diagnostics.Process]::Start($pinfo)
$finished = $proc.WaitForExit(60000)
$stdout = if ($finished) { $proc.StandardOutput.ReadToEnd() } else { '' }
$stderr = if ($finished) { $proc.StandardError.ReadToEnd() } else { '' }
if (-not $finished) { try { $proc.Kill() } catch {} }
Add-Case -Id 'BB-10' -Module '无头' -Title '文档承诺:没有控制台且没给 -InputScript 时报错退出,绝不挂起' -Priority 'P0' `
-Precondition 'stdout/stderr 被重定向(等价于计划任务/管道环境)' `
-Steps '不传参数直接运行 Manage-Backup.ps1,等待最多 60 秒' `
-Expected '60 秒内退出,退出码 2,并提示"没有可用的控制台"' `
-Actual ("60 秒内退出={0};退出码={1};输出片段={2}" -f $finished, $proc.ExitCode, (@($stdout, $stderr) -join ' ').Trim().Substring(0, [Math]::Min(90, (@($stdout, $stderr) -join ' ').Trim().Length))) `
-Pass ($finished -and $proc.ExitCode -eq 2) -Severity '致命'
# BB-11 孤儿归档审计
$orphanDir = Join-Path $sandbox 'Backups3'
New-Item -ItemType Directory -Path $orphanDir -Force | Out-Null
Copy-Item -LiteralPath (Join-Path $backupDir 'manifest.json') -Destination (Join-Path $orphanDir 'manifest.json') -ErrorAction SilentlyContinue
$fake = Join-Path $orphanDir 'GhostSoftware.7z'
if ($archives.Count -gt 0) { Copy-Item -LiteralPath $archives[0].FullName -Destination $fake }
$r = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $orphanDir)
$named = @($r.Output | Where-Object { $_ -match 'GhostSoftware' }).Count -gt 0
Add-Case -Id 'BB-11' -Module '审计' -Title '文档承诺:孤儿归档会被点名' -Priority 'P1' `
-Precondition 'Backups3\ 里放一个清单中不存在的 GhostSoftware.7z' `
-Steps '备份后看输出是否点名' `
-Expected '输出里出现该孤儿归档名' `
-Actual ("退出码={0};点名={1}" -f $r.ExitCode, $named) `
-Pass $named -Severity '一般'
# ---------------------------------------------------------------- 汇总
$report = [ordered]@{
testedAt = (Get-Date).ToString('s')
scope = '文档契约黑盒测试(README 为准)'
sandbox = $sandbox
total = $cases.Count
passed = @($cases | Where-Object Status -eq 'PASS').Count
failed = @($cases | Where-Object Status -eq 'FAIL').Count
fatalOrSevere = @($cases | Where-Object { $_.Status -eq 'FAIL' -and $_.Severity -in '致命', '严重' }).Count
cases = $cases
}
$report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutJson -Encoding utf8
$cases | Format-Table Id, Module, Priority, Status, Severity, Title -AutoSize
Write-Host ''
Write-Host ("合计 {0};通过 {1};失败 {2};致命/严重 {3}" -f $report.total, $report.passed, $report.failed, $report.fatalOrSevere)
Write-Host ("结果写入 {0}" -f $OutJson)
Write-Host ("沙盒(保留供排查): {0}" -f $sandbox)
+104
View File
@@ -0,0 +1,104 @@
{
"testedAt": "2026-09-28T12:43:39",
"total": 12,
"passed": 12,
"failed": 0,
"cases": [
{
"Id": "BB-01",
"Title": "真实备份:退出码 0 + 产出归档 + manifest",
"Expected": "退出码 0;≥1 个 .7z;manifest.json 存在",
"Actual": "退出码=0;归档=1",
"Status": "PASS",
"Note": ""
},
{
"Id": "BB-02",
"Title": "排除模式真的把内容挡在归档之外",
"Expected": "归档内无 skipme.log,有 a.txt/b.txt",
"Actual": "skipme=False;keep=True",
"Status": "PASS",
"Note": ""
},
{
"Id": "BB-03",
"Title": "-DryRun 一个字节都不写",
"Expected": "退出码 0;manifest SHA256 前后一致",
"Actual": "退出码=0;哈希一致=True",
"Status": "PASS",
"Note": ""
},
{
"Id": "BB-04",
"Title": "源不存在记 missing-source,不算失败",
"Expected": "含 missing-source;退出码 0",
"Actual": "actions=backed-up,missing-source;退出码=0",
"Status": "PASS",
"Note": ""
},
{
"Id": "BB-05",
"Title": "-Only 只处理匹配的条目",
"Expected": "退出码 0;输出不提未选中的 nonexistent",
"Actual": "退出码=0;提到 nonexistent=False",
"Status": "PASS",
"Note": ""
},
{
"Id": "BB-06",
"Title": "★显式指定的清单不存在 -> 必须报失败(本次修复点)",
"Expected": "退出码 ≠ 0;不创建模板;输出含\"指定的清单不存在\"",
"Actual": "退出码=1;误建模板=False;提示=True",
"Status": "PASS",
"Note": "阶段 1 原为 FAIL(退出码 0 且建了模板),本次修复后应转为 PASS"
},
{
"Id": "BB-06b",
"Title": "对照:未显式指定时首次运行仍建模板并退出 0",
"Expected": "退出码 0;模板被创建;随后仓库原文件已还原",
"Actual": "退出码=0;模板创建=True;仓库还原=True",
"Status": "PASS",
"Note": ""
},
{
"Id": "BB-07",
"Title": "旧名字垫片转发且退出码原样传递",
"Expected": "退出码 0;有改名提示",
"Actual": "退出码=0;提示=True",
"Status": "PASS",
"Note": ""
},
{
"Id": "BB-08",
"Title": "5.1 与 7.x 行为一致",
"Expected": "两个宿主退出码都是 0",
"Actual": "7=0;5.1=0",
"Status": "PASS",
"Note": ""
},
{
"Id": "BB-09",
"Title": "含 & 与 # 的路径(整行引号)",
"Expected": "退出码 0 且产出归档",
"Actual": "退出码=0;归档=1",
"Status": "PASS",
"Note": ""
},
{
"Id": "BB-10",
"Title": "无控制台且无 -InputScript 时报错退出,绝不挂起",
"Expected": "60 秒内退出且退出码 2",
"Actual": "已退出=True;退出码=2",
"Status": "PASS",
"Note": ""
},
{
"Id": "BB-11",
"Title": "★孤儿归档审计(改用真实运行)",
"Expected": "输出点名 GhostSoftware.7z",
"Actual": "退出码=0;点名=True",
"Status": "PASS",
"Note": "阶段 1 的 FAIL 系夹具错误:孤儿审计只在真实运行里报告,干跑不报"
}
]
}
+143
View File
@@ -0,0 +1,143 @@
{
"testedAt": "2026-09-28T12:35:02",
"scope": "文档契约黑盒测试(README 为准)",
"sandbox": "C:\\Users\\Shuery\\AppData\\Local\\Temp\\baknret-bb-45647a8d",
"total": 11,
"passed": 9,
"failed": 2,
"fatalOrSevere": 0,
"cases": [
{
"Id": "BB-01",
"Module": "备份",
"Title": "文档承诺:备份成功返回 0,并产出 .7z 归档",
"Priority": "P0",
"Precondition": "沙盒清单:1 个目录(含排除)+ 1 个不存在的源",
"Steps": "Backup-Data.ps1(无 -DryRun)",
"Expected": "退出码 0;Backups\\ 下出现 1 个 .7z;manifest.json 存在",
"Actual": "退出码=0;归档=1 个(srcdocs_from_C_+Users+Shuery+AppData+Local+Temp+baknret-bb-45647a8d.7z)",
"Status": "PASS",
"Severity": ""
},
{
"Id": "BB-02",
"Module": "排除",
"Title": "文档承诺::- 排除模式把内容挡在归档之外",
"Priority": "P0",
"Precondition": "清单里对源目录写了 :- 'nested\\skipme.log'",
"Steps": "7z l 列出归档内容",
"Expected": "归档里**没有** skipme.log,但有 a.txt 与 b.txt",
"Actual": "skipme.log 命中=False;a/b.txt 命中=True",
"Status": "PASS",
"Severity": ""
},
{
"Id": "BB-03",
"Module": "干跑",
"Title": "文档承诺:-DryRun 一个字节都不写(含 manifest.json)",
"Priority": "P0",
"Precondition": "已有 1 份归档与 manifest.json",
"Steps": "记录 SHA256 → 跑 -DryRun → 再记录 SHA256",
"Expected": "退出码 0;manifest.json 的 SHA256 前后完全一致",
"Actual": "退出码=0;哈希 6D726A618FD8 → 6D726A618FD8",
"Status": "PASS",
"Severity": ""
},
{
"Id": "BB-04",
"Module": "异常流",
"Title": "文档承诺:源路径不存在记 missing-source,不算失败",
"Priority": "P1",
"Precondition": "清单第 2 行指向不存在的目录",
"Steps": "跑备份后读 manifest.json 的 action 字段",
"Expected": "该条目 action=missing-source,且整体退出码仍为 0",
"Actual": "退出码=0;manifest action 分布=backed-up,missing-source",
"Status": "PASS",
"Severity": ""
},
{
"Id": "BB-05",
"Module": "干跑",
"Title": "文档承诺:-Only 只处理匹配的条目",
"Priority": "P1",
"Precondition": "清单 2 条:srcdocs(目录)、nonexistent(不存在)",
"Steps": "Backup-Data.ps1 -DryRun -Only 'srcdocs'",
"Expected": "退出码 0;输出里不出现未选中的 nonexistent 条目",
"Actual": "退出码=0;输出提到 nonexistent=False",
"Status": "PASS",
"Severity": ""
},
{
"Id": "BB-06",
"Module": "异常流",
"Title": "边界值:清单文件不存在时的行为",
"Priority": "P1",
"Precondition": "传入一个不存在的 -BackupListPath",
"Steps": "Backup-Data.ps1 -BackupListPath <不存在>",
"Expected": "明确报错(非 0 退出码)或给出可读提示,**不得静默返回 0**",
"Actual": "退出码=0;末行=[2026-09-28 12:34:54] [INFO] 模板 BackupList.txt 已创建,请编辑后重试。",
"Status": "FAIL",
"Severity": "一般"
},
{
"Id": "BB-07",
"Module": "兼容",
"Title": "文档承诺:旧名字 Backup.ps1 是转发垫片,退出码原样传递",
"Priority": "P1",
"Precondition": "实现已改名为 Backup-Data.ps1",
"Steps": "Backup.ps1 -DryRun(旧名字)",
"Expected": "打印改名提示;退出码与直接调用一致(0)",
"Actual": "退出码=0;有改名提示=True",
"Status": "PASS",
"Severity": ""
},
{
"Id": "BB-08",
"Module": "跨端一致性",
"Title": "文档承诺:Windows PowerShell 5.1 与 7.x 行为一致",
"Priority": "P0",
"Precondition": "同一沙盒、同一清单",
"Steps": "两个宿主各跑一次 -DryRun,比较退出码",
"Expected": "两者退出码都是 0(GBK 控制台下的中文输出不崩)",
"Actual": "7 退出码=0;5.1 退出码=0",
"Status": "PASS",
"Severity": ""
},
{
"Id": "BB-09",
"Module": "边界值",
"Title": "特殊字符:含 & 与 # 的路径(整行引号写法)",
"Priority": "P2",
"Precondition": "源目录名含 & 与 #;清单行整体加引号",
"Steps": "备份该条目",
"Expected": "退出码 0 且真的产出归档(# 不被当注释吃掉)",
"Actual": "退出码=0;归档数=1",
"Status": "PASS",
"Severity": ""
},
{
"Id": "BB-10",
"Module": "无头",
"Title": "文档承诺:没有控制台且没给 -InputScript 时报错退出,绝不挂起",
"Priority": "P0",
"Precondition": "stdout/stderr 被重定向(等价于计划任务/管道环境)",
"Steps": "不传参数直接运行 Manage-Backup.ps1,等待最多 60 秒",
"Expected": "60 秒内退出,退出码 2,并提示\"没有可用的控制台\"",
"Actual": "60 秒内退出=True;退出码=2;输出片段=û�п��õĿ���̨��Ҳû�и��� -InputScript���޷����뽻�����档\r\nҪ�ǽ���ִ�У���ָ�����������磺-Action Backup ",
"Status": "PASS",
"Severity": ""
},
{
"Id": "BB-11",
"Module": "审计",
"Title": "文档承诺:孤儿归档会被点名",
"Priority": "P1",
"Precondition": "Backups3\\ 里放一个清单中不存在的 GhostSoftware.7z",
"Steps": "备份后看输出是否点名",
"Expected": "输出里出现该孤儿归档名",
"Actual": "退出码=0;点名=False",
"Status": "FAIL",
"Severity": "一般"
}
]
}
+351
View File
@@ -0,0 +1,351 @@
# BakNRet CI/CD 流水线报告
- **报告时间**:2026-09-28
- **项目**:BakNRet(Windows 备份 / 恢复工具,PowerShell 5.1 + 7.x)
- **分支**:`refactor/ms-conventions`
- **测试环境**:Hyper-V VM `BakNRet-Lab`(Gen2 / 8 vCPU / 12 GB)+ 宿主 `STRIX-X870A`
- **流水线入口**:`/ci-cd-pipeline`(用户指定:测试环境为 Hyper-V 虚拟机)
> [!NOTE]
>
> **后续更新(2026-10-02)**:本报告里的两条许可证相关待办已关闭 —— 补 `LICENSE`
> (**Apache-2.0**,正文逐字采用 ASF 的 LICENSE-2.0.txt,仅填 APPENDIX 版权行)已落地,
> 对应许可证合规的 **L-1** 与依赖安全扫描的 **R-1**。报告正文保留为**流水线运行当时**的记录。
---
## 阶段总览
| 阶段 | 名称 | 状态 | 关键结果 |
| --- | --- | --- | --- |
| 0.1 | 依赖安全扫描 | ✅ 通过 | 零运行时依赖;**0 致命/严重**;2 条一般级卫生风险 |
| 0.2 | 许可证合规 | ⚠️ 通过(有缺口) | 无 copyleft 冲突;**主许可证未声明** |
| 0.3 | 语法检查与修复 | ✅ 通过 | 131/131 双宿主解析零错;修 2 处排版 + 1 处自伤;分析器 84→80 条、0 Error |
| 1 | 黑盒测试 | ✅ 通过 | 11 条用例:9 通过、2 失败(**0 致命/严重**) |
| 2 | 缺陷修复与回归 | ✅ 通过 | 修 1 个真实缺陷;回归 **12/12**;Pester 183→**192** 全绿 |
| 3 | 测试增强与性能 | ✅ 通过 | 性能基线首次建立、**无退化**;单测增强给出优先级、未擅自新增 |
| 4 | 代码规范化 | ✅ 通过 | 分析器格式规则门禁通过(0 Error);仓库自有格式门禁为准 |
| 5 | 文档生成与质量检查 | ✅ 通过 | `PROJECT_REPORT.md` 532 行;markdownlint **0 错**;死链 **0** |
| 6 | 数据库迁移审查 | ⏭️ **不适用** | 全仓无数据库、无 SQL、无迁移脚本 |
| 7 | 生产部署 | ⛔ **停在人工确认门** | 需你确认(见文末) |
| 8 | 收尾 | ✅ 完成 | 本报告 |
---
## 阶段 0:静态分析与修复
### 0.1 依赖安全扫描
| 项目 | 结论 |
| --- | --- |
| 依赖管理文件 | 不存在(无 `package.json` / `requirements.txt` / `go.mod` / `pom.xml` / `Cargo.toml`) |
| 运行时依赖 | **0 个模块**(只需 PowerShell 5.1 或 7.x + 7-Zip) |
| CVE | 无可报(没有任何被本仓库固定版本的第三方库) |
**风险**:R-1 无 `LICENSE`(一般)· R-2 工作区存在口令文件 `baknret.key`(一般,**未被 git 跟踪**,
`.gitignore:18 *.key` 已忽略;全程未读取其内容)· R-3 不检查 7z 版本(建议)·
R-4 口令经命令行传给 7z(建议,上游限制)。
### 0.2 许可证合规
| 组件 | 许可证 | 是否随分发 |
| --- | --- | --- |
| 本项目 | **未声明** | — |
| Pester 5.9.1 | Apache-2.0 | 否(`.tools/`,gitignore) |
| PSScriptAnalyzer 1.25.0 | MIT | 否 |
| Newtonsoft.Json | MIT | 否 |
| 7-Zip 26.03 | LGPL-2.1 + BSD-3 + unRAR 限制 | 否(用户自备) |
**无 copyleft 传染**。7-Zip 的 unRAR 限制只禁止「用其代码还原 RAR 压缩算法」,本项目不触发。
### 0.3 语法检查与自动修复
**修复的真实缺陷**
| 文件:行 | 规则 | 修复 |
| --- | --- | --- |
| `Backup.ps1:42` | `PSUseConsistentWhitespace` | `@($Rest)+ @(...)` → `@($Rest) + @(...)` |
| `Restore.ps1:42` | 同上 | 同上 |
**修复过程中的自伤与恢复(重要教训)**
编辑这两个文件后 **UTF-8 BOM 被抹掉**,导致 5.1 立即报 `The string is missing the terminator: '.`,
而 PowerShell 7 完全正常。用 `UTF8Encoding($true)` 重写后恢复,双宿主 `parse_bad=0`。
这条恰好实证了本仓库 Encode 层门禁的价值,也说明「编辑 PowerShell 源文件必须保留 BOM」是硬约束。
**未修复项**:80 条全部为风格类(0 Error),按用户决定登记为「仓库已声明的偏离」,逐条附依据:
行长 160 是配置里写明的有意偏离(`PSScriptAnalyzerSettings.psd1` + [ADR-0008](../docs/adr/0008-analyzer-deviations.md));
`PSPlaceCloseBrace` 等集中在 `Run-Tests.ps1` 的**测试夹具字符串**内(改了会改变断言语义);
`PSUseSupportsShouldProcess` 已全局排除属已知噪声;空 catch 是有意为之(注释已写明理由)。
**待人工确认的观察项**:`Write-BakNRetRunSummary` 的 `$Mode` 参数声明了 `ValidateSet` 与 `Mandatory`
但函数体内从未读取,且该函数未被仓库内任何代码调用(仅对外导出)。判断为未完工实现,
删除会破坏公共 API 参数契约,故**登记而不改动**。
---
## 阶段 1 → 2:黑盒测试与缺陷修复
### 测试环境确认(技能强制)
| 项目 | 事实 |
| --- | --- |
| 目标 | Hyper-V VM `BakNRet-Lab`(Gen2 / 8 vCPU / 12 GB / Default Switch 内部 NAT) |
| 检查点 | 自动检查点、**clean-baseline**、**sandbox-ready** |
| 数据隔离 | 仓库自述:全部操作在 VM 内进行,宿主机仓库 / `Backups\` / `logs\` 不被写入 |
| 生产暴露 | 无(无生产服务器、无域名、无对外服务) |
**已获用户明确确认**后开始测试。
### 用例与结果
| 编号 | 用例 | 阶段 1 | 阶段 2 回归 |
| --- | --- | --- | --- |
| BB-01 | 真实备份:退出码 0 + 产出归档 + manifest | PASS | PASS |
| BB-02 | 排除模式真的把内容挡在归档之外(逐个归档内容核对) | PASS | PASS |
| BB-03 | `-DryRun` 一个字节都不写(manifest SHA256 前后一致) | PASS | PASS |
| BB-04 | 源不存在记 `missing-source`,不算失败 | PASS | PASS |
| BB-05 | `-Only` 只处理匹配的条目 | PASS | PASS |
| BB-06 | **显式清单不存在 → 必须报失败** | **FAIL** | **PASS(已修)** |
| BB-06b | 对照:未显式指定时首次运行仍建模板 | — | PASS |
| BB-07 | 旧名字垫片转发且退出码原样传递 | PASS | PASS |
| BB-08 | 5.1 与 7.x 行为一致 | PASS | PASS |
| BB-09 | 含 `&` 与 `#` 的路径(整行引号写法) | PASS | PASS |
| BB-10 | 无控制台且无 `-InputScript` 时报错退出,绝不挂起 | PASS | PASS |
| BB-11 | 孤儿归档审计 | FAIL(夹具错误) | PASS |
- **阶段 1**:11 条 → 通过 9、失败 2、**致命/严重 0**
- **阶段 2 回归**:12 条 → **全部通过**
### 缺陷清单
| ID | 严重程度 | 标题 | 根因 | 修复 |
| --- | --- | --- | --- | --- |
| **DEF-01** | 一般 | 显式指定的清单不存在时**静默成功** | 「首次运行引导」与「路径写错」两条语义共用一条代码路径 | 按 `$PSBoundParameters.ContainsKey('BackupListPath')` 分流:显式指定 → ERROR + `exit 1`;未指定 → 保留引导 |
| DEF-02 | 提示 | 两处运算符前后缺空格 | 手写拼接 | 补空格(分析器该规则归零) |
| DEF-03 | 提示 | 孤儿审计在干跑下不报告 | **测试夹具错误**(非产品缺陷) | 修正夹具为真实运行 |
**DEF-01 的影响**:计划任务里 `-BackupListPath` 写错(或相对路径按了别的工作目录解析)时,
脚本会在错误位置凭空建一份模板并 **exit 0**;任务计划程序读到「上次运行结果 = 成功」,
而实际一个条目都没处理。这与本仓库已修过的「27 条被静默跳过、退出码仍是 0」是同一类缺陷,
因此判为需要修复。`Restore-Data.ps1` 有对称问题(生成清单 + exit 0),一并修复。
**DEF-03 的处置值得记录**:BB-11 初判 FAIL,复核后确认是**用例设计错了**(用了 `-DryRun`,
而孤儿审计只在真实运行里报告)。保留该记录,因为它说明「失败用例必须先复核再归因」,
否则会把测试自身的问题记到产品头上。
### 回归证据
```text
BakNRet 验收:层次 [Parse, Unit, Smoke, E2E],宿主 [7, 5.1]
[PASS] Encode any 受管 133 个文件:缺 BOM 0、CRLF 0、制表符 0
[PASS] Parse 7 解析通过 131/131 个文件
[PASS] Unit 7 / Smoke 7 / E2E 7
[PASS] Parse 5.1 解析通过 131/131 个文件
[PASS] Unit 5.1 / Smoke 5.1 / E2E 5.1
验收全部通过:9 项(宿主 7 + 5.1)
```
```text
Tests Passed: 192, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0
[+] [回归] 显式指定的清单不存在时报失败(退出码 1),且不在错误位置建模板 1.43s
[+] [回归] 未显式指定清单时的首次运行仍建模板并退出 0(引导不能被误伤) 1.44s
```
新增的 2 条用例是**成对**的:只测一条的话,「把所有缺失都改成 exit 1」这种错误实现也能骗过测试。
---
## 阶段 3:测试增强与性能验证
### 单元测试增强(可选步骤)
| 指标 | 数值 |
| --- | --- |
| 对外函数 | 89(与 `FunctionsToExport` 白名单逐一核对一致) |
| 测试中直接点名 | 70(78.7%) |
| 未直接点名 | 19(其中多条由集成路径间接覆盖) |
**建议补测**(本轮未擅自新增,因用户选择了最小侵入姿态,且该步骤标注为可选):
`Move-BakNRetArchiveIntoPlace`(原子替换,历史上真出过「先删后移」缺陷)、
`Resolve-BakNRetRootedPath`(相对路径按仓库根解析这条计划任务关键约定)。
安全描述符相关的 4 个函数**不建议**补单元测试 —— 它们需要真实提权与真实 NTFS,
仓库已用 `tools/lab/Lab.ps1 acl-test`(含负对照)在真 VM 里覆盖,那是更正确的层次。
### 性能基线(首次建立)
环境:宿主 `STRIX-X870A`,PowerShell 7.7.0-preview.5,7-Zip 26.03 (x64)
| 指标 | 采样 | min | 中位数 | max |
| --- | --- | --- | --- | --- |
| 模块导入 | 5 | 628.3 ms | **631.0 ms** | 2052.8 ms |
| 清单解析 ×50 轮 | 5 | 2535.9 ms | **2546.9 ms** | 2691.5 ms |
| 只读干跑(真实 28 条清单) | 5 | 9839.9 ms | **9869.2 ms** | 12900.8 ms |
| 源树扫描(200 文件 ×3 轮) | 1 | — | **18.2 ms** | — |
7z 吞吐(固定夹具 200 × 32 KB **随机数据**):`-mx=0` 46.7 ms / `-mx=5` 224.2 ms / `-mx=9` 225.2 ms。
夹具为不可压缩数据,故体积不随级别变化 —— 该夹具测的是**吞吐与 I/O,不是压缩率**。
**判定**:首次基线,无历史可比,**无退化**。基线文件:`.scratch/ci-cd/20260928-001722/perf_baseline.json`。
跨机比较无意义,仅同机同宿主下对比。
---
## 阶段 4:代码规范化
- **仓库自有的格式门禁**是 `PSScriptAnalyzerSettings.psd1` 打开的 6 条格式规则
(括号 / 缩进 / 空格 / 对齐 / 大小写)+ 160 字符行长,通过 `tools/Invoke-Analyzer.ps1` 执行。
- 本次:**0 条 Error**,格式类告警从 4 条(whitespace)降到 **0 条**。
- PowerShell 没有官方「Google 风格」格式化器;本仓库的等价物就是上面这套规则集,
故阶段 4 以仓库门禁为准,未引入外部格式化器(避免制造与仓库约定冲突的大 diff)。
- `README.md` / `PROJECT_REPORT.md` 由 **Prettier 3.9.9** 格式化并通过 `--check`。
---
## 阶段 5:文档生成与质量检查
| 产物 | 规模 | 质量门禁 |
| --- | --- | --- |
| `README.md` | 925 行 | Prettier ✅;markdownlint 17 → **10 错(全为 MD051 假阳性)**;外部链接 11/11 → 200;内部相对链接全通;63 个标题锚点全部可解析;3 张 Mermaid 图在真实浏览器中解析通过 |
| `PROJECT_REPORT.md` | 532 行 | Prettier ✅;markdownlint **0 错**;相对链接 18/18 有效;外部链接 3/3 → 200 |
**MD051 假阳性说明**:README 的标题带 emoji(如 `## 🚀 快速开始`),GitHub 生成的锚点是
`#-快速开始`(剥离 emoji 后前导连字符)。`markdownlint-cli2` 的锚点生成器**不做 emoji 剥离**,
因此把这类链接报为无效。已用独立脚本按 GitHub 规则复算全部锚点,**63 个全部可解析**,
故不修改标题(emoji 标题是本次文档改造的要求之一)。
**死链检查**:全部外部链接逐个 `HEAD` 请求验证,返回 200;相对链接逐个 `Test-Path` 验证存在。
---
## 阶段 6:数据库迁移审查 —— 不适用
| 检查 | 结果 |
| --- | --- |
| `*.sql` / `*.db` / `*.sqlite` 文件 | 无 |
| `Invoke-Sqlcmd` / `SqlConnection` / `CREATE TABLE` / `SELECT ... FROM` | 无命中 |
| 迁移脚本目录 | 不存在 |
本项目是本地 CLI 工具,**不涉及任何数据库**,该阶段跳过。
---
## 阶段 7:生产部署 —— 停在人工确认门
> [!CAUTION]
>
> **未执行任何部署动作。** 本项目的「生产部署」= 在真实机器上注册计划任务并让它按计划跑备份,
> 属于对用户个人环境产生持久副作用的操作,必须由你确认。
### 部署前状态
| 项目 | 状态 |
| --- | --- |
| 代码门禁 | ✅ 9/9(双宿主)· Pester 192/192 · 黑盒 12/12 |
| 工作树 | 6 个文件已改(+796 / −202),新增 `PROJECT_REPORT.md`(未提交) |
| 运行前提 | PowerShell 5.1/7.x ✅ · 7-Zip 26.03 ✅ |
| 阻塞项 | 会话审批策略为 `never`,`gsudo` 提权被自动拒绝(退出码 999);注册计划任务需要管理员 |
### 建议的部署步骤(待你确认后执行)
```powershell
# 1. 先确认清单与口令就位(口令应在仓库外)
.\Backup-Data.ps1 -DryRun # 只读,先看计划与空间预估
.\Backup-Data.ps1 -Only 'Edge' # 先只备一项,验证端到端
.\Restore-Data.ps1 -VerifyOnly # 只校验,不解压
# 2. 注册计划任务(需要管理员)
.\tools\Register-BackupTask.ps1 -At '21:30' -DryRun # 先看将要注册什么
.\tools\Register-BackupTask.ps1 -At '21:30' # 确认后注册
# 3. 部署后验证
Get-ScheduledTaskInfo -TaskName 'BakNRet Backup' # 上次运行结果
Start-ScheduledTask -TaskName 'BakNRet Backup' # 手动跑一次
```
### 回滚
```powershell
.\tools\Register-BackupTask.ps1 -Remove
```
**风险**:注册计划任务本身可逆(`-Remove`);但**首次真实备份会写入归档目录并消耗磁盘**
(README 已有空间预估与逐条目守卫,不够时只告警不中断)。
---
## 阶段 8:关键指标与建议
### 关键指标
| 指标 | 数值 |
| --- | --- |
| 验收门禁 | **9/9 PASS**(PS 7.7.0-preview.5 + 5.1.26100.9502 各一遍) |
| 解析 | **131/131** 文件双宿主零错 |
| Pester | **192 通过 / 0 失败 / 0 跳过**(183 → 补 2 条回归用例后 185 → 再补 7 条后 192) |
| 黑盒回归 | **12/12** |
| 静态分析 | **80 条 / 0 Error**(修复前 84) |
| 运行时依赖 | **0** |
| 致命 / 严重缺陷 | **0** |
| 修复的真实缺陷 | **1**(DEF-01)+ 2 处排版 |
| 代码变更 | 6 文件,+796 / −202 |
| 文档产物 | `README.md` 925 行 · `PROJECT_REPORT.md` 1004 行 |
### 后续轮次已执行(用户回复「继续」之后)
| 事项 | 结果 |
| --- | --- |
| 补单元测试(原建议 2 条) | ✅ 实际补 **7 条**断言:原子替换 3 条(含 AST 判定 `File.Replace` 与 `[NullString]::Value`)、路径解析 4 条(含 AST 判定「模块里不得引用 `$PSScriptRoot`」)。Pester 185 → **192**,0 失败 |
| 修正 README 的零依赖套件条数 | ✅ 实测为 **128 项**(原写 111,已改正)。这是上一轮 README 重写留下的**事实错误**,由 PROJECT_REPORT 的核对暴露出来 |
| 复核 PROJECT_REPORT.md 的数字 | ✅ 行数口径标注为「**非空行**」;Pester 计数更新为 192;**修正一处过度断言**(原文称「修复后代码在 VM 内验证可用」,实际只取到修复前的 Pester 快照) |
| 补 LICENSE / 移出 `baknret.key` / 确认 `$Mode` 意图 | ⏸️ 仍需你决定 |
> **指标口径说明(本次踩到的坑)**:工程规模按**非空行**统计是 **14389 行**,按**含空行**统计是 **16805 行**。
> 两者都对,但必须写明口径。本次就因为口径不同(我用含空行、报告用非空行),一度把一份**正确的**数字
> 误判为错误,直到逐目录复算(模块 4369 非空行在 HEAD 与工作树上完全一致)才定位到是口径差异。
> 报告里凡出现行数处已统一标注口径。
### 建议(按优先级)
| 优先级 | 事项 | 理由 |
| --- | --- | --- |
| ~~高~~ ✅ **已完成 2026-10-02** | 补 `LICENSE` | 已补 **Apache-2.0**(逐字采用 ASF 原文,仅填 APPENDIX 版权行) |
| ~~高~~ ✅ **已完成 2026-10-02** | 提交并同步到云端 | 3 个提交已推送到 `origin/main`(快进,非 force) |
| 中 | 把 `baknret.key` 移出仓库目录 | 虽未被跟踪,工作区放口令文件是卫生问题 |
| 中 | 确认 `Write-BakNRetRunSummary` 的 `$Mode` 意图 | 未使用参数 + 未被调用 |
| 低 | 在 VM 内补跑修复后的三套件 | 本次因提权被禁未取得(宿主侧已全绿) |
| 低 | 启动时打印 7-Zip 版本 | 便于排障 |
### 本次流水线的诚实边界
1. **VM 测试未跑全**:会话审批策略改为 `never` 后 `gsudo` 提权被自动拒绝,而 Hyper-V 与
PowerShell Direct 都需要管理员。**已核实的部分**(同步成功、VM 内 Pester 183 通过)如实记录;
零依赖与端到端套件在 VM 内的结果**本次未取得**,不做推断。宿主侧同等套件已全部通过。
2. **静态分析未压到 0**:剩余 80 条与仓库**已声明的偏离**冲突,按你的决定登记而不改,
逐条附依据。严格模式的「零容忍」在这里与仓库自身配置相抵,选择尊重仓库约定。
3. **阶段 7 未执行**:等你在下一轮明确确认。
---
## 附件索引
所有中间产物在 `.scratch/ci-cd/`:
| 文件 | 内容 |
| --- | --- |
| `20260928-001722/dependency_security_report.md` | 依赖安全扫描报告 |
| `20260928-001722/license_check_report.md` | 许可证合规报告 |
| `20260928-001722/stage0_static_analysis.md` | 阶段 0 完整报告(含 BOM 自伤记录) |
| `20260928-001722/stage3_tests_and_perf.md` | 阶段 3 报告(覆盖率分析 + 性能基线) |
| `20260928-001722/analyzer_raw.txt` | 修复前静态分析原始输出(84 条) |
| `20260928-001722/analyzer_after.txt` | 修复后静态分析输出(80 条) |
| `20260928-001722/pester_after_fix.txt` | 修复后 Pester 详细输出(185 通过;本轮补测后 192) |
| `20260928-001722/perf_baseline.json` | 性能基线数据 |
| `20260928-001722/blackbox_results.json` | 阶段 1 黑盒结果(11 条) |
| `20260928-001722/blackbox_regression.json` | 阶段 2 回归结果(12 条) |
| `blackbox-tests.ps1` | 黑盒用例脚本(可重跑) |
| `blackbox-regression.ps1` | 回归脚本(可重跑) |
| `perf-baseline.ps1` | 性能基线脚本(可重跑) |
| `../_verify/mermaid.png` | README 三张 Mermaid 图的浏览器渲染截图 |
| `../_verify/report_mermaid.png` | PROJECT_REPORT 四张图的渲染截图(本次独立复验;与 `20260928-001722/report_mermaid.png` 是**两次独立渲染**,字节不同属正常) |
| `20260928-001722/report_mermaid.png` | 同上四张图,由阶段 5 的报告生成方独立渲染并留证 |
+139
View File
@@ -0,0 +1,139 @@
# 宿主性能基线:对 BakNRet 的关键路径做可复现的计时。
# 设计原则:所有指标都是"比值"或"固定夹具下的绝对耗时",可以在同一台机器上重复对比。
param(
[string]$OutJson = '.scratch\ci-cd\20260928-001722\perf_baseline.json',
[int]$Repeat = 5,
[string]$Pwsh = 'pwsh'
)
$ErrorActionPreference = 'Stop'
$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$modulePath = Join-Path $root 'BakNRet\BakNRet.psd1'
$listPath = Join-Path $root 'BackupList.txt'
$cfgPath = Join-Path $root 'BackupConfig.psd1'
function Get-Median([double[]]$Values) {
$s = $Values | Sort-Object
$n = $s.Count
if ($n -eq 0) { return 0 }
if ($n % 2 -eq 1) { return $s[[int](($n - 1) / 2)] }
return ($s[$n / 2 - 1] + $s[$n / 2]) / 2
}
function Measure-Once([scriptblock]$Body) {
$sw = [System.Diagnostics.Stopwatch]::StartNew()
& $Body | Out-Null
$sw.Stop()
return $sw.Elapsed.TotalMilliseconds
}
$results = [ordered]@{
measuredAt = (Get-Date).ToString('s')
machine = $env:COMPUTERNAME
os = [System.Environment]::OSVersion.VersionString
psi = $PSVersionTable.PSVersion.ToString()
cpu = (Get-CimInstance Win32_Processor | Select-Object -First 1 -ExpandProperty Name)
logicalCpu = [int](Get-CimInstance Win32_ComputerSystem).NumberOfLogicalProcessors
workdir = $root
metrics = @{}
notes = @()
}
# ---------------------------------------------------------------- 夹具(固定内容,避免与真实数据联动)
$fixture = Join-Path $env:TEMP ('baknret-perf-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $fixture -Force | Out-Null
$srcDir = Join-Path $fixture 'src'
New-Item -ItemType Directory -Path $srcDir -Force | Out-Null
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$rnd = [Random]::new(20260928)
$payload = New-Object byte[] 32768
for ($f = 0; $f -lt 200; $f++) {
$sub = Join-Path $srcDir ('d{0:D2}' -f ($f % 10))
if (-not (Test-Path $sub)) { New-Item -ItemType Directory -Path $sub -Force | Out-Null }
$rnd.NextBytes($payload)
[System.IO.File]::WriteAllBytes((Join-Path $sub ("f$f.bin")), $payload)
}
$sw.Stop()
$results.fixture = [ordered]@{ files = 200; bytes = 200 * 32768; createMs = [math]::Round($sw.Elapsed.TotalMilliseconds, 1) }
# ---------------------------------------------------------------- 1. 模块导入
$imports = 1..$Repeat | ForEach-Object {
Measure-Once { & $Pwsh -NoProfile -Command "Import-Module '$modulePath' -Force" }
}
$results.metrics.moduleImportMs = [ordered]@{
min = [math]::Round(($imports | Measure-Object -Minimum).Minimum, 1)
median = [math]::Round((Get-Median $imports), 1)
max = [math]::Round(($imports | Measure-Object -Maximum).Maximum, 1)
samples = $Repeat
}
# ---------------------------------------------------------------- 2. 清单解析
$parseScript = @"
Import-Module '$modulePath' -Force
1..50 | ForEach-Object { Get-Content -LiteralPath '$listPath' | ForEach-Object { ConvertFrom-BackupListLine -Line `$_ } }
"@
$parseFile = Join-Path $fixture 'parse.ps1'
Set-Content -LiteralPath $parseFile -Value $parseScript -Encoding utf8
$parses = 1..$Repeat | ForEach-Object { Measure-Once { & $Pwsh -NoProfile -File $parseFile } }
$results.metrics.backupListParse50xMs = [ordered]@{
min = [math]::Round(($parses | Measure-Object -Minimum).Minimum, 1)
median = [math]::Round((Get-Median $parses), 1)
max = [math]::Round(($parses | Measure-Object -Maximum).Maximum, 1)
samples = $Repeat
}
# ---------------------------------------------------------------- 3. 只读干跑(真实清单 + 空间预估 + manifest)
$dryFile = Join-Path $fixture 'dry.ps1'
Set-Content -LiteralPath $dryFile -Encoding utf8 -Value @"
Set-Location '$root'
& '$root\Backup-Data.ps1' -DryRun -BackupDir '$fixture\Backups' -ConfigPath '$cfgPath' *> `$null
exit `$LASTEXITCODE
"@
$dries = 1..$Repeat | ForEach-Object { Measure-Once { & $Pwsh -NoProfile -File $dryFile } }
$results.metrics.dryRunFullListMs = [ordered]@{
min = [math]::Round(($dries | Measure-Object -Minimum).Minimum, 1)
median = [math]::Round((Get-Median $dries), 1)
max = [math]::Round(($dries | Measure-Object -Maximum).Maximum, 1)
samples = $Repeat
}
# ---------------------------------------------------------------- 4. 7z 压缩吞吐(固定夹具,无压缩)
$sevenZip = (Get-Command 7z -ErrorAction SilentlyContinue).Source
if (-not $sevenZip) {
foreach ($p in 'C:\Programs\Scoop\shims\7z.exe', 'C:\Program Files\7-Zip\7z.exe') { if (Test-Path $p) { $sevenZip = $p; break } }
}
if ($sevenZip) {
$archive = Join-Path $fixture 'bench.7z'
$bench = @()
foreach ($lvl in 0, 5, 9) {
if (Test-Path $archive) { Remove-Item $archive -Force }
$ms = Measure-Once { & $sevenZip a -t7z "-mx=$lvl" -bso0 -bsp0 $archive (Join-Path $srcDir '*') }
$bench += [pscustomobject]@{ level = $lvl; ms = [math]::Round($ms, 1); archiveBytes = (Get-Item $archive).Length }
}
$results.metrics.sevenZipBench = @($bench)
$results.fixture.compressionInputBytes = 200 * 32768
$results.metrics.sevenZipVersion = (& $sevenZip | Select-Object -First 2 | Select-Object -Last 1)
}
else {
$results.notes += '找不到 7z.exe,跳过压缩基准'
}
# ---------------------------------------------------------------- 5. 源树扫描(Get-BakNRetFolderSummary 走真实目录)
$summaryScript = @"
Import-Module '$modulePath' -Force
`$sw = [System.Diagnostics.Stopwatch]::StartNew()
1..3 | ForEach-Object { Get-BakNRetFolderSummary -FolderPath '$srcDir' | Out-Null }
`$sw.Stop()
Write-Output ([math]::Round(`$sw.Elapsed.TotalMilliseconds / 3, 1))
"@
$sumFile = Join-Path $fixture 'summary.ps1'
Set-Content -LiteralPath $sumFile -Value $summaryScript -Encoding utf8
$sumMs = (& $Pwsh -NoProfile -File $sumFile | Select-Object -Last 1)
$results.metrics.folderSummary200Files3xMs = [double]$sumMs
Remove-Item -LiteralPath $fixture -Recurse -Force -ErrorAction SilentlyContinue
$results | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $root $OutJson) -Encoding utf8
Write-Host ('基线已写入 {0}' -f $OutJson)
$results.metrics | ConvertTo-Json -Depth 6
+112
View File
@@ -0,0 +1,112 @@
# spec:增强健壮性 + 重构代码 + 重组结构 + 微软规范落地
Status: accepted
Date: 2026-09-26
本文件是这次改造的**唯一验收锚点**。每一项改动都要能被这里的某一条门槛判成通过或失败;判不了的就不要做。
## 1. 四件事
1. **增强健壮性** —— 修掉实测确认的缺陷,并把「支持 5.1」从纸面承诺变成可验证的事实。
2. **重构代码** —— 消除重复实现、死代码与闭包依赖,让两个入口只做编排。
3. **重组结构** —— 让文件系统与职责对齐,并落一份可读的模块导出面。
4. **按微软规范格式化代码与文档** —— 落到可重复执行的工具上,而不是一次性手工活。
## 2. 验收标准
| # | 门槛 | 改造前 | 目标 |
| --- | --- | --- | --- |
| 1 | Pester 套件 | 175 通过 / 0 失败 | 全绿(覆盖不得减少) |
| 2 | 零依赖套件(瘦身后为冒烟) | 101 通过 / 0 失败 | 全绿 |
| 3 | 全仓 `Parser::ParseFile`(5.1 与 7) | 5.1 上 6/6 文件失败 | **两版都零错误** |
| 4 | `tests/Run-E2E.ps1`(36 项) | 只在 7.x 上跑过 | **7 与 5.1 都全绿** |
| 5 | PSScriptAnalyzer 默认规则集 | 未安装 | `-Severity Warning,Error` 下 0 条 |
| 6 | PSScriptAnalyzer 格式化规则集 | 无 | 0 条 |
| 7 | 只读冒烟 | 未系统跑过 | `Backup.ps1 -DryRun` 与 `Restore.ps1 -VerifyOnly` 在真实清单上跑通,**一个字节都不写** |
| 8 | markdownlint(可选档,不进必绿门槛) | 无 | 配置就位,0 error |
| 9 | 术语表与决策记录 | 无 | `CONTEXT.md` + `docs/adr/` 6 条 |
第 3、4 条是新增的:改造前这两条根本不存在,而 README 却承诺了 5.1。
## 3. 已确认的决策
### 3.1 过程
- **D1** 基线与分支:先提交当前工作区为基线,再从基线开 `refactor/ms-conventions`。
- **D2** 过程产物:本文件即 spec;不为四条工作流各开 ticket。
- **D3** 行为边界:重构类改动保持行为等价;健壮性类改动可改变**边界行为**,但对外契约只增不改(参数名、退出码语义、配置字段、清单语法、归档布局一律向后兼容)。
### 3.2 结构
- **D4** 库代码形态:`BakNRet/` 目录承载模块 —— `BakNRet.psd1` + `BakNRet.psm1`(薄加载器,按显式顺序点源)+ `Public/` + `Private/`,**一函数一文件,文件名 = 函数名**。
- **D5** 入口与配置原地不动:`Backup.ps1`、`Restore.ps1`、`BackupList.txt`、`SoftwareCatalog.psd1`、`BackupConfig.psd1` 全部留在仓库根。
- **D6** `src/` 不用。实测证据:纯 PowerShell 且无构建步骤的仓库里,用 `src/` 的为 0。
- **D7** 公共面:`.psd1` 写**显式** `FunctionsToExport` 白名单(官方要求,且 `PSUseToExportFieldsInManifest` 恒开不可关);私有件靠 `Private/` + 不导出双保险。
- **D8** 测试拿私有函数:用 Pester 的 `InModuleScope`(已实测在 5.1 与 7 上都可用)。**不**让测试点源 `Private/*.ps1` —— 那会把模块级状态建在测试脚本自己的作用域里。
- **D9** 入口沉到只做编排:`Backup.ps1` / `Restore.ps1` 里的逻辑(压缩→校验→原子替换、manifest 条目读写、解压三分支、孤档审计、7z 校验、工具定位)全部下沉进模块,**分两个提交**:先纯搬家,再参数化。
- **D10** 闭包依赖参数化:`Save-ItemRecord` 不再直接改脚本级 `$manifest`;`Invoke-BackupItem` 不再读脚本级 `$tool` / `$password`;`Test-ItemSelected` 不再读 `$Only` / `$Skip`。
- **D11** 模块级状态收敛:5 个 `$script:` 变量集中到 `Private/State.ps1`,**访问器只覆盖"写"**,并补一个测试用的状态重置函数。
### 3.3 命名
- **D12** 公共面函数补 `BakNRet` 前缀;私有面保留短名。动词已 63/63 全部是批准动词,动词不动。
- **D13** `Set-Alias` 过渡垫片不留。
- **D14** 产品名统一写作 `BakNRet`(见 `CONTEXT.md`)。
### 3.4 声明与编码
- **D15** 可拼接性不变量(三条,写成测试):每个私有文件不写自己的 `param()` / `using` / `Set-StrictMode` / `Export-ModuleMember`;私有文件不引用 `$PSScriptRoot` 或相对路径;加载顺序只在加载器里出现一次。
- **D16** `tools/Build-BakNRetModule.ps1`:按加载器声明的同序拼接各文件,产出 `dist/BakNRet.psm1`(gitignore);不引入 ModuleBuilder/Sampler。
- **D17** 源文件一律 **UTF-8 with BOM**;`.editorconfig` 用 `charset = utf-8-bom` 把这条锁住。
- **D18** `.gitattributes` 用 `* text=auto eol=lf` 加二进制规则(照 PSScriptAnalyzer 的写法)。
- **D19** 版本声明:`#Requires -Version 5.1`;**绝不**写 `#Requires -PSEdition`。`.psd1` 写 `CompatiblePSEditions = @('Desktop','Core')`。
- **D20** `Set-StrictMode -Version 3.0`(不用 `Latest`,双版本要的是确定性),先在库模块内开,跑绿后再考虑入口。
### 3.5 静态分析与文档
- **D21** PSScriptAnalyzer 装进 `.tools/`(不动机器上的全局模块);`PSScriptAnalyzerSettings.psd1` 放仓库根;`tools/Invoke-Analyzer.ps1` 是唯一入口;**独立门禁脚本,不塞进 Pester 用例**。
- **D22** 全局 `ExcludeRules` 排除 `PSAvoidUsingWriteHost`(彩色控制台输出是本工具的刻意设计)。
- **D23** `PSUseSingularNouns` 用 `NounAllowList` 放行 `SecurityRecords` / `MapKeys` / `ArchiveTopLevelNames`。
- **D24** `Backup.ps1` 补 `SupportsShouldProcess`(新增 `-WhatIf` / `-Confirm`,属只增不改)。
- **D25** 启用 `PSAvoidLongLines`,上限 120,全仓折断。
- **D26** 注释帮助:公共面补齐 `.SYNOPSIS` / `.DESCRIPTION` / `.PARAMETER` / `.OUTPUTS` / `.EXAMPLE`;私有面只要求前两个。
- **D27** 文档拆分:README 瘦身成「是什么 + 快速开始 + 命令速查 + 指向 `docs/`」;11 篇主题进 `docs/`;「相对旧版修了什么」抽成 `CHANGELOG.md`;**`known-limits` 留在 README**。
- **D28** 6 条 ADR,不拆更细。
- **D29** markdownlint 用 `default: true`,行长放宽到 240(照 PowerShell 仓库的实践),只作可选检查。
- **D30** 测试入口:根级 `test.ps1` 作为唯一入口(`-Suite` 与 `-PSVersion` 参数);`tests/` 保留 Pester 的 `*.Tests.ps1` 命名,端到端降级为 `tests/E2E.ps1` 与 `tests/Drill.ps1`。
### 3.6 明确不做
- 不搬三个配置文件(D5)。
- 不把库发布到 PSGallery,不引入 ModuleBuilder / Sampler(D16)。
- 不留旧函数名的 `Set-Alias` 垫片(D13)。
- 不为 manifest 写一次性迁移脚本 —— 它是运行产物,下次运行自然老化。
- 不补一批特征化测试再动手 —— 现有 276 个断言足够,新增断言按「修一个补一个」加。
- 不自动搬 `baknret.key`。搬密钥是使用者的动作,本改造只负责停止往仓库里放默认路径。
## 4. 执行顺序
每一步独立提交,每一步都跑一遍全套验证。
1. `chore` 基线提交(含 `.gitignore` 加 `*.key`)
2. `fix` 源文件改存 UTF-8 with BOM + 立起 5.1 验收门槛
3. `fix` 暂存目录泄漏 —— 全仓唯一会把 junction 留在真实数据上的缺陷
4. `docs` `CONTEXT.md` 术语表
5. `refactor` `Common.psm1` → `BakNRet/{Public,Private}`,纯搬家 + 拼接校验
6. `refactor` 入口逻辑下沉 + 闭包依赖参数化
7. `refactor` 改名 + 模块清单显式导出面
8. `fix` 其余 P0(口令进日志 / manifest 原子写 / 空间守卫 / 并发保护)
9. `style` 格式化规则集 + 机械重排,再逐条修静态分析告警
10. `docs` README 拆分 + `CHANGELOG.md` + 6 条 ADR + `test.ps1` / `Invoke-Analyzer.ps1` + 门禁
顺序的三条依据:**先让 5.1 能解析**(否则后续所有验证都少一半);**先修唯一会伤数据的那一项**;**把可证明无损的结构搬家放在行为修复之前**(搬家用拼接校验证明零变化,之后每个修复都是小文件里的几行 diff,各自独立可回滚)。
## 5. 风险与回滚
| 风险 | 控制 |
| --- | --- |
| BOM 改造后 5.1 暴露新问题 | 单独一个提交,改完立刻跑 5.1 验收;有问题就在这一个提交上回退 |
| 结构搬家引入行为变化 | 用「同序拼接 == 模块内容」逐文件校验;先纯搬家、函数体一行不改 |
| 改名牵动 276 个断言 | 纯改名单独提交,跑绿后再做其它;不留别名垫片,避免两个名字并存 |
| 机械重排污染后续 review | 排在行为修复之后,单独提交,用 `git diff -w` 复核 |
| 真实归档数据受影响 | 全程不碰 `Backups/`;只读冒烟只用 `-DryRun` / `-VerifyOnly` |
+68
View File
@@ -0,0 +1,68 @@
# TUI 与入口重组 —— 验收锚点
本文件是这次改造的 spec:目标、已定决策、每轮的验收判据。**改动这个范围之外的代码要重新走一轮
确认**;改这个文件里的判据要说明为什么。
## 目标
把四个入口(主入口 + 备份 / 恢复 / 配置三个动作)与一个**零依赖 TUI** 落地,同时**不破坏**已经
建立的四条底线:
1. 两个 PowerShell 版本(5.1 与 7)都能跑;
2. 全仓解析零错、`test.ps1` 全绿、真实清单只读冒烟通过;
3. 无头/无人值守路径**行为与今天一致**(`exit` 退出码语义不变);
4. 零依赖承诺不被破坏(运行备份/恢复不需要任何模块)。
## 已定决策(细节见对应 ADR)
| # | 决策 | 出处 |
| --- | --- | --- |
| D1 | 零依赖自研 TUI;不用 ConsoleGuiTools / Spectre / Terminal.Gui | ADR-0010 |
| D2 | 界面**行内**(不平屏),只有状态行原地重画 | 本轮决策 |
| D3 | 第一道闸门是 `[Console]::IsOutputRedirected`;TUI 异常**不改退出码** | ADR-0010 |
| D4 | 日志:文件全量不变,TUI 下控制台输出降级为只写文件 | 本轮决策 |
| D5 | 进度用可注入回调(无头为 `$null`) | ADR-0011 |
| D6 | 入口改名 + 只留一轮的薄垫片;`Handle-Config` → `Edit-Config` | ADR-0012 |
| D7 | 三个配置界面:清单=行编辑器、配置=表单、名录=树+只读字段 | ADR-0013 |
| D8 | 写回=外科式改写,校验通过才原子替换;保存前留时间戳备份 | ADR-0013 |
| D9 | `-InputScript @('Down','Enter')` 作为输入缝;序列用尽仍在交互界面 → 直接报错退出 | 本轮决策 |
## 分三轮(每轮独立提交、独立断言、门禁全绿)
### 第一轮:TuiKit + 菜单骨架 + 清单编辑器
- `BakNRet/Public/` 里新增 TUI 积木:`Get-BakNRetCellWidth`(East-Asian-Wide 表 + 代理对)、
read-key 包装、`Write-BakNRetAt`(带"无控制台"退化)、菜单渲染、多选、确认。
- 入口重组:`Manage-Backup.ps1`(TUI + `-Action`/`-Quiet` 无头)、`Backup-Data.ps1`、
`Restore-Data.ps1`、`Edit-Config.ps1`;`Backup.ps1`/`Restore.ps1` 变垫片。
- `Edit-Config` 只做**清单行编辑器**。
**判据**:
- `Get-BakNRetCellWidth`:ASCII=列数、中文 2 列、字体边框 1 列、全角拉丁 2 列、半角片假名 1 列、
emoji(代理对)2 列、控制字符 0 列 —— 全部有断言。
- 清单编辑器**往返断言**:解析 → 改一个字段 → 写回 → 重新解析,除目标字段外逐字段相等,
注释行数不变。
- 无头闸门:把 `test.ps1` 的输出重定向时跑 `Manage-Backup -Action Backup -DryRun`,
断言不出现任何 TUI 输出、且退出码语义与今天一致。
- `-InputScript` 驱动:`@('Down','Enter')` 能走完"打开菜单 → 选备份 → 干跑 → 退出",序列用尽
却仍在交互界面时报错退出(不是挂起)。
- 垫片:`Backup.ps1` 转发后退出码与 `Backup-Data.ps1` 一致。
### 第二轮:配置编辑器(`BackupConfig.psd1` 扁平表单)
**判据**:往返断言(同上);非法取值被拒绝且**不落盘**;`logs/config-backups/` 里出现时间戳副本。
### 第三轮:名录编辑器(树 + 只读字段)
**判据**:往返断言;`Encrypt`/`Description` 可改,`Path`/`Exclude`/`Include` 只读;改完写回后
`Import-BaknretDataFile` 仍能读出**与改前逐字段相等**的内容(动态表达式与拼接串原样保留)。
## 风险
| 风险 | 表现 | 对策 |
| --- | --- | --- |
| 无控制台时 API 静默返回假值 | `RawUI.WindowSize` 给 `160x40`、`KeyAvailable` 恒 True | D3 的闸门;TUI 段全包 try/finally |
| 中文宽度算错 | 菜单右边框歪、光标定位偏 | 第一轮的宽度断言(含半角片假名/emoji) |
| TUI 吃掉了退出码 | 计划任务显示成功但备份失败 | TUI 异常绝不 `exit`;断言退出码语义 |
| 外科式改写改坏了别的行 | 配置注释丢失、表达式被毁 | 往返断言 + 校验通过才替换 + 时间戳备份 |
| 一次性做三个编辑器把质量摊薄 | 三个都半成品 | 分三轮,每轮独立验收 |
+855
View File
@@ -0,0 +1,855 @@
<#
.SYNOPSIS
按 BackupList.txt 执行备份。
.DESCRIPTION
与旧版相比的核心变化:
1. 退出码可靠 —— 不再用 Start-Process -PassThru(在 PowerShell 7.7.0-preview.4 上
ExitCode 恒为 $null,会把成功的压缩判成失败),改用 Invoke-ExternalCommand。
2. 先写临时归档 → 校验 → 原子替换。中断或断电只会留下 .tmp 文件,
不会污染正式归档;也不会再出现"半个归档被下次增量续写"的情况。
3. 不再使用 7z 的 u(更新)模式。7z 默认是固实压缩,u 本来就要重压大部分数据,
收益极小,却让排除规则和删除操作永远无法生效(旧归档里会一直留着已删文件)。
现在每次都从零打包,于是"排除规则改动"和"源里删掉的文件"都能真正反映到归档。
4. 每个条目写进 manifest.json:源、归档、时间、退出码、校验结果、失败原因。
跳过和失败从此有据可查,而不是只剩一行滚过去的控制台告警。
5. 结尾按失败数 exit,并写日志文件,计划任务能正确判断成败。
6. 磁盘空间守卫:放不下就拒绝该条目,低于阈值则告警。
与 SoftwareCatalog.psd1 的 Slot 结构配套:
* 一个软件 = 一个归档,归档内是 `<Slot>\<该 Path 的内容>`;
* 打包前用暂存目录 + junction / 硬链接把 Slot 名变成包里真实的目录名
(7z 没有"入库时改名"的能力),打包后立刻拆掉暂存目录;
* 清单行首 `+` = 仅备份、`-` = 仅恢复。
#>
[CmdletBinding()]
param(
[Parameter()]
[string]$BackupListPath,
[Parameter()]
[string]$BackupDir,
[Parameter()]
[string]$ConfigPath,
[Parameter()]
[string]$KeyFile,
# 只处理匹配这些通配符的条目(匹配原始路径或归档基础名)
[Parameter()]
[string[]]$Only = @(),
# 跳过匹配这些通配符的条目
[Parameter()]
[string[]]$Skip = @(),
# 忽略"源未更新"判断,强制重新打包
[Parameter()]
[switch]$Force,
# 成功后在 snapshots 目录留一份带时间戳的副本
[Parameter()]
[switch]$Snapshot,
# 额外计算归档的 SHA256 写入 manifest(大归档会更慢)
[Parameter()]
[switch]$Hash,
# 抑制压缩工具的实时输出(日志与 manifest 不受影响)
[Parameter()]
[switch]$QuietTool,
# 允许用"有警告"的不完整归档覆盖已有的完整归档(默认拒绝)
[Parameter()]
[switch]$AcceptWarnings,
# 只打印将要做什么,不实际写入
[Parameter()]
[switch]$DryRun
)
$ErrorActionPreference = 'Stop'
# 默认值不能写在 param() 里:Windows PowerShell 5.1 在带 [CmdletBinding()] 的脚本上,
# 参数绑定阶段还没有给 $PSScriptRoot 赋值,默认值表达式会拿到空串(实测:带
# [CmdletBinding()] -> 空串,不带 -> 正常;PowerShell 7 两种都正常)。所以默认值
# 一律在这里补 —— 这也是本仓库对 -BackupDir / -ConfigPath 一直在用的写法。
if (-not $BackupListPath) { $BackupListPath = Join-Path $PSScriptRoot 'BackupList.txt' }
if (-not $ConfigPath) { $ConfigPath = Join-Path $PSScriptRoot 'BackupConfig.psd1' }
# ============================================================================
# 载入依赖
# ============================================================================
$modulePath = Join-Path $PSScriptRoot 'BakNRet\BakNRet.psd1'
if (-not (Test-Path -LiteralPath $modulePath)) {
Write-Error "找不到依赖模块:$modulePath,请确保所有文件在同一目录。"
exit 1
}
Import-Module $modulePath -Force
if ($PSBoundParameters.ContainsKey('Verbose')) { Set-BakNRetDebug }
$script:Config = Get-BakNRetConfig -Path $ConfigPath
if (-not $BackupDir) { $BackupDir = Resolve-BakNRetRootedPath -Path $null -Default $script:Config.BackupDir -Root $PSScriptRoot }
$logDir = Resolve-BakNRetRootedPath -Path $null -Default $script:Config.LogDir -Root $PSScriptRoot
$snapshotDir = Resolve-BakNRetRootedPath -Path $null -Default $script:Config.SnapshotDir -Root $PSScriptRoot
$catalogPath = Resolve-BakNRetCatalogPath -Configured $script:Config.SoftwareCatalog -Root $PSScriptRoot
$manifestPath = Join-Path $BackupDir 'manifest.json'
$logPath = Start-BakNRetLog -Directory $logDir -Prefix 'backup'
$runStartedAt = Get-Date
Write-BakNRetLog "日志文件:$logPath"
Write-BakNRetLog "备份目录:$BackupDir"
Write-BakNRetLog ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath $catalogPath) { '' } else { '(不存在,将只支持字面路径)' }))
if (-not (Test-BakNRetAdministrator)) {
Write-BakNRetLog '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN
}
# 同一份备份目录同一时间只允许一个进程操作(见 BakNRet 模块的「运行锁」一节)。
# -DryRun 不取锁:它一个字节都不写,没必要被正在跑的备份挡在外面。
$runLock = $null
if (-not $DryRun) {
$runLock = Enter-BakNRetRunLock -Directory $BackupDir
if (-not $runLock) {
Write-BakNRetLog ("另一次运行正在进行中(锁文件:{0},里面写明了持有者)。本次不执行。" -f (Get-BakNRetRunLockPath -Directory $BackupDir)) -Level ERROR
Stop-BakNRetLog
exit 1
}
Write-BakNRetLog ("已取得运行锁:{0}" -f (Get-BakNRetRunLockPath -Directory $BackupDir)) -Level DEBUG
}
# ============================================================================
# 准备
# ============================================================================
if (-not (Test-Path -LiteralPath $BackupDir)) {
New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null
Write-BakNRetLog "创建备份目录: $BackupDir" -Level DEBUG
}
if (-not (Test-Path -LiteralPath $BackupListPath)) {
# 清单缺失有两条语义完全不同的路:
#
# ① 首次运行(没传 -BackupListPath,用的是仓库自带的那份)-> 建一份模板,退出 0。
# 这是「开箱即用」的引导,**不是失败**。
# ② 显式传了 -BackupListPath 却不存在 -> 是调用方的错误(路径写错、计划任务里
# 的相对路径按了别的工作目录解析)。此时绝不能建模板就退出 0:计划任务会读到
# 「上次运行结果 = 成功」,而实际上一个条目都没处理 —— 这个仓库刚把
# 「27 条被静默跳过、退出码仍是 0」当作一类缺陷修过,这条是同一类。
if ($PSBoundParameters.ContainsKey('BackupListPath')) {
Write-BakNRetLog ("指定的清单不存在:{0}" -f $BackupListPath) -Level ERROR
Write-BakNRetLog '显式指定 -BackupListPath 时不会自动创建模板:请检查路径是否写错;要生成一份起步模板,就去掉该参数。' -Level ERROR
Stop-BakNRetLog
exit 1
}
$template = "# BackupList.txt`n" +
"# 语法: [+|-] <软件名 或 绝对路径> [:: <路径>] [:- <排除模式>[,<排除模式>...]] [:+ <追加项>[,<追加项>...]] [:encrypt | :!encrypt] [@ <Key>='<值>'] [# 说明]`n" +
"# 示例: Edge`n" +
"# %UserProfile%\.ssh :encrypt`n" +
"# 完整语法见 README 与 BackupList.txt 自身的注释。`n"
[System.IO.File]::WriteAllText($BackupListPath, $template, [System.Text.UTF8Encoding]::new($true))
Write-BakNRetLog '模板 BackupList.txt 已创建,请编辑后重试。' -Level INFO
Stop-BakNRetLog
exit 0
}
$tool = Resolve-BakNRetCompressionTool
if (-not $tool) {
Write-BakNRetLog '没有找到可用的压缩工具。' -Level ERROR
Stop-BakNRetLog
exit 1
}
$toolVersion = try {
$info = (Get-Item -LiteralPath $tool.Command -ErrorAction Stop).VersionInfo
if ($info.ProductVersion) { $info.ProductVersion } elseif ($info.FileVersion) { $info.FileVersion } else { $null }
}
catch { $null }
Write-BakNRetLog ("压缩工具:{0}{1}" -f $tool.Name, $(if ($toolVersion) { "($toolVersion)" } else { '' }))
$manifest = Read-BakNRetManifest -Path $manifestPath
$manifest.compressor = [pscustomobject]@{ name = $tool.Name; command = $tool.Command; extension = $tool.Extension; version = $toolVersion }
$passwordFile = if ($KeyFile) { $KeyFile } else { $script:Config.Encryption.PasswordFile }
if ($passwordFile) {
# 相对路径按**仓库根**解析,而不是按当前工作目录:计划任务的工作目录是 C:\Windows\System32,
# 在那里 Test-Path 找不到 baknret.key,加密条目就会以"拿不到口令"失败,而配置看上去没问题。
$passwordFile = Resolve-BakNRetRootedPath -Path $passwordFile -Default $passwordFile -Root $PSScriptRoot
}
$password = Get-BakNRetPassword -PasswordFile $passwordFile
$encryptAll = [bool]$script:Config.Encryption.Enabled
$showToolOutput = (-not $QuietTool) -and ($script:Config.ToolOutput -ne 'quiet')
$toolQuietArgument = if ($showToolOutput) { @() } else { @('-bso0', '-bsp0') }
$lines = Get-Content -Encoding UTF8 -LiteralPath $BackupListPath
$seenBaseNames = @{}
$processed = 0; $skipped = 0; $failed = 0; $planned = 0
$securityErrorCount = 0 # 有条目"安全描述符里有读不到的对象"
$securityFailed = 0 # 有条目"安全描述符完全没存下来"
$failures = @()
$freeSpaceGB = Get-BakNRetFreeSpaceGB -Path $BackupDir
if ($freeSpaceGB -ge 0) {
Write-BakNRetLog ("备份目录所在卷剩余空间:{0} GB" -f $freeSpaceGB)
if ($freeSpaceGB -lt $script:Config.MinFreeSpaceGB) {
Write-BakNRetLog ("剩余空间低于阈值 {0} GB,大条目可能失败" -f $script:Config.MinFreeSpaceGB) -Level WARN
}
}
# 压缩 + 校验 + 原子替换;返回 @{ Ok; ExitCode; Warnings; Reason }
#
# 归档内容由调用方决定:它已经用 New-BakNRetArchiveStaging 把每个归档项按"归档内的名字"
# 挂进了 $StagingRoot(目录走 junction、文件走硬链接/复制),所以这里只做三件事:
# 1. 以暂存目录为工作目录调用压缩工具,把项名加进去;
# 2. 用 7z t 校验,并核对多 Slot 条目的每个顶层名字都真的在包里;
# 3. 有警告时按保护策略决定是否原子替换。
function Invoke-BackupItem {
param(
[Parameter(Mandatory = $true)][array]$SourceItems,
[Parameter(Mandatory = $true)][string]$StagingRoot,
[Parameter(Mandatory = $true)][string]$FinalPath,
[string[]]$ExcludePatterns = @(),
[switch]$UseEncryption,
[switch]$ProtectPrevious,
[switch]$AcceptWarnings
)
$tempPath = "$FinalPath.tmp$($tool.Extension)"
if (Test-Path -LiteralPath $tempPath) { Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue }
$warnings = $false
$lastExitCode = 0
$itemNames = @($SourceItems | ForEach-Object { [string]$_.ArchivePath })
$realPaths = @($SourceItems | ForEach-Object { [string]$_.RealPath })
try {
if ($SourceItems.Count -eq 0) {
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '没有可打包的源目录' }
}
if ($tool.Name -eq '7z') {
$optimized = Get-Optimized7zArgument -SourcePath $realPaths -Level $script:Config.CompressionLevel
$argument = @($optimized.Argument) + $toolQuietArgument + @($ExcludePatterns)
if ($UseEncryption) {
if (-not $password) {
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令(见 README「加密」)' }
}
$argument += "-p$password"
if ($script:Config.Encryption.EncryptHeaders) { $argument += '-mhe=on' }
}
$argument += $tempPath
$argument += $itemNames
$exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $StagingRoot
$lastExitCode = $exitCode
# 7z: 0 成功;1 警告(有文件读不到或跳过);2 及以上为失败
if ($exitCode -ne 0 -and $exitCode -ne 1) {
return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $warnings; Reason = "压缩工具退出码 $exitCode" }
}
if ($exitCode -eq 1) { $warnings = $true }
}
elseif ($tool.Name -eq 'RAR') {
$argument = @('a', '-m5', '-idp', '-idn') + $toolQuietArgument + @($ExcludePatterns)
if ($UseEncryption) {
if (-not $password) {
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令' }
}
$argument += "-p$password"
}
$argument += $tempPath
$argument += $itemNames
$exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $StagingRoot
$lastExitCode = $exitCode
if ($exitCode -ne 0) {
return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $warnings; Reason = "压缩工具退出码 $exitCode" }
}
}
else {
if ($UseEncryption) {
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '内置 ZIP 不支持加密,请改用 7z 或去掉加密' }
}
# Compress-Archive 没有排除开关,也没有加密;这是降级路径,只保证内容完整。
# 暂存目录里的名字就是归档内的名字,所以布局与 7z 分支一致。
$fullPaths = @($SourceItems | ForEach-Object { Join-Path $StagingRoot $_.ArchivePath })
Compress-Archive -Path $fullPaths -DestinationPath $tempPath -CompressionLevel Optimal -Force
}
if (-not (Test-Path -LiteralPath $tempPath)) {
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '压缩结束但没有生成临时归档' }
}
# 校验:确认归档可读且内容 CRC 正确
if ($script:Config.VerifyArchive -and $tool.Name -eq '7z') {
$verifyArgument = @('t', '-bso0', '-bsp0')
if ($UseEncryption -and $password) { $verifyArgument += "-p$password" }
$verifyArgument += $tempPath
$verifyCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $verifyArgument -WorkingDirectory $StagingRoot
if ($verifyCode -ne 0) {
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
return [pscustomobject]@{ Ok = $false; ExitCode = $verifyCode; Warnings = $false; Reason = "归档校验失败(7z t 退出码 $verifyCode),已丢弃临时文件" }
}
Write-BakNRetLog '归档校验通过(7z t)' -Level DEBUG
# 多个 Slot / 追加项时确认每一个顶层名字都真的进了归档:7z 的"警告"可能只体现在某一部分上
if ($SourceItems.Count -gt 1) {
$listed = @(Get-BakNRetArchiveTopLevelNames -ArchivePath $tempPath -SevenZip $tool.Command -Password $(if ($UseEncryption) { $password } else { $null }))
if ($listed.Count -gt 0) {
$expected = @($SourceItems | ForEach-Object { [string]$_.TopName } | Select-Object -Unique)
$absent = @($expected | Where-Object { $_ -notin $listed })
if ($absent.Count -gt 0) {
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
return [pscustomobject]@{ Ok = $false; ExitCode = $lastExitCode; Warnings = $true; Reason = ("归档缺少这些顶层条目:{0}(归档内实际有:{1})" -f ($absent -join '、'), ($listed -join '、')) }
}
}
}
}
# 关键保护:压缩工具报了警告(通常是有文件被占用读不到)时,
# 新归档是**不完整**的。用不完整归档覆盖已有的完整归档 = 静默丢数据。
# 实测:Edge 运行时备份,118 个文件读不到,其中包含 Login Data(密码)、
# Cookies、History、Web Data —— 恰恰是最不可再生的那部分。
if ($warnings -and $ProtectPrevious -and -not $AcceptWarnings) {
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
return [pscustomobject]@{
Ok = $false
ExitCode = $lastExitCode
Warnings = $true
Reason = '压缩工具报告有文件被占用而读不到,新归档不完整。为避免覆盖现有的完整归档已保留旧归档;请关闭占用该目录的程序后重跑,或确认可以接受后用 -AcceptWarnings 强制覆盖'
}
}
Move-BakNRetArchiveIntoPlace -TempPath $tempPath -DestinationPath $FinalPath
return [pscustomobject]@{ Ok = $true; ExitCode = 0; Warnings = $warnings; Reason = $null }
}
catch {
if (Test-Path -LiteralPath $tempPath) {
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
}
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = "$_" }
}
}
# ============================================================================
# 备份前空间预估(只读,不写任何东西)
# ============================================================================
# 只做一件事:动手之前告诉用户"这次大概要写多少、盘够不够"。
# 不做更复杂的占用控制 —— 真正拦住某个条目的是主循环里的逐条目守卫。
#
# 模型(按清单顺序模拟一遍):
# * 每个要重打的条目会先写一份**临时**归档,这时旧归档还在,所以那一刻占用的
# 是"当前累计净增量 + 本次预估";
# * 原子替换之后,本次净增量 = 预估 - 现有归档大小(换成更小的归档会把空间还回来)。
# 于是:峰值新增 = max_i( 第 i 项之前的累计净增量 + 第 i 项的预估大小 )。
$spacePlan = @()
$spaceSkipped = 0
$spaceNoSource = 0
foreach ($planLine in $lines) {
$planItem = ConvertFrom-BackupListLine -Line $planLine
if (-not $planItem) { continue }
$planDisplayPath = $planItem.Path
$planResolved = Resolve-BakNRetBackupEntry -Entry $planItem -CatalogPath $catalogPath
if (-not $planResolved.BaseName) { continue }
if (-not (Test-BakNRetItemSelected -DisplayPath $planDisplayPath -BaseName $planResolved.BaseName -Only $Only -Skip $Skip)) { continue }
if ($planResolved.Direction -eq 'restore') { continue }
if ($planResolved.Blocking) { continue }
$planItems = @($planResolved.Items | Where-Object { Test-Path -LiteralPath $_.RealPath })
if ($planItems.Count -eq 0) { $spaceNoSource++; continue }
$planSourceBytes = [int64]0
$planSourceFiles = 0
$planLatest = $null
foreach ($planSource in $planItems) {
$planSummary = Get-BakNRetFolderSummary -FolderPath $planSource.RealPath
$planSourceBytes += [int64]$planSummary.TotalSize
$planSourceFiles += [int]$planSummary.FileCount
if ($planSummary.LatestModifiedTime -and (-not $planLatest -or $planSummary.LatestModifiedTime -gt $planLatest)) {
$planLatest = $planSummary.LatestModifiedTime
}
}
$planArchiveName = $planResolved.BaseName + $tool.Extension
$planArchivePath = Join-Path $BackupDir $planArchiveName
$planExistingItem = if (Test-Path -LiteralPath $planArchivePath) { Get-Item -LiteralPath $planArchivePath } else { $null }
$planExistingBytes = if ($planExistingItem) { [int64]$planExistingItem.Length } else { [int64]0 }
# 与主循环同一套判断:源没更新就不会重打
if (-not $Force -and $planExistingItem -and $planLatest -and $planLatest -le $planExistingItem.LastWriteTime) {
$spaceSkipped++
continue
}
$planEstimate = if ($planExistingBytes -gt 0) {
[int64][math]::Min([double]$planSourceBytes, [double]$planExistingBytes * 1.3)
}
else {
# 没有历史归档可比时按"完全不压缩"的悲观值估,宁可报多不报少
$planSourceBytes
}
$spacePlan += [pscustomobject]@{
Name = $planResolved.BaseName
Source = $planDisplayPath
Files = $planSourceFiles
SourceBytes = $planSourceBytes
Existing = $planExistingBytes
Estimate = $planEstimate
}
}
$freeNowGB = Get-BakNRetFreeSpaceGB -Path $BackupDir
if ($spacePlan.Count -eq 0) {
Write-BakNRetLog '备份前空间预估:本次没有需要重打的条目(源未更新或源不存在),不会写入新归档' -Level INFO
}
else {
$spacePeak = [double]0
$spaceCumulative = [double]0
foreach ($plan in $spacePlan) {
$spacePeak = [math]::Max($spacePeak, $spaceCumulative + $plan.Estimate)
$spaceCumulative += ($plan.Estimate - $plan.Existing)
}
$peakGB = $spacePeak / 1GB
$netGB = $spaceCumulative / 1GB
$estimateGB = ((($spacePlan | Measure-Object -Property Estimate -Sum).Sum)) / 1GB
$existingGB = ((($spacePlan | Measure-Object -Property Existing -Sum).Sum)) / 1GB
Write-BakNRetLog '==== 备份前空间预估(只读)====' -Level INFO
Write-BakNRetLog (" 目标卷可用空间:{0} GB" -f $freeNowGB)
Write-BakNRetLog (" 本次要重打 {0} 个条目(另有 {1} 个源未更新会跳过、{2} 个源不存在)" -f $spacePlan.Count, $spaceSkipped, $spaceNoSource)
Write-BakNRetLog (" 新归档合计约 {0} GB;其中会替换掉的旧归档 {1} GB" -f [math]::Round($estimateGB, 2), [math]::Round($existingGB, 2))
foreach ($plan in ($spacePlan | Sort-Object Estimate -Descending | Select-Object -First 15)) {
Write-BakNRetLog (" - {0,-22} 源 {1,8:N1} MB / {2,6} 文件 现有 {3,7:N1} MB 预估 {4,7:N1} MB" -f `
$plan.Name, ($plan.SourceBytes / 1MB), $plan.Files, ($plan.Existing / 1MB), ($plan.Estimate / 1MB))
}
if ($spacePlan.Count -gt 15) {
Write-BakNRetLog (" …… 另有 {0} 个条目未逐条列出" -f ($spacePlan.Count - 15))
}
Write-BakNRetLog (" 预计峰值新增占用:{0} GB(全程净增量 {1} GB)" -f [math]::Round($peakGB, 2), [math]::Round($netGB, 2))
if ($freeNowGB -lt 0) {
Write-BakNRetLog ' 结论:读不到目标卷可用空间,请自行确认是否够用' -Level WARN
}
elseif ($peakGB -le $freeNowGB) {
Write-BakNRetLog (" 结论:空间足够(预计用 {0} GB / 可用 {1} GB)" -f [math]::Round($peakGB, 2), $freeNowGB) -Level INFO
}
else {
Write-BakNRetLog (" 结论:空间可能不够!预计需要 {0} GB,可用只有 {1} GB,差 {2} GB" -f `
[math]::Round($peakGB, 2), $freeNowGB, [math]::Round($peakGB - $freeNowGB, 2)) -Level WARN
Write-BakNRetLog ' 仍会继续执行:真正放不下的条目会被逐条目守卫跳过。建议先腾空间,或用 -Only / -Skip 分批备份。' -Level WARN
}
Write-BakNRetLog '============================' -Level INFO
}
# ============================================================================
# 主流程
# ============================================================================
foreach ($line in $lines) {
$item = ConvertFrom-BackupListLine -Line $line
if (-not $item) { continue }
$displayPath = $item.Path
$resolved = Resolve-BakNRetBackupEntry -Entry $item -CatalogPath $catalogPath
if (-not $resolved.BaseName) {
$record = New-BakNRetItemRecord -BaseName ('raw:' + $displayPath) -Source $displayPath -ResolvedSource $displayPath -Phase 'parse'
Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'failed' -Reason '无法生成归档名' | Out-Null
$failed++; $failures += $displayPath
continue
}
$baseName = $resolved.BaseName
$sourcePath = [Environment]::ExpandEnvironmentVariables($displayPath)
if (-not (Test-BakNRetItemSelected -DisplayPath $displayPath -BaseName $baseName -Only $Only -Skip $Skip)) {
Write-BakNRetLog "跳过(未选中): $displayPath" -Level DEBUG
continue
}
# 归档名登记必须在方向过滤**之前**:`-`(仅恢复)的条目不会产生归档,
# 但它对应的归档是有主的,不能被下游的孤儿审计当成没人要的孤儿。
# 备份列表里写重了会生成两个同名归档、互相覆盖 —— 直接报错,不猜。
if ($seenBaseNames.ContainsKey($baseName)) {
$reason = "归档名 '$baseName' 与清单中的 '$($seenBaseNames[$baseName])' 重复(由 '$displayPath' 生成),两者会互相覆盖"
Write-BakNRetLog "失败: $displayPath,$reason" -Level ERROR
$record = New-BakNRetItemRecord -BaseName $baseName -Source $displayPath -ResolvedSource $sourcePath -Phase 'backup'
Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'failed' -Reason $reason | Out-Null
$failed++; $failures += $displayPath
continue
}
$seenBaseNames[$baseName] = $displayPath
if ($resolved.Direction -eq 'restore') {
Write-BakNRetLog "跳过(行首 -,仅恢复): $displayPath" -Level INFO
continue
}
$record = New-BakNRetItemRecord -BaseName $baseName -Source $displayPath -ResolvedSource $sourcePath -Phase 'backup'
$record.archive = $baseName + $tool.Extension
if ($resolved.CatalogEntry) { $record.catalog = $resolved.CatalogEntry.Path }
$finalPath = Join-Path $BackupDir $record.archive
# root= 是历史标记:包内的一层目录现在由名录里的 Slot 决定,这里只告警不静默忽略。
if (@($item.Flags | Where-Object { $_ -like 'root=*' }).Count -gt 0) {
Write-BakNRetLog "警告: $displayPath 使用了 root= 标记。归档内的一层目录现在由 Slot 决定,该标记已废弃,本次忽略" -Level WARN
}
# 归档内路径冲突:明确失败,绝不把两块内容静默搅进同一棵树
if ($resolved.Blocking) {
Write-BakNRetLog "失败: $displayPath,$($resolved.Blocking)" -Level ERROR
Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'failed' -Reason $resolved.Blocking | Out-Null
$failed++; $failures += $displayPath
continue
}
# 动手之前先把"这条会打包哪些目录、归档里叫什么、排除了什么、为什么"讲清楚
$planListExcludes = @()
$planCatalogExcludes = @()
if ($resolved.HasExcludeOverride) {
$planListExcludes = @($resolved.ExcludePatterns)
}
else {
$planCatalogExcludes = @($resolved.Items | ForEach-Object { @($_.Exclude) } | Where-Object { $_ } | Select-Object -Unique)
}
Write-BakNRetBackupEntryPlan -Resolved $resolved -DisplayPath $displayPath `
-ListExcludes $planListExcludes -CatalogExcludes $planCatalogExcludes `
-ConfigExcludes @($script:Config.DefaultExcludes) -Comment $item.Comment
# Items 为空 = 解析不出任何归档项(名录里没这个软件名、或路径拆不出末级名)。
# 注意不能用 $resolved.Error 判断:名录里的路径不存在时 Error 有值,
# 但 Items 是给出的(恢复端要靠它把内容还原回原位),备份端由下面的存在性检查统一处理。
if ($resolved.Items.Count -eq 0) {
$reason = if ($resolved.Error) { $resolved.Error } else { '解析不出任何源路径' }
Write-BakNRetLog "跳过: $displayPath,$reason" -Level WARN
Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'missing-source' -Reason $reason | Out-Null
$skipped++
continue
}
# 源存在性检查必须在 Get-BakNRetFolderSummary / Get-Item 之前:
# 两者对不存在的路径要么抛异常、要么返回会误导判断的空摘要。
$missingItems = @($resolved.Items | Where-Object { -not (Test-Path -LiteralPath $_.RealPath) })
if ($missingItems.Count -ge $resolved.Items.Count) {
$missingText = @($missingItems | ForEach-Object { $_.RealPath }) -join ';'
Write-BakNRetLog "跳过: $displayPath,源路径不存在" -Level WARN
Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'missing-source' -Reason ('源路径不存在:' + $missingText) | Out-Null
$skipped++
continue
}
if ($missingItems.Count -gt 0) {
Write-BakNRetLog ("警告: {0} 有 {1} 个归档项的源路径不存在,本次只打包存在的部分:{2}" -f `
$displayPath, $missingItems.Count, (@($missingItems | ForEach-Object { $_.RealPath }) -join ';')) -Level WARN
}
# 归档里只放真实存在的源
$liveItems = @($resolved.Items | Where-Object { Test-Path -LiteralPath $_.RealPath })
# 归档内的顶层条目名 = 真实存在的归档项在包内的第一层名字(Slot 名 / 源目录名)。
# 这里记录可核对的事实,备份成功后还会用 Get-BakNRetArchiveTopLevelNames 与归档内容对账。
$record.roots = @($liveItems | ForEach-Object { $_.TopName } | Select-Object -Unique)
# 恢复端要知道每个项在归档里是目录还是文件:记在 manifest 里,
# 这样目标机器上目标还不存在(全新恢复)时也判断得出来。
$record.layouts = @($liveItems | ForEach-Object {
[ordered]@{
name = $_.ArchivePath
kind = $(if ($_.IsFile) { 'file' } else { 'dir' })
}
})
$primarySource = $liveItems[0].RealPath
if ([string]::IsNullOrWhiteSpace($primarySource)) {
Write-BakNRetLog "跳过: $displayPath,无法确定主源路径" -Level WARN
Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'invalid-path' -Reason '归档项没有可用路径' | Out-Null
$skipped++
continue
}
$summary = Get-BakNRetFolderSummary -FolderPath $primarySource
# 从第二个归档项开始累加。刻意不用 `$liveItems[1..($liveItems.Count-1)]`:
# 只有一项时 `1..0` 会退化成 `1,0`,把同一份源数两遍(旧实现的隐蔽 bug)。
for ($index = 1; $index -lt $liveItems.Count; $index++) {
$extra = Get-BakNRetFolderSummary -FolderPath $liveItems[$index].RealPath
$summary.FileCount += $extra.FileCount
$summary.TotalSize += $extra.TotalSize
if ($extra.LatestModifiedTime -and $extra.LatestModifiedTime -gt $summary.LatestModifiedTime) {
$summary.LatestModifiedTime = $extra.LatestModifiedTime
}
}
$record.sourceFiles = $summary.FileCount
$record.sourceBytes = $summary.TotalSize
$archiveExists = Test-Path -LiteralPath $finalPath
$archiveItem = if ($archiveExists) { Get-Item -LiteralPath $finalPath } else { $null }
Write-BakNRetLog ("开始备份: {0} -> {1}({2} 个文件,{3} MB)" -f $displayPath, $record.archive, $summary.FileCount, [math]::Round(($summary.TotalSize / 1MB), 2))
# 空目录时 Get-BakNRetFolderSummary 拿不到任何条目,回退到源自身的修改时间
# (源路径上面已经确认存在,这里的 Get-Item 不会再抛异常)
$sourceLatest = $summary.LatestModifiedTime
if (-not $sourceLatest) {
$sourceLatest = (Get-Item -LiteralPath $primarySource -Force).LastWriteTime
}
if (-not $Force -and $archiveItem -and $sourceLatest -and $sourceLatest -le $archiveItem.LastWriteTime) {
Write-BakNRetLog "跳过: $displayPath,源目录未更新" -Level INFO
$record.archiveBytes = $archiveItem.Length
Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'skip-unchanged' -Reason ('源最新修改时间 {0} 不晚于归档时间 {1}' -f $sourceLatest, $archiveItem.LastWriteTime) | Out-Null
$skipped++
continue
}
# 空间守卫:临时归档与正式归档会同时存在,因此按"新归档预估大小"要求剩余空间
$estimatedGB = $summary.TotalSize / 1GB
if ($archiveItem) {
$archiveGB = $archiveItem.Length / 1GB
$estimatedGB = [math]::Min($estimatedGB, $archiveGB * 1.3)
}
$freeSpaceGB = Get-BakNRetFreeSpaceGB -Path $BackupDir
if ($freeSpaceGB -ge 0 -and $estimatedGB -gt 0 -and $freeSpaceGB -lt $estimatedGB) {
$reason = ('剩余空间 {0} GB 不足以写入预估 {1} GB 的新归档' -f $freeSpaceGB, [math]::Round($estimatedGB, 2))
Write-BakNRetLog "失败: $displayPath,$reason" -Level ERROR
Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'failed' -Reason $reason | Out-Null
$failed++; $failures += $displayPath
continue
}
if ($DryRun) {
Write-BakNRetLog ("[试运行] 将打包 {0} -> {1}" -f $sourcePath, $finalPath) -Level INFO
$record.reason = '试运行,未执行压缩'
Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'planned' -Reason '试运行,未执行压缩' | Out-Null
$planned++
continue
}
$useEncryption = $encryptAll -or [bool]$resolved.Encrypt
$record.encrypted = [bool]$useEncryption
$startedAt = Get-Date
$record.attemptedAt = $startedAt.ToString('o')
# 排除参数:条目级 `:-` / `@ Exclude` 覆盖优先,否则用名录里各 Slot 自己的 Exclude;
# 再叠上 BackupConfig.psd1 的 DefaultExcludes。
# 模式先用 `<顶层名>\` 前缀分配到对应归档项上(`Scoop :- GlobalPersist\steam`),
# 再逐项翻译成 7z 的 -x! / -xr!,最后去重合并成一次调用的参数。
$patternSource = if ($resolved.HasExcludeOverride) {
@($resolved.ExcludePatterns)
}
else {
@($resolved.Items | ForEach-Object { @($_.Exclude) } | Where-Object { $_ } | Select-Object -Unique)
}
$allPatterns = @($script:Config.DefaultExcludes) + $patternSource
$scopeMap = Split-BakNRetPatternScope -Items $liveItems -Patterns $allPatterns
$excludeLists = @()
$excludeError = $null
for ($index = 0; $index -lt $liveItems.Count; $index++) {
$expanded = Get-BakNRetExcludeArgument -Item $liveItems[$index] -Patterns @($scopeMap[$index])
if ($expanded.Error) { $excludeError = $expanded.Error }
$excludeLists += , @($expanded.Arguments)
}
$effectiveExcludes = @(Merge-BakNRetExcludeArgument -ArgumentLists $excludeLists)
if ($excludeError) {
Write-BakNRetLog "失败: $displayPath,$excludeError" -Level ERROR
Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'failed' -Reason $excludeError | Out-Null
$failed++; $failures += $displayPath
continue
}
# 只有在"现有归档是完整的"时才值得保护它。没有 manifest 记录
# (本次重构之前留下的归档)时按完整处理——宁可保守。
$protectPrevious = [bool]$archiveExists
if ($archiveExists -and $manifest.items.Contains($baseName)) {
$previousRecord = $manifest.items[$baseName]
if (($previousRecord.PSObject.Properties.Name -contains 'warnings') -and $previousRecord.warnings) {
$protectPrevious = $false
}
}
# 归档内的一层目录名由 Slot / 追加项的归档内路径决定,所以先把它们以正确的名字
# 挂进暂存目录(junction / 硬链接),再让压缩工具以暂存目录为工作目录打包。
$stagingRoot = $null
try {
$stagingRoot = New-BakNRetArchiveStaging -Items $liveItems
$result = Invoke-BackupItem -SourceItems $liveItems -StagingRoot $stagingRoot `
-FinalPath $finalPath -ExcludePatterns $effectiveExcludes -UseEncryption:$useEncryption `
-ProtectPrevious:$protectPrevious -AcceptWarnings:$AcceptWarnings
}
catch {
$result = [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = "准备归档内容失败:$_" }
}
finally {
Remove-BakNRetArchiveStaging -Root $stagingRoot
}
$record.exitCode = $result.ExitCode
$record.attemptWarnings = [bool]$result.Warnings
$record.verified = [bool]$result.Ok
$record.durationSec = [math]::Round(((Get-Date) - $startedAt).TotalSeconds, 1)
if (-not $result.Ok) {
Write-BakNRetLog "备份失败: $displayPath,$($result.Reason)" -Level ERROR
Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'failed' -Reason $result.Reason | Out-Null
$failed++; $failures += $displayPath
continue
}
$written = Get-Item -LiteralPath $finalPath
$record.archiveBytes = $written.Length
if ($result.Warnings) {
Write-BakNRetLog "备份成功(压缩工具报告了警告,可能有文件被占用而没打进归档): $displayPath" -Level WARN
Write-BakNRetLog ' 该归档在 manifest 里标记为 warnings=true;如果以后现有归档是完整的,会拒绝被它覆盖' -Level WARN
}
else {
Write-BakNRetLog "备份成功: $baseName" -Level INFO
}
# ------------------------------------------------------------------
# 安全描述符(属主 / ACL)写进旁挂文件 <归档名>.acl.json
# ------------------------------------------------------------------
# 归档格式装不下它(7z 的 -sni 官方说明是"只能写进 WIM 归档"),所以放在归档旁边,
# 和归档一样先写 .tmp 再原子替换。属主必须一起存:C:\ProgramData 的 ACL 里有
# (A;OICIIO;GA;;;CO) CREATOR OWNER + inherit-only + GENERIC_ALL
# 而 CREATOR OWNER 是访问检查时才替换的占位符 —— 替换成"被检查对象的属主"。
# 只回放 ACE 文本、不恢复属主,等于把"谁创建的东西谁有全权"里的"谁"换成跑脚本的账户。
$securityMode = [string]$script:Config.Security.Mode
$securityFatal = $false
if ($securityMode -and ($securityMode -ne 'Off')) {
$sidecarName = "$baseName.acl.json"
$sidecarPath = Join-Path $BackupDir $sidecarName
try {
$capture = Get-BakNRetSecurityRecords -Items $liveItems -ScopeMap $scopeMap -Mode $securityMode `
-IncludeSacl:([bool]$script:Config.Security.IncludeSacl)
Save-BakNRetSecuritySidecar -Path $sidecarPath -Records $capture.Records -Mode $securityMode `
-IncludeSacl:([bool]$script:Config.Security.IncludeSacl) `
-Errors $capture.Errors -Scanned $capture.Scanned | Out-Null
$record.security = [ordered]@{
file = $sidecarName
mode = $securityMode
objects = $capture.Kept
scanned = $capture.Scanned
errors = $capture.Errors
capturedAt = (Get-Date).ToString('o')
}
Write-BakNRetLog ("安全描述符:{0} 个对象写进 {1}(扫描 {2} 个,读不到 {3} 个)" -f `
$capture.Kept, $sidecarName, $capture.Scanned, $capture.Errors) -Level INFO
if ($capture.Errors -gt 0) {
$securityErrorCount++
$unreadable = @($capture.Records | Where-Object { $_.e } | Select-Object -First 3 -ExpandProperty p)
Write-BakNRetLog (" {0} 个对象的安全描述符读不到(恢复后它们的属主/ACL 会是新建对象的默认值),例如:{1}" -f `
$capture.Errors, ($unreadable -join '、')) -Level WARN
}
}
catch {
$securityFailed++
Write-BakNRetLog "安全描述符采集/写盘失败:$displayPath —— $_" -Level WARN
$record.security = [ordered]@{ file = $sidecarName; error = "$_" }
if ([bool]$script:Config.Security.FailOnError) { $securityFatal = $true }
}
if ($securityFatal) {
Write-BakNRetLog "失败: $displayPath,归档已替换,但安全描述符没能存下来(Security.FailOnError = \$true)" -Level ERROR
Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'failed' -Reason '归档已替换,但安全描述符没能存下来' | Out-Null
$failed++; $failures += $displayPath
continue
}
}
if ($Hash -or $script:Config.ComputeHash) {
$record.sha256 = (Get-FileHash -LiteralPath $finalPath -Algorithm SHA256).Hash
Write-BakNRetLog "SHA256: $($record.sha256)" -Level DEBUG
}
if ($Snapshot -or $script:Config.Snapshot.Enabled) {
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$target = Join-Path (Join-Path $snapshotDir $stamp) $record.archive
$targetDir = Split-Path -Parent $target
if (-not (Test-Path -LiteralPath $targetDir)) { New-Item -ItemType Directory -Path $targetDir -Force | Out-Null }
Copy-Item -LiteralPath $finalPath -Destination $target -Force
Write-BakNRetLog "已留存快照: $target" -Level INFO
}
Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'backed-up' -Reason $null -ArchiveWarnings $result.Warnings | Out-Null
$processed++
}
# ============================================================================
# 收尾
# ============================================================================
if ($DryRun) {
Write-BakNRetLog '试运行:manifest 与归档都不会被写入' -Level INFO
}
else {
# manifest 里写了 archive 的记录,磁盘上就必须真有那个文件
$clearedArchiveFields = Sync-BakNRetManifestArchive -Manifest $manifest -BackupDir $BackupDir
if ($clearedArchiveFields.Count -gt 0) {
Write-BakNRetLog ("已清空 {0} 条记录里指向不存在归档的 archive 字段:{1}" -f $clearedArchiveFields.Count, ($clearedArchiveFields -join '、')) -Level WARN
}
Write-BakNRetManifest -Path $manifestPath -Manifest $manifest | Out-Null
Write-BakNRetLog "manifest 已更新:$manifestPath" -Level DEBUG
}
# 孤儿归档审计:磁盘上有、但**当前清单里任何条目都不指向**的归档。
# Restore.ps1 是按清单条目去找归档的,所以孤儿是**恢复不到**的 —— 必须显式点名,
# 免得下次清理时把还有用的归档当垃圾删掉(重构前那个 2.8 GB 的归档就是这么成孤儿的)。
#
# 判据只用清单,**不能用 manifest**:manifest 会一直留着历史条目,
# 于是"从清单里删掉某个条目(或把它合并进另一个条目)"留下的归档会被历史记录遮住,
# 审计就永远不会报——那正是最需要报出来的情况。
# $seenBaseNames 在方向过滤之前就登记,所以"行首 + / -"的条目也算有主。
# 只在整表运行时做:带 -Only/-Skip 时未选中的条目本来就不在 $seenBaseNames 里,
# 那种情况下报出来的全是假孤儿。
if (-not $DryRun -and $Only.Count -eq 0 -and $Skip.Count -eq 0) {
$known = @{}
foreach ($key in $seenBaseNames.Keys) { $known[$key] = $true }
$orphanArchives = @(Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Extension.ToLower() -in @('.7z', '.rar', '.zip', '.tar') -and -not $known.ContainsKey($_.BaseName) })
if ($orphanArchives.Count -gt 0) {
Write-BakNRetLog ("发现 {0} 个孤儿归档(当前清单里没有任何条目指向,恢复不到,注意别误删):" -f $orphanArchives.Count) -Level WARN
foreach ($orphan in $orphanArchives) {
$inManifest = $manifest.items.Contains($orphan.BaseName)
Write-BakNRetLog (" - {0}({1:N1} MB,{2}){3}" -f $orphan.Name, ($orphan.Length / 1MB), $orphan.LastWriteTime, $(if ($inManifest) { ';manifest 里还留着它的历史记录,但清单里已经没有了' } else { '' })) -Level WARN
}
}
else {
Write-BakNRetLog '孤儿归档审计:没有发现(所有归档都有清单条目指向)' -Level DEBUG
}
}
$counterText = @{ 成功 = $processed; 跳过 = $skipped; 失败 = $failed }
if ($DryRun) { $counterText['试运行计划'] = $planned }
Write-BakNRetRunSummary -Mode 'backup' -Manifest $manifest -StartedAt $runStartedAt -Failures $failures -Counters $counterText -OrphanArchives @($orphanArchives | Where-Object { $_ }) -SecurityFailed $securityFailed -SecurityErrorCount $securityErrorCount
$logPath = Get-BakNRetLogPath
if ($logPath) { Write-BakNRetLog "日志已写入:$logPath" -Level INFO }
Exit-BakNRetRunLock -Lock $runLock
Stop-BakNRetLog
if ($failed -gt 0) { exit 1 }
exit 0
+52 -894
View File
@@ -1,906 +1,64 @@
<# <#
.SYNOPSIS .SYNOPSIS
按 BackupList.txt 执行备份。 已改名:本脚本只是转发到 Backup-Data.ps1(这一层只保留一轮)。
.DESCRIPTION .DESCRIPTION
与旧版相比的核心变化: 为什么留一层转发(ADR-0012):入口脚本是**外部接口** —— README 里有二十多处引用、有使用者的
肌肉记忆、tools\Register-BackupTask.ps1 里也可能已经注册过这个路径。内部实现改名断了会当场
报错;外部接口改名断了是**静默没用**,而备份工具"静默没用"是最不能接受的失败方式。
1. 退出码可靠 —— 不再用 Start-Process -PassThru(在 PowerShell 7.7.0-preview.4 上 为什么用子进程、而不是 `& $target`:实测 `& script.ps1` 里子脚本的 exit **不会**把退出码传到
ExitCode 恒为 $null,会把成功的压缩判成失败),改用 Invoke-ExternalCommand。 父脚本的 $LASTEXITCODE —— 垫片会让失败变成"成功"(错配置时返回 0,被调用脚本返回 1),而计划
2. 先写临时归档 → 校验 → 原子替换。中断或断电只会留下 .tmp 文件, 任务正是靠退出码判断成败。
不会污染正式归档;也不会再出现"半个归档被下次增量续写"的情况。
3. 不再使用 7z 的 u(更新)模式。7z 默认是固实压缩,u 本来就要重压大部分数据,
收益极小,却让排除规则和删除操作永远无法生效(旧归档里会一直留着已删文件)。
现在每次都从零打包,于是"排除规则改动"和"源里删掉的文件"都能真正反映到归档。
4. 每个条目写进 manifest.json:源、归档、时间、退出码、校验结果、失败原因。
跳过和失败从此有据可查,而不是只剩一行滚过去的控制台告警。
5. 结尾按失败数 exit,并写日志文件,计划任务能正确判断成败。
6. 磁盘空间守卫:放不下就拒绝该条目,低于阈值则告警。
与 SoftwareCatalog.psd1 的 Slot 结构配套: 为什么重定向之后要**自己转发**:父进程的 stdout 常常是管道(测试与使用者的管道都在解析入口的
* 一个软件 = 一个归档,归档内是 `<Slot>\<该 Path 的内容>`; 输出),而 .NET 起的进程默认只继承控制台、不继承那个管道 —— 不重定向时子进程的输出就到不了
* 打包前用暂存目录 + junction / 硬链接把 Slot 名变成包里真实的目录名 调用方(实测红过)。所以显式重定向,再用**异步读**把两个流读出来转发(同步先读 stdout 再读
(7z 没有"入库时改名"的能力),打包后立刻拆掉暂存目录; stderr 会在管道写满时死锁)。代价是 stdout/stderr 的相对顺序不再保留 —— 这也正是这层垫片
* 清单行首 `+` = 仅备份、`-` = 仅恢复。 只留一轮的原因之一。
为什么导入模块时临时压掉 verbose:调用方可能给入口传 -Verbose(测试就是这么拿到详细日志的),
那样 Import-Module 会多打一行 "VERBOSE: Loading module from path ..." —— 而测试是**解析子进程
输出**做断言的,多这么一行就会把它顶掉。只压这一句,$Rest 里的 -Verbose 仍会原样转发。
这一层下一轮删。想用新名字就直接调 Backup-Data.ps1。
#> #>
[CmdletBinding()] [CmdletBinding()]
param( param(
[Parameter()] [Parameter(ValueFromRemainingArguments = $true)]$Rest
[string]$BackupListPath = (Join-Path $PSScriptRoot 'BackupList.txt'),
[Parameter()]
[string]$BackupDir,
[Parameter()]
[string]$ConfigPath = (Join-Path $PSScriptRoot 'BackupConfig.psd1'),
[Parameter()]
[string]$KeyFile,
# 只处理匹配这些通配符的条目(匹配原始路径或归档基础名)
[Parameter()]
[string[]]$Only = @(),
# 跳过匹配这些通配符的条目
[Parameter()]
[string[]]$Skip = @(),
# 忽略"源未更新"判断,强制重新打包
[Parameter()]
[switch]$Force,
# 成功后在 snapshots 目录留一份带时间戳的副本
[Parameter()]
[switch]$Snapshot,
# 额外计算归档的 SHA256 写入 manifest(大归档会更慢)
[Parameter()]
[switch]$Hash,
# 抑制压缩工具的实时输出(日志与 manifest 不受影响)
[Parameter()]
[switch]$QuietTool,
# 允许用"有警告"的不完整归档覆盖已有的完整归档(默认拒绝)
[Parameter()]
[switch]$AcceptWarnings,
# 只打印将要做什么,不实际写入
[Parameter()]
[switch]$DryRun
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
# ============================================================================ $savedVerbosePreference = $VerbosePreference
# 载入依赖 $VerbosePreference = 'SilentlyContinue'
# ============================================================================ Import-Module (Join-Path $PSScriptRoot 'BakNRet\BakNRet.psd1') -Force
$VerbosePreference = $savedVerbosePreference
$modulePath = Join-Path $PSScriptRoot 'Common.psm1'
if (-not (Test-Path -LiteralPath $modulePath)) { $target = Join-Path $PSScriptRoot 'Backup-Data.ps1'
Write-Error "找不到依赖模块:$modulePath,请确保所有文件在同一目录。" Write-Host '注意:Backup.ps1 已改名为 Backup-Data.ps1(这层转发只保留一轮)。' -ForegroundColor Yellow
exit 1
} $hostExe = (Get-Process -Id $PID).Path
Import-Module $modulePath -Force $forwardArguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $target) + @($Rest) + @(if ($PSBoundParameters.ContainsKey('Verbose')) { '-Verbose' }) + @(if ($PSBoundParameters.ContainsKey('Debug')) { '-Debug' })
if ($PSBoundParameters.ContainsKey('Verbose')) { Set-BaknretDebug } $startInfo = New-Object System.Diagnostics.ProcessStartInfo
$startInfo.FileName = $hostExe
$script:Config = Get-BaknretConfig -Path $ConfigPath $startInfo.Arguments = ConvertTo-BakNRetNativeArgumentString -ArgumentList $forwardArguments
$startInfo.UseShellExecute = $false
function Resolve-ConfigPath { $startInfo.RedirectStandardOutput = $true
param([string]$Path, [string]$Default) $startInfo.RedirectStandardError = $true
$value = if ($Path) { $Path } else { $Default }
if (-not [System.IO.Path]::IsPathRooted($value)) { $process = New-Object System.Diagnostics.Process
$value = Join-Path $PSScriptRoot $value $process.StartInfo = $startInfo
} [void]$process.Start()
return $value
} $stdoutTask = $process.StandardOutput.ReadToEndAsync()
$stderrTask = $process.StandardError.ReadToEndAsync()
if (-not $BackupDir) { $BackupDir = Resolve-ConfigPath -Path $null -Default $script:Config.BackupDir } $process.WaitForExit()
$logDir = Resolve-ConfigPath -Path $null -Default $script:Config.LogDir
$snapshotDir = Resolve-ConfigPath -Path $null -Default $script:Config.SnapshotDir $standardOutput = $stdoutTask.Result
$catalogPath = Resolve-CatalogPath -Configured $script:Config.SoftwareCatalog -Root $PSScriptRoot $standardError = $stderrTask.Result
$manifestPath = Join-Path $BackupDir 'manifest.json' if ($standardOutput) { Write-Host -NoNewline $standardOutput }
if ($standardError) { [Console]::Error.Write($standardError) }
$logPath = Start-BaknretLog -Directory $logDir -Prefix 'backup'
Write-Log "日志文件:$logPath" exit $process.ExitCode
Write-Log "备份目录:$BackupDir"
Write-Log ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath $catalogPath) { '' } else { '(不存在,将只支持字面路径)' }))
if (-not (Test-Administrator)) {
Write-Log '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN
}
# ============================================================================
# 准备
# ============================================================================
if (-not (Test-Path -LiteralPath $BackupDir)) {
New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null
Write-Log "创建备份目录: $BackupDir" -Level DEBUG
}
if (-not (Test-Path -LiteralPath $BackupListPath)) {
$template = "# BackupList.txt`n" +
"# 语法: [+|-] <软件名 或 绝对路径> [:: <路径>] [:- <排除模式>[,<排除模式>...]] [:+ <追加项>[,<追加项>...]] [:encrypt | :!encrypt] [@ <Key>='<值>'] [# 说明]`n" +
"# 示例: Edge`n" +
"# %UserProfile%\.ssh :encrypt`n" +
"# 完整语法见 README 与 BackupList.txt 自身的注释。`n"
[System.IO.File]::WriteAllText($BackupListPath, $template, [System.Text.UTF8Encoding]::new($false))
Write-Log '模板 BackupList.txt 已创建,请编辑后重试。' -Level INFO
Stop-BaknretLog
exit 0
}
$tool = Resolve-CompressionTool
if (-not $tool) {
Write-Log '没有找到可用的压缩工具。' -Level ERROR
Stop-BaknretLog
exit 1
}
$toolVersion = try {
$info = (Get-Item -LiteralPath $tool.Command -ErrorAction Stop).VersionInfo
if ($info.ProductVersion) { $info.ProductVersion } elseif ($info.FileVersion) { $info.FileVersion } else { $null }
} catch { $null }
Write-Log ("压缩工具:{0}{1}" -f $tool.Name, $(if ($toolVersion) { "($toolVersion)" } else { '' }))
$manifest = Read-BaknretManifest -Path $manifestPath
$manifest.compressor = [pscustomobject]@{ name = $tool.Name; command = $tool.Command; extension = $tool.Extension; version = $toolVersion }
$passwordFile = if ($KeyFile) { $KeyFile } else { $script:Config.Encryption.PasswordFile }
$password = Get-BaknretPassword -PasswordFile $passwordFile
$encryptAll = [bool]$script:Config.Encryption.Enabled
$showToolOutput = (-not $QuietTool) -and ($script:Config.ToolOutput -ne 'quiet')
$toolQuietArgument = if ($showToolOutput) { @() } else { @('-bso0', '-bsp0') }
$lines = Get-Content -LiteralPath $BackupListPath
$seenBaseNames = @{}
$processed = 0; $skipped = 0; $failed = 0; $planned = 0
$securityErrorCount = 0 # 有条目"安全描述符里有读不到的对象"
$securityFailed = 0 # 有条目"安全描述符完全没存下来"
$failures = @()
$freeSpaceGB = Get-BaknretFreeSpaceGB -Path $BackupDir
if ($freeSpaceGB -ge 0) {
Write-Log ("备份目录所在卷剩余空间:{0} GB" -f $freeSpaceGB)
if ($freeSpaceGB -lt $script:Config.MinFreeSpaceGB) {
Write-Log ("剩余空间低于阈值 {0} GB,大条目可能失败" -f $script:Config.MinFreeSpaceGB) -Level WARN
}
}
function Test-ItemSelected {
param([string]$DisplayPath, [string]$BaseName)
if ($Only.Count -gt 0) {
$matched = $false
foreach ($pattern in $Only) {
if ($DisplayPath -like $pattern -or $BaseName -like $pattern) { $matched = $true; break }
}
if (-not $matched) { return $false }
}
foreach ($pattern in $Skip) {
if ($DisplayPath -like $pattern -or $BaseName -like $pattern) { return $false }
}
return $true
}
function New-ItemRecord {
param([string]$BaseName, [string]$Source, [string]$ResolvedSource, [string]$Phase)
return [ordered]@{
baseName = $BaseName
source = $Source
resolvedSource = $ResolvedSource
roots = @()
layouts = @()
catalog = $null
archive = $null
action = $null
reason = $null
phase = $Phase
attemptedAt = (Get-Date).ToString('o')
finishedAt = $null
durationSec = $null
exitCode = $null
verified = $false
warnings = $false
attemptWarnings = $false
encrypted = $false
security = $null
sourceFiles = $null
sourceBytes = $null
archiveBytes = $null
sha256 = $null
lastSuccessAt = $null
successCount = 0
failCount = 0
}
}
function Save-ItemRecord {
param($Record, [string]$Action, [string]$Reason, [bool]$ArchiveWarnings = $false)
$previous = $null
if ($manifest.items.Contains($Record.baseName)) { $previous = $manifest.items[$Record.baseName] }
$Record.action = $Action
$Record.reason = $Reason
$Record.finishedAt = (Get-Date).ToString('o')
# warnings 描述的是"当前在位的归档",不是"这次尝试"。
# 只有真正换掉了归档才更新它;否则沿用上一条记录,
# 否则"因为不完整而保留旧归档"之后,下一次就失去保护了。
if ($Action -eq 'backed-up') {
$Record.warnings = $ArchiveWarnings
} elseif ($previous -and ($previous.PSObject.Properties.Name -contains 'warnings')) {
$Record.warnings = [bool]$previous.warnings
}
# security 描述的是"当前在位的归档"的旁挂文件,和 warnings 同理:
# 只有真的换了归档才更新它,否则跳过的那次会把已有记录清成 $null。
if ($Action -ne 'backed-up' -and $previous -and ($previous.PSObject.Properties.Name -contains 'security')) {
$Record.security = $previous.security
}
if ($previous) {
if ($previous.PSObject.Properties.Name -contains 'lastSuccessAt') { $Record.lastSuccessAt = $previous.lastSuccessAt }
if ($previous.PSObject.Properties.Name -contains 'successCount') { $Record.successCount = [int]$previous.successCount }
if ($previous.PSObject.Properties.Name -contains 'failCount') { $Record.failCount = [int]$previous.failCount }
}
if ($Action -eq 'backed-up') {
$Record.lastSuccessAt = $Record.finishedAt
$Record.successCount = [int]$Record.successCount + 1
} elseif ($Action -eq 'failed') {
$Record.failCount = [int]$Record.failCount + 1
}
$manifest.items[$Record.baseName] = $Record
return $Record
}
# 压缩 + 校验 + 原子替换;返回 @{ Ok; ExitCode; Warnings; Reason }
#
# 归档内容由调用方决定:它已经用 New-BaknretArchiveStaging 把每个归档项按"归档内的名字"
# 挂进了 $StagingRoot(目录走 junction、文件走硬链接/复制),所以这里只做三件事:
# 1. 以暂存目录为工作目录调用压缩工具,把项名加进去;
# 2. 用 7z t 校验,并核对多 Slot 条目的每个顶层名字都真的在包里;
# 3. 有警告时按保护策略决定是否原子替换。
function Invoke-BackupItem {
param(
[Parameter(Mandatory = $true)][array]$SourceItems,
[Parameter(Mandatory = $true)][string]$StagingRoot,
[Parameter(Mandatory = $true)][string]$FinalPath,
[string[]]$ExcludePatterns = @(),
[switch]$UseEncryption,
[switch]$ProtectPrevious,
[switch]$AcceptWarnings
)
$tempPath = "$FinalPath.tmp$($tool.Extension)"
if (Test-Path -LiteralPath $tempPath) { Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue }
$warnings = $false
$lastExitCode = 0
$itemNames = @($SourceItems | ForEach-Object { [string]$_.ArchivePath })
$realPaths = @($SourceItems | ForEach-Object { [string]$_.RealPath })
try {
if ($SourceItems.Count -eq 0) {
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '没有可打包的源目录' }
}
if ($tool.Name -eq '7z') {
$optimized = Get-Optimized7zArgument -SourcePath $realPaths -Level $script:Config.CompressionLevel
$argument = @($optimized.Argument) + $toolQuietArgument + @($ExcludePatterns)
if ($UseEncryption) {
if (-not $password) {
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令(见 README「加密」)' }
}
$argument += "-p$password"
if ($script:Config.Encryption.EncryptHeaders) { $argument += '-mhe=on' }
}
$argument += $tempPath
$argument += $itemNames
$exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $StagingRoot
$lastExitCode = $exitCode
# 7z: 0 成功;1 警告(有文件读不到或跳过);2 及以上为失败
if ($exitCode -ne 0 -and $exitCode -ne 1) {
return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $warnings; Reason = "压缩工具退出码 $exitCode" }
}
if ($exitCode -eq 1) { $warnings = $true }
}
elseif ($tool.Name -eq 'RAR') {
$argument = @('a', '-m5', '-idp', '-idn') + $toolQuietArgument + @($ExcludePatterns)
if ($UseEncryption) {
if (-not $password) {
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令' }
}
$argument += "-p$password"
}
$argument += $tempPath
$argument += $itemNames
$exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $StagingRoot
$lastExitCode = $exitCode
if ($exitCode -ne 0) {
return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $warnings; Reason = "压缩工具退出码 $exitCode" }
}
}
else {
if ($UseEncryption) {
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '内置 ZIP 不支持加密,请改用 7z 或去掉加密' }
}
# Compress-Archive 没有排除开关,也没有加密;这是降级路径,只保证内容完整。
# 暂存目录里的名字就是归档内的名字,所以布局与 7z 分支一致。
$fullPaths = @($SourceItems | ForEach-Object { Join-Path $StagingRoot $_.ArchivePath })
Compress-Archive -Path $fullPaths -DestinationPath $tempPath -CompressionLevel Optimal -Force
}
if (-not (Test-Path -LiteralPath $tempPath)) {
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '压缩结束但没有生成临时归档' }
}
# 校验:确认归档可读且内容 CRC 正确
if ($script:Config.VerifyArchive -and $tool.Name -eq '7z') {
$verifyArgument = @('t', '-bso0', '-bsp0')
if ($UseEncryption -and $password) { $verifyArgument += "-p$password" }
$verifyArgument += $tempPath
$verifyCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $verifyArgument -WorkingDirectory $StagingRoot
if ($verifyCode -ne 0) {
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
return [pscustomobject]@{ Ok = $false; ExitCode = $verifyCode; Warnings = $false; Reason = "归档校验失败(7z t 退出码 $verifyCode),已丢弃临时文件" }
}
Write-Log '归档校验通过(7z t)' -Level DEBUG
# 多个 Slot / 追加项时确认每一个顶层名字都真的进了归档:7z 的"警告"可能只体现在某一部分上
if ($SourceItems.Count -gt 1) {
$listed = @(Get-ArchiveTopLevelNames -ArchivePath $tempPath -SevenZip $tool.Command -Password $(if ($UseEncryption) { $password } else { $null }))
if ($listed.Count -gt 0) {
$expected = @($SourceItems | ForEach-Object { [string]$_.TopName } | Select-Object -Unique)
$absent = @($expected | Where-Object { $_ -notin $listed })
if ($absent.Count -gt 0) {
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
return [pscustomobject]@{ Ok = $false; ExitCode = $lastExitCode; Warnings = $true; Reason = ("归档缺少这些顶层条目:{0}(归档内实际有:{1})" -f ($absent -join '、'), ($listed -join '、')) }
}
}
}
}
# 关键保护:压缩工具报了警告(通常是有文件被占用读不到)时,
# 新归档是**不完整**的。用不完整归档覆盖已有的完整归档 = 静默丢数据。
# 实测:Edge 运行时备份,118 个文件读不到,其中包含 Login Data(密码)、
# Cookies、History、Web Data —— 恰恰是最不可再生的那部分。
if ($warnings -and $ProtectPrevious -and -not $AcceptWarnings) {
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
return [pscustomobject]@{
Ok = $false
ExitCode = $lastExitCode
Warnings = $true
Reason = '压缩工具报告有文件被占用而读不到,新归档不完整。为避免覆盖现有的完整归档已保留旧归档;请关闭占用该目录的程序后重跑,或确认可以接受后用 -AcceptWarnings 强制覆盖'
}
}
Move-BaknretArchiveIntoPlace -TempPath $tempPath -DestinationPath $FinalPath
return [pscustomobject]@{ Ok = $true; ExitCode = 0; Warnings = $warnings; Reason = $null }
} catch {
if (Test-Path -LiteralPath $tempPath) {
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
}
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = "$_" }
}
}
# ============================================================================
# 备份前空间预估(只读,不写任何东西)
# ============================================================================
# 只做一件事:动手之前告诉用户"这次大概要写多少、盘够不够"。
# 不做更复杂的占用控制 —— 真正拦住某个条目的是主循环里的逐条目守卫。
#
# 模型(按清单顺序模拟一遍):
# * 每个要重打的条目会先写一份**临时**归档,这时旧归档还在,所以那一刻占用的
# 是"当前累计净增量 + 本次预估";
# * 原子替换之后,本次净增量 = 预估 - 现有归档大小(换成更小的归档会把空间还回来)。
# 于是:峰值新增 = max_i( 第 i 项之前的累计净增量 + 第 i 项的预估大小 )。
$spacePlan = @()
$spaceSkipped = 0
$spaceNoSource = 0
foreach ($planLine in $lines) {
$planItem = ConvertFrom-BackupListLine -Line $planLine
if (-not $planItem) { continue }
$planDisplayPath = $planItem.Path
$planResolved = Resolve-BackupEntry -Entry $planItem -CatalogPath $catalogPath -MaxDepth $script:Config.CatalogMaxDepth
if (-not $planResolved.BaseName) { continue }
if (-not (Test-ItemSelected -DisplayPath $planDisplayPath -BaseName $planResolved.BaseName)) { continue }
if ($planResolved.Direction -eq 'restore') { continue }
if ($planResolved.Blocking) { continue }
$planItems = @($planResolved.Items | Where-Object { Test-Path -LiteralPath $_.RealPath })
if ($planItems.Count -eq 0) { $spaceNoSource++; continue }
$planSourceBytes = [int64]0
$planSourceFiles = 0
$planLatest = $null
foreach ($planSource in $planItems) {
$planSummary = Get-FolderSummary -FolderPath $planSource.RealPath
$planSourceBytes += [int64]$planSummary.TotalSize
$planSourceFiles += [int]$planSummary.FileCount
if ($planSummary.LatestModifiedTime -and (-not $planLatest -or $planSummary.LatestModifiedTime -gt $planLatest)) {
$planLatest = $planSummary.LatestModifiedTime
}
}
$planArchiveName = $planResolved.BaseName + $tool.Extension
$planArchivePath = Join-Path $BackupDir $planArchiveName
$planExistingItem = if (Test-Path -LiteralPath $planArchivePath) { Get-Item -LiteralPath $planArchivePath } else { $null }
$planExistingBytes = if ($planExistingItem) { [int64]$planExistingItem.Length } else { [int64]0 }
# 与主循环同一套判断:源没更新就不会重打
if (-not $Force -and $planExistingItem -and $planLatest -and $planLatest -le $planExistingItem.LastWriteTime) {
$spaceSkipped++
continue
}
$planEstimate = if ($planExistingBytes -gt 0) {
[int64][math]::Min([double]$planSourceBytes, [double]$planExistingBytes * 1.3)
} else {
# 没有历史归档可比时按"完全不压缩"的悲观值估,宁可报多不报少
$planSourceBytes
}
$spacePlan += [pscustomobject]@{
Name = $planResolved.BaseName
Source = $planDisplayPath
Files = $planSourceFiles
SourceBytes = $planSourceBytes
Existing = $planExistingBytes
Estimate = $planEstimate
}
}
$freeNowGB = Get-BaknretFreeSpaceGB -Path $BackupDir
if ($spacePlan.Count -eq 0) {
Write-Log '备份前空间预估:本次没有需要重打的条目(源未更新或源不存在),不会写入新归档' -Level INFO
} else {
$spacePeak = [double]0
$spaceCumulative = [double]0
foreach ($plan in $spacePlan) {
$spacePeak = [math]::Max($spacePeak, $spaceCumulative + $plan.Estimate)
$spaceCumulative += ($plan.Estimate - $plan.Existing)
}
$peakGB = $spacePeak / 1GB
$netGB = $spaceCumulative / 1GB
$estimateGB = ((($spacePlan | Measure-Object -Property Estimate -Sum).Sum)) / 1GB
$existingGB = ((($spacePlan | Measure-Object -Property Existing -Sum).Sum)) / 1GB
Write-Log '==== 备份前空间预估(只读)====' -Level INFO
Write-Log (" 目标卷可用空间:{0} GB" -f $freeNowGB)
Write-Log (" 本次要重打 {0} 个条目(另有 {1} 个源未更新会跳过、{2} 个源不存在)" -f $spacePlan.Count, $spaceSkipped, $spaceNoSource)
Write-Log (" 新归档合计约 {0} GB;其中会替换掉的旧归档 {1} GB" -f [math]::Round($estimateGB, 2), [math]::Round($existingGB, 2))
foreach ($plan in ($spacePlan | Sort-Object Estimate -Descending | Select-Object -First 15)) {
Write-Log (" - {0,-22} 源 {1,8:N1} MB / {2,6} 文件 现有 {3,7:N1} MB 预估 {4,7:N1} MB" -f `
$plan.Name, ($plan.SourceBytes / 1MB), $plan.Files, ($plan.Existing / 1MB), ($plan.Estimate / 1MB))
}
if ($spacePlan.Count -gt 15) {
Write-Log (" …… 另有 {0} 个条目未逐条列出" -f ($spacePlan.Count - 15))
}
Write-Log (" 预计峰值新增占用:{0} GB(全程净增量 {1} GB)" -f [math]::Round($peakGB, 2), [math]::Round($netGB, 2))
if ($freeNowGB -lt 0) {
Write-Log ' 结论:读不到目标卷可用空间,请自行确认是否够用' -Level WARN
} elseif ($peakGB -le $freeNowGB) {
Write-Log (" 结论:空间足够(预计用 {0} GB / 可用 {1} GB)" -f [math]::Round($peakGB, 2), $freeNowGB) -Level INFO
} else {
Write-Log (" 结论:空间可能不够!预计需要 {0} GB,可用只有 {1} GB,差 {2} GB" -f `
[math]::Round($peakGB, 2), $freeNowGB, [math]::Round($peakGB - $freeNowGB, 2)) -Level WARN
Write-Log ' 仍会继续执行:真正放不下的条目会被逐条目守卫跳过。建议先腾空间,或用 -Only / -Skip 分批备份。' -Level WARN
}
Write-Log '============================' -Level INFO
}
# ============================================================================
# 主流程
# ============================================================================
foreach ($line in $lines) {
$item = ConvertFrom-BackupListLine -Line $line
if (-not $item) { continue }
$displayPath = $item.Path
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catalogPath -MaxDepth $script:Config.CatalogMaxDepth
if (-not $resolved.BaseName) {
$record = New-ItemRecord -BaseName ('raw:' + $displayPath) -Source $displayPath -ResolvedSource $displayPath -Phase 'parse'
Save-ItemRecord -Record $record -Action 'failed' -Reason '无法生成归档名' | Out-Null
$failed++; $failures += $displayPath
continue
}
$baseName = $resolved.BaseName
$sourcePath = [Environment]::ExpandEnvironmentVariables($displayPath)
if (-not (Test-ItemSelected -DisplayPath $displayPath -BaseName $baseName)) {
Write-Log "跳过(未选中): $displayPath" -Level DEBUG
continue
}
# 归档名登记必须在方向过滤**之前**:`-`(仅恢复)的条目不会产生归档,
# 但它对应的归档是有主的,不能被下游的孤儿审计当成没人要的孤儿。
# 备份列表里写重了会生成两个同名归档、互相覆盖 —— 直接报错,不猜。
if ($seenBaseNames.ContainsKey($baseName)) {
$reason = "归档名 '$baseName' 与清单中的 '$($seenBaseNames[$baseName])' 重复(由 '$displayPath' 生成),两者会互相覆盖"
Write-Log "失败: $displayPath,$reason" -Level ERROR
$record = New-ItemRecord -BaseName $baseName -Source $displayPath -ResolvedSource $sourcePath -Phase 'backup'
Save-ItemRecord -Record $record -Action 'failed' -Reason $reason | Out-Null
$failed++; $failures += $displayPath
continue
}
$seenBaseNames[$baseName] = $displayPath
if ($resolved.Direction -eq 'restore') {
Write-Log "跳过(行首 -,仅恢复): $displayPath" -Level INFO
continue
}
$record = New-ItemRecord -BaseName $baseName -Source $displayPath -ResolvedSource $sourcePath -Phase 'backup'
$record.archive = $baseName + $tool.Extension
if ($resolved.CatalogEntry) { $record.catalog = $resolved.CatalogEntry.Path }
$finalPath = Join-Path $BackupDir $record.archive
# root= 是历史标记:包内的一层目录现在由名录里的 Slot 决定,这里只告警不静默忽略。
if (@($item.Flags | Where-Object { $_ -like 'root=*' }).Count -gt 0) {
Write-Log "警告: $displayPath 使用了 root= 标记。归档内的一层目录现在由 Slot 决定,该标记已废弃,本次忽略" -Level WARN
}
# 归档内路径冲突:明确失败,绝不把两块内容静默搅进同一棵树
if ($resolved.Blocking) {
Write-Log "失败: $displayPath,$($resolved.Blocking)" -Level ERROR
Save-ItemRecord -Record $record -Action 'failed' -Reason $resolved.Blocking | Out-Null
$failed++; $failures += $displayPath
continue
}
# 动手之前先把"这条会打包哪些目录、归档里叫什么、排除了什么、为什么"讲清楚
$planListExcludes = @()
$planCatalogExcludes = @()
if ($resolved.HasExcludeOverride) {
$planListExcludes = @($resolved.ExcludePatterns)
} else {
$planCatalogExcludes = @($resolved.Items | ForEach-Object { @($_.Exclude) } | Where-Object { $_ } | Select-Object -Unique)
}
Write-BackupEntryPlan -Resolved $resolved -DisplayPath $displayPath `
-ListExcludes $planListExcludes -CatalogExcludes $planCatalogExcludes `
-ConfigExcludes @($script:Config.DefaultExcludes) -Comment $item.Comment
# Items 为空 = 解析不出任何归档项(名录里没这个软件名、或路径拆不出末级名)。
# 注意不能用 $resolved.Error 判断:名录里的路径不存在时 Error 有值,
# 但 Items 是给出的(恢复端要靠它把内容还原回原位),备份端由下面的存在性检查统一处理。
if ($resolved.Items.Count -eq 0) {
$reason = if ($resolved.Error) { $resolved.Error } else { '解析不出任何源路径' }
Write-Log "跳过: $displayPath,$reason" -Level WARN
Save-ItemRecord -Record $record -Action 'missing-source' -Reason $reason | Out-Null
$skipped++
continue
}
# 源存在性检查必须在 Get-FolderSummary / Get-Item 之前:
# 两者对不存在的路径要么抛异常、要么返回会误导判断的空摘要。
$missingItems = @($resolved.Items | Where-Object { -not (Test-Path -LiteralPath $_.RealPath) })
if ($missingItems.Count -ge $resolved.Items.Count) {
$missingText = @($missingItems | ForEach-Object { $_.RealPath }) -join ';'
Write-Log "跳过: $displayPath,源路径不存在" -Level WARN
Save-ItemRecord -Record $record -Action 'missing-source' -Reason ('源路径不存在:' + $missingText) | Out-Null
$skipped++
continue
}
if ($missingItems.Count -gt 0) {
Write-Log ("警告: {0} 有 {1} 个归档项的源路径不存在,本次只打包存在的部分:{2}" -f `
$displayPath, $missingItems.Count, (@($missingItems | ForEach-Object { $_.RealPath }) -join ';')) -Level WARN
}
# 归档里只放真实存在的源
$liveItems = @($resolved.Items | Where-Object { Test-Path -LiteralPath $_.RealPath })
# 归档内的顶层条目名 = 真实存在的归档项在包内的第一层名字(Slot 名 / 源目录名)。
# 这里记录可核对的事实,备份成功后还会用 Get-ArchiveTopLevelNames 与归档内容对账。
$record.roots = @($liveItems | ForEach-Object { $_.TopName } | Select-Object -Unique)
# 恢复端要知道每个项在归档里是目录还是文件:记在 manifest 里,
# 这样目标机器上目标还不存在(全新恢复)时也判断得出来。
$record.layouts = @($liveItems | ForEach-Object {
[ordered]@{
name = $_.ArchivePath
kind = $(if ($_.IsFile) { 'file' } else { 'dir' })
}
})
$primarySource = $liveItems[0].RealPath
if ([string]::IsNullOrWhiteSpace($primarySource)) {
Write-Log "跳过: $displayPath,无法确定主源路径" -Level WARN
Save-ItemRecord -Record $record -Action 'invalid-path' -Reason '归档项没有可用路径' | Out-Null
$skipped++
continue
}
$summary = Get-FolderSummary -FolderPath $primarySource
# 从第二个归档项开始累加。刻意不用 `$liveItems[1..($liveItems.Count-1)]`:
# 只有一项时 `1..0` 会退化成 `1,0`,把同一份源数两遍(旧实现的隐蔽 bug)。
for ($index = 1; $index -lt $liveItems.Count; $index++) {
$extra = Get-FolderSummary -FolderPath $liveItems[$index].RealPath
$summary.FileCount += $extra.FileCount
$summary.TotalSize += $extra.TotalSize
if ($extra.LatestModifiedTime -and $extra.LatestModifiedTime -gt $summary.LatestModifiedTime) {
$summary.LatestModifiedTime = $extra.LatestModifiedTime
}
}
$record.sourceFiles = $summary.FileCount
$record.sourceBytes = $summary.TotalSize
$archiveExists = Test-Path -LiteralPath $finalPath
$archiveItem = if ($archiveExists) { Get-Item -LiteralPath $finalPath } else { $null }
Write-Log ("开始备份: {0} -> {1}({2} 个文件,{3} MB)" -f $displayPath, $record.archive, $summary.FileCount, [math]::Round(($summary.TotalSize / 1MB), 2))
# 空目录时 Get-FolderSummary 拿不到任何条目,回退到源自身的修改时间
# (源路径上面已经确认存在,这里的 Get-Item 不会再抛异常)
$sourceLatest = $summary.LatestModifiedTime
if (-not $sourceLatest) {
$sourceLatest = (Get-Item -LiteralPath $primarySource -Force).LastWriteTime
}
if (-not $Force -and $archiveItem -and $sourceLatest -and $sourceLatest -le $archiveItem.LastWriteTime) {
Write-Log "跳过: $displayPath,源目录未更新" -Level INFO
$record.archiveBytes = $archiveItem.Length
Save-ItemRecord -Record $record -Action 'skip-unchanged' -Reason ('源最新修改时间 {0} 不晚于归档时间 {1}' -f $sourceLatest, $archiveItem.LastWriteTime) | Out-Null
$skipped++
continue
}
# 空间守卫:临时归档与正式归档会同时存在,因此按"新归档预估大小"要求剩余空间
$estimatedGB = $summary.TotalSize / 1GB
if ($archiveItem) {
$archiveGB = $archiveItem.Length / 1GB
$estimatedGB = [math]::Min($estimatedGB, $archiveGB * 1.3)
}
$freeSpaceGB = Get-BaknretFreeSpaceGB -Path $BackupDir
if ($freeSpaceGB -ge 0 -and $estimatedGB -gt 0 -and $freeSpaceGB -lt $estimatedGB) {
$reason = ('剩余空间 {0} GB 不足以写入预估 {1} GB 的新归档' -f $freeSpaceGB, [math]::Round($estimatedGB, 2))
Write-Log "失败: $displayPath,$reason" -Level ERROR
Save-ItemRecord -Record $record -Action 'failed' -Reason $reason | Out-Null
$failed++; $failures += $displayPath
continue
}
if ($DryRun) {
Write-Log ("[试运行] 将打包 {0} -> {1}" -f $sourcePath, $finalPath) -Level INFO
$record.reason = '试运行,未执行压缩'
Save-ItemRecord -Record $record -Action 'planned' -Reason '试运行,未执行压缩' | Out-Null
$planned++
continue
}
$useEncryption = $encryptAll -or [bool]$resolved.Encrypt
$record.encrypted = [bool]$useEncryption
$startedAt = Get-Date
$record.attemptedAt = $startedAt.ToString('o')
# 排除参数:条目级 `:-` / `@ Exclude` 覆盖优先,否则用名录里各 Slot 自己的 Exclude;
# 再叠上 BackupConfig.psd1 的 DefaultExcludes。
# 模式先用 `<顶层名>\` 前缀分配到对应归档项上(`Scoop :- GlobalPersist\steam`),
# 再逐项翻译成 7z 的 -x! / -xr!,最后去重合并成一次调用的参数。
$patternSource = if ($resolved.HasExcludeOverride) {
@($resolved.ExcludePatterns)
} else {
@($resolved.Items | ForEach-Object { @($_.Exclude) } | Where-Object { $_ } | Select-Object -Unique)
}
$allPatterns = @($script:Config.DefaultExcludes) + $patternSource
$scopeMap = Split-BaknretPatternScope -Items $liveItems -Patterns $allPatterns
$excludeLists = @()
$excludeError = $null
for ($index = 0; $index -lt $liveItems.Count; $index++) {
$expanded = Get-BaknretExcludeArgument -Item $liveItems[$index] -Patterns @($scopeMap[$index])
if ($expanded.Error) { $excludeError = $expanded.Error }
$excludeLists += , @($expanded.Arguments)
}
$effectiveExcludes = @(Merge-BaknretExcludeArgument -ArgumentLists $excludeLists)
if ($excludeError) {
Write-Log "失败: $displayPath,$excludeError" -Level ERROR
Save-ItemRecord -Record $record -Action 'failed' -Reason $excludeError | Out-Null
$failed++; $failures += $displayPath
continue
}
# 只有在"现有归档是完整的"时才值得保护它。没有 manifest 记录
# (本次重构之前留下的归档)时按完整处理——宁可保守。
$protectPrevious = [bool]$archiveExists
if ($archiveExists -and $manifest.items.Contains($baseName)) {
$previousRecord = $manifest.items[$baseName]
if (($previousRecord.PSObject.Properties.Name -contains 'warnings') -and $previousRecord.warnings) {
$protectPrevious = $false
}
}
# 归档内的一层目录名由 Slot / 追加项的归档内路径决定,所以先把它们以正确的名字
# 挂进暂存目录(junction / 硬链接),再让压缩工具以暂存目录为工作目录打包。
$stagingRoot = $null
try {
$stagingRoot = New-BaknretArchiveStaging -Items $liveItems
$result = Invoke-BackupItem -SourceItems $liveItems -StagingRoot $stagingRoot `
-FinalPath $finalPath -ExcludePatterns $effectiveExcludes -UseEncryption:$useEncryption `
-ProtectPrevious:$protectPrevious -AcceptWarnings:$AcceptWarnings
} catch {
$result = [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = "准备归档内容失败:$_" }
} finally {
Remove-BaknretArchiveStaging -Root $stagingRoot
}
$record.exitCode = $result.ExitCode
$record.attemptWarnings = [bool]$result.Warnings
$record.verified = [bool]$result.Ok
$record.durationSec = [math]::Round(((Get-Date) - $startedAt).TotalSeconds, 1)
if (-not $result.Ok) {
Write-Log "备份失败: $displayPath,$($result.Reason)" -Level ERROR
Save-ItemRecord -Record $record -Action 'failed' -Reason $result.Reason | Out-Null
$failed++; $failures += $displayPath
continue
}
$written = Get-Item -LiteralPath $finalPath
$record.archiveBytes = $written.Length
if ($result.Warnings) {
Write-Log "备份成功(压缩工具报告了警告,可能有文件被占用而没打进归档): $displayPath" -Level WARN
Write-Log ' 该归档在 manifest 里标记为 warnings=true;如果以后现有归档是完整的,会拒绝被它覆盖' -Level WARN
} else {
Write-Log "备份成功: $baseName" -Level INFO
}
# ------------------------------------------------------------------
# 安全描述符(属主 / ACL)写进旁挂文件 <归档名>.acl.json
# ------------------------------------------------------------------
# 归档格式装不下它(7z 的 -sni 官方说明是"只能写进 WIM 归档"),所以放在归档旁边,
# 和归档一样先写 .tmp 再原子替换。属主必须一起存:C:\ProgramData 的 ACL 里有
# (A;OICIIO;GA;;;CO) CREATOR OWNER + inherit-only + GENERIC_ALL
# 而 CREATOR OWNER 是访问检查时才替换的占位符 —— 替换成"被检查对象的属主"。
# 只回放 ACE 文本、不恢复属主,等于把"谁创建的东西谁有全权"里的"谁"换成跑脚本的账户。
$securityMode = [string]$script:Config.Security.Mode
$securityFatal = $false
if ($securityMode -and ($securityMode -ne 'Off')) {
$sidecarName = "$baseName.acl.json"
$sidecarPath = Join-Path $BackupDir $sidecarName
try {
$capture = Get-BaknretSecurityRecords -Items $liveItems -ScopeMap $scopeMap -Mode $securityMode `
-IncludeSacl:([bool]$script:Config.Security.IncludeSacl)
Save-BaknretSecuritySidecar -Path $sidecarPath -Records $capture.Records -Mode $securityMode `
-IncludeSacl:([bool]$script:Config.Security.IncludeSacl) `
-Errors $capture.Errors -Scanned $capture.Scanned | Out-Null
$record.security = [ordered]@{
file = $sidecarName
mode = $securityMode
objects = $capture.Kept
scanned = $capture.Scanned
errors = $capture.Errors
capturedAt = (Get-Date).ToString('o')
}
Write-Log ("安全描述符:{0} 个对象写进 {1}(扫描 {2} 个,读不到 {3} 个)" -f `
$capture.Kept, $sidecarName, $capture.Scanned, $capture.Errors) -Level INFO
if ($capture.Errors -gt 0) {
$securityErrorCount++
$unreadable = @($capture.Records | Where-Object { $_.e } | Select-Object -First 3 -ExpandProperty p)
Write-Log (" {0} 个对象的安全描述符读不到(恢复后它们的属主/ACL 会是新建对象的默认值),例如:{1}" -f `
$capture.Errors, ($unreadable -join '、')) -Level WARN
}
} catch {
$securityFailed++
Write-Log "安全描述符采集/写盘失败:$displayPath —— $_" -Level WARN
$record.security = [ordered]@{ file = $sidecarName; error = "$_" }
if ([bool]$script:Config.Security.FailOnError) { $securityFatal = $true }
}
if ($securityFatal) {
Write-Log "失败: $displayPath,归档已替换,但安全描述符没能存下来(Security.FailOnError = \$true)" -Level ERROR
Save-ItemRecord -Record $record -Action 'failed' -Reason '归档已替换,但安全描述符没能存下来' | Out-Null
$failed++; $failures += $displayPath
continue
}
}
if ($Hash -or $script:Config.ComputeHash) {
$record.sha256 = (Get-FileHash -LiteralPath $finalPath -Algorithm SHA256).Hash
Write-Log "SHA256: $($record.sha256)" -Level DEBUG
}
if ($Snapshot -or $script:Config.Snapshot.Enabled) {
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$target = Join-Path (Join-Path $snapshotDir $stamp) $record.archive
$targetDir = Split-Path -Parent $target
if (-not (Test-Path -LiteralPath $targetDir)) { New-Item -ItemType Directory -Path $targetDir -Force | Out-Null }
Copy-Item -LiteralPath $finalPath -Destination $target -Force
Write-Log "已留存快照: $target" -Level INFO
}
Save-ItemRecord -Record $record -Action 'backed-up' -Reason $null -ArchiveWarnings $result.Warnings | Out-Null
$processed++
}
# ============================================================================
# 收尾
# ============================================================================
if ($DryRun) {
Write-Log '试运行:manifest 与归档都不会被写入' -Level INFO
} else {
# manifest 里写了 archive 的记录,磁盘上就必须真有那个文件
$clearedArchiveFields = Sync-BaknretManifestArchive -Manifest $manifest -BackupDir $BackupDir
if ($clearedArchiveFields.Count -gt 0) {
Write-Log ("已清空 {0} 条记录里指向不存在归档的 archive 字段:{1}" -f $clearedArchiveFields.Count, ($clearedArchiveFields -join '、')) -Level WARN
}
Write-BaknretManifest -Path $manifestPath -Manifest $manifest | Out-Null
Write-Log "manifest 已更新:$manifestPath" -Level DEBUG
}
# 孤儿归档审计:磁盘上有、但**当前清单里任何条目都不指向**的归档。
# Restore.ps1 是按清单条目去找归档的,所以孤儿是**恢复不到**的 —— 必须显式点名,
# 免得下次清理时把还有用的归档当垃圾删掉(重构前那个 2.8 GB 的归档就是这么成孤儿的)。
#
# 判据只用清单,**不能用 manifest**:manifest 会一直留着历史条目,
# 于是"从清单里删掉某个条目(或把它合并进另一个条目)"留下的归档会被历史记录遮住,
# 审计就永远不会报——那正是最需要报出来的情况。
# $seenBaseNames 在方向过滤之前就登记,所以"行首 + / -"的条目也算有主。
# 只在整表运行时做:带 -Only/-Skip 时未选中的条目本来就不在 $seenBaseNames 里,
# 那种情况下报出来的全是假孤儿。
if (-not $DryRun -and $Only.Count -eq 0 -and $Skip.Count -eq 0) {
$known = @{}
foreach ($key in $seenBaseNames.Keys) { $known[$key] = $true }
$orphanArchives = @(Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Extension.ToLower() -in @('.7z', '.rar', '.zip', '.tar') -and -not $known.ContainsKey($_.BaseName) })
if ($orphanArchives.Count -gt 0) {
Write-Log ("发现 {0} 个孤儿归档(当前清单里没有任何条目指向,恢复不到,注意别误删):" -f $orphanArchives.Count) -Level WARN
foreach ($orphan in $orphanArchives) {
$inManifest = $manifest.items.Contains($orphan.BaseName)
Write-Log (" - {0}({1:N1} MB,{2}){3}" -f $orphan.Name, ($orphan.Length / 1MB), $orphan.LastWriteTime, $(if ($inManifest) { ';manifest 里还留着它的历史记录,但清单里已经没有了' } else { '' })) -Level WARN
}
} else {
Write-Log '孤儿归档审计:没有发现(所有归档都有清单条目指向)' -Level DEBUG
}
}
if ($failures.Count -gt 0) {
Write-Log '失败条目:' -Level ERROR
foreach ($failure in $failures) { Write-Log " - $failure" -Level ERROR }
}
if ($securityFailed -gt 0) {
Write-Log ("有 {0} 个条目的安全描述符完全没能存下来(manifest 的 security.error 里有原文)" -f $securityFailed) -Level WARN
}
if ($securityErrorCount -gt 0) {
Write-Log ("有 {0} 个条目存在'读不到安全描述符'的对象;恢复后这些对象的属主/ACL 是新建对象的默认值,可查 manifest 的 security.errors" -f $securityErrorCount) -Level WARN
}
$summaryText = "备份完成。成功: $processed, 跳过: $skipped, 失败: $failed"
if ($DryRun) { $summaryText += ", 试运行计划: $planned" }
Write-Log $summaryText -Level INFO
$logPath = Get-BaknretLogPath
if ($logPath) { Write-Log "日志已写入:$logPath" -Level INFO }
Stop-BaknretLog
if ($failed -gt 0) { exit 1 }
exit 0
+7 -7
View File
@@ -1,4 +1,4 @@
<# <#
BakNRet 配置文件。 BakNRet 配置文件。
优先级:命令行参数 > 本文件 > 代码内置默认值。 优先级:命令行参数 > 本文件 > 代码内置默认值。
@@ -17,11 +17,6 @@
# 软件名录("软件名 -> 目录"映射表)。相对路径按本配置所在目录解析。 # 软件名录("软件名 -> 目录"映射表)。相对路径按本配置所在目录解析。
# BackupList.txt 里写软件名时靠它换成真实目录,归档名也取软件名。 # BackupList.txt 里写软件名时靠它换成真实目录,归档名也取软件名。
SoftwareCatalog = 'SoftwareCatalog.psd1' SoftwareCatalog = 'SoftwareCatalog.psd1'
# 名录里写的目录不存在时,按 "<名>_*" / "<名>-*" 向下找几层做前缀补全
# (应对 legendary -> legendary_2.0.4 这类带后缀的目录)
CatalogMaxDepth = 5
# 低于这个剩余空间(GB)就告警;真正放不下某个条目时会直接跳过该条目 # 低于这个剩余空间(GB)就告警;真正放不下某个条目时会直接跳过该条目
MinFreeSpaceGB = 5 MinFreeSpaceGB = 5
@@ -54,7 +49,12 @@
# 口令本身按以下优先级获取(见 README「加密」): # 口令本身按以下优先级获取(见 README「加密」):
# 1. -Password 命令行参数 # 1. -Password 命令行参数
# 2. $env:BAKNRET_PASSWORD # 2. $env:BAKNRET_PASSWORD
# 3. PasswordFile 指向的文件首行(文件必须在仓库之外) # 3. PasswordFile 指向的文件首行 (首行即口令)
#
# 出厂默认值就是仓库根的 baknret.key,靠 .gitignore 的 *.key 兜住「不被提交」。
# 这是**取舍**而非疏忽:留在仓库根最省事(口令与配置在一起,搬家不容易丢),代价是
# 「不提交」这件事依赖一个规则文件 —— 谁写了 git add -f、或把整个目录复制到别处再
# 初始化仓库,口令就会跟着走。要更稳就把文件放到仓库外,用下面的 B 或 A。
# 4. 交互式询问(仅交互式会话;计划任务里不会停下来等输入) # 4. 交互式询问(仅交互式会话;计划任务里不会停下来等输入)
# 全都拿不到时该条目明确失败,绝不退化成明文归档。 # 全都拿不到时该条目明确失败,绝不退化成明文归档。
# #
+1 -1
View File
@@ -1,4 +1,4 @@
########### ###########
# BackupList.txt —— 备份清单 # BackupList.txt —— 备份清单
########### ###########
# #
+124
View File
@@ -0,0 +1,124 @@
<#
模块清单。
为什么需要它:把"这个模块对外提供什么"变成一份可读的契约。这里 FunctionsToExport 是
**显式白名单**(官方性能指南明确要求不用通配符;PSUseToExportFieldsInManifest 也是恒开
的规则、关不掉)。名字少写一个,对应函数就不会被导出 —— 这是有意的:宁可导入方报
"找不到命令",也不要静默少一个函数。
CompatiblePSEditions 声明"桌面版与核心版都能导入":本模块兼容 Windows PowerShell 5.1
与 PowerShell 7.x。代价是放弃 PS 4.0 及以下,那是刻意的。
#>
@{
RootModule = 'BakNRet.psm1'
ModuleVersion = '1.0.0'
GUID = '9bc892ed-f852-4468-b1e7-d71a91cf2913'
Author = 'Shuery'
Description = 'BakNRet 公共模块:日志、外部命令调用、清单与名录解析、归档命名与暂存、manifest、安全描述符。兼容 Windows PowerShell 5.1 与 PowerShell 7.x。'
PowerShellVersion = '5.1'
CompatiblePSEditions = @('Desktop', 'Core')
FunctionsToExport = @(
'Invoke-BakNRetCatalogEditor',
'Get-BakNRetCatalogField',
'Set-BakNRetCatalogField',
'Invoke-BakNRetConfigEditor',
'Read-BakNRetLine',
'Get-BakNRetConfigSetting',
'Set-BakNRetConfigSetting',
'Invoke-BakNRetEntryScript',
'Invoke-BakNRetBackupListEditor',
'Set-BakNRetBackupListDirection',
'Save-BakNRetConfigFile',
'Get-BakNRetBackupListRow',
'Set-BakNRetBackupListRow',
'Invoke-BakNRetMenu',
'Update-BakNRetMenuState',
'Format-BakNRetPaddedText',
'Write-BakNRetAt',
'Get-BakNRetKeyName',
'New-BakNRetInputDriver',
'Read-BakNRetKey',
'Get-BakNRetCellWidth',
'Write-BakNRetRunSummary',
'Save-BakNRetItemRecord',
'New-BakNRetItemRecord',
'Find-BakNRet7zExecutable',
'Resolve-BakNRetRootedPath',
'Test-BakNRetItemSelected',
'Enter-BakNRetRunLock',
'Exit-BakNRetRunLock',
'Get-BakNRetRunLockPath',
'Set-BakNRetDebug',
'Start-BakNRetLog',
'Stop-BakNRetLog',
'Get-BakNRetLogPath',
'Write-BakNRetLog',
'Test-BakNRetAdministrator',
'Get-BakNRetFreeSpaceGB',
'ConvertTo-BakNRetNativeArgumentString',
'Invoke-ExternalCommand',
'Resolve-BakNRetCompressionTool',
'Get-Optimized7zArgument',
'Split-BakNRetToken',
'Remove-BakNRetQuote',
'Test-BakNRetMarker',
'ConvertFrom-BakNRetPatternList',
'ConvertFrom-BackupListLine',
'Test-BakNRetLiteralPath',
'Get-BakNRetRegexExclude',
'Get-BakNRetExcludeArgument',
'Split-BakNRetPatternScope',
'Merge-BakNRetExcludeArgument',
'Resolve-BakNRetCatalogPath',
'Get-BakNRetSoftwareCatalog',
'Find-BakNRetChildDirectoryByName',
'Format-BakNRetCatalogName',
'Expand-CatalogPathText',
'Get-BakNRetArchiveTopLevelNames',
'Get-BakNRetArchiveTopName',
'New-BakNRetArchiveItem',
'New-BakNRetJunction',
'Remove-BakNRetJunction',
'New-BakNRetArchiveStaging',
'Remove-BakNRetArchiveStaging',
'Get-BakNRetItemArchiveName',
'Resolve-BakNRetBackupEntry',
'Write-BakNRetBackupEntryPlan',
'Get-BakNRetBackupBaseName',
'Convert-BakNRetBackupFileNameToPath',
'Get-BakNRetFolderSummary',
'Read-BakNRetManifest',
'Write-BakNRetManifest',
'Sync-BakNRetManifestArchive',
'Move-BakNRetArchiveIntoPlace',
'Enable-BakNRetPrivilege',
'ConvertTo-BakNRetWildcardPattern',
'Test-BakNRetPathExcluded',
'Get-BakNRetAceSignatureList',
'Get-BakNRetSecuritySddlWithStale',
'Get-BakNRetSecurityRecord',
'Test-BakNRetSecurityRecordNeeded',
'Get-BakNRetSecurityRecords',
'Write-BakNRetAtomicText',
'Save-BakNRetSecuritySidecar',
'Read-BakNRetSecuritySidecar',
'Convert-BakNRetSidMap',
'Set-BakNRetObjectSecurity',
'Restore-BakNRetSecurity',
'Get-BakNRetConfig',
'Get-BakNRetPassword'
)
CmdletsToExport = @()
VariablesToExport = @()
AliasesToExport = @()
PrivateData = @{
PSData = @{
Tags = @('Backup', 'Restore', '7zip', 'Windows', 'PSEdition_Desktop', 'PSEdition_Core')
LicenseUri = 'https://www.apache.org/licenses/LICENSE-2.0'
Copyright = 'Copyright 2026 Shuery'
}
}
}
+274
View File
@@ -0,0 +1,274 @@
<#
.SYNOPSIS
BakNRet —— 备份 / 恢复脚本的公共功能模块。
.DESCRIPTION
提供日志(控制台 + 落盘)、外部命令调用(可取得真实退出码)、
BackupList.txt 语法解析、归档命名与逆向解析、目录摘要、manifest 读写、
磁盘剩余空间查询等公共能力。
兼容 Windows PowerShell 5.1 与 PowerShell 7.x:
* 不使用 ?? / 三元运算符 / Join-String / -AsHashtable 等 6.0+ 语法;
* 不使用 ProcessStartInfo.ArgumentList(5.1 上不存在),改为自行构造命令行。
模块内出现的备份清单语法(BackupList.txt 每一行):
[+|-] <软件名 或 绝对路径> [修饰符...] [# 说明]
[:: <Absolute\Path>] [:- <模式>[,...]] [:+ <包含项>[,...]]
[:encrypt | :!encrypt] [@ <Key>='<Value>']
标记(必须是独立的空白分隔记号,前后都要有空格):
+ 仅备份,不恢复(Restore.ps1 跳过)
- 仅恢复,不备份(Backup.ps1 跳过)
:: 覆盖 Path,等价于 `@ Path='...'`
:- 排除模式,等价于 `@ Exclude='...'`
:+ 追加包含项(<归档内相对路径>:<宿主机绝对路径>),等价于 `@ Include='...'`
:encrypt 该条目加密(`@ Encrypt='$true'`)
:!encrypt 该条目不加密(`@ Encrypt='$false'`)
@ Key='值' 覆盖 SoftwareCatalog.psd1 里的同名默认字段
兼容的历史写法:`@encrypt` / `@!encrypt` / `@pathname` / `@root=<名>`,
以及用双引号包住路径或模式值。
归档内布局(SoftwareCatalog.psd1 的 Slot 是包内的一层目录):
软件名条目 -> <Slot>\<该 Path 的内容>(Path 是文件时就是名为 <Slot> 的文件)
手写路径 -> <路径末级名>\...(历史布局,不变)
#>
# ----------------------------------------------------------------------------
# 加载器:下面这份点源顺序是**唯一**一份顺序声明。
#
# 每个函数一个文件,文件名 = 函数名;要合回单个 .psm1(发布形态,或做代码签名时需要),
# 跑 tools\Build-BakNRetModule.ps1 —— 它从本文件里读出顺序,所以顺序不需要维护两遍。
# ----------------------------------------------------------------------------
. (Join-Path $PSScriptRoot 'Private\State.ps1')
# ---- 日志 ----
. (Join-Path $PSScriptRoot 'Public\Set-BakNRetDebug.ps1')
. (Join-Path $PSScriptRoot 'Public\Start-BakNRetLog.ps1')
. (Join-Path $PSScriptRoot 'Public\Stop-BakNRetLog.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BakNRetLogPath.ps1')
. (Join-Path $PSScriptRoot 'Public\Write-BakNRetLog.ps1')
# ---- 运行锁 ----
. (Join-Path $PSScriptRoot 'Public\Get-BakNRetRunLockPath.ps1')
. (Join-Path $PSScriptRoot 'Public\Enter-BakNRetRunLock.ps1')
. (Join-Path $PSScriptRoot 'Public\Exit-BakNRetRunLock.ps1')
# ---- 环境 ----
. (Join-Path $PSScriptRoot 'Public\Test-BakNRetAdministrator.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BakNRetFreeSpaceGB.ps1')
# ---- 外部命令 ----
. (Join-Path $PSScriptRoot 'Public\ConvertTo-BakNRetNativeArgumentString.ps1')
. (Join-Path $PSScriptRoot 'Public\Invoke-ExternalCommand.ps1')
. (Join-Path $PSScriptRoot 'Public\Resolve-BakNRetCompressionTool.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-Optimized7zArgument.ps1')
# ---- BackupList.txt 解析 ----
. (Join-Path $PSScriptRoot 'Public\Split-BakNRetToken.ps1')
. (Join-Path $PSScriptRoot 'Public\Remove-BakNRetQuote.ps1')
. (Join-Path $PSScriptRoot 'Public\Test-BakNRetMarker.ps1')
. (Join-Path $PSScriptRoot 'Public\ConvertFrom-BakNRetPatternList.ps1')
. (Join-Path $PSScriptRoot 'Public\ConvertFrom-BackupListLine.ps1')
. (Join-Path $PSScriptRoot 'Public\Test-BakNRetLiteralPath.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BakNRetRegexExclude.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BakNRetExcludeArgument.ps1')
. (Join-Path $PSScriptRoot 'Public\Split-BakNRetPatternScope.ps1')
. (Join-Path $PSScriptRoot 'Public\Merge-BakNRetExcludeArgument.ps1')
# ---- 软件名录(SoftwareCatalog.psd1) ----
. (Join-Path $PSScriptRoot 'Public\Resolve-BakNRetCatalogPath.ps1')
. (Join-Path $PSScriptRoot 'Public\Format-BakNRetCatalogName.ps1')
. (Join-Path $PSScriptRoot 'Private\Test-BakNRetMapKey.ps1')
. (Join-Path $PSScriptRoot 'Private\Get-BakNRetMapValue.ps1')
. (Join-Path $PSScriptRoot 'Private\Get-BakNRetMapKeys.ps1')
. (Join-Path $PSScriptRoot 'Public\Expand-CatalogPathText.ps1')
. (Join-Path $PSScriptRoot 'Private\Import-BakNRetDataFile.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BakNRetSoftwareCatalog.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BakNRetArchiveTopLevelNames.ps1')
. (Join-Path $PSScriptRoot 'Public\Find-BakNRetChildDirectoryByName.ps1')
# ---- 归档命名与路径还原 ----
. (Join-Path $PSScriptRoot 'Public\Get-BakNRetItemArchiveName.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BakNRetArchiveTopName.ps1')
. (Join-Path $PSScriptRoot 'Public\New-BakNRetArchiveItem.ps1')
. (Join-Path $PSScriptRoot 'Public\New-BakNRetJunction.ps1')
. (Join-Path $PSScriptRoot 'Public\Remove-BakNRetJunction.ps1')
. (Join-Path $PSScriptRoot 'Public\New-BakNRetArchiveStaging.ps1')
. (Join-Path $PSScriptRoot 'Public\Remove-BakNRetArchiveStaging.ps1')
. (Join-Path $PSScriptRoot 'Public\Resolve-BakNRetBackupEntry.ps1')
. (Join-Path $PSScriptRoot 'Public\Write-BakNRetBackupEntryPlan.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BakNRetBackupBaseName.ps1')
. (Join-Path $PSScriptRoot 'Public\Convert-BakNRetBackupFileNameToPath.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BakNRetFolderSummary.ps1')
# ---- manifest.json ----
. (Join-Path $PSScriptRoot 'Public\Read-BakNRetManifest.ps1')
. (Join-Path $PSScriptRoot 'Public\Sync-BakNRetManifestArchive.ps1')
. (Join-Path $PSScriptRoot 'Public\Write-BakNRetManifest.ps1')
# ---- 归档原子替换 ----
. (Join-Path $PSScriptRoot 'Public\Move-BakNRetArchiveIntoPlace.ps1')
# ---- 安全描述符(NTFS 属主 / ACL) ----
. (Join-Path $PSScriptRoot 'Public\Enable-BakNRetPrivilege.ps1')
. (Join-Path $PSScriptRoot 'Public\ConvertTo-BakNRetWildcardPattern.ps1')
. (Join-Path $PSScriptRoot 'Public\Test-BakNRetPathExcluded.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BakNRetAceSignatureList.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BakNRetSecuritySddlWithStale.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BakNRetSecurityRecord.ps1')
. (Join-Path $PSScriptRoot 'Public\Test-BakNRetSecurityRecordNeeded.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BakNRetSecurityRecords.ps1')
. (Join-Path $PSScriptRoot 'Public\Write-BakNRetAtomicText.ps1')
. (Join-Path $PSScriptRoot 'Public\Save-BakNRetSecuritySidecar.ps1')
. (Join-Path $PSScriptRoot 'Public\Read-BakNRetSecuritySidecar.ps1')
. (Join-Path $PSScriptRoot 'Public\Convert-BakNRetSidMap.ps1')
. (Join-Path $PSScriptRoot 'Public\Set-BakNRetObjectSecurity.ps1')
. (Join-Path $PSScriptRoot 'Public\Restore-BakNRetSecurity.ps1')
# ---- 配置 ----
. (Join-Path $PSScriptRoot 'Public\Get-BakNRetConfig.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BakNRetPassword.ps1')
. (Join-Path $PSScriptRoot 'Public\Resolve-BakNRetRootedPath.ps1')
. (Join-Path $PSScriptRoot 'Public\Test-BakNRetItemSelected.ps1')
. (Join-Path $PSScriptRoot 'Public\Find-BakNRet7zExecutable.ps1')
. (Join-Path $PSScriptRoot 'Public\New-BakNRetItemRecord.ps1')
. (Join-Path $PSScriptRoot 'Public\Save-BakNRetItemRecord.ps1')
. (Join-Path $PSScriptRoot 'Public\Write-BakNRetRunSummary.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BakNRetCellWidth.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BakNRetKeyName.ps1')
. (Join-Path $PSScriptRoot 'Public\New-BakNRetInputDriver.ps1')
. (Join-Path $PSScriptRoot 'Public\Read-BakNRetKey.ps1')
. (Join-Path $PSScriptRoot 'Public\Format-BakNRetPaddedText.ps1')
. (Join-Path $PSScriptRoot 'Public\Write-BakNRetAt.ps1')
. (Join-Path $PSScriptRoot 'Public\Update-BakNRetMenuState.ps1')
. (Join-Path $PSScriptRoot 'Public\Invoke-BakNRetMenu.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BakNRetBackupListRow.ps1')
. (Join-Path $PSScriptRoot 'Public\Set-BakNRetBackupListRow.ps1')
. (Join-Path $PSScriptRoot 'Public\Save-BakNRetConfigFile.ps1')
. (Join-Path $PSScriptRoot 'Public\Set-BakNRetBackupListDirection.ps1')
. (Join-Path $PSScriptRoot 'Public\Invoke-BakNRetBackupListEditor.ps1')
. (Join-Path $PSScriptRoot 'Public\Invoke-BakNRetEntryScript.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BakNRetConfigSetting.ps1')
. (Join-Path $PSScriptRoot 'Public\Set-BakNRetConfigSetting.ps1')
. (Join-Path $PSScriptRoot 'Public\Read-BakNRetLine.ps1')
. (Join-Path $PSScriptRoot 'Public\Invoke-BakNRetConfigEditor.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BakNRetCatalogField.ps1')
. (Join-Path $PSScriptRoot 'Public\Set-BakNRetCatalogField.ps1')
. (Join-Path $PSScriptRoot 'Public\Invoke-BakNRetCatalogEditor.ps1')
Export-ModuleMember -Function @(
'Invoke-BakNRetCatalogEditor',
'Get-BakNRetCatalogField',
'Set-BakNRetCatalogField',
'Invoke-BakNRetConfigEditor',
'Read-BakNRetLine',
'Get-BakNRetConfigSetting',
'Set-BakNRetConfigSetting',
'Invoke-BakNRetEntryScript',
'Invoke-BakNRetBackupListEditor',
'Set-BakNRetBackupListDirection',
'Save-BakNRetConfigFile',
'Get-BakNRetBackupListRow',
'Set-BakNRetBackupListRow',
'Invoke-BakNRetMenu',
'Update-BakNRetMenuState',
'Format-BakNRetPaddedText',
'Write-BakNRetAt',
'Get-BakNRetKeyName',
'New-BakNRetInputDriver',
'Read-BakNRetKey',
'Get-BakNRetCellWidth',
'Write-BakNRetRunSummary',
'Save-BakNRetItemRecord',
'New-BakNRetItemRecord',
'Find-BakNRet7zExecutable',
'Resolve-BakNRetRootedPath',
'Test-BakNRetItemSelected',
'Enter-BakNRetRunLock',
'Exit-BakNRetRunLock',
'Get-BakNRetRunLockPath',
'Set-BakNRetDebug',
'Start-BakNRetLog',
'Stop-BakNRetLog',
'Get-BakNRetLogPath',
'Write-BakNRetLog',
'Test-BakNRetAdministrator',
'Get-BakNRetFreeSpaceGB',
'ConvertTo-BakNRetNativeArgumentString',
'Invoke-ExternalCommand',
'Resolve-BakNRetCompressionTool',
'Get-Optimized7zArgument',
'Split-BakNRetToken',
'Remove-BakNRetQuote',
'Test-BakNRetMarker',
'ConvertFrom-BakNRetPatternList',
'ConvertFrom-BackupListLine',
'Test-BakNRetLiteralPath',
'Get-BakNRetRegexExclude',
'Get-BakNRetExcludeArgument',
'Split-BakNRetPatternScope',
'Merge-BakNRetExcludeArgument',
'Resolve-BakNRetCatalogPath',
'Get-BakNRetSoftwareCatalog',
'Find-BakNRetChildDirectoryByName',
'Format-BakNRetCatalogName',
'Expand-CatalogPathText',
'Get-BakNRetArchiveTopLevelNames',
'Get-BakNRetArchiveTopName',
'New-BakNRetArchiveItem',
'New-BakNRetJunction',
'Remove-BakNRetJunction',
'New-BakNRetArchiveStaging',
'Remove-BakNRetArchiveStaging',
'Get-BakNRetItemArchiveName',
'Resolve-BakNRetBackupEntry',
'Write-BakNRetBackupEntryPlan',
'Get-BakNRetBackupBaseName',
'Convert-BakNRetBackupFileNameToPath',
'Get-BakNRetFolderSummary',
'Read-BakNRetManifest',
'Write-BakNRetManifest',
'Sync-BakNRetManifestArchive',
'Move-BakNRetArchiveIntoPlace',
'Enable-BakNRetPrivilege',
'ConvertTo-BakNRetWildcardPattern',
'Test-BakNRetPathExcluded',
'Get-BakNRetAceSignatureList',
'Get-BakNRetSecuritySddlWithStale',
'Get-BakNRetSecurityRecord',
'Test-BakNRetSecurityRecordNeeded',
'Get-BakNRetSecurityRecords',
'Write-BakNRetAtomicText',
'Save-BakNRetSecuritySidecar',
'Read-BakNRetSecuritySidecar',
'Convert-BakNRetSidMap',
'Set-BakNRetObjectSecurity',
'Restore-BakNRetSecurity',
'Get-BakNRetConfig',
'Get-BakNRetPassword'
)
+9
View File
@@ -0,0 +1,9 @@
function Get-BakNRetMapKeys {
<# .SYNOPSIS 列出哈希表或 JSON 对象的全部键。 #>
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '',
Justification = '名字里的复数是刻意的:单数形式已经被「只读单个对象」的那个函数占用,为了一条命名规则去重载语义得不偿失。')]
param($Map)
if ($null -eq $Map) { return @() }
if ($Map -is [System.Collections.IDictionary]) { return @($Map.Keys) }
return @($Map.PSObject.Properties.Name)
}
+11
View File
@@ -0,0 +1,11 @@
function Get-BakNRetMapValue {
<# .SYNOPSIS 从哈希表或 JSON 对象里按键取值。 #>
param($Map, [string]$Key)
if ($null -eq $Map) { return $null }
if ($Map -is [System.Collections.IDictionary]) {
if ($Map.Contains($Key)) { return $Map[$Key] }
return $null
}
if (@($Map.PSObject.Properties.Name) -contains $Key) { return $Map.$Key }
return $null
}
@@ -0,0 +1,30 @@
function Import-BakNRetDataFile {
<#
.SYNOPSIS
读取 .psd1 / .json 配置数据。
.DESCRIPTION
先用 Import-PowerShellDataFile(受限语法,不执行任意代码);它对 psd1 里
常见的字符串拼接(`'a,' + 'b'`)会直接报
"Cannot generate a PowerShell object for a ScriptBlock evaluating dynamic expressions",
这种情况下退回 `[scriptblock]::Create(...).Invoke()` 求值。
这个退路是可信的:名录与配置本来就是仓库里的本地文件,跟脚本同级,
而且 Slot 的 Path 里已经允许写 `$( ... )` 子表达式(同样是要执行的)。
#>
param([Parameter(Mandatory = $true)][string]$Path)
if ($Path.ToLower().EndsWith('.json')) {
return (Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop)
}
try {
return Import-PowerShellDataFile -LiteralPath $Path -ErrorAction Stop
}
catch {
$firstLine = ([string]$_.Exception.Message) -split "`r?`n" | Select-Object -First 1
Write-BakNRetLog "psd1 里有 Import-PowerShellDataFile 不接受的表达式($firstLine),改用 PowerShell 求值:$Path" -Level DEBUG
$raw = [System.IO.File]::ReadAllText($Path)
return [scriptblock]::Create($raw).Invoke()
}
}
+22
View File
@@ -0,0 +1,22 @@
# 模块级状态。
#
# 为什么集中在一个文件里:这些变量原先散在 3000 行里,"谁在什么时候改了它"没有答案,
# 而重构时任何一次搬动都可能踩到看不见的耦合。集中之后至少它的全貌是可见的。
#
# 注意 `$script:` 在这里指的是**模块**的脚本作用域:模块内 dot-source 的文件共享同一个
# 模块作用域(实测确认过),所以"加载器按顺序点源这些文件"与"点源一个大文件"等价。
$script:LogConfig = @{
TimeFormat = 'yyyy-MM-dd HH:mm:ss'
EnableDebug = $false
FilePath = $null
}
$script:LogEncoding = [System.Text.UTF8Encoding]::new($false)
$script:CatalogCache = @{}
$script:CatalogExpressionCache = @{}
$script:BakNRetPrivilegeState = @{}
+7
View File
@@ -0,0 +1,7 @@
function Test-BakNRetMapKey {
<# .SYNOPSIS 判断一个数据对象(哈希表或 JSON 对象)里有没有某个键。 #>
param($Map, [string]$Key)
if ($null -eq $Map) { return $false }
if ($Map -is [System.Collections.IDictionary]) { return $Map.Contains($Key) }
return @($Map.PSObject.Properties.Name) -contains $Key
}
@@ -0,0 +1,32 @@
function Convert-BakNRetBackupFileNameToPath {
<#
.SYNOPSIS
把归档文件名还原成原始路径(用于没有 manifest 时的兜底)。
.DESCRIPTION
只处理 <名>_from_<路径> 形式;`C_` 还原为 `C:`。
命名里本来就含 `+` 或 `_from_` 的真实目录名无法可靠还原,
这类情况应当依赖 manifest.json 而不是文件名。
#>
param([Parameter(Mandatory = $true)][string]$FileName)
$baseName = [System.IO.Path]::GetFileNameWithoutExtension($FileName)
if ($baseName -notmatch '_from_') { return $null }
try {
$folderPart, $pathPart = $baseName -split '_from_', 2
$parts = @($pathPart -split '\+' | Where-Object { -not [string]::IsNullOrEmpty($_) })
$parts = @($parts | ForEach-Object {
if ($_ -match '^([A-Za-z])_$') { "$($matches[1]):" } else { $_ }
})
$reconstructed = ($parts -join '\') + '\' + $folderPart
Write-BakNRetLog "逆向解析:$FileName -> $reconstructed" -Level DEBUG
return $reconstructed
}
catch {
Write-BakNRetLog "无法解析备份文件名:$FileName" -Level WARN
return $null
}
}
+28
View File
@@ -0,0 +1,28 @@
function Convert-BakNRetSidMap {
<#
.SYNOPSIS
按 SID 映射表改写 SDDL 里的 SID(跨机恢复用)。
.DESCRIPTION
只在**完整的 SID 记号**上替换:`S-1-5-21-1-2-3-1001` 是
`S-1-5-21-1-2-3-10012` 的前缀,直接 -replace 会改坏后者,
所以前后加边界断言(前面不能是数字或 -,后面不能是数字)。
#>
param(
[AllowEmptyString()][string]$Sddl,
[hashtable]$SidMap = @{}
)
$text = [string]$Sddl
if (-not $text -or -not $SidMap -or $SidMap.Count -eq 0) { return $text }
foreach ($old in @($SidMap.Keys)) {
$newSid = [string]$SidMap[$old]
$oldSid = [string]$old
if ([string]::IsNullOrWhiteSpace($oldSid) -or [string]::IsNullOrWhiteSpace($newSid)) { continue }
$pattern = '(?<![0-9-])' + [regex]::Escape($oldSid) + '(?![0-9])'
$text = [regex]::Replace($text, $pattern, $newSid)
}
return $text
}
@@ -0,0 +1,231 @@
function ConvertFrom-BackupListLine {
<#
.SYNOPSIS
解析 BackupList.txt 的一行。
.DESCRIPTION
返回 $null 表示注释 / 空行。正常返回包含:
Direction —— 'both' | 'backup'(行首 +,仅备份)| 'restore'(行首 -,仅恢复)
Path —— 目标原文(软件名或字面路径),**归档命名以它为准**
IsName —— 是否按软件名去名录里查
Overrides —— 显式给出的覆盖字段(hashtable,用 ContainsKey 判断有没有写)
Path / Exclude / Include / Encrypt
ExcludePatterns / Includes —— Overrides 的便捷视图(没写时是空数组)
Flags —— 兼容的历史标记(pathname / root=<名>)
Comment —— 行尾 `# 说明`
Raw —— 原始行
与旧实现的区别:
* `::` 现在表示"覆盖 Path"(旧版是 `:-` 的历史别名),排除一律写 `:-`;
* 新增行首 `+` / `-` 方向、`:encrypt` / `:!encrypt`、`@ Key='Value'` 覆盖;
* 修饰符必须是独立记号(前后加空格),所以 `C:\a:-b` 仍然是路径;
* 行首方向标记是唯一例外:`+` / `-` 贴在目标上(`+Edge`)或独立成记号
(`+ Edge`)都认。详见下面判定处的注释。
#>
param([Parameter(ValueFromPipeline = $true)][AllowEmptyString()][string]$Line)
process {
$content = ([string]$Line).Trim()
if ([string]::IsNullOrEmpty($content) -or $content.StartsWith('#')) {
return $null
}
# 行内注释:`#` 前面有空白时,它后面整段是"这条为什么这么配"的说明。
# 解析时摘出来单独放在 Comment 里,运行时打印,让人一眼看懂排除/追加的理由。
# (路径里的 `#` 必须紧贴前一个字符,所以 `C:\a#b` 不会受影响。)
$comment = $null
$commentIndex = $content.IndexOf(' #')
if ($commentIndex -ge 0) {
$comment = $content.Substring($commentIndex + 1).Trim().TrimStart('#').Trim()
$content = $content.Substring(0, $commentIndex).Trim()
if ([string]::IsNullOrEmpty($content)) { return $null }
}
$tokens = @(Split-BakNRetToken -Text $content)
if ($tokens.Count -eq 0) { return $null }
# 整行被一对引号包住是**历史写法**(`"C:\a b\CodeSpace :: X\"`)。
# 现在修饰符必须是独立记号,所以引号里的 `::` / `:-` 不再是修饰符。
# 这里刻意**不**替用户重新切分:老写法里的 `::` 当年是"排除",现在 `::` 是
# "覆盖 Path"——猜着切会把排除表当成新的源路径,比报错更糟。只告警。
if ($tokens.Count -eq 1) {
$raw = $tokens[0]
if ($raw.Length -ge 2) {
$first = $raw[0]
$last = $raw[$raw.Length - 1]
if ($first -eq $last -and ($first -eq '"' -or $first -eq "'")) {
$inner = $raw.Substring(1, $raw.Length - 2)
foreach ($innerToken in @(Split-BakNRetToken -Text $inner)) {
if (Test-BakNRetMarker -Token $innerToken) {
Write-BakNRetLog "整行被引号包住,引号里的修饰符不会被识别(历史写法)。请去掉外层引号,并注意现在 `:-` 才是排除、`::` 是覆盖 Path:$Line" -Level WARN
break
}
}
}
}
}
# 行首方向标记:`+` 仅备份、`-` 仅恢复。
#
# 两种写法都认:独立成记号(`+ Edge`)与贴在目标上(`+Edge`)。后者是本仓库清单
# 里的主流写法,而过去只认前者 —— 于是 `+WindowsTerminal` 被当成一个名叫
# `+WindowsTerminal` 的软件名,名录里查不到就退回当目录名,目录又不存在,
# 整条静默记成 missing-source 跳过;备份按"跳过不算失败"退出 0,所以一直没暴露。
#
# 只放宽"行首"这一个位置:修饰符(:: / :- / :+ / @)仍然必须是独立记号,
# 否则 `C:\a:-b` 这类路径会被切坏 —— 那是另一条已经钉住的行为。
$direction = 'both'
if ($tokens[0] -eq '+') {
$direction = 'backup'
$tokens = @($tokens | Select-Object -Skip 1)
}
elseif ($tokens[0] -eq '-') {
$direction = 'restore'
$tokens = @($tokens | Select-Object -Skip 1)
}
elseif ($tokens[0].Length -gt 1 -and $tokens[0][0] -eq '+') {
$direction = 'backup'
$tokens[0] = $tokens[0].Substring(1)
}
elseif ($tokens[0].Length -gt 1 -and $tokens[0][0] -eq '-') {
$direction = 'restore'
$tokens[0] = $tokens[0].Substring(1)
}
if ($tokens.Count -eq 0) { return $null }
# 第一个修饰符之前是目标。目标可以带空格(比如带引号的 "C:\Program Files\App"),
# 所以这里取"第一个修饰符记号之前的全部记号",而不是只取第一个记号。
$firstMarker = -1
for ($index = 0; $index -lt $tokens.Count; $index++) {
if (Test-BakNRetMarker -Token $tokens[$index]) { $firstMarker = $index; break }
}
if ($firstMarker -eq 0) {
Write-BakNRetLog "清单行缺少目标,已忽略:$Line" -Level WARN
return $null
}
if ($firstMarker -lt 0) {
$targetText = ($tokens -join ' ')
$markerTokens = @()
}
else {
$targetText = (($tokens[0..($firstMarker - 1)]) -join ' ')
$markerTokens = @($tokens[$firstMarker..($tokens.Count - 1)])
}
$target = Remove-BakNRetQuote -Text $targetText
if ([string]::IsNullOrWhiteSpace($target)) { return $null }
$overrides = @{}
$flags = @()
$unknownKeys = @()
$index = 0
while ($index -lt $markerTokens.Count) {
$kind = Test-BakNRetMarker -Token $markerTokens[$index]
$inline = $null
if ($kind -eq 'at') { $inline = $markerTokens[$index].Substring(1) }
$index++
$values = @()
if (-not [string]::IsNullOrWhiteSpace($inline)) { $values += $inline }
while ($index -lt $markerTokens.Count -and -not (Test-BakNRetMarker -Token $markerTokens[$index])) {
$values += $markerTokens[$index]
$index++
}
switch ($kind) {
'path' {
$value = Remove-BakNRetQuote -Text ($values -join ' ')
if (-not [string]::IsNullOrWhiteSpace($value)) {
if ($overrides.ContainsKey('Path')) {
Write-BakNRetLog "同一条目里给了多次路径覆盖,用最后一个:$Line" -Level WARN
}
$overrides['Path'] = $value
}
}
# 同类记号可以出现多次(`Foo :- a :- b`),**累积**而不是后者覆盖前者:
# 静默丢掉前一条排除规则正是这个工具最不该犯的错。
'exclude' {
$parsed = @(ConvertFrom-BakNRetPatternList -Values $values)
if ($overrides.ContainsKey('Exclude')) { $overrides['Exclude'] = @($overrides['Exclude']) + $parsed }
else { $overrides['Exclude'] = $parsed }
}
'include' {
$parsed = @(ConvertFrom-BakNRetPatternList -Values $values)
if ($overrides.ContainsKey('Include')) { $overrides['Include'] = @($overrides['Include']) + $parsed }
else { $overrides['Include'] = $parsed }
}
'encrypt' {
if ($overrides.ContainsKey('Encrypt')) { Write-BakNRetLog "同一条目里给了多次加密开关,用最后一个:$Line" -Level WARN }
$overrides['Encrypt'] = $true
}
'noencrypt' {
if ($overrides.ContainsKey('Encrypt')) { Write-BakNRetLog "同一条目里给了多次加密开关,用最后一个:$Line" -Level WARN }
$overrides['Encrypt'] = $false
}
'at' {
$text = Remove-BakNRetQuote -Text ($values -join ' ')
if ([string]::IsNullOrWhiteSpace($text)) { continue }
$equals = $text.IndexOf('=')
if ($equals -lt 0) {
# 兼容历史写法:`@encrypt` / `@!encrypt` / `@pathname` / `@root=名`
foreach ($legacy in @(ConvertFrom-BakNRetPatternList -Values @($text))) {
$name = $legacy.Trim().TrimStart('@')
if ($name -ieq 'encrypt') { $overrides['Encrypt'] = $true }
elseif ($name -ieq '!encrypt') { $overrides['Encrypt'] = $false }
elseif ($name) { $flags += $name }
}
continue
}
$key = $text.Substring(0, $equals).Trim()
$value = Remove-BakNRetQuote -Text $text.Substring($equals + 1)
switch -Regex ($key) {
'(?i)^path$' { $overrides['Path'] = $value }
'(?i)^exclude$' {
$parsed = @(ConvertFrom-BakNRetPatternList -Values @($value))
if ($overrides.ContainsKey('Exclude')) { $overrides['Exclude'] = @($overrides['Exclude']) + $parsed }
else { $overrides['Exclude'] = $parsed }
}
'(?i)^include$' {
$parsed = @(ConvertFrom-BakNRetPatternList -Values @($value))
if ($overrides.ContainsKey('Include')) { $overrides['Include'] = @($overrides['Include']) + $parsed }
else { $overrides['Include'] = $parsed }
}
'(?i)^encrypt$' { $overrides['Encrypt'] = [bool]($value -match '(?i)^(\$?true|1|yes|on)$') }
'(?i)^root$' { $flags += "root=$value" }
default { $unknownKeys += $key }
}
}
}
}
foreach ($unknown in $unknownKeys) {
Write-BakNRetLog "清单里的 @ 字段 '$unknown' 不是已知字段(Path / Exclude / Include / Encrypt),已忽略:$Line" -Level WARN
}
$resolvedExclude = @()
if ($overrides.ContainsKey('Exclude')) { $resolvedExclude = @($overrides['Exclude']) }
$resolvedInclude = @()
if ($overrides.ContainsKey('Include')) { $resolvedInclude = @($overrides['Include']) }
return [pscustomobject]@{
Direction = $direction
Path = $target
# 目录名或文件名,需要靠 SoftwareCatalog 换成真实路径;
# 带分隔符或 %变量% 的写法按字面路径处理。
IsName = (-not (Test-BakNRetLiteralPath -Path $target))
Overrides = $overrides
ExcludePatterns = $resolvedExclude
Includes = $resolvedInclude
Flags = @($flags)
UnknownKeys = @($unknownKeys)
Comment = $comment
Raw = $Line
}
}
}
@@ -0,0 +1,15 @@
function ConvertFrom-BakNRetPatternList {
<#
.SYNOPSIS
把修饰符的值列表拼成字符串并按 `,` / `;` 拆成多个模式。
#>
param([string[]]$Values = @())
$parts = @()
foreach ($value in @($Values)) {
$text = Remove-BakNRetQuote -Text ([string]$value)
if ([string]::IsNullOrWhiteSpace($text)) { continue }
$parts += @($text -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
}
return @($parts)
}
@@ -0,0 +1,54 @@
function ConvertTo-BakNRetNativeArgumentString {
<#
.SYNOPSIS
按 Windows 的命令行引用规则,把参数数组拼成单个命令行字符串。
.DESCRIPTION
ProcessStartInfo.Arguments 只接受字符串,而 PowerShell 5.1 没有
ArgumentList。手工拼参数会让含空格 / 引号 / 结尾反斜杠的路径出问题
(旧实现就是手工在参数里塞引号,反而让 7z 的排除模式全部失效)。
这里用标准算法:反斜杠只在引号前翻倍,内部引号前加反斜杠。
#>
param([string[]]$ArgumentList = @())
$parts = New-Object System.Collections.Generic.List[string]
foreach ($argument in $ArgumentList) {
if ($null -eq $argument) { continue }
$value = [string]$argument
if ($value.Length -gt 0 -and $value -notmatch '[\s"]') {
$parts.Add($value)
continue
}
$builder = New-Object System.Text.StringBuilder
[void]$builder.Append('"')
$backslashes = 0
foreach ($ch in $value.ToCharArray()) {
if ($ch -eq '\') { $backslashes++; continue }
if ($ch -eq '"') {
[void]$builder.Append('\' * (2 * $backslashes + 1))
[void]$builder.Append('"')
$backslashes = 0
continue
}
if ($backslashes -gt 0) {
[void]$builder.Append('\' * $backslashes)
$backslashes = 0
}
[void]$builder.Append($ch)
}
if ($backslashes -gt 0) {
[void]$builder.Append('\' * (2 * $backslashes))
}
[void]$builder.Append('"')
$parts.Add($builder.ToString())
}
return ($parts -join ' ')
}
@@ -0,0 +1,18 @@
function ConvertTo-BakNRetWildcardPattern {
<#
.SYNOPSIS
把 7z 风格的通配符(* 与 ?)转成正则片段。
.DESCRIPTION
与 Get-BakNRetExcludeArgument 保持一致:模式里的空格先转成 `?`(7z 的
`-x!` 不接受带空格的模式)。`*` 转 `.*`,跨过路径分隔符,
这样锚定模式 `Default\*` 才能命中 `Default\a\b`。
#>
param([AllowEmptyString()][string]$Pattern)
$text = ([string]$Pattern) -replace ' ', '?'
$escaped = [regex]::Escape($text)
$escaped = $escaped -replace '\\\*', '.*'
$escaped = $escaped -replace '\\\?', '.'
return $escaped
}
@@ -0,0 +1,98 @@
function Enable-BakNRetPrivilege {
<#
.SYNOPSIS
在当前进程令牌里启用指定特权,返回哪些没能启用。
.DESCRIPTION
必须显式启用。MSDN(SetNamedSecurityInfoW)写明:
"If the caller does not have the SeRestorePrivilege constant, this SID must be
contained in the caller's token, and must have the SE_GROUP_OWNER permission
enabled." 也就是说没有它就没法把属主改成别的账户,而失败信息只有一句
"Access is denied"(easily mistaken for a path problem)。
两个坑:
* 结构体嵌套赋值(`$tp.Privileges.Luid.LowPart = …`)在 PowerShell 里改的是
装箱副本,改了不生效,所以整段放进 C# 里做;
* AdjustTokenPrivileges 返回 true 也可能是 ERROR_NOT_ALL_ASSIGNED(1300),
那代表特权根本不在令牌里,必须当成失败。
返回 [pscustomobject]@{ Enabled; Missing; Failed }(都是名字数组)。
#>
param([string[]]$Name = @('SeRestorePrivilege', 'SeBackupPrivilege'))
$result = [pscustomobject]@{
Enabled = @()
Missing = @()
Failed = @()
}
if (-not ('BakNRet.Privileges' -as [type])) {
try {
Add-Type -Namespace BakNRet -Name Privileges -MemberDefinition @'
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool OpenProcessToken(IntPtr h, int acc, out IntPtr phtok);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool LookupPrivilegeValue(string host, string name, out long pluid);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool AdjustTokenPrivileges(IntPtr htok, bool disall,
ref TOKEN_PRIVILEGES newst, int len, IntPtr prev, IntPtr relen);
[DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess();
[DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h);
[StructLayout(LayoutKind.Sequential)] public struct LUID { public uint LowPart; public int HighPart; }
[StructLayout(LayoutKind.Sequential)] public struct LUID_AND_ATTRIBUTES { public LUID Luid; public uint Attributes; }
[StructLayout(LayoutKind.Sequential)] public struct TOKEN_PRIVILEGES { public uint PrivilegeCount; public LUID_AND_ATTRIBUTES Privileges; }
// 0 = 已启用;1 = 令牌里没有这个特权;2 = 其它失败
public static int Enable(string name) {
IntPtr token;
if (!OpenProcessToken(GetCurrentProcess(), 0x28, out token)) { return 2; }
try {
long luid;
if (!LookupPrivilegeValue(null, name, out luid)) { return 1; }
TOKEN_PRIVILEGES tp = new TOKEN_PRIVILEGES();
tp.PrivilegeCount = 1;
tp.Privileges.Luid.LowPart = (uint)(luid & 0xFFFFFFFF);
tp.Privileges.Luid.HighPart = (int)(luid >> 32);
tp.Privileges.Attributes = 0x2;
if (!AdjustTokenPrivileges(token, false, ref tp, 0, IntPtr.Zero, IntPtr.Zero)) { return 2; }
if (Marshal.GetLastWin32Error() == 1300) { return 1; }
return 0;
} finally { CloseHandle(token); }
}
'@
}
catch {
Write-BakNRetLog "特权启用代码编译失败(本次不启用任何特权):$($_.Exception.Message)" -Level WARN
$result.Failed = @($Name)
return $result
}
}
$enabled = @(); $missing = @(); $failed = @()
foreach ($privilege in @($Name)) {
$cacheKey = $privilege
if ($script:BakNRetPrivilegeState.ContainsKey($cacheKey)) {
$state = $script:BakNRetPrivilegeState[$cacheKey]
}
else {
$state = [BakNRet.Privileges]::Enable($privilege)
$script:BakNRetPrivilegeState[$cacheKey] = $state
}
switch ($state) {
0 { $enabled += $privilege }
1 { $missing += $privilege }
default { $failed += $privilege }
}
}
if ($missing.Count -gt 0) {
Write-BakNRetLog ("这些特权不在当前令牌里(需要管理员或 SYSTEM):{0} —— 属主将无法改成别的账户,只能恢复 DACL" -f ($missing -join '、')) -Level WARN
}
if ($failed.Count -gt 0) {
Write-BakNRetLog ("这些特权启用失败:{0}" -f ($failed -join '、')) -Level WARN
}
$result.Enabled = @($enabled)
$result.Missing = @($missing)
$result.Failed = @($failed)
return $result
}
+48
View File
@@ -0,0 +1,48 @@
function Enter-BakNRetRunLock {
<#
.SYNOPSIS
取得"同一份备份目录同一时间只允许一个进程操作"的锁;拿不到时返回 $null。
.DESCRIPTION
拿不到就直接返回 $null 交给调用方明确失败,**不等待**:单个条目压缩可能十几分钟,
"等它跑完"对用户来说和挂住没区别,不如直接说清楚是谁占着。
返回的是一个已打开的文件流。**不需要刻意释放**:进程退出(含 exit)时句柄由系统
关闭,锁随之释放。调用方仍应显式调 Exit-BakNRetRunLock,让锁的覆盖范围一眼可见。
#>
param([Parameter(Mandatory = $true)][string]$Directory)
if (-not (Test-Path -LiteralPath $Directory)) {
New-Item -ItemType Directory -Path $Directory -Force | Out-Null
}
$path = Get-BakNRetRunLockPath -Directory $Directory
try {
$stream = [System.IO.File]::Open(
$path,
[System.IO.FileMode]::OpenOrCreate,
[System.IO.FileAccess]::ReadWrite,
[System.IO.FileShare]::None)
}
catch {
# 不能只写 `catch [System.IO.IOException]`:PowerShell 会把 .NET 方法抛出的异常包成
# MethodInvocationException,按内层类型做的 catch 接不住,于是锁被占用时会直接抛出去,
# 而不是按约定返回 $null 让调用方明确失败(实测踩到,被自己的断言逮住)。
# 这里沿 InnerException 链找那个 IOException;不是它就把原异常抛回去(例如目录不可写
# 是 UnauthorizedAccessException,那是真错误,不该伪装成"另一次运行在进行中")。
$inner = $_.Exception
while ($inner -and $inner -isnot [System.IO.IOException]) { $inner = $inner.InnerException }
if (-not $inner) { throw }
Write-BakNRetLog ("运行锁不可用({0}):{1}" -f $inner.GetType().Name, $inner.Message) -Level DEBUG
return $null
}
# 写点线索进去:"到底是谁占着"这个问题不该靠猜
$info = 'pid={0}; started={1:o}; host={2}; user={3}' -f $PID, (Get-Date), $env:COMPUTERNAME, $env:USERNAME
$bytes = [System.Text.Encoding]::UTF8.GetBytes($info)
$stream.SetLength(0)
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
return $stream
}
+15
View File
@@ -0,0 +1,15 @@
function Exit-BakNRetRunLock {
<#
.SYNOPSIS
释放运行锁。锁文件本身留着 —— 它的内容是最后一次持有者的线索,删不删都无所谓。
#>
param($Lock)
if (-not $Lock) { return }
try {
$Lock.Dispose()
}
catch {
Write-BakNRetLog "释放运行锁失败(进程退出时会自动释放):$_" -Level WARN
}
}
+59
View File
@@ -0,0 +1,59 @@
function Expand-CatalogPathText {
<#
.SYNOPSIS
展开名录里写的路径:`%环境变量%` 与 `$( ... )` 子表达式。
.DESCRIPTION
名录就是一份受信任的本地 PowerShell 配置,所以 `$( ... )` 直接按 PowerShell 求值,
够写这两类东西:
Path = '$(if ($env:SCOOP) { $env:SCOOP } else { Join-Path $env:USERPROFILE "scoop" })\persist'
Path = '$(scoop prefix translucenttb)\settings.json'
求值结果按原字符串缓存(`scoop prefix` 要起一个进程,不能每个条目跑一遍)。
括号不配对时原样保留,不抛异常——手写配置要的是可读的告警,不是崩掉。
#>
param([AllowEmptyString()][string]$Text)
$value = [string]$Text
if ([string]::IsNullOrEmpty($value)) { return '' }
if ($script:CatalogExpressionCache.ContainsKey($value)) {
return $script:CatalogExpressionCache[$value]
}
$original = $value
$guard = 0
while ($guard -lt 32) {
$guard++
# 从最后一个 `$(` 开始处理,这样嵌套在外层的表达式最后才展开
$start = $value.LastIndexOf('$(')
if ($start -lt 0) { break }
$depth = 0
$end = -1
for ($index = $start + 1; $index -lt $value.Length; $index++) {
if ($value[$index] -eq '(') { $depth++ }
elseif ($value[$index] -eq ')') {
$depth--
if ($depth -eq 0) { $end = $index; break }
}
}
if ($end -lt 0) { break }
$expression = $value.Substring($start + 2, $end - $start - 2)
$replacement = ''
try {
$evaluated = [scriptblock]::Create($expression).Invoke()
if ($null -ne $evaluated) { $replacement = ([string]($evaluated)).Trim() }
}
catch {
Write-BakNRetLog "名录路径里的表达式求值失败:$expression —— $($_.Exception.Message)" -Level WARN
}
$value = $value.Substring(0, $start) + $replacement + $value.Substring($end + 1)
}
$value = [Environment]::ExpandEnvironmentVariables($value)
$script:CatalogExpressionCache[$original] = $value
return $value
}
@@ -0,0 +1,23 @@
function Find-BakNRet7zExecutable {
<#
.SYNOPSIS
定位 7z.exe:PATH 优先,其次是两个常见安装位置;找不到返回 $null。
.DESCRIPTION
为什么要单独抽出来:这段查找逻辑原先有两份 —— 模块里的 Resolve-BakNRetCompressionTool
有一份,Restore.ps1 里还有一个自带的 Get-7zExecutable。两套写法哪怕只差一个候选路径,
就会出现"备份找得到、恢复找不到"这种最难看的不一致。
注意这里只负责**找 7z**,不负责"找不到怎么办":那个策略两边本就不同 —— 备份可以退到
RAR 或内置 ZIP(能打包就行),而恢复必须真的能解压 7z,找不到就该明确报错。
#>
$sevenZip = Get-Command 7z -ErrorAction SilentlyContinue |
Select-Object -First 1 -ExpandProperty Source
if ($sevenZip) { return $sevenZip }
$candidates = @(
(Join-Path $env:ProgramFiles '7-Zip\7z.exe'),
(Join-Path ${env:ProgramFiles(x86)} '7-Zip\7z.exe')
)
return ($candidates | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1)
}
@@ -0,0 +1,32 @@
function Find-BakNRetChildDirectoryByName {
<#
.SYNOPSIS
在 $Parent 下按精确名或"<名>_<后缀>"/"<名>-<后缀>"形式找目录。
.DESCRIPTION
只做保守的前缀补全:必须以下一个字符是 _ 或 - 为界,
避免把 Legendary 匹配成 LegendarySomething。
为什么只搜一层、而且**没有**"向下找几层"的参数:见
docs/adr/0009-prefix-completion-is-single-level.md。实测那件事值得记住 ——
允许向下递归时,"fnm 那条会命中 AppData\Local\fnm_multishells 这个临时目录",
等于静默备份错的东西还报成功;而只搜一层时它是明确报"源不存在"。
#>
param(
[Parameter(Mandatory = $true)][string]$Parent,
[Parameter(Mandatory = $true)][string]$Name
)
$escaped = [regex]::Escape($Name)
$pattern = "^$escaped(_|-).+"
try {
return @(Get-ChildItem -LiteralPath $Parent -Directory -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -ieq $Name -or $_.Name -imatch $pattern } |
Sort-Object Name |
Select-Object -ExpandProperty FullName)
}
catch {
return @()
}
}
@@ -0,0 +1,20 @@
function Format-BakNRetCatalogName {
<#
.SYNOPSIS
把软件名规范化成合法的归档基础名。
.DESCRIPTION
软件名就是归档名,所以这里必须挡住非法文件名字符。
保留 & % +(与路径命名算法的白名单一致)。
#>
param([Parameter(Mandatory = $true)][string]$Name)
$invalidChars = [System.IO.Path]::GetInvalidFileNameChars() |
Where-Object { $_ -notin @('&', '%', '+') }
$clean = -join ($Name.Trim().ToCharArray() | ForEach-Object {
if ($_ -in $invalidChars) { '_' } else { $_ }
})
$clean = $clean -replace ':', '_'
return $clean.Trim()
}
@@ -0,0 +1,49 @@
function Format-BakNRetPaddedText {
<#
.SYNOPSIS
把文本按**列宽**补齐或裁剪到指定宽度。
.DESCRIPTION
为什么不能用 `.PadRight()` / `.Substring()`:中文 1 个 char 占 2 列。按字符数补,边框会歪;
按字符数裁,会把一个汉字劈成半个、显示成乱码。
这里一律按 Get-BakNRetCellWidth 算,并且**绝不切开一个宽字符** —— 当剩余宽度差 1 列而下一个
字符要占 2 列时,就停下并用空格补上那 1 列。返回的字符串保证**恰好 Width 列**(Width ≤ 0 时
返回空串),这条不变式是画边框与对齐的基础。
#>
param(
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text,
[Parameter(Mandatory = $true)][int]$Width
)
if ($Width -le 0) { return '' }
$textWidth = Get-BakNRetCellWidth -Text $Text
if ($textWidth -le $Width) {
return $Text + (' ' * ($Width - $textWidth))
}
# 超宽:按列裁剪,绝不切开代理对或宽字符
$kept = ''
$used = 0
$index = 0
while ($index -lt $Text.Length) {
$chunk = [string]$Text[$index]
$high = [int]$Text[$index]
if ($high -ge 0xD800 -and $high -le 0xDBFF -and ($index + 1) -lt $Text.Length) {
$low = [int]$Text[$index + 1]
if ($low -ge 0xDC00 -and $low -le 0xDFFF) {
$chunk = $Text.Substring($index, 2)
$index++
}
}
$index++
$chunkWidth = Get-BakNRetCellWidth -Text $chunk
if (($used + $chunkWidth) -gt $Width) { break }
$kept += $chunk
$used += $chunkWidth
}
return $kept + (' ' * ($Width - $used))
}
@@ -0,0 +1,49 @@
function Get-BakNRetArchiveTopLevelNames {
<#
.SYNOPSIS
列出归档内的顶层条目名(用于确认多目录打包时每个目录都真的进去了)。
.DESCRIPTION
**刻意不解析 7z 的输出**:读取子进程 stdout 需要创建管道,本机沙箱会直接拒绝
(Access to the path '\\.\pipe\LOCAL\dotnet_...' denied),文件重定向(> file)
同样被拒。所以改成"把归档解到临时目录,再看文件系统上有哪些顶层条目",
只依赖文件系统。代价是多一次解压(只在多目录条目上跑),
好处是这个校验在受限环境里真的会执行,而不是静默退化成空数组。
解压失败或拿不到 7z 时返回空数组,调用方据此跳过顶层名核对。
#>
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '',
Justification = '同上:它返回的是归档内的全部顶层名,而单数名已经被「取第一段」的那个函数占用。')]
param(
[Parameter(Mandatory = $true)][string]$ArchivePath,
[Parameter(Mandatory = $true)][string]$SevenZip,
[string]$Password
)
$staging = Join-Path $env:TEMP ("bnr-inspect-" + [guid]::NewGuid().ToString('N'))
$names = @()
try {
New-Item -ItemType Directory -Path $staging -Force | Out-Null
$argument = @('x', '-bso0', '-bsp0', '-y', "-o$staging")
if ($Password) { $argument += "-p$Password" }
$argument += $ArchivePath
$exitCode = Invoke-ExternalCommand -FilePath $SevenZip -ArgumentList $argument
if ($exitCode -ne 0) { return @() }
# 先把名字读进变量,再在 finally 里删临时目录;
# 直接在 return 里管道 Get-ChildItem 会被 finally 抢在前面,拿到空数组。
$names = @(Get-ChildItem -LiteralPath $staging -Force -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty Name)
}
catch {
Write-BakNRetLog "无法清点归档内容(跳过顶层名核对):$($_.Exception.Message)" -Level DEBUG
$names = @()
}
finally {
Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue
}
return $names
}
@@ -0,0 +1,44 @@
function Get-BakNRetBackupBaseName {
<#
.SYNOPSIS
由清单中的原始路径生成归档基础名。
.DESCRIPTION
算法与历史版本保持一致(否则已存在的 20 个归档会全部失联):
<末级名>_from_<去掉末级后的各级用 + 连接>
并保留 & % + 三个字符(环境变量写法依赖 %),其余非法字符换 _。
额外做一件事:把 `:` 归一化为 `_`,因此 C:\Foo 与 "C:\Foo" 结果相同。
#>
param([Parameter(Mandatory = $true)][string]$RawPath)
$normalized = $RawPath.Trim() -replace '[/\\]+', '\'
$parts = @($normalized -split '\\' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
if ($parts.Count -eq 0) {
Write-BakNRetLog "无法解析路径:$RawPath" -Level ERROR
return $null
}
$folderName = $parts[-1].Trim()
$pathParts = if ($parts.Count -gt 1) { $parts[0..($parts.Count - 2)] } else { @() }
$pathPart = ($pathParts | ForEach-Object { $_.Trim() }) -join '+'
$baseName = if ([string]::IsNullOrEmpty($pathPart)) {
$folderName
}
else {
"${folderName}_from_${pathPart}"
}
$invalidChars = [System.IO.Path]::GetInvalidFileNameChars() |
Where-Object { $_ -notin @('&', '%', '+') }
$baseName = -join ($baseName.ToCharArray() | ForEach-Object {
if ($_ -in $invalidChars) { '_' } else { $_ }
})
$baseName = $baseName -replace ':', '_'
Write-BakNRetLog "生成文件基础名:$baseName" -Level DEBUG
return $baseName
}
@@ -0,0 +1,32 @@
function Get-BakNRetBackupListRow {
<#
.SYNOPSIS
把清单文本拆成带**行号**的行表;能解析的行附上解析结果,注释与空行的 Record 是 $null。
.DESCRIPTION
为什么要保留注释行与空行:外科式改写(ADR-0013)要按行号改**一行**,而行号必须与磁盘上的
文件一一对应。如果只在表里放"真正的条目",行号就错位了,改一行会连锁改掉别处。
Raw 是原始行文本(原文一个字节都不动),解析器给的 Record.Raw 也是它 —— 编辑时只要没动这一行,
就写回原样,注释、对齐、`$( )` 表达式、跨行拼接都会原封不动地留下来。
#>
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text)
$lines = $Text -split "`r?`n"
$rows = @()
for ($i = 0; $i -lt $lines.Count; $i++) {
$raw = $lines[$i]
$record = $null
# 注释行与空行不解析(解析器对它们返回 $null,这里显式跳过以省掉一次无谓的调用)
$trimmed = $raw.Trim()
if ($trimmed -and -not $trimmed.StartsWith('#')) {
$record = ConvertFrom-BackupListLine -Line $raw
}
$rows += [pscustomobject]@{
Line = $i + 1
Raw = $raw
Record = $record
}
}
return @($rows)
}
@@ -0,0 +1,60 @@
function Get-BakNRetCatalogField {
<#
.SYNOPSIS
把名录文本拆成"Slot 级字段"的表:行号、软件名、Slot 名、字段名、原文值、是否可编辑。
.DESCRIPTION
可编辑面按**能不能安全往返**来定(ADR-0013):
* `Encrypt` / `Description`,且值在**一行之内** → 可编辑(真实名录里是 19 + 24 个);
* `Path` / `Exclude` / `Include` → 一律只读:它们常带 `$( )` 动态表达式(Scoop 那三条
的 `$(if ($env:SCOOP_GLOBAL) { ... })`)与**跨行字符串拼接**(Edge 的 Exclude),
"改一行"对它们没有明确含义;
* `Description` 也可能是跨行的(Edge 那条多行说明)→ 那种同样只读。
文件头部的注释模板里有 `SoftWareName = @{ ... }` 这样的**示例**:纯文本扫描会把它当成一个
软件,所以扫描时显式跳过块注释里的内容。注意本文档里**不能**写那对块注释符号的闭合形式:
它会提前结束上面这段注释(实测踩到:后半句变成了代码,而它是合法语法 —— 等于调用一个
同名命令,所以解析层抓不到,只有跑到那一步才炸)。
#>
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text)
$lines = $Text -split "`r?`n"
$inComment = $false
$app = ''
$slot = ''
$rows = @()
for ($index = 0; $index -lt $lines.Count; $index++) {
$line = $lines[$index]
$trimmed = $line.Trim()
# 块注释:整段跳过(否则模板里的示例会被当成真条目)
if ($trimmed.StartsWith('<#')) { $inComment = $true; continue }
if ($trimmed.StartsWith('#>')) { $inComment = $false; continue }
if ($inComment) { continue }
if ($trimmed -eq '' -or $trimmed.StartsWith('#')) { continue }
if ($line -match '^ ([\w''\-]+)\s*=\s*@\{\s*$') { $app = $Matches[1]; $slot = ''; continue }
if ($line -match '^ ([\w''\-]+)\s*=\s*@\{\s*$') { $slot = $Matches[1]; continue }
if ($line -match '^\s{8}\}') { $slot = ''; continue }
if ($line -match '^\s{4}\}') { $app = ''; continue }
if ($slot -and $line -match '^\s{12}([\w]+)\s*=\s*(.+)$') {
$key = $Matches[1]
$value = $Matches[2]
$singleLine = $value -match "^('.*'|(\`$true|\`$false))\s*$"
$rows += [pscustomobject]@{
Line = $index + 1
App = $app
Slot = $slot
Key = $key
Value = $value
Editable = (($key -eq 'Encrypt' -or $key -eq 'Description') -and $singleLine)
Raw = $line
}
}
}
return @($rows)
}
+51
View File
@@ -0,0 +1,51 @@
function Get-BakNRetCellWidth {
<#>
.SYNOPSIS
算一个字符串在控制台上占多少**列**(不是多少个字符)。
.DESCRIPTION
为什么必须自己算:
* `[string].Length` 是 UTF-16 code unit 的个数 —— 一个汉字是 1 个 char 但占 2 列;
* `$Host.UI.RawUI.LengthInBufferCells` 在 PowerShell 7 上是对的,但在 **Windows
PowerShell 5.1 上会给出错的答案**:实测 `'中文'` 返回 2(把宽字符当单列)、字体边框字符
返回 6(把 1 列的当宽字符)。更糟的是它**不报错** —— 算错的表现只是菜单右边框歪一点、
光标定位偏一列,而那种错没人会当场发现,所以只能用断言钉住。
表里三个容易搞错的边界(断言里都有):
* 半角片假名 U+FF61–FF9F 是**半角**(1 列),不能划进全角区;
* 全角拉丁(U+FF00–FF60)是 2 列;
* emoji 是代理对(两个 char、一个码点),按一个码点算。
#>
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text)
# East-Asian-Wide 区。半角片假名(U+FF61–FF9F)刻意不在表里。
$wideRanges = @(
@(0x1100, 0x115F), @(0x2E80, 0x303E), @(0x3041, 0x33FF), @(0x3400, 0x4DBF),
@(0x4E00, 0x9FFF), @(0xA000, 0xA4CF), @(0xAC00, 0xD7A3), @(0xF900, 0xFAFF),
@(0xFE30, 0xFE6F), @(0xFF00, 0xFF60), @(0xFFE0, 0xFFE6),
@(0x1F300, 0x1F64F), @(0x1F900, 0x1F9FF), @(0x20000, 0x2FFFD), @(0x30000, 0x3FFFD)
)
$width = 0
$index = 0
while ($index -lt $Text.Length) {
$codePoint = [int]$Text[$index]
# 代理对:合成一个码点,并按一个字符推进
if ($codePoint -ge 0xD800 -and $codePoint -le 0xDBFF -and ($index + 1) -lt $Text.Length) {
$low = [int]$Text[$index + 1]
if ($low -ge 0xDC00 -and $low -le 0xDFFF) {
$codePoint = 0x10000 + (($codePoint - 0xD800) * 0x400) + ($low - 0xDC00)
$index++
}
}
$index++
# 控制字符不占显示宽度
if ($codePoint -lt 0x20 -or $codePoint -eq 0x7F) { continue }
$cell = 1
foreach ($range in $wideRanges) {
if ($codePoint -ge $range[0] -and $codePoint -le $range[1]) { $cell = 2; break }
}
$width += $cell
}
return $width
}
@@ -0,0 +1,67 @@
function Get-BakNRetConfigSetting {
<#
.SYNOPSIS
把配置文本拆成"一行一个标量设置"的表:行号、点号路径、键、原值、行尾注释。
.DESCRIPTION
为什么只认**单行标量**:这份配置文件里还有三处嵌套哈希、一处数组、一处行内空哈希
(SidMap = @{})与大量多行注释。外科式改写(ADR-0013)只碰"一行就是一个值"的那些 ——
值跨行或本身是集合时,"改一行"这个动作没有明确含义,宁可不在界面上提供(如实显示为只读)。
路径用点号拼(Snapshot.Enabled):同名键在不同嵌套里会出现(Enabled 就有两处),
只用键名会把它们混成一个。
`Raw` 是原始行文本:编辑时若没动这一行,就写回原样,键名与对齐的空格一字节都不变。
#>
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text)
$lines = $Text -split "`r?`n"
$stack = New-Object System.Collections.Generic.List[string]
$rows = @()
for ($index = 0; $index -lt $lines.Count; $index++) {
$line = $lines[$index]
$trimmed = $line.Trim()
if ($trimmed -eq '' -or $trimmed.StartsWith('#')) { continue }
# 进入一层嵌套哈希
if ($trimmed -match '^([\w'']+)\s*=\s*@\{\s*$') {
$stack.Add($Matches[1])
continue
}
# 离开一层:闭括号可能带后续内容,只关心它是否以 } 开头
if ($trimmed.StartsWith('}')) {
if ($stack.Count -gt 0) { $stack.RemoveAt($stack.Count - 1) }
continue
}
# 单行标量:有 = ,且值不以 @ 开头(集合与嵌套不是标量)
if ($trimmed -match '^([\w'']+)\s*=\s*(.+)$') {
$key = $Matches[1]
$rest = $Matches[2]
if ($rest.StartsWith('@')) { continue }
$value = $rest
$comment = ''
$commentIndex = $rest.IndexOf(' #')
if ($commentIndex -ge 0) {
$value = $rest.Substring(0, $commentIndex).Trim()
$comment = $rest.Substring($commentIndex + 1).Trim()
}
$path = if ($stack.Count -gt 0) { ($stack -join '.') + '.' + $key } else { $key }
$rows += [pscustomobject]@{
Line = $index + 1
Path = $path
Key = $key
Value = $value
Comment = $comment
Raw = $line
}
}
}
return @($rows)
}
@@ -0,0 +1,36 @@
function Get-BakNRetFolderSummary {
<#
.SYNOPSIS
统计目录/文件的文件数、总大小与最新修改时间。
.DESCRIPTION
LatestModifiedTime 取**包含目录在内**的所有条目的最大值:
目录的 LastWriteTime 会在子项增删时更新,因此删掉文件也能被察觉。
#>
param([Parameter(Mandatory = $true)][string]$FolderPath)
try {
$items = @(Get-ChildItem -LiteralPath $FolderPath -Recurse -Force -ErrorAction SilentlyContinue)
$files = @($items | Where-Object { -not $_.PSIsContainer })
# 空目录时 Measure-Object 的 .Sum 是 $null 而不是 0(7.x 与 5.1 实测都一样)。
# 这个 $null 会一路传到备份前的空间守卫:$null / 1GB 得 0,而守卫判的是 -gt 0,
# 于是"空间不够"时不再拦截 —— 静默失效。所以在这里就把 0 补上。
$totalSize = ($files | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
if ($null -eq $totalSize) { $totalSize = 0 }
return [pscustomobject]@{
FileCount = $files.Count
TotalSize = [long]$totalSize
LatestModifiedTime = ($items | Measure-Object -Property LastWriteTime -Maximum -ErrorAction SilentlyContinue).Maximum
}
}
catch {
Write-BakNRetLog "无法读取文件夹摘要:$FolderPath" -Level WARN
return [pscustomobject]@{
FileCount = 0
TotalSize = 0
LatestModifiedTime = (Get-Item -LiteralPath $FolderPath -ErrorAction SilentlyContinue).LastWriteTime
}
}
}
@@ -0,0 +1,40 @@
function Get-BakNRetItemArchiveName {
<#
.SYNOPSIS
决定一个条目的归档基础名(不含扩展名)。
.DESCRIPTION
规则:
* 默认用**软件名**(看起来像软件名就查名录;名录里没有则退回可读的目录名);
* 条目带 `@pathname` 时用原来的路径命名算法;
* 条目本来就写的是字面路径(含分隔符或 %变量%)时也用路径命名算法,
这样现有清单不需要改写就能继续工作。
#>
param($Entry, [string]$CatalogPath)
# @pathname 时用"真实路径"跑路径命名算法。
# 清单里写的可能是软件名,必须先经名录换成真实路径,
# 否则 Get-BakNRetBackupBaseName 会对软件名本身运算,得出错误的名字。
if ($Entry.Flags -contains 'pathname') {
$nameSource = $Entry.Path
if (-not (Test-BakNRetLiteralPath -Path $Entry.Path)) {
$catalogForPath = Get-BakNRetSoftwareCatalog -Path $CatalogPath
if ($catalogForPath.ContainsKey($Entry.Path)) {
$nameSource = $catalogForPath[$Entry.Path].Path
}
}
return Get-BakNRetBackupBaseName -RawPath $nameSource
}
$looksLikePath = Test-BakNRetLiteralPath -Path $Entry.Path
if (-not $looksLikePath) {
$catalog = Get-BakNRetSoftwareCatalog -Path $CatalogPath
if ($catalog.ContainsKey($Entry.Path)) {
return $catalog[$Entry.Path].Name
}
Write-BakNRetLog "名录里没有 '$($Entry.Path)',按目录名处理" -Level WARN
return (Format-BakNRetCatalogName -Name $Entry.Path)
}
return Get-BakNRetBackupBaseName -RawPath $Entry.Path
}
+34
View File
@@ -0,0 +1,34 @@
function Get-BakNRetKeyName {
<#
.SYNOPSIS
把一次按键(虚拟键码 + 字符)归一化成一个稳定的键名,供 TUI 的各个界面判断。
.DESCRIPTION
为什么把它单独拆出来:**这是整个 TUI 里唯一能无控制台测试的部分**。读键本身必须有真终端,
而映射不需要 —— 把纯逻辑与 I/O 分开,门禁里就能把"上/下/回车/空格/字母"这些路径钉住,
只把"真的读到一个键"留给人工。
返回的键名:Up / Down / Left / Right / Enter / Esc / Space / Tab / Backspace /
Char:<小写字符> / Unknown<键码>。
#>
param(
[Parameter(Mandatory = $true)][int]$VirtualKeyCode,
[string]$Character
)
switch ($VirtualKeyCode) {
38 { return 'Up' }
40 { return 'Down' }
37 { return 'Left' }
39 { return 'Right' }
13 { return 'Enter' }
27 { return 'Esc' }
32 { return 'Space' }
9 { return 'Tab' }
8 { return 'Backspace' }
}
# 可打印 ASCII 归一成 Char:<小写>,这样 -InputScript 里写 'Char:k' 就是按 k
if ($Character -and $Character -match '^[ -~]$') { return 'Char:' + $Character.ToLowerInvariant() }
return 'Unknown' + $VirtualKeyCode
}
@@ -0,0 +1,211 @@
function Get-BakNRetSoftwareCatalog {
<#
.SYNOPSIS
载入"软件名 -> Slot 组"名录。
.DESCRIPTION
新结构(SoftwareCatalog.psd1):
@{
<软件名> = @{
<Slot 名> = @{
Path = '宿主机绝对路径'
Exclude = '!*Cache,Default\Extensions' # 可选
Include = 'Modules:D:\extra\ps-modules' # 可选
Encrypt = $true # 可选,默认 $false
Description = '这个 Slot 是干什么的' # 可选
}
}
}
Slot 是**归档内的一层目录**:`<Slot>\<该 Path 的内容>`。一个软件一个归档,
因此同名的目录(例如 scoop 的用户 persist 与全局 persist)只要放在不同 Slot 里就不会撞。
返回按软件名索引的哈希表,每项:
Name / Path / Description / Slots / Kind / Missing / Error / Raw
Slot 对象:Name / Declared / Resolved / Exists / IsFile / Suffixed /
Description / Exclude / Include / Encrypt
读取结果按"文件路径 + 时间戳 + 长度 + 内容 MD5"缓存:一次运行里名录只会真正
读一次(旧实现每解析一个条目就重新 Import 一遍,还会把 `$( ... )` 反复求值)。
#>
param(
[Parameter(Mandatory = $true)][string]$Path,
[switch]$NoCache
)
$result = @{}
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return $result }
$stamp = $null
if (-not $NoCache) {
try {
$item = Get-Item -LiteralPath $Path -ErrorAction Stop
$hash = (Get-FileHash -LiteralPath $Path -Algorithm SHA256 -ErrorAction Stop).Hash
$stamp = '{0}-{1}-{2}' -f $item.LastWriteTimeUtc.Ticks, $item.Length, $hash
if ($script:CatalogCache.ContainsKey($Path) -and $script:CatalogCache[$Path].Stamp -eq $stamp) {
return $script:CatalogCache[$Path].Data
}
}
catch {
$stamp = $null
}
}
$data = $null
try {
$data = Import-BakNRetDataFile -Path $Path
}
catch {
Write-BakNRetLog "软件名录读取失败:$Path —— $_" -Level ERROR
return $result
}
# 递归引入其它名录文件(路径相对本文件)
$includeValue = Get-BakNRetMapValue -Map $data -Key 'Includes'
if ($includeValue) {
$baseDir = Split-Path -Parent $Path
foreach ($include in @($includeValue)) {
if (-not $include) { continue }
$includePath = [string]$include
if (-not [System.IO.Path]::IsPathRooted($includePath)) { $includePath = Join-Path $baseDir $includePath }
$included = Get-BakNRetSoftwareCatalog -Path $includePath
foreach ($includedName in $included.Keys) {
if ($result.ContainsKey($includedName)) { continue }
$result[$includedName] = $included[$includedName]
}
}
}
foreach ($key in @(Get-BakNRetMapKeys -Map $data | Where-Object { $_ -ne 'Includes' })) {
$name = Format-BakNRetCatalogName -Name ([string]$key)
if (-not $name) { continue }
$raw = Get-BakNRetMapValue -Map $data -Key $key
if ($raw -isnot [System.Collections.IDictionary] -and $null -ne $raw -and -not ($raw -is [psobject] -and @($raw.PSObject.Properties.Name).Count -gt 0)) {
Write-BakNRetLog "名录条目 '$key' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }" -Level ERROR
continue
}
$slots = @()
$errors = @()
foreach ($slotKey in @(Get-BakNRetMapKeys -Map $raw)) {
$slotName = ([string]$slotKey).Trim()
if (-not $slotName) { continue }
$slotRaw = Get-BakNRetMapValue -Map $raw -Key $slotKey
if ($slotRaw -isnot [System.Collections.IDictionary] -and -not ($slotRaw -is [psobject])) {
$errors += "Slot $slotName 的写法不对,应写成 @{ Path = '...' }"
continue
}
$declaredRaw = [string](Get-BakNRetMapValue -Map $slotRaw -Key 'Path')
if ([string]::IsNullOrWhiteSpace($declaredRaw)) {
$errors += "Slot $slotName 缺少 Path"
continue
}
$declared = (Expand-CatalogPathText -Text $declaredRaw).Trim()
if ([string]::IsNullOrWhiteSpace($declared)) {
$errors += "Slot $slotName 的 Path 展开成空:$declaredRaw"
continue
}
# 逐个候选目录解析。一个 Slot 是归档内的一层目录,只能对应一个目录:
# 补全出多个候选(同名目录分散在多处)时必须拆成多个 Slot,否则会混成一棵树。
$candidates = @()
if (Test-Path -LiteralPath $declared) {
$candidates = @($declared)
}
else {
$parent = Split-Path -Path $declared -Parent
$leafName = Split-Path -Path $declared -Leaf
if ($parent -and $leafName -and (Test-Path -LiteralPath $parent)) {
$candidates = @(Find-BakNRetChildDirectoryByName -Parent $parent -Name $leafName)
}
}
if ($candidates.Count -gt 1) {
$errors += ("Slot {0} 的 Path 匹配到 {1} 个目录:{2};一个 Slot 只能对应一个目录,请拆成多个 Slot" -f `
$slotName, $candidates.Count, ($candidates -join '、'))
}
$exists = $candidates.Count -ge 1
$resolved = if ($exists) { $candidates[0] } else { $declared }
$isFile = $false
$suffixed = $false
if ($exists) {
$suffixed = -not ($resolved -ieq $declared)
$resolvedItem = Get-Item -LiteralPath $resolved -Force -ErrorAction SilentlyContinue
if ($resolvedItem) { $isFile = -not $resolvedItem.PSIsContainer }
}
$excludeText = [string](Get-BakNRetMapValue -Map $slotRaw -Key 'Exclude')
$includeText = [string](Get-BakNRetMapValue -Map $slotRaw -Key 'Include')
$encryptValue = Get-BakNRetMapValue -Map $slotRaw -Key 'Encrypt'
$description = [string](Get-BakNRetMapValue -Map $slotRaw -Key 'Description')
$slots += [pscustomobject]@{
Name = $slotName
Declared = $declared
Resolved = $resolved
Exists = $exists
IsFile = $isFile
Suffixed = $suffixed
Description = $description
Exclude = @(ConvertFrom-BakNRetPatternList -Values @($excludeText))
Include = @(ConvertFrom-BakNRetPatternList -Values @($includeText))
Encrypt = [bool]$encryptValue
}
}
if ($slots.Count -eq 0 -and $errors.Count -eq 0) { continue }
# PowerShell 的哈希表不保留书写顺序,而 Slot 的顺序会影响归档内条目顺序与
# "第一个 Slot" 的取值,所以这里按名字排序,保证每次运行完全一致。
$slots = @($slots | Sort-Object -Property Name)
$existing = @($slots | Where-Object { $_.Exists })
$missing = @($slots | Where-Object { -not $_.Exists })
$kind = if ($slots.Count -eq 0) { 'Invalid' }
elseif ($existing.Count -eq 0) { 'Unresolved' }
elseif ($missing.Count -gt 0) { 'Partial' }
elseif ($slots.Count -gt 1) { 'Multi' }
else { 'Single' }
if ($errors.Count -gt 0) {
Write-BakNRetLog ("名录条目 {0} 有问题:{1}" -f $name, ($errors -join ';')) -Level ERROR
}
elseif ($missing.Count -gt 0) {
Write-BakNRetLog ("名录:{0} 有 {1} 个 Slot 的路径当前不存在:{2}" -f $name, $missing.Count, (($missing | ForEach-Object { $_.Declared }) -join ';')) -Level DEBUG
}
if ($slots.Count -gt 0) {
Write-BakNRetLog ("名录:{0} -> {1} 个 Slot,其中存在 {2} 个" -f $name, $slots.Count, $existing.Count) -Level DEBUG
}
if ($result.ContainsKey($name)) {
Write-BakNRetLog ("名录里有两条规范化之后同名的条目:{0}(后者覆盖前者)" -f $name) -Level WARN
}
$result[$name] = [pscustomobject]@{
Name = $name
Path = $(if ($slots.Count -gt 0) { $slots[0].Declared } else { $null })
Description = $(if ($slots.Count -gt 0) { $slots[0].Description } else { $null })
Slots = @($slots)
Kind = $kind
Missing = @($missing | ForEach-Object { $_.Declared })
Error = $(if ($errors.Count -gt 0) { $errors -join ';' } else { $null })
Raw = $raw
}
}
if ($stamp) {
$script:CatalogCache[$Path] = [pscustomobject]@{ Stamp = $stamp; Data = $result }
}
return $result
}
@@ -0,0 +1,22 @@
function Get-BakNRetAceSignatureList {
<#
.SYNOPSIS
把 ACE 列表压成可比对的"签名"集合(`类型|SID|掩码`)。
.DESCRIPTION
只用来回答一个问题:"子对象上这条继承来的 ACE,在父目录的 ACL 里找得到出处吗?"
所以**刻意不带继承标志位**:同一条 ACE 传给文件子对象时容器继承位会被去掉
(实测父目录的 (A;OICI;FA;;;SY) 到文件上变成 (A;ID;FA;;;SY)),
带上标志比较会永远不相等。掩码取 AccessMask 整数值,避免枚举把组合权限拆得不一样。
#>
param([array]$Rules = @())
$list = @()
foreach ($rule in @($Rules)) {
if (-not $rule) { continue }
$mask = -1
try { $mask = [int]$rule.FileSystemRights } catch { $mask = -1 }
$list += ('{0}|{1}|{2}' -f $rule.AccessControlType, $rule.IdentityReference.Value, $mask)
}
return $list
}
@@ -0,0 +1,10 @@
function Get-BakNRetArchiveTopName {
<# .SYNOPSIS 取归档内相对路径的第一段(顶层名字)。 #>
param([AllowEmptyString()][string]$ArchivePath)
$clean = ([string]$ArchivePath).Trim().Trim([char[]]@('\', '/'))
if (-not $clean) { return '' }
$separator = $clean.IndexOfAny([char[]]@('\', '/'))
if ($separator -lt 0) { return $clean }
return $clean.Substring(0, $separator)
}
+65
View File
@@ -0,0 +1,65 @@
function Get-BakNRetConfig {
<#
.SYNOPSIS
读取 BackupConfig.psd1 并与内置默认值合并。
.DESCRIPTION
配置文件缺失不是错误:直接用默认值,让工具开箱可用。
#>
param([string]$Path)
$defaults = @{
BackupDir = 'Backups'
LogDir = 'logs'
SnapshotDir = 'Backups\snapshots'
SoftwareCatalog = 'SoftwareCatalog.psd1'
MinFreeSpaceGB = 8
VerifyArchive = $true
ComputeHash = $false
CompressionLevel = 9
ToolOutput = 'live' # live | quiet
Snapshot = @{ Enabled = $false; KeepCount = 3; KeepDays = 30 }
Encryption = @{ Enabled = $false; PasswordFile = ''; EncryptHeaders = $true }
# 安全描述符(属主 / ACL)的采集与回放。
# Mode Off | Roots | Smart | Full(语义见 Get-BakNRetSecurityRecords)
# **默认 Full**:这个功能存在的意义就是不丢权限,正确性优先于体积;
# Smart 是体积优化(靠继承复现的对象不落盘),已在真机上见过
# 它需要处理的"陈旧继承 ACE",判据偏保守,但终究是启发式。
# IncludeSacl 是否连审计规则(SACL)一起存取,需要 SeSecurityPrivilege
# SidMap 跨机恢复时的 SID 映射:@('S-1-5-21-旧-1001' = 'S-1-5-21-新-1001')
# FailOnError 安全描述符写盘失败时,是否把这条备份算作失败(默认只告警)
Security = @{
Mode = 'Full'
IncludeSacl = $false
SidMap = @{}
FailOnError = $false
}
DefaultExcludes = @()
}
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
return $defaults
}
try {
$loaded = Import-BakNRetDataFile -Path $Path
}
catch {
Write-BakNRetLog "配置文件读取失败(改用默认值):$Path —— $_" -Level WARN
return $defaults
}
foreach ($key in $loaded.Keys) {
if ($key -in @('Snapshot', 'Encryption', 'Security') -and $loaded[$key] -is [hashtable]) {
$merged = @{}
foreach ($subKey in $defaults[$key].Keys) { $merged[$subKey] = $defaults[$key][$subKey] }
foreach ($subKey in $loaded[$key].Keys) { $merged[$subKey] = $loaded[$key][$subKey] }
$defaults[$key] = $merged
}
else {
$defaults[$key] = $loaded[$key]
}
}
return $defaults
}
@@ -0,0 +1,61 @@
function Get-BakNRetExcludeArgument {
<#
.SYNOPSIS
把一个归档项的模式列表翻译成 7z 的 `-x!` / `-xr!` 参数。
.DESCRIPTION
传进来的模式**已经按项分配好**(见 Split-BakNRetPatternScope),因此这里
拿到的模式一律是"相对该项归档根"的:
* `<相对路径>` -> `-x!<ArchivePath>\<相对路径>`(锚定在归档根)
* `!<通配>` -> `-xr!<通配>`(任意层级,模式里的空格自动转 `?`)
* `!re:<正则>` -> 遍历源目录翻译成若干 `-x!<完整路径>`(见 Get-BakNRetRegexExclude)
7z 排除语义(已实测确认):
* `-x!<完整归档内路径>` 匹配对象的完整路径,所以要带上项自己的归档根名;
* 模式里不能有空格,也不能自己写引号;
* 参数总长度有上限,超了明确报错,不静默丢规则。
#>
param(
[Parameter(Mandatory = $true)]$Item,
[string[]]$Patterns = @(),
[int]$MaxRegexMatches = 300,
[int]$MaxCommandLineChars = 15000
)
$arguments = @()
$errorText = $null
foreach ($pattern in @($Patterns)) {
if ([string]::IsNullOrWhiteSpace($pattern)) { continue }
$text = ([string]$pattern).Trim()
if ($text.StartsWith('!re:')) {
$regexText = $text.Substring(4).Trim()
if (-not $regexText) { continue }
$expanded = Get-BakNRetRegexExclude -Item $Item -Pattern $regexText -MaxMatches $MaxRegexMatches
if ($expanded.Error) { $errorText = $expanded.Error; continue }
$arguments += @($expanded.Arguments)
continue
}
if ($text.StartsWith('!')) {
$component = $text.Substring(1).Trim()
if (-not $component) { continue }
$arguments += ('-xr!{0}' -f ($component -replace ' ', '?'))
continue
}
$relative = $text.Trim([char[]]@('\', '/'))
if (-not $relative) { continue }
$arguments += ('-x!{0}\{1}' -f $Item.ArchivePath, ($relative -replace ' ', '?'))
}
$totalChars = 0
foreach ($argument in $arguments) { $totalChars += $argument.Length + 1 }
if (-not $errorText -and $totalChars -gt $MaxCommandLineChars) {
$errorText = "排除参数合计约 $totalChars 字符,超过命令行安全长度;请用更粗的通配模式(例如 !*Cache)"
}
return , [pscustomobject]@{ Arguments = @($arguments); Error = $errorText }
}
+29
View File
@@ -0,0 +1,29 @@
function Get-BakNRetFreeSpaceGB {
<#
.SYNOPSIS
返回 $Path 所在卷的剩余空间(GB);无法确定时返回 -1。
.DESCRIPTION
只用 cmdlet(Split-Path -Qualifier + Get-PSDrive),
不做 .NET 静态调用以外的假设,便于在受限环境下运行。
#>
param([Parameter(Mandatory = $true)][string]$Path)
try {
$resolved = $Path
if (Test-Path -LiteralPath $Path) {
$item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
if ($item.PSProvider.Name -eq 'FileSystem') { $resolved = $item.FullName }
}
$qualifier = Split-Path -Qualifier $resolved -ErrorAction Stop
if (-not $qualifier) { return -1 }
$drive = Get-PSDrive -Name $qualifier.TrimEnd(':') -ErrorAction Stop
if ($null -eq $drive.Free) { return -1 }
return [math]::Round($drive.Free / 1GB, 2)
}
catch {
return -1
}
}
+4
View File
@@ -0,0 +1,4 @@
function Get-BakNRetLogPath {
<# .SYNOPSIS 返回当前日志文件路径(未启用时返回 $null)。 #>
return $script:LogConfig.FilePath
}
+56
View File
@@ -0,0 +1,56 @@
function Get-BakNRetPassword {
<#
.SYNOPSIS
取加密口令:命令行参数 > 环境变量 > 密码文件 > 交互式询问。
.DESCRIPTION
口令**绝不写入仓库**。优先级:
1. -Password(命令行传参,注意会短暂出现在进程列表里)
2. $env:BAKNRET_PASSWORD
3. PasswordFile 的首行(文件必须在仓库之外,脚本只记路径)
4. 交互式询问(仅当 allowPrompt 且当前是交互式会话)
全都拿不到就返回 $null,调用方必须失败退出,绝不能默默写明文归档。
交互式询问用的是 Read-Host -AsSecureString,输入不回显;但它需要真实控制台,
在计划任务/CI 里会把用户晾在那里等输入,所以只在交互式会话里才提示。
#>
param(
[string]$Password,
[string]$PasswordFile,
[switch]$AllowPrompt
)
if ($Password) { return $Password }
if ($env:BAKNRET_PASSWORD) { return $env:BAKNRET_PASSWORD }
if ($PasswordFile -and (Test-Path -LiteralPath $PasswordFile)) {
$line = Get-Content -LiteralPath $PasswordFile -TotalCount 1 -Encoding UTF8 -ErrorAction SilentlyContinue
if ($line) { return $line.Trim() }
}
if ($AllowPrompt) {
# 只有在真的会等人输入时才提示,避免计划任务里静默挂起
$interactive = $true
try { $interactive = -not [System.Console]::IsInputRedirected } catch { $interactive = $false }
if ($interactive) {
Write-BakNRetLog '需要加密口令,请在弹出的提示里输入(不会回显、不会落盘)' -Level WARN
try {
$secure = Read-Host -Prompt '请输入加密口令' -AsSecureString
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
try {
return [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
}
finally {
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
}
}
catch {
Write-BakNRetLog "口令输入失败:$_" -Level ERROR
return $null
}
}
}
return $null
}
@@ -0,0 +1,74 @@
function Get-BakNRetRegexExclude {
<#
.SYNOPSIS
把一条 `!re:<正则>` 展开成若干 `-x!<归档内路径>` 参数。
.DESCRIPTION
7z 本身只认通配符,不认正则,所以正则只能由脚本自己遍历源目录后翻译成
一条条精确的 `-x!<完整归档内路径>`:
* 逐层遍历,命中"目录名或相对路径"就把该目录整个排除,并且**不再往下走**
(否则一个命中会产生成千上万条参数);
* 展开结果有上限(MaxMatches),超过就明确报错,而不是悄悄漏排除或写出超长命令行。
注意:`!<通配>`(例如 `!*Cache`)不走这里——它在 .NET 里是非法正则
(`*` 前没有可重复的表达式),仍然按"任意层级匹配组件名"翻译成 `-xr!`。
#>
param(
[Parameter(Mandatory = $true)]$Item,
[Parameter(Mandatory = $true)][string]$Pattern,
[int]$MaxMatches = 300
)
$arguments = @()
$errorText = $null
try {
$regex = [System.Text.RegularExpressions.Regex]::new(
$Pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
}
catch {
return [pscustomobject]@{ Arguments = @(); Matches = 0; Error = "排除正则非法:$Pattern —— $($_.Exception.Message)" }
}
$real = [string]$Item.RealPath
if (-not $real -or -not (Test-Path -LiteralPath $real)) {
return [pscustomobject]@{ Arguments = @(); Matches = 0; Error = $null }
}
$root = Get-Item -LiteralPath $real -Force -ErrorAction SilentlyContinue
if (-not $root) { return [pscustomobject]@{ Arguments = @(); Matches = 0; Error = $null } }
if (-not $root.PSIsContainer) {
if ($regex.IsMatch($root.Name)) { $arguments += "-x!$($Item.ArchivePath)" }
return [pscustomobject]@{ Arguments = @($arguments); Matches = $arguments.Count; Error = $null }
}
# 用显式栈做深度优先遍历:命中就整棵剪掉,所以匹配数是"命中的最浅层数"。
$stack = New-Object System.Collections.Generic.Stack[object]
foreach ($child in @(Get-ChildItem -LiteralPath $root.FullName -Force -ErrorAction SilentlyContinue)) {
$stack.Push(@{ Relative = $child.Name; Item = $child })
}
while ($stack.Count -gt 0) {
$node = $stack.Pop()
$relative = [string]$node.Relative
$entry = $node.Item
if ($regex.IsMatch($entry.Name) -or $regex.IsMatch($relative)) {
$arguments += ('-x!{0}\{1}' -f $Item.ArchivePath, ($relative -replace '/', '\'))
if ($arguments.Count -gt $MaxMatches) {
$errorText = "排除正则 $Pattern 命中的路径超过 $MaxMatches 条,7z 命令行会过长;请改用更粗的通配模式(例如 !*Cache)"
break
}
continue
}
if ($entry.PSIsContainer) {
foreach ($child in @(Get-ChildItem -LiteralPath $entry.FullName -Force -ErrorAction SilentlyContinue)) {
$stack.Push(@{ Relative = ('{0}\{1}' -f $relative, $child.Name); Item = $child })
}
}
}
return [pscustomobject]@{ Arguments = @($arguments); Matches = $arguments.Count; Error = $errorText }
}
@@ -0,0 +1,5 @@
function Get-BakNRetRunLockPath {
<# .SYNOPSIS 运行锁文件的位置(放在备份目录里,与它保护的账本同处)。 #>
param([Parameter(Mandatory = $true)][string]$Directory)
return (Join-Path $Directory '.baknret.lock')
}
@@ -0,0 +1,102 @@
function Get-BakNRetSecurityRecord {
<#
.SYNOPSIS
读一个对象的安全描述符,产出可序列化的一条记录。
.DESCRIPTION
返回 [pscustomobject]:
p / k 归档内相对路径 / 类型(d 目录、f 文件)
s SDDL 原文(含 O: / G: / D:)
o / g 属主 / 属组 SID 字符串
e 读不到时的错误(**必须记账**,不能当成"没有特殊权限")
Protected / Explicit / Inherited / Inheritable / Analyzed
Smart 模式判断"是否与父目录不同"用的分析结果
属主/属组一律取 SID 字符串(GetOwner(SecurityIdentifier).Value):
走 .Owner 会触发账户名解析,孤儿 SID 上会抛异常或很慢,而我们只要数值身份。
读 SD 需要 READ_CONTROL;C:\ProgramData 里确实有 Get-Acl 直接报
"Attempted to perform an unauthorized operation" 的目录,先开 SeBackupPrivilege
能救回大部分,救不回的会带 e 字段落进 sidecar。
#>
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string]$Key,
[ValidateSet('d', 'f')][string]$Kind = 'd',
[switch]$IncludeSacl,
[AllowNull()][string[]]$ParentSignatures = $null
)
$record = [pscustomobject]@{
p = $Key
k = $Kind
s = $null
o = $null
g = $null
e = $null
Protected = $false
Explicit = 0
Inherited = 0
Inheritable = 0
InheritedSignatures = @()
AllSignatures = @()
Analyzed = $false
}
$acl = $null
try {
if ($IncludeSacl) {
$acl = Get-Acl -LiteralPath $Path -Audit -ErrorAction Stop
}
else {
$acl = Get-Acl -LiteralPath $Path -ErrorAction Stop
}
}
catch {
$record.e = $_.Exception.Message
return $record
}
try {
# 陈旧继承 ACE 要固化成显式 ACE,否则恢复后会消失(见 Get-BakNRetSecuritySddlWithStale)
$record.s = Get-BakNRetSecuritySddlWithStale -Acl $acl -ParentSignatures $ParentSignatures
}
catch {
$record.e = $_.Exception.Message
}
if (-not $record.s) {
if (-not $record.e) { $record.e = '读不到安全描述符' }
return $record
}
try { $record.o = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value } catch { $null = $_ }
try { $record.g = $acl.GetGroup([System.Security.Principal.SecurityIdentifier]).Value } catch { $null = $_ }
try {
$sid = [System.Security.Principal.SecurityIdentifier]
$record.Protected = [bool]$acl.AreAccessRulesProtected
$explicitRules = @($acl.GetAccessRules($true, $false, $sid))
$inheritedRules = @($acl.GetAccessRules($false, $true, $sid))
$record.Explicit = $explicitRules.Count
$record.Inherited = $inheritedRules.Count
$record.InheritedSignatures = @(Get-BakNRetAceSignatureList -Rules $inheritedRules)
$record.AllSignatures = @(Get-BakNRetAceSignatureList -Rules @($acl.GetAccessRules($true, $true, $sid)))
$inheritable = 0
foreach ($rule in @($acl.GetAccessRules($true, $true, $sid))) {
$fsRule = $rule -as [System.Security.AccessControl.FileSystemAccessRule]
if ($fsRule -and ($fsRule.InheritanceFlags -ne [System.Security.AccessControl.InheritanceFlags]::None)) {
$inheritable++
}
}
$record.Inheritable = $inheritable
$record.Analyzed = $true
}
catch {
# 分析失败时一律当成"需要保留"(Analyzed=$false 会让 keeper 直接保留)
$record.Analyzed = $false
}
return $record
}
@@ -0,0 +1,127 @@
function Get-BakNRetSecurityRecords {
<#
.SYNOPSIS
采集一组归档项的安全描述符,键是**归档内相对路径**(`<Slot>\…`)。
.DESCRIPTION
键用归档内路径而不是宿主机路径:目标机器上 `%UserProfile%` 会变、名录的前缀补全
(legendary -> legendary_2.0.4)也会变,只有归档内相对路径在两端是同一个坐标系。
遍历用显式栈,并且**跳过 reparse point**:PS 5.1 的 Get-ChildItem -Recurse 会
跟着 junction 无限转;scoop 的 `apps\<app>\current` 就是 junction,正撞在这个坑上。
$ScopeMap 由 Split-BakNRetPatternScope 产出(项下标 -> 该相对根的模式数组),
所以这里的排除判定与真正交给 7z 的 -x! / -xr! 是同一套规则。
Mode:
* Roots —— 只存每个归档项的根(最省,适合"权限只在根上"的场景)
* Smart —— 根 + 所有"继承复现不出来"的对象(默认;几万文件的树 sidecar 也只有几百 KB)
* Full —— 每一个对象都存(最保险,sidecar 会大到几 MB)
返回 [pscustomobject]@{ Records; Scanned; Kept; Errors }。
#>
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '',
Justification = '同上:它采集一整棵树的记录,单数名是「读单个对象」的那个函数。')]
param(
[array]$Items = @(),
[hashtable]$ScopeMap = @{},
[ValidateSet('Roots', 'Smart', 'Full')][string]$Mode = 'Smart',
[switch]$IncludeSacl
)
$records = New-Object System.Collections.Generic.List[object]
$scanned = 0
$errorCount = 0
# 读安全描述符要 READ_CONTROL:系统目录里读不到是常态(C:\ProgramData 下就有
# Get-Acl 直接报 "Attempted to perform an unauthorized operation" 的目录)。
# SeBackupPrivilege 启用后系统会把读权限授予任何文件;连它都没有的账户,
# 读不到的对象会带 e 字段落进 sidecar,而不是被静默当成"没有特殊权限"。
$privileges = @('SeBackupPrivilege')
if ($IncludeSacl) { $privileges += 'SeSecurityPrivilege' }
Enable-BakNRetPrivilege -Name $privileges | Out-Null
for ($index = 0; $index -lt $Items.Count; $index++) {
$item = $Items[$index]
if (-not $item) { continue }
$archiveRoot = [string]$item.ArchivePath
$real = [string]$item.RealPath
if ([string]::IsNullOrWhiteSpace($archiveRoot) -or [string]::IsNullOrWhiteSpace($real)) { continue }
if (-not (Test-Path -LiteralPath $real)) { continue }
$patterns = @()
if ($ScopeMap -and $ScopeMap.ContainsKey($index)) { $patterns = @($ScopeMap[$index]) }
$rootItem = Get-Item -LiteralPath $real -Force -ErrorAction SilentlyContinue
if (-not $rootItem) { continue }
if (-not $rootItem.PSIsContainer) {
$record = Get-BakNRetSecurityRecord -Path $real -Key $archiveRoot -Kind 'f' -IncludeSacl:$IncludeSacl
$scanned++
if ($record.e) { $errorCount++ }
$records.Add($record)
continue
}
$rootRecord = Get-BakNRetSecurityRecord -Path $real -Key $archiveRoot -Kind 'd' -IncludeSacl:$IncludeSacl
$scanned++
if ($rootRecord.e) { $errorCount++ }
$records.Add($rootRecord)
if ($Mode -eq 'Roots') { continue }
$pending = New-Object System.Collections.Generic.Stack[object]
$pending.Push(@{
Dir = $rootItem
Rel = ''
Owner = $rootRecord.o
Group = $rootRecord.g
Inheritable = $rootRecord.Inheritable
Signatures = $rootRecord.AllSignatures
})
while ($pending.Count -gt 0) {
$frame = $pending.Pop()
foreach ($child in @(Get-ChildItem -LiteralPath $frame.Dir.FullName -Force -ErrorAction SilentlyContinue)) {
if ($child.Attributes -band [IO.FileAttributes]::ReparsePoint) { continue }
$childRel = if ($frame.Rel) { $frame.Rel + '\' + $child.Name } else { $child.Name }
if (Test-BakNRetPathExcluded -RelativePath $childRel -Patterns $patterns) { continue }
$kind = if ($child.PSIsContainer) { 'd' } else { 'f' }
$record = Get-BakNRetSecurityRecord -Path $child.FullName -Key ($archiveRoot + '\' + $childRel) `
-Kind $kind -IncludeSacl:$IncludeSacl -ParentSignatures $frame.Signatures
$scanned++
if ($record.e) { $errorCount++ }
if ($Mode -eq 'Full') {
$records.Add($record)
}
elseif (Test-BakNRetSecurityRecordNeeded -Record $record `
-ParentOwner $frame.Owner -ParentGroup $frame.Group `
-ParentInheritable $frame.Inheritable -ParentSignatures $frame.Signatures) {
$records.Add($record)
}
if ($child.PSIsContainer) {
$pending.Push(@{
Dir = $child
Rel = $childRel
Owner = $record.o
Group = $record.g
Inheritable = $record.Inheritable
Signatures = $record.AllSignatures
})
}
}
}
}
return [pscustomobject]@{
Records = @($records.ToArray())
Scanned = $scanned
Kept = $records.Count
Errors = $errorCount
}
}
@@ -0,0 +1,73 @@
function Get-BakNRetSecuritySddlWithStale {
<#
.SYNOPSIS
对象与父目录的继承链**不自洽**时,把整套 ACE 冻结成显式副本(并置 protected),
返回改写后的 SDDL;自洽时原样返回 $Acl.Sddl。
.DESCRIPTION
恢复时只重放**显式** ACE,其余交给父目录重新继承 —— 对绝大多数对象这是最忠实的
做法(父目录修好之后继承会长出同样的 ACE,还保住了活继承语义)。
但有一类对象不行:它的 DACL 里留着**陈旧**的继承 ACE —— 父目录早就改过权限,
这条 ACE 已经没有任何出处。真机实测两件事:
1) 把父目录设成 protected 的新 DACL 之后,子对象仍留着从祖父目录继承来的
`(A;ID;FA;;;S-1-5-21-…)`;条数与父目录的可继承条数**正好都是 4**、内容却不同
—— 所以判据必须比 ACE 内容,不能只数条数。
2) Windows 在改写父目录时**不会**替子对象清掉这种已无出处的 ACE。于是
"目标上本来就留着它 + 我又补写一条显式 ACE" = 同一条 ACE 出现两次。
所以这类对象只能整套冻结:显式 ACE + 陈旧 ACE 全部按显式写,并置 protected
(protected 才不会被系统再补一遍继承 ACE)。代价是这个对象从此不跟随父目录
—— 但它本来就已经跟父目录脱节了,冻结是唯一"不丢 ACE、也不重复 ACE"的做法。
$ParentSignatures 为 $null 表示"调用方没有父目录上下文"(归档项根、单文件项),
此时不做任何改写。
#>
param(
[Parameter(Mandatory = $true)]$Acl,
[AllowNull()][string[]]$ParentSignatures = $null
)
if ($null -eq $ParentSignatures) { return $Acl.Sddl }
$sid = [System.Security.Principal.SecurityIdentifier]
$inherited = @($Acl.GetAccessRules($false, $true, $sid))
if ($inherited.Count -eq 0) { return $Acl.Sddl }
# 自洽 = 继承来的 ACE 每一条都能在父目录的 ACL 里找到出处
$stale = @()
foreach ($rule in $inherited) {
$signature = @(Get-BakNRetAceSignatureList -Rules @($rule))[0]
if ($ParentSignatures -notcontains $signature) { $stale += $rule }
}
if ($stale.Count -eq 0) { return $Acl.Sddl }
$rebuilt = $null
if ($Acl -is [System.Security.AccessControl.DirectorySecurity]) {
$rebuilt = New-Object System.Security.AccessControl.DirectorySecurity
}
else {
$rebuilt = New-Object System.Security.AccessControl.FileSecurity
}
# 整套(显式 + 继承)都按显式写:内容与备份时逐条一致,不靠继承去"猜"回来
foreach ($rule in @($Acl.GetAccessRules($true, $true, $sid))) { $rebuilt.AddAccessRule($rule) }
$sections = [System.Security.AccessControl.AccessControlSections]::Access
try {
$rebuilt.SetOwner($Acl.GetOwner($sid))
$sections = $sections -bor [System.Security.AccessControl.AccessControlSections]::Owner
}
catch { $null = $_ }
try {
$rebuilt.SetGroup($Acl.GetGroup($sid))
$sections = $sections -bor [System.Security.AccessControl.AccessControlSections]::Group
}
catch { $null = $_ }
$rebuilt.SetAccessRuleProtection($true, $false)
Write-BakNRetLog ("{0} 条继承 ACE 已无出处(父目录里找不到),整套 ACE 冻结为显式并置 protected" -f $stale.Count) -Level DEBUG
return $rebuilt.GetSecurityDescriptorSddlForm($sections)
}
@@ -0,0 +1,61 @@
function Get-Optimized7zArgument {
<#
.SYNOPSIS
根据源目录规模生成 7z 压缩参数(字典大小、线程数、快速字节数)。
.DESCRIPTION
SourcePath 可以是多个(一个条目可能有多个 Slot / 追加项),字典大小按合计规模算。
#>
param(
[Parameter(Mandatory = $true)][string[]]$SourcePath,
[int]$Level = 9
)
$totalSize = 0
$fileCount = 0
foreach ($path in $SourcePath) {
if ([string]::IsNullOrWhiteSpace($path)) { continue }
$item = Get-Item -LiteralPath $path -ErrorAction Stop
if ($item.PSIsContainer) {
$files = @(Get-ChildItem -LiteralPath $path -File -Recurse -ErrorAction SilentlyContinue)
$fileCount += $files.Count
$totalSize += [int64](@($files | Measure-Object -Property Length -Sum).Sum)
}
else {
$fileCount++
$totalSize += [int64]$item.Length
}
Write-BakNRetLog ("分析路径 '{0}':已累计 {1} 个文件,{2} MB" -f $path, $fileCount, [math]::Round($totalSize / 1MB, 2)) -Level DEBUG
}
if ($null -eq $totalSize) { $totalSize = 0 }
$totalSizeMB = [math]::Round($totalSize / 1MB, 2)
Write-BakNRetLog ("合计分析:{0} 个文件,总大小 {1} MB" -f $fileCount, $totalSizeMB) -Level DEBUG
if ($totalSizeMB -gt 1024) { $dictSize = '1024m' }
elseif ($totalSizeMB -gt 100) { $dictSize = '256m' }
elseif ($totalSizeMB -gt 10) { $dictSize = '32m' }
else { $dictSize = '16m' }
try {
$cpuCores = (Get-CimInstance Win32_ComputerSystem -ErrorAction Stop).NumberOfLogicalProcessors
$threads = [math]::Max(1, $cpuCores - 1)
}
catch {
$threads = 2
}
Write-BakNRetLog ("参数优化:字典=$dictSize, 线程=$threads, 级别=$Level") -Level DEBUG
return [pscustomobject]@{
# 只放压缩相关开关。输出开关(-bso0/-bsp0 或默认进度)必须由调用方
# 单独加一次:7z 对同一个开关出现两次会直接报
# "Multiple instances for switch" 并以退出码 7 失败。
Argument = @('a', '-t7z', "-mx=$Level", "-md=$dictSize", '-ms=on', "-mmt=$threads")
FileCount = $fileCount
TotalSize = $totalSize
TotalSizeMB = $totalSizeMB
}
}
@@ -0,0 +1,105 @@
function Invoke-BakNRetBackupListEditor {
<#
.SYNOPSIS
清单的行编辑器:选一行 → 选新方向 → 逐行校验 → 校验后原子保存(并留时间戳备份)。
.DESCRIPTION
把前面几块装配起来:行表(Get-BakNRetBackupListRow)、单行替换(Set-BakNRetBackupListRow)、
改方向(Set-BakNRetBackupListDirection)、校验后原子保存(Save-BakNRetConfigFile)。
因为输入走 -Driver,整个流程在门禁里可以被 `-InputScript @('Enter','Down','Enter')` 驱动,
断言看的是**返回值**与**文件内容**,不需要捕获控制台输出 —— 与菜单循环同一个套路。
四条"不猜":
* 选行或选方向时按 Esc → 什么都不写,也不产生备份;
* **方向菜单预选当前值** —— 不预选的话,"连按两次回车"会把字段改成菜单的第一项,
那不是"没改",是改错了(实测踩到);
* 方向没变 → 直接返回"没有要保存的内容",不做无意义的写盘与备份;
* 保存前**逐行校验**(每行都要能被 ConvertFrom-BackupListLine 解析)——这正是 TUI 编辑器
比手改安全的地方:手改没有校验。
#>
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)]$Driver,
[switch]$WhatIf
)
if (-not (Test-Path -LiteralPath $Path)) {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = "找不到清单文件:$Path"; BackupPath = $null }
}
$text = Get-Content -LiteralPath $Path -Raw -Encoding UTF8
$rows = @(Get-BakNRetBackupListRow -Text $text)
$entryRows = @($rows | Where-Object { $_.Record })
if ($entryRows.Count -eq 0) {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = '清单里没有可编辑的条目。'; BackupPath = $null }
}
$items = @(foreach ($row in $entryRows) {
[pscustomobject]@{
Key = [string]$row.Line
Text = ('第 {0,3} 行 {1,-7} {2}' -f $row.Line, $row.Record.Direction, $row.Record.Path)
}
})
$picked = Invoke-BakNRetMenu -Title ('清单:{0}' -f $Path) -Items $items -Driver $Driver -MultiSelect $false -Hint '↑↓ 选行 回车 编辑 Esc 取消'
if ($picked.Action -ne 'confirm') {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = ''; BackupPath = $null }
}
$lineNumber = [int]$picked.Chosen.Key
$target = $rows | Where-Object { $_.Line -eq $lineNumber } | Select-Object -First 1
$directionItems = @(
[pscustomobject]@{ Key = 'both'; Text = 'both  两个方向都处理(不带标记)' },
[pscustomobject]@{ Key = 'backup'; Text = 'backup 只备份(行首 +)' },
[pscustomobject]@{ Key = 'restore'; Text = 'restore 只恢复(行首 -)' }
)
# 预选当前取值:菜单停在字段现在的值上,用户要改才按方向键。
$initialIndex = 0
for ($scan = 0; $scan -lt $directionItems.Count; $scan++) {
if ($directionItems[$scan].Key -eq [string]$target.Record.Direction) {
$initialIndex = $scan
break
}
}
$chosen = Invoke-BakNRetMenu -Title '改成哪个方向?' -Items $directionItems -Driver $Driver -MultiSelect $false -InitialIndex $initialIndex -Hint '↑↓ 选方向 回车 应用 Esc 取消'
if ($chosen.Action -ne 'confirm') {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = ''; BackupPath = $null }
}
$newLine = Set-BakNRetBackupListDirection -Raw $target.Raw -Direction $chosen.Chosen.Key
if ($newLine -eq $target.Raw) {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = '方向没有变化,没有要保存的内容。'; BackupPath = $null }
}
$newText = Set-BakNRetBackupListRow -Text $text -Line $lineNumber -NewText $newLine
$validate = {
param($candidate)
$bad = @()
$number = 0
foreach ($one in ($candidate -split "`r?`n")) {
$number++
$trimmed = $one.Trim()
if (-not $trimmed -or $trimmed.StartsWith('#')) { continue }
if (-not (ConvertFrom-BackupListLine -Line $one)) {
$bad += ('第 {0} 行解析不了:{1}' -f $number, $trimmed)
}
}
return $bad
}
$saved = Save-BakNRetConfigFile -Path $Path -Text $newText -Validate $validate `
-BackupDirectory (Join-Path (Split-Path -Parent $Path) 'logs\config-backups') -WhatIf:$WhatIf
return [pscustomobject]@{
Saved = $saved.Saved
Changed = $true
Error = $saved.Error
BackupPath = $saved.BackupPath
}
}
@@ -0,0 +1,92 @@
function Invoke-BakNRetCatalogEditor {
<#
.SYNOPSIS
名录编辑器:列出**可编辑**的 Slot 字段 → 选一个 → 输入新值 → 校验后保存(并留时间戳备份)。
.DESCRIPTION
装配:Get-BakNRetCatalogField(读)→ 菜单选字段 → Read-BakNRetLine(输入新值)
→ Set-BakNRetCatalogField(只改那一行)→ Save-BakNRetConfigFile(校验通过才原子替换)。
菜单里**只列可编辑的字段**(单行的 Encrypt / Description):把只读的 Path / Exclude 也列出来
再拒绝,会让每个只读项浪费用户一次回车。可编辑面按"能不能安全往返"定,见 Get-BakNRetCatalogField。
校验用模块自己的 Import-BaknretDataFile:这份名录里有 `$( )` 动态表达式与跨行拼接,普通的数据
文件读取器读不了 —— 而正是它保证"改完之后整份文件还能被程序读回来"(值能读回来才算改成功)。
定位用 (软件名, Slot 名, 字段名) 三元组(菜单键里用 | 拼起来),因为同一软件可以有多个 Slot。
取消(选字段或输入时按 Esc)→ 什么都不写、也不产生备份。
#>
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)]$Driver,
[switch]$WhatIf
)
if (-not (Test-Path -LiteralPath $Path)) {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = "找不到名录文件:$Path"; BackupPath = $null }
}
$text = Get-Content -LiteralPath $Path -Raw -Encoding UTF8
$fields = @(Get-BakNRetCatalogField -Text $text | Where-Object { $_.Editable })
if ($fields.Count -eq 0) {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = '名录里没有可编辑的字段(只有单行的 Encrypt / Description 可改)。'; BackupPath = $null }
}
$items = @(foreach ($field in $fields) {
[pscustomobject]@{
Key = ('{0}|{1}|{2}' -f $field.App, $field.Slot, $field.Key)
Text = ('{0,-20} {1,-12} = {2}' -f $field.App, $field.Key, $field.Value)
}
})
$picked = Invoke-BakNRetMenu -Title ('名录:{0}' -f $Path) -Items $items -Driver $Driver -MultiSelect $false -Hint '↑↓ 选字段 回车 改值 Esc 取消'
if ($picked.Action -ne 'confirm') {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = ''; BackupPath = $null }
}
$parts = @([string]$picked.Chosen.Key -split '\|')
if ($parts.Count -ne 3) {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = ('菜单项的形状不对:{0}' -f $picked.Chosen.Key); BackupPath = $null }
}
$field = $fields | Where-Object { $_.App -eq $parts[0] -and $_.Slot -eq $parts[1] -and $_.Key -eq $parts[2] } | Select-Object -First 1
Write-BakNRetAt -X 0 -Y 0 -Text ("{0}.{1}.{2} 当前是 {3};输入新值后回车(Encrypt 用 `$true / `$false,Description 用带引号的字符串),Esc 取消:" -f $parts[0], $parts[1], $parts[2], $field.Value) -ForegroundColor Cyan
$newValue = Read-BakNRetLine -Driver $Driver
if ($null -eq $newValue) {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = ''; BackupPath = $null }
}
if ($newValue -eq $field.Value) {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = '值没有变化,没有要保存的内容。'; BackupPath = $null }
}
$newText = Set-BakNRetCatalogField -Text $text -App $parts[0] -Slot $parts[1] -Key $parts[2] -Value $newValue
$validate = {
param($candidate)
$probePath = Join-Path ([System.IO.Path]::GetTempPath()) ('bnr-cat-' + [guid]::NewGuid().ToString('N').Substring(0, 8) + '.psd1')
try {
[System.IO.File]::WriteAllText($probePath, $candidate, (New-Object System.Text.UTF8Encoding($true)))
$null = Import-BaknretDataFile -Path $probePath
return @()
}
catch {
return @('改完之后读不回来:' + $_.Exception.Message)
}
finally {
Remove-Item -LiteralPath $probePath -Force -ErrorAction SilentlyContinue
}
}
$saved = Save-BakNRetConfigFile -Path $Path -Text $newText -Validate $validate `
-BackupDirectory (Join-Path (Split-Path -Parent $Path) 'logs\config-backups') -WhatIf:$WhatIf
return [pscustomobject]@{
Saved = $saved.Saved
Changed = $true
Error = $saved.Error
BackupPath = $saved.BackupPath
}
}
@@ -0,0 +1,85 @@
function Invoke-BakNRetConfigEditor {
<#
.SYNOPSIS
配置编辑器:列出单行标量设置 → 选一个 → 输入新值 → 校验后保存(并留时间戳备份)。
.DESCRIPTION
装配:Get-BakNRetConfigSetting(读)→ Invoke-BakNRetMenu(选)→ Read-BakNRetLine(输入)
→ Set-BakNRetConfigSetting(只改那一行)→ Save-BakNRetConfigFile(校验通过才原子替换)。
输入行**从空开始**、当前值只作为提示显示:预填当前值看着贴心,实际上等于逼用户先删一遍
(对 `'Backups'` 这种带引号的原文尤其别扭)。
校验用 Import-PowerShellDataFile:这份配置里没有动态表达式,能被它读进来是个**强校验**
—— 比"语法能不能过"更强,它还能抓出"值是合法语法、但结构不对"。它只接受文件路径,所以
先把候选文本写到临时文件再读。
取消(选设置或输入时按 Esc)→ 什么都不写、也不产生备份。
值没变 → 直接返回"没有变化",不做无意义的写盘与备份。
#>
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)]$Driver,
[switch]$WhatIf
)
if (-not (Test-Path -LiteralPath $Path)) {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = "找不到配置文件:$Path"; BackupPath = $null }
}
$text = Get-Content -LiteralPath $Path -Raw -Encoding UTF8
$rows = @(Get-BakNRetConfigSetting -Text $text)
if ($rows.Count -eq 0) {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = '配置里没有可编辑的单行标量设置。'; BackupPath = $null }
}
$items = @(foreach ($row in $rows) {
[pscustomobject]@{ Key = $row.Path; Text = ('{0,-26} = {1}' -f $row.Path, $row.Value) }
})
$picked = Invoke-BakNRetMenu -Title ('配置:{0}' -f $Path) -Items $items -Driver $Driver -MultiSelect $false -Hint '↑↓ 选设置 回车 改值 Esc 取消'
if ($picked.Action -ne 'confirm') {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = ''; BackupPath = $null }
}
$setting = $rows | Where-Object { $_.Path -eq [string]$picked.Chosen.Key } | Select-Object -First 1
Write-BakNRetAt -X 0 -Y 0 -Text ("{0} 当前是 {1};输入新值后回车确认,Esc 取消(留空表示空串):" -f $setting.Path, $setting.Value) -ForegroundColor Cyan
$newValue = Read-BakNRetLine -Driver $Driver
if ($null -eq $newValue) {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = ''; BackupPath = $null }
}
if ($newValue -eq $setting.Value) {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = '值没有变化,没有要保存的内容。'; BackupPath = $null }
}
$newText = Set-BakNRetConfigSetting -Text $text -Path $setting.Path -Value $newValue
$validate = {
param($candidate)
$probePath = Join-Path ([System.IO.Path]::GetTempPath()) ('bnr-cfg-' + [guid]::NewGuid().ToString('N').Substring(0, 8) + '.psd1')
try {
[System.IO.File]::WriteAllText($probePath, $candidate, (New-Object System.Text.UTF8Encoding($true)))
$null = Import-PowerShellDataFile -Path $probePath -ErrorAction Stop
return @()
}
catch {
return @('改完之后读不回来:' + $_.Exception.Message)
}
finally {
Remove-Item -LiteralPath $probePath -Force -ErrorAction SilentlyContinue
}
}
$saved = Save-BakNRetConfigFile -Path $Path -Text $newText -Validate $validate `
-BackupDirectory (Join-Path (Split-Path -Parent $Path) 'logs\config-backups') -WhatIf:$WhatIf
return [pscustomobject]@{
Saved = $saved.Saved
Changed = $true
Error = $saved.Error
BackupPath = $saved.BackupPath
}
}
@@ -0,0 +1,56 @@
function Invoke-BakNRetEntryScript {
<#
.SYNOPSIS
在**子进程**里跑另一个入口脚本,并把它的退出码带回来。
.DESCRIPTION
为什么要起子进程(而不是 `& $target`):入口脚本结尾都是 `exit`,同进程调用会把调用方
一起带走;而且实测子脚本的 `exit` **不会**把退出码传给父脚本的 `$LASTEXITCODE` ——
`&` 转发会让"失败"变成"成功",而计划任务正是靠退出码判断成败。
`-Capture` 决定子进程的输出怎么走,两种场合各有正确解:
* **交互式**(默认):让子进程直接继承控制台最自然 —— 输出实时、顺序正确、还能接键盘。
* **`-Quiet`**:调用方要拿输出(管道 / 日志 / 测试),必须显式重定向再转发 ——
父进程的 stdout 常常是管道,而 .NET 起的进程默认只继承控制台、**不继承管道**,
不重定向的话子进程的输出到不了调用方(实测踩到)。
转发用**异步读**:同步先读完 stdout 再读 stderr,会在子进程写满另一个管道时死锁。
代价是 stdout/stderr 的相对顺序不再保留。
#>
param(
[Parameter(Mandatory = $true)][string]$Path,
[string[]]$ArgumentList = @(),
[switch]$Capture
)
$hostExe = (Get-Process -Id $PID).Path
$allArguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $Path) + @($ArgumentList)
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
$startInfo.FileName = $hostExe
# 5.1 上 ProcessStartInfo.ArgumentList 不存在,所以自己引号化
$startInfo.Arguments = ConvertTo-BakNRetNativeArgumentString -ArgumentList $allArguments
$startInfo.UseShellExecute = $false
$startInfo.RedirectStandardOutput = [bool]$Capture
$startInfo.RedirectStandardError = [bool]$Capture
$process = New-Object System.Diagnostics.Process
$process.StartInfo = $startInfo
[void]$process.Start()
if ($Capture) {
$standardOutputTask = $process.StandardOutput.ReadToEndAsync()
$standardErrorTask = $process.StandardError.ReadToEndAsync()
$process.WaitForExit()
$standardOutput = $standardOutputTask.Result
$standardError = $standardErrorTask.Result
if ($standardOutput) { Write-Host -NoNewline $standardOutput }
if ($standardError) { [Console]::Error.Write($standardError) }
}
else {
$process.WaitForExit()
}
return $process.ExitCode
}
+92
View File
@@ -0,0 +1,92 @@
function Invoke-BakNRetMenu {
<#
.SYNOPSIS
画一个行内菜单并把按键循环跑到底,返回用户选了什么。
.DESCRIPTION
形态是**行内**(ADR-0010):菜单按普通行往下打,之后每次按键只把那几行**原地重画**
(记下起始行,再 SetCursorPosition 回去)。不做全屏、不切备用缓冲区 —— 崩了的时候滚屏里
还留着上文,而"终端被 TUI 弄乱"是这类代码最难排查的故障。
没有控制台(重定向 / 计划任务 / 门禁)时:只打一次菜单、不重画、不抛错,靠 -Driver 里的
`-InputScript` 把按键喂进来。这样同一个循环在真终端和门禁里是**同一条代码路径**。
**绝不 exit、绝不写退出码**:TUI 的异常不该污染备份结果(ADR-0010)。这个函数只返回值,
退出码永远属于 `$failed` 语义。
空菜单直接返回 cancel 并说明原因 —— 没有可选项时不该等按键(第一次使用时归档目录就是空的,
等按键在计划任务里等于挂起)。
#>
param(
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Title,
[Parameter(Mandatory = $true)]$Items,
[Parameter(Mandatory = $true)]$Driver,
[bool]$MultiSelect = $true,
[int]$InitialIndex = 0,
[string[]]$CheckedKeys = @(),
[string]$Hint = '↑↓ 选择 空格 勾选 回车 确认 Esc 返回'
)
$itemList = @($Items | Where-Object { $_ })
if ($itemList.Count -eq 0) {
Write-BakNRetAt -X 0 -Y 0 -Text ("{0}:没有可选项。" -f $Title) -ForegroundColor Yellow
return [pscustomobject]@{ Action = 'cancel'; Index = 0; Checked = @{}; Chosen = $null; Reason = 'empty' }
}
# 有控制台才重画;没有就只打一次(重定向下重画毫无意义,还会把日志弄花)
$canRedraw = -not [Console]::IsOutputRedirected
$blockRow = 0
$checked = @{}
foreach ($k in @($CheckedKeys)) { if ($k) { $checked[$k] = $true } }
$state = [pscustomobject]@{ Index = $InitialIndex; Checked = $checked; Action = 'none' }
$consoleWidth = 80
if ($canRedraw) { try { $consoleWidth = [Console]::WindowWidth } catch { } }
while ($true) {
if ($canRedraw) {
try {
if ($blockRow -gt 0) { [Console]::SetCursorPosition(0, $blockRow) }
}
catch {
# 定位失败就退化成"每次往下打一遍",难看好过失败
$canRedraw = $false
}
}
Write-BakNRetAt -X 0 -Y 0 -Text $Title -ForegroundColor Cyan
if ($canRedraw -and $blockRow -eq 0) { $blockRow = [Console]::CursorTop }
for ($i = 0; $i -lt $itemList.Count; $i++) {
$item = $itemList[$i]
$cursor = if ($i -eq $state.Index) { '>' } else { ' ' }
$box = ''
if ($MultiSelect) {
$box = if ($state.Checked.ContainsKey([string]$item.Key)) { '[x] ' } else { '[ ] ' }
}
$line = ' {0} {1}{2}' -f $cursor, $box, $item.Text
# 按列宽裁剪:超长条目不能把边框/后续内容挤走,也不能把汉字劈成半个
$line = Format-BakNRetPaddedText -Text $line -Width ([Math]::Max(10, $consoleWidth - 1))
if ($i -eq $state.Index) { Write-BakNRetAt -X 0 -Y 0 -Text $line -ForegroundColor White }
else { Write-BakNRetAt -X 0 -Y 0 -Text $line }
}
Write-BakNRetAt -X 0 -Y 0 -Text $Hint -ForegroundColor DarkGray
$key = Read-BakNRetKey -Driver $Driver
$next = Update-BakNRetMenuState -Items $itemList -Index $state.Index -Checked $state.Checked -MultiSelect $MultiSelect -Key $key
$state = [pscustomobject]@{ Index = $next.Index; Checked = $next.Checked; Action = $next.Action }
if ($state.Action -ne 'none') { break }
}
$chosen = if ($state.Action -eq 'confirm') { $itemList[$state.Index] } else { $null }
return [pscustomobject]@{
Action = $state.Action
Index = $state.Index
Checked = $state.Checked
Chosen = $chosen
Reason = ''
}
}
+50
View File
@@ -0,0 +1,50 @@
function Invoke-ExternalCommand {
<#
.SYNOPSIS
运行外部程序并返回其真实退出码。
.DESCRIPTION
不要用 Start-Process -PassThru 取退出码:在 PowerShell 7.7.0-preview.4
上它稳定返回 $null,会把成功的压缩判成失败(旧版 Backup.ps1 的致命问题)。
这里用 .NET Process 直接启动并继承控制台:子进程输出实时可见,
ExitCode 可靠,且不经过 PowerShell 的管道捕获。
注意:不要给子进程做 stdout/stderr 重定向——某些受限环境会拒绝创建管道。
工具自己的输出直接进控制台,结构化记录由日志与 manifest 承担。
#>
param(
[Parameter(Mandatory = $true)][string]$FilePath,
[string[]]$ArgumentList = @(),
[string]$WorkingDirectory
)
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
$startInfo.FileName = $FilePath
$startInfo.Arguments = ConvertTo-BakNRetNativeArgumentString -ArgumentList $ArgumentList
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $false
if ($WorkingDirectory) {
$startInfo.WorkingDirectory = $WorkingDirectory
}
# 打印的那一行必须把口令遮蔽掉:7z / RAR 只接受命令行口令(`-p<口令>`),所以口令
# 必然出现在参数表里;一旦 -Verbose 打开 DEBUG,整条命令行就会落进 logs\*.log ——
# 而 BackupConfig.psd1 与文档都承诺过"口令不落盘、不写进仓库"。真正执行的仍然是
# $startInfo.Arguments,这里只改日志。
#
# 在**参数级别**遮蔽,而不是对拼好的命令行做正则:含空格的口令会被引号包起来
# ("-pmy pass"),正则在那种形态上很容易漏掉,而漏掉的代价是口令明文入日志。
$loggableArguments = @($ArgumentList | ForEach-Object {
if ($_ -is [string] -and $_ -like '-p*') { '-p<口令已隐藏>' } else { $_ }
})
Write-BakNRetLog ('执行: {0} {1}' -f $FilePath, (ConvertTo-BakNRetNativeArgumentString -ArgumentList $loggableArguments)) -Level DEBUG
$process = [System.Diagnostics.Process]::Start($startInfo)
try {
$process.WaitForExit()
return $process.ExitCode
}
finally {
$process.Dispose()
}
}
@@ -0,0 +1,19 @@
function Merge-BakNRetExcludeArgument {
<#
.SYNOPSIS
合并多个归档项展开出来的排除参数并去重(保序)。
#>
param([string[][]]$ArgumentLists = @())
$seen = @{}
$merged = @()
foreach ($list in @($ArgumentLists)) {
foreach ($argument in @($list)) {
if ([string]::IsNullOrWhiteSpace($argument)) { continue }
if ($seen.ContainsKey($argument)) { continue }
$seen[$argument] = $true
$merged += $argument
}
}
return @($merged)
}
@@ -0,0 +1,35 @@
function Move-BakNRetArchiveIntoPlace {
<#
.SYNOPSIS
把临时归档原子地替换到最终路径。
.DESCRIPTION
优先用 File.Move(overwrite)(同卷上是 MoveFileEx + REPLACE_EXISTING,
基本等价于原子替换);不支持时退化为先删后移。
#>
param(
[Parameter(Mandatory = $true)][string]$TempPath,
[Parameter(Mandatory = $true)][string]$DestinationPath
)
if (-not (Test-Path -LiteralPath $DestinationPath)) {
Move-Item -LiteralPath $TempPath -Destination $DestinationPath -Force
return
}
try {
# 7.x:单次原子替换(MoveFileEx + REPLACE_EXISTING)
[System.IO.File]::Move($TempPath, $DestinationPath, $true)
return
}
catch {
Write-BakNRetLog "File.Move(overwrite) 不可用(5.1 没有这个重载),改用 File.Replace:$_" -Level DEBUG
}
# 5.1 走的这条。以前是"先删后移"—— 中途失败会让目标文件消失(旧归档没了、新归档还在
# .tmp 里)。File.Replace 走 ReplaceFile API,在 .NET Framework 上同样可用:要么换成
# 新内容、要么保持旧内容,两个都不会消失。
# 第三个参数必须传 [NullString]::Value —— PowerShell 会把 $null 转成空串,于是 Replace
# 报"路径为空"(两个版本实测都这样)。
[System.IO.File]::Replace($TempPath, $DestinationPath, [NullString]::Value)
}
+41
View File
@@ -0,0 +1,41 @@
function New-BakNRetInputDriver {
<#
.SYNOPSIS
造一个输入驱动:要么按给定的按键序列走(给门禁用),要么读真终端。
.DESCRIPTION
为什么是"驱动对象"而不是模块级状态:这个仓库的原则是**状态属于调用方**(见 ADR-0011)。
界面循环自己持有这个对象,读完就丢;模块只提供读的能力。
-Script 给的是键名序列(例如 @('Down', 'Enter')),它会**按顺序**被消费;
序列用尽而界面还在等输入时必须**报错退出**,绝不能退回去读真终端 —— 那会让门禁挂起,
而挂起比变红糟得多(红会告诉你哪里错了,挂起只会耗掉你的时间)。
#>
param(
[string[]]$Script,
[switch]$NonInteractive
)
$hasConsole = -not [Console]::IsOutputRedirected
if ($NonInteractive -and -not $Script -and $hasConsole) {
# 明确要求非交互,却既没有脚本又坐在真终端前:不猜,直接报错
throw '指定了 -NonInteractive 但没有给出 -Script:没有可用的输入来源。'
}
# 用 List[string] 而不是数组。三个坑都踩过:
# ① `if` 是**语句**不是表达式,写在哈希字面量的值位置上会得不到结果;
# ② @($null) 会造出**一个含 $null 的单元素数组**,于是"空序列"被当成"有一个键";
# ③ **单元素数组存进 [pscustomobject] 会被拆成字符串** —— 于是 $Driver.Script[0] 取到的是
# 字符串的首字母(@('Esc') 变成 'E'),键名不再匹配、循环继续读、最后序列用尽。
# 这个坑只在"恰好一个键"时出现,所以只跑多键序列的断言看不见它(实测栽过一次)。
# List[string] 不会被拆,也能按索引取,两个版本行为一致。
$scriptKeys = [System.Collections.Generic.List[string]]::new()
foreach ($entry in @($Script)) { if ($entry) { $scriptKeys.Add([string]$entry) } }
return [pscustomobject]@{
Script = $scriptKeys
Index = 0
HasConsole = $hasConsole
NonInteractive = [bool]$NonInteractive
}
}
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff. Show more