改造开始前的完整状态,作为可回退的基点。此提交之后: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 就会把口令提交进版本库。默认值在后续提交中改为空。
488 lines
25 KiB
PowerShell
488 lines
25 KiB
PowerShell
<#
|
||
.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
|
||
}
|
||
}
|