Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions home/run_once_before_20-deploy-ssh-keys.ps1.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion home/run_onchange_after_generate-authorized-keys.ps1.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions home/run_onchange_after_generate-git-profiles.ps1.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = @(
Expand Down
129 changes: 129 additions & 0 deletions tests/powershell/deploy-ssh-keys.Tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# 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 'writes exact byte-for-byte content with no added trailing newline' {
& $script:Fixture | Out-Null

$expectedPrivate = 'TEST-ONLY-PRIVATE-KEY-CONTENT-secondary'
$expectedPublic = 'ssh-ed25519 AAAASECONDARY secondary@test'
$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 '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
}
}
78 changes: 78 additions & 0 deletions tests/powershell/fixtures/deploy-ssh-keys.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/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'
private = 'TEST-ONLY-PRIVATE-KEY-CONTENT-secondary'
public = 'ssh-ed25519 AAAASECONDARY secondary@test'
}
}

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))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# 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.'
7 changes: 6 additions & 1 deletion tests/powershell/signing-resolve.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,12 @@ 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).
if ($r.Output -notmatch "(?ms)^.*@'\r?\n(.*?)\r?\n^'@") {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
throw 'Could not locate the profile here-string in rendered output'
}
$renderedProfile = Join-Path ([IO.Path]::GetTempPath()) ("signing-{0}-profile" -f [guid]::NewGuid())
Expand Down
Loading