缺陷:Invoke-ExternalCommand 在 DEBUG 级打印整条命令行,而 7z / RAR 只接受命令行 口令(-p<口令>),所以口令必然出现在参数表里。一旦 -Verbose(Backup.ps1 / Restore.ps1 都会因它打开 DEBUG),logs\*.log 里就是明文口令 —— 与 BackupConfig.psd1 和文档里 "口令不落盘、不写进仓库"的承诺直接冲突。 修法:打印前把 -p 参数换成占位符;真正执行的仍然是原参数。在**参数级别**替换而不是 对拼好的命令行做正则 —— 含空格的口令会被引号包起来("-pmy pass"),正则在那种形态上 很容易漏掉,而漏掉的代价是口令明文入日志。 回归断言(零依赖与 Pester 各一份):打开 DEBUG、把日志指向临时目录、带一个哨兵口令 跑一次外部命令,然后读日志文件断言哨兵不在里面、占位符在里面。另加一条"测试的测试": 断言那行 DEBUG 记录确实写进去了 —— 否则前两条会在"压根没记录"时空跑通过。 断言有效性做了红绿证明:把遮蔽改回 $startInfo.Arguments 后断言变红并指名"口令明文 进了日志",还原后转绿。 验收:test.ps1 9/9 全绿;tests\Run-RealSmoke.ps1 4/4 全绿。
3195 lines
131 KiB
PowerShell
3195 lines
131 KiB
PowerShell
<#
|
||
.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 })
|
||
|
||
return [pscustomobject]@{
|
||
FileCount = $files.Count
|
||
TotalSize = ($files | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||
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
|
||
}
|
||
|
||
$temp = "$Path.tmp"
|
||
[System.IO.File]::WriteAllText($temp, $json, $script:LogEncoding)
|
||
|
||
if (Test-Path -LiteralPath $Path) {
|
||
Remove-Item -LiteralPath $Path -Force
|
||
}
|
||
Move-Item -LiteralPath $temp -Destination $Path -Force
|
||
return $Path
|
||
}
|
||
|
||
# ============================================================================
|
||
# 归档原子替换
|
||
# ============================================================================
|
||
|
||
function Move-BaknretArchiveIntoPlace {
|
||
<#
|
||
.SYNOPSIS
|
||
把临时归档原子地替换到最终路径。
|
||
|
||
.DESCRIPTION
|
||
优先用 File.Move(overwrite)(同卷上是 MoveFileEx + REPLACE_EXISTING,
|
||
基本等价于原子替换);不支持时退化为先删后移。
|
||
#>
|
||
param(
|
||
[Parameter(Mandatory = $true)][string]$TempPath,
|
||
[Parameter(Mandatory = $true)][string]$DestinationPath
|
||
)
|
||
|
||
try {
|
||
[System.IO.File]::Move($TempPath, $DestinationPath, $true)
|
||
return
|
||
} catch {
|
||
Write-Log "原子替换失败,退化为先删后移:$_" -Level DEBUG
|
||
}
|
||
|
||
if (Test-Path -LiteralPath $DestinationPath) {
|
||
Remove-Item -LiteralPath $DestinationPath -Force
|
||
}
|
||
Move-Item -LiteralPath $TempPath -Destination $DestinationPath -Force
|
||
}
|
||
|
||
# ============================================================================
|
||
# 安全描述符(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)
|
||
Move-BaknretArchiveIntoPlace -TempPath $temp -DestinationPath $Path
|
||
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'
|
||
)
|