Compare commits
53
Commits
2937eb6652
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cbcaaee48 | ||
|
|
45bbd66815 | ||
|
|
f4729baca7 | ||
|
|
7149ea691e | ||
|
|
d72fe63c02 | ||
|
|
400f2ad9a5 | ||
|
|
effc38fdd3 | ||
|
|
8c018533c9 | ||
|
|
4aa44e8965 | ||
|
|
92bbc995c4 | ||
|
|
7733b1bf6d | ||
|
|
c2bae35506 | ||
|
|
4e3c0461c0 | ||
|
|
5df40d3341 | ||
|
|
e1fe99671b | ||
|
|
59ec5a21d4 | ||
|
|
54972ab341 | ||
|
|
835fb0cf2b | ||
|
|
6315c72f69 | ||
|
|
347959d677 | ||
|
|
d9d7967e64 | ||
|
|
f2959a4279 | ||
|
|
8a0924330a | ||
|
|
60f2ae3e0f | ||
|
|
187549e2af | ||
|
|
e8f0a5ae19 | ||
|
|
2446c7c5c7 | ||
|
|
520257b5e5 | ||
|
|
120cf3584b | ||
|
|
c4452a749c | ||
|
|
319ec1da78 | ||
|
|
67ee2af006 | ||
|
|
fcf83ecb74 | ||
|
|
bbfece10b6 | ||
|
|
60ecbc2933 | ||
|
|
fb0d93a25f | ||
|
|
35aade63bc | ||
|
|
232a82cd3c | ||
|
|
3a3a57a6a1 | ||
|
|
42f02d0eca | ||
|
|
187d2759fd | ||
|
|
102a3e038d | ||
|
|
2ad7987ffe | ||
|
|
78f43c8cc9 | ||
|
|
10f97246c8 | ||
|
|
8d67a38fb7 | ||
|
|
79f83f6760 | ||
|
|
e17cdcda79 | ||
|
|
d32d4b511f | ||
|
|
8736bb2c67 | ||
|
|
e10503be76 | ||
|
|
2d26f78d15 | ||
|
|
dda36cfae5 |
No files matched your search
@@ -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
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
# 归档本体不进版本库(数 GB,且是随时可重建的产物)
|
||||
# 归档本体不进版本库(数 GB,且是随时可重建的产物)
|
||||
Backups/
|
||||
|
||||
# 运行日志
|
||||
@@ -23,3 +23,9 @@ logs/
|
||||
*.swp
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# 单文件构建产物(tools\Build-BakNRetModule.ps1 可随时重建)
|
||||
dist/
|
||||
# 调研探针的解包产物(NuGet 解包件与 .nupkg):结论进 ADR,二进制不进仓库
|
||||
.scratch/_probe/load/
|
||||
.scratch/_probe/nuget/
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 版本上。
|
||||
@@ -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 $_ } }
|
||||
@@ -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)))
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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' }))
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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 }
|
||||
@@ -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)))
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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 条**,本轮不擅自新增。
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 系夹具错误:孤儿审计只在真实运行里报告,干跑不报"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": "一般"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 的报告生成方独立渲染并留证 |
|
||||
@@ -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
|
||||
@@ -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` |
|
||||
@@ -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
@@ -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
@@ -1,906 +1,64 @@
|
||||
<#
|
||||
<#
|
||||
.SYNOPSIS
|
||||
按 BackupList.txt 执行备份。
|
||||
已改名:本脚本只是转发到 Backup-Data.ps1(这一层只保留一轮)。
|
||||
|
||||
.DESCRIPTION
|
||||
与旧版相比的核心变化:
|
||||
为什么留一层转发(ADR-0012):入口脚本是**外部接口** —— README 里有二十多处引用、有使用者的
|
||||
肌肉记忆、tools\Register-BackupTask.ps1 里也可能已经注册过这个路径。内部实现改名断了会当场
|
||||
报错;外部接口改名断了是**静默没用**,而备份工具"静默没用"是最不能接受的失败方式。
|
||||
|
||||
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. 磁盘空间守卫:放不下就拒绝该条目,低于阈值则告警。
|
||||
为什么用子进程、而不是 `& $target`:实测 `& script.ps1` 里子脚本的 exit **不会**把退出码传到
|
||||
父脚本的 $LASTEXITCODE —— 垫片会让失败变成"成功"(错配置时返回 0,被调用脚本返回 1),而计划
|
||||
任务正是靠退出码判断成败。
|
||||
|
||||
与 SoftwareCatalog.psd1 的 Slot 结构配套:
|
||||
* 一个软件 = 一个归档,归档内是 `<Slot>\<该 Path 的内容>`;
|
||||
* 打包前用暂存目录 + junction / 硬链接把 Slot 名变成包里真实的目录名
|
||||
(7z 没有"入库时改名"的能力),打包后立刻拆掉暂存目录;
|
||||
* 清单行首 `+` = 仅备份、`-` = 仅恢复。
|
||||
为什么重定向之后要**自己转发**:父进程的 stdout 常常是管道(测试与使用者的管道都在解析入口的
|
||||
输出),而 .NET 起的进程默认只继承控制台、不继承那个管道 —— 不重定向时子进程的输出就到不了
|
||||
调用方(实测红过)。所以显式重定向,再用**异步读**把两个流读出来转发(同步先读 stdout 再读
|
||||
stderr 会在管道写满时死锁)。代价是 stdout/stderr 的相对顺序不再保留 —— 这也正是这层垫片
|
||||
只留一轮的原因之一。
|
||||
|
||||
为什么导入模块时临时压掉 verbose:调用方可能给入口传 -Verbose(测试就是这么拿到详细日志的),
|
||||
那样 Import-Module 会多打一行 "VERBOSE: Loading module from path ..." —— 而测试是**解析子进程
|
||||
输出**做断言的,多这么一行就会把它顶掉。只压这一句,$Rest 里的 -Verbose 仍会原样转发。
|
||||
|
||||
这一层下一轮删。想用新名字就直接调 Backup-Data.ps1。
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter()]
|
||||
[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
|
||||
[Parameter(ValueFromRemainingArguments = $true)]$Rest
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# ============================================================================
|
||||
# 载入依赖
|
||||
# ============================================================================
|
||||
|
||||
$modulePath = Join-Path $PSScriptRoot 'Common.psm1'
|
||||
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
|
||||
|
||||
function Resolve-ConfigPath {
|
||||
param([string]$Path, [string]$Default)
|
||||
$value = if ($Path) { $Path } else { $Default }
|
||||
if (-not [System.IO.Path]::IsPathRooted($value)) {
|
||||
$value = Join-Path $PSScriptRoot $value
|
||||
}
|
||||
return $value
|
||||
}
|
||||
|
||||
if (-not $BackupDir) { $BackupDir = Resolve-ConfigPath -Path $null -Default $script:Config.BackupDir }
|
||||
$logDir = Resolve-ConfigPath -Path $null -Default $script:Config.LogDir
|
||||
$snapshotDir = Resolve-ConfigPath -Path $null -Default $script:Config.SnapshotDir
|
||||
$catalogPath = Resolve-CatalogPath -Configured $script:Config.SoftwareCatalog -Root $PSScriptRoot
|
||||
$manifestPath = Join-Path $BackupDir 'manifest.json'
|
||||
|
||||
$logPath = Start-BaknretLog -Directory $logDir -Prefix 'backup'
|
||||
Write-Log "日志文件:$logPath"
|
||||
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
|
||||
$savedVerbosePreference = $VerbosePreference
|
||||
$VerbosePreference = 'SilentlyContinue'
|
||||
Import-Module (Join-Path $PSScriptRoot 'BakNRet\BakNRet.psd1') -Force
|
||||
$VerbosePreference = $savedVerbosePreference
|
||||
|
||||
$target = Join-Path $PSScriptRoot 'Backup-Data.ps1'
|
||||
Write-Host '注意:Backup.ps1 已改名为 Backup-Data.ps1(这层转发只保留一轮)。' -ForegroundColor Yellow
|
||||
|
||||
$hostExe = (Get-Process -Id $PID).Path
|
||||
$forwardArguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $target) + @($Rest) + @(if ($PSBoundParameters.ContainsKey('Verbose')) { '-Verbose' }) + @(if ($PSBoundParameters.ContainsKey('Debug')) { '-Debug' })
|
||||
|
||||
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$startInfo.FileName = $hostExe
|
||||
$startInfo.Arguments = ConvertTo-BakNRetNativeArgumentString -ArgumentList $forwardArguments
|
||||
$startInfo.UseShellExecute = $false
|
||||
$startInfo.RedirectStandardOutput = $true
|
||||
$startInfo.RedirectStandardError = $true
|
||||
|
||||
$process = New-Object System.Diagnostics.Process
|
||||
$process.StartInfo = $startInfo
|
||||
[void]$process.Start()
|
||||
|
||||
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
|
||||
$stderrTask = $process.StandardError.ReadToEndAsync()
|
||||
$process.WaitForExit()
|
||||
|
||||
$standardOutput = $stdoutTask.Result
|
||||
$standardError = $stderrTask.Result
|
||||
if ($standardOutput) { Write-Host -NoNewline $standardOutput }
|
||||
if ($standardError) { [Console]::Error.Write($standardError) }
|
||||
|
||||
exit $process.ExitCode
|
||||
+7
-7
@@ -1,4 +1,4 @@
|
||||
<#
|
||||
<#
|
||||
BakNRet 配置文件。
|
||||
|
||||
优先级:命令行参数 > 本文件 > 代码内置默认值。
|
||||
@@ -17,11 +17,6 @@
|
||||
# 软件名录("软件名 -> 目录"映射表)。相对路径按本配置所在目录解析。
|
||||
# BackupList.txt 里写软件名时靠它换成真实目录,归档名也取软件名。
|
||||
SoftwareCatalog = 'SoftwareCatalog.psd1'
|
||||
|
||||
# 名录里写的目录不存在时,按 "<名>_*" / "<名>-*" 向下找几层做前缀补全
|
||||
# (应对 legendary -> legendary_2.0.4 这类带后缀的目录)
|
||||
CatalogMaxDepth = 5
|
||||
|
||||
# 低于这个剩余空间(GB)就告警;真正放不下某个条目时会直接跳过该条目
|
||||
MinFreeSpaceGB = 5
|
||||
|
||||
@@ -54,7 +49,12 @@
|
||||
# 口令本身按以下优先级获取(见 README「加密」):
|
||||
# 1. -Password 命令行参数
|
||||
# 2. $env:BAKNRET_PASSWORD
|
||||
# 3. PasswordFile 指向的文件首行(文件必须在仓库之外)
|
||||
# 3. PasswordFile 指向的文件首行 (首行即口令)
|
||||
#
|
||||
# 出厂默认值就是仓库根的 baknret.key,靠 .gitignore 的 *.key 兜住「不被提交」。
|
||||
# 这是**取舍**而非疏忽:留在仓库根最省事(口令与配置在一起,搬家不容易丢),代价是
|
||||
# 「不提交」这件事依赖一个规则文件 —— 谁写了 git add -f、或把整个目录复制到别处再
|
||||
# 初始化仓库,口令就会跟着走。要更稳就把文件放到仓库外,用下面的 B 或 A。
|
||||
# 4. 交互式询问(仅交互式会话;计划任务里不会停下来等输入)
|
||||
# 全都拿不到时该条目明确失败,绝不退化成明文归档。
|
||||
#
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
###########
|
||||
###########
|
||||
# BackupList.txt —— 备份清单
|
||||
###########
|
||||
#
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 = @{}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
function Exit-BakNRetRunLock {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
释放运行锁。锁文件本身留着 —— 它的内容是最后一次持有者的线索,删不删都无所谓。
|
||||
#>
|
||||
param($Lock)
|
||||
|
||||
if (-not $Lock) { return }
|
||||
try {
|
||||
$Lock.Dispose()
|
||||
}
|
||||
catch {
|
||||
Write-BakNRetLog "释放运行锁失败(进程退出时会自动释放):$_" -Level WARN
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
function Get-BakNRetLogPath {
|
||||
<# .SYNOPSIS 返回当前日志文件路径(未启用时返回 $null)。 #>
|
||||
return $script:LogConfig.FilePath
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = ''
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user