chore: 记录改造前基线

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

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

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

No files matched your search

+126
View File
@@ -0,0 +1,126 @@
<#
.SYNOPSIS
BakNRet 隔离沙盒的假数据生成器(在 VM 内运行)。
.DESCRIPTION
在 C:\BakNRet-Lab\sources 下造出一批**故意带刺**的源目录,用来在真机语义下压测
Backup.ps1 / Restore.ps1 —— 这些形态在宿主机上不敢随便试:
* 多 Slot 软件目录(Data / Config / Cache 三个子目录,各自可带排除);
* 单文件 Slot(一个 .json 直接当一个 Slot);
* 中文 + 空格 + 点的路径名;
* **真 NTFS 连接点(junction)** —— exFAT 的仓库里造不出来;
* **被占用文件** —— 后台进程持有句柄,验证「有文件没打进归档」的告警路径;
* 长路径(接近 260 字符)与 10 层深目录;
* DefaultExcludes 命中的垃圾文件(Thumbs.db / desktop.ini)与 *.log;
* 空目录;
* 一个约 50 MB 的文件,让归档大小/空间预估有实际数字;
* 一个「源不存在」条目对应的目录(故意不建)。
幂等:默认只在缺失时创建;-Force 会先删掉 sources 重建(删连接点用 rmdir,避免跟进目标)。
#>
[CmdletBinding()]
param(
[string]$Root = 'C:\BakNRet-Lab\sources',
[switch]$Force
)
$ErrorActionPreference = 'Stop'
function New-TextFile {
param([string]$Path, [string]$Content, [int]$Count = 1)
$dir = Split-Path -Parent $Path
if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
if ($Count -le 1) {
Set-Content -LiteralPath $Path -Value $Content -Encoding UTF8
} else {
Set-Content -LiteralPath $Path -Value (1..$Count | ForEach-Object { "$Content #$_" }) -Encoding UTF8
}
}
if ($Force -and (Test-Path -LiteralPath $Root)) {
Write-Host "清除已有沙盒源:$Root"
Get-ChildItem -LiteralPath $Root -Recurse -Force -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint } |
ForEach-Object { cmd /c rmdir "$($_.FullName)" 2>$null }
Remove-Item -LiteralPath $Root -Recurse -Force
}
New-Item -ItemType Directory -Force -Path $Root | Out-Null
# --- 1. 多 Slot 软件目录 -----------------------------------------------------
$appA = Join-Path $Root 'AppMultiSlot'
New-TextFile (Join-Path $appA 'Data\settings.json') '{ "theme": "dark", "slots": 3 }'
New-TextFile (Join-Path $appA 'Data\nested\deep\payload.bin') 'binary-ish-payload' -Count 40
New-TextFile (Join-Path $appA 'Config\app.ini') '[main]'
New-TextFile (Join-Path $appA 'Config\app.ini.bak') '[main] backup copy'
New-TextFile (Join-Path $appA 'Cache\cache-01.tmp') 'cache entry' -Count 20
New-TextFile (Join-Path $appA 'Cache\Thumbs.db') 'junk that DefaultExcludes should drop'
New-TextFile (Join-Path $appA 'Cache\desktop.ini') 'junk that DefaultExcludes should drop'
New-TextFile (Join-Path $appA 'Data\session.log') 'log line that an exclusion should drop' -Count 10
New-TextFile (Join-Path $appA 'Data\node_modules\pkg\index.js') 'module.exports = {}'
New-Item -ItemType Directory -Force -Path (Join-Path $appA 'Data\emptydir') | Out-Null
# --- 2. 单文件 Slot ----------------------------------------------------------
$appB = Join-Path $Root 'AppFileSlot'
New-TextFile (Join-Path $appB 'profile.json') '{ "name": "file-slot", "single": true }'
New-TextFile (Join-Path $appB 'readme.txt') 'file slot 的侧车说明'
# --- 3. 中文 + 空格 + 点的路径 ----------------------------------------------
$appC = Join-Path $Root '软件 目录.甲'
New-TextFile (Join-Path $appC '设置\配置 文件.ini') '中文路径内容'
New-TextFile (Join-Path $appC '数据 备份\记录.txt') '记录内容' -Count 5
# --- 4. 真 NTFS 连接点 -------------------------------------------------------
$realTarget = Join-Path $Root 'AppMultiSlot\Data'
$junction = Join-Path $Root 'JunctionToData'
if (-not (Test-Path -LiteralPath $junction)) {
$null = New-Item -ItemType Junction -Path $junction -Target $realTarget -ErrorAction SilentlyContinue
}
if (Test-Path -LiteralPath $junction) { Write-Host "连接点已建:$junction -> $realTarget" }
# --- 5. 长路径与深目录 -------------------------------------------------------
$cursor = Join-Path $Root 'AppDeep'
1..10 | ForEach-Object { $cursor = Join-Path $cursor "level$_" }
New-TextFile (Join-Path $cursor 'bottom.txt') 'deep content'
Write-Host ("最长路径长度:{0} 字符" -f (Join-Path $cursor 'bottom.txt').Length)
# --- 6. 50 MB 大文件 ---------------------------------------------------------
$bigDir = Join-Path $Root 'AppBig'
$bigFile = Join-Path $bigDir 'blob-50mb.bin'
if (-not (Test-Path -LiteralPath $bigFile)) {
New-Item -ItemType Directory -Force -Path $bigDir | Out-Null
$fs = [IO.File]::Create($bigFile)
try {
$rng = [Random]::new(20260926)
$chunk = [byte[]]::new(1MB)
for ($i = 0; $i -lt 50; $i++) { $rng.NextBytes($chunk); $fs.Write($chunk, 0, $chunk.Length) }
} finally { $fs.Dispose() }
}
Write-Host ("大文件:{0} MB" -f [math]::Round((Get-Item $bigFile).Length/1MB,1))
# --- 7. 被占用文件(后台进程持句柄 90 秒后释放)-----------------------------
$lockDir = Join-Path $Root 'AppLocked'
$lockFile = Join-Path $lockDir 'locked.bin'
New-Item -ItemType Directory -Force -Path $lockDir | Out-Null
New-TextFile $lockFile 'this file is held open by another process'
$holderLines = @(
'$path = $args[0]'
'$fs = [IO.File]::Open($path, ''Open'', ''ReadWrite'', ''None'')'
'try { Start-Sleep -Seconds 90 } finally { $fs.Dispose() }'
)
$holderPath = 'C:\BakNRet-Lab\state\hold-lock.ps1'
Set-Content -LiteralPath $holderPath -Value $holderLines -Encoding UTF8
Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File',$holderPath,$lockFile) -WindowStyle Hidden
Write-Host "已启动占用者进程(持句柄 90 秒):$lockFile"
# --- 8. 「源不存在」条目对应的目录:故意不建 ---------------------------------
Write-Host '故意不创建 MissingApp(用于验证源缺失只跳过、不失败)'
Write-Host ''
Write-Host '--- 沙盒源清单 ---'
Get-ChildItem -LiteralPath $Root -Force | ForEach-Object {
$files = @(Get-ChildItem -LiteralPath $_.FullName -Recurse -File -Force -ErrorAction SilentlyContinue)
$mb = [math]::Round((($files | Measure-Object Length -Sum).Sum) / 1MB, 2)
" {0,-24} {1,4} 个文件 {2,8} MB 连接点={3}" -f $_.Name, $files.Count, $mb, [bool]($_.Attributes -band [IO.FileAttributes]::ReparsePoint)
}
+118
View File
@@ -0,0 +1,118 @@
<#
.SYNOPSIS
BakNRet 隔离测试 VM 的首次登录供给脚本(由 unattend.xml 的 FirstLogonCommands 调用)。
.DESCRIPTION
运行环境是 VM 内全新安装的 Windows 11(Windows PowerShell 5.1、管理员 lab 账户)。
目标:把 VM 变成「可以直接跑 BakNRet 全链路测试」的真机状态:
1. 电源 / 休眠 / 锁屏:测试期间不要因为空闲睡下去;
2. 执行策略 Bypass(仅此实验 VM);
3. 把注入的 7-Zip 与 PowerShell 7 放到机器 PATH 上,与宿主机的工具版本对齐;
4. 安装 Pester 5.9.1(Windows PowerShell 与 pwsh 两侧都能导入);
5. 不让 Windows Update 自动重启,并给实验目录加 Defender 排除
(避免杀软把「文件被占用」类用例变成随机失败,同时显著拖慢压缩);
6. 关掉首次登录后的 SCOOBE「完成设备设置」向导;
7. 写出 state\provisioned.json 与 state\provision.ok 作为「供给完成」的硬凭据。
幂等:可重复执行,第二次跑不会失败。
#>
$ErrorActionPreference = 'Continue'
$ProgressPreference = 'SilentlyContinue'
$lab = 'C:\BakNRet-Lab'
$logDir = Join-Path $lab 'logs'
$stateDir = Join-Path $lab 'state'
New-Item -ItemType Directory -Force -Path $logDir, $stateDir | Out-Null
Start-Transcript -Path (Join-Path $logDir 'provision.log') -Force | Out-Null
function Step($m) { Write-Host "==> $m" }
try {
Step '1/7 电源与显示:不休眠、不锁屏、关休眠'
powercfg /change standby-timeout-ac 0 | Out-Null
powercfg /change monitor-timeout-ac 0 | Out-Null
powercfg /change hibernate-timeout-ac 0 | Out-Null
powercfg /hibernate off | Out-Null
Step '2/7 执行策略:LocalMachine = Bypass(仅此实验 VM)'
Set-ExecutionPolicy -Scope LocalMachine -ExecutionPolicy Bypass -Force
Step '3/7 工具上机器 PATH:7-Zip 与 PowerShell 7'
$zipSrc = Join-Path $lab 'payload\7zip'
$zipDst = 'C:\Program Files\7-Zip'
$pwshSrc = Join-Path $lab 'payload\pwsh'
$pwshDst = 'C:\Program Files\PowerShell\7'
if (-not (Test-Path $zipDst)) { robocopy $zipSrc $zipDst /MIR /NFL /NDL /NJH /NJS /NP | Out-Null }
if (-not (Test-Path $pwshDst)) { robocopy $pwshSrc $pwshDst /MIR /NFL /NDL /NJH /NJS /NP | Out-Null }
$machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine')
foreach ($p in @($zipDst, $pwshDst)) {
if ($machinePath -notlike "*$p*") { $machinePath = $machinePath.TrimEnd(';') + ';' + $p }
if ($env:Path -notlike "*$p*") { $env:Path = $env:Path.TrimEnd(';') + ';' + $p }
}
[Environment]::SetEnvironmentVariable('Path', $machinePath, 'Machine')
Step '4/7 安装 Pester 5.9.1(Windows PowerShell 与 pwsh 各一份)'
$pesterSrc = Join-Path $lab 'payload\Pester\5.9.1'
foreach ($dst in @("$env:ProgramFiles\WindowsPowerShell\Modules\Pester\5.9.1",
"$env:ProgramFiles\PowerShell\Modules\Pester\5.9.1")) {
if (-not (Test-Path $dst)) { robocopy $pesterSrc $dst /MIR /NFL /NDL /NJH /NJS /NP | Out-Null }
}
Step '5/7 Windows Update 不自动重启 + Defender 排除实验目录'
$wu = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU'
New-Item -Path $wu -Force | Out-Null
New-ItemProperty -Path $wu -Name 'NoAutoRebootWithLoggedOnUsers' -Value 1 -PropertyType DWord -Force | Out-Null
New-ItemProperty -Path $wu -Name 'AUOptions' -Value 2 -PropertyType DWord -Force | Out-Null
New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Power' -Name 'HiberbootEnabled' -Value 0 -PropertyType DWord -Force | Out-Null
Add-MpPreference -ExclusionPath 'C:\BakNRet', 'C:\BakNRet-Lab' -ErrorAction SilentlyContinue
Step '6/7 关掉 SCOOBE「完成设备设置」'
$scoobe = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\UserProfileEngagement'
New-Item -Path $scoobe -Force | Out-Null
New-ItemProperty -Path $scoobe -Name 'ScoobeSystemSettingEnabled' -Value 0 -PropertyType DWord -Force | Out-Null
Step '7/7 采集真机事实并落盘'
$zipExe = Join-Path $zipDst '7z.exe'
$pwshExe = Join-Path $pwshDst 'pwsh.exe'
$pwshVer = '缺失'
if (Test-Path $pwshExe) { $pwshVer = (& $pwshExe -NoProfile -Command '$PSVersionTable.PSVersion.ToString()' 2>&1) -join ' ' }
$zipVer = '缺失'
if (Test-Path $zipExe) { $zipVer = (& $zipExe 2>&1 | Select-Object -First 2) -join ' / ' }
$facts = [ordered]@{
ProvisionedAt = (Get-Date).ToString('s')
ComputerName = $env:COMPUTERNAME
User = (whoami)
IsAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
OsCaption = (Get-CimInstance Win32_OperatingSystem).Caption
OsVersion = (Get-CimInstance Win32_OperatingSystem).Version
OsBuild = (Get-CimInstance Win32_OperatingSystem).BuildNumber
OsArch = (Get-CimInstance Win32_OperatingSystem).OSArchitecture
WindowsPS = $PSVersionTable.PSVersion.ToString()
SevenZipVersion = $zipVer
PwshVersion = $pwshVer
PesterVersion = (Get-Module -ListAvailable Pester -ErrorAction SilentlyContinue | Select-Object -First 1).Version.ToString()
PathHasSevenZip = ([Environment]::GetEnvironmentVariable('Path', 'Machine')) -like '*7-Zip*'
PathHasPwsh = ([Environment]::GetEnvironmentVariable('Path', 'Machine')) -like '*PowerShell\7*'
CpuCount = (Get-CimInstance Win32_ComputerSystem).NumberOfLogicalProcessors
RamGB = [math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB, 1)
Drives = @(Get-Volume | Where-Object DriveLetter | ForEach-Object {
[ordered]@{ Letter = "$($_.DriveLetter):"; Fs = $_.FileSystemType; SizeGB = [math]::Round($_.Size / 1GB, 1) }
})
}
$facts | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $stateDir 'provisioned.json') -Encoding UTF8
$facts.GetEnumerator() | ForEach-Object { Write-Host (" {0,-16} {1}" -f $_.Key, $_.Value) }
'provision-ok' | Set-Content -LiteralPath (Join-Path $stateDir 'provision.ok') -Encoding ASCII
Write-Host '==> 供给完成'
}
catch {
Write-Host ("供给失败:" + $_.Exception.Message) -ForegroundColor Red
("provision-FAILED: " + $_.Exception.Message) | Set-Content -LiteralPath (Join-Path $stateDir 'provision.FAILED') -Encoding UTF8
}
finally {
Stop-Transcript | Out-Null
}
+491
View File
@@ -0,0 +1,491 @@
<#
.SYNOPSIS
BakNRet 的安全描述符(属主 / ACL)场景演练 —— 在 lab 虚拟机内运行。
.DESCRIPTION
两段,都是"真跑",不是模拟:
A. 用户级真实场景(上报的那条链路):
默认方式装 scoop → `scoop install vscode` → 打开 vscode 改配置
→ 备份 → 删源 → 恢复 → 断言 vscode 还能读能写、安全描述符与备份前一致。
B. 权限现场(C:\ProgramData 那种形态):
一个"属主**不是**当前账户 + CREATOR OWNER(inherit-only) + DACL protected"的
目录,备份 / 删源 / 恢复之后:
* 属主必须仍是原账户 —— CREATOR OWNER(S-1-3-0)不是账户,是访问检查时
才替换的占位符,替换成"被检查对象的属主"。属主一旦变成跑恢复脚本的账户,
那条 (A;OICIIO;GA;;;CO) 就把全权判给了脚本,原程序(服务账户)反而没了权限;
* 负对照:只搬文件、不回放安全描述符时,属主确实会落到当前账户头上 ——
也就是"不修就是什么样"。
.NOTES
由 Lab.ps1 的 acl-test 动词经 Invoke-GuestScriptFile 用 pwsh.exe 调起(PowerShell
Direct 的默认端点还是 5.1,读不了仓库里无 BOM 的 UTF-8 脚本,必须显式起 7)。
参数只传字符串,数组用 ';' 拼 —— 与 run-drill.ps1 同一套约定。
#>
[CmdletBinding()]
param(
[string]$RepoPath = 'C:\BakNRet',
[string]$WorkRoot = 'C:\BakNRet-Lab\acl',
[switch]$SkipScoop,
[switch]$KeepWorkRoot
)
$ErrorActionPreference = 'Stop'
# 与 run-suite-utf8.ps1 同理:把控制台编码钉成 UTF-8,中文断言输出才不乱
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
Import-Module (Join-Path $RepoPath 'Common.psm1') -Force
$script:Passed = 0
$script:Failures = @()
function Test-Scenario {
param([Parameter(Mandatory = $true)][string]$Name, [bool]$Ok, [string]$Detail = '')
if ($Ok) {
$script:Passed++
Write-Host (" [PASS] {0}" -f $Name) -ForegroundColor Green
} else {
$script:Failures += $Name
Write-Host (" [FAIL] {0}{1}" -f $Name, $(if ($Detail) { ' —— ' + $Detail } else { '' })) -ForegroundColor Red
}
}
function Get-SecurityFingerprint {
<#
.SYNOPSIS
属主 | 属组 | protected | 全部 ACE 的 类型|SID|掩码(排序)。
.NOTES
刻意不含继承标志位与 ID 标志:继承到文件子对象时容器继承位会被系统去掉,
而 ID 标志写不回去(不是可写的输入),两者都不影响有效权限。
#>
param([Parameter(Mandatory = $true)][string]$Path)
$acl = Get-Acl -LiteralPath $Path
$sid = [System.Security.Principal.SecurityIdentifier]
$aces = @($acl.GetAccessRules($true, $true, $sid) |
ForEach-Object { '{0}|{1}|{2}' -f $_.AccessControlType, $_.IdentityReference.Value, [int]$_.FileSystemRights } |
Sort-Object)
return ('O={0} G={1} P={2} [{3}]' -f $acl.GetOwner($sid).Value, $acl.GetGroup($sid).Value, $acl.AreAccessRulesProtected, ($aces -join ' '))
}
function Invoke-BaknretChild {
<#
.SYNOPSIS
用独立进程跑 Backup.ps1 / Restore.ps1(两个脚本结尾都会 exit)。
.NOTES
输出重定向到文件再读回:不经过 PowerShell 的管道。
#>
param(
[Parameter(Mandatory = $true)][string]$Script,
[Parameter(Mandatory = $true)][hashtable]$Parameters
)
$arguments = @('-NoProfile', '-NonInteractive', '-File', $Script)
foreach ($name in ($Parameters.Keys | Sort-Object)) {
$value = $Parameters[$name]
if ($value -is [bool]) {
if ($value) { $arguments += "-$name" }
continue
}
$arguments += "-$name"
if ($value -is [array]) { $arguments += $value } else { $arguments += [string]$value }
}
$outFile = Join-Path $WorkRoot ('out-' + [guid]::NewGuid().ToString('N').Substring(0, 6) + '.txt')
$process = Start-Process -FilePath 'pwsh.exe' -ArgumentList $arguments -NoNewWindow -Wait -PassThru `
-RedirectStandardOutput $outFile -RedirectStandardError "$outFile.err"
$lines = @(Get-Content -LiteralPath $outFile -Encoding UTF8 -ErrorAction SilentlyContinue)
return [pscustomobject]@{
ExitCode = $process.ExitCode
Lines = @($lines | ForEach-Object { [string]$_ })
Output = (($lines | Out-String))
LastLog = @($lines | Where-Object { $_ -match '\[(INFO|WARN|ERROR)\]' } | Select-Object -Last 6)
}
}
function Stop-VscodeProcesses {
<#
.SYNOPSIS
把 vscode 相关进程清掉。
.NOTES
不清理的后果是实测撞到的:`code --version` 会拉起 Code.exe,进程活着会把
apps\vscode 下的文件锁住 —— 于是"删源"删不干净、恢复也写不进去,
而且报错看起来像是权限问题(正是这个演练要避免的误判)。
#>
param([string]$AppRoot)
foreach ($name in 'Code', 'code', 'Code - Insiders') {
Get-Process -Name $name -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
}
if ($AppRoot) {
foreach ($process in @(Get-Process -ErrorAction SilentlyContinue)) {
try {
$path = $process.Path
if ($path -and $path.StartsWith($AppRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
}
} catch { }
}
}
Start-Sleep -Milliseconds 700
}
function Remove-TreeHard {
<#
.SYNOPSIS
删掉一棵树,包括带刺的 DACL、只读属性和连接点。
.DESCRIPTION
必须比 `Remove-Item -Recurse -Force` 更小心,实测撞到过两件事:
1) scoop 在版本目录里也建了 persist 连接点(`apps\vscode\1.139.1\data`
→ `persist\vscode\data`)。把 `persist\vscode` 当独立条目删掉之后,
那个连接点就成了**悬空连接点**:`Remove-Item -Recurse` 会跟进去
(目标没了 → "对路径 data 的访问被拒绝"),7z 解压也会试图穿过它写
(→ "Could not find a part of the path")。看起来像权限问题,其实是删除方式问题。
2) 带刺的 DACL(protected + 不给当前账户写权限)会让普通删除直接失败。
所以:先把所有连接点摘掉(rmdir 只删链接本身),再用 `rmdir /s /q` 删树;
还删不掉才 takeown / icacls /reset 之后再删。
#>
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return }
$links = @(Get-ChildItem -LiteralPath $Path -Recurse -Force -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint })
foreach ($link in $links) {
& cmd.exe /c ('rmdir "{0}"' -f $link.FullName) 2>&1 | Out-Null
Remove-BaknretJunction -Path $link.FullName
}
& cmd.exe /c ('rmdir /s /q "{0}"' -f $Path) 2>&1 | Out-Null
if (Test-Path -LiteralPath $Path) {
# 只处理这一条路径:拿回属主 → 换成继承来的默认 ACL → 再删
& takeown.exe /F $Path /R /D Y 2>&1 | Out-Null
& icacls.exe $Path /reset /T /C /Q 2>&1 | Out-Null
& cmd.exe /c ('rmdir /s /q "{0}"' -f $Path) 2>&1 | Out-Null
Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue
}
}
# ============================================================================
# 准备
# ============================================================================
if (Test-Path -LiteralPath $WorkRoot) {
Get-ChildItem -LiteralPath $WorkRoot -Directory -ErrorAction SilentlyContinue | ForEach-Object { Remove-TreeHard -Path $_.FullName }
} else {
New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null
}
$BackupDir = Join-Path $WorkRoot 'backups'
New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null
$privileges = Enable-BaknretPrivilege -Name @('SeRestorePrivilege', 'SeBackupPrivilege')
if ($privileges.Missing.Count -gt 0) {
Write-Host ('[acl] 警告:{0} 不在令牌里 —— 属主无法恢复,B 段会失败(应以管理员/SYSTEM 运行)' -f ($privileges.Missing -join '、')) -ForegroundColor Yellow
}
Write-Host ''
Write-Host '===== A. scoop 装的 vscode:备份 / 恢复后还能不能正常读写 =====' -ForegroundColor Cyan
$scoopRoot = Join-Path $env:USERPROFILE 'scoop'
$scoopCmd = Join-Path $scoopRoot 'shims\scoop.cmd'
$vscodeApp = Join-Path $scoopRoot 'apps\vscode'
$vscodePersist = Join-Path $scoopRoot 'persist\vscode'
# extras 里的 vscode 清单**没有 bin 条目**(实测确认),所以 scoop 根本不会生成
# shims\code.cmd —— CLI 在应用目录里,`current` 是指向版本目录的 junction。
# 两个位置都探,谁在就用谁。
$vscodeCli = Join-Path $vscodeApp 'current\bin\code.cmd'
$vscodeCliShim = Join-Path $scoopRoot 'shims\code.cmd'
$codeCmd = $null
if (-not $SkipScoop) {
if (-not (Test-Path -LiteralPath $scoopCmd)) {
# 官方安装器默认拒绝在管理员会话里安装(安全考虑)。PowerShell Direct 的会话是提权的,
# 所以按官方文档给管理员的写法加 -RunAsAdmin —— 目录仍然是默认的 %USERPROFILE%\scoop,
# 布局与普通用户装出来的完全一致(https://github.com/ScoopInstaller/Install#for-admin)。
Write-Host '[A] 用官方默认方式安装 scoop(-RunAsAdmin;目录仍是 %USERPROFILE%\scoop)' -ForegroundColor Yellow
try {
Invoke-Expression "& {$(Invoke-RestMethod -Uri 'https://get.scoop.sh')} -RunAsAdmin"
Write-Host ('[A] 安装器退出码:{0}' -f $LASTEXITCODE)
} catch {
Test-Scenario 'A: 安装 scoop' $false $_.Exception.Message
}
} else {
Write-Host '[A] scoop 已存在,跳过安装'
}
if (Test-Path -LiteralPath $scoopCmd) {
# VM 里没有 git,而 scoop 的 bucket add 是 git clone —— 直接把 main bucket 以 zip
# 形式放到位(scoop 只要求 buckets\main 下是清单文件,不关心它怎么来的)。
$mainBucket = Join-Path $scoopRoot 'buckets\main'
# 判据用 buckets\main\bucket(真正放清单的地方):第一次失败的 bucket add 会留下
# 一个**空**的 buckets\main,只看目录存在会把这种半成品当成"已就绪"。
if (-not (Test-Path -LiteralPath (Join-Path $mainBucket 'bucket'))) {
Write-Host '[A] main bucket 不可用(VM 里没有 git):用 zip 放进去' -ForegroundColor Yellow
$bucketZip = Join-Path $env:TEMP 'bnr-main-bucket.zip'
$bucketDir = Join-Path $env:TEMP 'bnr-main-bucket'
Invoke-WebRequest -Uri 'https://github.com/ScoopInstaller/Main/archive/refs/heads/master.zip' -OutFile $bucketZip
Remove-Item -LiteralPath $bucketDir -Recurse -Force -ErrorAction SilentlyContinue
Expand-Archive -LiteralPath $bucketZip -DestinationPath $bucketDir -Force
New-Item -ItemType Directory -Path (Join-Path $scoopRoot 'buckets') -Force | Out-Null
Remove-Item -LiteralPath $mainBucket -Recurse -Force -ErrorAction SilentlyContinue
Move-Item -LiteralPath (Join-Path $bucketDir 'Main-master') -Destination $mainBucket
Write-Host (' 清单数:{0}' -f @(Get-ChildItem -LiteralPath (Join-Path $mainBucket 'bucket') -Filter '*.json' -ErrorAction SilentlyContinue).Count)
}
}
# 装 git:之后 bucket 操作就是正常路径(scoop 的 bucket add 本质是 git clone)。
# 这台 VM 出厂不带 git,所以 main bucket 只能先用 zip 兜底进来,git 再由此装上。
if ((Test-Path -LiteralPath $scoopCmd) -and -not (Test-Path -LiteralPath (Join-Path $scoopRoot 'shims\git.exe'))) {
Write-Host '[A] scoop install git(后面 bucket add 要靠它)' -ForegroundColor Yellow
& $scoopCmd install git 2>&1 | ForEach-Object { ' ' + $_ }
}
# vscode 在 extras bucket,不在 main 里
if ((Test-Path -LiteralPath $scoopCmd) -and -not (Test-Path -LiteralPath (Join-Path $scoopRoot 'buckets\extras'))) {
Write-Host '[A] scoop bucket add extras' -ForegroundColor Yellow
& $scoopCmd bucket add extras 2>&1 | ForEach-Object { ' ' + $_ }
}
if ((Test-Path -LiteralPath $scoopCmd) -and -not (Test-Path -LiteralPath $vscodeCli)) {
Write-Host '[A] scoop install vscode(从 extras 下载几百 MB,慢是正常的)' -ForegroundColor Yellow
& $scoopCmd install vscode 2>&1 | ForEach-Object { ' ' + $_ }
if (-not (Test-Path -LiteralPath $vscodeCli)) {
Write-Host '[A] 第一次没装上,重试一次(下载超时是常见原因)' -ForegroundColor Yellow
& $scoopCmd install vscode 2>&1 | ForEach-Object { ' ' + $_ }
}
}
}
foreach ($candidate in @($vscodeCli, $vscodeCliShim)) {
if (Test-Path -LiteralPath $candidate) { $codeCmd = $candidate; break }
}
$vscodeReady = [bool]$codeCmd
if ($vscodeReady) {
Test-Scenario 'A: vscode 已安装(找到 CLI)' $true $codeCmd
} elseif ($SkipScoop) {
Write-Host (' [SKIP] 按 -SkipScoop 跳过 vscode({0} 不存在)' -f $vscodeCli) -ForegroundColor Yellow
} else {
Test-Scenario 'A: vscode 已安装(找到 CLI)' $false $vscodeCli
}
# 打开 vscode 改配置:先跑一次 CLI 让它初始化用户数据目录,再写一个可核对的设置
$probe = 'baknret-' + [guid]::NewGuid().ToString('N').Substring(0, 8)
$settingsPath = $null
if ($vscodeReady) {
$versionText = (& $codeCmd --version 2>&1 | Out-String).Trim()
Test-Scenario 'A: vscode CLI 可执行(--version)' ($LASTEXITCODE -eq 0) ($versionText -split "`n" | Select-Object -First 1)
# scoop 的 vscode 清单带 persist:data,用户数据落在 persist 里(portable 模式);
# 万一没有走 portable,退回 %APPDATA%\Code\User。
$userDataDir = Join-Path $vscodePersist 'data\user-data\User'
if (-not (Test-Path -LiteralPath (Join-Path $vscodePersist 'data'))) {
$userDataDir = Join-Path $env:APPDATA 'Code\User'
}
New-Item -ItemType Directory -Path $userDataDir -Force | Out-Null
$settingsPath = Join-Path $userDataDir 'settings.json'
[System.IO.File]::WriteAllText($settingsPath, ('{{"baknret.probe":"{0}","editor.fontSize":14}}' -f $probe))
Write-Host ('[A] 改过的配置:{0}' -f $settingsPath)
}
Write-Host ''
Write-Host '===== B. ProgramData 现场:属主 + CREATOR OWNER =====' -ForegroundColor Cyan
$bRoot = Join-Path $env:ProgramData 'baknret-acl-lab'
Remove-TreeHard -Path $bRoot
$bData = Join-Path $bRoot 'data'
New-Item -ItemType Directory -Path (Join-Path $bData 'sub') -Force | Out-Null
[System.IO.File]::WriteAllText((Join-Path $bData 'sub\a.txt'), 'acl payload')
# 属主设成 **SYSTEM**(不是当前账户、也不是提权进程默认拿到的 Administrators):
# 这正是"CREATOR OWNER 会把全权判给谁"的关键,也是不做安全描述符恢复时必然丢掉的东西。
# 注意 sections 必须带上 Owner —— 只传 Access 的话 SDDL 里的 O: 会被直接忽略,
# 于是属主还是"谁创建谁拥有",测试就退化成没有意义的形式。
$specialSddl = 'O:S-1-5-18D:PAI(A;OICIIO;GA;;;CO)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;0x1200a9;;;BU)'
$specialSecurity = New-Object System.Security.AccessControl.DirectorySecurity
$specialSecurity.SetSecurityDescriptorSddlForm($specialSddl, (
[System.Security.AccessControl.AccessControlSections]::Owner -bor
[System.Security.AccessControl.AccessControlSections]::Access))
[System.IO.FileSystemAclExtensions]::SetAccessControl((Get-Item -LiteralPath $bData), $specialSecurity)
# "跑脚本的账户"新建对象时实际会拿到什么属主 —— 用它做基准,负对照才有判据
$probeDir = Join-Path $WorkRoot 'owner-probe'
New-Item -ItemType Directory -Path $probeDir -Force | Out-Null
$creatorOwner = (Get-Acl -LiteralPath $probeDir).GetOwner([System.Security.Principal.SecurityIdentifier]).Value
$expected = @{}
foreach ($pair in @(@($vscodeApp, 'app'), @($vscodePersist, 'persist'), @($bData, 'programdata'), @((Join-Path $bData 'sub'), 'programdata-sub'))) {
if (Test-Path -LiteralPath $pair[0]) { $expected[$pair[1]] = Get-SecurityFingerprint -Path $pair[0] }
}
$sourceOwner = (Get-Acl -LiteralPath $bData).GetOwner([System.Security.Principal.SecurityIdentifier]).Value
Write-Host ('[B] 备份前 data 的属主:{0}' -f (Get-Acl -LiteralPath $bData).Owner)
$currentSid = ([System.Security.Principal.WindowsIdentity]::GetCurrent()).User.Value
Test-Scenario 'B: 现场造对了 —— 属主既不是当前账户、也不是新建对象的默认属主' `
(($sourceOwner -ne $currentSid) -and ($sourceOwner -ne $creatorOwner)) `
"owner=$sourceOwner current=$currentSid creatorDefault=$creatorOwner"
# ---------------------------------------------------------------------------
# 备份(三个条目)
# ---------------------------------------------------------------------------
$listPath = Join-Path $WorkRoot 'BackupList.txt'
$entries = @()
if ($vscodeReady) { $entries += $vscodeApp; $entries += $vscodePersist }
$entries += $bData
[System.IO.File]::WriteAllText($listPath, (($entries -join [Environment]::NewLine) + [Environment]::NewLine), [System.Text.UTF8Encoding]::new($false))
$configPath = Join-Path $WorkRoot 'BackupConfig.psd1'
$configText = @"
@{
BackupDir = '$BackupDir'
LogDir = '$(Join-Path $WorkRoot 'logs')'
SnapshotDir = '$(Join-Path $BackupDir 'snapshots')'
SoftwareCatalog = 'NoSuchCatalog.psd1'
MinFreeSpaceGB = 0
VerifyArchive = `$true
CompressionLevel = 1
ToolOutput = 'quiet'
Snapshot = @{ Enabled = `$false }
Encryption = @{ Enabled = `$false; PasswordFile = '' }
Security = @{ Mode = 'Full'; IncludeSacl = `$false; SidMap = @{}; FailOnError = `$true }
DefaultExcludes = @('!Thumbs.db', '!desktop.ini')
}
"@
[System.IO.File]::WriteAllText($configPath, $configText, [System.Text.UTF8Encoding]::new($false))
Write-Host ''
Write-Host '[备份] Backup.ps1' -ForegroundColor Yellow
$backup = Invoke-BaknretChild -Script (Join-Path $RepoPath 'Backup.ps1') -Parameters @{
BackupListPath = $listPath
ConfigPath = $configPath
BackupDir = $BackupDir
}
$backup.LastLog | ForEach-Object { ' ' + $_ }
Test-Scenario '备份退出码 0' ($backup.ExitCode -eq 0) ('exit=' + $backup.ExitCode)
Test-Scenario '每个条目都写了 .acl.json' (@(Get-ChildItem -LiteralPath $BackupDir -Filter '*.acl.json').Count -ge $entries.Count) `
('acl.json=' + @(Get-ChildItem -LiteralPath $BackupDir -Filter '*.acl.json').Count + ' 条目=' + $entries.Count)
# ---------------------------------------------------------------------------
# 删源 → 恢复
# ---------------------------------------------------------------------------
foreach ($path in $entries) {
if ($path -ieq $vscodeApp) { Stop-VscodeProcesses -AppRoot $vscodeApp }
Remove-TreeHard -Path $path
}
$leftovers = @($entries | Where-Object { Test-Path -LiteralPath $_ })
Test-Scenario '源已删除(模拟真的丢了)' ($leftovers.Count -eq 0) ($leftovers -join '、')
Write-Host ''
Write-Host '[恢复] Restore.ps1' -ForegroundColor Yellow
$restore = Invoke-BaknretChild -Script (Join-Path $RepoPath 'Restore.ps1') -Parameters @{
BackupListPath = $listPath
ConfigPath = $configPath
BackupDir = $BackupDir
Force = $true
}
$restore.LastLog | ForEach-Object { ' ' + $_ }
Test-Scenario '恢复退出码 0' ($restore.ExitCode -eq 0) ('exit=' + $restore.ExitCode)
Test-Scenario '恢复日志里出现安全描述符回放' ($restore.Output -match '安全描述符:回放') ''
# ---------------------------------------------------------------------------
# A 段断言:vscode 还能不能正常读写
# ---------------------------------------------------------------------------
Write-Host ''
Write-Host '--- A 断言 ---' -ForegroundColor Cyan
if ($vscodeReady) {
$versionText = (& $codeCmd --version 2>&1 | Out-String).Trim()
Test-Scenario 'A: 恢复后 vscode CLI 仍可执行' ($LASTEXITCODE -eq 0) ($versionText -split "`n" | Select-Object -First 1)
$settingsOk = $false
if ($settingsPath -and (Test-Path -LiteralPath $settingsPath)) {
$settingsOk = (Get-Content -LiteralPath $settingsPath -Raw) -match [regex]::Escape($probe)
}
Test-Scenario 'A: 改过的 settings.json 被原样恢复(读得到、内容对)' $settingsOk $settingsPath
# 写测试:vscode 的数据目录必须能新建文件 —— 这正是"无读写权限"症状的反面
$writeOk = $false
$detail = ''
try {
$probeFile = Join-Path (Split-Path -Parent $settingsPath) ('baknret-write-' + [guid]::NewGuid().ToString('N').Substring(0, 6) + '.tmp')
[System.IO.File]::WriteAllText($probeFile, 'write probe')
$writeOk = (Test-Path -LiteralPath $probeFile)
Remove-Item -LiteralPath $probeFile -Force -ErrorAction SilentlyContinue
} catch {
$detail = $_.Exception.Message
}
Test-Scenario 'A: vscode 的数据目录可写(新建文件成功)' $writeOk $detail
foreach ($pair in @(@($vscodeApp, 'app'), @($vscodePersist, 'persist'))) {
if (-not $expected.ContainsKey($pair[1])) { continue }
$expectedNormalized = $expected[$pair[1]] -replace ' P=(True|False) ', ' P='
$actualNormalized = (Get-SecurityFingerprint -Path $pair[0]) -replace ' P=(True|False) ', ' P='
Test-Scenario ("A: {0} 的安全指纹与备份前一致" -f $pair[1]) ($actualNormalized -eq $expectedNormalized) `
("want: " + $expectedNormalized + " / got: " + $actualNormalized)
}
} else {
Write-Host ' [SKIP] vscode 没装上,A 段的功能断言跳过(-SkipScoop 或下载失败)' -ForegroundColor Yellow
}
# ---------------------------------------------------------------------------
# B 段断言:属主与 CREATOR OWNER
# ---------------------------------------------------------------------------
Write-Host ''
Write-Host '--- B 断言 ---' -ForegroundColor Cyan
$restoredOwner = (Get-Acl -LiteralPath $bData).GetOwner([System.Security.Principal.SecurityIdentifier]).Value
Test-Scenario 'B: 现场属主确实是 SYSTEM(不是脚本账户的默认属主)' ($sourceOwner -eq 'S-1-5-18') "source=$sourceOwner"
Test-Scenario 'B: 恢复后属主 == 备份前的属主 —— CREATOR OWNER 才会判给原程序' ($restoredOwner -eq $sourceOwner) "want=$sourceOwner got=$restoredOwner"
Test-Scenario 'B: CREATOR OWNER 的 inherit-only ACE 还在' ((Get-Acl -LiteralPath $bData).Sddl -match '\(A;OICIIO;GA;;;CO\)') (Get-Acl -LiteralPath $bData).Sddl
$bExpected = $expected['programdata'] -replace ' P=(True|False) ', ' P='
$bActual = (Get-SecurityFingerprint -Path $bData) -replace ' P=(True|False) ', ' P='
Test-Scenario 'B: data 的安全指纹与备份前一致' ($bActual -eq $bExpected) ("want: $bExpected / got: $bActual")
if ($expected.ContainsKey('programdata-sub')) {
$subExpected = $expected['programdata-sub'] -replace ' P=(True|False) ', ' P='
$subActual = (Get-SecurityFingerprint -Path (Join-Path $bData 'sub')) -replace ' P=(True|False) ', ' P='
Test-Scenario 'B: 子目录的安全指纹与备份前一致' ($subActual -eq $subExpected) ("want: $subExpected / got: $subActual")
}
# 负对照:只搬文件、不回放安全描述符 —— 属主会落到"跑脚本的账户"头上,
# 也就是 (A;OICIIO;GA;;;CO) 把全权判给脚本、原程序没权限的那种状态。
$negative = Join-Path $WorkRoot 'negative-data'
& robocopy.exe $bData $negative /E /COPY:DAT /NFL /NDL /NJH /NJS /NP | Out-Null
$negativeOwner = (Get-Acl -LiteralPath $negative).GetOwner([System.Security.Principal.SecurityIdentifier]).Value
Test-Scenario 'B 负对照: 只搬文件时,属主变成"跑脚本的账户"而不再是原账户' `
(($negativeOwner -ne $sourceOwner) -and ($negativeOwner -eq $creatorOwner)) `
"negative=$negativeOwner creatorDefault=$creatorOwner"
Write-Host (' 原属主 = {0}' -f $sourceOwner)
Write-Host (' 恢复后属主 = {0}(应与原属主相同)' -f $restoredOwner)
Write-Host (' 负对照属主 = {0}(跑脚本的账户新建对象的默认属主)' -f $negativeOwner)
# ============================================================================
# 收尾
# ============================================================================
Write-Host ''
$total = $script:Passed + $script:Failures.Count
if ($script:Failures.Count -eq 0) {
Write-Host ('ACL 演练:全部通过 {0} 项' -f $total) -ForegroundColor Green
} else {
Write-Host ('ACL 演练:通过 {0} 项,失败 {1} 项' -f $script:Passed, $script:Failures.Count) -ForegroundColor Red
foreach ($failure in $script:Failures) { Write-Host (' - ' + $failure) -ForegroundColor Red }
}
if ($KeepWorkRoot) {
Write-Host ('临时目录保留:{0}' -f $WorkRoot) -ForegroundColor Yellow
} else {
Remove-TreeHard -Path $bRoot
Remove-TreeHard -Path (Join-Path $WorkRoot 'negative-data')
# 备份与日志留着,便于事后核对(归档可能有几百 MB,要腾空间就手工删这个目录)
}
if ($script:Failures.Count -gt 0) { exit 1 }
exit 0
+45
View File
@@ -0,0 +1,45 @@
<#
.SYNOPSIS
在 VM 内跑 tests\Restore-Drill.ps1,并把条目数组安全地传进去。
.DESCRIPTION
为什么需要这一层:跨进程传数组参数是坏的。
经 `pwsh -File Restore-Drill.ps1 -Entries A B C` 传进去时,只有第一个值能绑到
`[string[]]$Entries`,后面的会被当成多余的位置参数:
A positional parameter cannot be found that accepts argument '...'
而 JSON / 带引号的字符串又会在 Start-Process 拼命令行时被引号转义搞坏,
所以这里用 `;` 分隔的纯文本传条目,再在 PowerShell 内部用真正的数组绑定调用钻取脚本。
用法:pwsh -File run-drill.ps1 -BackupDir <归档目录> -ConfigPath <配置> -EntriesCsv 'A;B;C'
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$BackupDir,
[Parameter(Mandatory)][string]$ConfigPath,
[string]$EntriesCsv = '',
[switch]$KeepWorkRoot,
[switch]$AllowChanged
)
$ErrorActionPreference = 'Continue'
$entries = @()
if ($EntriesCsv) {
$entries = @($EntriesCsv.Split(';') | Where-Object { $_ -and $_.Trim() } | ForEach-Object { $_.Trim() })
}
# 必须用**哈希表** splat:数组 splat 会把 -Entries A B C 拆成三个独立参数,
# 只有 A 绑得上,B 会被当成多余的位置参数(A positional parameter cannot be found ...)。
$drillParams = [ordered]@{
BackupDir = $BackupDir
ConfigPath = $ConfigPath
}
if ($entries.Count -gt 0) { $drillParams['Entries'] = $entries }
if ($KeepWorkRoot) { $drillParams['KeepWorkRoot'] = $true }
if ($AllowChanged) { $drillParams['AllowChanged'] = $true }
Write-Host ("[lab] 恢复演练:{0} 个条目 -> {1}" -f $entries.Count, ($entries -join ' | '))
& 'C:\BakNRet\tests\Restore-Drill.ps1' @drillParams
+40
View File
@@ -0,0 +1,40 @@
<#
.SYNOPSIS
在 VM 内以 UTF-8 控制台编码运行一个测试套件(不改仓库里的任何测试代码)。
.DESCRIPTION
为什么需要它 —— tests\BakNRet*.Tests.ps1 的 Invoke-BaknretScript 是这么抓子进程输出的:
cmd /c pwsh -File Backup.ps1 ... > out.txt 2>&1
Get-Content -LiteralPath out.txt -Encoding UTF8
而 Backup.ps1 / Restore.ps1 的 Write-Log 走 Write-Host,写进 out.txt 的字节用的是
`[Console]::OutputEncoding`:
* 宿主机上它是 utf-8 -> 文件是 UTF-8 -> 按 UTF-8 读回,中文正确,套件全绿;
* 一台全新 Windows VM 上它是 ANSI 代码页(中文系统 936)
-> 文件是 GBK 字节 -> 按 UTF-8 读回得到替换字符 -> 断言中文的那几项失败。
这是测试环境假设问题,不是产品缺陷。本包装器把控制台输出编码先钉成 UTF-8,
于是 VM 里也能得到和宿主机一致的 150/150。
用法:pwsh -File run-suite-utf8.ps1 C:\BakNRet\tests\Run-Pester.ps1 [-KeepWorkRoot ...]
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, Position = 0)][string]$Suite,
[Parameter(Position = 1, ValueFromRemainingArguments = $true)][string[]]$SuiteArgs = @()
)
$ErrorActionPreference = 'Continue'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
Write-Host ("[lab] 包装器:PS {0},[Console]::OutputEncoding = {1}" -f $PSVersionTable.PSVersion, [Console]::OutputEncoding.WebName)
Write-Host ("[lab] 套件:{0} {1}" -f $Suite, ($SuiteArgs -join ' '))
if (-not (Test-Path -LiteralPath $Suite)) { Write-Error "找不到套件:$Suite"; exit 2 }
& $Suite @SuiteArgs
exit $LASTEXITCODE
@@ -0,0 +1,25 @@
<#
隔离沙盒配置:归档、日志、快照全部落在 C:\BakNRet-Lab 与 C:\BakNRet\ 下(都在 VM 内),
绝不碰宿主机仓库的 Backups\ 与 logs\。
取值偏「跑得快」而非「压得小」:CompressionLevel = 1,让一次全链路几秒钟跑完;
要压真实比例时用 -CompressionLevel 9 单独跑。
#>
@{
BackupDir = 'C:\BakNRet-Lab\Backups'
LogDir = 'C:\BakNRet-Lab\logs\backup'
SnapshotDir = 'C:\BakNRet-Lab\Backups\snapshots'
# 注意:SoftwareCatalog 的相对路径是按**仓库根**(Backup.ps1 所在目录)解析的,
# 不是按本配置文件所在目录;而且路径不存在时会**静默回退**到仓库真实的
# SoftwareCatalog.psd1。沙盒必须写成仓库根相对路径,否则软件名条目会悄悄用错名录。
SoftwareCatalog = 'tools\lab\payload\sandbox\SoftwareCatalog.psd1'
CatalogMaxDepth = 5
MinFreeSpaceGB = 0
VerifyArchive = $true
ComputeHash = $true
CompressionLevel = 1
ToolOutput = 'quiet'
Snapshot = @{ Enabled = $false; KeepCount = 3; KeepDays = 30 }
Encryption = @{ Enabled = $false; PasswordFile = 'C:\BakNRet-Lab\state\baknret.key'; EncryptHeaders = $true }
DefaultExcludes = @('!Thumbs.db','!desktop.ini')
}
+26
View File
@@ -0,0 +1,26 @@
###########
# BakNRet 隔离沙盒清单(只在 VM 内使用;所有路径都指向 C:\BakNRet-Lab\sources)
###########
#
# 形态覆盖:多 Slot 软件名、单文件 Slot、中文+空格路径、真 NTFS 连接点、手写路径、
# :- 排除、:+ 追加、:: 覆盖 Path、行首方向标记 + / -、源缺失条目。
# 约束提醒:归档名 = 软件名(或路径推导名),每行必须产生唯一归档名。
# ---- 软件名条目(查同目录的 SoftwareCatalog.psd1)----
AppMultiSlot :- CacheSlot\cache-01.tmp,!*.log # Slot 前缀排除 + 任意层级通配
AppFileSlot :+ Modules:C:\BakNRet-Lab\sources\AppMultiSlot\Config # 追加映射:把 Config 放到包内 Modules\
软件目录甲 # 中文 + 空格 + 点的路径
JunctionToData # 真 NTFS 连接点
MissingApp # 源不存在:记 missing-source,退出码仍 0
OverrideTarget :: C:\BakNRet-Lab\sources\AppMultiSlot\Config # :: 覆盖名录里故意写错的 Path
# ---- 手写路径条目 ----
C:\BakNRet-Lab\sources\AppBig
C:\BakNRet-Lab\sources\AppLocked # 被占用文件:验证「有文件没打进归档」的告警
# ---- 行首方向标记 ----
+ C:\BakNRet-Lab\sources\AppDeep # 仅备份,不恢复
- C:\BakNRet-Lab\sources\AppRestoreOnly # 仅恢复,不备份(备份端跳过)
@@ -0,0 +1,34 @@
<#
BakNRet 隔离沙盒名录:软件名 -> Slot 组,全部指向 C:\BakNRet-Lab\sources 下的假数据。
只在 VM 内使用,宿主机仓库里的 SoftwareCatalog.psd1 不受影响。
带连字符的键必须加引号(PSD1 会把它当减法);这里用中文键名,也统一加引号。
#>
@{
AppMultiSlot = @{
DefaultData = @{ Path = 'C:\BakNRet-Lab\sources\AppMultiSlot\Data'; Description = '主数据(含 node_modules、session.log、空目录)' }
DefaultConfig = @{ Path = 'C:\BakNRet-Lab\sources\AppMultiSlot\Config'; Description = '配置(含 .bak)' }
CacheSlot = @{ Path = 'C:\BakNRet-Lab\sources\AppMultiSlot\Cache'; Description = '缓存(清单里再排除一条 cache-01.tmp)' }
}
AppFileSlot = @{
Profile = @{ Path = 'C:\BakNRet-Lab\sources\AppFileSlot\profile.json'; Description = '单文件 Slot:归档内是名为 Profile 的文件' }
Readme = @{ Path = 'C:\BakNRet-Lab\sources\AppFileSlot\readme.txt'; Description = '另一个单文件 Slot' }
}
'软件目录甲' = @{
DefaultData = @{ Path = 'C:\BakNRet-Lab\sources\软件 目录.甲'; Description = '中文 + 空格 + 点的路径' }
}
JunctionToData = @{
DefaultData = @{ Path = 'C:\BakNRet-Lab\sources\JunctionToData'; Description = '真 NTFS 连接点(指向 AppMultiSlot\Data)' }
}
MissingApp = @{
DefaultData = @{ Path = 'C:\BakNRet-Lab\sources\MissingApp'; Description = '源不存在:备份跳过并记 missing-source,恢复仍知道目标位置' }
}
OverrideTarget = @{
DefaultData = @{ Path = 'C:\BakNRet-Lab\sources\OverrideTarget-故意不存在'; Description = '故意写错,由清单里的 :: 覆盖成存在的目录' }
}
}
+126
View File
@@ -0,0 +1,126 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
BakNRet 隔离测试 VM 的无人值守应答文件(离线部署路径)。
Windows 用 DISM 展开到 VHDX 之后,本文件被放到 C:\Windows\Panther\unattend.xml,
首次启动时由 Windows 在 specialize 与 oobeSystem 两个阶段读取。
设计要点:
* 不启用已废弃的 SkipMachineOOBE / SkipUserOOBE —— 在 Windows 11 25H2 上它们会让
OOBE 卡住;这里改用 OOBE 隐藏项 + BypassNRO + 明确的本地账户;
* 只创建一个本地管理员 lab,避免 OOBE 索要微软账户;
* AutoLogon 三次,用来跑 FirstLogonCommands 里的供给脚本;
* 口令占位符 __LABPASSWORD__ 由 tools\lab\New-BakNRetLab.ps1 在注入前替换成随机口令,
口令只留在宿主机 D:\VMs\BakNRet-Lab\state\credentials.json,不进版本库。
-->
<unattend xmlns="urn:schemas-microsoft-com:unattend">
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-Setup"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ComputerName>BAKNRET-LAB</ComputerName>
<TimeZone>China Standard Time</TimeZone>
<RegisteredOwner>BakNRet Lab</RegisteredOwner>
<RegisteredOrganization>BakNRet Lab</RegisteredOrganization>
</component>
<component name="Microsoft-Windows-Deployment"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<RunSynchronous>
<RunSynchronousCommand wcm:action="add">
<Order>1</Order>
<Description>跳过 OOBE 的联网 / 微软账户强制</Description>
<Path>reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE" /v BypassNRO /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>2</Order>
<Description>关掉“让我们完成设备设置”一类打扰</Description>
<Path>reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\UserProfileEngagement" /v ScoobeSystemSettingEnabled /t REG_DWORD /d 0 /f</Path>
</RunSynchronousCommand>
</RunSynchronous>
</component>
</settings>
<settings pass="oobeSystem">
<component name="Microsoft-Windows-International-Core"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<InputLocale>zh-CN</InputLocale>
<SystemLocale>zh-CN</SystemLocale>
<UILanguage>zh-CN</UILanguage>
<UserLocale>zh-CN</UserLocale>
</component>
<component name="Microsoft-Windows-Shell-Setup"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<NetworkLocation>Work</NetworkLocation>
<ProtectYourPC>3</ProtectYourPC>
</OOBE>
<UserAccounts>
<LocalAccounts>
<LocalAccount wcm:action="add">
<Name>lab</Name>
<DisplayName>Lab</DisplayName>
<Description>BakNRet 隔离测试账户</Description>
<Group>Administrators</Group>
<Password>
<Value>__LABPASSWORD__</Value>
<PlainText>true</PlainText>
</Password>
</LocalAccount>
</LocalAccounts>
</UserAccounts>
<AutoLogon>
<Username>lab</Username>
<Enabled>true</Enabled>
<LogonCount>3</LogonCount>
<Password>
<Value>__LABPASSWORD__</Value>
<PlainText>true</PlainText>
</Password>
</AutoLogon>
<FirstLogonCommands>
<SynchronousCommand wcm:action="add">
<Order>1</Order>
<Description>BakNRet lab 供给脚本(把 VM 变成可跑全链路测试的真机状态)</Description>
<CommandLine>powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\BakNRet-Lab\payload\provision.ps1</CommandLine>
</SynchronousCommand>
</FirstLogonCommands>
<TimeZone>China Standard Time</TimeZone>
</component>
</settings>
</unattend>