chore: 记录改造前基线

改造开始前的完整状态,作为可回退的基点。此提交之后:Pester 175 项、零依赖套件 101 项全绿;PowerShell 5.1 尚不可用(源文件无 BOM)。

包含此前未提交的在制品:安全描述符套件、Hyper-V 实验环境(tools/lab)、agent 约定(AGENTS.md 与 docs/agents)。

.gitignore 增加 *.key / *.pfx:BackupConfig.psd1 的 PasswordFile 此前默认指向仓库内的 baknret.key,一次 git add -A 就会把口令提交进版本库。默认值在后续提交中改为空。
This commit is contained in:
Shuery committed 2026-09-26 21:46:55 +08:00
1 parent 7173e8ae10
commit 2937eb6652
32 files changed
+8775 -1691

No files matched your search

+95 -27
View File
@@ -1,14 +1,20 @@
<#
.SYNOPSIS
把按路径命名的旧归档重命名成软件名,并重建 manifest.json。
把归档名对齐到当前清单规则,并重建 manifest.json。
.DESCRIPTION
重构前的归档名是 `<末级名>_from_<上级路径>`(如 FooClolor_from_C_+Programs.7z)。
引入软件名录后,归档名默认就是软件名(FooClolor.7z)。这个脚本负责把存量归档搬过去。
归档名由清单条目决定:
* 软件名条目 -> 归档名 = 软件名(`Edge.7z`);
* 手写路径条目 -> 归档名 = `<末级名>_from_<上级路径>`(`FooClolor_from_C_+Programs.7z`)。
条目写法变过(把软件名改成手写路径、改名、合并条目……)之后,磁盘上的旧归档名就与当前
规则对不上了 —— 那样的归档恢复不到,会被当成孤儿。这个脚本负责把它们搬过去。
做法:
1. 遍历清单条目,算出"旧名"(路径命名算法)与"新名"(当前规则);
2. 只在两者不同、且旧名归档确实存在时才处理;
1. 遍历清单条目,算出**当前规则下的目标名**,以及一组**候选旧名**
(路径命名算法 / 名录里的软件名 / manifest 里记过的归档名);
2. 目标名已经存在就跳过;否则在候选旧名里找实际存在的归档;
3. 重命名(不是复制,同卷上是元数据操作,不搬数据);
4. 重建 manifest.json,把旧记录的历史字段(成功次数、SHA256 等)迁过去;
5. 比对重命名前后的文件大小做完整性自检。
@@ -74,6 +80,29 @@ function Find-ArchiveByBaseName {
$manifestOld = Read-BaknretManifest -Path $manifestPath
# manifest 里的历史归档名按 source / resolvedSource 建索引:
# 条目写法改过(软件名 -> 手写路径、改名、合并)之后,键对不上了,
# 但"这条清单行原本指向哪儿"通常还留在这两个字段里,靠它才能把旧归档接上。
$manifestBySource = @{}
foreach ($key in @($manifestOld.items.Keys)) {
$record = $manifestOld.items[$key]
if (-not $record) { continue }
$archiveName = $key
if (($record.PSObject.Properties.Name -contains 'archive') -and $record.archive) {
$archiveName = [System.IO.Path]::GetFileNameWithoutExtension([string]$record.archive)
}
foreach ($field in 'source', 'resolvedSource', 'catalog') {
if (-not ($record.PSObject.Properties.Name -contains $field)) { continue }
$value = [string]$record.$field
if ([string]::IsNullOrWhiteSpace($value)) { continue }
$mapKey = $value.Trim().ToLower()
if (-not $manifestBySource.ContainsKey($mapKey)) { $manifestBySource[$mapKey] = @() }
$manifestBySource[$mapKey] += $archiveName
}
}
$plan = @()
$unchanged = 0
$missingOld = 0
@@ -85,23 +114,9 @@ foreach ($line in (Get-Content -LiteralPath $BackupListPath)) {
$item = ConvertFrom-BackupListLine -Line $line
if (-not $item) { continue }
# 旧名 = 对"真实源路径"跑路径命名算法。
# 注意:清单里现在写的是软件名,直接把它丢给 Get-BackupBaseName 会得到一个
# 恰好和软件名一模一样的"旧名"(legendary -> legendary),于是永远算不出
# 真正的旧名。必须先解析出真实路径。
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catalogPath -MaxDepth $config.CatalogMaxDepth
$newName = $resolved.BaseName
$oldNameSource = $item.Path
if ($resolved.IsName -and $resolved.CatalogEntry) { $oldNameSource = $resolved.CatalogEntry.Path }
if ([string]::IsNullOrWhiteSpace([string]$oldNameSource)) {
# 数组形式的名录条目没有唯一的"原路径",推不出旧归档名,跳过即可
Write-Host (" 跳过 {0}:名录条目是数组形式,算不出旧归档名" -f $item.Path) -ForegroundColor DarkGray
continue
}
$oldName = Get-BackupBaseName -RawPath $oldNameSource
if (-not $oldName -or -not $newName) { continue }
if (-not $newName) { continue }
if ($seenNew.ContainsKey($newName)) {
$conflicts += "归档名 '$newName' 被 '$($seenNew[$newName])' 和 '$($item.Path)' 同时使用"
@@ -109,11 +124,57 @@ foreach ($line in (Get-Content -LiteralPath $BackupListPath)) {
}
$seenNew[$newName] = $item.Path
$entries += [pscustomobject]@{ Item = $item; OldName = $oldName; NewName = $newName; Resolved = $resolved }
# 候选旧名(按可能性排序):
# 1. 路径命名算法(对名录条目要用 Slot 的 Path,直接拿软件名算出来的是错的);
# 2. 名录里的软件名(旧规则:归档名 = 软件名);
# 3. manifest 里为这条记录记过的归档名。
$candidates = @()
if ($oldName -eq $newName) { $unchanged++; continue }
$pathSource = $item.Path
if ($resolved.IsName) {
$slots = @($resolved.CatalogEntry.Slots)
$pathSource = if ($slots.Count -eq 1) { $slots[0].Declared } else { $null }
}
if ($pathSource) {
$derived = Get-BackupBaseName -RawPath $pathSource
if ($derived) { $candidates += $derived }
}
if ($resolved.IsName) {
$candidates += (Format-CatalogName -Name $item.Path)
}
if ($manifestOld.items.Contains($newName)) {
$recorded = $manifestOld.items[$newName]
if (($recorded.PSObject.Properties.Name -contains 'archive') -and $recorded.archive) {
$candidates += [System.IO.Path]::GetFileNameWithoutExtension([string]$recorded.archive)
}
}
$oldFile = Find-ArchiveByBaseName -BaseName $oldName -Directory $BackupDir -Formats $supportedFormats
# manifest 里"指向过同一个源"的历史归档名
$lookupKeys = @([string]$item.Path)
foreach ($entryItem in @($resolved.Items)) {
if ($entryItem.Declared) { $lookupKeys += [string]$entryItem.Declared }
if ($entryItem.RealPath) { $lookupKeys += [string]$entryItem.RealPath }
}
foreach ($lookupKey in $lookupKeys) {
if ([string]::IsNullOrWhiteSpace($lookupKey)) { continue }
$mapKey = $lookupKey.Trim().ToLower()
if ($manifestBySource.ContainsKey($mapKey)) { $candidates += @($manifestBySource[$mapKey]) }
}
$candidates = @($candidates | Where-Object { $_ -and $_ -ne $newName } | Select-Object -Unique)
$entries += [pscustomobject]@{ Item = $item; NewName = $newName; Resolved = $resolved; Candidates = $candidates }
if (Find-ArchiveByBaseName -BaseName $newName -Directory $BackupDir -Formats $supportedFormats) {
$unchanged++
continue
}
$oldFile = $null
foreach ($candidate in $candidates) {
$foundCandidate = Find-ArchiveByBaseName -BaseName $candidate -Directory $BackupDir -Formats $supportedFormats
if ($foundCandidate) { $oldFile = $foundCandidate; break }
}
if (-not $oldFile) { $missingOld++; continue }
$plan += [pscustomobject]@{
@@ -190,10 +251,14 @@ $now = (Get-Date).ToString('o')
foreach ($entry in $entries) {
$file = Find-ArchiveByBaseName -BaseName $entry.NewName -Directory $BackupDir -Formats $supportedFormats
# 历史字段优先从新键取,其次从旧键(路径命名)取
# 历史字段优先从新键取,其次从候选旧名里取
$previous = $null
if ($manifestOld.items.Contains($entry.NewName)) { $previous = $manifestOld.items[$entry.NewName] }
elseif ($manifestOld.items.Contains($entry.OldName)) { $previous = $manifestOld.items[$entry.OldName] }
else {
foreach ($candidate in $entry.Candidates) {
if ($manifestOld.items.Contains($candidate)) { $previous = $manifestOld.items[$candidate]; break }
}
}
$getPrevious = {
param([string]$Field)
@@ -205,7 +270,10 @@ foreach ($entry in $entries) {
baseName = $entry.NewName
source = $entry.Item.Path
resolvedSource = [Environment]::ExpandEnvironmentVariables($entry.Item.Path)
roots = @($entry.Resolved.Sources | ForEach-Object { $_.RootName })
roots = @($entry.Resolved.Items | ForEach-Object { $_.TopName } | Select-Object -Unique)
layouts = @($entry.Resolved.Items | ForEach-Object {
[ordered]@{ name = $_.ArchivePath; kind = $(if ($_.IsFile) { 'file' } else { 'dir' }) }
})
catalog = $(if ($entry.Resolved.CatalogEntry) { $entry.Resolved.CatalogEntry.Path } else { $null })
archive = $(if ($file) { $file.Name } else { $entry.NewName + '.7z' })
action = $(if ($file) { 'backed-up' } else { 'missing-source' })
@@ -218,7 +286,7 @@ foreach ($entry in $entries) {
verified = $false
warnings = $false
attemptWarnings = $false
encrypted = ($entry.Item.Flags -contains 'encrypt')
encrypted = [bool]$entry.Resolved.Encrypt
sourceFiles = (& $getPrevious 'sourceFiles')
sourceBytes = (& $getPrevious 'sourceBytes')
archiveBytes = $(if ($file) { $file.Length } else { $null })
+182
View File
@@ -0,0 +1,182 @@
<#
.SYNOPSIS
BakNRet 隔离测试环境的共享配置与工具函数(Hyper-V 真机级 VM)。
.DESCRIPTION
被 tools\lab\New-BakNRetLab.ps1(一次性搭建)与 tools\lab\Lab.ps1(日常使用)共同导入。
设计约定:
* 宿主机侧的一切状态(VHDX、日志、凭据、暂存包)都放在 $LabRoot 下,它刻意位于
**仓库之外**(默认 D:\VMs\BakNRet-Lab);测试用的归档、日志因此不会落进真实仓库,
真实仓库的 Backups\ 与 logs\ 在整套流程里只被读取、从不写入。
* VM 内的仓库副本在 C:\BakNRet,工具负载在 C:\BakNRet-Lab。
* 与 VM 的一切交互走 PowerShell Direct(VMBus),不依赖网络、不共享宿主机目录。
#>
$script:LabConfig = [ordered]@{
VmName = 'BakNRet-Lab'
LabRoot = 'D:\VMs\BakNRet-Lab'
VhdxPath = 'D:\VMs\BakNRet-Lab\vhdx\BakNRet-Lab.vhdx'
VhdxSizeGB = 80
IsoPath = 'F:\Images\Windows\Win11_25H2_Chinese_Simplified_x64_v2.iso'
ImageIndex = 4 # Windows 11 专业版
SwitchName = 'Default Switch'
MemoryStartupGB = 8
CpuCount = 8
GuestRepoPath = 'C:\BakNRet'
GuestLabPath = 'C:\BakNRet-Lab'
GuestUser = 'lab'
CheckpointName = 'clean-baseline'
RepoRoot = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
}
function Get-LabConfig { return $script:LabConfig }
function Get-LabPath {
<# .SYNOPSIS 取宿主机侧实验目录下的路径(自动建父目录)。 #>
param([Parameter(Mandatory)][string]$Relative)
$full = Join-Path $script:LabConfig.LabRoot $Relative
$parent = Split-Path -Parent $full
if ($parent -and -not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Force -Path $parent | Out-Null }
return $full
}
function Write-LabLog {
<# .SYNOPSIS 统一日志:同时进控制台与 $LabRoot\logs\lab.log。 #>
param([Parameter(Mandatory)][string]$Message, [ValidateSet('INFO','WARN','ERROR','STEP')][string]$Level = 'INFO')
$line = "[{0}] [{1,-5}] {2}" -f (Get-Date).ToString('HH:mm:ss'), $Level, $Message
switch ($Level) {
'STEP' { Write-Host $line -ForegroundColor Cyan }
'WARN' { Write-Host $line -ForegroundColor Yellow }
'ERROR' { Write-Host $line -ForegroundColor Red }
default { Write-Host $line }
}
Add-Content -LiteralPath (Get-LabPath 'logs\lab.log') -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue
}
function Test-LabElevated {
param()
return ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Assert-LabElevated {
<# .SYNOPSIS 需要管理员:非同权限时给出可直接复制的提权命令。 #>
param([Parameter(Mandatory)][string]$Why)
if (Test-LabElevated) { return }
$gsudo = (Get-Command gsudo -ErrorAction SilentlyContinue | Select-Object -First 1).Source
$self = $MyInvocation.PSCommandPath
$hint = if ($gsudo) { "`n $gsudo pwsh -NoProfile -File `"$self`" $($MyInvocation.Line)" } else { '' }
throw "需要管理员权限:$Why$hint"
}
function Get-LabCredentialPath { return (Join-Path $script:LabConfig.LabRoot 'state\credentials.json') }
function Save-LabCredential {
<# .SYNOPSIS 把 VM 内 lab 账户的口令写进宿主机侧的 credentials.json(在仓库之外)。 #>
param([Parameter(Mandatory)][string]$Password)
$path = Get-LabCredentialPath
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $path) | Out-Null
[ordered]@{
VmName = $script:LabConfig.VmName
User = $script:LabConfig.GuestUser
Password = $Password
SavedAt = (Get-Date).ToString('s')
} | ConvertTo-Json | Set-Content -LiteralPath $path -Encoding UTF8
return $path
}
function Get-LabCredential {
<# .SYNOPSIS 读出 VM 凭据为 PSCredential。 #>
param()
$path = Get-LabCredentialPath
if (-not (Test-Path -LiteralPath $path)) { throw "找不到 VM 凭据:$path(先跑 New-BakNRetLab.ps1)" }
$j = Get-Content -LiteralPath $path -Raw -Encoding UTF8 | ConvertFrom-Json
$sec = ConvertTo-SecureString $j.Password -AsPlainText -Force
return [pscredential]::new("$($j.User)", $sec)
}
function New-LabPassword {
<# .SYNOPSIS 生成只含字母数字的口令(避免 XML / 命令行转义问题)。 #>
param([int]$Length = 24)
$chars = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789'
return -join (1..$Length | ForEach-Object { $chars[(Get-Random -Minimum 0 -Maximum $chars.Length)] })
}
function Get-LabVm {
param()
return Get-VM -Name $script:LabConfig.VmName -ErrorAction SilentlyContinue
}
function Wait-LabVMRunning {
<# .SYNOPSIS 等 VM 进入 Running。 #>
param([int]$TimeoutSeconds = 300)
$sw = [Diagnostics.Stopwatch]::StartNew()
while ($sw.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
$vm = Get-LabVm
if ($vm -and $vm.State -eq 'Running') { return $true }
Start-Sleep -Seconds 3
}
return $false
}
function New-LabSession {
<# .SYNOPSIS 建立 PowerShell Direct 会话(VMBus,不经网络)。 #>
param([int]$RetrySeconds = 600)
$cred = Get-LabCredential
$sw = [Diagnostics.Stopwatch]::StartNew()
$lastError = $null
while ($sw.Elapsed.TotalSeconds -lt $RetrySeconds) {
try {
$s = New-PSSession -VMName $script:LabConfig.VmName -Credential $cred -ErrorAction Stop
Write-LabLog "PowerShell Direct 会话已建立(等待 $([math]::Round($sw.Elapsed.TotalSeconds)) 秒)"
return $s
} catch {
$lastError = $_.Exception.Message
Start-Sleep -Seconds 5
}
}
throw "无法建立 PowerShell Direct 会话:$lastError"
}
function Invoke-LabCommand {
<# .SYNOPSIS 在 VM 里跑一段脚本并回传结果(自动建/收会话)。 #>
param(
[Parameter(Mandatory)][scriptblock]$ScriptBlock,
[object[]]$ArgumentList = @(),
[int]$RetrySeconds = 600
)
$s = New-LabSession -RetrySeconds $RetrySeconds
try {
return Invoke-Command -Session $s -ScriptBlock $ScriptBlock -ArgumentList $ArgumentList -ErrorAction Stop
} finally {
Remove-PSSession -Session $s -ErrorAction SilentlyContinue
}
}
function Copy-LabFileToGuest {
<# .SYNOPSIS 宿主机 -> VM 传文件(Copy-VMFile,需要 Guest Service Interface)。 #>
param(
[Parameter(Mandatory)][string]$SourcePath,
[Parameter(Mandatory)][string]$DestinationPath
)
Copy-VMFile -VMName $script:LabConfig.VmName -SourcePath $SourcePath `
-DestinationPath $DestinationPath -CreateFullPath -FileSource Host -Force
}
function Get-HostSevenZip {
<# .SYNOPSIS 宿主机 7z 路径(用来打包仓库快照)。 #>
param()
$c = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $c) { throw '宿主机找不到 7z' }
return $c.Source
}
function Test-LabGuestReady {
<# .SYNOPSIS 判断 VM 内供给是否完成(provision.ok)。 #>
param()
try {
$r = Invoke-LabCommand -ScriptBlock { Test-Path 'C:\BakNRet-Lab\state\provision.ok' } -RetrySeconds 60
return [bool]$r
} catch { return $false }
}
+423
View File
@@ -0,0 +1,423 @@
<#
.SYNOPSIS
BakNRet 隔离测试环境(Hyper-V 真机级 VM)的日常入口。
.DESCRIPTION
与 New-BakNRetLab.ps1 的分工:那个负责**搭**,这个负责**用**。
动词:
status 看 VM 状态、检查点、供给事实、沙盒归档与最近日志
start/stop 启停 VM
wait 等 VM 内供给完成(首次搭建后)
sync 把当前仓库快照推进 VM(排除 Backups\ logs\ .git\ .tools\),并装好 Pester
seed 在 VM 里生成「带刺」的沙盒假数据(真 NTFS 连接点、被占用文件、长路径、中文路径…)
backup 在 VM 里用沙盒清单/配置真跑 Backup.ps1(可选 -DryRun)
restore 用真实归档做恢复演练(Restore-Drill.ps1),逐字节对拍
acl-test 安全描述符演练:scoop 装的 vscode 备份/恢复后仍可读写;ProgramData 那种
「属主 + CREATOR OWNER」的目录恢复后属主必须仍是原账户(另有负对照)
test 在 VM 里跑仓库自带的测试套件(pester / zero / e2e / all)
shell 打开到 VM 的交互式 PowerShell Direct 会话
console 打印 VM 内的供给日志与最新备份日志
checkpoint 打检查点(默认带时间戳;-CheckpointName 可指定)
reset 回到 clean-baseline 检查点(秒回干净状态)
destroy 删除 VM 与系统盘(需要 -Confirm)
一切都在 VM 内进行:宿主机的仓库、Backups\、logs\ 不会被这套流程写入。
.EXAMPLE
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 status
.EXAMPLE
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 sync
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 seed -Force
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 backup
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 restore
.EXAMPLE
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 test -Suite all
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, Position = 0)]
[ValidateSet('status','start','stop','wait','sync','seed','backup','restore','acl-test','test','shell','console','checkpoint','reset','destroy')]
[string]$Verb,
[ValidateSet('all','pester','zero','e2e')][string]$Suite = 'all',
# 恢复演练要处理的条目(写法同 BackupList.txt 的一行)
[string[]]$Entries,
[switch]$DryRun,
[switch]$Force,
[switch]$AcceptWarnings,
[switch]$KeepWork,
# acl-test 专用:跳过"装 scoop + scoop install vscode"(省掉几百 MB 下载,
# 只验证 ProgramData 那段的属主 / CREATOR OWNER)
[switch]$SkipScoop,
[string]$CheckpointName,
[switch]$Confirm
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'Lab-Common.ps1')
$cfg = Get-LabConfig
$guestSandbox = "$($cfg.GuestRepoPath)\tools\lab\payload\sandbox"
$guestList = "$guestSandbox\BackupList.txt"
$guestConfig = "$guestSandbox\BackupConfig.psd1"
$guestFixture = "$($cfg.GuestRepoPath)\tools\lab\payload\lab-fixtures.ps1"
$guestBackupDir = 'C:\BakNRet-Lab\Backups'
Assert-LabElevated -Why "Hyper-V 操作与 PowerShell Direct 都需要管理员(动词:$Verb)"
# ---------------------------------------------------------------------------
# 内部工具
# ---------------------------------------------------------------------------
function Get-VmSummary {
$vm = Get-LabVm
if (-not $vm) { return $null }
$mem = Get-VMMemory -VMName $cfg.VmName
return [pscustomobject]@{
Name = $vm.Name
State = $vm.State
Uptime = [int]$vm.Uptime.TotalSeconds
Cpu = $vm.ProcessorCount
MemoryGB = [math]::Round($mem.Startup / 1GB, 1)
Gen = $vm.Generation
UptimeText = "$([int]$vm.Uptime.TotalMinutes) 分钟"
}
}
function Invoke-GuestScriptFile {
<# .SYNOPSIS 在 VM 里用 pwsh 跑脚本文件,回传退出码与日志尾部。 #>
param(
[Parameter(Mandatory)][string]$ScriptPath,
# 不设 Mandatory:不需要参数的套件会传空数组,Mandatory 会拒绝空数组绑定
[string[]]$ScriptArgs = @(),
[Parameter(Mandatory)][string]$Tag,
[int]$TailLines = 30
)
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$logPath = "C:\BakNRet-Lab\logs\$Tag-$stamp.log"
# 参数用 JSON 传:数组直接经 Invoke-Command -ArgumentList 过去会退化成嵌套数组,
# 到 VM 里 Start-Process -ArgumentList 就会报「无法转换为 System.String」。
$argsJson = if (@($ScriptArgs).Count -eq 0) { '[]' } else { ConvertTo-Json -InputObject @($ScriptArgs) -Compress }
if (@($ScriptArgs).Count -eq 1 -and -not $argsJson.StartsWith('[') -and -not $argsJson.StartsWith('{')) { $argsJson = "[$argsJson]" }
return Invoke-LabCommand -ScriptBlock {
param($script, $argsJson, $logPath, $tailLines)
# ConvertFrom-Json 把 JSON 数组当成「一个对象」写出,直接 @(...) 会套成嵌套数组,
# 传到 Start-Process -ArgumentList 就报「无法转换为 System.String」。显式枚举摊平。
$scriptArgs = @()
if ($argsJson) {
$parsed = ConvertFrom-Json -InputObject $argsJson
$scriptArgs = @($parsed | ForEach-Object { [string]$_ })
}
# 子进程被重定向的 stdout 是**控制台代码页**(中文 Windows 上是 GBK/936),
# 用 -Encoding UTF8 读会整片乱码;而且 PS7 的 Get-Content -Encoding 不接受
# Encoding 对象。这里按「替换字符更少」的胜出者解码。
function Read-TextTail([string]$path, [int]$lines) {
if (-not (Test-Path -LiteralPath $path)) { return @() }
$bytes = [IO.File]::ReadAllBytes($path)
$asUtf8 = [Text.Encoding]::UTF8.GetString($bytes)
$asAnsi = [Text.Encoding]::GetEncoding([Globalization.CultureInfo]::CurrentCulture.TextInfo.ANSICodePage).GetString($bytes)
$badUtf8 = 0; foreach ($ch in $asUtf8.ToCharArray()) { if ($ch -eq [char]0xFFFD) { $badUtf8++ } }
$badAnsi = 0; foreach ($ch in $asAnsi.ToCharArray()) { if ($ch -eq [char]0xFFFD) { $badAnsi++ } }
$text = if ($badUtf8 -le $badAnsi) { $asUtf8 } else { $asAnsi }
return @($text -split "`r?`n" | Where-Object { $_ -ne '' } | Select-Object -Last $lines)
}
$all = @('-NoProfile','-ExecutionPolicy','Bypass','-File',$script) + $scriptArgs
$out = $logPath
$err = "$logPath.err"
$p = Start-Process -FilePath 'pwsh.exe' -ArgumentList $all -NoNewWindow -Wait -PassThru -RedirectStandardOutput $out -RedirectStandardError $err
[pscustomobject]@{
ExitCode = $p.ExitCode
LogPath = $out
Tail = @(Read-TextTail $out $tailLines)
ErrTail = @(Read-TextTail $err 10)
}
} -ArgumentList $ScriptPath, $argsJson, $logPath, $TailLines
}
function Invoke-LabSync {
$zip = Get-LabPath 'stage\repo.zip'
$sevenZip = Get-HostSevenZip
Write-LabLog "打包仓库快照:$($cfg.RepoRoot)(排除 Backups\ logs\ .git\ .tools\)" 'STEP'
Push-Location $cfg.RepoRoot
try {
& $sevenZip a -tzip $zip '.\*' '-xr!Backups' '-xr!logs' '-xr!.git' '-xr!.tools' '-xr!*.tmp.7z' '-xr!*.tmp.zip' -y | Out-Null
} finally { Pop-Location }
Write-LabLog ("快照大小 {0} MB" -f [math]::Round((Get-Item -LiteralPath $zip).Length / 1MB, 2))
Write-LabLog '推送到 VM(Copy-VMFile,走 VMBus)' 'STEP'
Copy-LabFileToGuest -SourcePath $zip -DestinationPath "$($cfg.GuestLabPath)\stage\repo.zip"
Write-LabLog '在 VM 内解开到 C:\BakNRet 并装好 Pester' 'STEP'
$info = Invoke-LabCommand -ScriptBlock {
param($guestRepo, $guestLab)
$sevenZip = 'C:\Program Files\7-Zip\7z.exe'
if (-not (Test-Path -LiteralPath $sevenZip)) { $sevenZip = Join-Path $guestLab 'payload\7zip\7z.exe' }
if (Test-Path -LiteralPath $guestRepo) { Remove-Item -LiteralPath $guestRepo -Recurse -Force }
New-Item -ItemType Directory -Force -Path $guestRepo | Out-Null
$null = & $sevenZip x "$guestLab\stage\repo.zip" "-o$guestRepo" -y
$pesterDst = Join-Path $guestRepo '.tools\modules\Pester\5.9.1'
New-Item -ItemType Directory -Force -Path $pesterDst | Out-Null
robocopy "$guestLab\payload\Pester\5.9.1" $pesterDst /MIR /NFL /NDL /NJH /NJS /NP | Out-Null
[pscustomobject]@{
SyncedAt = (Get-Date).ToString('s')
Files = (Get-ChildItem -LiteralPath $guestRepo -Recurse -File -ErrorAction SilentlyContinue | Measure-Object).Count
HasBackup = (Test-Path (Join-Path $guestRepo 'Backup.ps1'))
HasPester = (Test-Path (Join-Path $pesterDst 'Pester.psd1'))
}
} -ArgumentList $cfg.GuestRepoPath, $cfg.GuestLabPath
Write-LabLog ("同步完成:{0} 个文件,Backup.ps1={1},Pester={2}" -f $info.Files, $info.HasBackup, $info.HasPester) 'STEP'
return $info
}
function Show-GuestOutput {
param($Result, [switch]$Quiet)
if (-not $Quiet) {
foreach ($line in @($Result.Tail)) { Write-Host " $line" }
foreach ($line in @($Result.ErrTail)) { if ($line) { Write-Host " ! $line" -ForegroundColor Yellow } }
}
$color = if ($Result.ExitCode -eq 0) { 'Green' } else { 'Red' }
Write-Host (" 退出码 = {0}" -f $Result.ExitCode) -ForegroundColor $color
}
# ---------------------------------------------------------------------------
# 动词
# ---------------------------------------------------------------------------
switch ($Verb) {
'status' {
$s = Get-VmSummary
if (-not $s) {
Write-Host 'VM 不存在。先跑 tools\lab\New-BakNRetLab.ps1 搭建。' -ForegroundColor Yellow
break
}
Write-Host ''
Write-Host ('== BakNRet 隔离测试环境 ==') -ForegroundColor Cyan
Write-Host ("VM : {0} [{1}] 已运行 {2}" -f $s.Name, $s.State, $s.UptimeText)
Write-Host ("规格 : Gen{0} / {1} vCPU / {2} GB / Default Switch" -f $s.Gen, $s.Cpu, $s.MemoryGB)
Write-Host ("实验室目录: {0}" -f $cfg.LabRoot)
Write-Host ("VHDX : {0} ({1} GB 实际占用)" -f $cfg.VhdxPath, [math]::Round((Get-Item -LiteralPath $cfg.VhdxPath).Length / 1GB, 2))
$snaps = @(Get-VMSnapshot -VMName $cfg.VmName -ErrorAction SilentlyContinue)
Write-Host ("检查点 : {0}" -f $(if ($snaps) { ($snaps | ForEach-Object { "$($_.Name) [$($_.CreationTime.ToString('MM-dd HH:mm'))]" }) -join ', ' } else { '(无)' }))
if ($s.State -eq 'Running') {
try {
$g = Invoke-LabCommand -RetrySeconds 30 -ScriptBlock {
$ok = Test-Path 'C:\BakNRet-Lab\state\provision.ok'
$os = Get-CimInstance Win32_OperatingSystem
$arch = @()
if (Test-Path 'C:\BakNRet-Lab\Backups') {
$arch = @(Get-ChildItem 'C:\BakNRet-Lab\Backups' -Filter *.7z -ErrorAction SilentlyContinue |
ForEach-Object { [pscustomobject]@{ Name = $_.BaseName; MB = [math]::Round($_.Length / 1MB, 2) } })
}
$src = 'C:\BakNRet-Lab\sources'
[pscustomobject]@{
Provisioned = $ok
OsBuild = $os.BuildNumber
OsCaption = $os.Caption
GuestPS = $PSVersionTable.PSVersion.ToString()
RepoFiles = $(if (Test-Path 'C:\BakNRet') { (Get-ChildItem 'C:\BakNRet' -Recurse -File -ErrorAction SilentlyContinue | Measure-Object).Count } else { 0 })
SourceMB = $(if (Test-Path $src) { [math]::Round(((Get-ChildItem $src -Recurse -File -Force -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum) / 1MB, 1) } else { 0 })
Archives = $arch
LastLog = (Get-ChildItem 'C:\BakNRet-Lab\logs' -Filter 'backup-*.log' -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime | Select-Object -Last 1 -ExpandProperty Name)
}
}
Write-Host ("VM 内 : 供给={0} {1} (build {2}) PS={3}" -f $g.Provisioned, $g.OsCaption, $g.OsBuild, $g.GuestPS)
Write-Host ("仓库副本 : C:\BakNRet {0} 个文件" -f $g.RepoFiles)
Write-Host ("沙盒源数据 : {0} MB" -f $g.SourceMB)
if ($g.Archives.Count -gt 0) {
Write-Host ("沙盒归档 : {0} 个({1} MB 合计)" -f $g.Archives.Count, [math]::Round((($g.Archives | Measure-Object MB -Sum).Sum), 1))
$g.Archives | Sort-Object MB -Descending | ForEach-Object { Write-Host (" {0,-52} {1,8} MB" -f $_.Name, $_.MB) }
} else { Write-Host '沙盒归档 : (还没有,跑 Lab.ps1 backup)' }
if ($g.LastLog) { Write-Host ("最近备份日志: {0}" -f $g.LastLog) }
} catch {
Write-Host ("VM 内查询失败(可能还没起来):{0}" -f $_.Exception.Message) -ForegroundColor Yellow
}
}
Write-Host ''
}
'start' {
$vm = Get-LabVm
if (-not $vm) { throw 'VM 不存在,先跑 New-BakNRetLab.ps1' }
if ($vm.State -ne 'Running') { Start-VM -Name $cfg.VmName; $null = Wait-LabVMRunning -TimeoutSeconds 180 }
Write-LabLog "VM 状态:$((Get-LabVm).State)" 'STEP'
}
'stop' {
$vm = Get-LabVm
if ($vm -and $vm.State -eq 'Running') {
Write-LabLog '正常关机(走集成服务)' 'STEP'
Stop-VM -Name $cfg.VmName -ErrorAction SilentlyContinue
Start-Sleep -Seconds 3
if ((Get-LabVm).State -ne 'Off') { Write-LabLog '未关机,强制断电' 'WARN'; Stop-VM -Name $cfg.VmName -TurnOff -Force }
}
Write-LabLog "VM 状态:$((Get-LabVm).State)" 'STEP'
}
'wait' {
$sw = [Diagnostics.Stopwatch]::StartNew()
while ($sw.Elapsed.TotalMinutes -lt 30) {
if (Test-LabGuestReady) {
Write-LabLog ("VM 已就绪(等待 {0} 分钟)" -f [math]::Round($sw.Elapsed.TotalMinutes, 1)) 'STEP'
$facts = Invoke-LabCommand -ScriptBlock { Get-Content 'C:\BakNRet-Lab\state\provisioned.json' -Raw }
Write-Host $facts
break
}
Start-Sleep -Seconds 10
}
if (-not (Test-LabGuestReady)) { throw '等待超时:VM 内供给仍未完成' }
}
'sync' { $null = Invoke-LabSync }
'seed' {
Write-LabLog '先把当前工作树同步进 VM' 'STEP'; $null = Invoke-LabSync
$argList = @()
if ($Force) { $argList += '-Force' }
Write-LabLog '在 VM 内生成沙盒假数据' 'STEP'
$r = Invoke-GuestScriptFile -ScriptPath $guestFixture -ScriptArgs $argList -Tag 'fixtures' -TailLines 20
Show-GuestOutput $r
}
'backup' {
Write-LabLog '先把当前工作树同步进 VM' 'STEP'; $null = Invoke-LabSync
$argList = @('-BackupListPath', $guestList, '-ConfigPath', $guestConfig)
if ($DryRun) { $argList += '-DryRun' }
if ($Force) { $argList += '-Force' }
if ($AcceptWarnings) { $argList += '-AcceptWarnings' }
Write-LabLog "在 VM 内跑 Backup.ps1(DryRun=$DryRun,Force=$Force)" 'STEP'
$r = Invoke-GuestScriptFile -ScriptPath "$($cfg.GuestRepoPath)\Backup.ps1" -ScriptArgs $argList -Tag 'backup' -TailLines 40
Show-GuestOutput $r
}
'restore' {
Write-LabLog '先把当前工作树同步进 VM' 'STEP'; $null = Invoke-LabSync
if (-not $Entries -or $Entries.Count -eq 0) {
$Entries = @(
'AppMultiSlot', 'AppFileSlot', '软件目录甲', 'JunctionToData',
'C:\BakNRet-Lab\sources\AppBig', 'C:\BakNRet-Lab\sources\AppDeep'
)
}
# 数组参数不能跨进程传(-File 只会绑第一个值),改用 ';' 分隔的纯文本,
# 由 payload\run-drill.ps1 在 VM 内做真正的数组绑定
$entriesCsv = (@($Entries) | ForEach-Object { [string]$_ }) -join ';'
$argList = @('-BackupDir', $guestBackupDir, '-ConfigPath', $guestConfig, '-EntriesCsv', $entriesCsv)
if ($KeepWork) { $argList += '-KeepWorkRoot' }
Write-LabLog ("恢复演练:{0} 个条目" -f @($Entries).Count) 'STEP'
$r = Invoke-GuestScriptFile -ScriptPath "$($cfg.GuestRepoPath)\tools\lab\payload\run-drill.ps1" -ScriptArgs $argList -Tag 'drill' -TailLines 45
Show-GuestOutput $r
}
'acl-test' {
Write-LabLog '先把当前工作树同步进 VM' 'STEP'; $null = Invoke-LabSync
$argList = @('-RepoPath', $cfg.GuestRepoPath, '-WorkRoot', 'C:\BakNRet-Lab\acl')
if ($SkipScoop) { $argList += '-SkipScoop' }
if ($KeepWork) { $argList += '-KeepWorkRoot' }
Write-LabLog '安全描述符演练:scoop 装的 vscode + ProgramData 属主 / CREATOR OWNER' 'STEP'
$r = Invoke-GuestScriptFile -ScriptPath "$($cfg.GuestRepoPath)\tools\lab\payload\run-acl-scenario.ps1" -ScriptArgs $argList -Tag 'acl' -TailLines 60
Show-GuestOutput $r
# 其它动词都不回传 guest 退出码(只有 test 会扔异常),这个必须扔:
# 否则演练失败时宿主侧仍然退出 0,等于没有门禁。
if ($r.ExitCode -ne 0) {
throw ("ACL 演练失败(退出码 {0}),VM 内日志 {1}" -f $r.ExitCode, $r.LogPath)
}
}
'test' {
$map = [ordered]@{
pester = @{ Path = 'tests\Run-Pester.ps1'; Args = @(); Name = 'Pester 套件' }
zero = @{ Path = 'tests\Run-Tests.ps1'; Args = @(); Name = '零依赖套件' }
e2e = @{ Path = 'tests\Run-E2E.ps1'; Args = @(); Name = '端到端验收' }
}
$pick = if ($Suite -eq 'all') { @($map.Keys) } else { @($Suite) }
Write-LabLog '先把当前工作树同步进 VM' 'STEP'
$null = Invoke-LabSync
$results = @()
foreach ($key in $pick) {
$item = $map[$key]
$argList = @($item.Args)
if ($key -eq 'e2e' -and $KeepWork) { $argList += '-KeepWorkRoot' }
Write-LabLog ("跑 {0}({1})" -f $item.Name, $item.Path) 'STEP'
# 走 UTF-8 包装器:测试自己抓子进程输出时按 UTF-8 读回,
# 而 VM 的控制台输出编码是 ANSI(936),直接跑会有 8 项中文断言失败(见 README「已知问题」)
$wrapperPath = "$($cfg.GuestRepoPath)\tools\lab\payload\run-suite-utf8.ps1"
$suiteArgs = @("$($cfg.GuestRepoPath)\$($item.Path)") + $argList
$r = Invoke-GuestScriptFile -ScriptPath $wrapperPath -ScriptArgs $suiteArgs -Tag "test-$key" -TailLines 8
Show-GuestOutput $r -Quiet
foreach ($line in @($r.Tail) | Where-Object { $_ -match '全部通过|通过 \d+ 项,失败|通过\s*\d+' }) { Write-Host " $line" }
$results += [pscustomobject]@{ Suite = $item.Name; ExitCode = $r.ExitCode; Log = $r.LogPath }
}
Write-Host ''
Write-Host '== 套件结果 ==' -ForegroundColor Cyan
$results | ForEach-Object {
$color = if ($_.ExitCode -eq 0) { 'Green' } else { 'Red' }
Write-Host (" {0,-14} 退出码 {1} 日志 {2}" -f $_.Suite, $_.ExitCode, $_.Log) -ForegroundColor $color
}
$bad = @($results | Where-Object ExitCode -ne 0)
if ($bad.Count -gt 0) { throw ("有 {0} 套件失败" -f $bad.Count) }
}
'shell' {
Write-LabLog '进入 VM(PowerShell Direct)。退出用 exit。' 'STEP'
$cred = Get-LabCredential
Enter-PSSession -VMName $cfg.VmName -Credential $cred
}
'console' {
$r = Invoke-LabCommand -ScriptBlock {
$out = @()
foreach ($f in 'C:\BakNRet-Lab\logs\provision.log') {
if (Test-Path $f) { $out += "===== $f ====="; $out += @(Get-Content $f -Tail 40 -Encoding UTF8) }
}
$latest = Get-ChildItem 'C:\BakNRet-Lab\logs' -Filter 'backup-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime | Select-Object -Last 1
if ($latest) { $out += "===== $($latest.FullName) ====="; $out += @(Get-Content $latest.FullName -Tail 60 -Encoding UTF8) }
$out
}
$r | ForEach-Object { Write-Host $_ }
}
'checkpoint' {
if (-not $CheckpointName) { $CheckpointName = 'lab-' + (Get-Date -Format 'MMdd-HHmm') }
Checkpoint-VM -Name $cfg.VmName -SnapshotName $CheckpointName
Write-LabLog "已创建检查点 $CheckpointName" 'STEP'
}
'reset' {
if (-not $CheckpointName) { $CheckpointName = $cfg.CheckpointName }
$snap = Get-VMSnapshot -VMName $cfg.VmName -ErrorAction SilentlyContinue | Where-Object Name -eq $CheckpointName
if (-not $snap) { throw "找不到检查点 $CheckpointName" }
Write-LabLog "回到检查点 $CheckpointName" 'STEP'
Restore-VMSnapshot -VMSnapshot $snap -Confirm:$false
$null = Wait-LabVMRunning -TimeoutSeconds 240
Write-LabLog ("VM 状态:{0}" -f (Get-LabVm).State) 'STEP'
}
'destroy' {
if (-not $Confirm) { throw '这会删除 VM 与系统盘。确认请加 -Confirm。' }
$vm = Get-LabVm
if ($vm) {
if ($vm.State -ne 'Off') { Stop-VM -Name $cfg.VmName -TurnOff -Force }
Remove-VM -Name $cfg.VmName -Force
Write-LabLog "已删除虚拟机 $($cfg.VmName)" 'STEP'
}
if (Test-Path -LiteralPath $cfg.VhdxPath) {
Remove-Item -LiteralPath $cfg.VhdxPath -Force
Write-LabLog "已删除系统盘 $($cfg.VhdxPath)" 'STEP'
}
Write-LabLog '($LabRoot 下的日志与凭据保留,便于排查)' 'WARN'
}
}
+252
View File
@@ -0,0 +1,252 @@
<#
.SYNOPSIS
从零搭出 BakNRet 的 Hyper-V 隔离测试 VM(真机级:真 NTFS、真 ACL、真连接点、真重启)。
.DESCRIPTION
全流程无人值守、不需要点任何安装向导,也不需要 VM 的图形界面:
1. disk —— 挂载 Windows ISO,建 80 GB 动态 VHDX,按 UEFI 规范 GPT 分区,
用 DISM 把 install.wim 的指定版本展开进去,注入 7-Zip / PowerShell 7 /
Pester / 供给脚本,写入 C:\Windows\Panther\unattend.xml,最后 bcdboot 写引导;
2. vm —— 建 Gen2 虚拟机(8 GB / 8 vCPU、Default Switch、"来宾服务接口"打开、
关闭安全启动以便离线注入的引导链可用),挂载系统盘并启动;
3. provision —— 等首次登录的供给脚本跑完,读回 VM 自报的真机事实,然后打检查点
clean-baseline(之后 Lab.ps1 -Verb reset 可秒回到干净状态)。
幂等:已存在的 VHDX / VM 会复用,除非显式加 -Recreate。
.PARAMETER ListImages
只打印 ISO 里的映像索引清单,不建任何东西。
.PARAMETER Stage
all(默认)/ disk / vm / provision,可单独重跑某一段排查问题。
.EXAMPLE
gsudo pwsh -NoProfile -File .\tools\lab\New-BakNRetLab.ps1 -ListImages
.EXAMPLE
gsudo pwsh -NoProfile -File .\tools\lab\New-BakNRetLab.ps1
#>
[CmdletBinding()]
param(
[ValidateSet('all','disk','vm','provision')][string]$Stage = 'all',
[switch]$Recreate,
[switch]$ListImages,
[int]$ImageIndex = 0
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'Lab-Common.ps1')
$cfg = Get-LabConfig
if ($ImageIndex -gt 0) { $cfg.ImageIndex = $ImageIndex }
# ---------------------------------------------------------------------------
# ISO 与映像清单
# ---------------------------------------------------------------------------
function Get-IsoVolume {
$di = Get-DiskImage -ImagePath $cfg.IsoPath -ErrorAction SilentlyContinue
if (-not $di -or -not $di.Attached) { $di = Mount-DiskImage -ImagePath $cfg.IsoPath -PassThru }
Start-Sleep -Milliseconds 1200
return $di
}
function Get-ImageList {
param([Parameter(Mandatory)][string]$IsoLetter)
$wim = @('install.wim','install.esd') |
ForEach-Object { Join-Path "$IsoLetter`:\sources" $_ } |
Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
if (-not $wim) { throw "ISO ($IsoLetter`:) 里找不到 sources\install.wim|esd" }
$info = & dism.exe /English /Get-WimInfo /WimFile:"$wim" 2>&1
$list = @(); $cur = $null
foreach ($line in $info) {
if ($line -match '^Index\s*:\s*(\d+)') { if ($cur) { $list += $cur }; $cur = [ordered]@{ Index = [int]$Matches[1]; Name = ''; Size = '' } }
elseif ($cur -and $line -match '^Name\s*:\s*(.+?)\s*$') { $cur.Name = $Matches[1] }
elseif ($cur -and $line -match '^Size\s*:\s*(.+?)\s*$') { $cur.Size = $Matches[1] }
}
if ($cur) { $list += $cur }
return [pscustomobject]@{ WimPath = $wim; Images = $list }
}
if ($ListImages) {
Assert-LabElevated -Why '挂载 ISO 需要管理员'
$di = Get-IsoVolume
$letter = ($di | Get-Volume).DriveLetter
$il = Get-ImageList -IsoLetter $letter
Write-Host "映像文件:$($il.WimPath)" -ForegroundColor Cyan
$il.Images | ForEach-Object { " [{0}] {1} {2}" -f $_.Index, $_.Name, $_.Size }
return
}
# ---------------------------------------------------------------------------
# 1. 系统盘
# ---------------------------------------------------------------------------
function New-LabSystemDisk {
Assert-LabElevated -Why '创建/分区 VHDX 与 DISM 展开映像'
$espGuid = '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}'
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $cfg.VhdxPath) | Out-Null
if ((Test-Path -LiteralPath $cfg.VhdxPath) -and $Recreate) {
Write-LabLog "删除已有 VHDX:$($cfg.VhdxPath)" 'WARN'
$mounted = Get-VHD -Path $cfg.VhdxPath -ErrorAction SilentlyContinue
if ($mounted -and $mounted.Attached) { Dismount-VHD -Path $cfg.VhdxPath }
Remove-Item -LiteralPath $cfg.VhdxPath -Force
}
if (-not (Test-Path -LiteralPath $cfg.VhdxPath)) {
New-VHD -Path $cfg.VhdxPath -SizeBytes ($cfg.VhdxSizeGB * 1GB) -Dynamic | Out-Null
Write-LabLog "已创建动态 VHDX($($cfg.VhdxSizeGB) GB):$($cfg.VhdxPath)" 'STEP'
}
$vhd = Mount-VHD -Path $cfg.VhdxPath -Passthru
$disk = $vhd | Get-Disk
if ($disk.PartitionStyle -eq 'RAW') {
# Initialize-Disk 会顺手塞一个 MSR,先删掉,按 UEFI 规范自己建:ESP(300MB FAT32) + Windows(剩余 NTFS)
Initialize-Disk -Number $disk.Number -PartitionStyle GPT -Confirm:$false | Out-Null
Get-Partition -DiskNumber $disk.Number -ErrorAction SilentlyContinue |
Where-Object { $_.Type -eq 'Reserved' } | ForEach-Object { Remove-Partition -DiskNumber $_.DiskNumber -PartitionNumber $_.PartitionNumber -Confirm:$false }
$efi = New-Partition -DiskNumber $disk.Number -Size 300MB -GptType $espGuid -AssignDriveLetter
Format-Volume -Partition $efi -FileSystem FAT32 -NewFileSystemLabel 'System' -Confirm:$false -Force | Out-Null
$win = New-Partition -DiskNumber $disk.Number -UseMaximumSize -AssignDriveLetter
Format-Volume -Partition $win -FileSystem NTFS -NewFileSystemLabel 'Windows' -Confirm:$false -Force | Out-Null
Write-LabLog '已完成 GPT 分区(ESP 类型已按 EFI System Partition 建立)与格式化' 'STEP'
}
$efiPart = Get-Partition -DiskNumber $disk.Number | Where-Object GptType -eq $espGuid
$winPart = Get-Partition -DiskNumber $disk.Number | Where-Object { $_.GptType -eq '{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}' -and $_.Size -gt 1GB }
if (-not $efiPart -or -not $winPart) { throw '分区布局不符合预期(ESP / Windows 分区没找到)' }
$efiLetter = $efiPart.DriveLetter
$winLetter = $winPart.DriveLetter
if (-not $efiLetter) { Set-Partition -DiskNumber $disk.Number -PartitionNumber $efiPart.PartitionNumber -NewDriveLetter 'S'; $efiLetter = 'S' }
if (-not $winLetter) { Set-Partition -DiskNumber $disk.Number -PartitionNumber $winPart.PartitionNumber -NewDriveLetter 'W'; $winLetter = 'W' }
Write-LabLog "ESP = $efiLetter`:,Windows = $winLetter`:" 'STEP'
# ---- 展开映像 ----
if (-not (Test-Path -LiteralPath "$winLetter`:\Windows\System32\ntoskrnl.exe")) {
$di = Get-IsoVolume
$isoLetter = ($di | Get-Volume).DriveLetter
$il = Get-ImageList -IsoLetter $isoLetter
$pick = $il.Images | Where-Object Index -eq $cfg.ImageIndex
if (-not $pick) { throw "ISO 里没有索引 $($cfg.ImageIndex);可用:$($il.Images.Index -join ', ')" }
Write-LabLog "展开映像 [$($pick.Index)] $($pick.Name) -> $winLetter`:(需要十几分钟)" 'STEP'
$scratch = Get-LabPath 'scratch'
$out = Get-LabPath 'logs\dism-apply.out'
$err = Get-LabPath 'logs\dism-apply.err'
$proc = Start-Process -FilePath 'dism.exe' -NoNewWindow -Wait -PassThru -RedirectStandardOutput $out -RedirectStandardError $err `
-ArgumentList @('/English','/Apply-Image',"/ImageFile:$($il.WimPath)", "/Index:$($pick.Index)", "/ApplyDir:$winLetter`:\", "/ScratchDir:$scratch")
if ($proc.ExitCode -ne 0) { throw "DISM 展开失败,退出码 $($proc.ExitCode),见 $out / $err" }
Write-LabLog '映像展开完成' 'STEP'
} else {
Write-LabLog '系统盘上已有 Windows,跳过展开' 'WARN'
}
# ---- 注入负载与无人值守应答文件 ----
Write-LabLog '注入 7-Zip / PowerShell 7 / Pester / 供给脚本' 'STEP'
$payloadSrc = Join-Path $PSScriptRoot 'payload'
$guestLab = Join-Path "$winLetter`:\" ($cfg.GuestLabPath.TrimStart('\'))
foreach ($item in '7zip','pwsh','Pester','provision.ps1') {
$src = Join-Path $payloadSrc $item
$dst = Join-Path $guestLab ('payload\' + $item)
if (Test-Path -LiteralPath $src) {
$null = robocopy $src $dst /MIR /NFL /NDL /NJH /NJS /NP /R:1 /W:1
} else {
Write-LabLog "负载缺失(跳过):$src" 'WARN'
}
}
# 口令:随机生成,只留在宿主机 D:\VMs\BakNRet-Lab\state\credentials.json
$password = New-LabPassword
$credPath = Save-LabCredential -Password $password
Write-LabLog "已生成 VM 凭据($credPath)" 'STEP'
$unattendSrc = Get-Content -LiteralPath (Join-Path $payloadSrc 'unattend.xml') -Raw -Encoding UTF8
$unattendXml = $unattendSrc.Replace('__LABPASSWORD__', $password)
$panther = Join-Path "$winLetter`:\" 'Windows\Panther'
New-Item -ItemType Directory -Force -Path $panther | Out-Null
[System.IO.File]::WriteAllText((Join-Path $panther 'unattend.xml'), $unattendXml, [System.Text.UTF8Encoding]::new($true))
Write-LabLog "已写入 $panther\unattend.xml" 'STEP'
# ---- 引导 ----
Write-LabLog 'bcdboot 写 UEFI 引导' 'STEP'
& bcdboot.exe "$winLetter`:\Windows" /s "$efiLetter`:" /f UEFI | ForEach-Object { Write-LabLog " $_" }
if ($LASTEXITCODE -ne 0) { throw "bcdboot 失败,退出码 $LASTEXITCODE" }
$bootMgr = Join-Path "$efiLetter`:\" 'EFI\Microsoft\Boot\bootmgfw.efi'
if (-not (Test-Path -LiteralPath $bootMgr)) { throw "ESP 上没有 bootmgfw.efi:$bootMgr" }
Write-LabLog "引导文件就位:$bootMgr" 'STEP'
Dismount-VHD -Path $cfg.VhdxPath
Write-LabLog '系统盘已完成并卸载' 'STEP'
}
# ---------------------------------------------------------------------------
# 2. 虚拟机
# ---------------------------------------------------------------------------
function New-LabVM {
Assert-LabElevated -Why '创建/配置 Hyper-V 虚拟机'
$vm = Get-LabVm
if (-not $vm) {
Write-LabLog "创建虚拟机 $($cfg.VmName)(Gen2 / $($cfg.MemoryStartupGB) GB / $($cfg.CpuCount) vCPU)" 'STEP'
$vm = New-VM -Name $cfg.VmName -Generation 2 -MemoryStartupBytes ($cfg.MemoryStartupGB * 1GB) `
-VHDPath $cfg.VhdxPath -SwitchName $cfg.SwitchName
Set-VMProcessor -VMName $cfg.VmName -Count $cfg.CpuCount
Set-VMFirmware -VMName $cfg.VmName -EnableSecureBoot Off
Set-VM -Name $cfg.VmName -AutomaticStopAction TurnOff -AutomaticStartAction Nothing
# 集成服务名随系统语言变化(中文是「来宾服务接口」),按状态启用而不是按名字找
Get-VMIntegrationService -VMName $cfg.VmName | Where-Object { -not $_.Enabled } |
ForEach-Object { Enable-VMIntegrationService -VMName $cfg.VmName -Name $_.Name }
} else {
Write-LabLog "虚拟机 $($cfg.VmName) 已存在(状态 $($vm.State))" 'WARN'
if (-not (Get-VMHardDiskDrive -VMName $cfg.VmName | Where-Object Path -eq $cfg.VhdxPath)) {
Add-VMHardDiskDrive -VMName $cfg.VmName -Path $cfg.VhdxPath
}
}
$vm = Get-LabVm
if ($vm.State -ne 'Running') {
Write-LabLog '启动虚拟机' 'STEP'
Start-VM -Name $cfg.VmName
if (-not (Wait-LabVMRunning -TimeoutSeconds 180)) { throw '虚拟机没有进入 Running' }
}
Write-LabLog "虚拟机状态:$((Get-LabVm).State)" 'STEP'
}
# ---------------------------------------------------------------------------
# 3. 供给与检查点
# ---------------------------------------------------------------------------
function Wait-LabProvision {
Assert-LabElevated -Why 'PowerShell Direct 需要管理员'
Write-LabLog '等待 VM 内供给脚本完成(首次启动要几分钟)' 'STEP'
$sw = [Diagnostics.Stopwatch]::StartNew()
while ($sw.Elapsed.TotalMinutes -lt 30) {
if (Test-LabGuestReady) {
Write-LabLog "供给完成,耗时 $([math]::Round($sw.Elapsed.TotalMinutes,1)) 分钟" 'STEP'
$facts = Invoke-LabCommand -ScriptBlock { Get-Content 'C:\BakNRet-Lab\state\provisioned.json' -Raw }
Write-Host $facts
return
}
Start-Sleep -Seconds 15
}
throw '等待供给超时(30 分钟);用 Lab.ps1 -Verb console 到 VM 里看一眼 provision.log'
}
function New-LabCheckpoint {
Assert-LabElevated -Why '创建 Hyper-V 检查点'
$existing = Get-VMSnapshot -VMName $cfg.VmName -ErrorAction SilentlyContinue | Where-Object Name -eq $cfg.CheckpointName
if ($existing) { Write-LabLog "检查点 $($cfg.CheckpointName) 已存在,跳过" 'WARN'; return }
Checkpoint-VM -Name $cfg.VmName -SnapshotName $cfg.CheckpointName
Write-LabLog "已创建检查点 $($cfg.CheckpointName)(Lab.ps1 -Verb reset 可回到此状态)" 'STEP'
}
# ---------------------------------------------------------------------------
# 主流程
# ---------------------------------------------------------------------------
if ($Stage -in @('all','disk')) { New-LabSystemDisk }
if ($Stage -in @('all','vm')) { New-LabVM }
if ($Stage -in @('all','provision')) { Wait-LabProvision; New-LabCheckpoint }
Write-LabLog '搭建流程结束' 'STEP'
+205
View File
@@ -0,0 +1,205 @@
# tools\lab —— BakNRet 的隔离测试环境(Hyper-V 真机级 VM)
在**宿主机之外的 Windows 虚拟机**里跑 BakNRet 的备份 / 恢复 / 测试。宿主机仓库、`Backups\`、
`logs\` 在本环境里只被读取,从不写入;VM 内也没有挂载宿主机的任何目录(一切交互走
PowerShell Direct,也就是 VMBus,不需要网络共享)。
```
宿主机 隔离 VM(BakNRet-Lab)
────────────────────────────── ─────────────────────────────────────────
D:\Workspace\Temp\BakNRet ← 仓库(只读) ──sync──▶ C:\BakNRet 仓库副本(每次覆盖)
D:\VMs\BakNRet-Lab C:\BakNRet-Lab 工具负载 + 沙盒 + 日志
├─ vhdx\BakNRet-Lab.vhdx 系统盘 ├─ payload\ 7-Zip 26.03 / pwsh 7 / Pester 5.9.1
├─ state\credentials.json lab 口令 ├─ sources\ 带刺的假数据(见下)
├─ logs\ 全流程日志 ├─ Backups\ 沙盒归档 + manifest.json
└─ stage\repo.zip 仓库快照 └─ logs\ 脚本日志与重定向输出
```
## 为什么用它
真机语义是单元测试造不出来的。这套环境里能真正跑到:
| 形态 | 说明 |
| --- | --- |
| 真 NTFS 连接点(junction) | `sources\JunctionToData` 指向 `AppMultiSlot\Data`;真实源目录里不该造这种东西,VM 内的沙盒源可以随便折腾 |
| 被占用文件 | `AppLocked\locked.bin` 由后台进程持句柄,用来压「有文件没打进归档」的告警路径 |
| 长路径 / 深目录 | 10 层嵌套、112 字符路径 |
| 中文 + 空格 + 点的路径 | `sources\软件 目录.甲`,归档名同样是中文 |
| 多 Slot / 单文件 Slot | 一个软件多个 Slot(`<Slot>\<内容>`)与文件 Slot(包内是名为 Slot 的文件) |
| 排除与追加 | `:-` 的 Slot 前缀形式与 `!` 任意层级形式;`:+ Modules:<路径>` 追加映射 |
| 覆盖 Path | 清单里的 `:: <路径>` 覆盖名录里故意写错的 Path |
| 源不存在的条目 | 记 `missing-source`、退出码仍为 0 |
| 方向标记 | 行首 `+`(仅备份)与 `-`(仅恢复) |
| 增量判断 | 第二次备份对未变更的源报「源目录未更新」并跳过,`-Force` 强制重打 |
| 计划任务 / 重启持久性 | 真机环境,可注册计划任务、可重启后继续验证 |
## 搭建
前提:Windows 10/11 专业版或更高(需要 Hyper-V)、管理员权限、一个 Windows 安装 ISO。
默认读 `F:\Images\Windows\Win11_25H2_Chinese_Simplified_x64_v2.iso`(可在 `Lab-Common.ps1`
的 `$LabConfig` 里改)。
```powershell
# 0. 先看 ISO 里有哪些版本(记住要装的索引,默认 4 = 专业版)
gsudo pwsh -NoProfile -File .\tools\lab\New-BakNRetLab.ps1 -ListImages
# 1. 一次搭完:建 VHDX -> 分区 -> DISM 展开 -> 注入负载与无人值守文件 -> bcdboot
# -> 建 VM -> 首启无人值守 -> 等供给完成 -> 打 clean-baseline 检查点
gsudo pwsh -NoProfile -File .\tools\lab\New-BakNRetLab.ps1
```
全程**不需要点任何安装向导**,也不需要 VM 的图形界面:Windows 是用 DISM 离线展开进 VHDX 的,
首次启动由 `payload\unattend.xml`(放进 `C:\Windows\Panther\`)无人值守走完 specialize + OOBE,
再由 `payload\provision.ps1` 把 7-Zip / PowerShell 7 / Pester 装好并写上 PATH。
分阶段重跑(排查用):`-Stage disk` / `-Stage vm` / `-Stage provision`。
VM 规格:Gen2、8 vCPU、12 GB 静态内存、Default Switch(NAT,可联网)、80 GB 动态 VHDX
(实际占用约 15 GB,另有检查点差异盘)。lab 账户口令随机生成,只写在
`D:\VMs\BakNRet-Lab\state\credentials.json`(仓库之外),不进程版本库。
## 日常使用
```powershell
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 status # 一眼看状态
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 sync # 把当前工作树推给 VM
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 seed -Force # 重建带刺假数据
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 backup # VM 内真跑 Backup.ps1(沙盒清单+配置)
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 restore # 用真实归档做恢复演练(逐字节对拍)
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 acl-test # 安全描述符演练(scoop/vscode + ProgramData 属主)
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 acl-test -SkipScoop # 只跑 ProgramData 那段(不下载 vscode)
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 test -Suite all # 三套仓库自带测试
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 shell # 进去自己敲(exit 出来)
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 reset # 秒回 clean-baseline
```
动词一览:`status` / `start` / `stop` / `wait` / `sync` / `seed` / `backup` / `restore` /
`acl-test` / `test` / `shell` / `console` / `checkpoint` / `reset` / `destroy`。
`backup` 支持 `-DryRun` / `-Force` / `-AcceptWarnings`;`restore` 支持
`-Entries @('AppMultiSlot','C:\BakNRet-Lab\sources\AppBig')` 指定条目;`test` 支持
`-Suite pester|zero|e2e`;`acl-test` 支持 `-SkipScoop` / `-KeepWork`。
## acl-test:安全描述符演练(`payload\run-acl-scenario.ps1`)
两段,都在 VM 里真跑(不是模拟),宿主侧退出码由动词 `throw` 回传:
- **A. 用户级真实场景**:默认方式装 scoop(提权会话按官方写法加 `-RunAsAdmin`,目录仍是
`%USERPROFILE%\scoop`)→ `scoop install git` → `bucket add extras` → `scoop install vscode`
→ 改 vscode 的 `settings.json` → 备份 → 删源 → 恢复 → 断言:CLI 仍可执行、改过的配置原样
读得回、数据目录可写、app/persist 的安全指纹与备份前一致。
两个实测坑写在脚本注释里:extras 的 vscode 清单**没有 `bin` 条目**(所以没有 `shims\code.cmd`,
CLI 在 `apps\vscode\current\bin\code.cmd`);`code --version` 拉起的 `Code.exe` 会锁住文件,
删源前必须先清进程。
- **B. 权限现场**:`C:\ProgramData\baknret-acl-lab\data`,属主设成 **SYSTEM**、DACL 是
`protected` 且只有 `(A;OICIIO;GA;;;CO)` + SYSTEM/Administrators/Users —— 就是 ProgramData
下那些目录的形态。备份 / 删源 / 恢复后断言:**属主仍是 SYSTEM**、`CREATOR OWNER` 的
inherit-only ACE 还在、逐对象安全指纹与备份前一致;外加一条**负对照**(只搬文件、不回放
安全描述符)证明属主会落到"跑脚本的账户"头上。
## 沙盒清单 / 名录 / 配置
三个文件都在 `tools\lab\payload\sandbox\`,随 `sync` 进 VM:
- `BackupList.txt` —— 沙盒清单,覆盖上面表里的各种形态;
- `SoftwareCatalog.psd1` —— 软件名 → Slot 组,全部指向 `C:\BakNRet-Lab\sources`;
- `BackupConfig.psd1` —— 归档/日志/快照都落在 VM 内(`C:\BakNRet-Lab\Backups`),
压缩级别 1(跑得快),`ComputeHash = $true`(方便对拍)。
## 踩过的坑(照抄会踩)
1. **`SoftwareCatalog` 的相对路径是按仓库根解析的**,不是按配置文件所在目录;而且路径
**不存在时会静默回退**到仓库真实的 `SoftwareCatalog.psd1`。沙盒配置里必须写成仓库根
相对路径(`tools\lab\payload\sandbox\SoftwareCatalog.psd1`),否则软件名条目会悄悄用错名录。
2. **子进程被重定向的 stdout 是控制台代码页**(中文 Windows 上是 GBK/936),按 UTF-8 读会
整片乱码;`Lab.ps1` 因此按「替换字符更少」的候选解码。脚本自己写的
`logs\backup\backup-*.log` 反而是 UTF-8。
3. **`ConvertFrom-Json` 把 JSON 数组当作一个对象写出**,`@(...)` 会套成嵌套数组;数组参数
经 `Invoke-Command -ArgumentList` 传到 VM 里再交给 `Start-Process -ArgumentList` 会报
「无法转换为 System.String」。`Lab.ps1` 用 JSON 传参 + 显式枚举摊平。
4. **Hyper-V 对新建 VM 默认开自动检查点**,会不断堆叠差异盘。`New-BakNRetLab.ps1` 已关掉
(`AutomaticCheckpointsEnabled = $false`)。
5. **中文 Windows 上集成服务名是本地的**(「来宾服务接口」而不是 `Guest Service Interface`),
按名字启用会找不到;脚本改为「把所有未启用的集成服务启用」。
6. **全新 Gen2 VM 的 NVRAM 是空的**,固件会走 UEFI 回退路径 `\EFI\Boot\bootx64.efi`。
`bcdboot /f UEFI` 通常会写它;没写时脚本会从 `bootmgfw.efi` 补一份。
7. **`New-Partition -Size` 建出来的是普通数据分区**,不是 ESP;要按 UEFI 规范用
`-GptType '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}'` 建,事后再用 `Set-Partition -GptType`
改类型可能被拒(尤其打错分区号时)。`Initialize-Disk` 还会自带一个 MSR 分区。
8. **exFAT 卷上写不了硬链接**:DSH 的 write 工具用「临时目录 + 硬链接」做原子落盘,在 exFAT 上会
直接失败(EISDIR)。仓库已于 2026-09-26 迁到 NTFS(`D:\Workspace\Temp\BakNRet`),不再受影响;
但 U 盘上的其它数据仍受此限制 —— 改那里的文件要么用 shell 重定向,要么先写 NTFS 再拷。
9. VM 是**未激活**的 Windows:会有水印,个性化受限,功能测试不受影响。
10. **PowerShell Direct 的默认端点是 Windows PowerShell 5.1**(不是 7)。要在 VM 里跑 7 的代码
必须显式 `Start-Process pwsh.exe`(`Lab.ps1` 就是这么做的)。5.1 还读不了仓库里无 BOM 的
UTF-8 脚本(见下「已知问题」),`Import-Module C:\BakNRet\Common.psm1` 会报一串「缺少右 }」。
## 已知问题与规避
### 在 VM 里直接跑 `tests\Run-Pester.ps1` 会红 8 项(都是中文断言)
`tests\BakNRet*.Tests.ps1` 里的 `Invoke-BaknretScript` 这样抓子进程输出:
```
cmd /c pwsh -File Backup.ps1 ... > out.txt 2>&1
Get-Content -LiteralPath out.txt -Encoding UTF8
```
而 `Backup.ps1` / `Restore.ps1` 的 `Write-Log` 走 `Write-Host`,写进 `out.txt` 的**字节编码取自
`[Console]::OutputEncoding`**:
| 环境 | `[Console]::OutputEncoding` | 结果 |
| --- | --- | --- |
| 宿主机(日常会话) | `utf-8` | 文件是 UTF-8,按 UTF-8 读回正确 → 150/150 绿 |
| 全新 Windows VM(中文系统) | `gb2312`(936) | 文件是 GBK 字节,按 UTF-8 读回得到替换字符 → 8 项中文断言失败 |
实测:VM 里直接跑是 `142 通过 / 8 失败`;把控制台输出编码先钉成 UTF-8 后是 `150/150`。
这是**测试环境的编码假设问题,不是产品缺陷**(产品行为在两边完全一致)。
`Lab.ps1 test` 因此会经 `payload\run-suite-utf8.ps1` 运行套件,不需要改动仓库里的测试代码。
若要在仓库里根治(三选一):
1. 生成的 `.cmd` 里先 `chcp 65001 >nul`;
2. 子进程改成 `pwsh -Command "[Console]::OutputEncoding=[Text.Encoding]::UTF8; & '<脚本>' <参数>"`;
3. 读回时按控制台代码页解码,而不是写死 `-Encoding UTF8`。
### 名录改了、源没变时:归档与 manifest 会不一致
实测路径(在 VM 里真实撞到过):
1. 名录里某个条目的 Slot 定义变了(当时是把沙盒名录的路径修对之后);
2. 源目录一个字节没动;
3. 下一次 `Backup.ps1` 按「源未更新」跳过该条目 —— **归档保持旧内容**;
4. 但 manifest 的 `roots` / `layouts` 是按**当前**名录重新算的,于是它描述的内容比归档里实际有的多;
5. 恢复时才炸:`归档 AppFileSlot.7z 里既没有 'Profile',也没有旧布局的 '0'`。
报错是清楚的(不是静默错误),修复办法就是重打一次:`Lab.ps1 backup -Force`
(实测重打后 `Lab.ps1 restore` 立刻变成 6/6 逐字节对拍通过)。
如果希望产品层面自动发现,可以在「源未更新」的判断里带上「本次解析出的 roots/layouts 是否与
manifest 记录的一致」,不一致就不要跳过。### 仓库里的 PowerShell 文件是「UTF-8 无 BOM」
`Backup.ps1` / `Common.psm1` 等都没有 BOM(开头字节是 `3C 23 0A` = `<#` + 换行)。
PowerShell 7 默认按 UTF-8 读,没问题;**Windows PowerShell 5.1 会把无 BOM 文件按 ANSI(GBK) 读**,
中文注释会被拆出错字节,甚至报「语句块或类型定义中缺少右 }」这类假解析错误。
要么给这些文件加 BOM,要么在文档里明确只支持 PowerShell 7。
### 仓库位置(2026-09-26 已从 U 盘迁到 NTFS)
仓库原在 `F:\Backup\BakNRet`(exFAT 的 Ventoy U 盘),为了减少 U 盘读写、并且拿回 NTFS 的
ACL / 硬链接支持,已整体搬到 **`D:\Workspace\Temp\BakNRet`**(NTFS,561 个文件 / 7.45 GB,
搬迁后做了逐文件 SHA256 对拍,全部一致)。
对这套 lab 没有影响:`Lab-Common.ps1` 用 `$PSScriptRoot` 推导 `RepoRoot`,
搬迁后实测自动指向新路径,脚本无需改动。唯一仍在 U 盘上的是默认安装 ISO
(`F:\Images\Windows\...`),只在重新 `-Stage disk` 时**只读**用一次;想彻底不读 U 盘,
把它复制一份到 D: 再改 `Lab-Common.ps1` 的 `IsoPath` 即可。
仓库在 NTFS 上还顺带修好了 git:原先 exFAT 不记录属主,git 报 `dubious ownership` 全部命令失败;
搬迁后 `git status` / `git log` 直接可用(不需要 `safe.directory` 白名单)。## 拆掉
```powershell
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 destroy -Confirm # 删 VM 与系统盘
# 日志、凭据、仓库快照留在 D:\VMs\BakNRet-Lab 下,便于事后排查;确认不要了再手工删该目录
```
+126
View File
@@ -0,0 +1,126 @@
<#
.SYNOPSIS
BakNRet 隔离沙盒的假数据生成器(在 VM 内运行)。
.DESCRIPTION
在 C:\BakNRet-Lab\sources 下造出一批**故意带刺**的源目录,用来在真机语义下压测
Backup.ps1 / Restore.ps1 —— 这些形态在宿主机上不敢随便试:
* 多 Slot 软件目录(Data / Config / Cache 三个子目录,各自可带排除);
* 单文件 Slot(一个 .json 直接当一个 Slot);
* 中文 + 空格 + 点的路径名;
* **真 NTFS 连接点(junction)** —— exFAT 的仓库里造不出来;
* **被占用文件** —— 后台进程持有句柄,验证「有文件没打进归档」的告警路径;
* 长路径(接近 260 字符)与 10 层深目录;
* DefaultExcludes 命中的垃圾文件(Thumbs.db / desktop.ini)与 *.log;
* 空目录;
* 一个约 50 MB 的文件,让归档大小/空间预估有实际数字;
* 一个「源不存在」条目对应的目录(故意不建)。
幂等:默认只在缺失时创建;-Force 会先删掉 sources 重建(删连接点用 rmdir,避免跟进目标)。
#>
[CmdletBinding()]
param(
[string]$Root = 'C:\BakNRet-Lab\sources',
[switch]$Force
)
$ErrorActionPreference = 'Stop'
function New-TextFile {
param([string]$Path, [string]$Content, [int]$Count = 1)
$dir = Split-Path -Parent $Path
if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
if ($Count -le 1) {
Set-Content -LiteralPath $Path -Value $Content -Encoding UTF8
} else {
Set-Content -LiteralPath $Path -Value (1..$Count | ForEach-Object { "$Content #$_" }) -Encoding UTF8
}
}
if ($Force -and (Test-Path -LiteralPath $Root)) {
Write-Host "清除已有沙盒源:$Root"
Get-ChildItem -LiteralPath $Root -Recurse -Force -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint } |
ForEach-Object { cmd /c rmdir "$($_.FullName)" 2>$null }
Remove-Item -LiteralPath $Root -Recurse -Force
}
New-Item -ItemType Directory -Force -Path $Root | Out-Null
# --- 1. 多 Slot 软件目录 -----------------------------------------------------
$appA = Join-Path $Root 'AppMultiSlot'
New-TextFile (Join-Path $appA 'Data\settings.json') '{ "theme": "dark", "slots": 3 }'
New-TextFile (Join-Path $appA 'Data\nested\deep\payload.bin') 'binary-ish-payload' -Count 40
New-TextFile (Join-Path $appA 'Config\app.ini') '[main]'
New-TextFile (Join-Path $appA 'Config\app.ini.bak') '[main] backup copy'
New-TextFile (Join-Path $appA 'Cache\cache-01.tmp') 'cache entry' -Count 20
New-TextFile (Join-Path $appA 'Cache\Thumbs.db') 'junk that DefaultExcludes should drop'
New-TextFile (Join-Path $appA 'Cache\desktop.ini') 'junk that DefaultExcludes should drop'
New-TextFile (Join-Path $appA 'Data\session.log') 'log line that an exclusion should drop' -Count 10
New-TextFile (Join-Path $appA 'Data\node_modules\pkg\index.js') 'module.exports = {}'
New-Item -ItemType Directory -Force -Path (Join-Path $appA 'Data\emptydir') | Out-Null
# --- 2. 单文件 Slot ----------------------------------------------------------
$appB = Join-Path $Root 'AppFileSlot'
New-TextFile (Join-Path $appB 'profile.json') '{ "name": "file-slot", "single": true }'
New-TextFile (Join-Path $appB 'readme.txt') 'file slot 的侧车说明'
# --- 3. 中文 + 空格 + 点的路径 ----------------------------------------------
$appC = Join-Path $Root '软件 目录.甲'
New-TextFile (Join-Path $appC '设置\配置 文件.ini') '中文路径内容'
New-TextFile (Join-Path $appC '数据 备份\记录.txt') '记录内容' -Count 5
# --- 4. 真 NTFS 连接点 -------------------------------------------------------
$realTarget = Join-Path $Root 'AppMultiSlot\Data'
$junction = Join-Path $Root 'JunctionToData'
if (-not (Test-Path -LiteralPath $junction)) {
$null = New-Item -ItemType Junction -Path $junction -Target $realTarget -ErrorAction SilentlyContinue
}
if (Test-Path -LiteralPath $junction) { Write-Host "连接点已建:$junction -> $realTarget" }
# --- 5. 长路径与深目录 -------------------------------------------------------
$cursor = Join-Path $Root 'AppDeep'
1..10 | ForEach-Object { $cursor = Join-Path $cursor "level$_" }
New-TextFile (Join-Path $cursor 'bottom.txt') 'deep content'
Write-Host ("最长路径长度:{0} 字符" -f (Join-Path $cursor 'bottom.txt').Length)
# --- 6. 50 MB 大文件 ---------------------------------------------------------
$bigDir = Join-Path $Root 'AppBig'
$bigFile = Join-Path $bigDir 'blob-50mb.bin'
if (-not (Test-Path -LiteralPath $bigFile)) {
New-Item -ItemType Directory -Force -Path $bigDir | Out-Null
$fs = [IO.File]::Create($bigFile)
try {
$rng = [Random]::new(20260926)
$chunk = [byte[]]::new(1MB)
for ($i = 0; $i -lt 50; $i++) { $rng.NextBytes($chunk); $fs.Write($chunk, 0, $chunk.Length) }
} finally { $fs.Dispose() }
}
Write-Host ("大文件:{0} MB" -f [math]::Round((Get-Item $bigFile).Length/1MB,1))
# --- 7. 被占用文件(后台进程持句柄 90 秒后释放)-----------------------------
$lockDir = Join-Path $Root 'AppLocked'
$lockFile = Join-Path $lockDir 'locked.bin'
New-Item -ItemType Directory -Force -Path $lockDir | Out-Null
New-TextFile $lockFile 'this file is held open by another process'
$holderLines = @(
'$path = $args[0]'
'$fs = [IO.File]::Open($path, ''Open'', ''ReadWrite'', ''None'')'
'try { Start-Sleep -Seconds 90 } finally { $fs.Dispose() }'
)
$holderPath = 'C:\BakNRet-Lab\state\hold-lock.ps1'
Set-Content -LiteralPath $holderPath -Value $holderLines -Encoding UTF8
Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File',$holderPath,$lockFile) -WindowStyle Hidden
Write-Host "已启动占用者进程(持句柄 90 秒):$lockFile"
# --- 8. 「源不存在」条目对应的目录:故意不建 ---------------------------------
Write-Host '故意不创建 MissingApp(用于验证源缺失只跳过、不失败)'
Write-Host ''
Write-Host '--- 沙盒源清单 ---'
Get-ChildItem -LiteralPath $Root -Force | ForEach-Object {
$files = @(Get-ChildItem -LiteralPath $_.FullName -Recurse -File -Force -ErrorAction SilentlyContinue)
$mb = [math]::Round((($files | Measure-Object Length -Sum).Sum) / 1MB, 2)
" {0,-24} {1,4} 个文件 {2,8} MB 连接点={3}" -f $_.Name, $files.Count, $mb, [bool]($_.Attributes -band [IO.FileAttributes]::ReparsePoint)
}
+118
View File
@@ -0,0 +1,118 @@
<#
.SYNOPSIS
BakNRet 隔离测试 VM 的首次登录供给脚本(由 unattend.xml 的 FirstLogonCommands 调用)。
.DESCRIPTION
运行环境是 VM 内全新安装的 Windows 11(Windows PowerShell 5.1、管理员 lab 账户)。
目标:把 VM 变成「可以直接跑 BakNRet 全链路测试」的真机状态:
1. 电源 / 休眠 / 锁屏:测试期间不要因为空闲睡下去;
2. 执行策略 Bypass(仅此实验 VM);
3. 把注入的 7-Zip 与 PowerShell 7 放到机器 PATH 上,与宿主机的工具版本对齐;
4. 安装 Pester 5.9.1(Windows PowerShell 与 pwsh 两侧都能导入);
5. 不让 Windows Update 自动重启,并给实验目录加 Defender 排除
(避免杀软把「文件被占用」类用例变成随机失败,同时显著拖慢压缩);
6. 关掉首次登录后的 SCOOBE「完成设备设置」向导;
7. 写出 state\provisioned.json 与 state\provision.ok 作为「供给完成」的硬凭据。
幂等:可重复执行,第二次跑不会失败。
#>
$ErrorActionPreference = 'Continue'
$ProgressPreference = 'SilentlyContinue'
$lab = 'C:\BakNRet-Lab'
$logDir = Join-Path $lab 'logs'
$stateDir = Join-Path $lab 'state'
New-Item -ItemType Directory -Force -Path $logDir, $stateDir | Out-Null
Start-Transcript -Path (Join-Path $logDir 'provision.log') -Force | Out-Null
function Step($m) { Write-Host "==> $m" }
try {
Step '1/7 电源与显示:不休眠、不锁屏、关休眠'
powercfg /change standby-timeout-ac 0 | Out-Null
powercfg /change monitor-timeout-ac 0 | Out-Null
powercfg /change hibernate-timeout-ac 0 | Out-Null
powercfg /hibernate off | Out-Null
Step '2/7 执行策略:LocalMachine = Bypass(仅此实验 VM)'
Set-ExecutionPolicy -Scope LocalMachine -ExecutionPolicy Bypass -Force
Step '3/7 工具上机器 PATH:7-Zip 与 PowerShell 7'
$zipSrc = Join-Path $lab 'payload\7zip'
$zipDst = 'C:\Program Files\7-Zip'
$pwshSrc = Join-Path $lab 'payload\pwsh'
$pwshDst = 'C:\Program Files\PowerShell\7'
if (-not (Test-Path $zipDst)) { robocopy $zipSrc $zipDst /MIR /NFL /NDL /NJH /NJS /NP | Out-Null }
if (-not (Test-Path $pwshDst)) { robocopy $pwshSrc $pwshDst /MIR /NFL /NDL /NJH /NJS /NP | Out-Null }
$machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine')
foreach ($p in @($zipDst, $pwshDst)) {
if ($machinePath -notlike "*$p*") { $machinePath = $machinePath.TrimEnd(';') + ';' + $p }
if ($env:Path -notlike "*$p*") { $env:Path = $env:Path.TrimEnd(';') + ';' + $p }
}
[Environment]::SetEnvironmentVariable('Path', $machinePath, 'Machine')
Step '4/7 安装 Pester 5.9.1(Windows PowerShell 与 pwsh 各一份)'
$pesterSrc = Join-Path $lab 'payload\Pester\5.9.1'
foreach ($dst in @("$env:ProgramFiles\WindowsPowerShell\Modules\Pester\5.9.1",
"$env:ProgramFiles\PowerShell\Modules\Pester\5.9.1")) {
if (-not (Test-Path $dst)) { robocopy $pesterSrc $dst /MIR /NFL /NDL /NJH /NJS /NP | Out-Null }
}
Step '5/7 Windows Update 不自动重启 + Defender 排除实验目录'
$wu = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU'
New-Item -Path $wu -Force | Out-Null
New-ItemProperty -Path $wu -Name 'NoAutoRebootWithLoggedOnUsers' -Value 1 -PropertyType DWord -Force | Out-Null
New-ItemProperty -Path $wu -Name 'AUOptions' -Value 2 -PropertyType DWord -Force | Out-Null
New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Power' -Name 'HiberbootEnabled' -Value 0 -PropertyType DWord -Force | Out-Null
Add-MpPreference -ExclusionPath 'C:\BakNRet', 'C:\BakNRet-Lab' -ErrorAction SilentlyContinue
Step '6/7 关掉 SCOOBE「完成设备设置」'
$scoobe = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\UserProfileEngagement'
New-Item -Path $scoobe -Force | Out-Null
New-ItemProperty -Path $scoobe -Name 'ScoobeSystemSettingEnabled' -Value 0 -PropertyType DWord -Force | Out-Null
Step '7/7 采集真机事实并落盘'
$zipExe = Join-Path $zipDst '7z.exe'
$pwshExe = Join-Path $pwshDst 'pwsh.exe'
$pwshVer = '缺失'
if (Test-Path $pwshExe) { $pwshVer = (& $pwshExe -NoProfile -Command '$PSVersionTable.PSVersion.ToString()' 2>&1) -join ' ' }
$zipVer = '缺失'
if (Test-Path $zipExe) { $zipVer = (& $zipExe 2>&1 | Select-Object -First 2) -join ' / ' }
$facts = [ordered]@{
ProvisionedAt = (Get-Date).ToString('s')
ComputerName = $env:COMPUTERNAME
User = (whoami)
IsAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
OsCaption = (Get-CimInstance Win32_OperatingSystem).Caption
OsVersion = (Get-CimInstance Win32_OperatingSystem).Version
OsBuild = (Get-CimInstance Win32_OperatingSystem).BuildNumber
OsArch = (Get-CimInstance Win32_OperatingSystem).OSArchitecture
WindowsPS = $PSVersionTable.PSVersion.ToString()
SevenZipVersion = $zipVer
PwshVersion = $pwshVer
PesterVersion = (Get-Module -ListAvailable Pester -ErrorAction SilentlyContinue | Select-Object -First 1).Version.ToString()
PathHasSevenZip = ([Environment]::GetEnvironmentVariable('Path', 'Machine')) -like '*7-Zip*'
PathHasPwsh = ([Environment]::GetEnvironmentVariable('Path', 'Machine')) -like '*PowerShell\7*'
CpuCount = (Get-CimInstance Win32_ComputerSystem).NumberOfLogicalProcessors
RamGB = [math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB, 1)
Drives = @(Get-Volume | Where-Object DriveLetter | ForEach-Object {
[ordered]@{ Letter = "$($_.DriveLetter):"; Fs = $_.FileSystemType; SizeGB = [math]::Round($_.Size / 1GB, 1) }
})
}
$facts | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $stateDir 'provisioned.json') -Encoding UTF8
$facts.GetEnumerator() | ForEach-Object { Write-Host (" {0,-16} {1}" -f $_.Key, $_.Value) }
'provision-ok' | Set-Content -LiteralPath (Join-Path $stateDir 'provision.ok') -Encoding ASCII
Write-Host '==> 供给完成'
}
catch {
Write-Host ("供给失败:" + $_.Exception.Message) -ForegroundColor Red
("provision-FAILED: " + $_.Exception.Message) | Set-Content -LiteralPath (Join-Path $stateDir 'provision.FAILED') -Encoding UTF8
}
finally {
Stop-Transcript | Out-Null
}
+491
View File
@@ -0,0 +1,491 @@
<#
.SYNOPSIS
BakNRet 的安全描述符(属主 / ACL)场景演练 —— 在 lab 虚拟机内运行。
.DESCRIPTION
两段,都是"真跑",不是模拟:
A. 用户级真实场景(上报的那条链路):
默认方式装 scoop → `scoop install vscode` → 打开 vscode 改配置
→ 备份 → 删源 → 恢复 → 断言 vscode 还能读能写、安全描述符与备份前一致。
B. 权限现场(C:\ProgramData 那种形态):
一个"属主**不是**当前账户 + CREATOR OWNER(inherit-only) + DACL protected"的
目录,备份 / 删源 / 恢复之后:
* 属主必须仍是原账户 —— CREATOR OWNER(S-1-3-0)不是账户,是访问检查时
才替换的占位符,替换成"被检查对象的属主"。属主一旦变成跑恢复脚本的账户,
那条 (A;OICIIO;GA;;;CO) 就把全权判给了脚本,原程序(服务账户)反而没了权限;
* 负对照:只搬文件、不回放安全描述符时,属主确实会落到当前账户头上 ——
也就是"不修就是什么样"。
.NOTES
由 Lab.ps1 的 acl-test 动词经 Invoke-GuestScriptFile 用 pwsh.exe 调起(PowerShell
Direct 的默认端点还是 5.1,读不了仓库里无 BOM 的 UTF-8 脚本,必须显式起 7)。
参数只传字符串,数组用 ';' 拼 —— 与 run-drill.ps1 同一套约定。
#>
[CmdletBinding()]
param(
[string]$RepoPath = 'C:\BakNRet',
[string]$WorkRoot = 'C:\BakNRet-Lab\acl',
[switch]$SkipScoop,
[switch]$KeepWorkRoot
)
$ErrorActionPreference = 'Stop'
# 与 run-suite-utf8.ps1 同理:把控制台编码钉成 UTF-8,中文断言输出才不乱
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
Import-Module (Join-Path $RepoPath 'Common.psm1') -Force
$script:Passed = 0
$script:Failures = @()
function Test-Scenario {
param([Parameter(Mandatory = $true)][string]$Name, [bool]$Ok, [string]$Detail = '')
if ($Ok) {
$script:Passed++
Write-Host (" [PASS] {0}" -f $Name) -ForegroundColor Green
} else {
$script:Failures += $Name
Write-Host (" [FAIL] {0}{1}" -f $Name, $(if ($Detail) { ' —— ' + $Detail } else { '' })) -ForegroundColor Red
}
}
function Get-SecurityFingerprint {
<#
.SYNOPSIS
属主 | 属组 | protected | 全部 ACE 的 类型|SID|掩码(排序)。
.NOTES
刻意不含继承标志位与 ID 标志:继承到文件子对象时容器继承位会被系统去掉,
而 ID 标志写不回去(不是可写的输入),两者都不影响有效权限。
#>
param([Parameter(Mandatory = $true)][string]$Path)
$acl = Get-Acl -LiteralPath $Path
$sid = [System.Security.Principal.SecurityIdentifier]
$aces = @($acl.GetAccessRules($true, $true, $sid) |
ForEach-Object { '{0}|{1}|{2}' -f $_.AccessControlType, $_.IdentityReference.Value, [int]$_.FileSystemRights } |
Sort-Object)
return ('O={0} G={1} P={2} [{3}]' -f $acl.GetOwner($sid).Value, $acl.GetGroup($sid).Value, $acl.AreAccessRulesProtected, ($aces -join ' '))
}
function Invoke-BaknretChild {
<#
.SYNOPSIS
用独立进程跑 Backup.ps1 / Restore.ps1(两个脚本结尾都会 exit)。
.NOTES
输出重定向到文件再读回:不经过 PowerShell 的管道。
#>
param(
[Parameter(Mandatory = $true)][string]$Script,
[Parameter(Mandatory = $true)][hashtable]$Parameters
)
$arguments = @('-NoProfile', '-NonInteractive', '-File', $Script)
foreach ($name in ($Parameters.Keys | Sort-Object)) {
$value = $Parameters[$name]
if ($value -is [bool]) {
if ($value) { $arguments += "-$name" }
continue
}
$arguments += "-$name"
if ($value -is [array]) { $arguments += $value } else { $arguments += [string]$value }
}
$outFile = Join-Path $WorkRoot ('out-' + [guid]::NewGuid().ToString('N').Substring(0, 6) + '.txt')
$process = Start-Process -FilePath 'pwsh.exe' -ArgumentList $arguments -NoNewWindow -Wait -PassThru `
-RedirectStandardOutput $outFile -RedirectStandardError "$outFile.err"
$lines = @(Get-Content -LiteralPath $outFile -Encoding UTF8 -ErrorAction SilentlyContinue)
return [pscustomobject]@{
ExitCode = $process.ExitCode
Lines = @($lines | ForEach-Object { [string]$_ })
Output = (($lines | Out-String))
LastLog = @($lines | Where-Object { $_ -match '\[(INFO|WARN|ERROR)\]' } | Select-Object -Last 6)
}
}
function Stop-VscodeProcesses {
<#
.SYNOPSIS
把 vscode 相关进程清掉。
.NOTES
不清理的后果是实测撞到的:`code --version` 会拉起 Code.exe,进程活着会把
apps\vscode 下的文件锁住 —— 于是"删源"删不干净、恢复也写不进去,
而且报错看起来像是权限问题(正是这个演练要避免的误判)。
#>
param([string]$AppRoot)
foreach ($name in 'Code', 'code', 'Code - Insiders') {
Get-Process -Name $name -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
}
if ($AppRoot) {
foreach ($process in @(Get-Process -ErrorAction SilentlyContinue)) {
try {
$path = $process.Path
if ($path -and $path.StartsWith($AppRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
}
} catch { }
}
}
Start-Sleep -Milliseconds 700
}
function Remove-TreeHard {
<#
.SYNOPSIS
删掉一棵树,包括带刺的 DACL、只读属性和连接点。
.DESCRIPTION
必须比 `Remove-Item -Recurse -Force` 更小心,实测撞到过两件事:
1) scoop 在版本目录里也建了 persist 连接点(`apps\vscode\1.139.1\data`
→ `persist\vscode\data`)。把 `persist\vscode` 当独立条目删掉之后,
那个连接点就成了**悬空连接点**:`Remove-Item -Recurse` 会跟进去
(目标没了 → "对路径 data 的访问被拒绝"),7z 解压也会试图穿过它写
(→ "Could not find a part of the path")。看起来像权限问题,其实是删除方式问题。
2) 带刺的 DACL(protected + 不给当前账户写权限)会让普通删除直接失败。
所以:先把所有连接点摘掉(rmdir 只删链接本身),再用 `rmdir /s /q` 删树;
还删不掉才 takeown / icacls /reset 之后再删。
#>
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return }
$links = @(Get-ChildItem -LiteralPath $Path -Recurse -Force -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint })
foreach ($link in $links) {
& cmd.exe /c ('rmdir "{0}"' -f $link.FullName) 2>&1 | Out-Null
Remove-BaknretJunction -Path $link.FullName
}
& cmd.exe /c ('rmdir /s /q "{0}"' -f $Path) 2>&1 | Out-Null
if (Test-Path -LiteralPath $Path) {
# 只处理这一条路径:拿回属主 → 换成继承来的默认 ACL → 再删
& takeown.exe /F $Path /R /D Y 2>&1 | Out-Null
& icacls.exe $Path /reset /T /C /Q 2>&1 | Out-Null
& cmd.exe /c ('rmdir /s /q "{0}"' -f $Path) 2>&1 | Out-Null
Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue
}
}
# ============================================================================
# 准备
# ============================================================================
if (Test-Path -LiteralPath $WorkRoot) {
Get-ChildItem -LiteralPath $WorkRoot -Directory -ErrorAction SilentlyContinue | ForEach-Object { Remove-TreeHard -Path $_.FullName }
} else {
New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null
}
$BackupDir = Join-Path $WorkRoot 'backups'
New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null
$privileges = Enable-BaknretPrivilege -Name @('SeRestorePrivilege', 'SeBackupPrivilege')
if ($privileges.Missing.Count -gt 0) {
Write-Host ('[acl] 警告:{0} 不在令牌里 —— 属主无法恢复,B 段会失败(应以管理员/SYSTEM 运行)' -f ($privileges.Missing -join '、')) -ForegroundColor Yellow
}
Write-Host ''
Write-Host '===== A. scoop 装的 vscode:备份 / 恢复后还能不能正常读写 =====' -ForegroundColor Cyan
$scoopRoot = Join-Path $env:USERPROFILE 'scoop'
$scoopCmd = Join-Path $scoopRoot 'shims\scoop.cmd'
$vscodeApp = Join-Path $scoopRoot 'apps\vscode'
$vscodePersist = Join-Path $scoopRoot 'persist\vscode'
# extras 里的 vscode 清单**没有 bin 条目**(实测确认),所以 scoop 根本不会生成
# shims\code.cmd —— CLI 在应用目录里,`current` 是指向版本目录的 junction。
# 两个位置都探,谁在就用谁。
$vscodeCli = Join-Path $vscodeApp 'current\bin\code.cmd'
$vscodeCliShim = Join-Path $scoopRoot 'shims\code.cmd'
$codeCmd = $null
if (-not $SkipScoop) {
if (-not (Test-Path -LiteralPath $scoopCmd)) {
# 官方安装器默认拒绝在管理员会话里安装(安全考虑)。PowerShell Direct 的会话是提权的,
# 所以按官方文档给管理员的写法加 -RunAsAdmin —— 目录仍然是默认的 %USERPROFILE%\scoop,
# 布局与普通用户装出来的完全一致(https://github.com/ScoopInstaller/Install#for-admin)。
Write-Host '[A] 用官方默认方式安装 scoop(-RunAsAdmin;目录仍是 %USERPROFILE%\scoop)' -ForegroundColor Yellow
try {
Invoke-Expression "& {$(Invoke-RestMethod -Uri 'https://get.scoop.sh')} -RunAsAdmin"
Write-Host ('[A] 安装器退出码:{0}' -f $LASTEXITCODE)
} catch {
Test-Scenario 'A: 安装 scoop' $false $_.Exception.Message
}
} else {
Write-Host '[A] scoop 已存在,跳过安装'
}
if (Test-Path -LiteralPath $scoopCmd) {
# VM 里没有 git,而 scoop 的 bucket add 是 git clone —— 直接把 main bucket 以 zip
# 形式放到位(scoop 只要求 buckets\main 下是清单文件,不关心它怎么来的)。
$mainBucket = Join-Path $scoopRoot 'buckets\main'
# 判据用 buckets\main\bucket(真正放清单的地方):第一次失败的 bucket add 会留下
# 一个**空**的 buckets\main,只看目录存在会把这种半成品当成"已就绪"。
if (-not (Test-Path -LiteralPath (Join-Path $mainBucket 'bucket'))) {
Write-Host '[A] main bucket 不可用(VM 里没有 git):用 zip 放进去' -ForegroundColor Yellow
$bucketZip = Join-Path $env:TEMP 'bnr-main-bucket.zip'
$bucketDir = Join-Path $env:TEMP 'bnr-main-bucket'
Invoke-WebRequest -Uri 'https://github.com/ScoopInstaller/Main/archive/refs/heads/master.zip' -OutFile $bucketZip
Remove-Item -LiteralPath $bucketDir -Recurse -Force -ErrorAction SilentlyContinue
Expand-Archive -LiteralPath $bucketZip -DestinationPath $bucketDir -Force
New-Item -ItemType Directory -Path (Join-Path $scoopRoot 'buckets') -Force | Out-Null
Remove-Item -LiteralPath $mainBucket -Recurse -Force -ErrorAction SilentlyContinue
Move-Item -LiteralPath (Join-Path $bucketDir 'Main-master') -Destination $mainBucket
Write-Host (' 清单数:{0}' -f @(Get-ChildItem -LiteralPath (Join-Path $mainBucket 'bucket') -Filter '*.json' -ErrorAction SilentlyContinue).Count)
}
}
# 装 git:之后 bucket 操作就是正常路径(scoop 的 bucket add 本质是 git clone)。
# 这台 VM 出厂不带 git,所以 main bucket 只能先用 zip 兜底进来,git 再由此装上。
if ((Test-Path -LiteralPath $scoopCmd) -and -not (Test-Path -LiteralPath (Join-Path $scoopRoot 'shims\git.exe'))) {
Write-Host '[A] scoop install git(后面 bucket add 要靠它)' -ForegroundColor Yellow
& $scoopCmd install git 2>&1 | ForEach-Object { ' ' + $_ }
}
# vscode 在 extras bucket,不在 main 里
if ((Test-Path -LiteralPath $scoopCmd) -and -not (Test-Path -LiteralPath (Join-Path $scoopRoot 'buckets\extras'))) {
Write-Host '[A] scoop bucket add extras' -ForegroundColor Yellow
& $scoopCmd bucket add extras 2>&1 | ForEach-Object { ' ' + $_ }
}
if ((Test-Path -LiteralPath $scoopCmd) -and -not (Test-Path -LiteralPath $vscodeCli)) {
Write-Host '[A] scoop install vscode(从 extras 下载几百 MB,慢是正常的)' -ForegroundColor Yellow
& $scoopCmd install vscode 2>&1 | ForEach-Object { ' ' + $_ }
if (-not (Test-Path -LiteralPath $vscodeCli)) {
Write-Host '[A] 第一次没装上,重试一次(下载超时是常见原因)' -ForegroundColor Yellow
& $scoopCmd install vscode 2>&1 | ForEach-Object { ' ' + $_ }
}
}
}
foreach ($candidate in @($vscodeCli, $vscodeCliShim)) {
if (Test-Path -LiteralPath $candidate) { $codeCmd = $candidate; break }
}
$vscodeReady = [bool]$codeCmd
if ($vscodeReady) {
Test-Scenario 'A: vscode 已安装(找到 CLI)' $true $codeCmd
} elseif ($SkipScoop) {
Write-Host (' [SKIP] 按 -SkipScoop 跳过 vscode({0} 不存在)' -f $vscodeCli) -ForegroundColor Yellow
} else {
Test-Scenario 'A: vscode 已安装(找到 CLI)' $false $vscodeCli
}
# 打开 vscode 改配置:先跑一次 CLI 让它初始化用户数据目录,再写一个可核对的设置
$probe = 'baknret-' + [guid]::NewGuid().ToString('N').Substring(0, 8)
$settingsPath = $null
if ($vscodeReady) {
$versionText = (& $codeCmd --version 2>&1 | Out-String).Trim()
Test-Scenario 'A: vscode CLI 可执行(--version)' ($LASTEXITCODE -eq 0) ($versionText -split "`n" | Select-Object -First 1)
# scoop 的 vscode 清单带 persist:data,用户数据落在 persist 里(portable 模式);
# 万一没有走 portable,退回 %APPDATA%\Code\User。
$userDataDir = Join-Path $vscodePersist 'data\user-data\User'
if (-not (Test-Path -LiteralPath (Join-Path $vscodePersist 'data'))) {
$userDataDir = Join-Path $env:APPDATA 'Code\User'
}
New-Item -ItemType Directory -Path $userDataDir -Force | Out-Null
$settingsPath = Join-Path $userDataDir 'settings.json'
[System.IO.File]::WriteAllText($settingsPath, ('{{"baknret.probe":"{0}","editor.fontSize":14}}' -f $probe))
Write-Host ('[A] 改过的配置:{0}' -f $settingsPath)
}
Write-Host ''
Write-Host '===== B. ProgramData 现场:属主 + CREATOR OWNER =====' -ForegroundColor Cyan
$bRoot = Join-Path $env:ProgramData 'baknret-acl-lab'
Remove-TreeHard -Path $bRoot
$bData = Join-Path $bRoot 'data'
New-Item -ItemType Directory -Path (Join-Path $bData 'sub') -Force | Out-Null
[System.IO.File]::WriteAllText((Join-Path $bData 'sub\a.txt'), 'acl payload')
# 属主设成 **SYSTEM**(不是当前账户、也不是提权进程默认拿到的 Administrators):
# 这正是"CREATOR OWNER 会把全权判给谁"的关键,也是不做安全描述符恢复时必然丢掉的东西。
# 注意 sections 必须带上 Owner —— 只传 Access 的话 SDDL 里的 O: 会被直接忽略,
# 于是属主还是"谁创建谁拥有",测试就退化成没有意义的形式。
$specialSddl = 'O:S-1-5-18D:PAI(A;OICIIO;GA;;;CO)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;0x1200a9;;;BU)'
$specialSecurity = New-Object System.Security.AccessControl.DirectorySecurity
$specialSecurity.SetSecurityDescriptorSddlForm($specialSddl, (
[System.Security.AccessControl.AccessControlSections]::Owner -bor
[System.Security.AccessControl.AccessControlSections]::Access))
[System.IO.FileSystemAclExtensions]::SetAccessControl((Get-Item -LiteralPath $bData), $specialSecurity)
# "跑脚本的账户"新建对象时实际会拿到什么属主 —— 用它做基准,负对照才有判据
$probeDir = Join-Path $WorkRoot 'owner-probe'
New-Item -ItemType Directory -Path $probeDir -Force | Out-Null
$creatorOwner = (Get-Acl -LiteralPath $probeDir).GetOwner([System.Security.Principal.SecurityIdentifier]).Value
$expected = @{}
foreach ($pair in @(@($vscodeApp, 'app'), @($vscodePersist, 'persist'), @($bData, 'programdata'), @((Join-Path $bData 'sub'), 'programdata-sub'))) {
if (Test-Path -LiteralPath $pair[0]) { $expected[$pair[1]] = Get-SecurityFingerprint -Path $pair[0] }
}
$sourceOwner = (Get-Acl -LiteralPath $bData).GetOwner([System.Security.Principal.SecurityIdentifier]).Value
Write-Host ('[B] 备份前 data 的属主:{0}' -f (Get-Acl -LiteralPath $bData).Owner)
$currentSid = ([System.Security.Principal.WindowsIdentity]::GetCurrent()).User.Value
Test-Scenario 'B: 现场造对了 —— 属主既不是当前账户、也不是新建对象的默认属主' `
(($sourceOwner -ne $currentSid) -and ($sourceOwner -ne $creatorOwner)) `
"owner=$sourceOwner current=$currentSid creatorDefault=$creatorOwner"
# ---------------------------------------------------------------------------
# 备份(三个条目)
# ---------------------------------------------------------------------------
$listPath = Join-Path $WorkRoot 'BackupList.txt'
$entries = @()
if ($vscodeReady) { $entries += $vscodeApp; $entries += $vscodePersist }
$entries += $bData
[System.IO.File]::WriteAllText($listPath, (($entries -join [Environment]::NewLine) + [Environment]::NewLine), [System.Text.UTF8Encoding]::new($false))
$configPath = Join-Path $WorkRoot 'BackupConfig.psd1'
$configText = @"
@{
BackupDir = '$BackupDir'
LogDir = '$(Join-Path $WorkRoot 'logs')'
SnapshotDir = '$(Join-Path $BackupDir 'snapshots')'
SoftwareCatalog = 'NoSuchCatalog.psd1'
MinFreeSpaceGB = 0
VerifyArchive = `$true
CompressionLevel = 1
ToolOutput = 'quiet'
Snapshot = @{ Enabled = `$false }
Encryption = @{ Enabled = `$false; PasswordFile = '' }
Security = @{ Mode = 'Full'; IncludeSacl = `$false; SidMap = @{}; FailOnError = `$true }
DefaultExcludes = @('!Thumbs.db', '!desktop.ini')
}
"@
[System.IO.File]::WriteAllText($configPath, $configText, [System.Text.UTF8Encoding]::new($false))
Write-Host ''
Write-Host '[备份] Backup.ps1' -ForegroundColor Yellow
$backup = Invoke-BaknretChild -Script (Join-Path $RepoPath 'Backup.ps1') -Parameters @{
BackupListPath = $listPath
ConfigPath = $configPath
BackupDir = $BackupDir
}
$backup.LastLog | ForEach-Object { ' ' + $_ }
Test-Scenario '备份退出码 0' ($backup.ExitCode -eq 0) ('exit=' + $backup.ExitCode)
Test-Scenario '每个条目都写了 .acl.json' (@(Get-ChildItem -LiteralPath $BackupDir -Filter '*.acl.json').Count -ge $entries.Count) `
('acl.json=' + @(Get-ChildItem -LiteralPath $BackupDir -Filter '*.acl.json').Count + ' 条目=' + $entries.Count)
# ---------------------------------------------------------------------------
# 删源 → 恢复
# ---------------------------------------------------------------------------
foreach ($path in $entries) {
if ($path -ieq $vscodeApp) { Stop-VscodeProcesses -AppRoot $vscodeApp }
Remove-TreeHard -Path $path
}
$leftovers = @($entries | Where-Object { Test-Path -LiteralPath $_ })
Test-Scenario '源已删除(模拟真的丢了)' ($leftovers.Count -eq 0) ($leftovers -join '、')
Write-Host ''
Write-Host '[恢复] Restore.ps1' -ForegroundColor Yellow
$restore = Invoke-BaknretChild -Script (Join-Path $RepoPath 'Restore.ps1') -Parameters @{
BackupListPath = $listPath
ConfigPath = $configPath
BackupDir = $BackupDir
Force = $true
}
$restore.LastLog | ForEach-Object { ' ' + $_ }
Test-Scenario '恢复退出码 0' ($restore.ExitCode -eq 0) ('exit=' + $restore.ExitCode)
Test-Scenario '恢复日志里出现安全描述符回放' ($restore.Output -match '安全描述符:回放') ''
# ---------------------------------------------------------------------------
# A 段断言:vscode 还能不能正常读写
# ---------------------------------------------------------------------------
Write-Host ''
Write-Host '--- A 断言 ---' -ForegroundColor Cyan
if ($vscodeReady) {
$versionText = (& $codeCmd --version 2>&1 | Out-String).Trim()
Test-Scenario 'A: 恢复后 vscode CLI 仍可执行' ($LASTEXITCODE -eq 0) ($versionText -split "`n" | Select-Object -First 1)
$settingsOk = $false
if ($settingsPath -and (Test-Path -LiteralPath $settingsPath)) {
$settingsOk = (Get-Content -LiteralPath $settingsPath -Raw) -match [regex]::Escape($probe)
}
Test-Scenario 'A: 改过的 settings.json 被原样恢复(读得到、内容对)' $settingsOk $settingsPath
# 写测试:vscode 的数据目录必须能新建文件 —— 这正是"无读写权限"症状的反面
$writeOk = $false
$detail = ''
try {
$probeFile = Join-Path (Split-Path -Parent $settingsPath) ('baknret-write-' + [guid]::NewGuid().ToString('N').Substring(0, 6) + '.tmp')
[System.IO.File]::WriteAllText($probeFile, 'write probe')
$writeOk = (Test-Path -LiteralPath $probeFile)
Remove-Item -LiteralPath $probeFile -Force -ErrorAction SilentlyContinue
} catch {
$detail = $_.Exception.Message
}
Test-Scenario 'A: vscode 的数据目录可写(新建文件成功)' $writeOk $detail
foreach ($pair in @(@($vscodeApp, 'app'), @($vscodePersist, 'persist'))) {
if (-not $expected.ContainsKey($pair[1])) { continue }
$expectedNormalized = $expected[$pair[1]] -replace ' P=(True|False) ', ' P='
$actualNormalized = (Get-SecurityFingerprint -Path $pair[0]) -replace ' P=(True|False) ', ' P='
Test-Scenario ("A: {0} 的安全指纹与备份前一致" -f $pair[1]) ($actualNormalized -eq $expectedNormalized) `
("want: " + $expectedNormalized + " / got: " + $actualNormalized)
}
} else {
Write-Host ' [SKIP] vscode 没装上,A 段的功能断言跳过(-SkipScoop 或下载失败)' -ForegroundColor Yellow
}
# ---------------------------------------------------------------------------
# B 段断言:属主与 CREATOR OWNER
# ---------------------------------------------------------------------------
Write-Host ''
Write-Host '--- B 断言 ---' -ForegroundColor Cyan
$restoredOwner = (Get-Acl -LiteralPath $bData).GetOwner([System.Security.Principal.SecurityIdentifier]).Value
Test-Scenario 'B: 现场属主确实是 SYSTEM(不是脚本账户的默认属主)' ($sourceOwner -eq 'S-1-5-18') "source=$sourceOwner"
Test-Scenario 'B: 恢复后属主 == 备份前的属主 —— CREATOR OWNER 才会判给原程序' ($restoredOwner -eq $sourceOwner) "want=$sourceOwner got=$restoredOwner"
Test-Scenario 'B: CREATOR OWNER 的 inherit-only ACE 还在' ((Get-Acl -LiteralPath $bData).Sddl -match '\(A;OICIIO;GA;;;CO\)') (Get-Acl -LiteralPath $bData).Sddl
$bExpected = $expected['programdata'] -replace ' P=(True|False) ', ' P='
$bActual = (Get-SecurityFingerprint -Path $bData) -replace ' P=(True|False) ', ' P='
Test-Scenario 'B: data 的安全指纹与备份前一致' ($bActual -eq $bExpected) ("want: $bExpected / got: $bActual")
if ($expected.ContainsKey('programdata-sub')) {
$subExpected = $expected['programdata-sub'] -replace ' P=(True|False) ', ' P='
$subActual = (Get-SecurityFingerprint -Path (Join-Path $bData 'sub')) -replace ' P=(True|False) ', ' P='
Test-Scenario 'B: 子目录的安全指纹与备份前一致' ($subActual -eq $subExpected) ("want: $subExpected / got: $subActual")
}
# 负对照:只搬文件、不回放安全描述符 —— 属主会落到"跑脚本的账户"头上,
# 也就是 (A;OICIIO;GA;;;CO) 把全权判给脚本、原程序没权限的那种状态。
$negative = Join-Path $WorkRoot 'negative-data'
& robocopy.exe $bData $negative /E /COPY:DAT /NFL /NDL /NJH /NJS /NP | Out-Null
$negativeOwner = (Get-Acl -LiteralPath $negative).GetOwner([System.Security.Principal.SecurityIdentifier]).Value
Test-Scenario 'B 负对照: 只搬文件时,属主变成"跑脚本的账户"而不再是原账户' `
(($negativeOwner -ne $sourceOwner) -and ($negativeOwner -eq $creatorOwner)) `
"negative=$negativeOwner creatorDefault=$creatorOwner"
Write-Host (' 原属主 = {0}' -f $sourceOwner)
Write-Host (' 恢复后属主 = {0}(应与原属主相同)' -f $restoredOwner)
Write-Host (' 负对照属主 = {0}(跑脚本的账户新建对象的默认属主)' -f $negativeOwner)
# ============================================================================
# 收尾
# ============================================================================
Write-Host ''
$total = $script:Passed + $script:Failures.Count
if ($script:Failures.Count -eq 0) {
Write-Host ('ACL 演练:全部通过 {0} 项' -f $total) -ForegroundColor Green
} else {
Write-Host ('ACL 演练:通过 {0} 项,失败 {1} 项' -f $script:Passed, $script:Failures.Count) -ForegroundColor Red
foreach ($failure in $script:Failures) { Write-Host (' - ' + $failure) -ForegroundColor Red }
}
if ($KeepWorkRoot) {
Write-Host ('临时目录保留:{0}' -f $WorkRoot) -ForegroundColor Yellow
} else {
Remove-TreeHard -Path $bRoot
Remove-TreeHard -Path (Join-Path $WorkRoot 'negative-data')
# 备份与日志留着,便于事后核对(归档可能有几百 MB,要腾空间就手工删这个目录)
}
if ($script:Failures.Count -gt 0) { exit 1 }
exit 0
+45
View File
@@ -0,0 +1,45 @@
<#
.SYNOPSIS
在 VM 内跑 tests\Restore-Drill.ps1,并把条目数组安全地传进去。
.DESCRIPTION
为什么需要这一层:跨进程传数组参数是坏的。
经 `pwsh -File Restore-Drill.ps1 -Entries A B C` 传进去时,只有第一个值能绑到
`[string[]]$Entries`,后面的会被当成多余的位置参数:
A positional parameter cannot be found that accepts argument '...'
而 JSON / 带引号的字符串又会在 Start-Process 拼命令行时被引号转义搞坏,
所以这里用 `;` 分隔的纯文本传条目,再在 PowerShell 内部用真正的数组绑定调用钻取脚本。
用法:pwsh -File run-drill.ps1 -BackupDir <归档目录> -ConfigPath <配置> -EntriesCsv 'A;B;C'
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$BackupDir,
[Parameter(Mandatory)][string]$ConfigPath,
[string]$EntriesCsv = '',
[switch]$KeepWorkRoot,
[switch]$AllowChanged
)
$ErrorActionPreference = 'Continue'
$entries = @()
if ($EntriesCsv) {
$entries = @($EntriesCsv.Split(';') | Where-Object { $_ -and $_.Trim() } | ForEach-Object { $_.Trim() })
}
# 必须用**哈希表** splat:数组 splat 会把 -Entries A B C 拆成三个独立参数,
# 只有 A 绑得上,B 会被当成多余的位置参数(A positional parameter cannot be found ...)。
$drillParams = [ordered]@{
BackupDir = $BackupDir
ConfigPath = $ConfigPath
}
if ($entries.Count -gt 0) { $drillParams['Entries'] = $entries }
if ($KeepWorkRoot) { $drillParams['KeepWorkRoot'] = $true }
if ($AllowChanged) { $drillParams['AllowChanged'] = $true }
Write-Host ("[lab] 恢复演练:{0} 个条目 -> {1}" -f $entries.Count, ($entries -join ' | '))
& 'C:\BakNRet\tests\Restore-Drill.ps1' @drillParams
+40
View File
@@ -0,0 +1,40 @@
<#
.SYNOPSIS
在 VM 内以 UTF-8 控制台编码运行一个测试套件(不改仓库里的任何测试代码)。
.DESCRIPTION
为什么需要它 —— tests\BakNRet*.Tests.ps1 的 Invoke-BaknretScript 是这么抓子进程输出的:
cmd /c pwsh -File Backup.ps1 ... > out.txt 2>&1
Get-Content -LiteralPath out.txt -Encoding UTF8
而 Backup.ps1 / Restore.ps1 的 Write-Log 走 Write-Host,写进 out.txt 的字节用的是
`[Console]::OutputEncoding`:
* 宿主机上它是 utf-8 -> 文件是 UTF-8 -> 按 UTF-8 读回,中文正确,套件全绿;
* 一台全新 Windows VM 上它是 ANSI 代码页(中文系统 936)
-> 文件是 GBK 字节 -> 按 UTF-8 读回得到替换字符 -> 断言中文的那几项失败。
这是测试环境假设问题,不是产品缺陷。本包装器把控制台输出编码先钉成 UTF-8,
于是 VM 里也能得到和宿主机一致的 150/150。
用法:pwsh -File run-suite-utf8.ps1 C:\BakNRet\tests\Run-Pester.ps1 [-KeepWorkRoot ...]
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, Position = 0)][string]$Suite,
[Parameter(Position = 1, ValueFromRemainingArguments = $true)][string[]]$SuiteArgs = @()
)
$ErrorActionPreference = 'Continue'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
Write-Host ("[lab] 包装器:PS {0},[Console]::OutputEncoding = {1}" -f $PSVersionTable.PSVersion, [Console]::OutputEncoding.WebName)
Write-Host ("[lab] 套件:{0} {1}" -f $Suite, ($SuiteArgs -join ' '))
if (-not (Test-Path -LiteralPath $Suite)) { Write-Error "找不到套件:$Suite"; exit 2 }
& $Suite @SuiteArgs
exit $LASTEXITCODE
@@ -0,0 +1,25 @@
<#
隔离沙盒配置:归档、日志、快照全部落在 C:\BakNRet-Lab 与 C:\BakNRet\ 下(都在 VM 内),
绝不碰宿主机仓库的 Backups\ 与 logs\。
取值偏「跑得快」而非「压得小」:CompressionLevel = 1,让一次全链路几秒钟跑完;
要压真实比例时用 -CompressionLevel 9 单独跑。
#>
@{
BackupDir = 'C:\BakNRet-Lab\Backups'
LogDir = 'C:\BakNRet-Lab\logs\backup'
SnapshotDir = 'C:\BakNRet-Lab\Backups\snapshots'
# 注意:SoftwareCatalog 的相对路径是按**仓库根**(Backup.ps1 所在目录)解析的,
# 不是按本配置文件所在目录;而且路径不存在时会**静默回退**到仓库真实的
# SoftwareCatalog.psd1。沙盒必须写成仓库根相对路径,否则软件名条目会悄悄用错名录。
SoftwareCatalog = 'tools\lab\payload\sandbox\SoftwareCatalog.psd1'
CatalogMaxDepth = 5
MinFreeSpaceGB = 0
VerifyArchive = $true
ComputeHash = $true
CompressionLevel = 1
ToolOutput = 'quiet'
Snapshot = @{ Enabled = $false; KeepCount = 3; KeepDays = 30 }
Encryption = @{ Enabled = $false; PasswordFile = 'C:\BakNRet-Lab\state\baknret.key'; EncryptHeaders = $true }
DefaultExcludes = @('!Thumbs.db','!desktop.ini')
}
+26
View File
@@ -0,0 +1,26 @@
###########
# BakNRet 隔离沙盒清单(只在 VM 内使用;所有路径都指向 C:\BakNRet-Lab\sources)
###########
#
# 形态覆盖:多 Slot 软件名、单文件 Slot、中文+空格路径、真 NTFS 连接点、手写路径、
# :- 排除、:+ 追加、:: 覆盖 Path、行首方向标记 + / -、源缺失条目。
# 约束提醒:归档名 = 软件名(或路径推导名),每行必须产生唯一归档名。
# ---- 软件名条目(查同目录的 SoftwareCatalog.psd1)----
AppMultiSlot :- CacheSlot\cache-01.tmp,!*.log # Slot 前缀排除 + 任意层级通配
AppFileSlot :+ Modules:C:\BakNRet-Lab\sources\AppMultiSlot\Config # 追加映射:把 Config 放到包内 Modules\
软件目录甲 # 中文 + 空格 + 点的路径
JunctionToData # 真 NTFS 连接点
MissingApp # 源不存在:记 missing-source,退出码仍 0
OverrideTarget :: C:\BakNRet-Lab\sources\AppMultiSlot\Config # :: 覆盖名录里故意写错的 Path
# ---- 手写路径条目 ----
C:\BakNRet-Lab\sources\AppBig
C:\BakNRet-Lab\sources\AppLocked # 被占用文件:验证「有文件没打进归档」的告警
# ---- 行首方向标记 ----
+ C:\BakNRet-Lab\sources\AppDeep # 仅备份,不恢复
- C:\BakNRet-Lab\sources\AppRestoreOnly # 仅恢复,不备份(备份端跳过)
@@ -0,0 +1,34 @@
<#
BakNRet 隔离沙盒名录:软件名 -> Slot 组,全部指向 C:\BakNRet-Lab\sources 下的假数据。
只在 VM 内使用,宿主机仓库里的 SoftwareCatalog.psd1 不受影响。
带连字符的键必须加引号(PSD1 会把它当减法);这里用中文键名,也统一加引号。
#>
@{
AppMultiSlot = @{
DefaultData = @{ Path = 'C:\BakNRet-Lab\sources\AppMultiSlot\Data'; Description = '主数据(含 node_modules、session.log、空目录)' }
DefaultConfig = @{ Path = 'C:\BakNRet-Lab\sources\AppMultiSlot\Config'; Description = '配置(含 .bak)' }
CacheSlot = @{ Path = 'C:\BakNRet-Lab\sources\AppMultiSlot\Cache'; Description = '缓存(清单里再排除一条 cache-01.tmp)' }
}
AppFileSlot = @{
Profile = @{ Path = 'C:\BakNRet-Lab\sources\AppFileSlot\profile.json'; Description = '单文件 Slot:归档内是名为 Profile 的文件' }
Readme = @{ Path = 'C:\BakNRet-Lab\sources\AppFileSlot\readme.txt'; Description = '另一个单文件 Slot' }
}
'软件目录甲' = @{
DefaultData = @{ Path = 'C:\BakNRet-Lab\sources\软件 目录.甲'; Description = '中文 + 空格 + 点的路径' }
}
JunctionToData = @{
DefaultData = @{ Path = 'C:\BakNRet-Lab\sources\JunctionToData'; Description = '真 NTFS 连接点(指向 AppMultiSlot\Data)' }
}
MissingApp = @{
DefaultData = @{ Path = 'C:\BakNRet-Lab\sources\MissingApp'; Description = '源不存在:备份跳过并记 missing-source,恢复仍知道目标位置' }
}
OverrideTarget = @{
DefaultData = @{ Path = 'C:\BakNRet-Lab\sources\OverrideTarget-故意不存在'; Description = '故意写错,由清单里的 :: 覆盖成存在的目录' }
}
}
+126
View File
@@ -0,0 +1,126 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
BakNRet 隔离测试 VM 的无人值守应答文件(离线部署路径)。
Windows 用 DISM 展开到 VHDX 之后,本文件被放到 C:\Windows\Panther\unattend.xml,
首次启动时由 Windows 在 specialize 与 oobeSystem 两个阶段读取。
设计要点:
* 不启用已废弃的 SkipMachineOOBE / SkipUserOOBE —— 在 Windows 11 25H2 上它们会让
OOBE 卡住;这里改用 OOBE 隐藏项 + BypassNRO + 明确的本地账户;
* 只创建一个本地管理员 lab,避免 OOBE 索要微软账户;
* AutoLogon 三次,用来跑 FirstLogonCommands 里的供给脚本;
* 口令占位符 __LABPASSWORD__ 由 tools\lab\New-BakNRetLab.ps1 在注入前替换成随机口令,
口令只留在宿主机 D:\VMs\BakNRet-Lab\state\credentials.json,不进版本库。
-->
<unattend xmlns="urn:schemas-microsoft-com:unattend">
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-Setup"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ComputerName>BAKNRET-LAB</ComputerName>
<TimeZone>China Standard Time</TimeZone>
<RegisteredOwner>BakNRet Lab</RegisteredOwner>
<RegisteredOrganization>BakNRet Lab</RegisteredOrganization>
</component>
<component name="Microsoft-Windows-Deployment"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<RunSynchronous>
<RunSynchronousCommand wcm:action="add">
<Order>1</Order>
<Description>跳过 OOBE 的联网 / 微软账户强制</Description>
<Path>reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE" /v BypassNRO /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>2</Order>
<Description>关掉“让我们完成设备设置”一类打扰</Description>
<Path>reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\UserProfileEngagement" /v ScoobeSystemSettingEnabled /t REG_DWORD /d 0 /f</Path>
</RunSynchronousCommand>
</RunSynchronous>
</component>
</settings>
<settings pass="oobeSystem">
<component name="Microsoft-Windows-International-Core"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<InputLocale>zh-CN</InputLocale>
<SystemLocale>zh-CN</SystemLocale>
<UILanguage>zh-CN</UILanguage>
<UserLocale>zh-CN</UserLocale>
</component>
<component name="Microsoft-Windows-Shell-Setup"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<NetworkLocation>Work</NetworkLocation>
<ProtectYourPC>3</ProtectYourPC>
</OOBE>
<UserAccounts>
<LocalAccounts>
<LocalAccount wcm:action="add">
<Name>lab</Name>
<DisplayName>Lab</DisplayName>
<Description>BakNRet 隔离测试账户</Description>
<Group>Administrators</Group>
<Password>
<Value>__LABPASSWORD__</Value>
<PlainText>true</PlainText>
</Password>
</LocalAccount>
</LocalAccounts>
</UserAccounts>
<AutoLogon>
<Username>lab</Username>
<Enabled>true</Enabled>
<LogonCount>3</LogonCount>
<Password>
<Value>__LABPASSWORD__</Value>
<PlainText>true</PlainText>
</Password>
</AutoLogon>
<FirstLogonCommands>
<SynchronousCommand wcm:action="add">
<Order>1</Order>
<Description>BakNRet lab 供给脚本(把 VM 变成可跑全链路测试的真机状态)</Description>
<CommandLine>powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\BakNRet-Lab\payload\provision.ps1</CommandLine>
</SynchronousCommand>
</FirstLogonCommands>
<TimeZone>China Standard Time</TimeZone>
</component>
</settings>
</unattend>