Files
BakNRet/tests/BakNRet.Security.Tests.ps1
T
Shuery 2d26f78d15 fix: 源文件改存 UTF-8 with BOM,让 Windows PowerShell 5.1 真正可用
改造前:全仓 6/6 个源文件在 5.1 上解析失败(README 却承诺支持 5.1)。原因是文件是无 BOM 的
UTF-8,而 5.1 没有 BOM 就按 ANSI 代码页解码源码,中文变乱码、全角问号吃掉引号,整块语法塌掉。
现在 28/28 个文件在 5.1 与 7 上都解析零错误,E2E 36 项在 5.1 上全绿。

顺带修掉一个被 5.1 掩盖的缺陷:带 [CmdletBinding()] 的脚本在 5.1 上,param() 默认值里
拿不到 $PSScriptRoot(实测为空串,7 上正常)。于是 Backup.ps1 / Restore.ps1 在 5.1 上不传
路径参数就报错退出 —— 而计划任务恰恰不传。E2E 之所以看不见,是因为它总是显式传路径。
8 处默认值全部移到 param() 之后的解析段,沿用本仓库对 -BackupDir 一直在用的写法。

新增三条可重放的约定,让编码不再是一次性动作:
  .gitattributes 接管行尾(本机 core.autocrlf=true,会把工作区改成 CRLF 制造伪 diff)
  .editorconfig 用 charset = utf-8-bom 锁住 BOM
  tools\Set-SourceEncoding.ps1 是规范化脚本,tools\Invoke-* 之外的任何改动之后都能重放
  test.ps1 是唯一验收入口:Encode + Parse + Unit + Smoke + E2E,在 7 与 5.1 上各跑一遍

test.ps1 的 Encode 层直接检查"必须有 BOM"这条规则。加它的原因很实际:实测本仓库用的
编辑工具在保存时会悄悄去掉 BOM,而丢了 BOM 的文件只在 5.1 上出错、在 7 上完全正常,
没有这条检查就会一直漏过去。

已知未修(下一步处理):5.1 上 Unit 有 6 项、Smoke 有 1 项失败,全部源于测试夹具写
临时文件时没指定编码(5.1 的 Set-Content 默认 ANSI),与产品代码无关。
2026-09-26 22:05:42 +08:00

