diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..40d052b --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..cb1eb56 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/Backup.ps1 b/Backup.ps1 index fe51df4..18884d8 100644 --- a/Backup.ps1 +++ b/Backup.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS 按 BackupList.txt 执行备份。 @@ -27,13 +27,13 @@ [CmdletBinding()] param( [Parameter()] - [string]$BackupListPath = (Join-Path $PSScriptRoot 'BackupList.txt'), + [string]$BackupListPath, [Parameter()] [string]$BackupDir, [Parameter()] - [string]$ConfigPath = (Join-Path $PSScriptRoot 'BackupConfig.psd1'), + [string]$ConfigPath, [Parameter()] [string]$KeyFile, @@ -73,6 +73,13 @@ param( $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' } + # ============================================================================ # 载入依赖 # ============================================================================ diff --git a/BackupConfig.psd1 b/BackupConfig.psd1 index 390ec59..550c075 100644 --- a/BackupConfig.psd1 +++ b/BackupConfig.psd1 @@ -1,4 +1,4 @@ -<# +<# BakNRet 配置文件。 优先级:命令行参数 > 本文件 > 代码内置默认值。 diff --git a/BackupList.txt b/BackupList.txt index 2be240b..6788460 100644 --- a/BackupList.txt +++ b/BackupList.txt @@ -1,4 +1,4 @@ -########### +########### # BackupList.txt —— 备份清单 ########### # diff --git a/Common.psm1 b/Common.psm1 index 5113d64..85dfd34 100644 --- a/Common.psm1 +++ b/Common.psm1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS BakNRet —— 备份 / 恢复脚本的公共功能模块。 diff --git a/Restore.ps1 b/Restore.ps1 index 303e12f..53e448b 100644 --- a/Restore.ps1 +++ b/Restore.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS 按 BackupList.txt 执行恢复。 @@ -25,13 +25,13 @@ [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] param( [Parameter()] - [string]$BackupListPath = (Join-Path $PSScriptRoot 'BackupList.txt'), + [string]$BackupListPath, [Parameter()] [string]$BackupDir, [Parameter()] - [string]$ConfigPath = (Join-Path $PSScriptRoot 'BackupConfig.psd1'), + [string]$ConfigPath, [Parameter()] [string]$KeyFile, @@ -61,6 +61,13 @@ param( $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' } + if ($DryRun) { $WhatIfPreference = $true } # ============================================================================ diff --git a/SoftwareCatalog.psd1 b/SoftwareCatalog.psd1 index c37ebcb..01cf1d1 100644 --- a/SoftwareCatalog.psd1 +++ b/SoftwareCatalog.psd1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS 软件目录清单(SoftwareCatalog)。 diff --git a/test.ps1 b/test.ps1 new file mode 100644 index 0000000..9c5ec1f --- /dev/null +++ b/test.ps1 @@ -0,0 +1,253 @@ +<# +.SYNOPSIS + 跑 BakNRet 的全部验收层次,两个 PowerShell 版本都覆盖。 + +.DESCRIPTION + BakNRet 同时支持 Windows PowerShell 5.1 与 PowerShell 7.x,而这两个版本会踩到 + 不同的坑(源码编码、Get-Content 的默认编码、File.Move 的重载、原子替换的回退路径)。 + 所以「验收」不是一句命令,而是分层的: + + Parse 全仓 .ps1 / .psm1 / .psd1 在 5.1 与 7 上解析零错误 + Unit Pester 套件(需要 Pester 5+,优先用仓库内 .tools/ 的副本) + Smoke 零依赖套件(只要 PowerShell 与 7z) + E2E 端到端验收:备份 → 确认排除生效 → 删源 → 恢复 → 逐字节对拍 + Drill 真实归档恢复演练(只读,但依赖本机真实归档与活源,所以不在 All 里) + + -Suite All 等价于 Parse + Unit + Smoke + E2E。Drill 必须显式点名。 + +.PARAMETER Suite + 要跑哪些层次,可多选。 + +.PARAMETER PSVersion + 在哪些宿主上跑:7、5.1 或 Both(默认)。所有层次都会在点名的宿主上各跑一遍; + 某个宿主缺失或跑不动时报明确的失败,而不是静默跳过。 + +.PARAMETER Quiet + 只打印每层的结论,不把各套件的完整输出透出来。 + +.EXAMPLE + .\test.ps1 + +.EXAMPLE + .\test.ps1 -Suite Parse + +.EXAMPLE + .\test.ps1 -Suite E2E -PSVersion 5.1 +#> +[CmdletBinding()] +param( + [ValidateSet('All', 'Parse', 'Unit', 'Smoke', 'E2E', 'Drill')] + [string[]]$Suite = @('All'), + + [ValidateSet('7', '5.1', 'Both')] + [string]$PSVersion = 'Both', + + [switch]$Quiet +) + +$ErrorActionPreference = 'Stop' +$root = $PSScriptRoot + +# ---------------------------------------------------------------- 参数展开 + +$suites = [System.Collections.Generic.List[string]]::new() +foreach ($name in $Suite) { + $expanded = if ($name -eq 'All') { @('Parse', 'Unit', 'Smoke', 'E2E') } else { @($name) } + foreach ($item in $expanded) { + if (-not $suites.Contains($item)) { $suites.Add($item) } + } +} + +$hosts = if ($PSVersion -eq 'Both') { @('7', '5.1') } else { @($PSVersion) } + +$exeOf = @{ '7' = 'pwsh'; '5.1' = 'powershell' } +foreach ($h in $hosts) { + if (-not (Get-Command $exeOf[$h] -ErrorAction SilentlyContinue)) { + Write-Error ("找不到 {0},无法在 {1} 上验收。装好它,或用 -PSVersion 指定另一个宿主。" -f $exeOf[$h], $h) + exit 2 + } +} + +$script:Results = [System.Collections.Generic.List[object]]::new() + +function Add-Result { + param([string]$Layer, [string]$HostName, [bool]$Ok, [string]$Detail) + + $script:Results.Add([pscustomobject]@{ Layer = $Layer; HostName = $HostName; Ok = $Ok; Detail = $Detail }) + $mark = if ($Ok) { 'PASS' } else { 'FAIL' } + $color = if ($Ok) { 'Green' } else { 'Red' } + Write-Host (" [{0}] {1,-5} {2,-4} {3}" -f $mark, $Layer, $HostName, $Detail) -ForegroundColor $color +} + +function Invoke-ScriptInHost { + <# + 在指定宿主里用独立进程跑一个脚本,返回退出码。 + 独立进程是刻意的:Backup.ps1 / Restore.ps1 结尾都会 exit,同进程调用会把本脚本一起带走。 + #> + param( + [string]$HostName, + [string]$ScriptPath, + [string[]]$Arguments = @() + ) + + $stdout = Join-Path $env:TEMP ("baknret-test-{0}.log" -f [guid]::NewGuid().ToString('N')) + $argumentList = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $ScriptPath) + $Arguments + + try { + if ($Quiet) { + & $exeOf[$HostName] @argumentList *> $null + } else { + & $exeOf[$HostName] @argumentList 2>&1 | Tee-Object -FilePath $stdout | Out-Null + } + return $LASTEXITCODE + } finally { + Remove-Item -LiteralPath $stdout -Force -ErrorAction SilentlyContinue + } +} + +# ---------------------------------------------------------------- 各层实现 + +$parseProbeSource = @' +$root = $args[0] +$exclude = '\\(\.git|\.tools|\.scratch|Backups|logs|dist)\\' +# 注意:这里不能用 -Include 筛扩展名。5.1 与 7 的语义不同:-LiteralPath 上的 -Include +# 在 5.1 里会被忽略,于是 .md / .txt / .editorconfig / unattend.xml 全被拿去 ParseFile, +# 报一堆与 PowerShell 无关的假错(实测 40 个「文件」里 9 个不是 PowerShell)。 +$powerShellExtensions = @('.ps1', '.psm1', '.psd1') +$files = @(Get-ChildItem -LiteralPath $root -Recurse -File | + Where-Object { $powerShellExtensions -contains $_.Extension -and $_.FullName -notmatch $exclude }) +$bad = 0 +foreach ($file in $files) { + $errors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$null, [ref]$errors) + if ($errors -and $errors.Count) { + $bad++ + Write-Output (" [!] {0} : {1}" -f $file.FullName.Substring($root.Length).TrimStart('\'), $errors[0].Message) + } +} +Write-Output ("SUMMARY={0}/{1}" -f ($files.Count - $bad), $files.Count) +'@ + +function Invoke-EncodingCheck { + <# + 编码规则检查:与宿主无关,只跑一次。 + 规则见 tools\Set-SourceEncoding.ps1:受管文件必须有 BOM、行尾必须是 LF、不许有制表符。 + 「必须有 BOM」这条尤其重要:它一旦丢失,5.1 上会以各种莫名其妙的方式失败, + 而在 7 上完全正常 —— 实测就是这么漏过去的。 + #> + $excluded = '\\(\.git|\.tools|\.scratch|Backups|logs|dist|snapshots)\\' + $targets = @(Get-ChildItem -LiteralPath $root -Recurse -File | + Where-Object { $_.Extension -in '.ps1', '.psm1', '.psd1' -or $_.Name -eq 'BackupList.txt' } | + Where-Object { $_.FullName -notmatch $excluded }) + + $noBom = [System.Collections.Generic.List[string]]::new() + $crlf = [System.Collections.Generic.List[string]]::new() + $tabs = [System.Collections.Generic.List[string]]::new() + + foreach ($file in $targets) { + $relative = $file.FullName.Substring($root.Length).TrimStart('\') + $bytes = [System.IO.File]::ReadAllBytes($file.FullName) + if (-not ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF)) { + $noBom.Add($relative) + } + $text = (New-Object System.Text.UTF8Encoding($false)).GetString($bytes) + if ($text -match "`r`n") { $crlf.Add($relative) } + if ($text -match "`t") { $tabs.Add($relative) } + } + + foreach ($item in $noBom) { Write-Host (" 缺 BOM:{0}" -f $item) -ForegroundColor DarkGray } + foreach ($item in $crlf) { Write-Host (" 有 CRLF:{0}" -f $item) -ForegroundColor DarkGray } + foreach ($item in $tabs) { Write-Host (" 有制表符:{0}" -f $item) -ForegroundColor DarkGray } + + $ok = ($noBom.Count + $crlf.Count + $tabs.Count) -eq 0 + Add-Result -Layer 'Encode' -HostName 'any' -Ok $ok ` + -Detail ("受管 {0} 个文件:缺 BOM {1}、CRLF {2}、制表符 {3}" -f $targets.Count, $noBom.Count, $crlf.Count, $tabs.Count) +} + +function Invoke-ParseLayer { + param([string]$HostName) + + if (-not $script:EncodingChecked) { + $script:EncodingChecked = $true + Invoke-EncodingCheck + } + + $probe = Join-Path $env:TEMP 'baknret-parse-gate.ps1' + Set-Content -LiteralPath $probe -Value $parseProbeSource -Encoding ascii + + try { + $output = & $exeOf[$HostName] -NoProfile -ExecutionPolicy Bypass -File $probe $root 2>&1 + $summary = @($output | Where-Object { $_ -match '^SUMMARY=' }) + $failures = @($output | Where-Object { $_ -match '^\s+\[!\]' }) + + if (-not $summary) { + Add-Result -Layer 'Parse' -HostName $HostName -Ok $false -Detail '探针没有给出结论' + return + } + + $numbers = $summary[-1] -replace '^SUMMARY=', '' + $ok = ($numbers -split '/')[0] -eq ($numbers -split '/')[1] + if ($failures.Count -gt 0) { $failures | ForEach-Object { Write-Host $_ -ForegroundColor DarkGray } } + Add-Result -Layer 'Parse' -HostName $HostName -Ok $ok -Detail ("解析通过 {0} 个文件" -f $numbers) + } finally { + Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue + } +} + +function Invoke-SuiteLayer { + param( + [string]$Layer, + [string]$HostName, + [string]$RelativeScript, + [string[]]$Arguments = @() + ) + + $path = Join-Path $root $RelativeScript + if (-not (Test-Path -LiteralPath $path)) { + Add-Result -Layer $Layer -HostName $HostName -Ok $false -Detail ("找不到 {0}" -f $RelativeScript) + return + } + + # 套件自己会在结尾 exit:0 全绿、1 有失败、2 缺依赖 + $code = Invoke-ScriptInHost -HostName $HostName -ScriptPath $path -Arguments $Arguments + $detail = switch ($code) { + 0 { $RelativeScript } + 2 { ("{0} —— 缺少依赖(退出码 2)" -f $RelativeScript) } + default { ("{0} —— 退出码 {1}" -f $RelativeScript, $code) } + } + Add-Result -Layer $Layer -HostName $HostName -Ok ($code -eq 0) -Detail $detail +} + +# ---------------------------------------------------------------- 跑 + +Write-Host '' +Write-Host ("BakNRet 验收:层次 [{0}],宿主 [{1}]" -f ($suites -join ', '), ($hosts -join ', ')) -ForegroundColor Cyan +Write-Host '' + +foreach ($hostName in $hosts) { + foreach ($layer in $suites) { + switch ($layer) { + 'Parse' { Invoke-ParseLayer -HostName $hostName } + 'Unit' { Invoke-SuiteLayer -Layer 'Unit' -HostName $hostName -RelativeScript 'tests\Run-Pester.ps1' -Arguments @('-Verbosity', 'Minimal') } + 'Smoke' { Invoke-SuiteLayer -Layer 'Smoke' -HostName $hostName -RelativeScript 'tests\Run-Tests.ps1' } + 'E2E' { Invoke-SuiteLayer -Layer 'E2E' -HostName $hostName -RelativeScript 'tests\Run-E2E.ps1' } + 'Drill' { Invoke-SuiteLayer -Layer 'Drill' -HostName $hostName -RelativeScript 'tests\Restore-Drill.ps1' } + } + } +} + +# ---------------------------------------------------------------- 汇总 + +$failed = @($script:Results | Where-Object { -not $_.Ok }) + +Write-Host '' +Write-Host ('=' * 64) +if ($failed.Count -eq 0) { + Write-Host ("验收全部通过:{0} 项(宿主 {1})" -f $script:Results.Count, ($hosts -join ' + ')) -ForegroundColor Green +} else { + Write-Host ("验收有失败:{0} 项里失败 {1} 项" -f $script:Results.Count, $failed.Count) -ForegroundColor Red + $failed | ForEach-Object { Write-Host (" - {0} @ {1}:{2}" -f $_.Layer, $_.HostName, $_.Detail) -ForegroundColor Red } +} +Write-Host ('=' * 64) + +exit $(if ($failed.Count -eq 0) { 0 } else { 1 }) diff --git a/tests/BakNRet.Formats.Tests.ps1 b/tests/BakNRet.Formats.Tests.ps1 index 2e102a9..47c6566 100644 --- a/tests/BakNRet.Formats.Tests.ps1 +++ b/tests/BakNRet.Formats.Tests.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS 清单与名录的"格式契约"Pester 测试:软件名 / 手写路径两种写法, Slot 形状的 SoftwareCatalog.psd1,以及 `::` / `:-` / `:+` / `:encrypt` / `@ Key='Value'`。 diff --git a/tests/BakNRet.Security.Tests.ps1 b/tests/BakNRet.Security.Tests.ps1 index 6d82399..a6c67c5 100644 --- a/tests/BakNRet.Security.Tests.ps1 +++ b/tests/BakNRet.Security.Tests.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS 安全描述符(NTFS 属主 / ACL)的测试套件。 diff --git a/tests/BakNRet.Tests.ps1 b/tests/BakNRet.Tests.ps1 index 0b5fb2c..9df11af 100644 --- a/tests/BakNRet.Tests.ps1 +++ b/tests/BakNRet.Tests.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS BakNRet 的 Pester 测试套件(单元 + 集成 + 回归)。 diff --git a/tests/Restore-Drill.ps1 b/tests/Restore-Drill.ps1 index afa8fd3..309112f 100644 --- a/tests/Restore-Drill.ps1 +++ b/tests/Restore-Drill.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS 真实归档的恢复演练:把**硬盘上真实的归档**恢复到临时目标,再和活的源目录逐字节对拍。 @@ -53,7 +53,7 @@ param( 'D:\UserData\Documents\Aria' ), - [string]$ConfigPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'BackupConfig.psd1'), + [string]$ConfigPath, [string]$WorkRoot, @@ -66,6 +66,13 @@ param( $ErrorActionPreference = 'Stop' $projectRoot = Split-Path -Parent $PSScriptRoot + +# 默认值不能写在 param() 里:Windows PowerShell 5.1 在带 [CmdletBinding()] 的脚本上, +# 参数绑定阶段还没有给 $PSScriptRoot 赋值,默认值表达式会拿到空串(实测:带 +# [CmdletBinding()] -> 空串,不带 -> 正常;PowerShell 7 两种都正常)。所以默认值 +# 一律在这里补 —— 这也是本仓库对 -BackupDir / -ConfigPath 一直在用的写法。 +if (-not $ConfigPath) { $ConfigPath = Join-Path $projectRoot 'BackupConfig.psd1' } + $restoreScript = Join-Path $projectRoot 'Restore.ps1' Import-Module (Join-Path $projectRoot 'Common.psm1') -Force diff --git a/tests/Run-E2E.ps1 b/tests/Run-E2E.ps1 index 0fd5602..7494923 100644 --- a/tests/Run-E2E.ps1 +++ b/tests/Run-E2E.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS BakNRet 端到端验收:真实备份 -> 校验归档布局与排除 -> 删源 -> 恢复 -> 逐字节对拍。 diff --git a/tests/Run-Pester.ps1 b/tests/Run-Pester.ps1 index 1f15321..6bf54e9 100644 --- a/tests/Run-Pester.ps1 +++ b/tests/Run-Pester.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS 跑 BakNRet 的 Pester 测试套件(tests/BakNRet.Tests.ps1)。 @@ -27,11 +27,17 @@ param( [string[]]$Tag, # 默认跑 tests/ 下所有 *.Tests.ps1(Pester 按这个命名约定发现) - [string]$TestPath = $PSScriptRoot + [string]$TestPath ) $ErrorActionPreference = 'Stop' +# 默认值不能写在 param() 里:Windows PowerShell 5.1 在带 [CmdletBinding()] 的脚本上, +# 参数绑定阶段还没有给 $PSScriptRoot 赋值,默认值表达式会拿到空串(实测:带 +# [CmdletBinding()] -> 空串,不带 -> 正常;PowerShell 7 两种都正常)。所以默认值 +# 一律在这里补 —— 这也是本仓库对 -BackupDir / -ConfigPath 一直在用的写法。 +if (-not $TestPath) { $TestPath = $PSScriptRoot } + $projectRoot = Split-Path -Parent $PSScriptRoot $localModules = Join-Path $projectRoot '.tools\modules' if (Test-Path -LiteralPath $localModules) { diff --git a/tests/Run-Tests.ps1 b/tests/Run-Tests.ps1 index 726faf3..adfc35e 100644 --- a/tests/Run-Tests.ps1 +++ b/tests/Run-Tests.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS BakNRet 测试套件(零依赖,Windows PowerShell 5.1 与 PowerShell 7.x 均可运行)。 @@ -26,10 +26,16 @@ [CmdletBinding()] param( - [string]$ModulePath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'Common.psm1') + [string]$ModulePath ) $ErrorActionPreference = 'Stop' + +# 默认值不能写在 param() 里:Windows PowerShell 5.1 在带 [CmdletBinding()] 的脚本上, +# 参数绑定阶段还没有给 $PSScriptRoot 赋值,默认值表达式会拿到空串(实测:带 +# [CmdletBinding()] -> 空串,不带 -> 正常;PowerShell 7 两种都正常)。所以默认值 +# 一律在这里补 —— 这也是本仓库对 -BackupDir / -ConfigPath 一直在用的写法。 +if (-not $ModulePath) { $ModulePath = Join-Path (Split-Path -Parent $PSScriptRoot) 'Common.psm1' } Import-Module $ModulePath -Force Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force diff --git a/tests/TestHelpers.psm1 b/tests/TestHelpers.psm1 index a20379c..b0332da 100644 --- a/tests/TestHelpers.psm1 +++ b/tests/TestHelpers.psm1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS BakNRet 测试用的极简断言运行器(零依赖,PS 5.1 / 7.x 通用)。 diff --git a/tools/Install-TestDependencies.ps1 b/tools/Install-TestDependencies.ps1 index 589c865..31cfa33 100644 --- a/tools/Install-TestDependencies.ps1 +++ b/tools/Install-TestDependencies.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS 把测试用的 Pester 5 装进仓库内的 .tools\modules(不动机器上的全局模块)。 diff --git a/tools/Register-BackupTask.ps1 b/tools/Register-BackupTask.ps1 index 7e2860f..209c7e3 100644 --- a/tools/Register-BackupTask.ps1 +++ b/tools/Register-BackupTask.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS 注册 / 移除 BakNRet 的每日备份计划任务。 diff --git a/tools/Rename-Archives.ps1 b/tools/Rename-Archives.ps1 index 942ed13..397deba 100644 --- a/tools/Rename-Archives.ps1 +++ b/tools/Rename-Archives.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS 把归档名对齐到当前清单规则,并重建 manifest.json。 @@ -32,7 +32,7 @@ [CmdletBinding()] param( - [string]$BackupListPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'BackupList.txt'), + [string]$BackupListPath, [string]$BackupDir, [string]$ConfigPath, [switch]$Apply @@ -41,6 +41,13 @@ param( $ErrorActionPreference = 'Stop' $projectRoot = Split-Path -Parent $PSScriptRoot + +# 默认值不能写在 param() 里:Windows PowerShell 5.1 在带 [CmdletBinding()] 的脚本上, +# 参数绑定阶段还没有给 $PSScriptRoot 赋值,默认值表达式会拿到空串(实测:带 +# [CmdletBinding()] -> 空串,不带 -> 正常;PowerShell 7 两种都正常)。所以默认值 +# 一律在这里补 —— 这也是本仓库对 -BackupDir / -ConfigPath 一直在用的写法。 +if (-not $BackupListPath) { $BackupListPath = Join-Path $projectRoot 'BackupList.txt' } + Import-Module (Join-Path $projectRoot 'Common.psm1') -Force if (-not $ConfigPath) { $ConfigPath = Join-Path $projectRoot 'BackupConfig.psd1' } diff --git a/tools/Set-SourceEncoding.ps1 b/tools/Set-SourceEncoding.ps1 new file mode 100644 index 0000000..1091269 --- /dev/null +++ b/tools/Set-SourceEncoding.ps1 @@ -0,0 +1,79 @@ +<# +.SYNOPSIS + 把受管文本文件规范成仓库约定的编码:UTF-8 with BOM、LF 行尾。 + +.DESCRIPTION + 为什么需要一个可重放的脚本,而不是"改一次就完了": + + * Windows PowerShell 5.1 在没有 BOM 时会按 ANSI 代码页解码源码文件, + 中文变乱码,而全角问号之类的字节序列会吃掉字符串引号、整块语法失效。 + * 但很多编辑器与工具在保存时会**悄悄去掉 BOM**。实测本仓库用的编辑工具就会 + (改过一次 test.ps1 之后它只在 5.1 上解析失败,在 7 上完全正常,极其隐蔽)。 + * 所以"加过 BOM"必须是一次能在任何改动之后重放的规范化操作。 + + 受管范围与 .editorconfig 一致: + * *.ps1 / *.psm1 / *.psd1 —— PowerShell 引擎直接读取,必须有 BOM + * BackupList.txt —— 被 Get-Content 读取,必须有 BOM + + 只做两件事:补 BOM、把 CRLF 归一成 LF。不动缩进、不动内容、不换编码以外的东西。 + 文件按 UTF-8 解码:本仓库的所有文本文件都是 UTF-8(见 .editorconfig)。 + +.PARAMETER WhatIf + 只报告将要改动什么,不写任何文件。 + +.EXAMPLE + .\tools\Set-SourceEncoding.ps1 -WhatIf + +.EXAMPLE + .\tools\Set-SourceEncoding.ps1 +#> +[CmdletBinding(SupportsShouldProcess = $true)] +param() + +$ErrorActionPreference = 'Stop' +$projectRoot = Split-Path -Parent $PSScriptRoot + +$excluded = '\\(\.git|\.tools|Backups|logs|dist|snapshots)\\' +$targets = @(Get-ChildItem -LiteralPath $projectRoot -Recurse -File | + Where-Object { $_.Extension -in '.ps1', '.psm1', '.psd1' -or $_.Name -eq 'BackupList.txt' } | + Where-Object { $_.FullName -notmatch $excluded }) + +$utf8Bom = New-Object System.Text.UTF8Encoding($true) +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) + +$addedBom = @() +$normalizedLf = @() + +foreach ($file in $targets) { + $bytes = [System.IO.File]::ReadAllBytes($file.FullName) + $hasBom = $bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF + $text = if ($hasBom) { $utf8NoBom.GetString($bytes, 3, $bytes.Length - 3) } else { $utf8NoBom.GetString($bytes) } + + $normalized = ($text -split "`r?`n") -join "`n" + $needsWrite = (-not $hasBom) -or ($normalized -ne $text) + + if (-not $hasBom) { $addedBom += $file.FullName.Substring($projectRoot.Length).TrimStart('\') } + if ($normalized -ne $text) { $normalizedLf += $file.FullName.Substring($projectRoot.Length).TrimStart('\') } + + if ($needsWrite -and $PSCmdlet.ShouldProcess($file.FullName, '规范成 UTF-8 with BOM 与 LF')) { + [System.IO.File]::WriteAllText($file.FullName, $normalized, $utf8Bom) + } +} + +Write-Host '' +Write-Host ("受管文件 {0} 个" -f $targets.Count) +if ($addedBom.Count -gt 0) { + Write-Host ("补上 BOM {0} 个:" -f $addedBom.Count) -ForegroundColor Yellow + $addedBom | ForEach-Object { Write-Host " $_" } +} else { + Write-Host '所有文件都已经带 BOM。' -ForegroundColor Green +} +if ($normalizedLf.Count -gt 0) { + Write-Host ("行尾归一成 LF {0} 个:" -f $normalizedLf.Count) -ForegroundColor Yellow + $normalizedLf | ForEach-Object { Write-Host " $_" } +} + +if (-not $WhatIfPreference) { + Write-Host '' + Write-Host '下一步:.\test.ps1 -Suite Parse 确认两个版本都能解析。' -ForegroundColor Cyan +} diff --git a/tools/lab/Lab.ps1 b/tools/lab/Lab.ps1 index 51c03c3..9fd6e7c 100644 --- a/tools/lab/Lab.ps1 +++ b/tools/lab/Lab.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS BakNRet 隔离测试环境(Hyper-V 真机级 VM)的日常入口。 diff --git a/tools/lab/payload/lab-fixtures.ps1 b/tools/lab/payload/lab-fixtures.ps1 index 8eed45a..f082cc7 100644 --- a/tools/lab/payload/lab-fixtures.ps1 +++ b/tools/lab/payload/lab-fixtures.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS BakNRet 隔离沙盒的假数据生成器(在 VM 内运行)。 diff --git a/tools/lab/payload/run-acl-scenario.ps1 b/tools/lab/payload/run-acl-scenario.ps1 index 808b3cd..57241bf 100644 --- a/tools/lab/payload/run-acl-scenario.ps1 +++ b/tools/lab/payload/run-acl-scenario.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS BakNRet 的安全描述符(属主 / ACL)场景演练 —— 在 lab 虚拟机内运行。