179 lines
5.7 KiB
PowerShell
179 lines
5.7 KiB
PowerShell
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$PackagePath,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$TargetRoot,
|
|
|
|
[Parameter(Mandatory = $false)]
|
|
[switch]$DryRun
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
function Normalize-RelativePath {
|
|
param([string]$Path)
|
|
$value = $Path -replace "\\", "/"
|
|
$value = $value.Trim()
|
|
while ($value.StartsWith("./")) {
|
|
$value = $value.Substring(2)
|
|
}
|
|
$value = $value.TrimStart("/")
|
|
return $value.TrimEnd("/")
|
|
}
|
|
|
|
function Test-PathMatchesPrefix {
|
|
param(
|
|
[string]$RelativePath,
|
|
[string]$Prefix
|
|
)
|
|
|
|
$pathValue = Normalize-RelativePath -Path $RelativePath
|
|
$prefixValue = Normalize-RelativePath -Path $Prefix
|
|
if ([string]::IsNullOrWhiteSpace($prefixValue)) { return $false }
|
|
return $pathValue -eq $prefixValue -or $pathValue.StartsWith("$prefixValue/")
|
|
}
|
|
|
|
function Test-IsAllowed {
|
|
param(
|
|
[string]$RelativePath,
|
|
[string[]]$AllowedPaths
|
|
)
|
|
|
|
foreach ($allowed in $AllowedPaths) {
|
|
if (Test-PathMatchesPrefix -RelativePath $RelativePath -Prefix $allowed) {
|
|
return $true
|
|
}
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Test-IsBlocked {
|
|
param(
|
|
[string]$RelativePath,
|
|
[string[]]$BlockedPaths
|
|
)
|
|
|
|
foreach ($blocked in $BlockedPaths) {
|
|
if (Test-PathMatchesPrefix -RelativePath $RelativePath -Prefix $blocked) {
|
|
return $true
|
|
}
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function New-Directory {
|
|
param([string]$Path)
|
|
if (-not (Test-Path -LiteralPath $Path)) {
|
|
New-Item -ItemType Directory -Force -Path $Path | Out-Null
|
|
}
|
|
}
|
|
|
|
function Get-TargetFileHash {
|
|
param([string]$Path)
|
|
if (-not (Test-Path -LiteralPath $Path)) { return "" }
|
|
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
}
|
|
|
|
$resolvedPackage = Resolve-Path -LiteralPath $PackagePath
|
|
$packageItem = Get-Item -LiteralPath $resolvedPackage.Path
|
|
$packageRoot = if ($packageItem.PSIsContainer) { $packageItem.FullName } else { Split-Path -Parent $packageItem.FullName }
|
|
$packageManifestPath = if ($packageItem.PSIsContainer) { Join-Path $packageRoot "package-manifest.json" } else { $packageItem.FullName }
|
|
|
|
if (-not (Test-Path -LiteralPath $packageManifestPath)) {
|
|
throw "Package manifest not found: $packageManifestPath"
|
|
}
|
|
if (-not (Test-Path -LiteralPath $TargetRoot)) {
|
|
throw "Target root not found: $TargetRoot"
|
|
}
|
|
|
|
$targetRootPath = (Resolve-Path -LiteralPath $TargetRoot).Path
|
|
$packageManifest = Get-Content -LiteralPath $packageManifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
$allowedPaths = @($packageManifest.system_paths | ForEach-Object { [string]$_ })
|
|
$blockedPaths = @($packageManifest.excluded_paths + $packageManifest.preserve_paths | ForEach-Object { [string]$_ })
|
|
|
|
$operations = New-Object System.Collections.Generic.List[object]
|
|
|
|
foreach ($file in @($packageManifest.files)) {
|
|
$relative = Normalize-RelativePath -Path ([string]$file.path)
|
|
if (-not (Test-IsAllowed -RelativePath $relative -AllowedPaths $allowedPaths)) {
|
|
throw "Package file is outside system allowlist: $relative"
|
|
}
|
|
if (Test-IsBlocked -RelativePath $relative -BlockedPaths $blockedPaths) {
|
|
throw "Package file targets a preserved/excluded path: $relative"
|
|
}
|
|
|
|
$source = Join-Path $packageRoot ($relative -replace "/", "\")
|
|
if (-not (Test-Path -LiteralPath $source)) {
|
|
throw "Package file is missing: $relative"
|
|
}
|
|
|
|
$sourceHash = (Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
if ($sourceHash -ne ([string]$file.sha256).ToLowerInvariant()) {
|
|
throw "Checksum mismatch for package file: $relative"
|
|
}
|
|
|
|
$target = Join-Path $targetRootPath ($relative -replace "/", "\")
|
|
$targetHash = Get-TargetFileHash -Path $target
|
|
if ($targetHash -eq $sourceHash) {
|
|
continue
|
|
}
|
|
|
|
$action = if (Test-Path -LiteralPath $target) { "update" } else { "create" }
|
|
$operations.Add([ordered]@{
|
|
action = $action
|
|
path = $relative
|
|
source = $source
|
|
target = $target
|
|
sha256 = $sourceHash
|
|
})
|
|
}
|
|
|
|
if ($DryRun) {
|
|
Write-Output "Dry run: $($operations.Count) file operation(s) would be applied."
|
|
foreach ($operation in $operations) {
|
|
Write-Output "$($operation.action): $($operation.path)"
|
|
}
|
|
return
|
|
}
|
|
|
|
$timestamp = (Get-Date).ToUniversalTime().ToString("yyyyMMdd-HHmmss-fff")
|
|
$hotUpdateDir = Join-Path $targetRootPath "workspace/.hot-update"
|
|
$backupRoot = Join-Path $hotUpdateDir "backups/$timestamp"
|
|
New-Directory -Path $backupRoot
|
|
|
|
$backupEntries = New-Object System.Collections.Generic.List[object]
|
|
foreach ($operation in $operations) {
|
|
if ($operation.action -eq "update") {
|
|
$backupPath = Join-Path $backupRoot ($operation.path -replace "/", "\")
|
|
New-Directory -Path (Split-Path -Parent $backupPath)
|
|
Copy-Item -LiteralPath $operation.target -Destination $backupPath -Force
|
|
$backupEntries.Add([ordered]@{
|
|
path = $operation.path
|
|
backup_path = ("workspace/.hot-update/backups/$timestamp/" + $operation.path)
|
|
original_sha256 = (Get-FileHash -LiteralPath $backupPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
})
|
|
}
|
|
|
|
New-Directory -Path (Split-Path -Parent $operation.target)
|
|
Copy-Item -LiteralPath $operation.source -Destination $operation.target -Force
|
|
}
|
|
|
|
$applyLog = [ordered]@{
|
|
applied_at = (Get-Date).ToUniversalTime().ToString("o")
|
|
package_name = [string]$packageManifest.package_name
|
|
version = [string]$packageManifest.version
|
|
dry_run = $false
|
|
operations = $operations
|
|
backups = $backupEntries
|
|
}
|
|
|
|
$backupManifestPath = Join-Path $backupRoot "backup-manifest.json"
|
|
$applyLog | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $backupManifestPath -Encoding UTF8
|
|
|
|
$applyLogPath = Join-Path $hotUpdateDir "last-apply.json"
|
|
$applyLog | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $applyLogPath -Encoding UTF8
|
|
|
|
Write-Output "Applied $($operations.Count) file operation(s)."
|
|
Write-Output "Backup: $backupRoot"
|