diff --git a/home/run_once_before_20-deploy-ssh-keys.ps1.tmpl b/home/run_once_before_20-deploy-ssh-keys.ps1.tmpl index 5b2075e..52826c4 100644 --- a/home/run_once_before_20-deploy-ssh-keys.ps1.tmpl +++ b/home/run_once_before_20-deploy-ssh-keys.ps1.tmpl @@ -31,9 +31,14 @@ $privateKey = Join-Path $sshDir '{{ $sshKey.filename }}' $publicKey = Join-Path $sshDir '{{ $sshKey.filename }}.pub' if (-not (Test-Path $privateKey)) { - @' + $privateKeyContent = @' {{ template "get-ssh-key" dict "item" $sshKey.item "type" "private" "ctx" $ }} -'@ | Set-Content -Path $privateKey -Encoding utf8NoBOM -NoNewline +'@ + # PS5.1 rejects the utf8NoBOM encoding literal (PS6+ only); write the + # BOM-less UTF-8 bytes directly instead. No trailing newline is added, + # matching the prior -NoNewline call. + [System.IO.File]::WriteAllText($privateKey, $privateKeyContent, + [System.Text.UTF8Encoding]::new($false)) # Restrict to current user only (icacls works without elevation) icacls $privateKey /inheritance:r /grant:r "${env:USERNAME}:(F)" 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { @@ -46,9 +51,14 @@ if (-not (Test-Path $privateKey)) { } if (-not (Test-Path $publicKey)) { - @' + $publicKeyContent = @' {{ template "get-ssh-key" dict "item" $sshKey.item "type" "public" "ctx" $ }} -'@ | Set-Content -Path $publicKey -Encoding utf8NoBOM -NoNewline +'@ + # PS5.1 rejects the utf8NoBOM encoding literal (PS6+ only); write the + # BOM-less UTF-8 bytes directly instead. No trailing newline is added, + # matching the prior -NoNewline call. + [System.IO.File]::WriteAllText($publicKey, $publicKeyContent, + [System.Text.UTF8Encoding]::new($false)) Write-Host ' Public key written.' Record-State -Category 'sshKey' -Name '{{ $key }}.pub' -Path $publicKey } else { diff --git a/home/run_onchange_after_generate-authorized-keys.ps1.tmpl b/home/run_onchange_after_generate-authorized-keys.ps1.tmpl index fa2ecdc..09f810e 100644 --- a/home/run_onchange_after_generate-authorized-keys.ps1.tmpl +++ b/home/run_onchange_after_generate-authorized-keys.ps1.tmpl @@ -88,7 +88,12 @@ if ($hasValidBlock) { $outLines += $endMarker } -($outLines -join "`n") + "`n" | Set-Content -Path $authorized -Encoding utf8NoBOM -NoNewline +# PS5.1 rejects the utf8NoBOM encoding literal (PS6+ only); write the +# BOM-less UTF-8 bytes directly instead. The joined content already +# carries its own trailing newline, matching the prior -NoNewline call. +$authorizedContent = ($outLines -join "`n") + "`n" +[System.IO.File]::WriteAllText($authorized, $authorizedContent, + [System.Text.UTF8Encoding]::new($false)) # Keep the file private, but still readable by the LocalSystem sshd # service when Windows OpenSSH is configured to use per-user keys. diff --git a/home/run_onchange_after_generate-git-profiles.ps1.tmpl b/home/run_onchange_after_generate-git-profiles.ps1.tmpl index ab52ac9..9cef842 100644 --- a/home/run_onchange_after_generate-git-profiles.ps1.tmpl +++ b/home/run_onchange_after_generate-git-profiles.ps1.tmpl @@ -24,7 +24,7 @@ New-Item -ItemType Directory -Path $profilesDir -Force | Out-Null {{- $sshAbsPath := "" -}} {{- if $sshFile -}}{{ $sshAbsPath = printf "%s/.ssh/%s.pub" $homeDir $sshFile -}}{{- end }} $profilePath = Join-Path $profilesDir '{{ $key }}' -@' +$profileContent = @' {{- if eq $format "ssh" }} [gpg] format = ssh @@ -53,7 +53,13 @@ $profilePath = Join-Path $profilesDir '{{ $key }}' tag-ssh = "!f() { git -c 'gpg.format=ssh' -c 'user.signingkey={{ $sshAbsPath }}' -c 'tag.gpgsign=true' tag \"$@\"; }; f" rebase-ssh = "!f() { git -c 'gpg.format=ssh' -c 'user.signingkey={{ $sshAbsPath }}' -c 'commit.gpgsign=true' rebase \"$@\"; }; f" {{- end }} -'@ | Set-Content -Path $profilePath -Encoding utf8NoBOM +'@ +# PS5.1 rejects the utf8NoBOM encoding literal (PS6+ only); write the +# BOM-less UTF-8 bytes directly instead. Set-Content's default (no +# -NoNewline) behavior appends one platform newline after the content, +# so replicate that explicitly to preserve prior byte-for-byte output. +[System.IO.File]::WriteAllText($profilePath, $profileContent + [System.Environment]::NewLine, + [System.Text.UTF8Encoding]::new($false)) {{- end }} $validProfiles = @( diff --git a/tests/powershell/deploy-ssh-keys.Tests.ps1 b/tests/powershell/deploy-ssh-keys.Tests.ps1 new file mode 100644 index 0000000..8f52520 --- /dev/null +++ b/tests/powershell/deploy-ssh-keys.Tests.ps1 @@ -0,0 +1,147 @@ +# Tests for the PowerShell SSH key deployment script. +# Exercises: BOM-less UTF-8 encoding (PS5.1-compatible), exact byte +# preservation (no added trailing newline), skip-if-exists behavior, +# and the Windows ACL restriction applied to private keys only. + +BeforeAll { + $script:Fixture = Join-Path $PSScriptRoot 'fixtures/deploy-ssh-keys.ps1' +} + +Describe 'deploy-ssh-keys' { + + BeforeEach { + $script:OriginalHome = $HOME + $script:OriginalUserName = $env:USERNAME + $script:HomeDir = 'TestDrive:\home' + if (Test-Path $script:HomeDir) { + Remove-Item $script:HomeDir -Recurse -Force + } + New-Item -ItemType Directory -Path $script:HomeDir -Force | Out-Null + Set-Variable -Name HOME -Value $script:HomeDir -Scope Global -Force + $env:USERNAME = 'sample-user' + $global:DotfilesTestIcaclsCalls = @() + + function global:icacls { + $global:DotfilesTestIcaclsCalls += , @($args) + $global:LASTEXITCODE = 0 + } + + $script:SshDirReal = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath( + (Join-Path $script:HomeDir '.ssh')) + } + + AfterEach { + Set-Variable -Name HOME -Value $script:OriginalHome -Scope Global -Force + $env:USERNAME = $script:OriginalUserName + Remove-Item Function:\icacls -ErrorAction SilentlyContinue + } + + It 'writes both key files with no BOM' { + & $script:Fixture | Out-Null + + $privateBytes = [System.IO.File]::ReadAllBytes((Join-Path $script:SshDirReal 'id_secondary')) + $publicBytes = [System.IO.File]::ReadAllBytes((Join-Path $script:SshDirReal 'id_secondary.pub')) + + # A UTF-8 BOM is the 3-byte sequence EF BB BF; neither file should + # start with it. + ($privateBytes.Length -ge 3 -and $privateBytes[0] -eq 0xEF -and + $privateBytes[1] -eq 0xBB -and $privateBytes[2] -eq 0xBF) | Should -BeFalse + ($publicBytes.Length -ge 3 -and $publicBytes[0] -eq 0xEF -and + $publicBytes[1] -eq 0xBB -and $publicBytes[2] -eq 0xBF) | Should -BeFalse + } + + It 'preserves a secret''s own trailing newline exactly, with no additional newline added' { + & $script:Fixture | Out-Null + + # secondary's fixture content already ends with "`n" (mirroring a + # real SSH key secret's conventional trailing newline) -- this + # must survive untouched, and no *second* newline may be appended. + $expectedPrivate = "TEST-ONLY-PRIVATE-KEY-CONTENT-secondary`n" + $expectedPublic = "ssh-ed25519 AAAASECONDARY secondary@test`n" + $expectedPrivateBytes = [System.Text.UTF8Encoding]::new($false).GetBytes($expectedPrivate) + $expectedPublicBytes = [System.Text.UTF8Encoding]::new($false).GetBytes($expectedPublic) + + $actualPrivateBytes = [System.IO.File]::ReadAllBytes((Join-Path $script:SshDirReal 'id_secondary')) + $actualPublicBytes = [System.IO.File]::ReadAllBytes((Join-Path $script:SshDirReal 'id_secondary.pub')) + + [System.Linq.Enumerable]::SequenceEqual($actualPrivateBytes, $expectedPrivateBytes) | Should -BeTrue + [System.Linq.Enumerable]::SequenceEqual($actualPublicBytes, $expectedPublicBytes) | Should -BeTrue + } + + It 'writes exact byte-for-byte content with no added trailing newline when the secret has none' { + & $script:Fixture | Out-Null + + # primary's fixture content has no trailing newline at all -- the + # multi-line-content case below covers this shape; here we assert + # the same no-addition guarantee on the simpler single-line public + # key, which also has no trailing newline in the fixture. + $expectedPublic = 'ssh-ed25519 AAAAPRIMARY primary@test' + $expectedPublicBytes = [System.Text.UTF8Encoding]::new($false).GetBytes($expectedPublic) + + $actualPublicBytes = [System.IO.File]::ReadAllBytes((Join-Path $script:SshDirReal 'id_primary.pub')) + + [System.Linq.Enumerable]::SequenceEqual($actualPublicBytes, $expectedPublicBytes) | Should -BeTrue + } + + It 'preserves embedded newlines in multi-line key content exactly' { + & $script:Fixture | Out-Null + + $expected = "TEST-ONLY-PRIVATE-KEY-CONTENT-primary`nline two" + $expectedBytes = [System.Text.UTF8Encoding]::new($false).GetBytes($expected) + $actualBytes = [System.IO.File]::ReadAllBytes((Join-Path $script:SshDirReal 'id_primary')) + + [System.Linq.Enumerable]::SequenceEqual($actualBytes, $expectedBytes) | Should -BeTrue + } + + It 'writes both key pairs for multiple configured keys' { + & $script:Fixture | Out-Null + + Join-Path $script:SshDirReal 'id_primary' | Should -Exist + Join-Path $script:SshDirReal 'id_primary.pub' | Should -Exist + Join-Path $script:SshDirReal 'id_secondary' | Should -Exist + Join-Path $script:SshDirReal 'id_secondary.pub' | Should -Exist + } + + It 'restricts private key permissions to the current user via icacls' { + & $script:Fixture | Out-Null + + # @(...) forces array semantics: Where-Object unwraps a single + # matching element when that element is itself an array, which + # would otherwise make .Count report the inner args count instead + # of the number of matching icacls calls. + $privateCalls = @($global:DotfilesTestIcaclsCalls | + Where-Object { $_ -contains (Join-Path $script:SshDirReal 'id_primary') }) + $privateCalls.Count | Should -Be 1 + $privateCalls[0] | Should -Contain 'sample-user:(F)' + } + + It 'does not run icacls against the public key' { + & $script:Fixture | Out-Null + + $publicCalls = @($global:DotfilesTestIcaclsCalls | + Where-Object { $_ -contains (Join-Path $script:SshDirReal 'id_primary.pub') }) + $publicCalls.Count | Should -Be 0 + } + + It 'skips writing a key that already exists' { + New-Item -ItemType Directory -Path $script:SshDirReal -Force | Out-Null + $existingPath = Join-Path $script:SshDirReal 'id_primary' + [System.IO.File]::WriteAllText($existingPath, 'PRE-EXISTING-CONTENT', + [System.Text.UTF8Encoding]::new($false)) + + & $script:Fixture | Out-Null + + $content = [System.IO.File]::ReadAllText($existingPath) + $content | Should -Be 'PRE-EXISTING-CONTENT' + } + + It 'produces no diff when re-run with keys already deployed' { + & $script:Fixture | Out-Null + $before = [System.IO.File]::ReadAllBytes((Join-Path $script:SshDirReal 'id_primary')) + + & $script:Fixture | Out-Null + $after = [System.IO.File]::ReadAllBytes((Join-Path $script:SshDirReal 'id_primary')) + + [System.Linq.Enumerable]::SequenceEqual($before, $after) | Should -BeTrue + } +} diff --git a/tests/powershell/fixtures/deploy-ssh-keys.ps1 b/tests/powershell/fixtures/deploy-ssh-keys.ps1 new file mode 100644 index 0000000..c9d044c --- /dev/null +++ b/tests/powershell/fixtures/deploy-ssh-keys.ps1 @@ -0,0 +1,82 @@ +#!/usr/bin/env pwsh +# Pre-rendered test fixture for run_once_before_20-deploy-ssh-keys.ps1.tmpl. +# Contains two hardcoded SSH key entries: +# primary - filename 'id_primary' +# secondary - filename 'id_secondary' +# +# This script is intentionally NOT a chezmoi template, and the key +# content below is synthetic placeholder text (not a real key pair) -- +# it exists only to exercise the encoding/newline/permission handling +# this script performs, not real SSH functionality. +$ErrorActionPreference = 'Stop' + +$sshDir = Join-Path $HOME '.ssh' +New-Item -ItemType Directory -Path $sshDir -Force | Out-Null + +# [System.IO.File] does not understand PS provider paths (e.g. TestDrive:\...), +# so resolve to a real filesystem path before using it with .NET I/O below. +$sshDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($sshDir) + +$deployState = Join-Path $HOME '.local/bin/secret-deploy-state.ps1' +function Record-State { + param([string]$Category, [string]$Name, [string]$Path) + if (-not (Test-Path $deployState)) { return } + try { & $deployState record $Category $Name $Path | Out-Null } catch { } +} + +$sshKeys = [ordered]@{ + primary = @{ + filename = 'id_primary' + private = "TEST-ONLY-PRIVATE-KEY-CONTENT-primary`nline two" + public = 'ssh-ed25519 AAAAPRIMARY primary@test' + } + secondary = @{ + filename = 'id_secondary' + # A real SSH key secret, as commonly stored by a secret manager or + # read from a PEM/OpenSSH-format file, conventionally ends with a + # trailing newline. Model that shape here to verify it survives + # the write untouched -- with no *additional* newline appended. + private = "TEST-ONLY-PRIVATE-KEY-CONTENT-secondary`n" + public = "ssh-ed25519 AAAASECONDARY secondary@test`n" + } +} + +foreach ($key in $sshKeys.Keys) { + $sshKey = $sshKeys[$key] + Write-Host "Deploying SSH key: $key ($($sshKey.filename))..." + $privateKey = Join-Path $sshDir $sshKey.filename + $publicKey = Join-Path $sshDir "$($sshKey.filename).pub" + + if (-not (Test-Path $privateKey)) { + $privateKeyContent = $sshKey.private + # PS5.1 rejects the utf8NoBOM encoding literal (PS6+ only); write the + # BOM-less UTF-8 bytes directly instead. No trailing newline is added, + # matching the prior -NoNewline call. + [System.IO.File]::WriteAllText($privateKey, $privateKeyContent, + [System.Text.UTF8Encoding]::new($false)) + # Restrict to current user only (icacls works without elevation) + icacls $privateKey /inheritance:r /grant:r "${env:USERNAME}:(F)" 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Warning "Failed to restrict permissions on ${privateKey} (icacls exit code: $LASTEXITCODE)" + } + Write-Host ' Private key written.' + Record-State -Category 'sshKey' -Name $key -Path $privateKey + } else { + Write-Host ' Private key already exists; skipping.' + } + + if (-not (Test-Path $publicKey)) { + $publicKeyContent = $sshKey.public + # PS5.1 rejects the utf8NoBOM encoding literal (PS6+ only); write the + # BOM-less UTF-8 bytes directly instead. No trailing newline is added, + # matching the prior -NoNewline call. + [System.IO.File]::WriteAllText($publicKey, $publicKeyContent, + [System.Text.UTF8Encoding]::new($false)) + Write-Host ' Public key written.' + Record-State -Category 'sshKey' -Name "$key.pub" -Path $publicKey + } else { + Write-Host ' Public key already exists; skipping.' + } +} + +Write-Host 'SSH key deployment complete.' diff --git a/tests/powershell/signing-resolve.Tests.ps1 b/tests/powershell/signing-resolve.Tests.ps1 index 5bd7b6b..8f923be 100644 --- a/tests/powershell/signing-resolve.Tests.ps1 +++ b/tests/powershell/signing-resolve.Tests.ps1 @@ -131,7 +131,16 @@ Describe 'signing-resolve' -Skip:(-not $script:HasChezmoi) { # The rendered output is the generator *script* source, which # writes the profile gitconfig via a here-string; extract the # here-string body so it can be read as a real gitconfig file. - if ($r.Output -notmatch "(?ms)^@'\r?\n(.*?)\r?\n^'@") { + # `@'` may be preceded on the same line by a variable assignment + # (e.g. `$profileContent = @'`) now that the generator captures + # the here-string into a variable before writing it via + # [System.IO.File]::WriteAllText (PS5.1-compatible BOM-less + # UTF-8; see run_onchange_after_generate-git-profiles.ps1.tmpl). + # The opener prefix uses [^\r\n]* (not .*) so it stays confined + # to one line under (?s) dot-matches-newline: a greedy .* would + # span every profile in a multi-profile render and capture the + # LAST here-string instead of the first. + if ($r.Output -notmatch "(?ms)^[^\r\n]*@'\r?\n(.*?)\r?\n^'@") { throw 'Could not locate the profile here-string in rendered output' } $renderedProfile = Join-Path ([IO.Path]::GetTempPath()) ("signing-{0}-profile" -f [guid]::NewGuid())