Files
BakNRet/tools/lab/payload/run-acl-scenario.ps1
T
Shuery 2ad7987ffe refactor: Common.psm1 拆成 BakNRet/{Public,Private},一函数一文件 + 薄加载器
3152 行、66 个函数的单文件模块拆成:
  BakNRet\BakNRet.psd1   模块清单:FunctionsToExport 是显式白名单(62 个名字)
  BakNRet\BakNRet.psm1   加载器:点源顺序的唯一一处声明
  BakNRet\Public\*.ps1   62 个对外函数,一函数一文件,文件名 = 函数名
  BakNRet\Private\*.ps1  4 个内部函数 + State.ps1(模块级状态集中一处)

为什么是一函数一文件:这是社区里脚本模块的主流形态(调研实测:winutil 79 个、
Terminal-Icons 24 个、ModuleBuilder 23 个,全部如此)。收益是改动落在小文件里、diff 按职责
可读、模块级状态有唯一去处。

为什么这不算"打散":模块内 dot-source 的文件共享同一个模块作用域(实测确认),所以
"按顺序点源 67 个文件"与"点源一个大文件"在语义上等价;顺序只在加载器里出现一次,
tools\Build-BakNRetModule.ps1 从那里读出顺序就能拼回单文件 —— 本次产物 dist\BakNRet.psm1
3240 行、两个版本都解析零错。

新增一条断言把这条承诺钉住:加载器点源的文件集合必须与磁盘一致、导出名单必须与
Public\ 一一对应。漏一个文件或漏一个名字就是静默少一个函数 —— 而那种错在运行时只表现为
"找不到命令"。

引用更新:11 个文件里的 Common.psm1 改成 BakNRet\BakNRet.psd1(走清单导入,
FunctionsToExport 才真的说了算);Common.psm1 直接删除,不留转发垫片。

验收:test.ps1 9/9 全绿(7 与 5.1),98 个文件两版解析零错,276 个断言原样通过 ——
这次搬家没有改变任何可观察行为。
2026-09-27 09:34:10 +08:00

519 lines
27 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<#
.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 'BakNRet\BakNRet.psd1') -Force
$script:Passed = 0
$script:Failures = @()
function Invoke-NativeTolerant {
<#
.SYNOPSIS
跑一个原生命令,把 stdout 与 stderr 合并成字符串数组返回,不让 stderr 变成错误。
.DESCRIPTION
Windows PowerShell 5.1 在 $ErrorActionPreference = 'Stop' 下会把原生命令写到
stderr 的内容升级成终止性的 NativeCommandError(PowerShell 7 改了这条规矩)。
本脚本要调用 rmdir / takeown / icacls / scoop / code,其中 rmdir 与 takeown 在
"对象已经处理过"或"连接点已经悬空"时就会往 stderr 写字 —— 那些话不是错误:真正该
判断的是"删掉了没有",用 Test-Path 看。所以在进入原生命令时把 EAP 放到 Continue,
退出时还原。这也是 tests\BakNRet.Security.Tests.ps1 里对 takeown / icacls 用的同一招。
#>
param(
[Parameter(Mandatory = $true)][string]$FilePath,
[string[]]$ArgumentList = @()
)
$previous = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
return @(& $FilePath @ArgumentList 2>&1)
} finally {
$ErrorActionPreference = $previous
}
}
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) {
$null = Invoke-NativeTolerant -FilePath 'cmd.exe' -ArgumentList @('/c', ('rmdir "{0}"' -f $link.FullName))
Remove-BaknretJunction -Path $link.FullName
}
$null = Invoke-NativeTolerant -FilePath 'cmd.exe' -ArgumentList @('/c', ('rmdir /s /q "{0}"' -f $Path))
if (Test-Path -LiteralPath $Path) {
# 只处理这一条路径:拿回属主 → 换成继承来的默认 ACL → 再删
$null = Invoke-NativeTolerant -FilePath 'takeown.exe' -ArgumentList @('/F', $Path, '/R', '/D', 'Y')
$null = Invoke-NativeTolerant -FilePath 'icacls.exe' -ArgumentList @($Path, '/reset', '/T', '/C', '/Q')
$null = Invoke-NativeTolerant -FilePath 'cmd.exe' -ArgumentList @('/c', ('rmdir /s /q "{0}"' -f $Path))
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
Invoke-NativeTolerant -FilePath $scoopCmd -ArgumentList @('install', 'git') | 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
Invoke-NativeTolerant -FilePath $scoopCmd -ArgumentList @('bucket', 'add', 'extras') | ForEach-Object { ' ' + $_ }
}
if ((Test-Path -LiteralPath $scoopCmd) -and -not (Test-Path -LiteralPath $vscodeCli)) {
Write-Host '[A] scoop install vscode(从 extras 下载几百 MB,慢是正常的)' -ForegroundColor Yellow
Invoke-NativeTolerant -FilePath $scoopCmd -ArgumentList @('install', 'vscode') | ForEach-Object { ' ' + $_ }
if (-not (Test-Path -LiteralPath $vscodeCli)) {
Write-Host '[A] 第一次没装上,重试一次(下载超时是常见原因)' -ForegroundColor Yellow
Invoke-NativeTolerant -FilePath $scoopCmd -ArgumentList @('install', 'vscode') | 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 = (Invoke-NativeTolerant -FilePath $codeCmd -ArgumentList @('--version') | 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($true))
$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($true))
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 = (Invoke-NativeTolerant -FilePath $codeCmd -ArgumentList @('--version') | 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 -Encoding UTF8 -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