三件事:
1) tools\Install-TestDependencies.ps1 现在也把 PSScriptAnalyzer 装进仓库内的 .tools\modules
(不动机器上的全局模块,与 Pester 同一策略)。
2) PSScriptAnalyzerSettings.psd1:这是必要的,不是装饰 —— 那 6 条格式规则
(括号、缩进、空格、对齐、大小写)默认全是 Disabled,所以不带 -Settings 的
`Invoke-ScriptAnalyzer -Severity Warning,Error` 会**静默漏掉全部排版问题**。本文件用 Rules
把它们打开(而不是用 IncludeRules 换一套),于是默认规则与格式规则同时生效。
三条有意的排除都写明了理由:PSAvoidUsingWriteHost(彩色控制台输出是这份工具的刻意设计)、
PSUseShouldProcessForStateChangingFunctions(WhatIf 的边界在入口脚本,给库里 27 个改状态的
函数都加上反而会"静默跳过",备份看着成功却什么都没做)、PSAvoidUsingPlainTextForPassword
(7z 只接受命令行口令,这是 7z 的限制,README 里写明了取舍)。
3) tools\Invoke-Analyzer.ps1:独立门禁(不塞进 Pester 用例 —— 套件跑一次二十多秒,
混进去会让"测试红了"这句话失去分辨力),路径过滤与验收门槛的 Encode/Parse 两层一致。
全仓重排结果:706 条告警 -> 67 条。修掉的 639 条全部是格式(闭括号 168、空格 80、
对齐 68、缩进 60、行长 229)。重排后 9/9 验收全绿、100 个文件两版解析零错、
276 个断言原样通过 —— 机械重排没有改变任何可观察行为。
如实说明两件事:
* 行长上限设成 160,**不是**官方默认的 120。120 在本仓库意味着 270 处改动(主要是
中文注释与测试夹具里的一行式目录),160 意味着 41 处。160 仍是"宽但可读",而理由是写在
配置文件里的:这不是悄悄放宽,想收紧到 120 时那份清单就在分析器输出里。
* 剩余 67 条里,41 条是上面那批行长,其余 26 条是分析器找出的真问题(未使用参数 6、
空 catch 6、MD5 指纹 1、覆盖内置命令 1、switch 默认值 1 等)。其中
Find-ChildDirectoryByName 的 MaxDepth 参数从未被使用 —— 也就是配置里的
CatalogMaxDepth = 5 是假的,前缀补全实际只查 1 层。这条要改行为、且影响真实名录的解析
结果,留给你拍板,不在本提交里动手。
531 lines
27 KiB
PowerShell
531 lines
27 KiB
PowerShell
<#
|
||
.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
|