refactor: Common.psm1 拆成 BakNRet/{Public,Private},一函数一文件 + 薄加载器
3152 行、66 个函数的单文件模块拆成: BakNRet\BakNRet.psd1 模块清单:FunctionsToExport 是显式白名单(62 个名字) BakNRet\BakNRet.psm1 加载器:点源顺序的唯一一处声明 BakNRet\Public\*.ps1 62 个对外函数,一函数一文件,文件名 = 函数名 BakNRet\Private\*.ps1 4 个内部函数 + State.ps1(模块级状态集中一处) 为什么是一函数一文件:这是社区里脚本模块的主流形态(调研实测:winutil 79 个、 Terminal-Icons 24 个、ModuleBuilder 23 个,全部如此)。收益是改动落在小文件里、diff 按职责 可读、模块级状态有唯一去处。 为什么这不算"打散":模块内 dot-source 的文件共享同一个模块作用域(实测确认),所以 "按顺序点源 67 个文件"与"点源一个大文件"在语义上等价;顺序只在加载器里出现一次, tools\Build-BakNRetModule.ps1 从那里读出顺序就能拼回单文件 —— 本次产物 dist\BakNRet.psm1 3240 行、两个版本都解析零错。 新增一条断言把这条承诺钉住:加载器点源的文件集合必须与磁盘一致、导出名单必须与 Public\ 一一对应。漏一个文件或漏一个名字就是静默少一个函数 —— 而那种错在运行时只表现为 "找不到命令"。 引用更新:11 个文件里的 Common.psm1 改成 BakNRet\BakNRet.psd1(走清单导入, FunctionsToExport 才真的说了算);Common.psm1 直接删除,不留转发垫片。 验收:test.ps1 9/9 全绿(7 与 5.1),98 个文件两版解析零错,276 个断言原样通过 —— 这次搬家没有改变任何可观察行为。
This commit is contained in:
1 parent
78f43c8cc9
commit
2ad7987ffe
83 files changed
+3547
-3310
No files matched your search
+4
-1
@@ -1,4 +1,4 @@
|
|||||||
# 归档本体不进版本库(数 GB,且是随时可重建的产物)
|
# 归档本体不进版本库(数 GB,且是随时可重建的产物)
|
||||||
Backups/
|
Backups/
|
||||||
|
|
||||||
# 运行日志
|
# 运行日志
|
||||||
@@ -23,3 +23,6 @@ logs/
|
|||||||
*.swp
|
*.swp
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
desktop.ini
|
desktop.ini
|
||||||
|
|
||||||
|
# 单文件构建产物(tools\Build-BakNRetModule.ps1 可随时重建)
|
||||||
|
dist/
|
||||||
+2
-2
@@ -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)) {
|
if (-not (Test-Path -LiteralPath $modulePath)) {
|
||||||
Write-Error "找不到依赖模块:$modulePath,请确保所有文件在同一目录。"
|
Write-Error "找不到依赖模块:$modulePath,请确保所有文件在同一目录。"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -118,7 +118,7 @@ Write-Log ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath
|
|||||||
if (-not (Test-Administrator)) {
|
if (-not (Test-Administrator)) {
|
||||||
Write-Log '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN
|
Write-Log '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN
|
||||||
}
|
}
|
||||||
# 同一份备份目录同一时间只允许一个进程操作(见 Common.psm1 的「运行锁」一节)。
|
# 同一份备份目录同一时间只允许一个进程操作(见 BakNRet 模块的「运行锁」一节)。
|
||||||
# -DryRun 不取锁:它一个字节都不写,没必要被正在跑的备份挡在外面。
|
# -DryRun 不取锁:它一个字节都不写,没必要被正在跑的备份挡在外面。
|
||||||
$runLock = $null
|
$runLock = $null
|
||||||
if (-not $DryRun) {
|
if (-not $DryRun) {
|
||||||
|
|||||||
@@ -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') }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 每一行):
|
||||||
|
|
||||||
|
[+|-] <软件名 或 绝对路径> [修饰符...] [# 说明]
|
||||||
|
[:: <Absolute\Path>] [:- <模式>[,...]] [:+ <包含项>[,...]]
|
||||||
|
[:encrypt | :!encrypt] [@ <Key>='<Value>']
|
||||||
|
|
||||||
|
标记(必须是独立的空白分隔记号,前后都要有空格):
|
||||||
|
+ 仅备份,不恢复(Restore.ps1 跳过)
|
||||||
|
- 仅恢复,不备份(Backup.ps1 跳过)
|
||||||
|
:: 覆盖 Path,等价于 `@ Path='...'`
|
||||||
|
:- 排除模式,等价于 `@ Exclude='...'`
|
||||||
|
:+ 追加包含项(<归档内相对路径>:<宿主机绝对路径>),等价于 `@ Include='...'`
|
||||||
|
:encrypt 该条目加密(`@ Encrypt='$true'`)
|
||||||
|
:!encrypt 该条目不加密(`@ Encrypt='$false'`)
|
||||||
|
@ Key='值' 覆盖 SoftwareCatalog.psd1 里的同名默认字段
|
||||||
|
|
||||||
|
兼容的历史写法:`@encrypt` / `@!encrypt` / `@pathname` / `@root=<名>`,
|
||||||
|
以及用双引号包住路径或模式值。
|
||||||
|
|
||||||
|
归档内布局(SoftwareCatalog.psd1 的 Slot 是包内的一层目录):
|
||||||
|
软件名条目 -> <Slot>\<该 Path 的内容>(Path 是文件时就是名为 <Slot> 的文件)
|
||||||
|
手写路径 -> <路径末级名>\...(历史布局,不变)
|
||||||
|
#>
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# 加载器:下面这份点源顺序是**唯一**一份顺序声明。
|
||||||
|
#
|
||||||
|
# 每个函数一个文件,文件名 = 函数名;要合回单个 .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'
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = @{}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = '(?<![0-9-])' + [regex]::Escape($oldSid) + '(?![0-9])'
|
||||||
|
$text = [regex]::Replace($text, $pattern, $newSid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return $text
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 ' ')
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
function Exit-BaknretRunLock {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
释放运行锁。锁文件本身留着 —— 它的内容是最后一次持有者的线索,删不删都无所谓。
|
||||||
|
#>
|
||||||
|
param($Lock)
|
||||||
|
|
||||||
|
if (-not $Lock) { return }
|
||||||
|
try {
|
||||||
|
$Lock.Dispose()
|
||||||
|
} catch {
|
||||||
|
Write-Log "释放运行锁失败(进程退出时会自动释放):$_" -Level WARN
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 @()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
function Get-BaknretExcludeArgument {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
把一个归档项的模式列表翻译成 7z 的 `-x!` / `-xr!` 参数。
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
传进来的模式**已经按项分配好**(见 Split-BaknretPatternScope),因此这里
|
||||||
|
拿到的模式一律是"相对该项归档根"的:
|
||||||
|
|
||||||
|
* `<相对路径>` -> `-x!<ArchivePath>\<相对路径>`(锚定在归档根)
|
||||||
|
* `!<通配>` -> `-xr!<通配>`(任意层级,模式里的空格自动转 `?`)
|
||||||
|
* `!re:<正则>` -> 遍历源目录翻译成若干 `-x!<完整路径>`(见 Get-BaknretRegexExclude)
|
||||||
|
|
||||||
|
7z 排除语义(已实测确认):
|
||||||
|
* `-x!<完整归档内路径>` 匹配对象的完整路径,所以要带上项自己的归档根名;
|
||||||
|
* 模式里不能有空格,也不能自己写引号;
|
||||||
|
* 参数总长度有上限,超了明确报错,不静默丢规则。
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]$Item,
|
||||||
|
[string[]]$Patterns = @(),
|
||||||
|
[int]$MaxRegexMatches = 300,
|
||||||
|
[int]$MaxCommandLineChars = 15000
|
||||||
|
)
|
||||||
|
|
||||||
|
$arguments = @()
|
||||||
|
$errorText = $null
|
||||||
|
|
||||||
|
foreach ($pattern in @($Patterns)) {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($pattern)) { continue }
|
||||||
|
$text = ([string]$pattern).Trim()
|
||||||
|
|
||||||
|
if ($text.StartsWith('!re:')) {
|
||||||
|
$regexText = $text.Substring(4).Trim()
|
||||||
|
if (-not $regexText) { continue }
|
||||||
|
$expanded = Get-BaknretRegexExclude -Item $Item -Pattern $regexText -MaxMatches $MaxRegexMatches
|
||||||
|
if ($expanded.Error) { $errorText = $expanded.Error; continue }
|
||||||
|
$arguments += @($expanded.Arguments)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($text.StartsWith('!')) {
|
||||||
|
$component = $text.Substring(1).Trim()
|
||||||
|
if (-not $component) { continue }
|
||||||
|
$arguments += ('-xr!{0}' -f ($component -replace ' ', '?'))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$relative = $text.Trim([char[]]@('\', '/'))
|
||||||
|
if (-not $relative) { continue }
|
||||||
|
$arguments += ('-x!{0}\{1}' -f $Item.ArchivePath, ($relative -replace ' ', '?'))
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalChars = 0
|
||||||
|
foreach ($argument in $arguments) { $totalChars += $argument.Length + 1 }
|
||||||
|
if (-not $errorText -and $totalChars -gt $MaxCommandLineChars) {
|
||||||
|
$errorText = "排除参数合计约 $totalChars 字符,超过命令行安全长度;请用更粗的通配模式(例如 !*Cache)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return , [pscustomobject]@{ Arguments = @($arguments); Error = $errorText }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
function Get-BaknretLogPath {
|
||||||
|
<# .SYNOPSIS 返回当前日志文件路径(未启用时返回 $null)。 #>
|
||||||
|
return $script:LogConfig.FilePath
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
function Get-BaknretRunLockPath {
|
||||||
|
<# .SYNOPSIS 运行锁文件的位置(放在备份目录里,与它保护的账本同处)。 #>
|
||||||
|
param([Parameter(Mandatory = $true)][string]$Directory)
|
||||||
|
return (Join-Path $Directory '.baknret.lock')
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
function Get-BaknretSecurityRecords {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
采集一组归档项的安全描述符,键是**归档内相对路径**(`<Slot>\…`)。
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
键用归档内路径而不是宿主机路径:目标机器上 `%UserProfile%` 会变、名录的前缀补全
|
||||||
|
(legendary -> legendary_2.0.4)也会变,只有归档内相对路径在两端是同一个坐标系。
|
||||||
|
|
||||||
|
遍历用显式栈,并且**跳过 reparse point**:PS 5.1 的 Get-ChildItem -Recurse 会
|
||||||
|
跟着 junction 无限转;scoop 的 `apps\<app>\current` 就是 junction,正撞在这个坑上。
|
||||||
|
|
||||||
|
$ScopeMap 由 Split-BaknretPatternScope 产出(项下标 -> 该相对根的模式数组),
|
||||||
|
所以这里的排除判定与真正交给 7z 的 -x! / -xr! 是同一套规则。
|
||||||
|
|
||||||
|
Mode:
|
||||||
|
* Roots —— 只存每个归档项的根(最省,适合"权限只在根上"的场景)
|
||||||
|
* Smart —— 根 + 所有"继承复现不出来"的对象(默认;几万文件的树 sidecar 也只有几百 KB)
|
||||||
|
* Full —— 每一个对象都存(最保险,sidecar 会大到几 MB)
|
||||||
|
|
||||||
|
返回 [pscustomobject]@{ Records; Scanned; Kept; Errors }。
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[array]$Items = @(),
|
||||||
|
[hashtable]$ScopeMap = @{},
|
||||||
|
[ValidateSet('Roots', 'Smart', 'Full')][string]$Mode = 'Smart',
|
||||||
|
[switch]$IncludeSacl
|
||||||
|
)
|
||||||
|
|
||||||
|
$records = New-Object System.Collections.Generic.List[object]
|
||||||
|
$scanned = 0
|
||||||
|
$errorCount = 0
|
||||||
|
|
||||||
|
# 读安全描述符要 READ_CONTROL:系统目录里读不到是常态(C:\ProgramData 下就有
|
||||||
|
# Get-Acl 直接报 "Attempted to perform an unauthorized operation" 的目录)。
|
||||||
|
# SeBackupPrivilege 启用后系统会把读权限授予任何文件;连它都没有的账户,
|
||||||
|
# 读不到的对象会带 e 字段落进 sidecar,而不是被静默当成"没有特殊权限"。
|
||||||
|
$privileges = @('SeBackupPrivilege')
|
||||||
|
if ($IncludeSacl) { $privileges += 'SeSecurityPrivilege' }
|
||||||
|
Enable-BaknretPrivilege -Name $privileges | Out-Null
|
||||||
|
|
||||||
|
for ($index = 0; $index -lt $Items.Count; $index++) {
|
||||||
|
$item = $Items[$index]
|
||||||
|
if (-not $item) { continue }
|
||||||
|
|
||||||
|
$archiveRoot = [string]$item.ArchivePath
|
||||||
|
$real = [string]$item.RealPath
|
||||||
|
if ([string]::IsNullOrWhiteSpace($archiveRoot) -or [string]::IsNullOrWhiteSpace($real)) { continue }
|
||||||
|
if (-not (Test-Path -LiteralPath $real)) { continue }
|
||||||
|
|
||||||
|
$patterns = @()
|
||||||
|
if ($ScopeMap -and $ScopeMap.ContainsKey($index)) { $patterns = @($ScopeMap[$index]) }
|
||||||
|
|
||||||
|
$rootItem = Get-Item -LiteralPath $real -Force -ErrorAction SilentlyContinue
|
||||||
|
if (-not $rootItem) { continue }
|
||||||
|
|
||||||
|
if (-not $rootItem.PSIsContainer) {
|
||||||
|
$record = Get-BaknretSecurityRecord -Path $real -Key $archiveRoot -Kind 'f' -IncludeSacl:$IncludeSacl
|
||||||
|
$scanned++
|
||||||
|
if ($record.e) { $errorCount++ }
|
||||||
|
$records.Add($record)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$rootRecord = Get-BaknretSecurityRecord -Path $real -Key $archiveRoot -Kind 'd' -IncludeSacl:$IncludeSacl
|
||||||
|
$scanned++
|
||||||
|
if ($rootRecord.e) { $errorCount++ }
|
||||||
|
$records.Add($rootRecord)
|
||||||
|
|
||||||
|
if ($Mode -eq 'Roots') { continue }
|
||||||
|
|
||||||
|
$pending = New-Object System.Collections.Generic.Stack[object]
|
||||||
|
$pending.Push(@{
|
||||||
|
Dir = $rootItem
|
||||||
|
Rel = ''
|
||||||
|
Owner = $rootRecord.o
|
||||||
|
Group = $rootRecord.g
|
||||||
|
Inheritable = $rootRecord.Inheritable
|
||||||
|
Signatures = $rootRecord.AllSignatures
|
||||||
|
})
|
||||||
|
|
||||||
|
while ($pending.Count -gt 0) {
|
||||||
|
$frame = $pending.Pop()
|
||||||
|
foreach ($child in @(Get-ChildItem -LiteralPath $frame.Dir.FullName -Force -ErrorAction SilentlyContinue)) {
|
||||||
|
if ($child.Attributes -band [IO.FileAttributes]::ReparsePoint) { continue }
|
||||||
|
|
||||||
|
$childRel = if ($frame.Rel) { $frame.Rel + '\' + $child.Name } else { $child.Name }
|
||||||
|
if (Test-BaknretPathExcluded -RelativePath $childRel -Patterns $patterns) { continue }
|
||||||
|
|
||||||
|
$kind = if ($child.PSIsContainer) { 'd' } else { 'f' }
|
||||||
|
$record = Get-BaknretSecurityRecord -Path $child.FullName -Key ($archiveRoot + '\' + $childRel) `
|
||||||
|
-Kind $kind -IncludeSacl:$IncludeSacl -ParentSignatures $frame.Signatures
|
||||||
|
$scanned++
|
||||||
|
if ($record.e) { $errorCount++ }
|
||||||
|
|
||||||
|
if ($Mode -eq 'Full') {
|
||||||
|
$records.Add($record)
|
||||||
|
} elseif (Test-BaknretSecurityRecordNeeded -Record $record `
|
||||||
|
-ParentOwner $frame.Owner -ParentGroup $frame.Group `
|
||||||
|
-ParentInheritable $frame.Inheritable -ParentSignatures $frame.Signatures) {
|
||||||
|
$records.Add($record)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($child.PSIsContainer) {
|
||||||
|
$pending.Push(@{
|
||||||
|
Dir = $child
|
||||||
|
Rel = $childRel
|
||||||
|
Owner = $record.o
|
||||||
|
Group = $record.g
|
||||||
|
Inheritable = $record.Inheritable
|
||||||
|
Signatures = $record.AllSignatures
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Records = @($records.ToArray())
|
||||||
|
Scanned = $scanned
|
||||||
|
Kept = $records.Count
|
||||||
|
Errors = $errorCount
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
function Get-SoftwareCatalog {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
载入"软件名 -> Slot 组"名录。
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
新结构(SoftwareCatalog.psd1):
|
||||||
|
|
||||||
|
@{
|
||||||
|
<软件名> = @{
|
||||||
|
<Slot 名> = @{
|
||||||
|
Path = '宿主机绝对路径'
|
||||||
|
Exclude = '!*Cache,Default\Extensions' # 可选
|
||||||
|
Include = 'Modules:D:\extra\ps-modules' # 可选
|
||||||
|
Encrypt = $true # 可选,默认 $false
|
||||||
|
Description = '这个 Slot 是干什么的' # 可选
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Slot 是**归档内的一层目录**:`<Slot>\<该 Path 的内容>`。一个软件一个归档,
|
||||||
|
因此同名的目录(例如 scoop 的用户 persist 与全局 persist)只要放在不同 Slot 里就不会撞。
|
||||||
|
|
||||||
|
返回按软件名索引的哈希表,每项:
|
||||||
|
|
||||||
|
Name / Path / Description / Slots / Kind / Missing / Error / Raw
|
||||||
|
|
||||||
|
Slot 对象:Name / Declared / Resolved / Exists / IsFile / Suffixed /
|
||||||
|
Description / Exclude / Include / Encrypt
|
||||||
|
|
||||||
|
读取结果按"文件路径 + 时间戳 + 长度 + 内容 MD5"缓存:一次运行里名录只会真正
|
||||||
|
读一次(旧实现每解析一个条目就重新 Import 一遍,还会把 `$( ... )` 反复求值)。
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Path,
|
||||||
|
[int]$MaxDepth = 5,
|
||||||
|
[switch]$NoCache
|
||||||
|
)
|
||||||
|
|
||||||
|
$result = @{}
|
||||||
|
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return $result }
|
||||||
|
|
||||||
|
$stamp = $null
|
||||||
|
if (-not $NoCache) {
|
||||||
|
try {
|
||||||
|
$item = Get-Item -LiteralPath $Path -ErrorAction Stop
|
||||||
|
$hash = (Get-FileHash -LiteralPath $Path -Algorithm MD5 -ErrorAction Stop).Hash
|
||||||
|
$stamp = '{0}-{1}-{2}' -f $item.LastWriteTimeUtc.Ticks, $item.Length, $hash
|
||||||
|
if ($script:CatalogCache.ContainsKey($Path) -and $script:CatalogCache[$Path].Stamp -eq $stamp) {
|
||||||
|
return $script:CatalogCache[$Path].Data
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
$stamp = $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $null
|
||||||
|
try {
|
||||||
|
$data = Import-BaknretDataFile -Path $Path
|
||||||
|
} catch {
|
||||||
|
Write-Log "软件名录读取失败:$Path —— $_" -Level ERROR
|
||||||
|
return $result
|
||||||
|
}
|
||||||
|
|
||||||
|
# 递归引入其它名录文件(路径相对本文件)
|
||||||
|
$includeValue = Get-BaknretMapValue -Map $data -Key 'Includes'
|
||||||
|
if ($includeValue) {
|
||||||
|
$baseDir = Split-Path -Parent $Path
|
||||||
|
foreach ($include in @($includeValue)) {
|
||||||
|
if (-not $include) { continue }
|
||||||
|
$includePath = [string]$include
|
||||||
|
if (-not [System.IO.Path]::IsPathRooted($includePath)) { $includePath = Join-Path $baseDir $includePath }
|
||||||
|
$included = Get-SoftwareCatalog -Path $includePath -MaxDepth $MaxDepth
|
||||||
|
foreach ($includedName in $included.Keys) {
|
||||||
|
if ($result.ContainsKey($includedName)) { continue }
|
||||||
|
$result[$includedName] = $included[$includedName]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($key in @(Get-BaknretMapKeys -Map $data | Where-Object { $_ -ne 'Includes' })) {
|
||||||
|
$name = Format-CatalogName -Name ([string]$key)
|
||||||
|
if (-not $name) { continue }
|
||||||
|
|
||||||
|
$raw = Get-BaknretMapValue -Map $data -Key $key
|
||||||
|
if ($raw -isnot [System.Collections.IDictionary] -and $null -ne $raw -and -not ($raw -is [psobject] -and @($raw.PSObject.Properties.Name).Count -gt 0)) {
|
||||||
|
Write-Log "名录条目 '$key' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }" -Level ERROR
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$slots = @()
|
||||||
|
$errors = @()
|
||||||
|
|
||||||
|
foreach ($slotKey in @(Get-BaknretMapKeys -Map $raw)) {
|
||||||
|
$slotName = ([string]$slotKey).Trim()
|
||||||
|
if (-not $slotName) { continue }
|
||||||
|
|
||||||
|
$slotRaw = Get-BaknretMapValue -Map $raw -Key $slotKey
|
||||||
|
if ($slotRaw -isnot [System.Collections.IDictionary] -and -not ($slotRaw -is [psobject])) {
|
||||||
|
$errors += "Slot $slotName 的写法不对,应写成 @{ Path = '...' }"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$declaredRaw = [string](Get-BaknretMapValue -Map $slotRaw -Key 'Path')
|
||||||
|
if ([string]::IsNullOrWhiteSpace($declaredRaw)) {
|
||||||
|
$errors += "Slot $slotName 缺少 Path"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$declared = (Expand-CatalogPathText -Text $declaredRaw).Trim()
|
||||||
|
if ([string]::IsNullOrWhiteSpace($declared)) {
|
||||||
|
$errors += "Slot $slotName 的 Path 展开成空:$declaredRaw"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
# 逐个候选目录解析。一个 Slot 是归档内的一层目录,只能对应一个目录:
|
||||||
|
# 补全出多个候选(同名目录分散在多处)时必须拆成多个 Slot,否则会混成一棵树。
|
||||||
|
$candidates = @()
|
||||||
|
if (Test-Path -LiteralPath $declared) {
|
||||||
|
$candidates = @($declared)
|
||||||
|
} else {
|
||||||
|
$parent = Split-Path -Path $declared -Parent
|
||||||
|
$leafName = Split-Path -Path $declared -Leaf
|
||||||
|
if ($parent -and $leafName -and (Test-Path -LiteralPath $parent)) {
|
||||||
|
$candidates = @(Find-ChildDirectoryByName -Parent $parent -Name $leafName -MaxDepth $MaxDepth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($candidates.Count -gt 1) {
|
||||||
|
$errors += ("Slot {0} 的 Path 匹配到 {1} 个目录:{2};一个 Slot 只能对应一个目录,请拆成多个 Slot" -f `
|
||||||
|
$slotName, $candidates.Count, ($candidates -join '、'))
|
||||||
|
}
|
||||||
|
|
||||||
|
$exists = $candidates.Count -ge 1
|
||||||
|
$resolved = if ($exists) { $candidates[0] } else { $declared }
|
||||||
|
$isFile = $false
|
||||||
|
$suffixed = $false
|
||||||
|
if ($exists) {
|
||||||
|
$suffixed = -not ($resolved -ieq $declared)
|
||||||
|
$resolvedItem = Get-Item -LiteralPath $resolved -Force -ErrorAction SilentlyContinue
|
||||||
|
if ($resolvedItem) { $isFile = -not $resolvedItem.PSIsContainer }
|
||||||
|
}
|
||||||
|
|
||||||
|
$excludeText = [string](Get-BaknretMapValue -Map $slotRaw -Key 'Exclude')
|
||||||
|
$includeText = [string](Get-BaknretMapValue -Map $slotRaw -Key 'Include')
|
||||||
|
$encryptValue = Get-BaknretMapValue -Map $slotRaw -Key 'Encrypt'
|
||||||
|
$description = [string](Get-BaknretMapValue -Map $slotRaw -Key 'Description')
|
||||||
|
|
||||||
|
$slots += [pscustomobject]@{
|
||||||
|
Name = $slotName
|
||||||
|
Declared = $declared
|
||||||
|
Resolved = $resolved
|
||||||
|
Exists = $exists
|
||||||
|
IsFile = $isFile
|
||||||
|
Suffixed = $suffixed
|
||||||
|
Description = $description
|
||||||
|
Exclude = @(ConvertFrom-BaknretPatternList -Values @($excludeText))
|
||||||
|
Include = @(ConvertFrom-BaknretPatternList -Values @($includeText))
|
||||||
|
Encrypt = [bool]$encryptValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($slots.Count -eq 0 -and $errors.Count -eq 0) { continue }
|
||||||
|
|
||||||
|
# PowerShell 的哈希表不保留书写顺序,而 Slot 的顺序会影响归档内条目顺序与
|
||||||
|
# "第一个 Slot" 的取值,所以这里按名字排序,保证每次运行完全一致。
|
||||||
|
$slots = @($slots | Sort-Object -Property Name)
|
||||||
|
|
||||||
|
$existing = @($slots | Where-Object { $_.Exists })
|
||||||
|
$missing = @($slots | Where-Object { -not $_.Exists })
|
||||||
|
$kind = if ($slots.Count -eq 0) { 'Invalid' }
|
||||||
|
elseif ($existing.Count -eq 0) { 'Unresolved' }
|
||||||
|
elseif ($missing.Count -gt 0) { 'Partial' }
|
||||||
|
elseif ($slots.Count -gt 1) { 'Multi' }
|
||||||
|
else { 'Single' }
|
||||||
|
|
||||||
|
if ($errors.Count -gt 0) {
|
||||||
|
Write-Log ("名录条目 {0} 有问题:{1}" -f $name, ($errors -join ';')) -Level ERROR
|
||||||
|
} elseif ($missing.Count -gt 0) {
|
||||||
|
Write-Log ("名录:{0} 有 {1} 个 Slot 的路径当前不存在:{2}" -f $name, $missing.Count, (($missing | ForEach-Object { $_.Declared }) -join ';')) -Level DEBUG
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($slots.Count -gt 0) {
|
||||||
|
Write-Log ("名录:{0} -> {1} 个 Slot,其中存在 {2} 个" -f $name, $slots.Count, $existing.Count) -Level DEBUG
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result.ContainsKey($name)) {
|
||||||
|
Write-Log ("名录里有两条规范化之后同名的条目:{0}(后者覆盖前者)" -f $name) -Level WARN
|
||||||
|
}
|
||||||
|
|
||||||
|
$result[$name] = [pscustomobject]@{
|
||||||
|
Name = $name
|
||||||
|
Path = $(if ($slots.Count -gt 0) { $slots[0].Declared } else { $null })
|
||||||
|
Description = $(if ($slots.Count -gt 0) { $slots[0].Description } else { $null })
|
||||||
|
Slots = @($slots)
|
||||||
|
Kind = $kind
|
||||||
|
Missing = @($missing | ForEach-Object { $_.Declared })
|
||||||
|
Error = $(if ($errors.Count -gt 0) { $errors -join ';' } else { $null })
|
||||||
|
Raw = $raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stamp) {
|
||||||
|
$script:CatalogCache[$Path] = [pscustomobject]@{ Stamp = $stamp; Data = $result }
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
function New-BaknretArchiveItem {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
构造一个"归档项":宿主机上的一个目录 / 文件,对应归档内的一条路径。
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
ArchivePath 是**归档内的相对路径**,语义分两种:
|
||||||
|
* 目录项 -> `<ArchivePath>\<目录内容>`(ArchivePath 是容器)
|
||||||
|
* 文件项 -> `<ArchivePath>` 就是那个文件本身
|
||||||
|
这样"是目录还是文件"只看归档就能判断,恢复端不必猜。
|
||||||
|
|
||||||
|
Origin 说明这个项是怎么来的(catalog / path / include),运行时会逐条打印,
|
||||||
|
方便回答"这个目录为什么会在包里"。
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$ArchivePath,
|
||||||
|
[Parameter(Mandatory = $true)][string]$RealPath,
|
||||||
|
[ValidateSet('slot', 'path', 'include')][string]$Kind = 'slot',
|
||||||
|
[string]$Slot = $null,
|
||||||
|
[string]$Description = $null,
|
||||||
|
[string]$Origin = 'catalog',
|
||||||
|
[bool]$Exists = $false,
|
||||||
|
[bool]$IsFile = $false,
|
||||||
|
[string[]]$Exclude = @()
|
||||||
|
)
|
||||||
|
|
||||||
|
$clean = ([string]$ArchivePath).Trim().Trim([char[]]@('\', '/'))
|
||||||
|
return [pscustomobject]@{
|
||||||
|
ArchivePath = $clean
|
||||||
|
TopName = (Get-BaknretArchiveTopName -ArchivePath $clean)
|
||||||
|
RealPath = $RealPath
|
||||||
|
Kind = $Kind
|
||||||
|
Slot = $Slot
|
||||||
|
Description = $Description
|
||||||
|
Origin = $Origin
|
||||||
|
Exists = $Exists
|
||||||
|
IsFile = $IsFile
|
||||||
|
Exclude = @($Exclude)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
function New-BaknretJunction {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
建一个 junction;失败时抛异常(调用方决定降级还是报错)。
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
恢复时用它做"零拷贝落地":把 `<目标父目录>\<Slot>` 建成指向真实目标目录的
|
||||||
|
junction,再让 7z 往那里解(写入会穿过 junction 落到真实目录里),
|
||||||
|
解完立刻拆掉连接点。这样不必"先解到临时目录再整体搬一遍"。
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Path,
|
||||||
|
[Parameter(Mandatory = $true)][string]$Target
|
||||||
|
)
|
||||||
|
|
||||||
|
if (Test-Path -LiteralPath $Path) {
|
||||||
|
throw "连接点目标已存在:$Path"
|
||||||
|
}
|
||||||
|
New-Item -ItemType Junction -Path $Path -Target $Target -ErrorAction Stop | Out-Null
|
||||||
|
return $Path
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>\<Path 内容>`(文件 Slot 就是名为 `<Slot>` 的文件);
|
||||||
|
* 手写路径 -> `<末级名>\...`(与历史归档一致,不变)。
|
||||||
|
|
||||||
|
名录里的 Slot 存在但路径当前不存在时**照样产出归档项**:源被删掉正是要恢复的场景,
|
||||||
|
备份端按存在性跳过,恢复端靠它把内容还原回原位。
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
$Entry,
|
||||||
|
[string]$CatalogPath,
|
||||||
|
[int]$MaxDepth = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
$isName = -not (Test-LiteralPath -Path $Entry.Path)
|
||||||
|
$forcePathFlavor = ($Entry.Flags -contains 'pathname')
|
||||||
|
$baseName = Get-ItemArchiveName -Entry $Entry -CatalogPath $CatalogPath -MaxDepth $MaxDepth
|
||||||
|
|
||||||
|
$overrides = $Entry.Overrides
|
||||||
|
if (-not $overrides) { $overrides = @{} }
|
||||||
|
$overridePath = if ($overrides.ContainsKey('Path')) { [string]$overrides['Path'] } else { $null }
|
||||||
|
$hasExcludeOverride = $overrides.ContainsKey('Exclude')
|
||||||
|
$entryExclude = if ($hasExcludeOverride) { @($overrides['Exclude']) } else { @() }
|
||||||
|
$hasIncludeOverride = $overrides.ContainsKey('Include')
|
||||||
|
$entryInclude = if ($hasIncludeOverride) { @($overrides['Include']) } else { @() }
|
||||||
|
$hasEncryptOverride = $overrides.ContainsKey('Encrypt')
|
||||||
|
|
||||||
|
$items = @()
|
||||||
|
$catalogEntry = $null
|
||||||
|
$errorText = $null
|
||||||
|
$blocking = $null
|
||||||
|
$archiveFlavor = if ($isName) { 'name' } else { 'path' }
|
||||||
|
|
||||||
|
if (-not $isName) {
|
||||||
|
# ---- 写法二:用户手写的目录 / 文件 ----
|
||||||
|
$real = [string]$Entry.Path
|
||||||
|
if ($overridePath) { $real = $overridePath }
|
||||||
|
$real = [Environment]::ExpandEnvironmentVariables($real).Trim()
|
||||||
|
|
||||||
|
$leaf = Split-Path -Path $real -Leaf
|
||||||
|
if ($forcePathFlavor) { $archiveFlavor = 'path' }
|
||||||
|
|
||||||
|
$exists = $false
|
||||||
|
$isFile = $false
|
||||||
|
if ($real) {
|
||||||
|
$exists = Test-Path -LiteralPath $real
|
||||||
|
if ($exists) {
|
||||||
|
$item = Get-Item -LiteralPath $real -Force -ErrorAction SilentlyContinue
|
||||||
|
if ($item) { $isFile = -not $item.PSIsContainer }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($leaf) -or [string]::IsNullOrWhiteSpace($real)) {
|
||||||
|
$errorText = "无法从路径里拆出末级名:$real"
|
||||||
|
} else {
|
||||||
|
$items += New-BaknretArchiveItem -ArchivePath $leaf -RealPath $real -Kind 'path' `
|
||||||
|
-Origin 'path' -Exists $exists -IsFile $isFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# ---- 写法一:软件名录里的软件名 ----
|
||||||
|
$catalog = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth
|
||||||
|
if (-not $catalog.ContainsKey($Entry.Path)) {
|
||||||
|
$errorText = "软件名录里没有 '$($Entry.Path)'"
|
||||||
|
} else {
|
||||||
|
$catalogEntry = $catalog[$Entry.Path]
|
||||||
|
# 名录条目自身有问题(Slot 缺 Path、前缀补全命中多个目录……)时整条失败:
|
||||||
|
# 继续跑只会"少打包一块"或"任选一个目录",那正是最该避免的静默错误。
|
||||||
|
if ($catalogEntry.Error) {
|
||||||
|
$errorText = $catalogEntry.Error
|
||||||
|
if (-not $blocking) { $blocking = "软件名录里的 '$($Entry.Path)' 有问题:$($catalogEntry.Error)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
$slots = @($catalogEntry.Slots)
|
||||||
|
if ($overridePath -and $slots.Count -ne 1) {
|
||||||
|
$blocking = ("'{0}' 有 {1} 个 Slot,不能用一个 `::` / `@ Path=` 覆盖路径;请写清楚是哪个 Slot" -f `
|
||||||
|
$Entry.Path, $slots.Count)
|
||||||
|
} else {
|
||||||
|
foreach ($slot in $slots) {
|
||||||
|
$resolvedPath = $slot.Resolved
|
||||||
|
$exists = $slot.Exists
|
||||||
|
$isFile = $slot.IsFile
|
||||||
|
|
||||||
|
if ($overridePath) {
|
||||||
|
$resolvedPath = [Environment]::ExpandEnvironmentVariables($overridePath).Trim()
|
||||||
|
$exists = Test-Path -LiteralPath $resolvedPath
|
||||||
|
$isFile = $false
|
||||||
|
if ($exists) {
|
||||||
|
$item = Get-Item -LiteralPath $resolvedPath -Force -ErrorAction SilentlyContinue
|
||||||
|
if ($item) { $isFile = -not $item.PSIsContainer }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$items += New-BaknretArchiveItem -ArchivePath $slot.Name -RealPath $resolvedPath -Kind 'slot' `
|
||||||
|
-Slot $slot.Name -Description $slot.Description -Origin 'catalog' `
|
||||||
|
-Exists $exists -IsFile $isFile -Exclude $slot.Exclude
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 包含项:`<归档内相对路径>:<宿主机绝对路径>`,把宿主机上的目录 / 文件放到包内指定位置。
|
||||||
|
# 条目级写 `:+` / `@ Include=` 就覆盖名录里的 Include;没写就用名录里各 Slot 的。
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
$includeTexts = @()
|
||||||
|
if ($hasIncludeOverride) {
|
||||||
|
$includeTexts = @($entryInclude)
|
||||||
|
} elseif ($catalogEntry) {
|
||||||
|
foreach ($slot in @($catalogEntry.Slots)) { $includeTexts += @($slot.Include) }
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($includeText in $includeTexts) {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($includeText)) { continue }
|
||||||
|
$text = ([string]$includeText).Trim()
|
||||||
|
|
||||||
|
$archivePart = ''
|
||||||
|
$hostPart = $text
|
||||||
|
$separator = $text.IndexOf(':')
|
||||||
|
if ($separator -ge 0) {
|
||||||
|
$archivePart = $text.Substring(0, $separator).Trim()
|
||||||
|
$hostPart = $text.Substring($separator + 1).Trim()
|
||||||
|
|
||||||
|
# 写成 `D:\extra\ps-modules:Modules`(宿主机在前)时纠正并告警。
|
||||||
|
# 判据:第一个冒号前只有盘符那一个字母,而且紧跟着 `\` 或 `/`。
|
||||||
|
# 这时按**最后一个**冒号切,才能把宿主机路径完整地拿回来。
|
||||||
|
if ($archivePart -match '^[A-Za-z]$' -and ($hostPart.StartsWith('\') -or $hostPart.StartsWith('/'))) {
|
||||||
|
$lastSeparator = $text.LastIndexOf(':')
|
||||||
|
if ($lastSeparator -gt $separator) {
|
||||||
|
Write-Log ("包含项写得像'宿主机:归档内':{0} —— 语法应为 <归档内相对路径>:<宿主机绝对路径>,已按后者解释" -f $text) -Level WARN
|
||||||
|
$hostPart = $text.Substring(0, $lastSeparator).Trim()
|
||||||
|
$archivePart = $text.Substring($lastSeparator + 1).Trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$hostPath = [Environment]::ExpandEnvironmentVariables($hostPart).Trim()
|
||||||
|
if ([string]::IsNullOrWhiteSpace($hostPath)) {
|
||||||
|
Write-Log "包含项 '$text' 里没有宿主机路径,已忽略" -Level WARN
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$exists = Test-Path -LiteralPath $hostPath
|
||||||
|
$isFile = $false
|
||||||
|
if ($exists) {
|
||||||
|
$item = Get-Item -LiteralPath $hostPath -Force -ErrorAction SilentlyContinue
|
||||||
|
if ($item) { $isFile = -not $item.PSIsContainer }
|
||||||
|
}
|
||||||
|
|
||||||
|
$archivePath = $archivePart
|
||||||
|
if ([string]::IsNullOrWhiteSpace($archivePath)) { $archivePath = Split-Path -Path $hostPath -Leaf }
|
||||||
|
|
||||||
|
$items += New-BaknretArchiveItem -ArchivePath $archivePath -RealPath $hostPath -Kind 'include' `
|
||||||
|
-Origin 'include' -Exists $exists -IsFile $isFile
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 归档内路径冲突拦截
|
||||||
|
# 一个目录 / 文件在包内只能有一个位置:重名会互相覆盖,祖宗关系会混成一棵树。
|
||||||
|
# 宁可明确报错,也不要静默搅在一起。
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
$seen = @{}
|
||||||
|
$collisions = @()
|
||||||
|
foreach ($item in $items) {
|
||||||
|
$key = ([string]$item.ArchivePath).ToLower()
|
||||||
|
if (-not $key) { continue }
|
||||||
|
if ($seen.ContainsKey($key)) {
|
||||||
|
$collisions += ("'{0}'({1} 与 {2})" -f $item.ArchivePath, $seen[$key], $item.RealPath)
|
||||||
|
} else {
|
||||||
|
$seen[$key] = $item.RealPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($item in $items) {
|
||||||
|
$key = ([string]$item.ArchivePath).ToLower()
|
||||||
|
foreach ($other in $seen.Keys) {
|
||||||
|
if ($other -eq $key) { continue }
|
||||||
|
if ($other.StartsWith("$key\") -or $key.StartsWith("$other\")) {
|
||||||
|
$collisions += ("'{0}' 与 '{1}' 是父子关系,包内会互相覆盖" -f $item.ArchivePath, $other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$collisions = @($collisions | Select-Object -Unique)
|
||||||
|
|
||||||
|
if ($collisions.Count -gt 0) {
|
||||||
|
$blocking = ("归档内路径冲突:{0}。每个 Slot / 追加项在包内必须有唯一位置," +
|
||||||
|
"请改 Slot 名或归档内相对路径。") -f ($collisions -join ';')
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 加密:清单覆盖优先,其次是名录里各 Slot 的 Encrypt 取或。
|
||||||
|
# 一个软件一个归档,所以 Slot 之间不一致时按"加密"处理(宁可多加密,不可漏加密)。
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
$encrypt = $false
|
||||||
|
if ($hasEncryptOverride) {
|
||||||
|
$encrypt = [bool]$overrides['Encrypt']
|
||||||
|
} elseif ($catalogEntry) {
|
||||||
|
$slots = @($catalogEntry.Slots)
|
||||||
|
$encryptedSlots = @($slots | Where-Object { $_.Encrypt })
|
||||||
|
$encrypt = $encryptedSlots.Count -gt 0
|
||||||
|
if ($encryptedSlots.Count -gt 0 -and $encryptedSlots.Count -lt $slots.Count) {
|
||||||
|
Write-Log ("{0}:名录里各 Slot 的 Encrypt 不一致,整个归档按加密处理" -f $Entry.Path) -Level WARN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [pscustomobject]@{
|
||||||
|
IsName = [bool]$isName
|
||||||
|
CatalogEntry = $catalogEntry
|
||||||
|
BaseName = $baseName
|
||||||
|
ArchiveFlavor = $archiveFlavor
|
||||||
|
Direction = $Entry.Direction
|
||||||
|
Items = @($items)
|
||||||
|
Encrypt = [bool]$encrypt
|
||||||
|
ExcludePatterns = @($entryExclude)
|
||||||
|
HasExcludeOverride = [bool]$hasExcludeOverride
|
||||||
|
Includes = @($entryInclude)
|
||||||
|
HasIncludeOverride = [bool]$hasIncludeOverride
|
||||||
|
Source = $Entry.Path
|
||||||
|
Error = $errorText
|
||||||
|
Blocking = $blocking
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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' }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
function Set-BaknretDebug {
|
||||||
|
<# .SYNOPSIS 打开 DEBUG 级别日志。 #>
|
||||||
|
param([switch]$Enabled = $true)
|
||||||
|
$script:LogConfig.EnableDebug = [bool]$Enabled
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
function Start-BaknretLog {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
把后续日志同时写入 <Directory>/<Prefix>-<时间戳>.log,返回日志文件路径。
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Directory,
|
||||||
|
[string]$Prefix = 'run'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $Directory)) {
|
||||||
|
New-Item -ItemType Directory -Path $Directory -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = '{0}-{1}.log' -f $Prefix, (Get-Date -Format 'yyyyMMdd-HHmmss')
|
||||||
|
$path = Join-Path $Directory $name
|
||||||
|
$script:LogConfig.FilePath = $path
|
||||||
|
[System.IO.File]::WriteAllText($path, '', $script:LogEncoding)
|
||||||
|
return $path
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
function Stop-BaknretLog {
|
||||||
|
<# .SYNOPSIS 停止写入日志文件。 #>
|
||||||
|
$script:LogConfig.FilePath = $null
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
function Test-Administrator {
|
||||||
|
<# .SYNOPSIS 当前进程是否以管理员身份运行。 #>
|
||||||
|
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
|
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 ' 排除:无(整包收下)'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
# 日志落盘失败时保持沉默:不能因为写日志失败而让备份失败。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
-3296
File diff suppressed because it is too large.
Load diff
+2
-2
@@ -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)) {
|
if (-not (Test-Path -LiteralPath $modulePath)) {
|
||||||
Write-Error "找不到依赖模块:$modulePath,请确保所有文件在同一目录。"
|
Write-Error "找不到依赖模块:$modulePath,请确保所有文件在同一目录。"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -109,7 +109,7 @@ if ($WhatIfPreference) { Write-Log '试运行模式(-WhatIf / -DryRun):不
|
|||||||
if (-not (Test-Administrator)) {
|
if (-not (Test-Administrator)) {
|
||||||
Write-Log '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN
|
Write-Log '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN
|
||||||
}
|
}
|
||||||
# 同一份备份目录同一时间只允许一个进程操作(见 Common.psm1 的「运行锁」一节)。
|
# 同一份备份目录同一时间只允许一个进程操作(见 BakNRet 模块的「运行锁」一节)。
|
||||||
# 三种只读模式不取锁:它们一个字节都不写,没必要被正在跑的备份挡在外面。
|
# 三种只读模式不取锁:它们一个字节都不写,没必要被正在跑的备份挡在外面。
|
||||||
$runLock = $null
|
$runLock = $null
|
||||||
if (-not $WhatIfPreference -and -not $VerifyOnly) {
|
if (-not $WhatIfPreference -and -not $VerifyOnly) {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ BeforeAll {
|
|||||||
$script:RestoreScript = Join-Path $script:ProjectRoot 'Restore.ps1'
|
$script:RestoreScript = Join-Path $script:ProjectRoot 'Restore.ps1'
|
||||||
$script:SevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
|
$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))
|
$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
|
New-Item -ItemType Directory -Path $script:Sandbox -Force | Out-Null
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ BeforeAll {
|
|||||||
$script:BackupScript = Join-Path $script:ProjectRoot 'Backup.ps1'
|
$script:BackupScript = Join-Path $script:ProjectRoot 'Backup.ps1'
|
||||||
$script:RestoreScript = Join-Path $script:ProjectRoot 'Restore.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))
|
$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
|
New-Item -ItemType Directory -Path $script:Sandbox -Force | Out-Null
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ BeforeAll {
|
|||||||
$script:RestoreScript = Join-Path $script:ProjectRoot 'Restore.ps1'
|
$script:RestoreScript = Join-Path $script:ProjectRoot 'Restore.ps1'
|
||||||
$script:SevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
|
$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))
|
$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
|
New-Item -ItemType Directory -Path $script:Sandbox -Force | Out-Null
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ if (-not $ConfigPath) { $ConfigPath = Join-Path $projectRoot 'BackupConfig.psd1'
|
|||||||
|
|
||||||
$restoreScript = Join-Path $projectRoot 'Restore.ps1'
|
$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)) {
|
if (-not (Test-Path -LiteralPath $restoreScript)) {
|
||||||
Write-Error "找不到 Restore.ps1:$restoreScript"
|
Write-Error "找不到 Restore.ps1:$restoreScript"
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
|
|||||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||||
$backupScript = Join-Path $projectRoot 'Backup.ps1'
|
$backupScript = Join-Path $projectRoot 'Backup.ps1'
|
||||||
$restoreScript = Join-Path $projectRoot 'Restore.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
|
Reset-TestResult
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ param(
|
|||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
$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 $ModulePath -Force
|
||||||
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
|
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
|
||||||
|
|
||||||
|
|||||||
+42
-1
@@ -35,7 +35,7 @@ $ErrorActionPreference = 'Stop'
|
|||||||
# 参数绑定阶段还没有给 $PSScriptRoot 赋值,默认值表达式会拿到空串(实测:带
|
# 参数绑定阶段还没有给 $PSScriptRoot 赋值,默认值表达式会拿到空串(实测:带
|
||||||
# [CmdletBinding()] -> 空串,不带 -> 正常;PowerShell 7 两种都正常)。所以默认值
|
# [CmdletBinding()] -> 空串,不带 -> 正常;PowerShell 7 两种都正常)。所以默认值
|
||||||
# 一律在这里补 —— 这也是本仓库对 -BackupDir / -ConfigPath 一直在用的写法。
|
# 一律在这里补 —— 这也是本仓库对 -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 $ModulePath -Force
|
||||||
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -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 $sandbox -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -48,7 +48,7 @@ $projectRoot = Split-Path -Parent $PSScriptRoot
|
|||||||
# 一律在这里补 —— 这也是本仓库对 -BackupDir / -ConfigPath 一直在用的写法。
|
# 一律在这里补 —— 这也是本仓库对 -BackupDir / -ConfigPath 一直在用的写法。
|
||||||
if (-not $BackupListPath) { $BackupListPath = Join-Path $projectRoot 'BackupList.txt' }
|
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' }
|
if (-not $ConfigPath) { $ConfigPath = Join-Path $projectRoot 'BackupConfig.psd1' }
|
||||||
$config = Get-BaknretConfig -Path $ConfigPath
|
$config = Get-BaknretConfig -Path $ConfigPath
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ $ErrorActionPreference = 'Stop'
|
|||||||
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
||||||
$OutputEncoding = [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:Passed = 0
|
||||||
$script:Failures = @()
|
$script:Failures = @()
|
||||||
|
|||||||
Reference in new issue
Block a user