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"