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