Files
BakNRet/Common.psm1
T
Shuery 79f83f6760 fix: manifest 先删后移会丢账本;5.1 拿不到原子替换;空目录让空间守卫静默失效
四件事都在"原子替换与空间守卫"这条线上:

1) Write-BaknretManifest 自己写了"写 .tmp → 删旧 → Move-Item"。Move-Item 一失败,
   旧 manifest 就已经没了 —— 而 manifest 是"这块归档是谁的"的唯一账本。改成复用
   Write-BaknretAtomicText。

2) Write-BaknretAtomicText 原本也是走 Move-BaknretArchiveIntoPlace,而后者在 5.1 上
   必然退化成"先删后移"(三参数 File.Move 是 .NET Core 3.0+ 才有的重载)。现在目标存在时
   改用 File.Replace(ReplaceFile API):要么换成新内容、要么保持旧内容,两个都不会消失。
   实测目标只读时替换失败、旧内容完好、.tmp 保留便于排查。

3) Move-BaknretArchiveIntoPlace 的 5.1 降级路径同样改成 File.Replace —— 之前那条
   "先删后移"会在中途失败时让归档消失(旧归档没了、新归档还在 .tmp 里)。
   注意第三个参数必须传 [NullString]::Value:PowerShell 会把 $null 转成空串,Replace 于是
   报"路径为空"(两个版本实测都这样,我第一版就踩了)。

4) Get-FolderSummary 对空目录返回的 TotalSize 是 $null 而不是 0(Measure-Object 空
   输入的行为,两版一致)。$null / 1GB 得 0,而备份前的空间守卫判的是 -gt 0 —— 空间不足时
   不再拦截,静默失效。现在补成 0。

回归断言(零依赖与 Pester 各一份):空目录的摘要必须是整数 0;原子写成功时内容到位
且不留 .tmp、失败时旧内容完好(用只读目标强制失败)。

验收:test.ps1 9/9 全绿 —— 5.1 那一遍的通过同时证明了 File.Replace 这条新路径真的
在 5.1 上成立;tests\Run-RealSmoke.ps1 4/4 全绿。
2026-09-26 22:47:14 +08:00

