Files
Julian lechnerandClaude Sonnet 5 1c2d155fcc fix(installer): write manifest hashes lowercase and without a BOM (PowerShell)
Windows PowerShell 5.1's -Encoding UTF8 always prepends a BOM, and
Get-FileHash returns uppercase hex. Bash's manifest reader expects neither,
so a manifest written by this module and later read by the Bash
equivalent (or vice versa) mismatched on every single entry: the BOM
folded into the header's first field, and every hash comparison failed on
case alone. Hashes are now lowercased on read and write, and the manifest
file itself is written UTF-8 without a BOM.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 16:07:17 +02:00

117 lines
5.9 KiB
PowerShell

function Get-ManagedManifestPath {
param([Parameter(Mandatory=$true)][string]$Destination)
return (Join-Path $Destination '.ai-config-manifest.tsv')
}
function Get-Sha256Hash {
param([Parameter(Mandatory=$true)][string]$Path)
# Lowercase to match sha256sum's output, since the manifest must be readable by
# both this module and its Bash equivalent regardless of which one wrote it.
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
}
function Get-Sha256HashOfBytes {
param([Parameter(Mandatory=$true)][byte[]]$Bytes)
$sha256 = [System.Security.Cryptography.SHA256]::Create()
try { return ([System.BitConverter]::ToString($sha256.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant() }
finally { $sha256.Dispose() }
}
function Read-ManagedManifest {
param([Parameter(Mandatory=$true)][string]$ManifestPath)
$entries = @{}
if (Test-Path -LiteralPath $ManifestPath) {
# Lowercase to tolerate an older manifest written before hashes were normalized.
Import-Csv -LiteralPath $ManifestPath -Delimiter ([char]9) | ForEach-Object { $entries[$_.path] = $_.sha256.ToLowerInvariant() }
}
return $entries
}
function Write-ManagedManifest {
param([Parameter(Mandatory=$true)][string]$ManifestPath, [Parameter(Mandatory=$true)][hashtable]$Entries)
$lines = @("path`tsha256")
foreach ($path in ($Entries.Keys | Sort-Object)) { $lines += "$path`t$($Entries[$path])" }
New-Item (Split-Path $ManifestPath -Parent) -ItemType Directory -Force | Out-Null
# Windows PowerShell 5.1's -Encoding UTF8 always prepends a BOM, which Bash's plain
# `read` would otherwise fold into the first field of the header line. Write UTF-8
# without BOM and with LF line endings so either implementation can read the file
# regardless of which one wrote it.
$content = ($lines -join "`n") + "`n"
[System.IO.File]::WriteAllText($ManifestPath, $content, (New-Object System.Text.UTF8Encoding($false)))
}
function Sync-ManagedDestination {
<#
.SYNOPSIS
Installs one generated source tree into a destination directory, tracking
every installed file in a manifest so a later run can detect files this
setup previously managed that were since removed from the source (stale)
or changed on disk by the user (locally modified), without ever touching
a file it never installed.
#>
param(
[Parameter(Mandatory=$true)][string]$Source,
[Parameter(Mandatory=$true)][string]$Destination,
[Parameter(Mandatory=$true)][string]$Stamp,
[string]$AiConfigRoot,
[string]$ShellCommand,
[string]$WindowsShellCommand,
[switch]$DryRun
)
Write-Output "SOURCE $Source -> $Destination"
$manifestPath = Get-ManagedManifestPath $Destination
$oldManifest = Read-ManagedManifest $manifestPath
$newManifest = @{}
$backupRoot = Join-Path $Destination "backups/$Stamp"
Get-ChildItem $Source -File -Recurse | ForEach-Object {
$relative = $_.FullName.Substring($Source.Length).TrimStart([char[]]@('\','/')).Replace('\','/')
$target = Join-Path $Destination $relative
$rawContent = Get-Content -LiteralPath $_.FullName -Raw
$needsSubstitution = $rawContent.Contains('__AI_CONFIG_ROOT__') -or $rawContent.Contains('__HOOK_COMMAND__') -or $rawContent.Contains('__WINDOWS_HOOK_COMMAND__')
if ($needsSubstitution) {
$finalContent = $rawContent.Replace('__AI_CONFIG_ROOT__', $AiConfigRoot).Replace('__HOOK_COMMAND__', $ShellCommand).Replace('__WINDOWS_HOOK_COMMAND__', $WindowsShellCommand).Replace('__HOOK_SCRIPT__', 'flashbang.ps1').Replace('__WINDOWS_HOOK_SCRIPT__', 'flashbang.ps1')
$newBytes = [System.Text.Encoding]::UTF8.GetBytes($finalContent)
} else {
$newBytes = [System.IO.File]::ReadAllBytes($_.FullName)
}
$newHash = Get-Sha256HashOfBytes $newBytes
$newManifest[$relative] = $newHash
$exists = Test-Path -LiteralPath $target
if ($exists -and (Get-Sha256Hash $target) -eq $newHash) { Write-Output "UNCHANGED $target"; return }
$action = if ($exists) { 'UPDATE' } else { 'CREATE' }
if ($DryRun) { Write-Output "DRYRUN $action $target"; return }
if ($exists) {
$wasManaged = $oldManifest.ContainsKey($relative)
$backup = Join-Path $backupRoot $relative
New-Item (Split-Path $backup -Parent) -ItemType Directory -Force | Out-Null
Copy-Item -LiteralPath $target $backup -Force
Write-Output "BACKUP $target -> $backup"
if (-not $wasManaged) { Write-Output "WARN $target existed before this installation but was not tracked by a previous run; it was backed up before being overwritten." }
}
New-Item (Split-Path $target -Parent) -ItemType Directory -Force | Out-Null
[System.IO.File]::WriteAllBytes($target, $newBytes)
Write-Output "$action $target"
}
foreach ($relative in @($oldManifest.Keys | Where-Object { -not $newManifest.ContainsKey($_) })) {
$target = Join-Path $Destination $relative
if (-not (Test-Path -LiteralPath $target)) { continue }
if ($DryRun) { Write-Output "DRYRUN REMOVE $target"; continue }
$backup = Join-Path $backupRoot $relative
New-Item (Split-Path $backup -Parent) -ItemType Directory -Force | Out-Null
Copy-Item -LiteralPath $target $backup -Force
Write-Output "BACKUP $target -> $backup"
if ((Get-Sha256Hash $target) -eq $oldManifest[$relative]) {
Remove-Item -LiteralPath $target -Force
Write-Output "REMOVE $target"
} else {
Write-Output "WARN $target was managed by a previous installation and has changed locally; it was backed up but left in place instead of being removed."
}
}
if (-not $DryRun) { Write-ManagedManifest $manifestPath $newManifest }
}