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:
1 parent
7173e8ae10
commit
2937eb6652
32 files changed
+8775
-1691
No files matched your search
+95
-27
@@ -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 })
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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 下,便于事后排查;确认不要了再手工删该目录
|
||||
```
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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 = '故意写错,由清单里的 :: 覆盖成存在的目录' }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user