diff --git a/.gitignore b/.gitignore index ce50799..94f8872 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# 归档本体不进版本库(数 GB,且是随时可重建的产物) +# 归档本体不进版本库(数 GB,且是随时可重建的产物) Backups/ # 运行日志 @@ -23,3 +23,6 @@ logs/ *.swp Thumbs.db desktop.ini + +# 单文件构建产物(tools\Build-BakNRetModule.ps1 可随时重建) +dist/ diff --git a/Backup.ps1 b/Backup.ps1 index 6352071..c71d58c 100644 --- a/Backup.ps1 +++ b/Backup.ps1 @@ -84,7 +84,7 @@ if (-not $ConfigPath) { $ConfigPath = Join-Path $PSScriptRoot 'BackupConfig.psd1 # 载入依赖 # ============================================================================ -$modulePath = Join-Path $PSScriptRoot 'Common.psm1' +$modulePath = Join-Path $PSScriptRoot 'BakNRet\BakNRet.psd1' if (-not (Test-Path -LiteralPath $modulePath)) { Write-Error "找不到依赖模块:$modulePath,请确保所有文件在同一目录。" exit 1 @@ -118,7 +118,7 @@ Write-Log ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath if (-not (Test-Administrator)) { Write-Log '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN } -# 同一份备份目录同一时间只允许一个进程操作(见 Common.psm1 的「运行锁」一节)。 +# 同一份备份目录同一时间只允许一个进程操作(见 BakNRet 模块的「运行锁」一节)。 # -DryRun 不取锁:它一个字节都不写,没必要被正在跑的备份挡在外面。 $runLock = $null if (-not $DryRun) { diff --git a/BakNRet/BakNRet.psd1 b/BakNRet/BakNRet.psd1 new file mode 100644 index 0000000..caedd6d --- /dev/null +++ b/BakNRet/BakNRet.psd1 @@ -0,0 +1,93 @@ +<# + 模块清单。 + + 为什么需要它:把"这个模块对外提供什么"变成一份可读的契约。这里 FunctionsToExport 是 + **显式白名单**(官方性能指南明确要求不用通配符;PSUseToExportFieldsInManifest 也是恒开 + 的规则、关不掉)。名字少写一个,对应函数就不会被导出 —— 这是有意的:宁可导入方报 + "找不到命令",也不要静默少一个函数。 + + CompatiblePSEditions 声明"桌面版与核心版都能导入":本模块兼容 Windows PowerShell 5.1 + 与 PowerShell 7.x。代价是放弃 PS 4.0 及以下,那是刻意的。 +#> +@{ + RootModule = 'BakNRet.psm1' + ModuleVersion = '1.0.0' + GUID = '9bc892ed-f852-4468-b1e7-d71a91cf2913' + Author = 'Shuery' + Description = 'BakNRet 公共模块:日志、外部命令调用、清单与名录解析、归档命名与暂存、manifest、安全描述符。兼容 Windows PowerShell 5.1 与 PowerShell 7.x。' + PowerShellVersion = '5.1' + CompatiblePSEditions = @('Desktop', 'Core') + + FunctionsToExport = @( + 'Enter-BaknretRunLock', + 'Exit-BaknretRunLock', + 'Get-BaknretRunLockPath', + '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' + ) + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + + PrivateData = @{ + PSData = @{ Tags = @('Backup', 'Restore', '7zip', 'Windows', 'PSEdition_Desktop', 'PSEdition_Core') } + } +} + diff --git a/BakNRet/BakNRet.psm1 b/BakNRet/BakNRet.psm1 new file mode 100644 index 0000000..a4bd714 --- /dev/null +++ b/BakNRet/BakNRet.psm1 @@ -0,0 +1,200 @@ +<# +.SYNOPSIS + BakNRet —— 备份 / 恢复脚本的公共功能模块。 + +.DESCRIPTION + 提供日志(控制台 + 落盘)、外部命令调用(可取得真实退出码)、 + BackupList.txt 语法解析、归档命名与逆向解析、目录摘要、manifest 读写、 + 磁盘剩余空间查询等公共能力。 + + 兼容 Windows PowerShell 5.1 与 PowerShell 7.x: + * 不使用 ?? / 三元运算符 / Join-String / -AsHashtable 等 6.0+ 语法; + * 不使用 ProcessStartInfo.ArgumentList(5.1 上不存在),改为自行构造命令行。 + + 模块内出现的备份清单语法(BackupList.txt 每一行): + + [+|-] <软件名 或 绝对路径> [修饰符...] [# 说明] + [:: ] [:- <模式>[,...]] [:+ <包含项>[,...]] + [:encrypt | :!encrypt] [@ =''] + + 标记(必须是独立的空白分隔记号,前后都要有空格): + + 仅备份,不恢复(Restore.ps1 跳过) + - 仅恢复,不备份(Backup.ps1 跳过) + :: 覆盖 Path,等价于 `@ Path='...'` + :- 排除模式,等价于 `@ Exclude='...'` + :+ 追加包含项(<归档内相对路径>:<宿主机绝对路径>),等价于 `@ Include='...'` + :encrypt 该条目加密(`@ Encrypt='$true'`) + :!encrypt 该条目不加密(`@ Encrypt='$false'`) + @ Key='值' 覆盖 SoftwareCatalog.psd1 里的同名默认字段 + + 兼容的历史写法:`@encrypt` / `@!encrypt` / `@pathname` / `@root=<名>`, + 以及用双引号包住路径或模式值。 + + 归档内布局(SoftwareCatalog.psd1 的 Slot 是包内的一层目录): + 软件名条目 -> \<该 Path 的内容>(Path 是文件时就是名为 的文件) + 手写路径 -> <路径末级名>\...(历史布局,不变) +#> + + +# ---------------------------------------------------------------------------- +# 加载器:下面这份点源顺序是**唯一**一份顺序声明。 +# +# 每个函数一个文件,文件名 = 函数名;要合回单个 .psm1(发布形态,或做代码签名时需要), +# 跑 tools\Build-BakNRetModule.ps1 —— 它从本文件里读出顺序,所以顺序不需要维护两遍。 +# ---------------------------------------------------------------------------- + +. (Join-Path $PSScriptRoot 'Private\State.ps1') + +# ---- 日志 ---- +. (Join-Path $PSScriptRoot 'Public\Set-BaknretDebug.ps1') +. (Join-Path $PSScriptRoot 'Public\Start-BaknretLog.ps1') +. (Join-Path $PSScriptRoot 'Public\Stop-BaknretLog.ps1') +. (Join-Path $PSScriptRoot 'Public\Get-BaknretLogPath.ps1') +. (Join-Path $PSScriptRoot 'Public\Write-Log.ps1') + +# ---- 运行锁 ---- +. (Join-Path $PSScriptRoot 'Public\Get-BaknretRunLockPath.ps1') +. (Join-Path $PSScriptRoot 'Public\Enter-BaknretRunLock.ps1') +. (Join-Path $PSScriptRoot 'Public\Exit-BaknretRunLock.ps1') + +# ---- 环境 ---- +. (Join-Path $PSScriptRoot 'Public\Test-Administrator.ps1') +. (Join-Path $PSScriptRoot 'Public\Get-BaknretFreeSpaceGB.ps1') + +# ---- 外部命令 ---- +. (Join-Path $PSScriptRoot 'Public\ConvertTo-NativeArgumentString.ps1') +. (Join-Path $PSScriptRoot 'Public\Invoke-ExternalCommand.ps1') +. (Join-Path $PSScriptRoot 'Public\Resolve-CompressionTool.ps1') +. (Join-Path $PSScriptRoot 'Public\Get-Optimized7zArgument.ps1') + +# ---- BackupList.txt 解析 ---- +. (Join-Path $PSScriptRoot 'Public\Split-BaknretToken.ps1') +. (Join-Path $PSScriptRoot 'Public\Remove-BaknretQuote.ps1') +. (Join-Path $PSScriptRoot 'Public\Test-BaknretMarker.ps1') +. (Join-Path $PSScriptRoot 'Public\ConvertFrom-BaknretPatternList.ps1') +. (Join-Path $PSScriptRoot 'Public\ConvertFrom-BackupListLine.ps1') +. (Join-Path $PSScriptRoot 'Public\Test-LiteralPath.ps1') +. (Join-Path $PSScriptRoot 'Public\Get-BaknretRegexExclude.ps1') +. (Join-Path $PSScriptRoot 'Public\Get-BaknretExcludeArgument.ps1') +. (Join-Path $PSScriptRoot 'Public\Split-BaknretPatternScope.ps1') +. (Join-Path $PSScriptRoot 'Public\Merge-BaknretExcludeArgument.ps1') + +# ---- 软件名录(SoftwareCatalog.psd1) ---- +. (Join-Path $PSScriptRoot 'Public\Resolve-CatalogPath.ps1') +. (Join-Path $PSScriptRoot 'Public\Format-CatalogName.ps1') +. (Join-Path $PSScriptRoot 'Private\Test-BaknretMapKey.ps1') +. (Join-Path $PSScriptRoot 'Private\Get-BaknretMapValue.ps1') +. (Join-Path $PSScriptRoot 'Private\Get-BaknretMapKeys.ps1') +. (Join-Path $PSScriptRoot 'Public\Expand-CatalogPathText.ps1') +. (Join-Path $PSScriptRoot 'Private\Import-BaknretDataFile.ps1') +. (Join-Path $PSScriptRoot 'Public\Get-SoftwareCatalog.ps1') +. (Join-Path $PSScriptRoot 'Public\Get-ArchiveTopLevelNames.ps1') +. (Join-Path $PSScriptRoot 'Public\Find-ChildDirectoryByName.ps1') + +# ---- 归档命名与路径还原 ---- +. (Join-Path $PSScriptRoot 'Public\Get-ItemArchiveName.ps1') +. (Join-Path $PSScriptRoot 'Public\Get-BaknretArchiveTopName.ps1') +. (Join-Path $PSScriptRoot 'Public\New-BaknretArchiveItem.ps1') +. (Join-Path $PSScriptRoot 'Public\New-BaknretJunction.ps1') +. (Join-Path $PSScriptRoot 'Public\Remove-BaknretJunction.ps1') +. (Join-Path $PSScriptRoot 'Public\New-BaknretArchiveStaging.ps1') +. (Join-Path $PSScriptRoot 'Public\Remove-BaknretArchiveStaging.ps1') +. (Join-Path $PSScriptRoot 'Public\Resolve-BackupEntry.ps1') +. (Join-Path $PSScriptRoot 'Public\Write-BackupEntryPlan.ps1') +. (Join-Path $PSScriptRoot 'Public\Get-BackupBaseName.ps1') +. (Join-Path $PSScriptRoot 'Public\Convert-BackupFileNameToPath.ps1') +. (Join-Path $PSScriptRoot 'Public\Get-FolderSummary.ps1') + +# ---- manifest.json ---- +. (Join-Path $PSScriptRoot 'Public\Read-BaknretManifest.ps1') +. (Join-Path $PSScriptRoot 'Public\Sync-BaknretManifestArchive.ps1') +. (Join-Path $PSScriptRoot 'Public\Write-BaknretManifest.ps1') + +# ---- 归档原子替换 ---- +. (Join-Path $PSScriptRoot 'Public\Move-BaknretArchiveIntoPlace.ps1') + +# ---- 安全描述符(NTFS 属主 / ACL) ---- +. (Join-Path $PSScriptRoot 'Public\Enable-BaknretPrivilege.ps1') +. (Join-Path $PSScriptRoot 'Public\ConvertTo-BaknretWildcardPattern.ps1') +. (Join-Path $PSScriptRoot 'Public\Test-BaknretPathExcluded.ps1') +. (Join-Path $PSScriptRoot 'Public\Get-BaknretAceSignatureList.ps1') +. (Join-Path $PSScriptRoot 'Public\Get-BaknretSecuritySddlWithStale.ps1') +. (Join-Path $PSScriptRoot 'Public\Get-BaknretSecurityRecord.ps1') +. (Join-Path $PSScriptRoot 'Public\Test-BaknretSecurityRecordNeeded.ps1') +. (Join-Path $PSScriptRoot 'Public\Get-BaknretSecurityRecords.ps1') +. (Join-Path $PSScriptRoot 'Public\Write-BaknretAtomicText.ps1') +. (Join-Path $PSScriptRoot 'Public\Save-BaknretSecuritySidecar.ps1') +. (Join-Path $PSScriptRoot 'Public\Read-BaknretSecuritySidecar.ps1') +. (Join-Path $PSScriptRoot 'Public\Convert-BaknretSidMap.ps1') +. (Join-Path $PSScriptRoot 'Public\Set-BaknretObjectSecurity.ps1') +. (Join-Path $PSScriptRoot 'Public\Restore-BaknretSecurity.ps1') + +# ---- 配置 ---- +. (Join-Path $PSScriptRoot 'Public\Get-BaknretConfig.ps1') +. (Join-Path $PSScriptRoot 'Public\Get-BaknretPassword.ps1') + +Export-ModuleMember -Function @( + 'Enter-BaknretRunLock', + 'Exit-BaknretRunLock', + 'Get-BaknretRunLockPath', + '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' +) + diff --git a/BakNRet/Private/Get-BaknretMapKeys.ps1 b/BakNRet/Private/Get-BaknretMapKeys.ps1 new file mode 100644 index 0000000..3b9359b --- /dev/null +++ b/BakNRet/Private/Get-BaknretMapKeys.ps1 @@ -0,0 +1,7 @@ +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) +} diff --git a/BakNRet/Private/Get-BaknretMapValue.ps1 b/BakNRet/Private/Get-BaknretMapValue.ps1 new file mode 100644 index 0000000..22fa8d7 --- /dev/null +++ b/BakNRet/Private/Get-BaknretMapValue.ps1 @@ -0,0 +1,11 @@ +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 +} diff --git a/BakNRet/Private/Import-BaknretDataFile.ps1 b/BakNRet/Private/Import-BaknretDataFile.ps1 new file mode 100644 index 0000000..ee62a35 --- /dev/null +++ b/BakNRet/Private/Import-BaknretDataFile.ps1 @@ -0,0 +1,29 @@ +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() + } +} diff --git a/BakNRet/Private/State.ps1 b/BakNRet/Private/State.ps1 new file mode 100644 index 0000000..fd69bb0 --- /dev/null +++ b/BakNRet/Private/State.ps1 @@ -0,0 +1,22 @@ +# 模块级状态。 +# +# 为什么集中在一个文件里:这些变量原先散在 3000 行里,"谁在什么时候改了它"没有答案, +# 而重构时任何一次搬动都可能踩到看不见的耦合。集中之后至少它的全貌是可见的。 +# +# 注意 `$script:` 在这里指的是**模块**的脚本作用域:模块内 dot-source 的文件共享同一个 +# 模块作用域(实测确认过),所以"加载器按顺序点源这些文件"与"点源一个大文件"等价。 + +$script:LogConfig = @{ + TimeFormat = 'yyyy-MM-dd HH:mm:ss' + EnableDebug = $false + FilePath = $null +} + +$script:LogEncoding = [System.Text.UTF8Encoding]::new($false) + +$script:CatalogCache = @{} + +$script:CatalogExpressionCache = @{} + +$script:BaknretPrivilegeState = @{} + diff --git a/BakNRet/Private/Test-BaknretMapKey.ps1 b/BakNRet/Private/Test-BaknretMapKey.ps1 new file mode 100644 index 0000000..ebebe09 --- /dev/null +++ b/BakNRet/Private/Test-BaknretMapKey.ps1 @@ -0,0 +1,7 @@ +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 +} diff --git a/BakNRet/Public/Convert-BackupFileNameToPath.ps1 b/BakNRet/Public/Convert-BackupFileNameToPath.ps1 new file mode 100644 index 0000000..4f5dcae --- /dev/null +++ b/BakNRet/Public/Convert-BackupFileNameToPath.ps1 @@ -0,0 +1,31 @@ +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 + } +} diff --git a/BakNRet/Public/Convert-BaknretSidMap.ps1 b/BakNRet/Public/Convert-BaknretSidMap.ps1 new file mode 100644 index 0000000..f272ff5 --- /dev/null +++ b/BakNRet/Public/Convert-BaknretSidMap.ps1 @@ -0,0 +1,28 @@ +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 = '(?) + 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 + } + } +} diff --git a/BakNRet/Public/ConvertFrom-BaknretPatternList.ps1 b/BakNRet/Public/ConvertFrom-BaknretPatternList.ps1 new file mode 100644 index 0000000..ecb9fb6 --- /dev/null +++ b/BakNRet/Public/ConvertFrom-BaknretPatternList.ps1 @@ -0,0 +1,15 @@ +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) +} diff --git a/BakNRet/Public/ConvertTo-BaknretWildcardPattern.ps1 b/BakNRet/Public/ConvertTo-BaknretWildcardPattern.ps1 new file mode 100644 index 0000000..89a4fa9 --- /dev/null +++ b/BakNRet/Public/ConvertTo-BaknretWildcardPattern.ps1 @@ -0,0 +1,18 @@ +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 +} diff --git a/BakNRet/Public/ConvertTo-NativeArgumentString.ps1 b/BakNRet/Public/ConvertTo-NativeArgumentString.ps1 new file mode 100644 index 0000000..1bbd001 --- /dev/null +++ b/BakNRet/Public/ConvertTo-NativeArgumentString.ps1 @@ -0,0 +1,54 @@ +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 ' ') +} diff --git a/BakNRet/Public/Enable-BaknretPrivilege.ps1 b/BakNRet/Public/Enable-BaknretPrivilege.ps1 new file mode 100644 index 0000000..148ea11 --- /dev/null +++ b/BakNRet/Public/Enable-BaknretPrivilege.ps1 @@ -0,0 +1,96 @@ +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 +} diff --git a/BakNRet/Public/Enter-BaknretRunLock.ps1 b/BakNRet/Public/Enter-BaknretRunLock.ps1 new file mode 100644 index 0000000..f030727 --- /dev/null +++ b/BakNRet/Public/Enter-BaknretRunLock.ps1 @@ -0,0 +1,47 @@ +function Enter-BaknretRunLock { + <# + .SYNOPSIS + 取得"同一份备份目录同一时间只允许一个进程操作"的锁;拿不到时返回 $null。 + + .DESCRIPTION + 拿不到就直接返回 $null 交给调用方明确失败,**不等待**:单个条目压缩可能十几分钟, + "等它跑完"对用户来说和挂住没区别,不如直接说清楚是谁占着。 + + 返回的是一个已打开的文件流。**不需要刻意释放**:进程退出(含 exit)时句柄由系统 + 关闭,锁随之释放。调用方仍应显式调 Exit-BaknretRunLock,让锁的覆盖范围一眼可见。 + #> + param([Parameter(Mandatory = $true)][string]$Directory) + + if (-not (Test-Path -LiteralPath $Directory)) { + New-Item -ItemType Directory -Path $Directory -Force | Out-Null + } + + $path = Get-BaknretRunLockPath -Directory $Directory + try { + $stream = [System.IO.File]::Open( + $path, + [System.IO.FileMode]::OpenOrCreate, + [System.IO.FileAccess]::ReadWrite, + [System.IO.FileShare]::None) + } catch { + # 不能只写 `catch [System.IO.IOException]`:PowerShell 会把 .NET 方法抛出的异常包成 + # MethodInvocationException,按内层类型做的 catch 接不住,于是锁被占用时会直接抛出去, + # 而不是按约定返回 $null 让调用方明确失败(实测踩到,被自己的断言逮住)。 + # 这里沿 InnerException 链找那个 IOException;不是它就把原异常抛回去(例如目录不可写 + # 是 UnauthorizedAccessException,那是真错误,不该伪装成"另一次运行在进行中")。 + $inner = $_.Exception + while ($inner -and $inner -isnot [System.IO.IOException]) { $inner = $inner.InnerException } + if (-not $inner) { throw } + + Write-Log ("运行锁不可用({0}):{1}" -f $inner.GetType().Name, $inner.Message) -Level DEBUG + return $null + } + + # 写点线索进去:"到底是谁占着"这个问题不该靠猜 + $info = 'pid={0}; started={1:o}; host={2}; user={3}' -f $PID, (Get-Date), $env:COMPUTERNAME, $env:USERNAME + $bytes = [System.Text.Encoding]::UTF8.GetBytes($info) + $stream.SetLength(0) + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush() + return $stream +} diff --git a/BakNRet/Public/Exit-BaknretRunLock.ps1 b/BakNRet/Public/Exit-BaknretRunLock.ps1 new file mode 100644 index 0000000..5654917 --- /dev/null +++ b/BakNRet/Public/Exit-BaknretRunLock.ps1 @@ -0,0 +1,14 @@ +function Exit-BaknretRunLock { + <# + .SYNOPSIS + 释放运行锁。锁文件本身留着 —— 它的内容是最后一次持有者的线索,删不删都无所谓。 + #> + param($Lock) + + if (-not $Lock) { return } + try { + $Lock.Dispose() + } catch { + Write-Log "释放运行锁失败(进程退出时会自动释放):$_" -Level WARN + } +} diff --git a/BakNRet/Public/Expand-CatalogPathText.ps1 b/BakNRet/Public/Expand-CatalogPathText.ps1 new file mode 100644 index 0000000..a5b4abf --- /dev/null +++ b/BakNRet/Public/Expand-CatalogPathText.ps1 @@ -0,0 +1,58 @@ +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 +} diff --git a/BakNRet/Public/Find-ChildDirectoryByName.ps1 b/BakNRet/Public/Find-ChildDirectoryByName.ps1 new file mode 100644 index 0000000..878ca6b --- /dev/null +++ b/BakNRet/Public/Find-ChildDirectoryByName.ps1 @@ -0,0 +1,27 @@ +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 @() + } +} diff --git a/BakNRet/Public/Format-CatalogName.ps1 b/BakNRet/Public/Format-CatalogName.ps1 new file mode 100644 index 0000000..b99e8e3 --- /dev/null +++ b/BakNRet/Public/Format-CatalogName.ps1 @@ -0,0 +1,20 @@ +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() +} diff --git a/BakNRet/Public/Get-ArchiveTopLevelNames.ps1 b/BakNRet/Public/Get-ArchiveTopLevelNames.ps1 new file mode 100644 index 0000000..8375374 --- /dev/null +++ b/BakNRet/Public/Get-ArchiveTopLevelNames.ps1 @@ -0,0 +1,45 @@ +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 +} diff --git a/BakNRet/Public/Get-BackupBaseName.ps1 b/BakNRet/Public/Get-BackupBaseName.ps1 new file mode 100644 index 0000000..a758589 --- /dev/null +++ b/BakNRet/Public/Get-BackupBaseName.ps1 @@ -0,0 +1,43 @@ +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 +} diff --git a/BakNRet/Public/Get-BaknretAceSignatureList.ps1 b/BakNRet/Public/Get-BaknretAceSignatureList.ps1 new file mode 100644 index 0000000..d2274c6 --- /dev/null +++ b/BakNRet/Public/Get-BaknretAceSignatureList.ps1 @@ -0,0 +1,22 @@ +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 +} diff --git a/BakNRet/Public/Get-BaknretArchiveTopName.ps1 b/BakNRet/Public/Get-BaknretArchiveTopName.ps1 new file mode 100644 index 0000000..0710f2b --- /dev/null +++ b/BakNRet/Public/Get-BaknretArchiveTopName.ps1 @@ -0,0 +1,10 @@ +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) +} diff --git a/BakNRet/Public/Get-BaknretConfig.ps1 b/BakNRet/Public/Get-BaknretConfig.ps1 new file mode 100644 index 0000000..3edc2e2 --- /dev/null +++ b/BakNRet/Public/Get-BaknretConfig.ps1 @@ -0,0 +1,64 @@ +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 +} diff --git a/BakNRet/Public/Get-BaknretExcludeArgument.ps1 b/BakNRet/Public/Get-BaknretExcludeArgument.ps1 new file mode 100644 index 0000000..4478509 --- /dev/null +++ b/BakNRet/Public/Get-BaknretExcludeArgument.ps1 @@ -0,0 +1,61 @@ +function Get-BaknretExcludeArgument { + <# + .SYNOPSIS + 把一个归档项的模式列表翻译成 7z 的 `-x!` / `-xr!` 参数。 + + .DESCRIPTION + 传进来的模式**已经按项分配好**(见 Split-BaknretPatternScope),因此这里 + 拿到的模式一律是"相对该项归档根"的: + + * `<相对路径>` -> `-x!\<相对路径>`(锚定在归档根) + * `!<通配>` -> `-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 } +} diff --git a/BakNRet/Public/Get-BaknretFreeSpaceGB.ps1 b/BakNRet/Public/Get-BaknretFreeSpaceGB.ps1 new file mode 100644 index 0000000..e00c58f --- /dev/null +++ b/BakNRet/Public/Get-BaknretFreeSpaceGB.ps1 @@ -0,0 +1,28 @@ +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 + } +} diff --git a/BakNRet/Public/Get-BaknretLogPath.ps1 b/BakNRet/Public/Get-BaknretLogPath.ps1 new file mode 100644 index 0000000..cd68b2a --- /dev/null +++ b/BakNRet/Public/Get-BaknretLogPath.ps1 @@ -0,0 +1,4 @@ +function Get-BaknretLogPath { + <# .SYNOPSIS 返回当前日志文件路径(未启用时返回 $null)。 #> + return $script:LogConfig.FilePath +} diff --git a/BakNRet/Public/Get-BaknretPassword.ps1 b/BakNRet/Public/Get-BaknretPassword.ps1 new file mode 100644 index 0000000..aceb5fd --- /dev/null +++ b/BakNRet/Public/Get-BaknretPassword.ps1 @@ -0,0 +1,54 @@ +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 +} diff --git a/BakNRet/Public/Get-BaknretRegexExclude.ps1 b/BakNRet/Public/Get-BaknretRegexExclude.ps1 new file mode 100644 index 0000000..2cd9aaf --- /dev/null +++ b/BakNRet/Public/Get-BaknretRegexExclude.ps1 @@ -0,0 +1,73 @@ +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 } +} diff --git a/BakNRet/Public/Get-BaknretRunLockPath.ps1 b/BakNRet/Public/Get-BaknretRunLockPath.ps1 new file mode 100644 index 0000000..e357548 --- /dev/null +++ b/BakNRet/Public/Get-BaknretRunLockPath.ps1 @@ -0,0 +1,5 @@ +function Get-BaknretRunLockPath { + <# .SYNOPSIS 运行锁文件的位置(放在备份目录里,与它保护的账本同处)。 #> + param([Parameter(Mandatory = $true)][string]$Directory) + return (Join-Path $Directory '.baknret.lock') +} diff --git a/BakNRet/Public/Get-BaknretSecurityRecord.ps1 b/BakNRet/Public/Get-BaknretSecurityRecord.ps1 new file mode 100644 index 0000000..2429439 --- /dev/null +++ b/BakNRet/Public/Get-BaknretSecurityRecord.ps1 @@ -0,0 +1,98 @@ +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 +} diff --git a/BakNRet/Public/Get-BaknretSecurityRecords.ps1 b/BakNRet/Public/Get-BaknretSecurityRecords.ps1 new file mode 100644 index 0000000..e1fb8e2 --- /dev/null +++ b/BakNRet/Public/Get-BaknretSecurityRecords.ps1 @@ -0,0 +1,124 @@ +function Get-BaknretSecurityRecords { + <# + .SYNOPSIS + 采集一组归档项的安全描述符,键是**归档内相对路径**(`\…`)。 + + .DESCRIPTION + 键用归档内路径而不是宿主机路径:目标机器上 `%UserProfile%` 会变、名录的前缀补全 + (legendary -> legendary_2.0.4)也会变,只有归档内相对路径在两端是同一个坐标系。 + + 遍历用显式栈,并且**跳过 reparse point**:PS 5.1 的 Get-ChildItem -Recurse 会 + 跟着 junction 无限转;scoop 的 `apps\\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 + } +} diff --git a/BakNRet/Public/Get-BaknretSecuritySddlWithStale.ps1 b/BakNRet/Public/Get-BaknretSecuritySddlWithStale.ps1 new file mode 100644 index 0000000..a5c5fda --- /dev/null +++ b/BakNRet/Public/Get-BaknretSecuritySddlWithStale.ps1 @@ -0,0 +1,70 @@ +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) +} diff --git a/BakNRet/Public/Get-FolderSummary.ps1 b/BakNRet/Public/Get-FolderSummary.ps1 new file mode 100644 index 0000000..3cb8d08 --- /dev/null +++ b/BakNRet/Public/Get-FolderSummary.ps1 @@ -0,0 +1,35 @@ +function Get-FolderSummary { + <# + .SYNOPSIS + 统计目录/文件的文件数、总大小与最新修改时间。 + + .DESCRIPTION + LatestModifiedTime 取**包含目录在内**的所有条目的最大值: + 目录的 LastWriteTime 会在子项增删时更新,因此删掉文件也能被察觉。 + #> + param([Parameter(Mandatory = $true)][string]$FolderPath) + + try { + $items = @(Get-ChildItem -LiteralPath $FolderPath -Recurse -Force -ErrorAction SilentlyContinue) + $files = @($items | Where-Object { -not $_.PSIsContainer }) + + # 空目录时 Measure-Object 的 .Sum 是 $null 而不是 0(7.x 与 5.1 实测都一样)。 + # 这个 $null 会一路传到备份前的空间守卫:$null / 1GB 得 0,而守卫判的是 -gt 0, + # 于是"空间不够"时不再拦截 —— 静默失效。所以在这里就把 0 补上。 + $totalSize = ($files | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum + if ($null -eq $totalSize) { $totalSize = 0 } + + return [pscustomobject]@{ + FileCount = $files.Count + TotalSize = [long]$totalSize + LatestModifiedTime = ($items | Measure-Object -Property LastWriteTime -Maximum -ErrorAction SilentlyContinue).Maximum + } + } catch { + Write-Log "无法读取文件夹摘要:$FolderPath" -Level WARN + return [pscustomobject]@{ + FileCount = 0 + TotalSize = 0 + LatestModifiedTime = (Get-Item -LiteralPath $FolderPath -ErrorAction SilentlyContinue).LastWriteTime + } + } +} diff --git a/BakNRet/Public/Get-ItemArchiveName.ps1 b/BakNRet/Public/Get-ItemArchiveName.ps1 new file mode 100644 index 0000000..22d6ec7 --- /dev/null +++ b/BakNRet/Public/Get-ItemArchiveName.ps1 @@ -0,0 +1,40 @@ +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 +} diff --git a/BakNRet/Public/Get-Optimized7zArgument.ps1 b/BakNRet/Public/Get-Optimized7zArgument.ps1 new file mode 100644 index 0000000..32bbdb0 --- /dev/null +++ b/BakNRet/Public/Get-Optimized7zArgument.ps1 @@ -0,0 +1,59 @@ +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 + } +} diff --git a/BakNRet/Public/Get-SoftwareCatalog.ps1 b/BakNRet/Public/Get-SoftwareCatalog.ps1 new file mode 100644 index 0000000..cb379f5 --- /dev/null +++ b/BakNRet/Public/Get-SoftwareCatalog.ps1 @@ -0,0 +1,208 @@ +function Get-SoftwareCatalog { + <# + .SYNOPSIS + 载入"软件名 -> Slot 组"名录。 + + .DESCRIPTION + 新结构(SoftwareCatalog.psd1): + + @{ + <软件名> = @{ + = @{ + Path = '宿主机绝对路径' + Exclude = '!*Cache,Default\Extensions' # 可选 + Include = 'Modules:D:\extra\ps-modules' # 可选 + Encrypt = $true # 可选,默认 $false + Description = '这个 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' 格式不对:应写成 @{ = @{ 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 +} diff --git a/BakNRet/Public/Invoke-ExternalCommand.ps1 b/BakNRet/Public/Invoke-ExternalCommand.ps1 new file mode 100644 index 0000000..66afe71 --- /dev/null +++ b/BakNRet/Public/Invoke-ExternalCommand.ps1 @@ -0,0 +1,49 @@ +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() + } +} diff --git a/BakNRet/Public/Merge-BaknretExcludeArgument.ps1 b/BakNRet/Public/Merge-BaknretExcludeArgument.ps1 new file mode 100644 index 0000000..d13dece --- /dev/null +++ b/BakNRet/Public/Merge-BaknretExcludeArgument.ps1 @@ -0,0 +1,19 @@ +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) +} diff --git a/BakNRet/Public/Move-BaknretArchiveIntoPlace.ps1 b/BakNRet/Public/Move-BaknretArchiveIntoPlace.ps1 new file mode 100644 index 0000000..c4bef60 --- /dev/null +++ b/BakNRet/Public/Move-BaknretArchiveIntoPlace.ps1 @@ -0,0 +1,34 @@ +function Move-BaknretArchiveIntoPlace { + <# + .SYNOPSIS + 把临时归档原子地替换到最终路径。 + + .DESCRIPTION + 优先用 File.Move(overwrite)(同卷上是 MoveFileEx + REPLACE_EXISTING, + 基本等价于原子替换);不支持时退化为先删后移。 + #> + param( + [Parameter(Mandatory = $true)][string]$TempPath, + [Parameter(Mandatory = $true)][string]$DestinationPath + ) + + if (-not (Test-Path -LiteralPath $DestinationPath)) { + Move-Item -LiteralPath $TempPath -Destination $DestinationPath -Force + return + } + + try { + # 7.x:单次原子替换(MoveFileEx + REPLACE_EXISTING) + [System.IO.File]::Move($TempPath, $DestinationPath, $true) + return + } catch { + Write-Log "File.Move(overwrite) 不可用(5.1 没有这个重载),改用 File.Replace:$_" -Level DEBUG + } + + # 5.1 走的这条。以前是"先删后移"—— 中途失败会让目标文件消失(旧归档没了、新归档还在 + # .tmp 里)。File.Replace 走 ReplaceFile API,在 .NET Framework 上同样可用:要么换成 + # 新内容、要么保持旧内容,两个都不会消失。 + # 第三个参数必须传 [NullString]::Value —— PowerShell 会把 $null 转成空串,于是 Replace + # 报"路径为空"(两个版本实测都这样)。 + [System.IO.File]::Replace($TempPath, $DestinationPath, [NullString]::Value) +} diff --git a/BakNRet/Public/New-BaknretArchiveItem.ps1 b/BakNRet/Public/New-BaknretArchiveItem.ps1 new file mode 100644 index 0000000..280d086 --- /dev/null +++ b/BakNRet/Public/New-BaknretArchiveItem.ps1 @@ -0,0 +1,40 @@ +function New-BaknretArchiveItem { + <# + .SYNOPSIS + 构造一个"归档项":宿主机上的一个目录 / 文件,对应归档内的一条路径。 + + .DESCRIPTION + 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) + } +} diff --git a/BakNRet/Public/New-BaknretArchiveStaging.ps1 b/BakNRet/Public/New-BaknretArchiveStaging.ps1 new file mode 100644 index 0000000..0f075f1 --- /dev/null +++ b/BakNRet/Public/New-BaknretArchiveStaging.ps1 @@ -0,0 +1,69 @@ +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 + } +} diff --git a/BakNRet/Public/New-BaknretJunction.ps1 b/BakNRet/Public/New-BaknretJunction.ps1 new file mode 100644 index 0000000..2adddb2 --- /dev/null +++ b/BakNRet/Public/New-BaknretJunction.ps1 @@ -0,0 +1,21 @@ +function New-BaknretJunction { + <# + .SYNOPSIS + 建一个 junction;失败时抛异常(调用方决定降级还是报错)。 + + .DESCRIPTION + 恢复时用它做"零拷贝落地":把 `<目标父目录>\` 建成指向真实目标目录的 + 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 +} diff --git a/BakNRet/Public/Read-BaknretManifest.ps1 b/BakNRet/Public/Read-BaknretManifest.ps1 new file mode 100644 index 0000000..78f6bbb --- /dev/null +++ b/BakNRet/Public/Read-BaknretManifest.ps1 @@ -0,0 +1,45 @@ +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 + } +} diff --git a/BakNRet/Public/Read-BaknretSecuritySidecar.ps1 b/BakNRet/Public/Read-BaknretSecuritySidecar.ps1 new file mode 100644 index 0000000..23288a9 --- /dev/null +++ b/BakNRet/Public/Read-BaknretSecuritySidecar.ps1 @@ -0,0 +1,32 @@ +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 + } +} diff --git a/BakNRet/Public/Remove-BaknretArchiveStaging.ps1 b/BakNRet/Public/Remove-BaknretArchiveStaging.ps1 new file mode 100644 index 0000000..f43136e --- /dev/null +++ b/BakNRet/Public/Remove-BaknretArchiveStaging.ps1 @@ -0,0 +1,28 @@ +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 +} diff --git a/BakNRet/Public/Remove-BaknretJunction.ps1 b/BakNRet/Public/Remove-BaknretJunction.ps1 new file mode 100644 index 0000000..6b16b75 --- /dev/null +++ b/BakNRet/Public/Remove-BaknretJunction.ps1 @@ -0,0 +1,15 @@ +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 + } +} diff --git a/BakNRet/Public/Remove-BaknretQuote.ps1 b/BakNRet/Public/Remove-BaknretQuote.ps1 new file mode 100644 index 0000000..2f3e92a --- /dev/null +++ b/BakNRet/Public/Remove-BaknretQuote.ps1 @@ -0,0 +1,17 @@ +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 +} diff --git a/BakNRet/Public/Resolve-BackupEntry.ps1 b/BakNRet/Public/Resolve-BackupEntry.ps1 new file mode 100644 index 0000000..ffdef99 --- /dev/null +++ b/BakNRet/Public/Resolve-BackupEntry.ps1 @@ -0,0 +1,237 @@ +function Resolve-BackupEntry { + <# + .SYNOPSIS + 把清单条目解析成"实际要打包什么、归档里长什么样"。 + + .DESCRIPTION + 返回: + + IsName / BaseName / ArchiveFlavor / Direction + CatalogEntry —— 名录条目(软件名写法才有) + Items —— 归档项数组(见 New-BaknretArchiveItem) + Encrypt —— 该归档是否加密 + ExcludePatterns / HasExcludeOverride —— 条目级 `:-` / `@ Exclude` 覆盖 + Includes / HasIncludeOverride —— 条目级 `:+` / `@ Include` 覆盖 + Error —— 可恢复的问题(例如名录里路径不存在) + Blocking —— 必须整条失败的问题(归档内路径冲突等) + + 归档内部布局: + * 软件名条目 -> `\`(文件 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 + } +} diff --git a/BakNRet/Public/Resolve-CatalogPath.ps1 b/BakNRet/Public/Resolve-CatalogPath.ps1 new file mode 100644 index 0000000..1a9670f --- /dev/null +++ b/BakNRet/Public/Resolve-CatalogPath.ps1 @@ -0,0 +1,21 @@ +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] +} diff --git a/BakNRet/Public/Resolve-CompressionTool.ps1 b/BakNRet/Public/Resolve-CompressionTool.ps1 new file mode 100644 index 0000000..9386ab5 --- /dev/null +++ b/BakNRet/Public/Resolve-CompressionTool.ps1 @@ -0,0 +1,33 @@ +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' } +} diff --git a/BakNRet/Public/Restore-BaknretSecurity.ps1 b/BakNRet/Public/Restore-BaknretSecurity.ps1 new file mode 100644 index 0000000..5bd5bc4 --- /dev/null +++ b/BakNRet/Public/Restore-BaknretSecurity.ps1 @@ -0,0 +1,105 @@ +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 +} diff --git a/BakNRet/Public/Save-BaknretSecuritySidecar.ps1 b/BakNRet/Public/Save-BaknretSecuritySidecar.ps1 new file mode 100644 index 0000000..6b0f468 --- /dev/null +++ b/BakNRet/Public/Save-BaknretSecuritySidecar.ps1 @@ -0,0 +1,51 @@ +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) +} diff --git a/BakNRet/Public/Set-BaknretDebug.ps1 b/BakNRet/Public/Set-BaknretDebug.ps1 new file mode 100644 index 0000000..b1ea744 --- /dev/null +++ b/BakNRet/Public/Set-BaknretDebug.ps1 @@ -0,0 +1,5 @@ +function Set-BaknretDebug { + <# .SYNOPSIS 打开 DEBUG 级别日志。 #> + param([switch]$Enabled = $true) + $script:LogConfig.EnableDebug = [bool]$Enabled +} diff --git a/BakNRet/Public/Set-BaknretObjectSecurity.ps1 b/BakNRet/Public/Set-BaknretObjectSecurity.ps1 new file mode 100644 index 0000000..50a889d --- /dev/null +++ b/BakNRet/Public/Set-BaknretObjectSecurity.ps1 @@ -0,0 +1,49 @@ +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) + } +} diff --git a/BakNRet/Public/Split-BaknretPatternScope.ps1 b/BakNRet/Public/Split-BaknretPatternScope.ps1 new file mode 100644 index 0000000..110c695 --- /dev/null +++ b/BakNRet/Public/Split-BaknretPatternScope.ps1 @@ -0,0 +1,60 @@ +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 +} diff --git a/BakNRet/Public/Split-BaknretToken.ps1 b/BakNRet/Public/Split-BaknretToken.ps1 new file mode 100644 index 0000000..2e05938 --- /dev/null +++ b/BakNRet/Public/Split-BaknretToken.ps1 @@ -0,0 +1,41 @@ +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() +} diff --git a/BakNRet/Public/Start-BaknretLog.ps1 b/BakNRet/Public/Start-BaknretLog.ps1 new file mode 100644 index 0000000..ca57615 --- /dev/null +++ b/BakNRet/Public/Start-BaknretLog.ps1 @@ -0,0 +1,20 @@ +function Start-BaknretLog { + <# + .SYNOPSIS + 把后续日志同时写入 /-<时间戳>.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 +} diff --git a/BakNRet/Public/Stop-BaknretLog.ps1 b/BakNRet/Public/Stop-BaknretLog.ps1 new file mode 100644 index 0000000..96e1cf8 --- /dev/null +++ b/BakNRet/Public/Stop-BaknretLog.ps1 @@ -0,0 +1,4 @@ +function Stop-BaknretLog { + <# .SYNOPSIS 停止写入日志文件。 #> + $script:LogConfig.FilePath = $null +} diff --git a/BakNRet/Public/Sync-BaknretManifestArchive.ps1 b/BakNRet/Public/Sync-BaknretManifestArchive.ps1 new file mode 100644 index 0000000..2aeeb7d --- /dev/null +++ b/BakNRet/Public/Sync-BaknretManifestArchive.ps1 @@ -0,0 +1,40 @@ +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 +} diff --git a/BakNRet/Public/Test-Administrator.ps1 b/BakNRet/Public/Test-Administrator.ps1 new file mode 100644 index 0000000..04320d8 --- /dev/null +++ b/BakNRet/Public/Test-Administrator.ps1 @@ -0,0 +1,5 @@ +function Test-Administrator { + <# .SYNOPSIS 当前进程是否以管理员身份运行。 #> + $principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} diff --git a/BakNRet/Public/Test-BaknretMarker.ps1 b/BakNRet/Public/Test-BaknretMarker.ps1 new file mode 100644 index 0000000..6a45b20 --- /dev/null +++ b/BakNRet/Public/Test-BaknretMarker.ps1 @@ -0,0 +1,25 @@ +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 +} diff --git a/BakNRet/Public/Test-BaknretPathExcluded.ps1 b/BakNRet/Public/Test-BaknretPathExcluded.ps1 new file mode 100644 index 0000000..e3f0807 --- /dev/null +++ b/BakNRet/Public/Test-BaknretPathExcluded.ps1 @@ -0,0 +1,62 @@ +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 +} diff --git a/BakNRet/Public/Test-BaknretSecurityRecordNeeded.ps1 b/BakNRet/Public/Test-BaknretSecurityRecordNeeded.ps1 new file mode 100644 index 0000000..e709945 --- /dev/null +++ b/BakNRet/Public/Test-BaknretSecurityRecordNeeded.ps1 @@ -0,0 +1,47 @@ +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 +} diff --git a/BakNRet/Public/Test-LiteralPath.ps1 b/BakNRet/Public/Test-LiteralPath.ps1 new file mode 100644 index 0000000..97ad34d --- /dev/null +++ b/BakNRet/Public/Test-LiteralPath.ps1 @@ -0,0 +1,16 @@ +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 +} diff --git a/BakNRet/Public/Write-BackupEntryPlan.ps1 b/BakNRet/Public/Write-BackupEntryPlan.ps1 new file mode 100644 index 0000000..2e838f7 --- /dev/null +++ b/BakNRet/Public/Write-BackupEntryPlan.ps1 @@ -0,0 +1,67 @@ +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 ' 排除:无(整包收下)' + } +} diff --git a/BakNRet/Public/Write-BaknretAtomicText.ps1 b/BakNRet/Public/Write-BaknretAtomicText.ps1 new file mode 100644 index 0000000..6f288ef --- /dev/null +++ b/BakNRet/Public/Write-BaknretAtomicText.ps1 @@ -0,0 +1,28 @@ +function Write-BaknretAtomicText { + <# + .SYNOPSIS + 原子写一个文本文件(先写 .tmp,再替换)。 + #> + param( + [Parameter(Mandatory = $true)][string]$Path, + [AllowEmptyString()][string]$Text = '' + ) + + $directory = Split-Path -Parent $Path + if ($directory -and -not (Test-Path -LiteralPath $directory)) { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + } + + $temp = "$Path.tmp" + [System.IO.File]::WriteAllText($temp, $Text, $script:LogEncoding) + + if (-not (Test-Path -LiteralPath $Path)) { + Move-BaknretArchiveIntoPlace -TempPath $temp -DestinationPath $Path + return $Path + } + + # 目标已存在:用 File.Replace。失败时旧内容完好、.tmp 留着便于排查(两版实测一致)—— + # 这正是"宁可这次没换成,也不能让目标消失"。 + [System.IO.File]::Replace($temp, $Path, [NullString]::Value) + return $Path +} diff --git a/BakNRet/Public/Write-BaknretManifest.ps1 b/BakNRet/Public/Write-BaknretManifest.ps1 new file mode 100644 index 0000000..1b9c010 --- /dev/null +++ b/BakNRet/Public/Write-BaknretManifest.ps1 @@ -0,0 +1,23 @@ +function Write-BaknretManifest { + <# + .SYNOPSIS + 原子写入 manifest.json(UTF-8 无 BOM)。 + #> + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)]$Manifest + ) + + $Manifest.updatedAt = (Get-Date).ToString('o') + $json = $Manifest | ConvertTo-Json -Depth 6 + + $directory = Split-Path -Parent $Path + if ($directory -and -not (Test-Path -LiteralPath $directory)) { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + } + + # 复用原子写,而不是自己"删旧再改名":后者一旦在中途失败,旧 manifest 已经没了 —— + # 而 manifest 是"这块归档是谁的"的唯一账本,丢了它只能靠文件名反推。 + Write-BaknretAtomicText -Path $Path -Text $json + return $Path +} diff --git a/BakNRet/Public/Write-Log.ps1 b/BakNRet/Public/Write-Log.ps1 new file mode 100644 index 0000000..0225c2d --- /dev/null +++ b/BakNRet/Public/Write-Log.ps1 @@ -0,0 +1,46 @@ +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 { + # 日志落盘失败时保持沉默:不能因为写日志失败而让备份失败。 + } + } + } +} diff --git a/Common.psm1 b/Common.psm1 deleted file mode 100644 index bbc8565..0000000 --- a/Common.psm1 +++ /dev/null @@ -1,3296 +0,0 @@ -<# -.SYNOPSIS - BakNRet —— 备份 / 恢复脚本的公共功能模块。 - -.DESCRIPTION - 提供日志(控制台 + 落盘)、外部命令调用(可取得真实退出码)、 - BackupList.txt 语法解析、归档命名与逆向解析、目录摘要、manifest 读写、 - 磁盘剩余空间查询等公共能力。 - - 兼容 Windows PowerShell 5.1 与 PowerShell 7.x: - * 不使用 ?? / 三元运算符 / Join-String / -AsHashtable 等 6.0+ 语法; - * 不使用 ProcessStartInfo.ArgumentList(5.1 上不存在),改为自行构造命令行。 - - 模块内出现的备份清单语法(BackupList.txt 每一行): - - [+|-] <软件名 或 绝对路径> [修饰符...] [# 说明] - [:: ] [:- <模式>[,...]] [:+ <包含项>[,...]] - [:encrypt | :!encrypt] [@ =''] - - 标记(必须是独立的空白分隔记号,前后都要有空格): - + 仅备份,不恢复(Restore.ps1 跳过) - - 仅恢复,不备份(Backup.ps1 跳过) - :: 覆盖 Path,等价于 `@ Path='...'` - :- 排除模式,等价于 `@ Exclude='...'` - :+ 追加包含项(<归档内相对路径>:<宿主机绝对路径>),等价于 `@ Include='...'` - :encrypt 该条目加密(`@ Encrypt='$true'`) - :!encrypt 该条目不加密(`@ Encrypt='$false'`) - @ Key='值' 覆盖 SoftwareCatalog.psd1 里的同名默认字段 - - 兼容的历史写法:`@encrypt` / `@!encrypt` / `@pathname` / `@root=<名>`, - 以及用双引号包住路径或模式值。 - - 归档内布局(SoftwareCatalog.psd1 的 Slot 是包内的一层目录): - 软件名条目 -> \<该 Path 的内容>(Path 是文件时就是名为 的文件) - 手写路径 -> <路径末级名>\...(历史布局,不变) -#> - -$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 - 把后续日志同时写入 /-<时间戳>.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 { - # 日志落盘失败时保持沉默:不能因为写日志失败而让备份失败。 - } - } - } -} - -# ============================================================================ -# 运行锁 -# ============================================================================ -# 为什么需要它:Backup.ps1 与 Restore.ps1 都会写 manifest.json,也都会在备份目录里生成 -# 与替换归档。计划任务与手动运行撞在一起时,两边会互相覆盖对方的账本;更糟的是两边会 -# 用同一个 <归档>.tmp 名字,把彼此的临时归档当成自己的。 -# -# 用**独占文件句柄**(FileShare.None)而不是命名互斥体: -# * 句柄由内核持有,进程被杀 / 崩溃时自动关闭,锁自动释放 —— 不会留下需要人工清理的 -# 陈旧锁;命名互斥体要跨会话(计划任务在另一个会话里跑)还得用 Global\ 前缀, -# 而那需要额外权限。 -# * 它是文件系统的锁:不区分会话、不区分终端,计划任务与手动运行会互相看见。 - -function Get-BaknretRunLockPath { - <# .SYNOPSIS 运行锁文件的位置(放在备份目录里,与它保护的账本同处)。 #> - param([Parameter(Mandatory = $true)][string]$Directory) - return (Join-Path $Directory '.baknret.lock') -} - -function Enter-BaknretRunLock { - <# - .SYNOPSIS - 取得"同一份备份目录同一时间只允许一个进程操作"的锁;拿不到时返回 $null。 - - .DESCRIPTION - 拿不到就直接返回 $null 交给调用方明确失败,**不等待**:单个条目压缩可能十几分钟, - "等它跑完"对用户来说和挂住没区别,不如直接说清楚是谁占着。 - - 返回的是一个已打开的文件流。**不需要刻意释放**:进程退出(含 exit)时句柄由系统 - 关闭,锁随之释放。调用方仍应显式调 Exit-BaknretRunLock,让锁的覆盖范围一眼可见。 - #> - param([Parameter(Mandatory = $true)][string]$Directory) - - if (-not (Test-Path -LiteralPath $Directory)) { - New-Item -ItemType Directory -Path $Directory -Force | Out-Null - } - - $path = Get-BaknretRunLockPath -Directory $Directory - try { - $stream = [System.IO.File]::Open( - $path, - [System.IO.FileMode]::OpenOrCreate, - [System.IO.FileAccess]::ReadWrite, - [System.IO.FileShare]::None) - } catch { - # 不能只写 `catch [System.IO.IOException]`:PowerShell 会把 .NET 方法抛出的异常包成 - # MethodInvocationException,按内层类型做的 catch 接不住,于是锁被占用时会直接抛出去, - # 而不是按约定返回 $null 让调用方明确失败(实测踩到,被自己的断言逮住)。 - # 这里沿 InnerException 链找那个 IOException;不是它就把原异常抛回去(例如目录不可写 - # 是 UnauthorizedAccessException,那是真错误,不该伪装成"另一次运行在进行中")。 - $inner = $_.Exception - while ($inner -and $inner -isnot [System.IO.IOException]) { $inner = $inner.InnerException } - if (-not $inner) { throw } - - Write-Log ("运行锁不可用({0}):{1}" -f $inner.GetType().Name, $inner.Message) -Level DEBUG - return $null - } - - # 写点线索进去:"到底是谁占着"这个问题不该靠猜 - $info = 'pid={0}; started={1:o}; host={2}; user={3}' -f $PID, (Get-Date), $env:COMPUTERNAME, $env:USERNAME - $bytes = [System.Text.Encoding]::UTF8.GetBytes($info) - $stream.SetLength(0) - $stream.Write($bytes, 0, $bytes.Length) - $stream.Flush() - return $stream -} - -function Exit-BaknretRunLock { - <# - .SYNOPSIS - 释放运行锁。锁文件本身留着 —— 它的内容是最后一次持有者的线索,删不删都无所谓。 - #> - param($Lock) - - if (-not $Lock) { return } - try { - $Lock.Dispose() - } catch { - Write-Log "释放运行锁失败(进程退出时会自动释放):$_" -Level WARN - } -} -# ============================================================================ -# 环境 -# ============================================================================ - -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!\<相对路径>`(锚定在归档根) - * `!<通配>` -> `-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): - - @{ - <软件名> = @{ - = @{ - Path = '宿主机绝对路径' - Exclude = '!*Cache,Default\Extensions' # 可选 - Include = 'Modules:D:\extra\ps-modules' # 可选 - Encrypt = $true # 可选,默认 $false - Description = '这个 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' 格式不对:应写成 @{ = @{ 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 是容器) - * 文件项 -> `` 就是那个文件本身 - 这样"是目录还是文件"只看归档就能判断,恢复端不必猜。 - - 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 - 恢复时用它做"零拷贝落地":把 `<目标父目录>\` 建成指向真实目标目录的 - 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 就是名为 `` 的文件); - * 手写路径 -> `<末级名>\...`(与历史归档一致,不变)。 - - 名录里的 Slot 存在但路径当前不存在时**照样产出归档项**:源被删掉正是要恢复的场景, - 备份端按存在性跳过,恢复端靠它把内容还原回原位。 - #> - param( - $Entry, - [string]$CatalogPath, - [int]$MaxDepth = 5 - ) - - $isName = -not (Test-LiteralPath -Path $Entry.Path) - $forcePathFlavor = ($Entry.Flags -contains 'pathname') - $baseName = Get-ItemArchiveName -Entry $Entry -CatalogPath $CatalogPath -MaxDepth $MaxDepth - - $overrides = $Entry.Overrides - if (-not $overrides) { $overrides = @{} } - $overridePath = if ($overrides.ContainsKey('Path')) { [string]$overrides['Path'] } else { $null } - $hasExcludeOverride = $overrides.ContainsKey('Exclude') - $entryExclude = if ($hasExcludeOverride) { @($overrides['Exclude']) } else { @() } - $hasIncludeOverride = $overrides.ContainsKey('Include') - $entryInclude = if ($hasIncludeOverride) { @($overrides['Include']) } else { @() } - $hasEncryptOverride = $overrides.ContainsKey('Encrypt') - - $items = @() - $catalogEntry = $null - $errorText = $null - $blocking = $null - $archiveFlavor = if ($isName) { 'name' } else { 'path' } - - if (-not $isName) { - # ---- 写法二:用户手写的目录 / 文件 ---- - $real = [string]$Entry.Path - if ($overridePath) { $real = $overridePath } - $real = [Environment]::ExpandEnvironmentVariables($real).Trim() - - $leaf = Split-Path -Path $real -Leaf - if ($forcePathFlavor) { $archiveFlavor = 'path' } - - $exists = $false - $isFile = $false - if ($real) { - $exists = Test-Path -LiteralPath $real - if ($exists) { - $item = Get-Item -LiteralPath $real -Force -ErrorAction SilentlyContinue - if ($item) { $isFile = -not $item.PSIsContainer } - } - } - - if ([string]::IsNullOrWhiteSpace($leaf) -or [string]::IsNullOrWhiteSpace($real)) { - $errorText = "无法从路径里拆出末级名:$real" - } else { - $items += New-BaknretArchiveItem -ArchivePath $leaf -RealPath $real -Kind 'path' ` - -Origin 'path' -Exists $exists -IsFile $isFile - } - } - else { - # ---- 写法一:软件名录里的软件名 ---- - $catalog = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth - if (-not $catalog.ContainsKey($Entry.Path)) { - $errorText = "软件名录里没有 '$($Entry.Path)'" - } else { - $catalogEntry = $catalog[$Entry.Path] - # 名录条目自身有问题(Slot 缺 Path、前缀补全命中多个目录……)时整条失败: - # 继续跑只会"少打包一块"或"任选一个目录",那正是最该避免的静默错误。 - if ($catalogEntry.Error) { - $errorText = $catalogEntry.Error - if (-not $blocking) { $blocking = "软件名录里的 '$($Entry.Path)' 有问题:$($catalogEntry.Error)" } - } - - $slots = @($catalogEntry.Slots) - if ($overridePath -and $slots.Count -ne 1) { - $blocking = ("'{0}' 有 {1} 个 Slot,不能用一个 `::` / `@ Path=` 覆盖路径;请写清楚是哪个 Slot" -f ` - $Entry.Path, $slots.Count) - } else { - foreach ($slot in $slots) { - $resolvedPath = $slot.Resolved - $exists = $slot.Exists - $isFile = $slot.IsFile - - if ($overridePath) { - $resolvedPath = [Environment]::ExpandEnvironmentVariables($overridePath).Trim() - $exists = Test-Path -LiteralPath $resolvedPath - $isFile = $false - if ($exists) { - $item = Get-Item -LiteralPath $resolvedPath -Force -ErrorAction SilentlyContinue - if ($item) { $isFile = -not $item.PSIsContainer } - } - } - - $items += New-BaknretArchiveItem -ArchivePath $slot.Name -RealPath $resolvedPath -Kind 'slot' ` - -Slot $slot.Name -Description $slot.Description -Origin 'catalog' ` - -Exists $exists -IsFile $isFile -Exclude $slot.Exclude - } - } - } - } - - # ------------------------------------------------------------------ - # 包含项:`<归档内相对路径>:<宿主机绝对路径>`,把宿主机上的目录 / 文件放到包内指定位置。 - # 条目级写 `:+` / `@ Include=` 就覆盖名录里的 Include;没写就用名录里各 Slot 的。 - # ------------------------------------------------------------------ - $includeTexts = @() - if ($hasIncludeOverride) { - $includeTexts = @($entryInclude) - } elseif ($catalogEntry) { - foreach ($slot in @($catalogEntry.Slots)) { $includeTexts += @($slot.Include) } - } - - foreach ($includeText in $includeTexts) { - if ([string]::IsNullOrWhiteSpace($includeText)) { continue } - $text = ([string]$includeText).Trim() - - $archivePart = '' - $hostPart = $text - $separator = $text.IndexOf(':') - if ($separator -ge 0) { - $archivePart = $text.Substring(0, $separator).Trim() - $hostPart = $text.Substring($separator + 1).Trim() - - # 写成 `D:\extra\ps-modules:Modules`(宿主机在前)时纠正并告警。 - # 判据:第一个冒号前只有盘符那一个字母,而且紧跟着 `\` 或 `/`。 - # 这时按**最后一个**冒号切,才能把宿主机路径完整地拿回来。 - if ($archivePart -match '^[A-Za-z]$' -and ($hostPart.StartsWith('\') -or $hostPart.StartsWith('/'))) { - $lastSeparator = $text.LastIndexOf(':') - if ($lastSeparator -gt $separator) { - Write-Log ("包含项写得像'宿主机:归档内':{0} —— 语法应为 <归档内相对路径>:<宿主机绝对路径>,已按后者解释" -f $text) -Level WARN - $hostPart = $text.Substring(0, $lastSeparator).Trim() - $archivePart = $text.Substring($lastSeparator + 1).Trim() - } - } - } - - $hostPath = [Environment]::ExpandEnvironmentVariables($hostPart).Trim() - if ([string]::IsNullOrWhiteSpace($hostPath)) { - Write-Log "包含项 '$text' 里没有宿主机路径,已忽略" -Level WARN - continue - } - - $exists = Test-Path -LiteralPath $hostPath - $isFile = $false - if ($exists) { - $item = Get-Item -LiteralPath $hostPath -Force -ErrorAction SilentlyContinue - if ($item) { $isFile = -not $item.PSIsContainer } - } - - $archivePath = $archivePart - if ([string]::IsNullOrWhiteSpace($archivePath)) { $archivePath = Split-Path -Path $hostPath -Leaf } - - $items += New-BaknretArchiveItem -ArchivePath $archivePath -RealPath $hostPath -Kind 'include' ` - -Origin 'include' -Exists $exists -IsFile $isFile - } - - # ------------------------------------------------------------------ - # 归档内路径冲突拦截 - # 一个目录 / 文件在包内只能有一个位置:重名会互相覆盖,祖宗关系会混成一棵树。 - # 宁可明确报错,也不要静默搅在一起。 - # ------------------------------------------------------------------ - $seen = @{} - $collisions = @() - foreach ($item in $items) { - $key = ([string]$item.ArchivePath).ToLower() - if (-not $key) { continue } - if ($seen.ContainsKey($key)) { - $collisions += ("'{0}'({1} 与 {2})" -f $item.ArchivePath, $seen[$key], $item.RealPath) - } else { - $seen[$key] = $item.RealPath - } - } - foreach ($item in $items) { - $key = ([string]$item.ArchivePath).ToLower() - foreach ($other in $seen.Keys) { - if ($other -eq $key) { continue } - if ($other.StartsWith("$key\") -or $key.StartsWith("$other\")) { - $collisions += ("'{0}' 与 '{1}' 是父子关系,包内会互相覆盖" -f $item.ArchivePath, $other) - } - } - } - $collisions = @($collisions | Select-Object -Unique) - - if ($collisions.Count -gt 0) { - $blocking = ("归档内路径冲突:{0}。每个 Slot / 追加项在包内必须有唯一位置," + - "请改 Slot 名或归档内相对路径。") -f ($collisions -join ';') - } - - # ------------------------------------------------------------------ - # 加密:清单覆盖优先,其次是名录里各 Slot 的 Encrypt 取或。 - # 一个软件一个归档,所以 Slot 之间不一致时按"加密"处理(宁可多加密,不可漏加密)。 - # ------------------------------------------------------------------ - $encrypt = $false - if ($hasEncryptOverride) { - $encrypt = [bool]$overrides['Encrypt'] - } elseif ($catalogEntry) { - $slots = @($catalogEntry.Slots) - $encryptedSlots = @($slots | Where-Object { $_.Encrypt }) - $encrypt = $encryptedSlots.Count -gt 0 - if ($encryptedSlots.Count -gt 0 -and $encryptedSlots.Count -lt $slots.Count) { - Write-Log ("{0}:名录里各 Slot 的 Encrypt 不一致,整个归档按加密处理" -f $Entry.Path) -Level WARN - } - } - - return [pscustomobject]@{ - IsName = [bool]$isName - CatalogEntry = $catalogEntry - BaseName = $baseName - ArchiveFlavor = $archiveFlavor - Direction = $Entry.Direction - Items = @($items) - Encrypt = [bool]$encrypt - ExcludePatterns = @($entryExclude) - HasExcludeOverride = [bool]$hasExcludeOverride - Includes = @($entryInclude) - HasIncludeOverride = [bool]$hasIncludeOverride - Source = $Entry.Path - Error = $errorText - Blocking = $blocking - } -} - -function Write-BackupEntryPlan { - <# - .SYNOPSIS - 在动手打包之前,把"这条会打包哪些目录、归档里长什么样、排除了什么、为什么"打印出来。 - - .DESCRIPTION - 逐项打印:归档内路径、宿主机路径、它是怎么来的(名录 / 手写路径 / 追加)、 - 当前在不在、是文件还是目录、以及这个 Slot 是干什么的(Description)。 - #> - param( - [Parameter(Mandatory = $true)]$Resolved, - [Parameter(Mandatory = $true)][string]$DisplayPath, - [string[]]$ListExcludes = @(), - [string[]]$CatalogExcludes = @(), - [string[]]$ConfigExcludes = @(), - [string]$Comment - ) - - $originText = @{ - 'catalog' = '软件名录' - 'path' = '手写路径' - 'include' = '追加项(清单 :+ / 名录 Include)' - } - $directionText = @{ - 'both' = '备份 + 恢复' - 'backup' = '仅备份(行首 +)' - 'restore' = '仅恢复(行首 -)' - } - - Write-Log ("条目:{0}" -f $DisplayPath) - Write-Log (" 归档:{0}.7z;方向:{1};加密:{2}" -f $Resolved.BaseName, - $(if ($directionText.ContainsKey($Resolved.Direction)) { $directionText[$Resolved.Direction] } else { $Resolved.Direction }), - $(if ($Resolved.Encrypt) { '是' } else { '否' })) - if ($Comment) { Write-Log (" 说明:{0}" -f $Comment) } - if ($Resolved.Error) { Write-Log (" 提示:{0}" -f $Resolved.Error) -Level WARN } - - $items = @($Resolved.Items) - if ($items.Count -eq 0) { Write-Log ' 归档项:没有解析出任何目录' -Level WARN } - - for ($index = 0; $index -lt $items.Count; $index++) { - $item = $items[$index] - $exists = Test-Path -LiteralPath $item.RealPath - $origin = if ($item.Origin -and $originText.ContainsKey($item.Origin)) { $originText[$item.Origin] } else { $item.Origin } - - Write-Log (" 归档项 {0}/{1}:{2} <- {3}" -f ($index + 1), $items.Count, $item.ArchivePath, $item.RealPath) - Write-Log (" 来源:{0};{1};{2}" -f $origin, - $(if ($exists) { '存在,会打包' } else { '当前不存在,本次跳过' }), - $(if ($item.IsFile) { '文件' } else { '目录' })) - if ($item.Description) { Write-Log (" 介绍:{0}" -f $item.Description) } - if (@($item.Exclude).Count -gt 0) { - Write-Log (" 名录里的排除:{0}" -f (@($item.Exclude) -join '、')) - } - } - - if ($ListExcludes.Count -gt 0) { - Write-Log (" 排除 {0} 条(来自清单的 :- / @ Exclude):{1}" -f $ListExcludes.Count, ($ListExcludes -join '、')) - } - if ($CatalogExcludes.Count -gt 0) { - Write-Log (" 排除 {0} 条(来自名录 Slot 的 Exclude):{1}" -f $CatalogExcludes.Count, ($CatalogExcludes -join '、')) - } - if ($ConfigExcludes.Count -gt 0) { - Write-Log (" 排除 {0} 条(来自 BackupConfig.psd1 的 DefaultExcludes):{1}" -f $ConfigExcludes.Count, ($ConfigExcludes -join '、')) - } - if ($ListExcludes.Count -eq 0 -and $CatalogExcludes.Count -eq 0 -and $ConfigExcludes.Count -eq 0) { - Write-Log ' 排除:无(整包收下)' - } -} - -function Get-BackupBaseName { - <# - .SYNOPSIS - 由清单中的原始路径生成归档基础名。 - - .DESCRIPTION - 算法与历史版本保持一致(否则已存在的 20 个归档会全部失联): - <末级名>_from_<去掉末级后的各级用 + 连接> - 并保留 & % + 三个字符(环境变量写法依赖 %),其余非法字符换 _。 - - 额外做一件事:把 `:` 归一化为 `_`,因此 C:\Foo 与 "C:\Foo" 结果相同。 - #> - param([Parameter(Mandatory = $true)][string]$RawPath) - - $normalized = $RawPath.Trim() -replace '[/\\]+', '\' - $parts = @($normalized -split '\\' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) - - if ($parts.Count -eq 0) { - Write-Log "无法解析路径:$RawPath" -Level ERROR - return $null - } - - $folderName = $parts[-1].Trim() - $pathParts = if ($parts.Count -gt 1) { $parts[0..($parts.Count - 2)] } else { @() } - - $pathPart = ($pathParts | ForEach-Object { $_.Trim() }) -join '+' - $baseName = if ([string]::IsNullOrEmpty($pathPart)) { - $folderName - } else { - "${folderName}_from_${pathPart}" - } - - $invalidChars = [System.IO.Path]::GetInvalidFileNameChars() | - Where-Object { $_ -notin @('&', '%', '+') } - - $baseName = -join ($baseName.ToCharArray() | ForEach-Object { - if ($_ -in $invalidChars) { '_' } else { $_ } - }) - $baseName = $baseName -replace ':', '_' - - Write-Log "生成文件基础名:$baseName" -Level DEBUG - return $baseName -} - -function Convert-BackupFileNameToPath { - <# - .SYNOPSIS - 把归档文件名还原成原始路径(用于没有 manifest 时的兜底)。 - - .DESCRIPTION - 只处理 <名>_from_<路径> 形式;`C_` 还原为 `C:`。 - 命名里本来就含 `+` 或 `_from_` 的真实目录名无法可靠还原, - 这类情况应当依赖 manifest.json 而不是文件名。 - #> - param([Parameter(Mandatory = $true)][string]$FileName) - - $baseName = [System.IO.Path]::GetFileNameWithoutExtension($FileName) - if ($baseName -notmatch '_from_') { return $null } - - try { - $folderPart, $pathPart = $baseName -split '_from_', 2 - $parts = @($pathPart -split '\+' | Where-Object { -not [string]::IsNullOrEmpty($_) }) - - $parts = @($parts | ForEach-Object { - if ($_ -match '^([A-Za-z])_$') { "$($matches[1]):" } else { $_ } - }) - - $reconstructed = ($parts -join '\') + '\' + $folderPart - Write-Log "逆向解析:$FileName -> $reconstructed" -Level DEBUG - return $reconstructed - } catch { - Write-Log "无法解析备份文件名:$FileName" -Level WARN - return $null - } -} - -function Get-FolderSummary { - <# - .SYNOPSIS - 统计目录/文件的文件数、总大小与最新修改时间。 - - .DESCRIPTION - LatestModifiedTime 取**包含目录在内**的所有条目的最大值: - 目录的 LastWriteTime 会在子项增删时更新,因此删掉文件也能被察觉。 - #> - param([Parameter(Mandatory = $true)][string]$FolderPath) - - try { - $items = @(Get-ChildItem -LiteralPath $FolderPath -Recurse -Force -ErrorAction SilentlyContinue) - $files = @($items | Where-Object { -not $_.PSIsContainer }) - - # 空目录时 Measure-Object 的 .Sum 是 $null 而不是 0(7.x 与 5.1 实测都一样)。 - # 这个 $null 会一路传到备份前的空间守卫:$null / 1GB 得 0,而守卫判的是 -gt 0, - # 于是"空间不够"时不再拦截 —— 静默失效。所以在这里就把 0 补上。 - $totalSize = ($files | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum - if ($null -eq $totalSize) { $totalSize = 0 } - - return [pscustomobject]@{ - FileCount = $files.Count - TotalSize = [long]$totalSize - LatestModifiedTime = ($items | Measure-Object -Property LastWriteTime -Maximum -ErrorAction SilentlyContinue).Maximum - } - } catch { - Write-Log "无法读取文件夹摘要:$FolderPath" -Level WARN - return [pscustomobject]@{ - FileCount = 0 - TotalSize = 0 - LatestModifiedTime = (Get-Item -LiteralPath $FolderPath -ErrorAction SilentlyContinue).LastWriteTime - } - } -} - -# ============================================================================ -# manifest.json -# ============================================================================ - -function Read-BaknretManifest { - <# - .SYNOPSIS - 读取 manifest.json;不存在或损坏时返回空清单。 - - .DESCRIPTION - items 是按归档基础名索引的对象,方便按条目合并与查找。 - 损坏时只告警不中断:manifest 只是记录,不该成为备份的阻塞点。 - #> - param([Parameter(Mandatory = $true)][string]$Path) - - $empty = [pscustomobject]@{ - schemaVersion = 1 - tool = 'BakNRet' - updatedAt = $null - compressor = $null - items = [ordered]@{} - } - - if (-not (Test-Path -LiteralPath $Path)) { return $empty } - - try { - $raw = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 -ErrorAction Stop - if ([string]::IsNullOrWhiteSpace($raw)) { return $empty } - - $parsed = $raw | ConvertFrom-Json -ErrorAction Stop - $items = [ordered]@{} - if ($parsed.PSObject.Properties.Name -contains 'items' -and $parsed.items) { - foreach ($property in $parsed.items.PSObject.Properties) { - $items[$property.Name] = $property.Value - } - } - - return [pscustomobject]@{ - schemaVersion = 1 - tool = 'BakNRet' - updatedAt = $parsed.updatedAt - compressor = $parsed.compressor - items = $items - } - } catch { - Write-Log "manifest 解析失败(将重新建立):$Path —— $_" -Level WARN - return $empty - } -} - -function Sync-BaknretManifestArchive { - <# - .SYNOPSIS - 清空 manifest 里"指向了一个不存在的归档"的 archive 字段,返回被清空的条目名。 - - .DESCRIPTION - 维持一条不变式:**manifest 里写了 archive 的记录,磁盘上就一定有那个文件。** - - 没有这条不变式时会出现两种误导: - * 源不存在的条目(missing-source / invalid-path)本来就没有归档,记录里却留着 - 一个不存在的文件名,Restore 每次都会打一条 - "manifest 记录的归档不存在,回退按文件名查找",看着像出了问题其实没有; - * 人工删掉了某个归档(例如把它并进了另一个条目)之后,记录还宣称它在那儿。 - - 只清 archive 字段,保留条目本身的历史(source / 成功次数 / 上次恢复时间), - 因为"这个软件曾经备份过、现在源不在了"本身就是有用信息。 - #> - param( - [Parameter(Mandatory = $true)]$Manifest, - [Parameter(Mandatory = $true)][string]$BackupDir - ) - - $cleared = @() - if (-not $Manifest -or -not $Manifest.items) { return , $cleared } - - foreach ($key in @($Manifest.items.Keys)) { - $item = $Manifest.items[$key] - if (-not $item) { continue } - if (-not ($item.PSObject.Properties.Name -contains 'archive')) { continue } - - $archive = $item.archive - if ([string]::IsNullOrWhiteSpace([string]$archive)) { continue } - if (Test-Path -LiteralPath (Join-Path $BackupDir $archive)) { continue } - - $item.archive = $null - $cleared += $key - } - - return , $cleared -} - -function Write-BaknretManifest { - <# - .SYNOPSIS - 原子写入 manifest.json(UTF-8 无 BOM)。 - #> - param( - [Parameter(Mandatory = $true)][string]$Path, - [Parameter(Mandatory = $true)]$Manifest - ) - - $Manifest.updatedAt = (Get-Date).ToString('o') - $json = $Manifest | ConvertTo-Json -Depth 6 - - $directory = Split-Path -Parent $Path - if ($directory -and -not (Test-Path -LiteralPath $directory)) { - New-Item -ItemType Directory -Path $directory -Force | Out-Null - } - - # 复用原子写,而不是自己"删旧再改名":后者一旦在中途失败,旧 manifest 已经没了 —— - # 而 manifest 是"这块归档是谁的"的唯一账本,丢了它只能靠文件名反推。 - Write-BaknretAtomicText -Path $Path -Text $json - return $Path -} - -# ============================================================================ -# 归档原子替换 -# ============================================================================ - -function Move-BaknretArchiveIntoPlace { - <# - .SYNOPSIS - 把临时归档原子地替换到最终路径。 - - .DESCRIPTION - 优先用 File.Move(overwrite)(同卷上是 MoveFileEx + REPLACE_EXISTING, - 基本等价于原子替换);不支持时退化为先删后移。 - #> - param( - [Parameter(Mandatory = $true)][string]$TempPath, - [Parameter(Mandatory = $true)][string]$DestinationPath - ) - - if (-not (Test-Path -LiteralPath $DestinationPath)) { - Move-Item -LiteralPath $TempPath -Destination $DestinationPath -Force - return - } - - try { - # 7.x:单次原子替换(MoveFileEx + REPLACE_EXISTING) - [System.IO.File]::Move($TempPath, $DestinationPath, $true) - return - } catch { - Write-Log "File.Move(overwrite) 不可用(5.1 没有这个重载),改用 File.Replace:$_" -Level DEBUG - } - - # 5.1 走的这条。以前是"先删后移"—— 中途失败会让目标文件消失(旧归档没了、新归档还在 - # .tmp 里)。File.Replace 走 ReplaceFile API,在 .NET Framework 上同样可用:要么换成 - # 新内容、要么保持旧内容,两个都不会消失。 - # 第三个参数必须传 [NullString]::Value —— PowerShell 会把 $null 转成空串,于是 Replace - # 报"路径为空"(两个版本实测都这样)。 - [System.IO.File]::Replace($TempPath, $DestinationPath, [NullString]::Value) -} - -# ============================================================================ -# 安全描述符(NTFS 属主 / ACL) -# ============================================================================ -# 为什么需要它:归档格式(.7z / .zip / .tar)**不承载 NT 安全描述符** —— -# 7-Zip 的 -sni(Store NT security information)官方文档写明"当前版本只能写进 WIM 归档"。 -# 于是"备份 → 恢复"之后,每个对象的安全描述符都是新建对象的默认值: -# 属主是跑恢复脚本的那个进程,DACL 是从目标父目录继承来的那一套。 -# -# 对 C:\ProgramData 下的目录这是致命的,它的 ACL 里有: -# (A;OICIIO;GA;;;CO) CREATOR OWNER + inherit-only + GENERIC_ALL -# 而 CREATOR OWNER(S-1-3-0)不是账户,是**访问检查时才替换的占位符**: -# 替换成"被检查对象的属主"。所以只回放 ACE 文本、不恢复属主,等于把 -# "谁创建的东西谁有全权"里的那个"谁"换成了跑脚本的账户,原程序反而没权限。 -# -# 存储格式:每对象一条 SDDL($acl.Sddl 原文)。SDDL 的 SID 是数值形式,CO / OW -# 这类占位符原样保留,往返无损;**绝不做账户名解析**——名字解析会把占位符映射成 -# 当前用户,或者直接抛 IdentityNotMappedException,那正是"权限落到脚本头上"的另一种成因。 -# -# 恢复:自顶向下、每个对象一次写 Owner|Group|Access;原本不 protected 的 DACL -# 只写显式 ACE,其余交给(已经修好的)父目录重新继承,保住"活继承"的语义。 -# 写属主需要 SeRestorePrivilege,而且必须**显式启用**:管理员的过滤令牌里它默认是 -# disabled,Set-Acl / SetAccessControl 都不会替你打开(见 Enable-BaknretPrivilege)。 - -$script:BaknretPrivilegeState = @{} - -function Enable-BaknretPrivilege { - <# - .SYNOPSIS - 在当前进程令牌里启用指定特权,返回哪些没能启用。 - - .DESCRIPTION - 必须显式启用。MSDN(SetNamedSecurityInfoW)写明: - "If the caller does not have the SeRestorePrivilege constant, this SID must be - contained in the caller's token, and must have the SE_GROUP_OWNER permission - enabled." 也就是说没有它就没法把属主改成别的账户,而失败信息只有一句 - "Access is denied"(easily mistaken for a path problem)。 - - 两个坑: - * 结构体嵌套赋值(`$tp.Privileges.Luid.LowPart = …`)在 PowerShell 里改的是 - 装箱副本,改了不生效,所以整段放进 C# 里做; - * AdjustTokenPrivileges 返回 true 也可能是 ERROR_NOT_ALL_ASSIGNED(1300), - 那代表特权根本不在令牌里,必须当成失败。 - - 返回 [pscustomobject]@{ Enabled; Missing; Failed }(都是名字数组)。 - #> - param([string[]]$Name = @('SeRestorePrivilege', 'SeBackupPrivilege')) - - $result = [pscustomobject]@{ - Enabled = @() - Missing = @() - Failed = @() - } - - if (-not ('Baknret.Privileges' -as [type])) { - try { - Add-Type -Namespace Baknret -Name Privileges -MemberDefinition @' -[DllImport("advapi32.dll", SetLastError = true)] -static extern bool OpenProcessToken(IntPtr h, int acc, out IntPtr phtok); -[DllImport("advapi32.dll", SetLastError = true)] -static extern bool LookupPrivilegeValue(string host, string name, out long pluid); -[DllImport("advapi32.dll", SetLastError = true)] -static extern bool AdjustTokenPrivileges(IntPtr htok, bool disall, - ref TOKEN_PRIVILEGES newst, int len, IntPtr prev, IntPtr relen); -[DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess(); -[DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h); -[StructLayout(LayoutKind.Sequential)] public struct LUID { public uint LowPart; public int HighPart; } -[StructLayout(LayoutKind.Sequential)] public struct LUID_AND_ATTRIBUTES { public LUID Luid; public uint Attributes; } -[StructLayout(LayoutKind.Sequential)] public struct TOKEN_PRIVILEGES { public uint PrivilegeCount; public LUID_AND_ATTRIBUTES Privileges; } -// 0 = 已启用;1 = 令牌里没有这个特权;2 = 其它失败 -public static int Enable(string name) { - IntPtr token; - if (!OpenProcessToken(GetCurrentProcess(), 0x28, out token)) { return 2; } - try { - long luid; - if (!LookupPrivilegeValue(null, name, out luid)) { return 1; } - TOKEN_PRIVILEGES tp = new TOKEN_PRIVILEGES(); - tp.PrivilegeCount = 1; - tp.Privileges.Luid.LowPart = (uint)(luid & 0xFFFFFFFF); - tp.Privileges.Luid.HighPart = (int)(luid >> 32); - tp.Privileges.Attributes = 0x2; - if (!AdjustTokenPrivileges(token, false, ref tp, 0, IntPtr.Zero, IntPtr.Zero)) { return 2; } - if (Marshal.GetLastWin32Error() == 1300) { return 1; } - return 0; - } finally { CloseHandle(token); } -} -'@ - } catch { - Write-Log "特权启用代码编译失败(本次不启用任何特权):$($_.Exception.Message)" -Level WARN - $result.Failed = @($Name) - return $result - } - } - - $enabled = @(); $missing = @(); $failed = @() - foreach ($privilege in @($Name)) { - $cacheKey = $privilege - if ($script:BaknretPrivilegeState.ContainsKey($cacheKey)) { - $state = $script:BaknretPrivilegeState[$cacheKey] - } else { - $state = [Baknret.Privileges]::Enable($privilege) - $script:BaknretPrivilegeState[$cacheKey] = $state - } - switch ($state) { - 0 { $enabled += $privilege } - 1 { $missing += $privilege } - default { $failed += $privilege } - } - } - - if ($missing.Count -gt 0) { - Write-Log ("这些特权不在当前令牌里(需要管理员或 SYSTEM):{0} —— 属主将无法改成别的账户,只能恢复 DACL" -f ($missing -join '、')) -Level WARN - } - if ($failed.Count -gt 0) { - Write-Log ("这些特权启用失败:{0}" -f ($failed -join '、')) -Level WARN - } - - $result.Enabled = @($enabled) - $result.Missing = @($missing) - $result.Failed = @($failed) - return $result -} - -function ConvertTo-BaknretWildcardPattern { - <# - .SYNOPSIS - 把 7z 风格的通配符(* 与 ?)转成正则片段。 - - .DESCRIPTION - 与 Get-BaknretExcludeArgument 保持一致:模式里的空格先转成 `?`(7z 的 - `-x!` 不接受带空格的模式)。`*` 转 `.*`,跨过路径分隔符, - 这样锚定模式 `Default\*` 才能命中 `Default\a\b`。 - #> - param([AllowEmptyString()][string]$Pattern) - - $text = ([string]$Pattern) -replace ' ', '?' - $escaped = [regex]::Escape($text) - $escaped = $escaped -replace '\\\*', '.*' - $escaped = $escaped -replace '\\\?', '.' - return $escaped -} - -function Test-BaknretPathExcluded { - <# - .SYNOPSIS - 判断归档内的一个相对路径是否命中排除模式。 - - .DESCRIPTION - 安全描述符采集走的目录树必须和真正打进归档的那棵树一致,否则会出现 - "归档里有、安全描述符里没有"(恢复后那块内容变成新建对象的默认 ACL)。 - 所以这里与交给 7z 的 -x! / -xr! 语义对齐: - - * `<相对路径>` 锚定在本归档项的根上(`Default\Cache` 只命中它自己那棵子树) - * `!<通配>` 任意层级按**组件名**匹配(`!*Cache` 命中任意一层叫 *Cache 的目录) - * `!re:<正则>` 正则:命中组件名或整条相对路径 - - $RelativePath 用 `\` 分隔,且**不含归档项的根名**。 - #> - param( - [AllowEmptyString()][string]$RelativePath, - [string[]]$Patterns = @() - ) - - $relative = ([string]$RelativePath).Trim([char[]]@('\', '/')) - if (-not $relative) { return $false } - $components = @($relative -split '\\') - - foreach ($pattern in @($Patterns)) { - if ([string]::IsNullOrWhiteSpace($pattern)) { continue } - $text = ([string]$pattern).Trim() - - if ($text.StartsWith('!re:')) { - $regexText = $text.Substring(4).Trim() - if (-not $regexText) { continue } - try { - $options = [System.Text.RegularExpressions.RegexOptions]::IgnoreCase - if ([regex]::IsMatch($relative, $regexText, $options)) { return $true } - foreach ($component in $components) { - if ([regex]::IsMatch($component, $regexText, $options)) { return $true } - } - } catch { - Write-Log "排除正则非法,安全描述符采集按'不排除'处理:$regexText —— $($_.Exception.Message)" -Level WARN - } - continue - } - - if ($text.StartsWith('!')) { - $wildcard = $text.Substring(1).Trim() - if (-not $wildcard) { continue } - $componentPattern = '^(?:' + (ConvertTo-BaknretWildcardPattern -Pattern $wildcard) + ')$' - foreach ($component in $components) { - if ($component -match $componentPattern) { return $true } - } - continue - } - - $anchored = ([string]$text).Trim([char[]]@('\', '/')) - if (-not $anchored) { continue } - $anchoredPattern = '^(?:' + (ConvertTo-BaknretWildcardPattern -Pattern $anchored) + ')$' - if ($relative -match $anchoredPattern) { return $true } - } - - return $false -} - -function Get-BaknretAceSignatureList { - <# - .SYNOPSIS - 把 ACE 列表压成可比对的"签名"集合(`类型|SID|掩码`)。 - - .DESCRIPTION - 只用来回答一个问题:"子对象上这条继承来的 ACE,在父目录的 ACL 里找得到出处吗?" - 所以**刻意不带继承标志位**:同一条 ACE 传给文件子对象时容器继承位会被去掉 - (实测父目录的 (A;OICI;FA;;;SY) 到文件上变成 (A;ID;FA;;;SY)), - 带上标志比较会永远不相等。掩码取 AccessMask 整数值,避免枚举把组合权限拆得不一样。 - #> - param([array]$Rules = @()) - - $list = @() - foreach ($rule in @($Rules)) { - if (-not $rule) { continue } - $mask = -1 - try { $mask = [int]$rule.FileSystemRights } catch { $mask = -1 } - $list += ('{0}|{1}|{2}' -f $rule.AccessControlType, $rule.IdentityReference.Value, $mask) - } - return $list -} - -function Get-BaknretSecuritySddlWithStale { - <# - .SYNOPSIS - 对象与父目录的继承链**不自洽**时,把整套 ACE 冻结成显式副本(并置 protected), - 返回改写后的 SDDL;自洽时原样返回 $Acl.Sddl。 - - .DESCRIPTION - 恢复时只重放**显式** ACE,其余交给父目录重新继承 —— 对绝大多数对象这是最忠实的 - 做法(父目录修好之后继承会长出同样的 ACE,还保住了活继承语义)。 - - 但有一类对象不行:它的 DACL 里留着**陈旧**的继承 ACE —— 父目录早就改过权限, - 这条 ACE 已经没有任何出处。真机实测两件事: - - 1) 把父目录设成 protected 的新 DACL 之后,子对象仍留着从祖父目录继承来的 - `(A;ID;FA;;;S-1-5-21-…)`;条数与父目录的可继承条数**正好都是 4**、内容却不同 - —— 所以判据必须比 ACE 内容,不能只数条数。 - 2) Windows 在改写父目录时**不会**替子对象清掉这种已无出处的 ACE。于是 - "目标上本来就留着它 + 我又补写一条显式 ACE" = 同一条 ACE 出现两次。 - - 所以这类对象只能整套冻结:显式 ACE + 陈旧 ACE 全部按显式写,并置 protected - (protected 才不会被系统再补一遍继承 ACE)。代价是这个对象从此不跟随父目录 - —— 但它本来就已经跟父目录脱节了,冻结是唯一"不丢 ACE、也不重复 ACE"的做法。 - - $ParentSignatures 为 $null 表示"调用方没有父目录上下文"(归档项根、单文件项), - 此时不做任何改写。 - #> - param( - [Parameter(Mandatory = $true)]$Acl, - [AllowNull()][string[]]$ParentSignatures = $null - ) - - if ($null -eq $ParentSignatures) { return $Acl.Sddl } - - $sid = [System.Security.Principal.SecurityIdentifier] - $inherited = @($Acl.GetAccessRules($false, $true, $sid)) - if ($inherited.Count -eq 0) { return $Acl.Sddl } - - # 自洽 = 继承来的 ACE 每一条都能在父目录的 ACL 里找到出处 - $stale = @() - foreach ($rule in $inherited) { - $signature = @(Get-BaknretAceSignatureList -Rules @($rule))[0] - if ($ParentSignatures -notcontains $signature) { $stale += $rule } - } - if ($stale.Count -eq 0) { return $Acl.Sddl } - - $rebuilt = $null - if ($Acl -is [System.Security.AccessControl.DirectorySecurity]) { - $rebuilt = New-Object System.Security.AccessControl.DirectorySecurity - } else { - $rebuilt = New-Object System.Security.AccessControl.FileSecurity - } - - # 整套(显式 + 继承)都按显式写:内容与备份时逐条一致,不靠继承去"猜"回来 - foreach ($rule in @($Acl.GetAccessRules($true, $true, $sid))) { $rebuilt.AddAccessRule($rule) } - - $sections = [System.Security.AccessControl.AccessControlSections]::Access - try { - $rebuilt.SetOwner($Acl.GetOwner($sid)) - $sections = $sections -bor [System.Security.AccessControl.AccessControlSections]::Owner - } catch { } - try { - $rebuilt.SetGroup($Acl.GetGroup($sid)) - $sections = $sections -bor [System.Security.AccessControl.AccessControlSections]::Group - } catch { } - - $rebuilt.SetAccessRuleProtection($true, $false) - - Write-Log ("{0} 条继承 ACE 已无出处(父目录里找不到),整套 ACE 冻结为显式并置 protected" -f $stale.Count) -Level DEBUG - return $rebuilt.GetSecurityDescriptorSddlForm($sections) -} - -function Get-BaknretSecurityRecord { - <# - .SYNOPSIS - 读一个对象的安全描述符,产出可序列化的一条记录。 - - .DESCRIPTION - 返回 [pscustomobject]: - p / k 归档内相对路径 / 类型(d 目录、f 文件) - s SDDL 原文(含 O: / G: / D:) - o / g 属主 / 属组 SID 字符串 - e 读不到时的错误(**必须记账**,不能当成"没有特殊权限") - Protected / Explicit / Inherited / Inheritable / Analyzed - Smart 模式判断"是否与父目录不同"用的分析结果 - - 属主/属组一律取 SID 字符串(GetOwner(SecurityIdentifier).Value): - 走 .Owner 会触发账户名解析,孤儿 SID 上会抛异常或很慢,而我们只要数值身份。 - - 读 SD 需要 READ_CONTROL;C:\ProgramData 里确实有 Get-Acl 直接报 - "Attempted to perform an unauthorized operation" 的目录,先开 SeBackupPrivilege - 能救回大部分,救不回的会带 e 字段落进 sidecar。 - #> - param( - [Parameter(Mandatory = $true)][string]$Path, - [Parameter(Mandatory = $true)][string]$Key, - [ValidateSet('d', 'f')][string]$Kind = 'd', - [switch]$IncludeSacl, - [AllowNull()][string[]]$ParentSignatures = $null - ) - - $record = [pscustomobject]@{ - p = $Key - k = $Kind - s = $null - o = $null - g = $null - e = $null - Protected = $false - Explicit = 0 - Inherited = 0 - Inheritable = 0 - InheritedSignatures = @() - AllSignatures = @() - Analyzed = $false - } - - $acl = $null - try { - if ($IncludeSacl) { - $acl = Get-Acl -LiteralPath $Path -Audit -ErrorAction Stop - } else { - $acl = Get-Acl -LiteralPath $Path -ErrorAction Stop - } - } catch { - $record.e = $_.Exception.Message - return $record - } - - try { - # 陈旧继承 ACE 要固化成显式 ACE,否则恢复后会消失(见 Get-BaknretSecuritySddlWithStale) - $record.s = Get-BaknretSecuritySddlWithStale -Acl $acl -ParentSignatures $ParentSignatures - } catch { - $record.e = $_.Exception.Message - } - if (-not $record.s) { - if (-not $record.e) { $record.e = '读不到安全描述符' } - return $record - } - - try { $record.o = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value } catch { } - try { $record.g = $acl.GetGroup([System.Security.Principal.SecurityIdentifier]).Value } catch { } - - try { - $sid = [System.Security.Principal.SecurityIdentifier] - $record.Protected = [bool]$acl.AreAccessRulesProtected - - $explicitRules = @($acl.GetAccessRules($true, $false, $sid)) - $inheritedRules = @($acl.GetAccessRules($false, $true, $sid)) - $record.Explicit = $explicitRules.Count - $record.Inherited = $inheritedRules.Count - $record.InheritedSignatures = @(Get-BaknretAceSignatureList -Rules $inheritedRules) - $record.AllSignatures = @(Get-BaknretAceSignatureList -Rules @($acl.GetAccessRules($true, $true, $sid))) - - $inheritable = 0 - foreach ($rule in @($acl.GetAccessRules($true, $true, $sid))) { - $fsRule = $rule -as [System.Security.AccessControl.FileSystemAccessRule] - if ($fsRule -and ($fsRule.InheritanceFlags -ne [System.Security.AccessControl.InheritanceFlags]::None)) { - $inheritable++ - } - } - $record.Inheritable = $inheritable - $record.Analyzed = $true - } catch { - # 分析失败时一律当成"需要保留"(Analyzed=$false 会让 keeper 直接保留) - $record.Analyzed = $false - } - - return $record -} - -function Test-BaknretSecurityRecordNeeded { - <# - .SYNOPSIS - Smart 模式下判断这条记录是否必须落进 sidecar。 - - .DESCRIPTION - 判据是"恢复时不能被继承自动复现",任何一条成立就得留: - - * 读不到(e)—— 必须记账,恢复时要能报出来; - * DACL 是 protected(断开继承)—— 只靠父目录继承永远复现不出这一套; - * 有显式 ACE(Explicit > 0)—— 同上; - * NULL DACL(NO_ACCESS_CONTROL)—— 那不是"没有特殊权限",是"人人全权"; - * 属主 / 属组与父目录不同 —— CREATOR OWNER 的解析结果就取决于属主; - * 继承链路与父目录脱节 —— 条数对不上,或某条继承来的 ACE 在父目录 ACL 里 - 找不到出处(父目录改过权限、子对象还留着老 ACE);空 DACL 也会在这里露出来。 - - 分析不了(Analyzed=$false)时一律保留:多存永远比少存安全。 - #> - param( - [Parameter(Mandatory = $true)]$Record, - [string]$ParentOwner, - [string]$ParentGroup, - [int]$ParentInheritable = -1, - [string[]]$ParentSignatures = @(), - [switch]$Force - ) - - if ($Force) { return $true } - if ($Record.e) { return $true } - if (-not $Record.s) { return $true } - if (-not $Record.Analyzed) { return $true } - if ($Record.Protected) { return $true } - if ($Record.Explicit -gt 0) { return $true } - if ($Record.s -match 'NO_ACCESS_CONTROL') { return $true } - if ($Record.o -and $ParentOwner -and ($Record.o -ne $ParentOwner)) { return $true } - if ($Record.g -and $ParentGroup -and ($Record.g -ne $ParentGroup)) { return $true } - - # 继承链还接不接得上父目录:先比条数,再比每一条在父目录 ACL 里有没有出处。 - # 只比条数会漏判 —— 真机实测过:子对象留着"改权限之前"的老 ACE, - # 条数与父目录可继承条数正好相等(都 4 条),内容却完全不同。 - if ($ParentInheritable -ge 0 -and $Record.Inherited -ne $ParentInheritable) { return $true } - foreach ($signature in @($Record.InheritedSignatures)) { - if ($ParentSignatures -notcontains $signature) { return $true } - } - - return $false -} - -function Get-BaknretSecurityRecords { - <# - .SYNOPSIS - 采集一组归档项的安全描述符,键是**归档内相对路径**(`\…`)。 - - .DESCRIPTION - 键用归档内路径而不是宿主机路径:目标机器上 `%UserProfile%` 会变、名录的前缀补全 - (legendary -> legendary_2.0.4)也会变,只有归档内相对路径在两端是同一个坐标系。 - - 遍历用显式栈,并且**跳过 reparse point**:PS 5.1 的 Get-ChildItem -Recurse 会 - 跟着 junction 无限转;scoop 的 `apps\\current` 就是 junction,正撞在这个坑上。 - - $ScopeMap 由 Split-BaknretPatternScope 产出(项下标 -> 该相对根的模式数组), - 所以这里的排除判定与真正交给 7z 的 -x! / -xr! 是同一套规则。 - - Mode: - * Roots —— 只存每个归档项的根(最省,适合"权限只在根上"的场景) - * Smart —— 根 + 所有"继承复现不出来"的对象(默认;几万文件的树 sidecar 也只有几百 KB) - * Full —— 每一个对象都存(最保险,sidecar 会大到几 MB) - - 返回 [pscustomobject]@{ Records; Scanned; Kept; Errors }。 - #> - param( - [array]$Items = @(), - [hashtable]$ScopeMap = @{}, - [ValidateSet('Roots', 'Smart', 'Full')][string]$Mode = 'Smart', - [switch]$IncludeSacl - ) - - $records = New-Object System.Collections.Generic.List[object] - $scanned = 0 - $errorCount = 0 - - # 读安全描述符要 READ_CONTROL:系统目录里读不到是常态(C:\ProgramData 下就有 - # Get-Acl 直接报 "Attempted to perform an unauthorized operation" 的目录)。 - # SeBackupPrivilege 启用后系统会把读权限授予任何文件;连它都没有的账户, - # 读不到的对象会带 e 字段落进 sidecar,而不是被静默当成"没有特殊权限"。 - $privileges = @('SeBackupPrivilege') - if ($IncludeSacl) { $privileges += 'SeSecurityPrivilege' } - Enable-BaknretPrivilege -Name $privileges | Out-Null - - for ($index = 0; $index -lt $Items.Count; $index++) { - $item = $Items[$index] - if (-not $item) { continue } - - $archiveRoot = [string]$item.ArchivePath - $real = [string]$item.RealPath - if ([string]::IsNullOrWhiteSpace($archiveRoot) -or [string]::IsNullOrWhiteSpace($real)) { continue } - if (-not (Test-Path -LiteralPath $real)) { continue } - - $patterns = @() - if ($ScopeMap -and $ScopeMap.ContainsKey($index)) { $patterns = @($ScopeMap[$index]) } - - $rootItem = Get-Item -LiteralPath $real -Force -ErrorAction SilentlyContinue - if (-not $rootItem) { continue } - - if (-not $rootItem.PSIsContainer) { - $record = Get-BaknretSecurityRecord -Path $real -Key $archiveRoot -Kind 'f' -IncludeSacl:$IncludeSacl - $scanned++ - if ($record.e) { $errorCount++ } - $records.Add($record) - continue - } - - $rootRecord = Get-BaknretSecurityRecord -Path $real -Key $archiveRoot -Kind 'd' -IncludeSacl:$IncludeSacl - $scanned++ - if ($rootRecord.e) { $errorCount++ } - $records.Add($rootRecord) - - if ($Mode -eq 'Roots') { continue } - - $pending = New-Object System.Collections.Generic.Stack[object] - $pending.Push(@{ - Dir = $rootItem - Rel = '' - Owner = $rootRecord.o - Group = $rootRecord.g - Inheritable = $rootRecord.Inheritable - Signatures = $rootRecord.AllSignatures - }) - - while ($pending.Count -gt 0) { - $frame = $pending.Pop() - foreach ($child in @(Get-ChildItem -LiteralPath $frame.Dir.FullName -Force -ErrorAction SilentlyContinue)) { - if ($child.Attributes -band [IO.FileAttributes]::ReparsePoint) { continue } - - $childRel = if ($frame.Rel) { $frame.Rel + '\' + $child.Name } else { $child.Name } - if (Test-BaknretPathExcluded -RelativePath $childRel -Patterns $patterns) { continue } - - $kind = if ($child.PSIsContainer) { 'd' } else { 'f' } - $record = Get-BaknretSecurityRecord -Path $child.FullName -Key ($archiveRoot + '\' + $childRel) ` - -Kind $kind -IncludeSacl:$IncludeSacl -ParentSignatures $frame.Signatures - $scanned++ - if ($record.e) { $errorCount++ } - - if ($Mode -eq 'Full') { - $records.Add($record) - } elseif (Test-BaknretSecurityRecordNeeded -Record $record ` - -ParentOwner $frame.Owner -ParentGroup $frame.Group ` - -ParentInheritable $frame.Inheritable -ParentSignatures $frame.Signatures) { - $records.Add($record) - } - - if ($child.PSIsContainer) { - $pending.Push(@{ - Dir = $child - Rel = $childRel - Owner = $record.o - Group = $record.g - Inheritable = $record.Inheritable - Signatures = $record.AllSignatures - }) - } - } - } - } - - return [pscustomobject]@{ - Records = @($records.ToArray()) - Scanned = $scanned - Kept = $records.Count - Errors = $errorCount - } -} - -function Write-BaknretAtomicText { - <# - .SYNOPSIS - 原子写一个文本文件(先写 .tmp,再替换)。 - #> - param( - [Parameter(Mandatory = $true)][string]$Path, - [AllowEmptyString()][string]$Text = '' - ) - - $directory = Split-Path -Parent $Path - if ($directory -and -not (Test-Path -LiteralPath $directory)) { - New-Item -ItemType Directory -Path $directory -Force | Out-Null - } - - $temp = "$Path.tmp" - [System.IO.File]::WriteAllText($temp, $Text, $script:LogEncoding) - - if (-not (Test-Path -LiteralPath $Path)) { - Move-BaknretArchiveIntoPlace -TempPath $temp -DestinationPath $Path - return $Path - } - - # 目标已存在:用 File.Replace。失败时旧内容完好、.tmp 留着便于排查(两版实测一致)—— - # 这正是"宁可这次没换成,也不能让目标消失"。 - [System.IO.File]::Replace($temp, $Path, [NullString]::Value) - return $Path -} - -function Save-BaknretSecuritySidecar { - <# - .SYNOPSIS - 把采集结果写成 sidecar(`<归档名>.acl.json`)。 - - .DESCRIPTION - 放在归档旁边而不是塞进归档里:7z 装不下它,塞进去又会污染 Slot 布局 - (归档内顶层名是要与 manifest 的 roots/layouts 对账的)。 - 代价是它得跟归档一起搬,README 里已写明。 - - 用 JSON 数组而不是"路径 -> SDDL"的对象:ConvertFrom-Json 出来的是 - PSCustomObject,按深度排序还得自己摊平;数组直接有序。 - #> - param( - [Parameter(Mandatory = $true)][string]$Path, - [array]$Records = @(), - [string]$Mode = 'Smart', - [bool]$IncludeSacl = $false, - [int]$Errors = 0, - [int]$Scanned = 0 - ) - - $projected = @() - foreach ($record in @($Records)) { - if (-not $record) { continue } - $entry = [ordered]@{ - p = [string]$record.p - k = [string]$record.k - } - if ($record.s) { $entry.s = [string]$record.s } - if ($record.o) { $entry.o = [string]$record.o } - if ($record.g) { $entry.g = [string]$record.g } - if ($record.e) { $entry.e = [string]$record.e } - $projected += $entry - } - - $payload = [ordered]@{ - schemaVersion = 1 - tool = 'BakNRet' - capturedAt = (Get-Date).ToString('o') - mode = $Mode - includeSacl = [bool]$IncludeSacl - objectCount = $projected.Count - scannedCount = $Scanned - errorCount = $Errors - records = @($projected) - } - - $json = $payload | ConvertTo-Json -Depth 5 - return (Write-BaknretAtomicText -Path $Path -Text $json) -} - -function Read-BaknretSecuritySidecar { - <# - .SYNOPSIS - 读 sidecar;不存在或损坏时返回 $null(调用方据此打"该归档不含安全描述符"的告警)。 - #> - param([Parameter(Mandatory = $true)][string]$Path) - - if (-not (Test-Path -LiteralPath $Path)) { return $null } - - try { - $raw = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 -ErrorAction Stop - if ([string]::IsNullOrWhiteSpace($raw)) { return $null } - - $parsed = $raw | ConvertFrom-Json -ErrorAction Stop - $records = @() - if (($parsed.PSObject.Properties.Name -contains 'records') -and $parsed.records) { - $records = @($parsed.records) - } - - return [pscustomobject]@{ - CapturedAt = $parsed.capturedAt - Mode = $parsed.mode - IncludeSacl = [bool]$parsed.includeSacl - ObjectCount = $parsed.objectCount - ErrorCount = $parsed.errorCount - Records = @($records) - } - } catch { - Write-Log "安全描述符文件解析失败:$Path —— $($_.Exception.Message)" -Level WARN - return $null - } -} - -function Convert-BaknretSidMap { - <# - .SYNOPSIS - 按 SID 映射表改写 SDDL 里的 SID(跨机恢复用)。 - - .DESCRIPTION - 只在**完整的 SID 记号**上替换:`S-1-5-21-1-2-3-1001` 是 - `S-1-5-21-1-2-3-10012` 的前缀,直接 -replace 会改坏后者, - 所以前后加边界断言(前面不能是数字或 -,后面不能是数字)。 - #> - param( - [AllowEmptyString()][string]$Sddl, - [hashtable]$SidMap = @{} - ) - - $text = [string]$Sddl - if (-not $text -or -not $SidMap -or $SidMap.Count -eq 0) { return $text } - - foreach ($old in @($SidMap.Keys)) { - $newSid = [string]$SidMap[$old] - $oldSid = [string]$old - if ([string]::IsNullOrWhiteSpace($oldSid) -or [string]::IsNullOrWhiteSpace($newSid)) { continue } - $pattern = '(? - 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 @( - 'Enter-BaknretRunLock', - 'Exit-BaknretRunLock', - 'Get-BaknretRunLockPath', - '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' -) diff --git a/Restore.ps1 b/Restore.ps1 index f823d51..3f02198 100644 --- a/Restore.ps1 +++ b/Restore.ps1 @@ -74,7 +74,7 @@ if ($DryRun) { $WhatIfPreference = $true } # 载入依赖 # ============================================================================ -$modulePath = Join-Path $PSScriptRoot 'Common.psm1' +$modulePath = Join-Path $PSScriptRoot 'BakNRet\BakNRet.psd1' if (-not (Test-Path -LiteralPath $modulePath)) { Write-Error "找不到依赖模块:$modulePath,请确保所有文件在同一目录。" exit 1 @@ -109,7 +109,7 @@ if ($WhatIfPreference) { Write-Log '试运行模式(-WhatIf / -DryRun):不 if (-not (Test-Administrator)) { Write-Log '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN } -# 同一份备份目录同一时间只允许一个进程操作(见 Common.psm1 的「运行锁」一节)。 +# 同一份备份目录同一时间只允许一个进程操作(见 BakNRet 模块的「运行锁」一节)。 # 三种只读模式不取锁:它们一个字节都不写,没必要被正在跑的备份挡在外面。 $runLock = $null if (-not $WhatIfPreference -and -not $VerifyOnly) { diff --git a/tests/BakNRet.Formats.Tests.ps1 b/tests/BakNRet.Formats.Tests.ps1 index d52f3cd..2b4fd4d 100644 --- a/tests/BakNRet.Formats.Tests.ps1 +++ b/tests/BakNRet.Formats.Tests.ps1 @@ -30,7 +30,7 @@ BeforeAll { $script:RestoreScript = Join-Path $script:ProjectRoot 'Restore.ps1' $script:SevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source - Import-Module (Join-Path $script:ProjectRoot 'Common.psm1') -Force + Import-Module (Join-Path $script:ProjectRoot 'BakNRet\BakNRet.psd1') -Force $script:Sandbox = Join-Path $env:TEMP ('baknret-formats-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) New-Item -ItemType Directory -Path $script:Sandbox -Force | Out-Null diff --git a/tests/BakNRet.Security.Tests.ps1 b/tests/BakNRet.Security.Tests.ps1 index 694685e..0a426dc 100644 --- a/tests/BakNRet.Security.Tests.ps1 +++ b/tests/BakNRet.Security.Tests.ps1 @@ -28,7 +28,7 @@ BeforeAll { $script:BackupScript = Join-Path $script:ProjectRoot 'Backup.ps1' $script:RestoreScript = Join-Path $script:ProjectRoot 'Restore.ps1' - Import-Module (Join-Path $script:ProjectRoot 'Common.psm1') -Force + Import-Module (Join-Path $script:ProjectRoot 'BakNRet\BakNRet.psd1') -Force $script:Sandbox = Join-Path $env:TEMP ('baknret-acl-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) New-Item -ItemType Directory -Path $script:Sandbox -Force | Out-Null diff --git a/tests/BakNRet.Tests.ps1 b/tests/BakNRet.Tests.ps1 index 54d40ed..9d1df78 100644 --- a/tests/BakNRet.Tests.ps1 +++ b/tests/BakNRet.Tests.ps1 @@ -41,7 +41,7 @@ BeforeAll { $script:RestoreScript = Join-Path $script:ProjectRoot 'Restore.ps1' $script:SevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source - Import-Module (Join-Path $script:ProjectRoot 'Common.psm1') -Force + Import-Module (Join-Path $script:ProjectRoot 'BakNRet\BakNRet.psd1') -Force $script:Sandbox = Join-Path $env:TEMP ('baknret-pester-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) New-Item -ItemType Directory -Path $script:Sandbox -Force | Out-Null diff --git a/tests/Restore-Drill.ps1 b/tests/Restore-Drill.ps1 index a22e6aa..e2f6099 100644 --- a/tests/Restore-Drill.ps1 +++ b/tests/Restore-Drill.ps1 @@ -75,7 +75,7 @@ if (-not $ConfigPath) { $ConfigPath = Join-Path $projectRoot 'BackupConfig.psd1' $restoreScript = Join-Path $projectRoot 'Restore.ps1' -Import-Module (Join-Path $projectRoot 'Common.psm1') -Force +Import-Module (Join-Path $projectRoot 'BakNRet\BakNRet.psd1') -Force if (-not (Test-Path -LiteralPath $restoreScript)) { Write-Error "找不到 Restore.ps1:$restoreScript" diff --git a/tests/Run-E2E.ps1 b/tests/Run-E2E.ps1 index b1204df..76f273c 100644 --- a/tests/Run-E2E.ps1 +++ b/tests/Run-E2E.ps1 @@ -37,7 +37,7 @@ Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force $projectRoot = Split-Path -Parent $PSScriptRoot $backupScript = Join-Path $projectRoot 'Backup.ps1' $restoreScript = Join-Path $projectRoot 'Restore.ps1' -Import-Module (Join-Path $projectRoot 'Common.psm1') -Force +Import-Module (Join-Path $projectRoot 'BakNRet\BakNRet.psd1') -Force Reset-TestResult diff --git a/tests/Run-RealSmoke.ps1 b/tests/Run-RealSmoke.ps1 index 770a5e1..f607536 100644 --- a/tests/Run-RealSmoke.ps1 +++ b/tests/Run-RealSmoke.ps1 @@ -37,7 +37,7 @@ param( $ErrorActionPreference = 'Stop' $projectRoot = Split-Path -Parent $PSScriptRoot -if (-not $ModulePath) { $ModulePath = Join-Path $projectRoot 'Common.psm1' } +if (-not $ModulePath) { $ModulePath = Join-Path $projectRoot 'BakNRet\BakNRet.psd1' } Import-Module $ModulePath -Force Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force diff --git a/tests/Run-Tests.ps1 b/tests/Run-Tests.ps1 index d0c4211..61c0550 100644 --- a/tests/Run-Tests.ps1 +++ b/tests/Run-Tests.ps1 @@ -35,7 +35,7 @@ $ErrorActionPreference = 'Stop' # 参数绑定阶段还没有给 $PSScriptRoot 赋值,默认值表达式会拿到空串(实测:带 # [CmdletBinding()] -> 空串,不带 -> 正常;PowerShell 7 两种都正常)。所以默认值 # 一律在这里补 —— 这也是本仓库对 -BackupDir / -ConfigPath 一直在用的写法。 -if (-not $ModulePath) { $ModulePath = Join-Path (Split-Path -Parent $PSScriptRoot) 'Common.psm1' } +if (-not $ModulePath) { $ModulePath = Join-Path (Split-Path -Parent $PSScriptRoot) 'BakNRet\BakNRet.psd1' } Import-Module $ModulePath -Force Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force @@ -1314,6 +1314,47 @@ Test-Case '运行锁:释放之后可以重新取得' { } } +Test-Case '模块可以合回单文件:加载器的顺序声明完整、导出名单与 Public\ 一一对应' { + # 这是"拆分可逆"那条承诺的守卫。顺序只在加载器里出现一次、导出面只在清单里出现一次, + # 两者都必须与磁盘上的文件对得上 —— 否则哪天有人加了文件却忘了点源,工具照样能跑, + # 而"合回单文件"会静默少一个函数。 + $repoRoot = Split-Path -Parent $PSScriptRoot + $moduleRoot = Join-Path $repoRoot 'BakNRet' + $loaderPath = Join-Path $moduleRoot 'BakNRet.psm1' + Assert-FileExists $loaderPath + $loader = @(Get-Content -Encoding UTF8 -LiteralPath $loaderPath) + + # 用 Split 取路径,不用正则:这一行里同时有引号与反斜杠,正则嵌进测试文件之后 + # 多一层转义,很容易取到 0 个(第一版就是这样,而手工核对是 67 个)。 + $sources = @() + foreach ($line in $loader) { + if ($line -like '*Join-Path*' -and $line -like "*.ps1')*") { + $quoted = $line.Split("'") + if ($quoted.Count -ge 3) { $sources += $quoted[1] } + } + } + Assert-True ($sources.Count -ge 60) "加载器里只点源了 $($sources.Count) 个文件,太少" + foreach ($rel in $sources) { Assert-FileExists (Join-Path $moduleRoot ($rel -replace '/', '\')) } + + # 磁盘上的每个函数文件都必须被点源到 —— 漏掉一个就是静默少一个函数 + $onDisk = @(Get-ChildItem -LiteralPath (Join-Path $moduleRoot 'Public'), (Join-Path $moduleRoot 'Private') -Filter *.ps1 | + ForEach-Object { "$($_.Directory.Name)\$($_.Name)" }) + $missingFromLoader = @($onDisk | Where-Object { $sources -notcontains $_ }) + Assert-Equal 0 $missingFromLoader.Count ("这些文件没被加载器点源:" + ($missingFromLoader -join '、')) + + # 导出名单必须与 Public\ 下的文件一一对应(少一个就是静默不导出) + $exported = @() + foreach ($line in $loader) { + $trimmed = $line.Trim() + if ($trimmed.StartsWith("'")) { $exported += $trimmed.TrimStart("'").TrimEnd("'", ',') } + } + $publicNames = @(Get-ChildItem -LiteralPath (Join-Path $moduleRoot 'Public') -Filter *.ps1 | ForEach-Object { $_.BaseName }) + $notExported = @($publicNames | Where-Object { $exported -notcontains $_ }) + Assert-Equal 0 $notExported.Count ("Public\ 下有文件没写进导出名单:" + ($notExported -join '、')) + $noFile = @($exported | Where-Object { $publicNames -notcontains $_ }) + Assert-Equal 0 $noFile.Count ("导出名单里有名字没有对应文件:" + ($noFile -join '、')) +} + # ============================================================================ Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue diff --git a/tools/Build-BakNRetModule.ps1 b/tools/Build-BakNRetModule.ps1 new file mode 100644 index 0000000..830364f --- /dev/null +++ b/tools/Build-BakNRetModule.ps1 @@ -0,0 +1,88 @@ +<# +.SYNOPSIS + 把 BakNRet 模块的多个源文件按加载器声明的顺序,拼成单个 .psm1 放进 dist\。 + +.DESCRIPTION + 为什么需要它:拆分源码是为了好读、好 review、让每次改动落在小文件里;但 PowerShell 的 + 脚本模块在**发布形态**上更适合单个文件(加载更快、代码签名只需签一个文件、类与 using + 语句的行为最可预测)。有了这个脚本,"拆开的源码"与"单文件的模块"都随时可得,而不是 + 只能二选一。 + + 顺序从哪儿来:从 BakNRet\BakNRet.psm1 里读出来 —— 顺序只声明一次,这里不重复写一遍。 + 导出清单同理,直接照抄加载器尾部。 + + dist\ 已进 .gitignore:它是可重建的产物,不是源。 + +.PARAMETER WhatIf + 只报告将要拼什么,不写文件。 + +.EXAMPLE + .\tools\Build-BakNRetModule.ps1 -WhatIf + +.EXAMPLE + .\tools\Build-BakNRetModule.ps1 +#> +[CmdletBinding(SupportsShouldProcess = $true)] +param() + +$ErrorActionPreference = 'Stop' + +$projectRoot = Split-Path -Parent $PSScriptRoot +$moduleRoot = Join-Path $projectRoot 'BakNRet' +$loaderPath = Join-Path $moduleRoot 'BakNRet.psm1' + +if (-not (Test-Path -LiteralPath $loaderPath)) { + throw "找不到加载器:$loaderPath" +} + +$loaderLines = @(Get-Content -Encoding UTF8 -LiteralPath $loaderPath) + +# 顺序与导出清单都从加载器读 —— 这样"能不能合回去"不依赖任何人记得同时改两个地方 +$sources = [System.Collections.Generic.List[string]]::new() +foreach ($line in $loaderLines) { + if ($line -match "^\. \(Join-Path \`$PSScriptRoot '([^']+)'\)$") { + $sources.Add((Join-Path $moduleRoot ($Matches[1] -replace '/', '\'))) + } +} +if ($sources.Count -eq 0) { throw '加载器里一个点源都没有 —— 顺序声明丢了?' } +foreach ($source in $sources) { + if (-not (Test-Path -LiteralPath $source)) { throw "加载器指向的文件不存在:$source" } +} + +$exportStart = -1 +for ($i = 0; $i -lt $loaderLines.Count; $i++) { + if ($loaderLines[$i] -match '^Export-ModuleMember\b') { $exportStart = $i; break } +} +if ($exportStart -lt 0) { throw '加载器里找不到 Export-ModuleMember' } +$exportBlock = @($loaderLines[$exportStart..($loaderLines.Count - 1)]) + +$distDir = Join-Path $projectRoot 'dist' +$outputPath = Join-Path $distDir 'BakNRet.psm1' + +Write-Host '' +Write-Host ("源文件 {0} 个" -f $sources.Count) +Write-Host ("产物 {0}" -f $outputPath) + +if (-not $PSCmdlet.ShouldProcess($outputPath, ("拼成单文件({0} 个源文件)" -f $sources.Count))) { + return +} + +if (-not (Test-Path -LiteralPath $distDir)) { + New-Item -ItemType Directory -Path $distDir -Force | Out-Null +} + +$parts = foreach ($source in $sources) { + (@(Get-Content -Encoding UTF8 -LiteralPath $source) -join "`n") +} +$text = (($parts -join "`n`n") + "`n`n" + ($exportBlock -join "`n") + "`n") +[System.IO.File]::WriteAllText($outputPath, $text, (New-Object System.Text.UTF8Encoding($true))) + +# 产物必须能解析 —— 否则"合并"这件事只是把坏掉的东西藏起来 +$errors = $null +[void][System.Management.Automation.Language.Parser]::ParseFile($outputPath, [ref]$null, [ref]$errors) +if ($errors -and $errors.Count) { + throw ("拼出来的单文件解析失败({0} 个错误):{1}" -f $errors.Count, $errors[0].Message) +} + +Write-Host '' +Write-Host ("完成:{0} 行,{1:N1} KB" -f (Get-Content -LiteralPath $outputPath).Count, ((Get-Item $outputPath).Length / 1KB)) -ForegroundColor Green diff --git a/tools/Rename-Archives.ps1 b/tools/Rename-Archives.ps1 index 0698435..18c5370 100644 --- a/tools/Rename-Archives.ps1 +++ b/tools/Rename-Archives.ps1 @@ -48,7 +48,7 @@ $projectRoot = Split-Path -Parent $PSScriptRoot # 一律在这里补 —— 这也是本仓库对 -BackupDir / -ConfigPath 一直在用的写法。 if (-not $BackupListPath) { $BackupListPath = Join-Path $projectRoot 'BackupList.txt' } -Import-Module (Join-Path $projectRoot 'Common.psm1') -Force +Import-Module (Join-Path $projectRoot 'BakNRet\BakNRet.psd1') -Force if (-not $ConfigPath) { $ConfigPath = Join-Path $projectRoot 'BackupConfig.psd1' } $config = Get-BaknretConfig -Path $ConfigPath diff --git a/tools/lab/payload/run-acl-scenario.ps1 b/tools/lab/payload/run-acl-scenario.ps1 index 67eec85..ae92d5a 100644 --- a/tools/lab/payload/run-acl-scenario.ps1 +++ b/tools/lab/payload/run-acl-scenario.ps1 @@ -38,7 +38,7 @@ $ErrorActionPreference = 'Stop' [Console]::InputEncoding = [System.Text.Encoding]::UTF8 $OutputEncoding = [System.Text.Encoding]::UTF8 -Import-Module (Join-Path $RepoPath 'Common.psm1') -Force +Import-Module (Join-Path $RepoPath 'BakNRet\BakNRet.psd1') -Force $script:Passed = 0 $script:Failures = @()