<# .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