<# .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 } # 这两个是原生命令,失败与否都不该打断测试 —— 它们的唯一目的是"把 ACL 复位到能删"。 # # 必须在 Continue 下调用:Windows PowerShell 5.1 在 $ErrorActionPreference = 'Stop' # 时,会把原生命令写到 stderr 的内容升级成终止性的 NativeCommandError 抛出来 # (takeown / icacls 对"已经处理过"的对象就会写 stderr)。PowerShell 7 改了这条 # 规矩,所以这段在 7 上一直是绿的,只在 5.1 上炸。 $previousPreference = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { & takeown.exe /F $Path /R /D Y 2>&1 | Out-Null & icacls.exe $Path /reset /T /C /Q 2>&1 | Out-Null } finally { $ErrorActionPreference = $previousPreference } 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($true)) $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($true)) 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 -Encoding UTF8 -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 } }