Payload/scripts/kaden/utils/strip-bom.ps1
|
<# .SYNOPSIS Rewrite files to UTF-8 without a BOM. .DESCRIPTION Repo PowerShell is UTF-8 without a BOM (PSScriptAnalyzerSettings.psd1 excludes PSUseBOMForUnicodeEncodedFile for that reason; lint-powershell.ps1 fails when EF BB BF is present). This script strips a UTF-8 BOM, and converts UTF-16 LE or BE (BOM) to UTF-8 without a BOM. Files that already have no BOM are left unchanged. Pass one or more file paths. Relative paths resolve from the current directory, then from the repository root. Rewrites stay inside the repository unless -AllowOutsideRepo is set. -StagedOnly rewrites staged *.ps1 / *.psm1 / *.psd1 under the current git toplevel (pre-commit Phase 1), then re-stages any file that changed. pwsh -File passes each path as its own argument. Use bare paths, or -Path for the first path and the rest bare. A direct call inside pwsh can use -Path a.ps1, b.ps1. .PARAMETER Path One or more file paths. Alias: Paths. Further paths may follow. .PARAMETER AllowOutsideRepo Rewrite a file that resolves outside this repository. .PARAMETER StagedOnly Strip BOM from staged PowerShell files in the current git toplevel and re-stage rewritten paths. Ignores -Path. Exit 0 when nothing is staged. .EXAMPLE pwsh -NoProfile -File .\scripts\kaden\utils\strip-bom.ps1 scripts\kaden\harness\lib\HarnessContract.psm1 tests\harness\Some.Tests.ps1 .EXAMPLE pwsh -NoProfile -File .\scripts\kaden\utils\strip-bom.ps1 -Path scripts\a.ps1 tests\harness\Some.Tests.ps1 .EXAMPLE pwsh -NoProfile -File .\scripts\kaden\utils\strip-bom.ps1 -StagedOnly .EXAMPLE Get-Help .\scripts\kaden\utils\strip-bom.ps1 -Parameter Path #> #Requires -Version 5.1 [CmdletBinding(DefaultParameterSetName = 'Files')] param( [Parameter(ParameterSetName = 'Files', Position = 0)] [Alias('Paths')] [string[]]$Path = @(), [Parameter(ParameterSetName = 'Files', ValueFromRemainingArguments = $true, DontShow = $true)] [string[]]$ExtraPath, [Parameter(ParameterSetName = 'Files')] [switch]$AllowOutsideRepo, [Parameter(ParameterSetName = 'Staged')] [switch]$StagedOnly, [Parameter(ParameterSetName = 'Help')] [Alias('h', 'help')] [switch]$ShowHelp ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $cliKitManifest = Join-Path $PSScriptRoot "PsCliUiKit\PsCliUiKit.psd1" if (-not (Test-Path -LiteralPath $cliKitManifest)) { throw "PsCliUiKit manifest not found: $cliKitManifest" } Import-Module -Name $cliKitManifest -Force function Resolve-StripBomRepoRoot { $current = $PSScriptRoot while ($true) { $marker = Join-Path $current "scripts\kaden\harness-invoke.ps1" if (Test-Path -LiteralPath $marker) { return $current } $parent = Split-Path $current -Parent if ([string]::IsNullOrWhiteSpace($parent) -or $parent -eq $current) { return $null } $current = $parent } } function Resolve-StripBomInputPath { param( [Parameter(Mandatory = $true)][string]$InputPath, [string]$RepoRoot = '' ) if ([string]::IsNullOrWhiteSpace($InputPath)) { throw "Path is empty." } if (Test-Path -LiteralPath $InputPath) { $resolved = (Resolve-Path -LiteralPath $InputPath).Path } elseif (-not [string]::IsNullOrWhiteSpace($RepoRoot) -and -not [System.IO.Path]::IsPathRooted($InputPath)) { $candidate = Join-Path $RepoRoot $InputPath if (-not (Test-Path -LiteralPath $candidate)) { throw "File not found: $InputPath" } $resolved = (Resolve-Path -LiteralPath $candidate).Path } else { throw "File not found: $InputPath" } $item = Get-Item -LiteralPath $resolved if ($item.PSIsContainer) { throw "Not a file: $resolved" } return $resolved } function Test-StripBomPathInsideRepo { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$RepoRoot ) $rootFull = [System.IO.Path]::GetFullPath($RepoRoot) $separator = [System.IO.Path]::DirectorySeparatorChar if (-not $rootFull.EndsWith($separator)) { $rootFull += $separator } $full = [System.IO.Path]::GetFullPath($Path) return $full.StartsWith($rootFull, [System.StringComparison]::OrdinalIgnoreCase) } function Get-StripBomGitCommand { $gitCmd = Get-Command git -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $gitCmd) { $gitCmd = Get-Command git.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 } if (-not $gitCmd) { throw 'git not found on PATH (tried git, then git.exe)' } return $gitCmd.Source } function Get-StripBomStagedPowerShellFile { <# .SYNOPSIS Resolve staged PowerShell paths under a git toplevel (zero or more files). #> param([Parameter(Mandatory = $true)][string]$RepoRoot) $git = Get-StripBomGitCommand Push-Location $RepoRoot try { $staged = @(& $git diff --cached --name-only --diff-filter=ACMR -- '*.ps1' '*.psm1' '*.psd1') if ($LASTEXITCODE -ne 0) { throw "git diff --cached failed with exit $LASTEXITCODE" } return @( $staged | ForEach-Object { ($_ -replace '\\', '/').Trim() } | Where-Object { $_ } | ForEach-Object { Join-Path $RepoRoot ($_ -replace '/', [System.IO.Path]::DirectorySeparatorChar) } | Where-Object { Test-Path -LiteralPath $_ } ) } finally { Pop-Location } } function ConvertTo-StripBomUtf8 { param([Parameter(Mandatory = $true)][string]$LiteralPath) $bytes = [System.IO.File]::ReadAllBytes($LiteralPath) $kind = "none" $text = $null if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $kind = "utf8-bom" $utf8Strict = New-Object System.Text.UTF8Encoding $false, $true $text = $utf8Strict.GetString($bytes, 3, $bytes.Length - 3) } elseif ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) { if (($bytes.Length % 2) -ne 0) { throw "UTF-16 LE byte length is odd: $LiteralPath" } $kind = "utf16-le-bom" $utf16 = New-Object System.Text.UnicodeEncoding $false, $true, $true $text = $utf16.GetString($bytes, 2, $bytes.Length - 2) } elseif ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) { if (($bytes.Length % 2) -ne 0) { throw "UTF-16 BE byte length is odd: $LiteralPath" } $kind = "utf16-be-bom" $utf16be = New-Object System.Text.UnicodeEncoding $true, $true, $true $text = $utf16be.GetString($bytes, 2, $bytes.Length - 2) } if ($kind -eq "none") { return [pscustomobject]@{ Path = $LiteralPath Result = "unchanged" Detail = "" } } $utf8 = New-Object System.Text.UTF8Encoding $false $tmp = "$LiteralPath.nobom" try { [System.IO.File]::WriteAllText($tmp, $text, $utf8) Move-Item -LiteralPath $tmp -Destination $LiteralPath -Force } finally { if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Force } } $after = [System.IO.File]::ReadAllBytes($LiteralPath) $stillBom = ($after.Length -ge 3 -and $after[0] -eq 0xEF -and $after[1] -eq 0xBB -and $after[2] -eq 0xBF) if ($stillBom) { throw "Rewrite left a UTF-8 BOM on $LiteralPath" } $detail = "" $result = "removed" if ($kind -eq "utf16-le-bom") { $result = "converted" $detail = "utf-16 le" } elseif ($kind -eq "utf16-be-bom") { $result = "converted" $detail = "utf-16 be" } return [pscustomobject]@{ Path = $LiteralPath Result = $result Detail = $detail } } try { if ($ShowHelp) { (Get-Help -Name $PSCommandPath -Full | Out-String -Width 100) | Out-Host return } $resolvedPaths = New-Object System.Collections.Generic.List[string] $repoRoot = '' $restageRewritten = $false if ($StagedOnly) { $git = Get-StripBomGitCommand $repoRoot = (& $git rev-parse --show-toplevel 2>$null) if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($repoRoot)) { throw 'git rev-parse --show-toplevel failed. Run -StagedOnly from inside a git work tree.' } $repoRoot = $repoRoot.Trim() foreach ($full in @(Get-StripBomStagedPowerShellFile -RepoRoot $repoRoot)) { [void]$resolvedPaths.Add($full) } $restageRewritten = $true if ($resolvedPaths.Count -eq 0) { Write-CliSection -Title "Strip BOM" Write-CliStatus -State "Info" -Message "No staged .ps1/.psm1/.psd1 files; skipping." return } } else { $requested = New-Object System.Collections.Generic.List[string] foreach ($entry in @($Path)) { if (-not [string]::IsNullOrWhiteSpace($entry)) { [void]$requested.Add($entry) } } if ($null -ne $ExtraPath) { foreach ($entry in @($ExtraPath)) { if (-not [string]::IsNullOrWhiteSpace($entry)) { [void]$requested.Add($entry) } } } if ($requested.Count -eq 0) { throw "No files given. Get-Help `"$PSCommandPath`" -Parameter Path" } $repoRoot = Resolve-StripBomRepoRoot foreach ($inputPath in $requested) { $full = Resolve-StripBomInputPath -InputPath $inputPath -RepoRoot $repoRoot [void]$resolvedPaths.Add($full) } if (-not $AllowOutsideRepo) { if ([string]::IsNullOrWhiteSpace($repoRoot)) { throw "Could not locate the repository root. Get-Help `"$PSCommandPath`" -Parameter AllowOutsideRepo" } foreach ($full in $resolvedPaths) { if (-not (Test-StripBomPathInsideRepo -Path $full -RepoRoot $repoRoot)) { throw "Refusing to rewrite a file outside the repository: $full. Get-Help `"$PSCommandPath`" -Parameter AllowOutsideRepo" } } } } Write-CliSection -Title "Strip BOM" $rewritten = 0 $unchanged = 0 $rewrittenPaths = New-Object System.Collections.Generic.List[string] foreach ($full in $resolvedPaths) { $result = ConvertTo-StripBomUtf8 -LiteralPath $full if ($result.Result -eq "unchanged") { $unchanged++ Write-CliKeyValue -Label "unchanged" -LabelWidth 10 -Value "[muted]$($result.Path)[-]" } elseif ($result.Result -eq "converted") { $rewritten++ [void]$rewrittenPaths.Add($full) Write-CliKeyValue -Label "converted" -LabelWidth 10 -Value "[success]$($result.Path)[-] [muted]($($result.Detail))[-]" } else { $rewritten++ [void]$rewrittenPaths.Add($full) Write-CliKeyValue -Label "removed" -LabelWidth 10 -Value "[success]$($result.Path)[-]" } } if ($restageRewritten -and $rewrittenPaths.Count -gt 0) { $git = Get-StripBomGitCommand Push-Location $repoRoot try { & $git add -- @($rewrittenPaths) if ($LASTEXITCODE -ne 0) { throw "git add failed with exit $LASTEXITCODE after BOM rewrite" } Write-CliKeyValue -Label "restaged" -LabelWidth 10 -Value ("[success]{0} file(s)[-]" -f $rewrittenPaths.Count) } finally { Pop-Location } } $summaryState = if ($rewritten -gt 0) { "Success" } else { "Info" } Write-CliStatus -State $summaryState -Message "$rewritten rewritten, $unchanged unchanged" } catch { Write-CliSection -Title "Strip BOM failed" -Style "error" Write-CliStatus -State "Error" -Message $_.Exception.Message exit 2 } finally { Write-CliEnd } |