3213 lines
132 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
BakNRet —— 备份 / 恢复脚本的公共功能模块。
.DESCRIPTION
提供日志(控制台 + 落盘)、外部命令调用(可取得真实退出码)、
BackupList.txt 语法解析、归档命名与逆向解析、目录摘要、manifest 读写、
磁盘剩余空间查询等公共能力。
兼容 Windows PowerShell 5.1 与 PowerShell 7.x:
* 不使用 ?? / 三元运算符 / Join-String / -AsHashtable 等 6.0+ 语法;
* 不使用 ProcessStartInfo.ArgumentList(5.1 上不存在),改为自行构造命令行。
模块内出现的备份清单语法(BackupList.txt 每一行):
[+|-] <软件名 或 绝对路径> [修饰符...] [# 说明]
[:: <Absolute\Path>] [:- <模式>[,...]] [:+ <包含项>[,...]]
[:encrypt | :!encrypt] [@ <Key>='<Value>']
标记(必须是独立的空白分隔记号,前后都要有空格):
+ 仅备份,不恢复(Restore.ps1 跳过)
- 仅恢复,不备份(Backup.ps1 跳过)
:: 覆盖 Path,等价于 `@ Path='...'`
:- 排除模式,等价于 `@ Exclude='...'`
:+ 追加包含项(<归档内相对路径>:<宿主机绝对路径>),等价于 `@ Include='...'`
:encrypt 该条目加密(`@ Encrypt='$true'`)
:!encrypt 该条目不加密(`@ Encrypt='$false'`)
@ Key='值' 覆盖 SoftwareCatalog.psd1 里的同名默认字段
兼容的历史写法:`@encrypt` / `@!encrypt` / `@pathname` / `@root=<名>`,
以及用双引号包住路径或模式值。
归档内布局(SoftwareCatalog.psd1 的 Slot 是包内的一层目录):
软件名条目 -> <Slot>\<该 Path 的内容>(Path 是文件时就是名为 <Slot> 的文件)
手写路径 -> <路径末级名>\...(历史布局,不变)
#>
$script:LogConfig = @{
TimeFormat = 'yyyy-MM-dd HH:mm:ss'
EnableDebug = $false
FilePath = $null
}
$script:LogEncoding = [System.Text.UTF8Encoding]::new($false)
# 名录读取缓存:一次运行里同一个文件只 Import 一次,`$( ... )` 也只求值一次。
# 键是文件路径,值里带内容指纹,文件被改过就自然失效。
$script:CatalogCache = @{}
$script:CatalogExpressionCache = @{}
# ============================================================================
# 日志
# ============================================================================
function Set-BaknretDebug {
<# .SYNOPSIS 打开 DEBUG 级别日志。 #>
param([switch]$Enabled = $true)
$script:LogConfig.EnableDebug = [bool]$Enabled
}
function Start-BaknretLog {
<#
.SYNOPSIS
把后续日志同时写入 <Directory>/<Prefix>-<时间戳>.log,返回日志文件路径。
#>
param(
[Parameter(Mandatory = $true)][string]$Directory,
[string]$Prefix = 'run'
)
if (-not (Test-Path -LiteralPath $Directory)) {
New-Item -ItemType Directory -Path $Directory -Force | Out-Null
}
$name = '{0}-{1}.log' -f $Prefix, (Get-Date -Format 'yyyyMMdd-HHmmss')
$path = Join-Path $Directory $name
$script:LogConfig.FilePath = $path
[System.IO.File]::WriteAllText($path, '', $script:LogEncoding)
return $path
}
function Stop-BaknretLog {
<# .SYNOPSIS 停止写入日志文件。 #>
$script:LogConfig.FilePath = $null
}
function Get-BaknretLogPath {
<# .SYNOPSIS 返回当前日志文件路径(未启用时返回 $null)。 #>
return $script:LogConfig.FilePath
}
function Write-Log {
<#
.SYNOPSIS
写一条日志到控制台,并在启用日志文件时落盘。
.DESCRIPTION
落盘失败不会影响主流程(吞掉异常),因为备份本身比日志更重要。
#>
param(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[ValidateNotNullOrEmpty()]
[string]$Message,
[Parameter()]
[ValidateSet('INFO', 'WARN', 'ERROR', 'DEBUG')]
[string]$Level = 'INFO'
)
process {
if ($Level -eq 'DEBUG' -and -not $script:LogConfig.EnableDebug) {
return
}
$timestamp = Get-Date -Format $script:LogConfig.TimeFormat
$line = "[$timestamp] [$Level] $Message"
$colorMap = @{
'INFO' = 'Green'
'WARN' = 'Yellow'
'ERROR' = 'Red'
'DEBUG' = 'Gray'
}
Write-Host $line -ForegroundColor $colorMap[$Level]
if ($script:LogConfig.FilePath) {
try {
[System.IO.File]::AppendAllText(
$script:LogConfig.FilePath,
$line + [Environment]::NewLine,
$script:LogEncoding)
} catch {
# 日志落盘失败时保持沉默:不能因为写日志失败而让备份失败。
}
}
}
}
# ============================================================================
# 环境
# ============================================================================
function Test-Administrator {
<# .SYNOPSIS 当前进程是否以管理员身份运行。 #>
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Get-BaknretFreeSpaceGB {
<#
.SYNOPSIS
返回 $Path 所在卷的剩余空间(GB);无法确定时返回 -1。
.DESCRIPTION
只用 cmdlet(Split-Path -Qualifier + Get-PSDrive),
不做 .NET 静态调用以外的假设,便于在受限环境下运行。
#>
param([Parameter(Mandatory = $true)][string]$Path)
try {
$resolved = $Path
if (Test-Path -LiteralPath $Path) {
$item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
if ($item.PSProvider.Name -eq 'FileSystem') { $resolved = $item.FullName }
}
$qualifier = Split-Path -Qualifier $resolved -ErrorAction Stop
if (-not $qualifier) { return -1 }
$drive = Get-PSDrive -Name $qualifier.TrimEnd(':') -ErrorAction Stop
if ($null -eq $drive.Free) { return -1 }
return [math]::Round($drive.Free / 1GB, 2)
} catch {
return -1
}
}
# ============================================================================
# 外部命令
# ============================================================================
function ConvertTo-NativeArgumentString {
<#
.SYNOPSIS
按 Windows 的命令行引用规则,把参数数组拼成单个命令行字符串。
.DESCRIPTION
ProcessStartInfo.Arguments 只接受字符串,而 PowerShell 5.1 没有
ArgumentList。手工拼参数会让含空格 / 引号 / 结尾反斜杠的路径出问题
(旧实现就是手工在参数里塞引号,反而让 7z 的排除模式全部失效)。
这里用标准算法:反斜杠只在引号前翻倍,内部引号前加反斜杠。
#>
param([string[]]$ArgumentList = @())
$parts = New-Object System.Collections.Generic.List[string]
foreach ($argument in $ArgumentList) {
if ($null -eq $argument) { continue }
$value = [string]$argument
if ($value.Length -gt 0 -and $value -notmatch '[\s"]') {
$parts.Add($value)
continue
}
$builder = New-Object System.Text.StringBuilder
[void]$builder.Append('"')
$backslashes = 0
foreach ($ch in $value.ToCharArray()) {
if ($ch -eq '\') { $backslashes++; continue }
if ($ch -eq '"') {
[void]$builder.Append('\' * (2 * $backslashes + 1))
[void]$builder.Append('"')
$backslashes = 0
continue
}
if ($backslashes -gt 0) {
[void]$builder.Append('\' * $backslashes)
$backslashes = 0
}
[void]$builder.Append($ch)
}
if ($backslashes -gt 0) {
[void]$builder.Append('\' * (2 * $backslashes))
}
[void]$builder.Append('"')
$parts.Add($builder.ToString())
}
return ($parts -join ' ')
}
function Invoke-ExternalCommand {
<#
.SYNOPSIS
运行外部程序并返回其真实退出码。
.DESCRIPTION
不要用 Start-Process -PassThru 取退出码:在 PowerShell 7.7.0-preview.4
上它稳定返回 $null,会把成功的压缩判成失败(旧版 Backup.ps1 的致命问题)。
这里用 .NET Process 直接启动并继承控制台:子进程输出实时可见,
ExitCode 可靠,且不经过 PowerShell 的管道捕获。
注意:不要给子进程做 stdout/stderr 重定向——某些受限环境会拒绝创建管道。
工具自己的输出直接进控制台,结构化记录由日志与 manifest 承担。
#>
param(
[Parameter(Mandatory = $true)][string]$FilePath,
[string[]]$ArgumentList = @(),
[string]$WorkingDirectory
)
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
$startInfo.FileName = $FilePath
$startInfo.Arguments = ConvertTo-NativeArgumentString -ArgumentList $ArgumentList
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $false
if ($WorkingDirectory) {
$startInfo.WorkingDirectory = $WorkingDirectory
}
# 打印的那一行必须把口令遮蔽掉:7z / RAR 只接受命令行口令(`-p<口令>`),所以口令
# 必然出现在参数表里;一旦 -Verbose 打开 DEBUG,整条命令行就会落进 logs\*.log ——
# 而 BackupConfig.psd1 与文档都承诺过"口令不落盘、不写进仓库"。真正执行的仍然是
# $startInfo.Arguments,这里只改日志。
#
# 在**参数级别**遮蔽,而不是对拼好的命令行做正则:含空格的口令会被引号包起来
# ("-pmy pass"),正则在那种形态上很容易漏掉,而漏掉的代价是口令明文入日志。
$loggableArguments = @($ArgumentList | ForEach-Object {
if ($_ -is [string] -and $_ -like '-p*') { '-p<口令已隐藏>' } else { $_ }
})
Write-Log ('执行: {0} {1}' -f $FilePath, (ConvertTo-NativeArgumentString -ArgumentList $loggableArguments)) -Level DEBUG
$process = [System.Diagnostics.Process]::Start($startInfo)
try {
$process.WaitForExit()
return $process.ExitCode
} finally {
$process.Dispose()
}
}
function Resolve-CompressionTool {
<#
.SYNOPSIS
探测可用的压缩工具,优先 7z,其次 RAR,最后内置 ZIP。
.DESCRIPTION
只返回工具身份,不再返回没人用的 FullArgs / FallbackArgs
(旧实现里 7z 的那两份参数是死代码,真正的参数由 Get-Optimized7zArgument 生成)。
#>
$sevenZip = Get-Command 7z -ErrorAction SilentlyContinue |
Select-Object -First 1 -ExpandProperty Source
if (-not $sevenZip) {
$candidates = @(
(Join-Path $env:ProgramFiles '7-Zip\7z.exe'),
(Join-Path ${env:ProgramFiles(x86)} '7-Zip\7z.exe')
)
$sevenZip = $candidates | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1
}
if ($sevenZip) {
Write-Log '检测到 7z 压缩工具' -Level DEBUG
return [pscustomobject]@{ Name = '7z'; Command = $sevenZip; Extension = '.7z' }
}
$rar = Get-Command rar, winrar -ErrorAction SilentlyContinue |
Select-Object -First 1 -ExpandProperty Source
if ($rar) {
Write-Log '检测到 RAR 压缩工具' -Level DEBUG
return [pscustomobject]@{ Name = 'RAR'; Command = $rar; Extension = '.rar' }
}
Write-Log '使用内置 ZIP 工具' -Level DEBUG
return [pscustomobject]@{ Name = 'ZIP'; Command = 'Compress-Archive'; Extension = '.zip' }
}
function Get-Optimized7zArgument {
<#
.SYNOPSIS
根据源目录规模生成 7z 压缩参数(字典大小、线程数、快速字节数)。
.DESCRIPTION
SourcePath 可以是多个(一个条目可能有多个 Slot / 追加项),字典大小按合计规模算。
#>
param(
[Parameter(Mandatory = $true)][string[]]$SourcePath,
[int]$Level = 9
)
$totalSize = 0
$fileCount = 0
foreach ($path in $SourcePath) {
if ([string]::IsNullOrWhiteSpace($path)) { continue }
$item = Get-Item -LiteralPath $path -ErrorAction Stop
if ($item.PSIsContainer) {
$files = @(Get-ChildItem -LiteralPath $path -File -Recurse -ErrorAction SilentlyContinue)
$fileCount += $files.Count
$totalSize += [int64](@($files | Measure-Object -Property Length -Sum).Sum)
} else {
$fileCount++
$totalSize += [int64]$item.Length
}
Write-Log ("分析路径 '{0}':已累计 {1} 个文件,{2} MB" -f $path, $fileCount, [math]::Round($totalSize / 1MB, 2)) -Level DEBUG
}
if ($null -eq $totalSize) { $totalSize = 0 }
$totalSizeMB = [math]::Round($totalSize / 1MB, 2)
Write-Log ("合计分析:{0} 个文件,总大小 {1} MB" -f $fileCount, $totalSizeMB) -Level DEBUG
if ($totalSizeMB -gt 1024) { $dictSize = '1024m' }
elseif ($totalSizeMB -gt 100) { $dictSize = '256m' }
elseif ($totalSizeMB -gt 10) { $dictSize = '32m' }
else { $dictSize = '16m' }
try {
$cpuCores = (Get-CimInstance Win32_ComputerSystem -ErrorAction Stop).NumberOfLogicalProcessors
$threads = [math]::Max(1, $cpuCores - 1)
} catch {
$threads = 2
}
Write-Log ("参数优化:字典=$dictSize, 线程=$threads, 级别=$Level") -Level DEBUG
return [pscustomobject]@{
# 只放压缩相关开关。输出开关(-bso0/-bsp0 或默认进度)必须由调用方
# 单独加一次:7z 对同一个开关出现两次会直接报
# "Multiple instances for switch" 并以退出码 7 失败。
Argument = @('a', '-t7z', "-mx=$Level", "-md=$dictSize", '-ms=on', "-mmt=$threads")
FileCount = $fileCount
TotalSize = $totalSize
TotalSizeMB = $totalSizeMB
}
}
# ============================================================================
# BackupList.txt 解析
# ============================================================================
function Split-BaknretToken {
<#
.SYNOPSIS
把清单的一行切成空白分隔的记号;引号内的空白不切分,引号本身留在记号里。
.DESCRIPTION
保留引号是为了让调用方分得清 `:- 'a,b'`(一个带逗号的值)与 `:- a,b`(两个值)。
引号不配对时按"引号一直延伸到行尾"处理,不抛异常——清单是手写的,
解析器要能给出可读的结果,而不是崩在半个引号上。
#>
param([AllowEmptyString()][string]$Text)
$tokens = New-Object System.Collections.Generic.List[string]
$builder = New-Object System.Text.StringBuilder
$quote = [char]0
foreach ($ch in ([string]$Text).ToCharArray()) {
if ($quote -ne [char]0) {
[void]$builder.Append($ch)
if ($ch -eq $quote) { $quote = [char]0 }
continue
}
if ($ch -eq "'" -or $ch -eq '"') {
$quote = $ch
[void]$builder.Append($ch)
continue
}
if ([char]::IsWhiteSpace($ch)) {
if ($builder.Length -gt 0) {
$tokens.Add($builder.ToString())
[void]$builder.Clear()
}
continue
}
[void]$builder.Append($ch)
}
if ($builder.Length -gt 0) { $tokens.Add($builder.ToString()) }
# 刻意不用 `,$array` 包一层:调用方都用 @(...) 收结果,包了反而会变成"数组套数组"。
return $tokens.ToArray()
}
function Remove-BaknretQuote {
<#
.SYNOPSIS
去掉值两端成对的引号(单双都认);不成对时原样返回。
#>
param([AllowEmptyString()][string]$Text)
$value = ([string]$Text).Trim()
if ($value.Length -ge 2) {
$first = $value[0]
$last = $value[$value.Length - 1]
if (($first -eq $last) -and ($first -eq "'" -or $first -eq '"')) {
return $value.Substring(1, $value.Length - 2)
}
}
return $value
}
function Test-BaknretMarker {
<#
.SYNOPSIS
判断一个记号是不是清单修饰符,返回它的种类;不是则返回 $null。
.DESCRIPTION
修饰符必须是**独立记号**(前后都有空白),所以这里做的是全等比较,
不是前缀匹配:`C:\a:-b` 仍然是一个路径,不会被看成 `:-`。
#>
param([AllowEmptyString()][string]$Token)
$text = ([string]$Token).Trim()
if (-not $text) { return $null }
switch -CaseSensitive ($text) {
'::' { return 'path' }
':-' { return 'exclude' }
':+' { return 'include' }
':encrypt' { return 'encrypt' }
':!encrypt' { return 'noencrypt' }
}
if ($text.StartsWith('@')) { return 'at' }
return $null
}
function ConvertFrom-BaknretPatternList {
<#
.SYNOPSIS
把修饰符的值列表拼成字符串并按 `,` / `;` 拆成多个模式。
#>
param([string[]]$Values = @())
$parts = @()
foreach ($value in @($Values)) {
$text = Remove-BaknretQuote -Text ([string]$value)
if ([string]::IsNullOrWhiteSpace($text)) { continue }
$parts += @($text -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
}
return @($parts)
}
function ConvertFrom-BackupListLine {
<#
.SYNOPSIS
解析 BackupList.txt 的一行。
.DESCRIPTION
返回 $null 表示注释 / 空行。正常返回包含:
Direction —— 'both' | 'backup'(行首 +,仅备份)| 'restore'(行首 -,仅恢复)
Path —— 目标原文(软件名或字面路径),**归档命名以它为准**
IsName —— 是否按软件名去名录里查
Overrides —— 显式给出的覆盖字段(hashtable,用 ContainsKey 判断有没有写)
Path / Exclude / Include / Encrypt
ExcludePatterns / Includes —— Overrides 的便捷视图(没写时是空数组)
Flags —— 兼容的历史标记(pathname / root=<名>)
Comment —— 行尾 `# 说明`
Raw —— 原始行
与旧实现的区别:
* `::` 现在表示"覆盖 Path"(旧版是 `:-` 的历史别名),排除一律写 `:-`;
* 新增行首 `+` / `-` 方向、`:encrypt` / `:!encrypt`、`@ Key='Value'` 覆盖;
* 修饰符必须是独立记号(前后加空格),所以 `C:\a:-b` 仍然是路径;
* 行首方向标记是唯一例外:`+` / `-` 贴在目标上(`+Edge`)或独立成记号
(`+ Edge`)都认。详见下面判定处的注释。
#>
param([Parameter(ValueFromPipeline = $true)][AllowEmptyString()][string]$Line)
process {
$content = ([string]$Line).Trim()
if ([string]::IsNullOrEmpty($content) -or $content.StartsWith('#')) {
return $null
}
# 行内注释:`#` 前面有空白时,它后面整段是"这条为什么这么配"的说明。
# 解析时摘出来单独放在 Comment 里,运行时打印,让人一眼看懂排除/追加的理由。
# (路径里的 `#` 必须紧贴前一个字符,所以 `C:\a#b` 不会受影响。)
$comment = $null
$commentIndex = $content.IndexOf(' #')
if ($commentIndex -ge 0) {
$comment = $content.Substring($commentIndex + 1).Trim().TrimStart('#').Trim()
$content = $content.Substring(0, $commentIndex).Trim()
if ([string]::IsNullOrEmpty($content)) { return $null }
}
$tokens = @(Split-BaknretToken -Text $content)
if ($tokens.Count -eq 0) { return $null }
# 整行被一对引号包住是**历史写法**(`"C:\a b\CodeSpace :: X\"`)。
# 现在修饰符必须是独立记号,所以引号里的 `::` / `:-` 不再是修饰符。
# 这里刻意**不**替用户重新切分:老写法里的 `::` 当年是"排除",现在 `::` 是
# "覆盖 Path"——猜着切会把排除表当成新的源路径,比报错更糟。只告警。
if ($tokens.Count -eq 1) {
$raw = $tokens[0]
if ($raw.Length -ge 2) {
$first = $raw[0]
$last = $raw[$raw.Length - 1]
if ($first -eq $last -and ($first -eq '"' -or $first -eq "'")) {
$inner = $raw.Substring(1, $raw.Length - 2)
foreach ($innerToken in @(Split-BaknretToken -Text $inner)) {
if (Test-BaknretMarker -Token $innerToken) {
Write-Log "整行被引号包住,引号里的修饰符不会被识别(历史写法)。请去掉外层引号,并注意现在 `:-` 才是排除、`::` 是覆盖 Path:$Line" -Level WARN
break
}
}
}
}
}
# 行首方向标记:`+` 仅备份、`-` 仅恢复。
#
# 两种写法都认:独立成记号(`+ Edge`)与贴在目标上(`+Edge`)。后者是本仓库清单
# 里的主流写法,而过去只认前者 —— 于是 `+WindowsTerminal` 被当成一个名叫
# `+WindowsTerminal` 的软件名,名录里查不到就退回当目录名,目录又不存在,
# 整条静默记成 missing-source 跳过;备份按"跳过不算失败"退出 0,所以一直没暴露。
#
# 只放宽"行首"这一个位置:修饰符(:: / :- / :+ / @)仍然必须是独立记号,
# 否则 `C:\a:-b` 这类路径会被切坏 —— 那是另一条已经钉住的行为。
$direction = 'both'
if ($tokens[0] -eq '+') {
$direction = 'backup'
$tokens = @($tokens | Select-Object -Skip 1)
} elseif ($tokens[0] -eq '-') {
$direction = 'restore'
$tokens = @($tokens | Select-Object -Skip 1)
} elseif ($tokens[0].Length -gt 1 -and $tokens[0][0] -eq '+') {
$direction = 'backup'
$tokens[0] = $tokens[0].Substring(1)
} elseif ($tokens[0].Length -gt 1 -and $tokens[0][0] -eq '-') {
$direction = 'restore'
$tokens[0] = $tokens[0].Substring(1)
}
if ($tokens.Count -eq 0) { return $null }
# 第一个修饰符之前是目标。目标可以带空格(比如带引号的 "C:\Program Files\App"),
# 所以这里取"第一个修饰符记号之前的全部记号",而不是只取第一个记号。
$firstMarker = -1
for ($index = 0; $index -lt $tokens.Count; $index++) {
if (Test-BaknretMarker -Token $tokens[$index]) { $firstMarker = $index; break }
}
if ($firstMarker -eq 0) {
Write-Log "清单行缺少目标,已忽略:$Line" -Level WARN
return $null
}
if ($firstMarker -lt 0) {
$targetText = ($tokens -join ' ')
$markerTokens = @()
} else {
$targetText = (($tokens[0..($firstMarker - 1)]) -join ' ')
$markerTokens = @($tokens[$firstMarker..($tokens.Count - 1)])
}
$target = Remove-BaknretQuote -Text $targetText
if ([string]::IsNullOrWhiteSpace($target)) { return $null }
$overrides = @{}
$flags = @()
$unknownKeys = @()
$index = 0
while ($index -lt $markerTokens.Count) {
$kind = Test-BaknretMarker -Token $markerTokens[$index]
$inline = $null
if ($kind -eq 'at') { $inline = $markerTokens[$index].Substring(1) }
$index++
$values = @()
if (-not [string]::IsNullOrWhiteSpace($inline)) { $values += $inline }
while ($index -lt $markerTokens.Count -and -not (Test-BaknretMarker -Token $markerTokens[$index])) {
$values += $markerTokens[$index]
$index++
}
switch ($kind) {
'path' {
$value = Remove-BaknretQuote -Text ($values -join ' ')
if (-not [string]::IsNullOrWhiteSpace($value)) {
if ($overrides.ContainsKey('Path')) {
Write-Log "同一条目里给了多次路径覆盖,用最后一个:$Line" -Level WARN
}
$overrides['Path'] = $value
}
}
# 同类记号可以出现多次(`Foo :- a :- b`),**累积**而不是后者覆盖前者:
# 静默丢掉前一条排除规则正是这个工具最不该犯的错。
'exclude' {
$parsed = @(ConvertFrom-BaknretPatternList -Values $values)
if ($overrides.ContainsKey('Exclude')) { $overrides['Exclude'] = @($overrides['Exclude']) + $parsed }
else { $overrides['Exclude'] = $parsed }
}
'include' {
$parsed = @(ConvertFrom-BaknretPatternList -Values $values)
if ($overrides.ContainsKey('Include')) { $overrides['Include'] = @($overrides['Include']) + $parsed }
else { $overrides['Include'] = $parsed }
}
'encrypt' {
if ($overrides.ContainsKey('Encrypt')) { Write-Log "同一条目里给了多次加密开关,用最后一个:$Line" -Level WARN }
$overrides['Encrypt'] = $true
}
'noencrypt' {
if ($overrides.ContainsKey('Encrypt')) { Write-Log "同一条目里给了多次加密开关,用最后一个:$Line" -Level WARN }
$overrides['Encrypt'] = $false
}
'at' {
$text = Remove-BaknretQuote -Text ($values -join ' ')
if ([string]::IsNullOrWhiteSpace($text)) { continue }
$equals = $text.IndexOf('=')
if ($equals -lt 0) {
# 兼容历史写法:`@encrypt` / `@!encrypt` / `@pathname` / `@root=名`
foreach ($legacy in @(ConvertFrom-BaknretPatternList -Values @($text))) {
$name = $legacy.Trim().TrimStart('@')
if ($name -ieq 'encrypt') { $overrides['Encrypt'] = $true }
elseif ($name -ieq '!encrypt') { $overrides['Encrypt'] = $false }
elseif ($name) { $flags += $name }
}
continue
}
$key = $text.Substring(0, $equals).Trim()
$value = Remove-BaknretQuote -Text $text.Substring($equals + 1)
switch -Regex ($key) {
'(?i)^path$' { $overrides['Path'] = $value }
'(?i)^exclude$' {
$parsed = @(ConvertFrom-BaknretPatternList -Values @($value))
if ($overrides.ContainsKey('Exclude')) { $overrides['Exclude'] = @($overrides['Exclude']) + $parsed }
else { $overrides['Exclude'] = $parsed }
}
'(?i)^include$' {
$parsed = @(ConvertFrom-BaknretPatternList -Values @($value))
if ($overrides.ContainsKey('Include')) { $overrides['Include'] = @($overrides['Include']) + $parsed }
else { $overrides['Include'] = $parsed }
}
'(?i)^encrypt$' { $overrides['Encrypt'] = [bool]($value -match '(?i)^(\$?true|1|yes|on)$') }
'(?i)^root$' { $flags += "root=$value" }
default { $unknownKeys += $key }
}
}
}
}
foreach ($unknown in $unknownKeys) {
Write-Log "清单里的 @ 字段 '$unknown' 不是已知字段(Path / Exclude / Include / Encrypt),已忽略:$Line" -Level WARN
}
$resolvedExclude = @()
if ($overrides.ContainsKey('Exclude')) { $resolvedExclude = @($overrides['Exclude']) }
$resolvedInclude = @()
if ($overrides.ContainsKey('Include')) { $resolvedInclude = @($overrides['Include']) }
return [pscustomobject]@{
Direction = $direction
Path = $target
# 目录名或文件名,需要靠 SoftwareCatalog 换成真实路径;
# 带分隔符或 %变量% 的写法按字面路径处理。
IsName = (-not (Test-LiteralPath -Path $target))
Overrides = $overrides
ExcludePatterns = $resolvedExclude
Includes = $resolvedInclude
Flags = @($flags)
UnknownKeys = @($unknownKeys)
Comment = $comment
Raw = $Line
}
}
}
function Test-LiteralPath {
<#
.SYNOPSIS
判断清单里的一行是不是"字面路径"(而非软件名)。
.DESCRIPTION
出现分隔符(\ 或 /)或 %环境变量% 就当作字面路径,其余按软件名去名录里查。
这条规则保证:现有的全路径清单不需要任何改写就能继续工作。
#>
param([AllowEmptyString()][string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return $true }
if ($Path.Contains('\') -or $Path.Contains('/')) { return $true }
if ($Path.Contains('%')) { return $true }
return $false
}
function Get-BaknretRegexExclude {
<#
.SYNOPSIS
把一条 `!re:<正则>` 展开成若干 `-x!<归档内路径>` 参数。
.DESCRIPTION
7z 本身只认通配符,不认正则,所以正则只能由脚本自己遍历源目录后翻译成
一条条精确的 `-x!<完整归档内路径>`:
* 逐层遍历,命中"目录名或相对路径"就把该目录整个排除,并且**不再往下走**
(否则一个命中会产生成千上万条参数);
* 展开结果有上限(MaxMatches),超过就明确报错,而不是悄悄漏排除或写出超长命令行。
注意:`!<通配>`(例如 `!*Cache`)不走这里——它在 .NET 里是非法正则
(`*` 前没有可重复的表达式),仍然按"任意层级匹配组件名"翻译成 `-xr!`。
#>
param(
[Parameter(Mandatory = $true)]$Item,
[Parameter(Mandatory = $true)][string]$Pattern,
[int]$MaxMatches = 300
)
$arguments = @()
$errorText = $null
try {
$regex = [System.Text.RegularExpressions.Regex]::new(
$Pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
} catch {
return [pscustomobject]@{ Arguments = @(); Matches = 0; Error = "排除正则非法:$Pattern —— $($_.Exception.Message)" }
}
$real = [string]$Item.RealPath
if (-not $real -or -not (Test-Path -LiteralPath $real)) {
return [pscustomobject]@{ Arguments = @(); Matches = 0; Error = $null }
}
$root = Get-Item -LiteralPath $real -Force -ErrorAction SilentlyContinue
if (-not $root) { return [pscustomobject]@{ Arguments = @(); Matches = 0; Error = $null } }
if (-not $root.PSIsContainer) {
if ($regex.IsMatch($root.Name)) { $arguments += "-x!$($Item.ArchivePath)" }
return [pscustomobject]@{ Arguments = @($arguments); Matches = $arguments.Count; Error = $null }
}
# 用显式栈做深度优先遍历:命中就整棵剪掉,所以匹配数是"命中的最浅层数"。
$stack = New-Object System.Collections.Generic.Stack[object]
foreach ($child in @(Get-ChildItem -LiteralPath $root.FullName -Force -ErrorAction SilentlyContinue)) {
$stack.Push(@{ Relative = $child.Name; Item = $child })
}
while ($stack.Count -gt 0) {
$node = $stack.Pop()
$relative = [string]$node.Relative
$entry = $node.Item
if ($regex.IsMatch($entry.Name) -or $regex.IsMatch($relative)) {
$arguments += ('-x!{0}\{1}' -f $Item.ArchivePath, ($relative -replace '/', '\'))
if ($arguments.Count -gt $MaxMatches) {
$errorText = "排除正则 $Pattern 命中的路径超过 $MaxMatches 条,7z 命令行会过长;请改用更粗的通配模式(例如 !*Cache)"
break
}
continue
}
if ($entry.PSIsContainer) {
foreach ($child in @(Get-ChildItem -LiteralPath $entry.FullName -Force -ErrorAction SilentlyContinue)) {
$stack.Push(@{ Relative = ('{0}\{1}' -f $relative, $child.Name); Item = $child })
}
}
}
return [pscustomobject]@{ Arguments = @($arguments); Matches = $arguments.Count; Error = $errorText }
}
function Get-BaknretExcludeArgument {
<#
.SYNOPSIS
把一个归档项的模式列表翻译成 7z 的 `-x!` / `-xr!` 参数。
.DESCRIPTION
传进来的模式**已经按项分配好**(见 Split-BaknretPatternScope),因此这里
拿到的模式一律是"相对该项归档根"的:
* `<相对路径>` -> `-x!<ArchivePath>\<相对路径>`(锚定在归档根)
* `!<通配>` -> `-xr!<通配>`(任意层级,模式里的空格自动转 `?`)
* `!re:<正则>` -> 遍历源目录翻译成若干 `-x!<完整路径>`(见 Get-BaknretRegexExclude)
7z 排除语义(已实测确认):
* `-x!<完整归档内路径>` 匹配对象的完整路径,所以要带上项自己的归档根名;
* 模式里不能有空格,也不能自己写引号;
* 参数总长度有上限,超了明确报错,不静默丢规则。
#>
param(
[Parameter(Mandatory = $true)]$Item,
[string[]]$Patterns = @(),
[int]$MaxRegexMatches = 300,
[int]$MaxCommandLineChars = 15000
)
$arguments = @()
$errorText = $null
foreach ($pattern in @($Patterns)) {
if ([string]::IsNullOrWhiteSpace($pattern)) { continue }
$text = ([string]$pattern).Trim()
if ($text.StartsWith('!re:')) {
$regexText = $text.Substring(4).Trim()
if (-not $regexText) { continue }
$expanded = Get-BaknretRegexExclude -Item $Item -Pattern $regexText -MaxMatches $MaxRegexMatches
if ($expanded.Error) { $errorText = $expanded.Error; continue }
$arguments += @($expanded.Arguments)
continue
}
if ($text.StartsWith('!')) {
$component = $text.Substring(1).Trim()
if (-not $component) { continue }
$arguments += ('-xr!{0}' -f ($component -replace ' ', '?'))
continue
}
$relative = $text.Trim([char[]]@('\', '/'))
if (-not $relative) { continue }
$arguments += ('-x!{0}\{1}' -f $Item.ArchivePath, ($relative -replace ' ', '?'))
}
$totalChars = 0
foreach ($argument in $arguments) { $totalChars += $argument.Length + 1 }
if (-not $errorText -and $totalChars -gt $MaxCommandLineChars) {
$errorText = "排除参数合计约 $totalChars 字符,超过命令行安全长度;请用更粗的通配模式(例如 !*Cache)"
}
return , [pscustomobject]@{ Arguments = @($arguments); Error = $errorText }
}
function Split-BaknretPatternScope {
<#
.SYNOPSIS
把条目级的模式按 `<归档项名>\` 前缀分配到各个归档项上。
.DESCRIPTION
软件目录里的一个软件可以有多个 Slot(各是一个归档内的顶层目录),
所以 `:-` / `Exclude` 里的模式要用第一段点名它作用在哪个 Slot 上:
Scoop :- GlobalPersist\steam\steamapps
这里把 `GlobalPersist\` 摘掉、只把 `steam\steamapps` 交给 GlobalPersist 这一项;
第一段没点名任何项时,普通模式对每个项各展开一份(`<项>\<模式>`),
`!` 开头与 `!re:` 开头本来就是"任意层级"的,直接广播到每一项,由调用方去重。
返回 hashtable:项的下标 -> 模式数组。
#>
param(
[Parameter(Mandatory = $true)][array]$Items,
[string[]]$Patterns = @()
)
$map = @{}
for ($index = 0; $index -lt $Items.Count; $index++) { $map[$index] = @() }
# 归档项的名字(顶层目录名)。同一个条目里不允许重名,Resolve-BackupEntry 会拦。
$topIndex = @{}
for ($index = 0; $index -lt $Items.Count; $index++) {
$name = [string]$Items[$index].ArchivePath
if (-not $name) { continue }
$topIndex[$name.ToLower()] = $index
}
foreach ($pattern in @($Patterns)) {
if ([string]::IsNullOrWhiteSpace($pattern)) { continue }
$text = ([string]$pattern).Trim()
if ($text.StartsWith('!re:') -or $text.StartsWith('!')) {
for ($index = 0; $index -lt $Items.Count; $index++) { $map[$index] += $text }
continue
}
$head = $text
$separator = $text.IndexOfAny([char[]]@('\', '/'))
$rest = ''
if ($separator -ge 0) {
$head = $text.Substring(0, $separator)
$rest = $text.Substring($separator + 1).Trim([char[]]@('\', '/'))
}
if ($rest -and $topIndex.ContainsKey($head.ToLower())) {
$map[$topIndex[$head.ToLower()]] += $rest
continue
}
for ($index = 0; $index -lt $Items.Count; $index++) { $map[$index] += $text }
}
return $map
}
function Merge-BaknretExcludeArgument {
<#
.SYNOPSIS
合并多个归档项展开出来的排除参数并去重(保序)。
#>
param([string[][]]$ArgumentLists = @())
$seen = @{}
$merged = @()
foreach ($list in @($ArgumentLists)) {
foreach ($argument in @($list)) {
if ([string]::IsNullOrWhiteSpace($argument)) { continue }
if ($seen.ContainsKey($argument)) { continue }
$seen[$argument] = $true
$merged += $argument
}
}
return @($merged)
}
# ============================================================================
# 软件名录(SoftwareCatalog.psd1)
# ============================================================================
function Resolve-CatalogPath {
<#
.SYNOPSIS
计算软件名录的绝对路径(优先 .psd1,找不到就退而用 .json)。
#>
param([string]$Configured, [string]$Root)
$candidates = @()
if ($Configured) {
$value = $Configured
if (-not [System.IO.Path]::IsPathRooted($value)) { $value = Join-Path $Root $value }
$candidates += $value
}
$candidates += (Join-Path $Root 'SoftwareCatalog.psd1')
$candidates += (Join-Path $Root 'SoftwareCatalog.json')
foreach ($candidate in $candidates) {
if (Test-Path -LiteralPath $candidate) { return $candidate }
}
return $candidates[0]
}
function Format-CatalogName {
<#
.SYNOPSIS
把软件名规范化成合法的归档基础名。
.DESCRIPTION
软件名就是归档名,所以这里必须挡住非法文件名字符。
保留 & % +(与路径命名算法的白名单一致)。
#>
param([Parameter(Mandatory = $true)][string]$Name)
$invalidChars = [System.IO.Path]::GetInvalidFileNameChars() |
Where-Object { $_ -notin @('&', '%', '+') }
$clean = -join ($Name.Trim().ToCharArray() | ForEach-Object {
if ($_ -in $invalidChars) { '_' } else { $_ }
})
$clean = $clean -replace ':', '_'
return $clean.Trim()
}
function Test-BaknretMapKey {
<# .SYNOPSIS 判断一个数据对象(哈希表或 JSON 对象)里有没有某个键。 #>
param($Map, [string]$Key)
if ($null -eq $Map) { return $false }
if ($Map -is [System.Collections.IDictionary]) { return $Map.Contains($Key) }
return @($Map.PSObject.Properties.Name) -contains $Key
}
function Get-BaknretMapValue {
<# .SYNOPSIS 从哈希表或 JSON 对象里按键取值。 #>
param($Map, [string]$Key)
if ($null -eq $Map) { return $null }
if ($Map -is [System.Collections.IDictionary]) {
if ($Map.Contains($Key)) { return $Map[$Key] }
return $null
}
if (@($Map.PSObject.Properties.Name) -contains $Key) { return $Map.$Key }
return $null
}
function Get-BaknretMapKeys {
<# .SYNOPSIS 列出哈希表或 JSON 对象的全部键。 #>
param($Map)
if ($null -eq $Map) { return @() }
if ($Map -is [System.Collections.IDictionary]) { return @($Map.Keys) }
return @($Map.PSObject.Properties.Name)
}
function Expand-CatalogPathText {
<#
.SYNOPSIS
展开名录里写的路径:`%环境变量%` 与 `$( ... )` 子表达式。
.DESCRIPTION
名录就是一份受信任的本地 PowerShell 配置,所以 `$( ... )` 直接按 PowerShell 求值,
够写这两类东西:
Path = '$(if ($env:SCOOP) { $env:SCOOP } else { Join-Path $env:USERPROFILE "scoop" })\persist'
Path = '$(scoop prefix translucenttb)\settings.json'
求值结果按原字符串缓存(`scoop prefix` 要起一个进程,不能每个条目跑一遍)。
括号不配对时原样保留,不抛异常——手写配置要的是可读的告警,不是崩掉。
#>
param([AllowEmptyString()][string]$Text)
$value = [string]$Text
if ([string]::IsNullOrEmpty($value)) { return '' }
if ($script:CatalogExpressionCache.ContainsKey($value)) {
return $script:CatalogExpressionCache[$value]
}
$original = $value
$guard = 0
while ($guard -lt 32) {
$guard++
# 从最后一个 `$(` 开始处理,这样嵌套在外层的表达式最后才展开
$start = $value.LastIndexOf('$(')
if ($start -lt 0) { break }
$depth = 0
$end = -1
for ($index = $start + 1; $index -lt $value.Length; $index++) {
if ($value[$index] -eq '(') { $depth++ }
elseif ($value[$index] -eq ')') {
$depth--
if ($depth -eq 0) { $end = $index; break }
}
}
if ($end -lt 0) { break }
$expression = $value.Substring($start + 2, $end - $start - 2)
$replacement = ''
try {
$evaluated = [scriptblock]::Create($expression).Invoke()
if ($null -ne $evaluated) { $replacement = ([string]($evaluated)).Trim() }
} catch {
Write-Log "名录路径里的表达式求值失败:$expression —— $($_.Exception.Message)" -Level WARN
}
$value = $value.Substring(0, $start) + $replacement + $value.Substring($end + 1)
}
$value = [Environment]::ExpandEnvironmentVariables($value)
$script:CatalogExpressionCache[$original] = $value
return $value
}
function Import-BaknretDataFile {
<#
.SYNOPSIS
读取 .psd1 / .json 配置数据。
.DESCRIPTION
先用 Import-PowerShellDataFile(受限语法,不执行任意代码);它对 psd1 里
常见的字符串拼接(`'a,' + 'b'`)会直接报
"Cannot generate a PowerShell object for a ScriptBlock evaluating dynamic expressions",
这种情况下退回 `[scriptblock]::Create(...).Invoke()` 求值。
这个退路是可信的:名录与配置本来就是仓库里的本地文件,跟脚本同级,
而且 Slot 的 Path 里已经允许写 `$( ... )` 子表达式(同样是要执行的)。
#>
param([Parameter(Mandatory = $true)][string]$Path)
if ($Path.ToLower().EndsWith('.json')) {
return (Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop)
}
try {
return Import-PowerShellDataFile -LiteralPath $Path -ErrorAction Stop
} catch {
$firstLine = ([string]$_.Exception.Message) -split "`r?`n" | Select-Object -First 1
Write-Log "psd1 里有 Import-PowerShellDataFile 不接受的表达式($firstLine),改用 PowerShell 求值:$Path" -Level DEBUG
$raw = [System.IO.File]::ReadAllText($Path)
return [scriptblock]::Create($raw).Invoke()
}
}
function Get-SoftwareCatalog {
<#
.SYNOPSIS
载入"软件名 -> Slot 组"名录。
.DESCRIPTION
新结构(SoftwareCatalog.psd1):
@{
<软件名> = @{
<Slot 名> = @{
Path = '宿主机绝对路径'
Exclude = '!*Cache,Default\Extensions' # 可选
Include = 'Modules:D:\extra\ps-modules' # 可选
Encrypt = $true # 可选,默认 $false
Description = '这个 Slot 是干什么的' # 可选
}
}
}
Slot 是**归档内的一层目录**:`<Slot>\<该 Path 的内容>`。一个软件一个归档,
因此同名的目录(例如 scoop 的用户 persist 与全局 persist)只要放在不同 Slot 里就不会撞。
返回按软件名索引的哈希表,每项:
Name / Path / Description / Slots / Kind / Missing / Error / Raw
Slot 对象:Name / Declared / Resolved / Exists / IsFile / Suffixed /
Description / Exclude / Include / Encrypt
读取结果按"文件路径 + 时间戳 + 长度 + 内容 MD5"缓存:一次运行里名录只会真正
读一次(旧实现每解析一个条目就重新 Import 一遍,还会把 `$( ... )` 反复求值)。
#>
param(
[Parameter(Mandatory = $true)][string]$Path,
[int]$MaxDepth = 5,
[switch]$NoCache
)
$result = @{}
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return $result }
$stamp = $null
if (-not $NoCache) {
try {
$item = Get-Item -LiteralPath $Path -ErrorAction Stop
$hash = (Get-FileHash -LiteralPath $Path -Algorithm MD5 -ErrorAction Stop).Hash
$stamp = '{0}-{1}-{2}' -f $item.LastWriteTimeUtc.Ticks, $item.Length, $hash
if ($script:CatalogCache.ContainsKey($Path) -and $script:CatalogCache[$Path].Stamp -eq $stamp) {
return $script:CatalogCache[$Path].Data
}
} catch {
$stamp = $null
}
}
$data = $null
try {
$data = Import-BaknretDataFile -Path $Path
} catch {
Write-Log "软件名录读取失败:$Path —— $_" -Level ERROR
return $result
}
# 递归引入其它名录文件(路径相对本文件)
$includeValue = Get-BaknretMapValue -Map $data -Key 'Includes'
if ($includeValue) {
$baseDir = Split-Path -Parent $Path
foreach ($include in @($includeValue)) {
if (-not $include) { continue }
$includePath = [string]$include
if (-not [System.IO.Path]::IsPathRooted($includePath)) { $includePath = Join-Path $baseDir $includePath }
$included = Get-SoftwareCatalog -Path $includePath -MaxDepth $MaxDepth
foreach ($includedName in $included.Keys) {
if ($result.ContainsKey($includedName)) { continue }
$result[$includedName] = $included[$includedName]
}
}
}
foreach ($key in @(Get-BaknretMapKeys -Map $data | Where-Object { $_ -ne 'Includes' })) {
$name = Format-CatalogName -Name ([string]$key)
if (-not $name) { continue }
$raw = Get-BaknretMapValue -Map $data -Key $key
if ($raw -isnot [System.Collections.IDictionary] -and $null -ne $raw -and -not ($raw -is [psobject] -and @($raw.PSObject.Properties.Name).Count -gt 0)) {
Write-Log "名录条目 '$key' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }" -Level ERROR
continue
}
$slots = @()
$errors = @()
foreach ($slotKey in @(Get-BaknretMapKeys -Map $raw)) {
$slotName = ([string]$slotKey).Trim()
if (-not $slotName) { continue }
$slotRaw = Get-BaknretMapValue -Map $raw -Key $slotKey
if ($slotRaw -isnot [System.Collections.IDictionary] -and -not ($slotRaw -is [psobject])) {
$errors += "Slot $slotName 的写法不对,应写成 @{ Path = '...' }"
continue
}
$declaredRaw = [string](Get-BaknretMapValue -Map $slotRaw -Key 'Path')
if ([string]::IsNullOrWhiteSpace($declaredRaw)) {
$errors += "Slot $slotName 缺少 Path"
continue
}
$declared = (Expand-CatalogPathText -Text $declaredRaw).Trim()
if ([string]::IsNullOrWhiteSpace($declared)) {
$errors += "Slot $slotName 的 Path 展开成空:$declaredRaw"
continue
}
# 逐个候选目录解析。一个 Slot 是归档内的一层目录,只能对应一个目录:
# 补全出多个候选(同名目录分散在多处)时必须拆成多个 Slot,否则会混成一棵树。
$candidates = @()
if (Test-Path -LiteralPath $declared) {
$candidates = @($declared)
} else {
$parent = Split-Path -Path $declared -Parent
$leafName = Split-Path -Path $declared -Leaf
if ($parent -and $leafName -and (Test-Path -LiteralPath $parent)) {
$candidates = @(Find-ChildDirectoryByName -Parent $parent -Name $leafName -MaxDepth $MaxDepth)
}
}
if ($candidates.Count -gt 1) {
$errors += ("Slot {0} 的 Path 匹配到 {1} 个目录:{2};一个 Slot 只能对应一个目录,请拆成多个 Slot" -f `
$slotName, $candidates.Count, ($candidates -join '、'))
}
$exists = $candidates.Count -ge 1
$resolved = if ($exists) { $candidates[0] } else { $declared }
$isFile = $false
$suffixed = $false
if ($exists) {
$suffixed = -not ($resolved -ieq $declared)
$resolvedItem = Get-Item -LiteralPath $resolved -Force -ErrorAction SilentlyContinue
if ($resolvedItem) { $isFile = -not $resolvedItem.PSIsContainer }
}
$excludeText = [string](Get-BaknretMapValue -Map $slotRaw -Key 'Exclude')
$includeText = [string](Get-BaknretMapValue -Map $slotRaw -Key 'Include')
$encryptValue = Get-BaknretMapValue -Map $slotRaw -Key 'Encrypt'
$description = [string](Get-BaknretMapValue -Map $slotRaw -Key 'Description')
$slots += [pscustomobject]@{
Name = $slotName
Declared = $declared
Resolved = $resolved
Exists = $exists
IsFile = $isFile
Suffixed = $suffixed
Description = $description
Exclude = @(ConvertFrom-BaknretPatternList -Values @($excludeText))
Include = @(ConvertFrom-BaknretPatternList -Values @($includeText))
Encrypt = [bool]$encryptValue
}
}
if ($slots.Count -eq 0 -and $errors.Count -eq 0) { continue }
# PowerShell 的哈希表不保留书写顺序,而 Slot 的顺序会影响归档内条目顺序与
# "第一个 Slot" 的取值,所以这里按名字排序,保证每次运行完全一致。
$slots = @($slots | Sort-Object -Property Name)
$existing = @($slots | Where-Object { $_.Exists })
$missing = @($slots | Where-Object { -not $_.Exists })
$kind = if ($slots.Count -eq 0) { 'Invalid' }
elseif ($existing.Count -eq 0) { 'Unresolved' }
elseif ($missing.Count -gt 0) { 'Partial' }
elseif ($slots.Count -gt 1) { 'Multi' }
else { 'Single' }
if ($errors.Count -gt 0) {
Write-Log ("名录条目 {0} 有问题:{1}" -f $name, ($errors -join ';')) -Level ERROR
} elseif ($missing.Count -gt 0) {
Write-Log ("名录:{0} 有 {1} 个 Slot 的路径当前不存在:{2}" -f $name, $missing.Count, (($missing | ForEach-Object { $_.Declared }) -join ';')) -Level DEBUG
}
if ($slots.Count -gt 0) {
Write-Log ("名录:{0} -> {1} 个 Slot,其中存在 {2} 个" -f $name, $slots.Count, $existing.Count) -Level DEBUG
}
if ($result.ContainsKey($name)) {
Write-Log ("名录里有两条规范化之后同名的条目:{0}(后者覆盖前者)" -f $name) -Level WARN
}
$result[$name] = [pscustomobject]@{
Name = $name
Path = $(if ($slots.Count -gt 0) { $slots[0].Declared } else { $null })
Description = $(if ($slots.Count -gt 0) { $slots[0].Description } else { $null })
Slots = @($slots)
Kind = $kind
Missing = @($missing | ForEach-Object { $_.Declared })
Error = $(if ($errors.Count -gt 0) { $errors -join ';' } else { $null })
Raw = $raw
}
}
if ($stamp) {
$script:CatalogCache[$Path] = [pscustomobject]@{ Stamp = $stamp; Data = $result }
}
return $result
}
function Get-ArchiveTopLevelNames {
<#
.SYNOPSIS
列出归档内的顶层条目名(用于确认多目录打包时每个目录都真的进去了)。
.DESCRIPTION
**刻意不解析 7z 的输出**:读取子进程 stdout 需要创建管道,本机沙箱会直接拒绝
(Access to the path '\\.\pipe\LOCAL\dotnet_...' denied),文件重定向(> file)
同样被拒。所以改成"把归档解到临时目录,再看文件系统上有哪些顶层条目",
只依赖文件系统。代价是多一次解压(只在多目录条目上跑),
好处是这个校验在受限环境里真的会执行,而不是静默退化成空数组。
解压失败或拿不到 7z 时返回空数组,调用方据此跳过顶层名核对。
#>
param(
[Parameter(Mandatory = $true)][string]$ArchivePath,
[Parameter(Mandatory = $true)][string]$SevenZip,
[string]$Password
)
$staging = Join-Path $env:TEMP ("bnr-inspect-" + [guid]::NewGuid().ToString('N'))
$names = @()
try {
New-Item -ItemType Directory -Path $staging -Force | Out-Null
$argument = @('x', '-bso0', '-bsp0', '-y', "-o$staging")
if ($Password) { $argument += "-p$Password" }
$argument += $ArchivePath
$exitCode = Invoke-ExternalCommand -FilePath $SevenZip -ArgumentList $argument
if ($exitCode -ne 0) { return @() }
# 先把名字读进变量,再在 finally 里删临时目录;
# 直接在 return 里管道 Get-ChildItem 会被 finally 抢在前面,拿到空数组。
$names = @(Get-ChildItem -LiteralPath $staging -Force -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty Name)
} catch {
Write-Log "无法清点归档内容(跳过顶层名核对):$($_.Exception.Message)" -Level DEBUG
$names = @()
} finally {
Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue
}
return $names
}
function Find-ChildDirectoryByName {
<#
.SYNOPSIS
在 $Parent 下按精确名或"<名>_<后缀>"/"<名>-<后缀>"形式找目录。
.DESCRIPTION
只做保守的前缀补全:必须以下一个字符是 _ 或 - 为界,
避免把 Legendary 匹配成 LegendarySomething。
#>
param(
[Parameter(Mandatory = $true)][string]$Parent,
[Parameter(Mandatory = $true)][string]$Name,
[int]$MaxDepth = 5
)
$escaped = [regex]::Escape($Name)
$pattern = "^$escaped(_|-).+"
try {
return @(Get-ChildItem -LiteralPath $Parent -Directory -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -ieq $Name -or $_.Name -imatch $pattern } |
Sort-Object Name |
Select-Object -ExpandProperty FullName)
} catch {
return @()
}
}
# ============================================================================
# 归档命名与路径还原
# ============================================================================
function Get-ItemArchiveName {
<#
.SYNOPSIS
决定一个条目的归档基础名(不含扩展名)。
.DESCRIPTION
规则:
* 默认用**软件名**(看起来像软件名就查名录;名录里没有则退回可读的目录名);
* 条目带 `@pathname` 时用原来的路径命名算法;
* 条目本来就写的是字面路径(含分隔符或 %变量%)时也用路径命名算法,
这样现有清单不需要改写就能继续工作。
#>
param($Entry, [string]$CatalogPath, [int]$MaxDepth = 5)
# @pathname 时用"真实路径"跑路径命名算法。
# 清单里写的可能是软件名,必须先经名录换成真实路径,
# 否则 Get-BackupBaseName 会对软件名本身运算,得出错误的名字。
if ($Entry.Flags -contains 'pathname') {
$nameSource = $Entry.Path
if (-not (Test-LiteralPath -Path $Entry.Path)) {
$catalogForPath = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth
if ($catalogForPath.ContainsKey($Entry.Path)) {
$nameSource = $catalogForPath[$Entry.Path].Path
}
}
return Get-BackupBaseName -RawPath $nameSource
}
$looksLikePath = Test-LiteralPath -Path $Entry.Path
if (-not $looksLikePath) {
$catalog = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth
if ($catalog.ContainsKey($Entry.Path)) {
return $catalog[$Entry.Path].Name
}
Write-Log "名录里没有 '$($Entry.Path)',按目录名处理" -Level WARN
return (Format-CatalogName -Name $Entry.Path)
}
return Get-BackupBaseName -RawPath $Entry.Path
}
function Get-BaknretArchiveTopName {
<# .SYNOPSIS 取归档内相对路径的第一段(顶层名字)。 #>
param([AllowEmptyString()][string]$ArchivePath)
$clean = ([string]$ArchivePath).Trim().Trim([char[]]@('\', '/'))
if (-not $clean) { return '' }
$separator = $clean.IndexOfAny([char[]]@('\', '/'))
if ($separator -lt 0) { return $clean }
return $clean.Substring(0, $separator)
}
function New-BaknretArchiveItem {
<#
.SYNOPSIS
构造一个"归档项":宿主机上的一个目录 / 文件,对应归档内的一条路径。
.DESCRIPTION
ArchivePath 是**归档内的相对路径**,语义分两种:
* 目录项 -> `<ArchivePath>\<目录内容>`(ArchivePath 是容器)
* 文件项 -> `<ArchivePath>` 就是那个文件本身
这样"是目录还是文件"只看归档就能判断,恢复端不必猜。
Origin 说明这个项是怎么来的(catalog / path / include),运行时会逐条打印,
方便回答"这个目录为什么会在包里"。
#>
param(
[Parameter(Mandatory = $true)][string]$ArchivePath,
[Parameter(Mandatory = $true)][string]$RealPath,
[ValidateSet('slot', 'path', 'include')][string]$Kind = 'slot',
[string]$Slot = $null,
[string]$Description = $null,
[string]$Origin = 'catalog',
[bool]$Exists = $false,
[bool]$IsFile = $false,
[string[]]$Exclude = @()
)
$clean = ([string]$ArchivePath).Trim().Trim([char[]]@('\', '/'))
return [pscustomobject]@{
ArchivePath = $clean
TopName = (Get-BaknretArchiveTopName -ArchivePath $clean)
RealPath = $RealPath
Kind = $Kind
Slot = $Slot
Description = $Description
Origin = $Origin
Exists = $Exists
IsFile = $IsFile
Exclude = @($Exclude)
}
}
function New-BaknretJunction {
<#
.SYNOPSIS
建一个 junction;失败时抛异常(调用方决定降级还是报错)。
.DESCRIPTION
恢复时用它做"零拷贝落地":把 `<目标父目录>\<Slot>` 建成指向真实目标目录的
junction,再让 7z 往那里解(写入会穿过 junction 落到真实目录里),
解完立刻拆掉连接点。这样不必"先解到临时目录再整体搬一遍"。
#>
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string]$Target
)
if (Test-Path -LiteralPath $Path) {
throw "连接点目标已存在:$Path"
}
New-Item -ItemType Junction -Path $Path -Target $Target -ErrorAction Stop | Out-Null
return $Path
}
function Remove-BaknretJunction {
<#
.SYNOPSIS
只删连接点本身,绝不顺着它删到目标目录里去。
#>
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return }
try {
# Directory.Delete(path, recursive: $false) 删的只是重解析点,不碰目标内容
[System.IO.Directory]::Delete($Path, $false)
} catch {
Remove-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
}
}
function New-BaknretArchiveStaging {
<#
.SYNOPSIS
建一个暂存目录,把每个归档项按"归档内的名字"挂进去,供压缩工具直接打包。
.DESCRIPTION
7z 没有"入库时改名"的能力:加进去的名字就是文件系统上的名字。Slot 要成为归档内的一层
目录,就得让它在暂存目录里真的叫那个名字:
* 目录项 -> 建 junction(不复制数据,等于零成本改名);
* 文件项 -> 先试硬链接(同卷),失败再复制(配置文件都很小)。
返回暂存目录路径;调用方用完必须调 Remove-BaknretArchiveStaging 清理。
建不出连接点时**明确抛错**,绝不悄悄退化成另一种归档布局 —— 布局一变,恢复就对不上。
#>
param(
[Parameter(Mandatory = $true)][array]$Items,
[string]$Root = $null
)
if (-not $Root) { $Root = Join-Path $env:TEMP ('bnr-stage-' + [guid]::NewGuid().ToString('N')) }
if (-not (Test-Path -LiteralPath $Root)) {
New-Item -ItemType Directory -Path $Root -Force | Out-Null
}
# 半途失败必须在这里自己清干净,不能把责任留给调用方。
#
# 原因:调用方拿到的是**返回值**,而抛错时根本没有返回值 —— Backup.ps1 的 finally 里
# `$stagingRoot` 还是 $null,而 Remove-BaknretArchiveStaging 对 $null 是直接 return。
# 结果是已经建好的 junction 与临时目录永久留在 %TEMP%,而那些 junction 指向的是真实
# 数据;临时目录迟早会被某次 Remove-Item -Recurse 扫到,那一下就会走进真实数据。
try {
foreach ($item in $Items) {
if ([string]::IsNullOrWhiteSpace([string]$item.ArchivePath)) {
throw "归档项缺少归档内路径:$($item.RealPath)"
}
$linkPath = Join-Path $Root $item.ArchivePath
$parent = Split-Path -Path $linkPath -Parent
if ($parent -and -not (Test-Path -LiteralPath $parent)) {
New-Item -ItemType Directory -Path $parent -Force | Out-Null
}
if (Test-Path -LiteralPath $linkPath) { Remove-BaknretJunction -Path $linkPath }
if ($item.IsFile) {
try {
New-Item -ItemType HardLink -Path $linkPath -Target $item.RealPath -ErrorAction Stop | Out-Null
} catch {
Write-Log ("暂存:硬链接不可用({0}),改为复制文件 {1}" -f $_.Exception.Message, $item.RealPath) -Level DEBUG
Copy-Item -LiteralPath $item.RealPath -Destination $linkPath -Force -ErrorAction Stop
}
} else {
New-Item -ItemType Junction -Path $linkPath -Target $item.RealPath -ErrorAction Stop | Out-Null
}
Write-Log ("暂存:{0} -> {1}" -f $item.ArchivePath, $item.RealPath) -Level DEBUG
}
return $Root
} catch {
try {
Remove-BaknretArchiveStaging -Root $Root
} catch {
# 清理失败不能盖掉真正的失败原因(那才是排查需要的),所以只告警并点名残留路径
Write-Log ("暂存目录自清理失败,需要手工删除:{0} —— {1}" -f $Root, $_.Exception.Message) -Level WARN
}
throw
}
}
function Remove-BaknretArchiveStaging {
<#
.SYNOPSIS
安全拆掉暂存目录:先手工摘掉 junction,再删剩下的普通文件 / 目录。
.DESCRIPTION
绝不能直接 `Remove-Item -Recurse` 了事:那会顺着 junction 走进真实数据里。
这里自己走一遍目录树,遇到连接点只删连接点本身。
#>
param([string]$Root)
if (-not $Root -or -not (Test-Path -LiteralPath $Root)) { return }
$pending = New-Object System.Collections.Generic.Stack[string]
$pending.Push($Root)
while ($pending.Count -gt 0) {
$current = $pending.Pop()
foreach ($child in @(Get-ChildItem -LiteralPath $current -Force -ErrorAction SilentlyContinue)) {
if ($child.LinkType -eq 'Junction' -or $child.LinkType -eq 'SymbolicLink') {
Remove-BaknretJunction -Path $child.FullName
continue
}
if ($child.PSIsContainer) { $pending.Push($child.FullName) }
}
}
Remove-Item -LiteralPath $Root -Recurse -Force -ErrorAction SilentlyContinue
}
function Resolve-BackupEntry {
<#
.SYNOPSIS
把清单条目解析成"实际要打包什么、归档里长什么样"。
.DESCRIPTION
返回:
IsName / BaseName / ArchiveFlavor / Direction
CatalogEntry —— 名录条目(软件名写法才有)
Items —— 归档项数组(见 New-BaknretArchiveItem)
Encrypt —— 该归档是否加密
ExcludePatterns / HasExcludeOverride —— 条目级 `:-` / `@ Exclude` 覆盖
Includes / HasIncludeOverride —— 条目级 `:+` / `@ Include` 覆盖
Error —— 可恢复的问题(例如名录里路径不存在)
Blocking —— 必须整条失败的问题(归档内路径冲突等)
归档内部布局:
* 软件名条目 -> `<Slot>\<Path 内容>`(文件 Slot 就是名为 `<Slot>` 的文件);
* 手写路径 -> `<末级名>\...`(与历史归档一致,不变)。
名录里的 Slot 存在但路径当前不存在时**照样产出归档项**:源被删掉正是要恢复的场景,
备份端按存在性跳过,恢复端靠它把内容还原回原位。
#>
param(
$Entry,
[string]$CatalogPath,
[int]$MaxDepth = 5
)
$isName = -not (Test-LiteralPath -Path $Entry.Path)
$forcePathFlavor = ($Entry.Flags -contains 'pathname')
$baseName = Get-ItemArchiveName -Entry $Entry -CatalogPath $CatalogPath -MaxDepth $MaxDepth
$overrides = $Entry.Overrides
if (-not $overrides) { $overrides = @{} }
$overridePath = if ($overrides.ContainsKey('Path')) { [string]$overrides['Path'] } else { $null }
$hasExcludeOverride = $overrides.ContainsKey('Exclude')
$entryExclude = if ($hasExcludeOverride) { @($overrides['Exclude']) } else { @() }
$hasIncludeOverride = $overrides.ContainsKey('Include')
$entryInclude = if ($hasIncludeOverride) { @($overrides['Include']) } else { @() }
$hasEncryptOverride = $overrides.ContainsKey('Encrypt')
$items = @()
$catalogEntry = $null
$errorText = $null
$blocking = $null
$archiveFlavor = if ($isName) { 'name' } else { 'path' }
if (-not $isName) {
# ---- 写法二:用户手写的目录 / 文件 ----
$real = [string]$Entry.Path
if ($overridePath) { $real = $overridePath }
$real = [Environment]::ExpandEnvironmentVariables($real).Trim()
$leaf = Split-Path -Path $real -Leaf
if ($forcePathFlavor) { $archiveFlavor = 'path' }
$exists = $false
$isFile = $false
if ($real) {
$exists = Test-Path -LiteralPath $real
if ($exists) {
$item = Get-Item -LiteralPath $real -Force -ErrorAction SilentlyContinue
if ($item) { $isFile = -not $item.PSIsContainer }
}
}
if ([string]::IsNullOrWhiteSpace($leaf) -or [string]::IsNullOrWhiteSpace($real)) {
$errorText = "无法从路径里拆出末级名:$real"
} else {
$items += New-BaknretArchiveItem -ArchivePath $leaf -RealPath $real -Kind 'path' `
-Origin 'path' -Exists $exists -IsFile $isFile
}
}
else {
# ---- 写法一:软件名录里的软件名 ----
$catalog = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth
if (-not $catalog.ContainsKey($Entry.Path)) {
$errorText = "软件名录里没有 '$($Entry.Path)'"
} else {
$catalogEntry = $catalog[$Entry.Path]
# 名录条目自身有问题(Slot 缺 Path、前缀补全命中多个目录……)时整条失败:
# 继续跑只会"少打包一块"或"任选一个目录",那正是最该避免的静默错误。
if ($catalogEntry.Error) {
$errorText = $catalogEntry.Error
if (-not $blocking) { $blocking = "软件名录里的 '$($Entry.Path)' 有问题:$($catalogEntry.Error)" }
}
$slots = @($catalogEntry.Slots)
if ($overridePath -and $slots.Count -ne 1) {
$blocking = ("'{0}' 有 {1} 个 Slot,不能用一个 `::` / `@ Path=` 覆盖路径;请写清楚是哪个 Slot" -f `
$Entry.Path, $slots.Count)
} else {
foreach ($slot in $slots) {
$resolvedPath = $slot.Resolved
$exists = $slot.Exists
$isFile = $slot.IsFile
if ($overridePath) {
$resolvedPath = [Environment]::ExpandEnvironmentVariables($overridePath).Trim()
$exists = Test-Path -LiteralPath $resolvedPath
$isFile = $false
if ($exists) {
$item = Get-Item -LiteralPath $resolvedPath -Force -ErrorAction SilentlyContinue
if ($item) { $isFile = -not $item.PSIsContainer }
}
}
$items += New-BaknretArchiveItem -ArchivePath $slot.Name -RealPath $resolvedPath -Kind 'slot' `
-Slot $slot.Name -Description $slot.Description -Origin 'catalog' `
-Exists $exists -IsFile $isFile -Exclude $slot.Exclude
}
}
}
}
# ------------------------------------------------------------------
# 包含项:`<归档内相对路径>:<宿主机绝对路径>`,把宿主机上的目录 / 文件放到包内指定位置。
# 条目级写 `:+` / `@ Include=` 就覆盖名录里的 Include;没写就用名录里各 Slot 的。
# ------------------------------------------------------------------
$includeTexts = @()
if ($hasIncludeOverride) {
$includeTexts = @($entryInclude)
} elseif ($catalogEntry) {
foreach ($slot in @($catalogEntry.Slots)) { $includeTexts += @($slot.Include) }
}
foreach ($includeText in $includeTexts) {
if ([string]::IsNullOrWhiteSpace($includeText)) { continue }
$text = ([string]$includeText).Trim()
$archivePart = ''
$hostPart = $text
$separator = $text.IndexOf(':')
if ($separator -ge 0) {
$archivePart = $text.Substring(0, $separator).Trim()
$hostPart = $text.Substring($separator + 1).Trim()
# 写成 `D:\extra\ps-modules:Modules`(宿主机在前)时纠正并告警。
# 判据:第一个冒号前只有盘符那一个字母,而且紧跟着 `\` 或 `/`。
# 这时按**最后一个**冒号切,才能把宿主机路径完整地拿回来。
if ($archivePart -match '^[A-Za-z]$' -and ($hostPart.StartsWith('\') -or $hostPart.StartsWith('/'))) {
$lastSeparator = $text.LastIndexOf(':')
if ($lastSeparator -gt $separator) {
Write-Log ("包含项写得像'宿主机:归档内':{0} —— 语法应为 <归档内相对路径>:<宿主机绝对路径>,已按后者解释" -f $text) -Level WARN
$hostPart = $text.Substring(0, $lastSeparator).Trim()
$archivePart = $text.Substring($lastSeparator + 1).Trim()
}
}
}
$hostPath = [Environment]::ExpandEnvironmentVariables($hostPart).Trim()
if ([string]::IsNullOrWhiteSpace($hostPath)) {
Write-Log "包含项 '$text' 里没有宿主机路径,已忽略" -Level WARN
continue
}
$exists = Test-Path -LiteralPath $hostPath
$isFile = $false
if ($exists) {
$item = Get-Item -LiteralPath $hostPath -Force -ErrorAction SilentlyContinue
if ($item) { $isFile = -not $item.PSIsContainer }
}
$archivePath = $archivePart
if ([string]::IsNullOrWhiteSpace($archivePath)) { $archivePath = Split-Path -Path $hostPath -Leaf }
$items += New-BaknretArchiveItem -ArchivePath $archivePath -RealPath $hostPath -Kind 'include' `
-Origin 'include' -Exists $exists -IsFile $isFile
}
# ------------------------------------------------------------------
# 归档内路径冲突拦截
# 一个目录 / 文件在包内只能有一个位置:重名会互相覆盖,祖宗关系会混成一棵树。
# 宁可明确报错,也不要静默搅在一起。
# ------------------------------------------------------------------
$seen = @{}
$collisions = @()
foreach ($item in $items) {
$key = ([string]$item.ArchivePath).ToLower()
if (-not $key) { continue }
if ($seen.ContainsKey($key)) {
$collisions += ("'{0}'({1} 与 {2})" -f $item.ArchivePath, $seen[$key], $item.RealPath)
} else {
$seen[$key] = $item.RealPath
}
}
foreach ($item in $items) {
$key = ([string]$item.ArchivePath).ToLower()
foreach ($other in $seen.Keys) {
if ($other -eq $key) { continue }
if ($other.StartsWith("$key\") -or $key.StartsWith("$other\")) {
$collisions += ("'{0}' 与 '{1}' 是父子关系,包内会互相覆盖" -f $item.ArchivePath, $other)
}
}
}
$collisions = @($collisions | Select-Object -Unique)
if ($collisions.Count -gt 0) {
$blocking = ("归档内路径冲突:{0}。每个 Slot / 追加项在包内必须有唯一位置," +
"请改 Slot 名或归档内相对路径。") -f ($collisions -join ';')
}
# ------------------------------------------------------------------
# 加密:清单覆盖优先,其次是名录里各 Slot 的 Encrypt 取或。
# 一个软件一个归档,所以 Slot 之间不一致时按"加密"处理(宁可多加密,不可漏加密)。
# ------------------------------------------------------------------
$encrypt = $false
if ($hasEncryptOverride) {
$encrypt = [bool]$overrides['Encrypt']
} elseif ($catalogEntry) {
$slots = @($catalogEntry.Slots)
$encryptedSlots = @($slots | Where-Object { $_.Encrypt })
$encrypt = $encryptedSlots.Count -gt 0
if ($encryptedSlots.Count -gt 0 -and $encryptedSlots.Count -lt $slots.Count) {
Write-Log ("{0}:名录里各 Slot 的 Encrypt 不一致,整个归档按加密处理" -f $Entry.Path) -Level WARN
}
}
return [pscustomobject]@{
IsName = [bool]$isName
CatalogEntry = $catalogEntry
BaseName = $baseName
ArchiveFlavor = $archiveFlavor
Direction = $Entry.Direction
Items = @($items)
Encrypt = [bool]$encrypt
ExcludePatterns = @($entryExclude)
HasExcludeOverride = [bool]$hasExcludeOverride
Includes = @($entryInclude)
HasIncludeOverride = [bool]$hasIncludeOverride
Source = $Entry.Path
Error = $errorText
Blocking = $blocking
}
}
function Write-BackupEntryPlan {
<#
.SYNOPSIS
在动手打包之前,把"这条会打包哪些目录、归档里长什么样、排除了什么、为什么"打印出来。
.DESCRIPTION
逐项打印:归档内路径、宿主机路径、它是怎么来的(名录 / 手写路径 / 追加)、
当前在不在、是文件还是目录、以及这个 Slot 是干什么的(Description)。
#>
param(
[Parameter(Mandatory = $true)]$Resolved,
[Parameter(Mandatory = $true)][string]$DisplayPath,
[string[]]$ListExcludes = @(),
[string[]]$CatalogExcludes = @(),
[string[]]$ConfigExcludes = @(),
[string]$Comment
)
$originText = @{
'catalog' = '软件名录'
'path' = '手写路径'
'include' = '追加项(清单 :+ / 名录 Include)'
}
$directionText = @{
'both' = '备份 + 恢复'
'backup' = '仅备份(行首 +)'
'restore' = '仅恢复(行首 -)'
}
Write-Log ("条目:{0}" -f $DisplayPath)
Write-Log (" 归档:{0}.7z;方向:{1};加密:{2}" -f $Resolved.BaseName,
$(if ($directionText.ContainsKey($Resolved.Direction)) { $directionText[$Resolved.Direction] } else { $Resolved.Direction }),
$(if ($Resolved.Encrypt) { '是' } else { '否' }))
if ($Comment) { Write-Log (" 说明:{0}" -f $Comment) }
if ($Resolved.Error) { Write-Log (" 提示:{0}" -f $Resolved.Error) -Level WARN }
$items = @($Resolved.Items)
if ($items.Count -eq 0) { Write-Log ' 归档项:没有解析出任何目录' -Level WARN }
for ($index = 0; $index -lt $items.Count; $index++) {
$item = $items[$index]
$exists = Test-Path -LiteralPath $item.RealPath
$origin = if ($item.Origin -and $originText.ContainsKey($item.Origin)) { $originText[$item.Origin] } else { $item.Origin }
Write-Log (" 归档项 {0}/{1}:{2} <- {3}" -f ($index + 1), $items.Count, $item.ArchivePath, $item.RealPath)
Write-Log (" 来源:{0};{1};{2}" -f $origin,
$(if ($exists) { '存在,会打包' } else { '当前不存在,本次跳过' }),
$(if ($item.IsFile) { '文件' } else { '目录' }))
if ($item.Description) { Write-Log (" 介绍:{0}" -f $item.Description) }
if (@($item.Exclude).Count -gt 0) {
Write-Log (" 名录里的排除:{0}" -f (@($item.Exclude) -join '、'))
}
}
if ($ListExcludes.Count -gt 0) {
Write-Log (" 排除 {0} 条(来自清单的 :- / @ Exclude):{1}" -f $ListExcludes.Count, ($ListExcludes -join '、'))
}
if ($CatalogExcludes.Count -gt 0) {
Write-Log (" 排除 {0} 条(来自名录 Slot 的 Exclude):{1}" -f $CatalogExcludes.Count, ($CatalogExcludes -join '、'))
}
if ($ConfigExcludes.Count -gt 0) {
Write-Log (" 排除 {0} 条(来自 BackupConfig.psd1 的 DefaultExcludes):{1}" -f $ConfigExcludes.Count, ($ConfigExcludes -join '、'))
}
if ($ListExcludes.Count -eq 0 -and $CatalogExcludes.Count -eq 0 -and $ConfigExcludes.Count -eq 0) {
Write-Log ' 排除:无(整包收下)'
}
}
function Get-BackupBaseName {
<#
.SYNOPSIS
由清单中的原始路径生成归档基础名。
.DESCRIPTION
算法与历史版本保持一致(否则已存在的 20 个归档会全部失联):
<末级名>_from_<去掉末级后的各级用 + 连接>
并保留 & % + 三个字符(环境变量写法依赖 %),其余非法字符换 _。
额外做一件事:把 `:` 归一化为 `_`,因此 C:\Foo 与 "C:\Foo" 结果相同。
#>
param([Parameter(Mandatory = $true)][string]$RawPath)
$normalized = $RawPath.Trim() -replace '[/\\]+', '\'
$parts = @($normalized -split '\\' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
if ($parts.Count -eq 0) {
Write-Log "无法解析路径:$RawPath" -Level ERROR
return $null
}
$folderName = $parts[-1].Trim()
$pathParts = if ($parts.Count -gt 1) { $parts[0..($parts.Count - 2)] } else { @() }
$pathPart = ($pathParts | ForEach-Object { $_.Trim() }) -join '+'
$baseName = if ([string]::IsNullOrEmpty($pathPart)) {
$folderName
} else {
"${folderName}_from_${pathPart}"
}
$invalidChars = [System.IO.Path]::GetInvalidFileNameChars() |
Where-Object { $_ -notin @('&', '%', '+') }
$baseName = -join ($baseName.ToCharArray() | ForEach-Object {
if ($_ -in $invalidChars) { '_' } else { $_ }
})
$baseName = $baseName -replace ':', '_'
Write-Log "生成文件基础名:$baseName" -Level DEBUG
return $baseName
}
function Convert-BackupFileNameToPath {
<#
.SYNOPSIS
把归档文件名还原成原始路径(用于没有 manifest 时的兜底)。
.DESCRIPTION
只处理 <名>_from_<路径> 形式;`C_` 还原为 `C:`。
命名里本来就含 `+` 或 `_from_` 的真实目录名无法可靠还原,
这类情况应当依赖 manifest.json 而不是文件名。
#>
param([Parameter(Mandatory = $true)][string]$FileName)
$baseName = [System.IO.Path]::GetFileNameWithoutExtension($FileName)
if ($baseName -notmatch '_from_') { return $null }
try {
$folderPart, $pathPart = $baseName -split '_from_', 2
$parts = @($pathPart -split '\+' | Where-Object { -not [string]::IsNullOrEmpty($_) })
$parts = @($parts | ForEach-Object {
if ($_ -match '^([A-Za-z])_$') { "$($matches[1]):" } else { $_ }
})
$reconstructed = ($parts -join '\') + '\' + $folderPart
Write-Log "逆向解析:$FileName -> $reconstructed" -Level DEBUG
return $reconstructed
} catch {
Write-Log "无法解析备份文件名:$FileName" -Level WARN
return $null
}
}
function Get-FolderSummary {
<#
.SYNOPSIS
统计目录/文件的文件数、总大小与最新修改时间。
.DESCRIPTION
LatestModifiedTime 取**包含目录在内**的所有条目的最大值:
目录的 LastWriteTime 会在子项增删时更新,因此删掉文件也能被察觉。
#>
param([Parameter(Mandatory = $true)][string]$FolderPath)
try {
$items = @(Get-ChildItem -LiteralPath $FolderPath -Recurse -Force -ErrorAction SilentlyContinue)
$files = @($items | Where-Object { -not $_.PSIsContainer })
# 空目录时 Measure-Object 的 .Sum 是 $null 而不是 0(7.x 与 5.1 实测都一样)。
# 这个 $null 会一路传到备份前的空间守卫:$null / 1GB 得 0,而守卫判的是 -gt 0,
# 于是"空间不够"时不再拦截 —— 静默失效。所以在这里就把 0 补上。
$totalSize = ($files | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
if ($null -eq $totalSize) { $totalSize = 0 }
return [pscustomobject]@{
FileCount = $files.Count
TotalSize = [long]$totalSize
LatestModifiedTime = ($items | Measure-Object -Property LastWriteTime -Maximum -ErrorAction SilentlyContinue).Maximum
}
} catch {
Write-Log "无法读取文件夹摘要:$FolderPath" -Level WARN
return [pscustomobject]@{
FileCount = 0
TotalSize = 0
LatestModifiedTime = (Get-Item -LiteralPath $FolderPath -ErrorAction SilentlyContinue).LastWriteTime
}
}
}
# ============================================================================
# manifest.json
# ============================================================================
function Read-BaknretManifest {
<#
.SYNOPSIS
读取 manifest.json;不存在或损坏时返回空清单。
.DESCRIPTION
items 是按归档基础名索引的对象,方便按条目合并与查找。
损坏时只告警不中断:manifest 只是记录,不该成为备份的阻塞点。
#>
param([Parameter(Mandatory = $true)][string]$Path)
$empty = [pscustomobject]@{
schemaVersion = 1
tool = 'BakNRet'
updatedAt = $null
compressor = $null
items = [ordered]@{}
}
if (-not (Test-Path -LiteralPath $Path)) { return $empty }
try {
$raw = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 -ErrorAction Stop
if ([string]::IsNullOrWhiteSpace($raw)) { return $empty }
$parsed = $raw | ConvertFrom-Json -ErrorAction Stop
$items = [ordered]@{}
if ($parsed.PSObject.Properties.Name -contains 'items' -and $parsed.items) {
foreach ($property in $parsed.items.PSObject.Properties) {
$items[$property.Name] = $property.Value
}
}
return [pscustomobject]@{
schemaVersion = 1
tool = 'BakNRet'
updatedAt = $parsed.updatedAt
compressor = $parsed.compressor
items = $items
}
} catch {
Write-Log "manifest 解析失败(将重新建立):$Path —— $_" -Level WARN
return $empty
}
}
function Sync-BaknretManifestArchive {
<#
.SYNOPSIS
清空 manifest 里"指向了一个不存在的归档"的 archive 字段,返回被清空的条目名。
.DESCRIPTION
维持一条不变式:**manifest 里写了 archive 的记录,磁盘上就一定有那个文件。**
没有这条不变式时会出现两种误导:
* 源不存在的条目(missing-source / invalid-path)本来就没有归档,记录里却留着
一个不存在的文件名,Restore 每次都会打一条
"manifest 记录的归档不存在,回退按文件名查找",看着像出了问题其实没有;
* 人工删掉了某个归档(例如把它并进了另一个条目)之后,记录还宣称它在那儿。
只清 archive 字段,保留条目本身的历史(source / 成功次数 / 上次恢复时间),
因为"这个软件曾经备份过、现在源不在了"本身就是有用信息。
#>
param(
[Parameter(Mandatory = $true)]$Manifest,
[Parameter(Mandatory = $true)][string]$BackupDir
)
$cleared = @()
if (-not $Manifest -or -not $Manifest.items) { return , $cleared }
foreach ($key in @($Manifest.items.Keys)) {
$item = $Manifest.items[$key]
if (-not $item) { continue }
if (-not ($item.PSObject.Properties.Name -contains 'archive')) { continue }
$archive = $item.archive
if ([string]::IsNullOrWhiteSpace([string]$archive)) { continue }
if (Test-Path -LiteralPath (Join-Path $BackupDir $archive)) { continue }
$item.archive = $null
$cleared += $key
}
return , $cleared
}
function Write-BaknretManifest {
<#
.SYNOPSIS
原子写入 manifest.json(UTF-8 无 BOM)。
#>
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)]$Manifest
)
$Manifest.updatedAt = (Get-Date).ToString('o')
$json = $Manifest | ConvertTo-Json -Depth 6
$directory = Split-Path -Parent $Path
if ($directory -and -not (Test-Path -LiteralPath $directory)) {
New-Item -ItemType Directory -Path $directory -Force | Out-Null
}
# 复用原子写,而不是自己"删旧再改名":后者一旦在中途失败,旧 manifest 已经没了 ——
# 而 manifest 是"这块归档是谁的"的唯一账本,丢了它只能靠文件名反推。
Write-BaknretAtomicText -Path $Path -Text $json
return $Path
}
# ============================================================================
# 归档原子替换
# ============================================================================
function Move-BaknretArchiveIntoPlace {
<#
.SYNOPSIS
把临时归档原子地替换到最终路径。
.DESCRIPTION
优先用 File.Move(overwrite)(同卷上是 MoveFileEx + REPLACE_EXISTING,
基本等价于原子替换);不支持时退化为先删后移。
#>
param(
[Parameter(Mandatory = $true)][string]$TempPath,
[Parameter(Mandatory = $true)][string]$DestinationPath
)
if (-not (Test-Path -LiteralPath $DestinationPath)) {
Move-Item -LiteralPath $TempPath -Destination $DestinationPath -Force
return
}
try {
# 7.x:单次原子替换(MoveFileEx + REPLACE_EXISTING)
[System.IO.File]::Move($TempPath, $DestinationPath, $true)
return
} catch {
Write-Log "File.Move(overwrite) 不可用(5.1 没有这个重载),改用 File.Replace:$_" -Level DEBUG
}
# 5.1 走的这条。以前是"先删后移"—— 中途失败会让目标文件消失(旧归档没了、新归档还在
# .tmp 里)。File.Replace 走 ReplaceFile API,在 .NET Framework 上同样可用:要么换成
# 新内容、要么保持旧内容,两个都不会消失。
# 第三个参数必须传 [NullString]::Value —— PowerShell 会把 $null 转成空串,于是 Replace
# 报"路径为空"(两个版本实测都这样)。
[System.IO.File]::Replace($TempPath, $DestinationPath, [NullString]::Value)
}
# ============================================================================
# 安全描述符(NTFS 属主 / ACL)
# ============================================================================
# 为什么需要它:归档格式(.7z / .zip / .tar)**不承载 NT 安全描述符** ——
# 7-Zip 的 -sni(Store NT security information)官方文档写明"当前版本只能写进 WIM 归档"。
# 于是"备份 → 恢复"之后,每个对象的安全描述符都是新建对象的默认值:
# 属主是跑恢复脚本的那个进程,DACL 是从目标父目录继承来的那一套。
#
# 对 C:\ProgramData 下的目录这是致命的,它的 ACL 里有:
# (A;OICIIO;GA;;;CO) CREATOR OWNER + inherit-only + GENERIC_ALL
# 而 CREATOR OWNER(S-1-3-0)不是账户,是**访问检查时才替换的占位符**:
# 替换成"被检查对象的属主"。所以只回放 ACE 文本、不恢复属主,等于把
# "谁创建的东西谁有全权"里的那个"谁"换成了跑脚本的账户,原程序反而没权限。
#
# 存储格式:每对象一条 SDDL($acl.Sddl 原文)。SDDL 的 SID 是数值形式,CO / OW
# 这类占位符原样保留,往返无损;**绝不做账户名解析**——名字解析会把占位符映射成
# 当前用户,或者直接抛 IdentityNotMappedException,那正是"权限落到脚本头上"的另一种成因。
#
# 恢复:自顶向下、每个对象一次写 Owner|Group|Access;原本不 protected 的 DACL
# 只写显式 ACE,其余交给(已经修好的)父目录重新继承,保住"活继承"的语义。
# 写属主需要 SeRestorePrivilege,而且必须**显式启用**:管理员的过滤令牌里它默认是
# disabled,Set-Acl / SetAccessControl 都不会替你打开(见 Enable-BaknretPrivilege)。
$script:BaknretPrivilegeState = @{}
function Enable-BaknretPrivilege {
<#
.SYNOPSIS
在当前进程令牌里启用指定特权,返回哪些没能启用。
.DESCRIPTION
必须显式启用。MSDN(SetNamedSecurityInfoW)写明:
"If the caller does not have the SeRestorePrivilege constant, this SID must be
contained in the caller's token, and must have the SE_GROUP_OWNER permission
enabled." 也就是说没有它就没法把属主改成别的账户,而失败信息只有一句
"Access is denied"(easily mistaken for a path problem)。
两个坑:
* 结构体嵌套赋值(`$tp.Privileges.Luid.LowPart = …`)在 PowerShell 里改的是
装箱副本,改了不生效,所以整段放进 C# 里做;
* AdjustTokenPrivileges 返回 true 也可能是 ERROR_NOT_ALL_ASSIGNED(1300),
那代表特权根本不在令牌里,必须当成失败。
返回 [pscustomobject]@{ Enabled; Missing; Failed }(都是名字数组)。
#>
param([string[]]$Name = @('SeRestorePrivilege', 'SeBackupPrivilege'))
$result = [pscustomobject]@{
Enabled = @()
Missing = @()
Failed = @()
}
if (-not ('Baknret.Privileges' -as [type])) {
try {
Add-Type -Namespace Baknret -Name Privileges -MemberDefinition @'
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool OpenProcessToken(IntPtr h, int acc, out IntPtr phtok);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool LookupPrivilegeValue(string host, string name, out long pluid);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool AdjustTokenPrivileges(IntPtr htok, bool disall,
ref TOKEN_PRIVILEGES newst, int len, IntPtr prev, IntPtr relen);
[DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess();
[DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h);
[StructLayout(LayoutKind.Sequential)] public struct LUID { public uint LowPart; public int HighPart; }
[StructLayout(LayoutKind.Sequential)] public struct LUID_AND_ATTRIBUTES { public LUID Luid; public uint Attributes; }
[StructLayout(LayoutKind.Sequential)] public struct TOKEN_PRIVILEGES { public uint PrivilegeCount; public LUID_AND_ATTRIBUTES Privileges; }
// 0 = 已启用;1 = 令牌里没有这个特权;2 = 其它失败
public static int Enable(string name) {
IntPtr token;
if (!OpenProcessToken(GetCurrentProcess(), 0x28, out token)) { return 2; }
try {
long luid;
if (!LookupPrivilegeValue(null, name, out luid)) { return 1; }
TOKEN_PRIVILEGES tp = new TOKEN_PRIVILEGES();
tp.PrivilegeCount = 1;
tp.Privileges.Luid.LowPart = (uint)(luid & 0xFFFFFFFF);
tp.Privileges.Luid.HighPart = (int)(luid >> 32);
tp.Privileges.Attributes = 0x2;
if (!AdjustTokenPrivileges(token, false, ref tp, 0, IntPtr.Zero, IntPtr.Zero)) { return 2; }
if (Marshal.GetLastWin32Error() == 1300) { return 1; }
return 0;
} finally { CloseHandle(token); }
}
'@
} catch {
Write-Log "特权启用代码编译失败(本次不启用任何特权):$($_.Exception.Message)" -Level WARN
$result.Failed = @($Name)
return $result
}
}
$enabled = @(); $missing = @(); $failed = @()
foreach ($privilege in @($Name)) {
$cacheKey = $privilege
if ($script:BaknretPrivilegeState.ContainsKey($cacheKey)) {
$state = $script:BaknretPrivilegeState[$cacheKey]
} else {
$state = [Baknret.Privileges]::Enable($privilege)
$script:BaknretPrivilegeState[$cacheKey] = $state
}
switch ($state) {
0 { $enabled += $privilege }
1 { $missing += $privilege }
default { $failed += $privilege }
}
}
if ($missing.Count -gt 0) {
Write-Log ("这些特权不在当前令牌里(需要管理员或 SYSTEM):{0} —— 属主将无法改成别的账户,只能恢复 DACL" -f ($missing -join '、')) -Level WARN
}
if ($failed.Count -gt 0) {
Write-Log ("这些特权启用失败:{0}" -f ($failed -join '、')) -Level WARN
}
$result.Enabled = @($enabled)
$result.Missing = @($missing)
$result.Failed = @($failed)
return $result
}
function ConvertTo-BaknretWildcardPattern {
<#
.SYNOPSIS
把 7z 风格的通配符(* 与 ?)转成正则片段。
.DESCRIPTION
与 Get-BaknretExcludeArgument 保持一致:模式里的空格先转成 `?`(7z 的
`-x!` 不接受带空格的模式)。`*` 转 `.*`,跨过路径分隔符,
这样锚定模式 `Default\*` 才能命中 `Default\a\b`。
#>
param([AllowEmptyString()][string]$Pattern)
$text = ([string]$Pattern) -replace ' ', '?'
$escaped = [regex]::Escape($text)
$escaped = $escaped -replace '\\\*', '.*'
$escaped = $escaped -replace '\\\?', '.'
return $escaped
}
function Test-BaknretPathExcluded {
<#
.SYNOPSIS
判断归档内的一个相对路径是否命中排除模式。
.DESCRIPTION
安全描述符采集走的目录树必须和真正打进归档的那棵树一致,否则会出现
"归档里有、安全描述符里没有"(恢复后那块内容变成新建对象的默认 ACL)。
所以这里与交给 7z 的 -x! / -xr! 语义对齐:
* `<相对路径>` 锚定在本归档项的根上(`Default\Cache` 只命中它自己那棵子树)
* `!<通配>` 任意层级按**组件名**匹配(`!*Cache` 命中任意一层叫 *Cache 的目录)
* `!re:<正则>` 正则:命中组件名或整条相对路径
$RelativePath 用 `\` 分隔,且**不含归档项的根名**。
#>
param(
[AllowEmptyString()][string]$RelativePath,
[string[]]$Patterns = @()
)
$relative = ([string]$RelativePath).Trim([char[]]@('\', '/'))
if (-not $relative) { return $false }
$components = @($relative -split '\\')
foreach ($pattern in @($Patterns)) {
if ([string]::IsNullOrWhiteSpace($pattern)) { continue }
$text = ([string]$pattern).Trim()
if ($text.StartsWith('!re:')) {
$regexText = $text.Substring(4).Trim()
if (-not $regexText) { continue }
try {
$options = [System.Text.RegularExpressions.RegexOptions]::IgnoreCase
if ([regex]::IsMatch($relative, $regexText, $options)) { return $true }
foreach ($component in $components) {
if ([regex]::IsMatch($component, $regexText, $options)) { return $true }
}
} catch {
Write-Log "排除正则非法,安全描述符采集按'不排除'处理:$regexText —— $($_.Exception.Message)" -Level WARN
}
continue
}
if ($text.StartsWith('!')) {
$wildcard = $text.Substring(1).Trim()
if (-not $wildcard) { continue }
$componentPattern = '^(?:' + (ConvertTo-BaknretWildcardPattern -Pattern $wildcard) + ')$'
foreach ($component in $components) {
if ($component -match $componentPattern) { return $true }
}
continue
}
$anchored = ([string]$text).Trim([char[]]@('\', '/'))
if (-not $anchored) { continue }
$anchoredPattern = '^(?:' + (ConvertTo-BaknretWildcardPattern -Pattern $anchored) + ')$'
if ($relative -match $anchoredPattern) { return $true }
}
return $false
}
function Get-BaknretAceSignatureList {
<#
.SYNOPSIS
把 ACE 列表压成可比对的"签名"集合(`类型|SID|掩码`)。
.DESCRIPTION
只用来回答一个问题:"子对象上这条继承来的 ACE,在父目录的 ACL 里找得到出处吗?"
所以**刻意不带继承标志位**:同一条 ACE 传给文件子对象时容器继承位会被去掉
(实测父目录的 (A;OICI;FA;;;SY) 到文件上变成 (A;ID;FA;;;SY)),
带上标志比较会永远不相等。掩码取 AccessMask 整数值,避免枚举把组合权限拆得不一样。
#>
param([array]$Rules = @())
$list = @()
foreach ($rule in @($Rules)) {
if (-not $rule) { continue }
$mask = -1
try { $mask = [int]$rule.FileSystemRights } catch { $mask = -1 }
$list += ('{0}|{1}|{2}' -f $rule.AccessControlType, $rule.IdentityReference.Value, $mask)
}
return $list
}
function Get-BaknretSecuritySddlWithStale {
<#
.SYNOPSIS
对象与父目录的继承链**不自洽**时,把整套 ACE 冻结成显式副本(并置 protected),
返回改写后的 SDDL;自洽时原样返回 $Acl.Sddl。
.DESCRIPTION
恢复时只重放**显式** ACE,其余交给父目录重新继承 —— 对绝大多数对象这是最忠实的
做法(父目录修好之后继承会长出同样的 ACE,还保住了活继承语义)。
但有一类对象不行:它的 DACL 里留着**陈旧**的继承 ACE —— 父目录早就改过权限,
这条 ACE 已经没有任何出处。真机实测两件事:
1) 把父目录设成 protected 的新 DACL 之后,子对象仍留着从祖父目录继承来的
`(A;ID;FA;;;S-1-5-21-…)`;条数与父目录的可继承条数**正好都是 4**、内容却不同
—— 所以判据必须比 ACE 内容,不能只数条数。
2) Windows 在改写父目录时**不会**替子对象清掉这种已无出处的 ACE。于是
"目标上本来就留着它 + 我又补写一条显式 ACE" = 同一条 ACE 出现两次。
所以这类对象只能整套冻结:显式 ACE + 陈旧 ACE 全部按显式写,并置 protected
(protected 才不会被系统再补一遍继承 ACE)。代价是这个对象从此不跟随父目录
—— 但它本来就已经跟父目录脱节了,冻结是唯一"不丢 ACE、也不重复 ACE"的做法。
$ParentSignatures 为 $null 表示"调用方没有父目录上下文"(归档项根、单文件项),
此时不做任何改写。
#>
param(
[Parameter(Mandatory = $true)]$Acl,
[AllowNull()][string[]]$ParentSignatures = $null
)
if ($null -eq $ParentSignatures) { return $Acl.Sddl }
$sid = [System.Security.Principal.SecurityIdentifier]
$inherited = @($Acl.GetAccessRules($false, $true, $sid))
if ($inherited.Count -eq 0) { return $Acl.Sddl }
# 自洽 = 继承来的 ACE 每一条都能在父目录的 ACL 里找到出处
$stale = @()
foreach ($rule in $inherited) {
$signature = @(Get-BaknretAceSignatureList -Rules @($rule))[0]
if ($ParentSignatures -notcontains $signature) { $stale += $rule }
}
if ($stale.Count -eq 0) { return $Acl.Sddl }
$rebuilt = $null
if ($Acl -is [System.Security.AccessControl.DirectorySecurity]) {
$rebuilt = New-Object System.Security.AccessControl.DirectorySecurity
} else {
$rebuilt = New-Object System.Security.AccessControl.FileSecurity
}
# 整套(显式 + 继承)都按显式写:内容与备份时逐条一致,不靠继承去"猜"回来
foreach ($rule in @($Acl.GetAccessRules($true, $true, $sid))) { $rebuilt.AddAccessRule($rule) }
$sections = [System.Security.AccessControl.AccessControlSections]::Access
try {
$rebuilt.SetOwner($Acl.GetOwner($sid))
$sections = $sections -bor [System.Security.AccessControl.AccessControlSections]::Owner
} catch { }
try {
$rebuilt.SetGroup($Acl.GetGroup($sid))
$sections = $sections -bor [System.Security.AccessControl.AccessControlSections]::Group
} catch { }
$rebuilt.SetAccessRuleProtection($true, $false)
Write-Log ("{0} 条继承 ACE 已无出处(父目录里找不到),整套 ACE 冻结为显式并置 protected" -f $stale.Count) -Level DEBUG
return $rebuilt.GetSecurityDescriptorSddlForm($sections)
}
function Get-BaknretSecurityRecord {
<#
.SYNOPSIS
读一个对象的安全描述符,产出可序列化的一条记录。
.DESCRIPTION
返回 [pscustomobject]:
p / k 归档内相对路径 / 类型(d 目录、f 文件)
s SDDL 原文(含 O: / G: / D:)
o / g 属主 / 属组 SID 字符串
e 读不到时的错误(**必须记账**,不能当成"没有特殊权限")
Protected / Explicit / Inherited / Inheritable / Analyzed
Smart 模式判断"是否与父目录不同"用的分析结果
属主/属组一律取 SID 字符串(GetOwner(SecurityIdentifier).Value):
走 .Owner 会触发账户名解析,孤儿 SID 上会抛异常或很慢,而我们只要数值身份。
读 SD 需要 READ_CONTROL;C:\ProgramData 里确实有 Get-Acl 直接报
"Attempted to perform an unauthorized operation" 的目录,先开 SeBackupPrivilege
能救回大部分,救不回的会带 e 字段落进 sidecar。
#>
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string]$Key,
[ValidateSet('d', 'f')][string]$Kind = 'd',
[switch]$IncludeSacl,
[AllowNull()][string[]]$ParentSignatures = $null
)
$record = [pscustomobject]@{
p = $Key
k = $Kind
s = $null
o = $null
g = $null
e = $null
Protected = $false
Explicit = 0
Inherited = 0
Inheritable = 0
InheritedSignatures = @()
AllSignatures = @()
Analyzed = $false
}
$acl = $null
try {
if ($IncludeSacl) {
$acl = Get-Acl -LiteralPath $Path -Audit -ErrorAction Stop
} else {
$acl = Get-Acl -LiteralPath $Path -ErrorAction Stop
}
} catch {
$record.e = $_.Exception.Message
return $record
}
try {
# 陈旧继承 ACE 要固化成显式 ACE,否则恢复后会消失(见 Get-BaknretSecuritySddlWithStale)
$record.s = Get-BaknretSecuritySddlWithStale -Acl $acl -ParentSignatures $ParentSignatures
} catch {
$record.e = $_.Exception.Message
}
if (-not $record.s) {
if (-not $record.e) { $record.e = '读不到安全描述符' }
return $record
}
try { $record.o = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value } catch { }
try { $record.g = $acl.GetGroup([System.Security.Principal.SecurityIdentifier]).Value } catch { }
try {
$sid = [System.Security.Principal.SecurityIdentifier]
$record.Protected = [bool]$acl.AreAccessRulesProtected
$explicitRules = @($acl.GetAccessRules($true, $false, $sid))
$inheritedRules = @($acl.GetAccessRules($false, $true, $sid))
$record.Explicit = $explicitRules.Count
$record.Inherited = $inheritedRules.Count
$record.InheritedSignatures = @(Get-BaknretAceSignatureList -Rules $inheritedRules)
$record.AllSignatures = @(Get-BaknretAceSignatureList -Rules @($acl.GetAccessRules($true, $true, $sid)))
$inheritable = 0
foreach ($rule in @($acl.GetAccessRules($true, $true, $sid))) {
$fsRule = $rule -as [System.Security.AccessControl.FileSystemAccessRule]
if ($fsRule -and ($fsRule.InheritanceFlags -ne [System.Security.AccessControl.InheritanceFlags]::None)) {
$inheritable++
}
}
$record.Inheritable = $inheritable
$record.Analyzed = $true
} catch {
# 分析失败时一律当成"需要保留"(Analyzed=$false 会让 keeper 直接保留)
$record.Analyzed = $false
}
return $record
}
function Test-BaknretSecurityRecordNeeded {
<#
.SYNOPSIS
Smart 模式下判断这条记录是否必须落进 sidecar。
.DESCRIPTION
判据是"恢复时不能被继承自动复现",任何一条成立就得留:
* 读不到(e)—— 必须记账,恢复时要能报出来;
* DACL 是 protected(断开继承)—— 只靠父目录继承永远复现不出这一套;
* 有显式 ACE(Explicit > 0)—— 同上;
* NULL DACL(NO_ACCESS_CONTROL)—— 那不是"没有特殊权限",是"人人全权";
* 属主 / 属组与父目录不同 —— CREATOR OWNER 的解析结果就取决于属主;
* 继承链路与父目录脱节 —— 条数对不上,或某条继承来的 ACE 在父目录 ACL 里
找不到出处(父目录改过权限、子对象还留着老 ACE);空 DACL 也会在这里露出来。
分析不了(Analyzed=$false)时一律保留:多存永远比少存安全。
#>
param(
[Parameter(Mandatory = $true)]$Record,
[string]$ParentOwner,
[string]$ParentGroup,
[int]$ParentInheritable = -1,
[string[]]$ParentSignatures = @(),
[switch]$Force
)
if ($Force) { return $true }
if ($Record.e) { return $true }
if (-not $Record.s) { return $true }
if (-not $Record.Analyzed) { return $true }
if ($Record.Protected) { return $true }
if ($Record.Explicit -gt 0) { return $true }
if ($Record.s -match 'NO_ACCESS_CONTROL') { return $true }
if ($Record.o -and $ParentOwner -and ($Record.o -ne $ParentOwner)) { return $true }
if ($Record.g -and $ParentGroup -and ($Record.g -ne $ParentGroup)) { return $true }
# 继承链还接不接得上父目录:先比条数,再比每一条在父目录 ACL 里有没有出处。
# 只比条数会漏判 —— 真机实测过:子对象留着"改权限之前"的老 ACE,
# 条数与父目录可继承条数正好相等(都 4 条),内容却完全不同。
if ($ParentInheritable -ge 0 -and $Record.Inherited -ne $ParentInheritable) { return $true }
foreach ($signature in @($Record.InheritedSignatures)) {
if ($ParentSignatures -notcontains $signature) { return $true }
}
return $false
}
function Get-BaknretSecurityRecords {
<#
.SYNOPSIS
采集一组归档项的安全描述符,键是**归档内相对路径**(`<Slot>\…`)。
.DESCRIPTION
键用归档内路径而不是宿主机路径:目标机器上 `%UserProfile%` 会变、名录的前缀补全
(legendary -> legendary_2.0.4)也会变,只有归档内相对路径在两端是同一个坐标系。
遍历用显式栈,并且**跳过 reparse point**:PS 5.1 的 Get-ChildItem -Recurse 会
跟着 junction 无限转;scoop 的 `apps\<app>\current` 就是 junction,正撞在这个坑上。
$ScopeMap 由 Split-BaknretPatternScope 产出(项下标 -> 该相对根的模式数组),
所以这里的排除判定与真正交给 7z 的 -x! / -xr! 是同一套规则。
Mode:
* Roots —— 只存每个归档项的根(最省,适合"权限只在根上"的场景)
* Smart —— 根 + 所有"继承复现不出来"的对象(默认;几万文件的树 sidecar 也只有几百 KB)
* Full —— 每一个对象都存(最保险,sidecar 会大到几 MB)
返回 [pscustomobject]@{ Records; Scanned; Kept; Errors }。
#>
param(
[array]$Items = @(),
[hashtable]$ScopeMap = @{},
[ValidateSet('Roots', 'Smart', 'Full')][string]$Mode = 'Smart',
[switch]$IncludeSacl
)
$records = New-Object System.Collections.Generic.List[object]
$scanned = 0
$errorCount = 0
# 读安全描述符要 READ_CONTROL:系统目录里读不到是常态(C:\ProgramData 下就有
# Get-Acl 直接报 "Attempted to perform an unauthorized operation" 的目录)。
# SeBackupPrivilege 启用后系统会把读权限授予任何文件;连它都没有的账户,
# 读不到的对象会带 e 字段落进 sidecar,而不是被静默当成"没有特殊权限"。
$privileges = @('SeBackupPrivilege')
if ($IncludeSacl) { $privileges += 'SeSecurityPrivilege' }
Enable-BaknretPrivilege -Name $privileges | Out-Null
for ($index = 0; $index -lt $Items.Count; $index++) {
$item = $Items[$index]
if (-not $item) { continue }
$archiveRoot = [string]$item.ArchivePath
$real = [string]$item.RealPath
if ([string]::IsNullOrWhiteSpace($archiveRoot) -or [string]::IsNullOrWhiteSpace($real)) { continue }
if (-not (Test-Path -LiteralPath $real)) { continue }
$patterns = @()
if ($ScopeMap -and $ScopeMap.ContainsKey($index)) { $patterns = @($ScopeMap[$index]) }
$rootItem = Get-Item -LiteralPath $real -Force -ErrorAction SilentlyContinue
if (-not $rootItem) { continue }
if (-not $rootItem.PSIsContainer) {
$record = Get-BaknretSecurityRecord -Path $real -Key $archiveRoot -Kind 'f' -IncludeSacl:$IncludeSacl
$scanned++
if ($record.e) { $errorCount++ }
$records.Add($record)
continue
}
$rootRecord = Get-BaknretSecurityRecord -Path $real -Key $archiveRoot -Kind 'd' -IncludeSacl:$IncludeSacl
$scanned++
if ($rootRecord.e) { $errorCount++ }
$records.Add($rootRecord)
if ($Mode -eq 'Roots') { continue }
$pending = New-Object System.Collections.Generic.Stack[object]
$pending.Push(@{
Dir = $rootItem
Rel = ''
Owner = $rootRecord.o
Group = $rootRecord.g
Inheritable = $rootRecord.Inheritable
Signatures = $rootRecord.AllSignatures
})
while ($pending.Count -gt 0) {
$frame = $pending.Pop()
foreach ($child in @(Get-ChildItem -LiteralPath $frame.Dir.FullName -Force -ErrorAction SilentlyContinue)) {
if ($child.Attributes -band [IO.FileAttributes]::ReparsePoint) { continue }
$childRel = if ($frame.Rel) { $frame.Rel + '\' + $child.Name } else { $child.Name }
if (Test-BaknretPathExcluded -RelativePath $childRel -Patterns $patterns) { continue }
$kind = if ($child.PSIsContainer) { 'd' } else { 'f' }
$record = Get-BaknretSecurityRecord -Path $child.FullName -Key ($archiveRoot + '\' + $childRel) `
-Kind $kind -IncludeSacl:$IncludeSacl -ParentSignatures $frame.Signatures
$scanned++
if ($record.e) { $errorCount++ }
if ($Mode -eq 'Full') {
$records.Add($record)
} elseif (Test-BaknretSecurityRecordNeeded -Record $record `
-ParentOwner $frame.Owner -ParentGroup $frame.Group `
-ParentInheritable $frame.Inheritable -ParentSignatures $frame.Signatures) {
$records.Add($record)
}
if ($child.PSIsContainer) {
$pending.Push(@{
Dir = $child
Rel = $childRel
Owner = $record.o
Group = $record.g
Inheritable = $record.Inheritable
Signatures = $record.AllSignatures
})
}
}
}
}
return [pscustomobject]@{
Records = @($records.ToArray())
Scanned = $scanned
Kept = $records.Count
Errors = $errorCount
}
}
function Write-BaknretAtomicText {
<#
.SYNOPSIS
原子写一个文本文件(先写 .tmp,再替换)。
#>
param(
[Parameter(Mandatory = $true)][string]$Path,
[AllowEmptyString()][string]$Text = ''
)
$directory = Split-Path -Parent $Path
if ($directory -and -not (Test-Path -LiteralPath $directory)) {
New-Item -ItemType Directory -Path $directory -Force | Out-Null
}
$temp = "$Path.tmp"
[System.IO.File]::WriteAllText($temp, $Text, $script:LogEncoding)
if (-not (Test-Path -LiteralPath $Path)) {
Move-BaknretArchiveIntoPlace -TempPath $temp -DestinationPath $Path
return $Path
}
# 目标已存在:用 File.Replace。失败时旧内容完好、.tmp 留着便于排查(两版实测一致)——
# 这正是"宁可这次没换成,也不能让目标消失"。
[System.IO.File]::Replace($temp, $Path, [NullString]::Value)
return $Path
}
function Save-BaknretSecuritySidecar {
<#
.SYNOPSIS
把采集结果写成 sidecar(`<归档名>.acl.json`)。
.DESCRIPTION
放在归档旁边而不是塞进归档里:7z 装不下它,塞进去又会污染 Slot 布局
(归档内顶层名是要与 manifest 的 roots/layouts 对账的)。
代价是它得跟归档一起搬,README 里已写明。
用 JSON 数组而不是"路径 -> SDDL"的对象:ConvertFrom-Json 出来的是
PSCustomObject,按深度排序还得自己摊平;数组直接有序。
#>
param(
[Parameter(Mandatory = $true)][string]$Path,
[array]$Records = @(),
[string]$Mode = 'Smart',
[bool]$IncludeSacl = $false,
[int]$Errors = 0,
[int]$Scanned = 0
)
$projected = @()
foreach ($record in @($Records)) {
if (-not $record) { continue }
$entry = [ordered]@{
p = [string]$record.p
k = [string]$record.k
}
if ($record.s) { $entry.s = [string]$record.s }
if ($record.o) { $entry.o = [string]$record.o }
if ($record.g) { $entry.g = [string]$record.g }
if ($record.e) { $entry.e = [string]$record.e }
$projected += $entry
}
$payload = [ordered]@{
schemaVersion = 1
tool = 'BakNRet'
capturedAt = (Get-Date).ToString('o')
mode = $Mode
includeSacl = [bool]$IncludeSacl
objectCount = $projected.Count
scannedCount = $Scanned
errorCount = $Errors
records = @($projected)
}
$json = $payload | ConvertTo-Json -Depth 5
return (Write-BaknretAtomicText -Path $Path -Text $json)
}
function Read-BaknretSecuritySidecar {
<#
.SYNOPSIS
读 sidecar;不存在或损坏时返回 $null(调用方据此打"该归档不含安全描述符"的告警)。
#>
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return $null }
try {
$raw = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 -ErrorAction Stop
if ([string]::IsNullOrWhiteSpace($raw)) { return $null }
$parsed = $raw | ConvertFrom-Json -ErrorAction Stop
$records = @()
if (($parsed.PSObject.Properties.Name -contains 'records') -and $parsed.records) {
$records = @($parsed.records)
}
return [pscustomobject]@{
CapturedAt = $parsed.capturedAt
Mode = $parsed.mode
IncludeSacl = [bool]$parsed.includeSacl
ObjectCount = $parsed.objectCount
ErrorCount = $parsed.errorCount
Records = @($records)
}
} catch {
Write-Log "安全描述符文件解析失败:$Path —— $($_.Exception.Message)" -Level WARN
return $null
}
}
function Convert-BaknretSidMap {
<#
.SYNOPSIS
按 SID 映射表改写 SDDL 里的 SID(跨机恢复用)。
.DESCRIPTION
只在**完整的 SID 记号**上替换:`S-1-5-21-1-2-3-1001` 是
`S-1-5-21-1-2-3-10012` 的前缀,直接 -replace 会改坏后者,
所以前后加边界断言(前面不能是数字或 -,后面不能是数字)。
#>
param(
[AllowEmptyString()][string]$Sddl,
[hashtable]$SidMap = @{}
)
$text = [string]$Sddl
if (-not $text -or -not $SidMap -or $SidMap.Count -eq 0) { return $text }
foreach ($old in @($SidMap.Keys)) {
$newSid = [string]$SidMap[$old]
$oldSid = [string]$old
if ([string]::IsNullOrWhiteSpace($oldSid) -or [string]::IsNullOrWhiteSpace($newSid)) { continue }
$pattern = '(?<![0-9-])' + [regex]::Escape($oldSid) + '(?![0-9])'
$text = [regex]::Replace($text, $pattern, $newSid)
}
return $text
}
function Set-BaknretObjectSecurity {
<#
.SYNOPSIS
把一条 SDDL 落到一个对象上。
.DESCRIPTION
从 SDDL 构造 —— SID 原样保留,**不做任何账户名解析**
(`CO` / `OW` 这类占位符不会被翻译成"当前用户")。
三级 Scope:`All` 写属主 + 属组 + DACL;`OwnerAndAccess` 丢下属组(把主组设成
一个不在令牌里的 SID 需要特权,而它对访问判定几乎没有影响,不能因为它把属主一起丢掉);
`AccessOnly` 只写 DACL。真机实测过:SDDL 里 G: 一失败,整次 SetAccessControl 就抛异常,
连 DACL 都落不下去 —— 所以回退链是必需的,不是保守。
原本不 protected 的 DACL 会先 SetAccessRuleProtection($false, $false):
丢掉"继承来的副本",只把显式 ACE 写盘,其余交给父目录重新继承
(父目录此时已经修好了,所以结果与备份时一致,而且保住了活继承语义)。
protected 的 DACL 原样写,连 protected 位一起。
#>
param(
[Parameter(Mandatory = $true)]$Item,
[Parameter(Mandatory = $true)][string]$Sddl,
[ValidateSet('All', 'OwnerAndAccess', 'AccessOnly')][string]$Scope = 'All'
)
$sections = [System.Security.AccessControl.AccessControlSections]::Access
if ($Scope -ne 'AccessOnly') {
if ($Sddl -match 'O:') { $sections = $sections -bor [System.Security.AccessControl.AccessControlSections]::Owner }
if ($Scope -eq 'All' -and $Sddl -match 'G:') { $sections = $sections -bor [System.Security.AccessControl.AccessControlSections]::Group }
}
if ($Item.PSIsContainer) {
$sd = New-Object System.Security.AccessControl.DirectorySecurity
} else {
$sd = New-Object System.Security.AccessControl.FileSecurity
}
$sd.SetSecurityDescriptorSddlForm($Sddl, $sections)
if (-not $sd.AreAccessRulesProtected) {
$sd.SetAccessRuleProtection($false, $false)
}
if ($PSVersionTable.PSEdition -eq 'Core') {
[System.IO.FileSystemAclExtensions]::SetAccessControl($Item, $sd)
} else {
$Item.SetAccessControl($sd)
}
}
function Restore-BaknretSecurity {
<#
.SYNOPSIS
把 sidecar 里属于某个归档项的那部分安全描述符,回放到真实目标路径上。
.DESCRIPTION
只处理 `p` 等于/位于 $ArchiveRoot 之下的记录(一项一棵子树,和其它恢复语义一致)。
顺序很重要:**按深度自顶向下**。父目录先写,子对象的继承才会收敛到原样;
反过来做会被父目录的继承覆盖掉。
原文件在归档里没解出来(被排除、或本来就缺失)时跳过,并计入 Skipped。
返回 [pscustomobject]@{ Total; Applied; OwnerFailed; Skipped; Failed; Failures }。
#>
param(
[Parameter(Mandatory = $true)]$Sidecar,
[Parameter(Mandatory = $true)][string]$ArchiveRoot,
[Parameter(Mandatory = $true)][string]$TargetPath,
[hashtable]$SidMap = @{},
[switch]$WhatIf
)
$result = [pscustomobject]@{
Total = 0
Applied = 0
OwnerFailed = 0
Skipped = 0
Failed = 0
Failures = @()
}
# 写属主需要 SeRestorePrivilege,而且必须**显式启用**:管理员的过滤令牌里它默认是
# disabled,Set-Acl / SetAccessControl 都不会替你打开。没有它,属主会写失败并静默
# 退化成"只恢复 DACL" —— 那恰恰丢掉了这个功能存在的理由(CREATOR OWNER 判给谁)。
Enable-BaknretPrivilege -Name @('SeRestorePrivilege', 'SeBackupPrivilege') | Out-Null
if (-not $Sidecar -or -not $Sidecar.Records) { return $result }
$root = ([string]$ArchiveRoot).Trim([char[]]@('\', '/'))
if ([string]::IsNullOrWhiteSpace($root)) { return $result }
$prefix = "$root\"
$selected = @()
foreach ($record in @($Sidecar.Records)) {
if (-not $record) { continue }
$key = [string]$record.p
if ([string]::IsNullOrWhiteSpace($key)) { continue }
$relative = $null
if ($key -ieq $root) {
$relative = ''
} elseif ($key.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) {
$relative = $key.Substring($prefix.Length)
} else {
continue
}
$selected += [pscustomobject]@{ Relative = $relative; Record = $record }
}
if ($selected.Count -eq 0) { return $result }
$ordered = @($selected | Sort-Object -Property `
@{ Expression = { @(($_.Relative) -split '\\').Count } }, `
@{ Expression = { $_.Relative } })
foreach ($entry in $ordered) {
$target = if ($entry.Relative) { Join-Path $TargetPath $entry.Relative } else { $TargetPath }
$result.Total++
if ($entry.Record.e -or -not $entry.Record.s) { $result.Skipped++; continue }
if (-not (Test-Path -LiteralPath $target)) { $result.Skipped++; continue }
$item = Get-Item -LiteralPath $target -Force -ErrorAction SilentlyContinue
if (-not $item) { $result.Skipped++; continue }
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { $result.Skipped++; continue }
if ($WhatIf) { continue }
$sddl = Convert-BaknretSidMap -Sddl ([string]$entry.Record.s) -SidMap $SidMap
try {
Set-BaknretObjectSecurity -Item $item -Sddl $sddl -Scope All
$result.Applied++
} catch {
$fullError = $_
try {
Set-BaknretObjectSecurity -Item $item -Sddl $sddl -Scope OwnerAndAccess
$result.OwnerFailed++
$result.Failures += ("{0}:属组未恢复,属主与 DACL 已恢复({1})" -f $target, $fullError.Exception.Message)
} catch {
try {
Set-BaknretObjectSecurity -Item $item -Sddl $sddl -Scope AccessOnly
$result.OwnerFailed++
$result.Failures += ("{0}:属主/属组未恢复({1}),已只恢复 DACL" -f $target, $_.Exception.Message)
} catch {
$result.Failed++
$result.Failures += ("{0}:{1}" -f $target, $_.Exception.Message)
}
}
}
}
return $result
}
# ============================================================================
# 配置
# ============================================================================
function Get-BaknretConfig {
<#
.SYNOPSIS
读取 BackupConfig.psd1 并与内置默认值合并。
.DESCRIPTION
配置文件缺失不是错误:直接用默认值,让工具开箱可用。
#>
param([string]$Path)
$defaults = @{
BackupDir = 'Backups'
LogDir = 'logs'
SnapshotDir = 'Backups\snapshots'
SoftwareCatalog = 'SoftwareCatalog.psd1'
CatalogMaxDepth = 5
MinFreeSpaceGB = 8
VerifyArchive = $true
ComputeHash = $false
CompressionLevel = 9
ToolOutput = 'live' # live | quiet
Snapshot = @{ Enabled = $false; KeepCount = 3; KeepDays = 30 }
Encryption = @{ Enabled = $false; PasswordFile = ''; EncryptHeaders = $true }
# 安全描述符(属主 / ACL)的采集与回放。
# Mode Off | Roots | Smart | Full(语义见 Get-BaknretSecurityRecords)
# **默认 Full**:这个功能存在的意义就是不丢权限,正确性优先于体积;
# Smart 是体积优化(靠继承复现的对象不落盘),已在真机上见过
# 它需要处理的"陈旧继承 ACE",判据偏保守,但终究是启发式。
# IncludeSacl 是否连审计规则(SACL)一起存取,需要 SeSecurityPrivilege
# SidMap 跨机恢复时的 SID 映射:@('S-1-5-21-旧-1001' = 'S-1-5-21-新-1001')
# FailOnError 安全描述符写盘失败时,是否把这条备份算作失败(默认只告警)
Security = @{
Mode = 'Full'
IncludeSacl = $false
SidMap = @{}
FailOnError = $false
}
DefaultExcludes = @()
}
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
return $defaults
}
try {
$loaded = Import-BaknretDataFile -Path $Path
} catch {
Write-Log "配置文件读取失败(改用默认值):$Path —— $_" -Level WARN
return $defaults
}
foreach ($key in $loaded.Keys) {
if ($key -in @('Snapshot', 'Encryption', 'Security') -and $loaded[$key] -is [hashtable]) {
$merged = @{}
foreach ($subKey in $defaults[$key].Keys) { $merged[$subKey] = $defaults[$key][$subKey] }
foreach ($subKey in $loaded[$key].Keys) { $merged[$subKey] = $loaded[$key][$subKey] }
$defaults[$key] = $merged
} else {
$defaults[$key] = $loaded[$key]
}
}
return $defaults
}
function Get-BaknretPassword {
<#
.SYNOPSIS
取加密口令:命令行参数 > 环境变量 > 密码文件 > 交互式询问。
.DESCRIPTION
口令**绝不写入仓库**。优先级:
1. -Password(命令行传参,注意会短暂出现在进程列表里)
2. $env:BAKNRET_PASSWORD
3. PasswordFile 的首行(文件必须在仓库之外,脚本只记路径)
4. 交互式询问(仅当 allowPrompt 且当前是交互式会话)
全都拿不到就返回 $null,调用方必须失败退出,绝不能默默写明文归档。
交互式询问用的是 Read-Host -AsSecureString,输入不回显;但它需要真实控制台,
在计划任务/CI 里会把用户晾在那里等输入,所以只在交互式会话里才提示。
#>
param(
[string]$Password,
[string]$PasswordFile,
[switch]$AllowPrompt
)
if ($Password) { return $Password }
if ($env:BAKNRET_PASSWORD) { return $env:BAKNRET_PASSWORD }
if ($PasswordFile -and (Test-Path -LiteralPath $PasswordFile)) {
$line = Get-Content -LiteralPath $PasswordFile -TotalCount 1 -Encoding UTF8 -ErrorAction SilentlyContinue
if ($line) { return $line.Trim() }
}
if ($AllowPrompt) {
# 只有在真的会等人输入时才提示,避免计划任务里静默挂起
$interactive = $true
try { $interactive = -not [System.Console]::IsInputRedirected } catch { $interactive = $false }
if ($interactive) {
Write-Log '需要加密口令,请在弹出的提示里输入(不会回显、不会落盘)' -Level WARN
try {
$secure = Read-Host -Prompt '请输入加密口令' -AsSecureString
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
try {
return [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
} finally {
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
}
} catch {
Write-Log "口令输入失败:$_" -Level ERROR
return $null
}
}
}
return $null
}
Export-ModuleMember -Function @(
'Set-BaknretDebug', 'Start-BaknretLog', 'Stop-BaknretLog', 'Get-BaknretLogPath', 'Write-Log',
'Test-Administrator', 'Get-BaknretFreeSpaceGB',
'ConvertTo-NativeArgumentString', 'Invoke-ExternalCommand', 'Resolve-CompressionTool', 'Get-Optimized7zArgument',
'Split-BaknretToken', 'Remove-BaknretQuote', 'Test-BaknretMarker', 'ConvertFrom-BaknretPatternList',
'ConvertFrom-BackupListLine', 'Test-LiteralPath',
'Get-BaknretRegexExclude', 'Get-BaknretExcludeArgument', 'Split-BaknretPatternScope', 'Merge-BaknretExcludeArgument',
'Resolve-CatalogPath', 'Get-SoftwareCatalog', 'Find-ChildDirectoryByName', 'Format-CatalogName',
'Expand-CatalogPathText', 'Get-ArchiveTopLevelNames',
'Get-BaknretArchiveTopName', 'New-BaknretArchiveItem', 'New-BaknretJunction', 'Remove-BaknretJunction',
'New-BaknretArchiveStaging', 'Remove-BaknretArchiveStaging',
'Get-ItemArchiveName', 'Resolve-BackupEntry', 'Write-BackupEntryPlan', 'Get-BackupBaseName', 'Convert-BackupFileNameToPath',
'Get-FolderSummary',
'Read-BaknretManifest', 'Write-BaknretManifest', 'Sync-BaknretManifestArchive', 'Move-BaknretArchiveIntoPlace',
'Enable-BaknretPrivilege', 'ConvertTo-BaknretWildcardPattern', 'Test-BaknretPathExcluded',
'Get-BaknretAceSignatureList', 'Get-BaknretSecuritySddlWithStale', 'Get-BaknretSecurityRecord', 'Test-BaknretSecurityRecordNeeded', 'Get-BaknretSecurityRecords',
'Write-BaknretAtomicText', 'Save-BaknretSecuritySidecar', 'Read-BaknretSecuritySidecar',
'Convert-BaknretSidMap', 'Set-BaknretObjectSecurity', 'Restore-BaknretSecurity',
'Get-BaknretConfig', 'Get-BaknretPassword'
)