488 lines
25 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<#
.SYNOPSIS
安全描述符(NTFS 属主 / ACL)的测试套件。
.DESCRIPTION
为什么单独一套:这一块的核心契约不是"文件内容对不对",而是**安全描述符的形状**——
* `C:\ProgramData` 下的目录 ACL 里有 `(A;OICIIO;GA;;;CO)`:CREATOR OWNER 是访问
检查时才替换的占位符,替换成"被检查对象的属主"。只回放 ACE 文本、不恢复属主,
等于把"谁创建的东西谁有全权"里的"谁"换成跑脚本的账户;
* 归档格式(.7z)根本不承载安全描述符(7-Zip 的 -sni 只能写进 WIM),
所以这一块全部靠 <归档名>.acl.json 旁挂文件 + 显式的回放步骤。
断言用的"安全指纹"刻意**不含** ACE 的继承标志位与 ID(inherited)标志:
继承到文件子对象时容器继承位会被系统去掉,而 ID 标志写不回去(不是可写的输入)。
这两处差异都不改变有效权限,进等式只会制造假失败。
跑法:
.\tests\Run-Pester.ps1 # 会连这一套一起跑
Invoke-Pester -Path .\tests\BakNRet.Security.Tests.ps1
#>
# 发现阶段(discovery)也会执行文件顶层代码,-Skip: 用到的判据必须在这里算好
$script:HasSevenZip = [bool](Get-Command 7z -ErrorAction SilentlyContinue)
BeforeAll {
$script:ProjectRoot = Split-Path -Parent $PSScriptRoot
$script:BackupScript = Join-Path $script:ProjectRoot 'Backup.ps1'
$script:RestoreScript = Join-Path $script:ProjectRoot 'Restore.ps1'
Import-Module (Join-Path $script:ProjectRoot 'Common.psm1') -Force
$script:Sandbox = Join-Path $env:TEMP ('baknret-acl-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $script:Sandbox -Force | Out-Null
# 一个"带刺"的 DACL:CREATOR OWNER(inherit-only, GENERIC_ALL) + 全权给 SYSTEM/Administrators
# + 一条**孤儿 SID** 的显式 ACE(数值形式的 SID,绝不按账户名写)+ DACL protected。
# 这正是 ProgramData 下那些目录的形态,也是"名字解析会把权限落到脚本头上"的现场。
$script:OrphanSid = 'S-1-5-21-1111111111-2222222222-3333333333-4444'
$script:SpecialDacl = 'D:PAI(A;OICIIO;GA;;;CO)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;0x1200a9;;;BU)(A;;0x1201bf;;;' + $script:OrphanSid + ')'
function Set-AclRaw {
<# .SYNOPSIS 写安全描述符:.NET Core 走扩展方法,5.1 走实例方法。 #>
param([Parameter(Mandatory = $true)][System.IO.FileSystemInfo]$Item, [Parameter(Mandatory = $true)]$Security)
if ($PSVersionTable.PSEdition -eq 'Core') {
[System.IO.FileSystemAclExtensions]::SetAccessControl($Item, $Security)
} else {
$Item.SetAccessControl($Security)
}
}
function Get-AclFingerprint {
<#
.SYNOPSIS
逐对象的"安全指纹":属主 | 属组 | protected | 全部 ACE 的 类型|SID|掩码(排序)。
.DESCRIPTION
比 SDDL 原文更适合做断言:继承标志位与 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 New-AclSourceTree {
<#
.SYNOPSIS
造源目录树并打上"带刺"的 DACL,返回逐对象的安全指纹。
.NOTES
DACL 是在子树建好**之后**才打的 —— 这样 sub / a.txt 上会留下"父目录改过权限、
自己还留着老 ACE"的陈旧继承 ACE,正是采集端必须处理的那种对象。
#>
param([Parameter(Mandatory = $true)][string]$Root)
New-Item -ItemType Directory -Path (Join-Path $Root 'sub') -Force | Out-Null
[System.IO.File]::WriteAllText((Join-Path $Root 'sub\a.txt'), 'acl payload')
$security = New-Object System.Security.AccessControl.DirectorySecurity
$security.SetSecurityDescriptorSddlForm($script:SpecialDacl, [System.Security.AccessControl.AccessControlSections]::Access)
Set-AclRaw -Item (Get-Item -LiteralPath $Root) -Security $security
$fingerprints = @{}
foreach ($relative in '.', 'sub', 'sub\a.txt') {
$path = if ($relative -eq '.') { $Root } else { Join-Path $Root $relative }
$fingerprints[$relative] = Get-AclFingerprint -Path $path
}
return $fingerprints
}
function Reset-AclTree {
<# .SYNOPSIS 先把 ACL 复位再删:拒绝型 / protected 的 DACL 会让 Remove-Item 直接失败。 #>
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return }
& takeown.exe /F $Path /R /D Y 2>&1 | Out-Null
& icacls.exe $Path /reset /T /C /Q 2>&1 | Out-Null
Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue
}
function Invoke-BaknretScript {
<# .SYNOPSIS 用子进程跑 Backup.ps1 / Restore.ps1(两个脚本结尾都 exit,必须独立进程)。 #>
param(
[Parameter(Mandatory = $true)][string]$Script,
[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 ([System.IO.Path]::GetTempPath()) ('bnr-aclout-' + [guid]::NewGuid().ToString('N') + '.txt')
$cmdFile = Join-Path ([System.IO.Path]::GetTempPath()) ('bnr-aclcmd-' + [guid]::NewGuid().ToString('N') + '.cmd')
$argString = (@($arguments | ForEach-Object { '"' + ($_ -replace '"', '\"') + '"' }) -join ' ')
$batch = "@echo off`r`n" + 'pwsh ' + $argString + ' > "' + $outFile + '" 2>&1' + "`r`nexit /b %ERRORLEVEL%`r`n"
[System.IO.File]::WriteAllText($cmdFile, $batch, [System.Text.UTF8Encoding]::new($false))
$exitCode = $null
$lines = @()
try {
$exitCode = Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', $cmdFile)
$lines = @(Get-Content -LiteralPath $outFile -Encoding UTF8 -ErrorAction SilentlyContinue)
} finally {
Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $cmdFile -Force -ErrorAction SilentlyContinue
}
return [pscustomobject]@{
ExitCode = $exitCode
Lines = @($lines | ForEach-Object { [string]$_ })
Output = (($lines | Out-String))
}
}
function New-AclEntryHarness {
<#
.SYNOPSIS
造一份独立的 BackupList / BackupConfig,返回各个路径。
.NOTES
用**手写路径**条目,不依赖 SoftwareCatalog:归档名由路径推出,
测试也就不用管名录的解析规则。
#>
param([Parameter(Mandatory = $true)][string]$Name, [Parameter(Mandatory = $true)][string]$Root)
$dir = Join-Path $script:Sandbox $Name
New-Item -ItemType Directory -Path $dir -Force | Out-Null
$sourcePath = Join-Path $dir 'source'
$backupDir = Join-Path $dir 'backups'
New-Item -ItemType Directory -Path $backupDir -Force | Out-Null
$listPath = Join-Path $dir 'BackupList.txt'
[System.IO.File]::WriteAllText($listPath, "$sourcePath`n", [System.Text.UTF8Encoding]::new($false))
$configPath = Join-Path $dir 'BackupConfig.psd1'
$configText = @"
@{
BackupDir = '$backupDir'
LogDir = '$(Join-Path $dir 'logs')'
SnapshotDir = '$(Join-Path $backupDir 'snapshots')'
SoftwareCatalog = 'NoSuchCatalog.psd1'
MinFreeSpaceGB = 0
VerifyArchive = `$true
ComputeHash = `$false
CompressionLevel = 1
ToolOutput = 'quiet'
Snapshot = @{ Enabled = `$false }
Encryption = @{ Enabled = `$false; PasswordFile = '' }
Security = @{ Mode = 'Full'; IncludeSacl = `$false; SidMap = @{}; FailOnError = `$false }
DefaultExcludes = @()
}
"@
[System.IO.File]::WriteAllText($configPath, $configText, [System.Text.UTF8Encoding]::new($false))
return [pscustomobject]@{
Dir = $dir
SourcePath = $sourcePath
BackupDir = $backupDir
ListPath = $listPath
ConfigPath = $configPath
}
}
}
AfterAll {
foreach ($name in 'walk', 'capture', 'restore', 'integration') {
$path = Join-Path $script:Sandbox $name
Reset-AclTree -Path $path
}
if ($script:Sandbox -and (Test-Path -LiteralPath $script:Sandbox)) {
Reset-AclTree -Path $script:Sandbox
Remove-Item -LiteralPath $script:Sandbox -Recurse -Force -ErrorAction SilentlyContinue
}
}
# ============================================================================
Describe '排除判定与 7z 的 -x! / -xr! 语义对齐' {
# ============================================================================
It '锚定模式只命中它自己那棵子树' {
Test-BaknretPathExcluded -RelativePath 'Default\Cache' -Patterns @('Default\Cache') | Should -BeTrue
Test-BaknretPathExcluded -RelativePath 'Default\Cache\sub\x.bin' -Patterns @('Default\Cache') | Should -BeFalse
Test-BaknretPathExcluded -RelativePath 'Other\Cache' -Patterns @('Default\Cache') | Should -BeFalse
}
It '! 通配按任意层级的组件名匹配(* 不是正则)' {
Test-BaknretPathExcluded -RelativePath 'a\Code Cache\f' -Patterns @('!*Cache') | Should -BeTrue
Test-BaknretPathExcluded -RelativePath 'Code Cache' -Patterns @('!*Cache') | Should -BeTrue
Test-BaknretPathExcluded -RelativePath 'a\teamcache.bin' -Patterns @('!*Cache') | Should -BeFalse
}
It '!re: 走正则,且组件名与整条相对路径都算命中' {
Test-BaknretPathExcluded -RelativePath 'x\y\a.log' -Patterns @('!re:\.log$') | Should -BeTrue
Test-BaknretPathExcluded -RelativePath 'x\y\a.txt' -Patterns @('!re:\.log$') | Should -BeFalse
Test-BaknretPathExcluded -RelativePath 'Cache\data' -Patterns @('!re:^Cache$') | Should -BeTrue
}
It '没有模式时一律不排除' {
Test-BaknretPathExcluded -RelativePath 'a\b' -Patterns @() | Should -BeFalse
Test-BaknretPathExcluded -RelativePath '' -Patterns @('!*') | Should -BeFalse
}
It '模式里的空格按 7z 的规矩当 ? 处理' {
Test-BaknretPathExcluded -RelativePath 'a\Cache' -Patterns @('!*Cache Extras') | Should -BeFalse
Test-BaknretPathExcluded -RelativePath 'a\Cache Extras' -Patterns @('!*Cache Extras') | Should -BeTrue
}
}
# ============================================================================
Describe 'SID 映射(跨机恢复)' {
# ============================================================================
It '整 SID 精确替换' {
$sddl = 'O:S-1-5-21-1-2-3-1001G:S-1-5-21-1-2-3-1001D:(A;;FA;;;S-1-5-21-1-2-3-1001)'
$mapped = Convert-BaknretSidMap -Sddl $sddl -SidMap @{ 'S-1-5-21-1-2-3-1001' = 'S-1-5-21-9-8-7-1001' }
$mapped | Should -Be 'O:S-1-5-21-9-8-7-1001G:S-1-5-21-9-8-7-1001D:(A;;FA;;;S-1-5-21-9-8-7-1001)'
}
It '不会误伤以它为前缀的更长的 SID' {
$sddl = 'D:(A;;FA;;;S-1-5-21-1-2-3-1001)(A;;FA;;;S-1-5-21-1-2-3-10012)'
$mapped = Convert-BaknretSidMap -Sddl $sddl -SidMap @{ 'S-1-5-21-1-2-3-1001' = 'S-1-5-21-9-8-7-1001' }
$mapped | Should -Be 'D:(A;;FA;;;S-1-5-21-9-8-7-1001)(A;;FA;;;S-1-5-21-1-2-3-10012)'
}
It '空映射表时原样返回' {
$sddl = 'D:(A;;FA;;;SY)'
Convert-BaknretSidMap -Sddl $sddl -SidMap @{} | Should -Be $sddl
}
}
# ============================================================================
Describe '安全描述符采集' {
# ============================================================================
BeforeAll {
$script:CaptureRoot = Join-Path $script:Sandbox 'capture\Data'
$script:CaptureItem = [pscustomobject]@{ ArchivePath = 'Data'; RealPath = $script:CaptureRoot }
$script:CaptureFingerprints = New-AclSourceTree -Root $script:CaptureRoot
}
It 'Full:每个对象一条记录,键是归档内相对路径' {
$capture = Get-BaknretSecurityRecords -Items @($script:CaptureItem) -Mode Full
$capture.Scanned | Should -Be 3
$capture.Kept | Should -Be 3
$capture.Errors | Should -Be 0
@($capture.Records | ForEach-Object { $_.p }) | Should -Be @('Data', 'Data\sub', 'Data\sub\a.txt')
}
It '根记录的 SDDL 保留了 CREATOR OWNER、IO 标志、孤儿 SID 和 protected 位' {
$capture = Get-BaknretSecurityRecords -Items @($script:CaptureItem) -Mode Full
$root = @($capture.Records | Where-Object { $_.p -eq 'Data' })[0]
$root.s | Should -Match 'D:PAI'
$root.s | Should -Match '\(A;OICIIO;GA;;;CO\)'
$root.s | Should -BeLike "*$script:OrphanSid*"
$root.o | Should -Be $script:CaptureFingerprints['.'].Split(' ')[0].Substring(2)
}
It 'Smart 比 Full 少,但根永远保留' {
$full = Get-BaknretSecurityRecords -Items @($script:CaptureItem) -Mode Full
$smart = Get-BaknretSecurityRecords -Items @($script:CaptureItem) -Mode Smart
$smart.Kept | Should -BeLessOrEqual $full.Kept
@($smart.Records | ForEach-Object { $_.p }) | Should -Contain 'Data'
}
It 'Roots 只存归档项的根,不再往下走' {
$roots = Get-BaknretSecurityRecords -Items @($script:CaptureItem) -Mode Roots
$roots.Kept | Should -Be 1
$roots.Records[0].p | Should -Be 'Data'
}
It 'sidecar 往返:条数与 SDDL 原样保留' {
$capture = Get-BaknretSecurityRecords -Items @($script:CaptureItem) -Mode Full
$path = Join-Path $script:Sandbox 'roundtrip.acl.json'
Save-BaknretSecuritySidecar -Path $path -Records $capture.Records -Mode Full -Errors $capture.Errors -Scanned $capture.Scanned | Out-Null
$sidecar = Read-BaknretSecuritySidecar -Path $path
$sidecar.Records.Count | Should -Be 3
$record = @($sidecar.Records | Where-Object { $_.p -eq 'Data\sub\a.txt' })[0]
$record.k | Should -Be 'f'
$record.s | Should -Match 'D:'
}
It '旁挂文件不存在时读出 $null(调用方据此打告警,而不是静默当没事)' {
Read-BaknretSecuritySidecar -Path (Join-Path $script:Sandbox 'nope.acl.json') | Should -BeNullOrEmpty
}
It '排除模式在采集时同样生效(采集树 == 归档树)' {
# 刻意用一棵**不带**特殊 DACL 的树:带刺的 ACL 里没有"新建子目录"的权限,
# 在它里面造测试数据会被系统直接拒绝(那本身也是这套功能要防的事)。
$walkRoot = Join-Path $script:Sandbox 'walk\Data'
New-Item -ItemType Directory -Path (Join-Path $walkRoot 'Cache') -Force | Out-Null
[System.IO.File]::WriteAllText((Join-Path $walkRoot 'Cache\c.bin'), 'x')
[System.IO.File]::WriteAllText((Join-Path $walkRoot 'keep.txt'), 'x')
$walkItem = [pscustomobject]@{ ArchivePath = 'Data'; RealPath = $walkRoot }
$capture = Get-BaknretSecurityRecords -Items @($walkItem) -Mode Full -ScopeMap @{ 0 = @('!Cache') }
@($capture.Records | ForEach-Object { $_.p }) | Should -Not -Contain 'Data\Cache'
@($capture.Records | ForEach-Object { $_.p }) | Should -Contain 'Data\keep.txt'
}
}
# ============================================================================
Describe '安全描述符回放' {
# ============================================================================
BeforeAll {
$script:RestoreRoot = Join-Path $script:Sandbox 'restore\Data'
$script:TargetRoot = Join-Path $script:Sandbox 'restore\target'
$script:RestoreFingerprints = New-AclSourceTree -Root $script:RestoreRoot
$capture = Get-BaknretSecurityRecords -Items @([pscustomobject]@{ ArchivePath = 'Data'; RealPath = $script:RestoreRoot }) -Mode Full
$script:RestoreSidecarPath = Join-Path $script:Sandbox 'restore.acl.json'
Save-BaknretSecuritySidecar -Path $script:RestoreSidecarPath -Records $capture.Records -Mode Full -Errors $capture.Errors -Scanned $capture.Scanned | Out-Null
$script:RestoreSidecar = Read-BaknretSecuritySidecar -Path $script:RestoreSidecarPath
}
It '回放后根对象的安全描述符与源逐字节一致(protected / CO / 孤儿 SID 全在)' {
# 干净目标:只拷内容,不带 ACL(ACL 是新建对象的默认值)
& robocopy.exe $script:RestoreRoot $script:TargetRoot /E /COPY:DAT /NFL /NDL /NJH /NJS /NP | Out-Null
$result = Restore-BaknretSecurity -Sidecar $script:RestoreSidecar -ArchiveRoot 'Data' -TargetPath $script:TargetRoot
$result.Total | Should -Be 3
$result.Failed | Should -Be 0
$result.Applied | Should -Be 3
(Get-Acl -LiteralPath $script:TargetRoot).Sddl | Should -Be (Get-Acl -LiteralPath $script:RestoreRoot).Sddl
}
It '全部对象的安全指纹与源一致(属主/属组/ACE 集合)' {
foreach ($relative in '.', 'sub', 'sub\a.txt') {
$sourcePath = if ($relative -eq '.') { $script:RestoreRoot } else { Join-Path $script:RestoreRoot $relative }
$targetPath = if ($relative -eq '.') { $script:TargetRoot } else { Join-Path $script:TargetRoot $relative }
# 唯一允许的差异:陈旧继承 ACE 被"冻结"成显式 + protected 的对象,
# protected 位会从 False 变 True(见 Get-BaknretSecuritySddlWithStale)。
$expected = $script:RestoreFingerprints[$relative] -replace ' P=(True|False) ', ' P='
$actual = (Get-AclFingerprint -Path $targetPath) -replace ' P=(True|False) ', ' P='
$actual | Should -Be $expected -Because "$relative 的 ACE 集合应当与源一致"
}
}
It '目标不存在或不是普通对象时记 Skipped,不记 Failed' {
$result = Restore-BaknretSecurity -Sidecar $script:RestoreSidecar -ArchiveRoot 'Data' `
-TargetPath (Join-Path $script:Sandbox 'restore\does-not-exist')
$result.Total | Should -Be 3
$result.Skipped | Should -Be 3
$result.Failed | Should -Be 0
}
It '归档根名对不上时一条都不回放(不会把兄弟项的 ACL 倒过来)' {
$result = Restore-BaknretSecurity -Sidecar $script:RestoreSidecar -ArchiveRoot 'Other' -TargetPath $script:TargetRoot
$result.Total | Should -Be 0
$result.Applied | Should -Be 0
}
It '属组写不进去时不会连累 DACL:回退到底也要把 ACL 落下去' {
$path = Join-Path $script:Sandbox 'restore\bogus-group'
New-Item -ItemType Directory -Path $path -Force | Out-Null
# 属组写成一个本机不存在的 SID:改主组需要特权,这一层必然失败
$sddl = 'O:' + (Get-Acl -LiteralPath $path).GetOwner([System.Security.Principal.SecurityIdentifier]).Value +
'G:' + $script:OrphanSid + 'D:(A;;FA;;;SY)'
$sidecar = [pscustomobject]@{
Records = @([pscustomobject]@{ p = 'Data'; k = 'd'; s = $sddl })
}
$result = Restore-BaknretSecurity -Sidecar $sidecar -ArchiveRoot 'Data' -TargetPath $path
$result.Failed | Should -Be 0
($result.Applied + $result.OwnerFailed) | Should -Be 1
(Get-Acl -LiteralPath $path).Sddl | Should -Match '\(A;;FA;;;SY\)'
}
It '对象的安全描述符读不到时带 e 记账,回放时跳过而不是写坏' {
$record = [pscustomobject]@{ p = 'Data'; k = 'd'; s = $null; e = '读不到' }
$sidecar = [pscustomobject]@{ Records = @($record) }
$path = Join-Path $script:Sandbox 'restore\bogus-group'
$result = Restore-BaknretSecurity -Sidecar $sidecar -ArchiveRoot 'Data' -TargetPath $path
$result.Skipped | Should -Be 1
$result.Applied | Should -Be 0
$result.Failed | Should -Be 0
}
}
# ============================================================================
Describe '与 Backup.ps1 / Restore.ps1 的集成' -Skip:(-not $script:HasSevenZip) {
# ============================================================================
BeforeAll {
$script:Harness = New-AclEntryHarness -Name 'integration' -Root $script:Sandbox
$script:IntegrationFingerprints = New-AclSourceTree -Root $script:Harness.SourcePath
}
It '备份会写出 <归档名>.acl.json,并在 manifest 里记下它' {
$result = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:Harness.ListPath
ConfigPath = $script:Harness.ConfigPath
BackupDir = $script:Harness.BackupDir
}
$result.ExitCode | Should -Be 0
$sidecars = @(Get-ChildItem -LiteralPath $script:Harness.BackupDir -Filter '*.acl.json' -ErrorAction SilentlyContinue)
$sidecars.Count | Should -Be 1
$result.Output | Should -Match '安全描述符:3 个对象'
$manifest = Get-Content -LiteralPath (Join-Path $script:Harness.BackupDir 'manifest.json') -Raw | ConvertFrom-Json
$key = @($manifest.items.PSObject.Properties.Name)[0]
$manifest.items.$key.security.file | Should -Be $sidecars[0].Name
$manifest.items.$key.security.objects | Should -Be 3
$manifest.items.$key.security.errors | Should -Be 0
}
It '恢复会把安全描述符回放回去(删源之后仍然逐对象与备份前一致)' {
Reset-AclTree -Path $script:Harness.SourcePath
(Test-Path -LiteralPath $script:Harness.SourcePath) | Should -BeFalse
$result = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:Harness.ListPath
ConfigPath = $script:Harness.ConfigPath
BackupDir = $script:Harness.BackupDir
Force = $true
}
$result.ExitCode | Should -Be 0
$result.Output | Should -Match '安全描述符:回放 3/3 个对象'
(Get-Acl -LiteralPath $script:Harness.SourcePath).Sddl | Should -Match '\(A;OICIIO;GA;;;CO\)'
(Get-Acl -LiteralPath $script:Harness.SourcePath).Sddl | Should -BeLike "*$script:OrphanSid*"
foreach ($relative in '.', 'sub', 'sub\a.txt') {
$path = if ($relative -eq '.') { $script:Harness.SourcePath } else { Join-Path $script:Harness.SourcePath $relative }
$expected = $script:IntegrationFingerprints[$relative] -replace ' P=(True|False) ', ' P='
$actual = (Get-AclFingerprint -Path $path) -replace ' P=(True|False) ', ' P='
$actual | Should -Be $expected -Because "$relative 的安全指纹应当与备份前一致"
}
}
It '-SkipSecurity 时不回放(目标保持新建对象的默认 ACL)' {
Reset-AclTree -Path $script:Harness.SourcePath
$result = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:Harness.ListPath
ConfigPath = $script:Harness.ConfigPath
BackupDir = $script:Harness.BackupDir
Force = $true
SkipSecurity = $true
}
$result.ExitCode | Should -Be 0
(Get-Acl -LiteralPath $script:Harness.SourcePath).Sddl | Should -Not -Match '\(A;OICIIO;GA;;;CO\)'
}
It '归档旁边没有 acl.json 时打告警、不算失败(旧归档照样恢复得出来)' {
Reset-AclTree -Path $script:Harness.SourcePath
Get-ChildItem -LiteralPath $script:Harness.BackupDir -Filter '*.acl.json' | Remove-Item -Force
$result = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:Harness.ListPath
ConfigPath = $script:Harness.ConfigPath
BackupDir = $script:Harness.BackupDir
Force = $true
}
$result.ExitCode | Should -Be 0
$result.Output | Should -Match '没有安全描述符旁挂文件'
(Test-Path -LiteralPath (Join-Path $script:Harness.SourcePath 'sub\a.txt')) | Should -BeTrue
}
}