From c67bbf0fdcf0b674e3dfb582a2fe44dc373a79ba Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Thu, 23 Jul 2026 18:21:15 +0200 Subject: [PATCH 01/21] Bump Sodium dependency from 2.2.2 to 2.2.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sodium 2.2.5 delivers significant performance improvements with no breaking API changes. Key gains since 2.2.2: - ConvertFrom-SodiumSealedBox: -44% per-call overhead (196 -> 109 µs) - ConvertTo-SodiumSealedBox: -22% per-call overhead (136 -> 105 µs) - New-SodiumKeyPair -Seed: -49% per-call overhead (95 -> 49 µs) - Module cold import: -37% (67 ms -> 42 ms) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/functions/public/Get-Context.ps1 | 2 +- src/functions/public/Set-Context.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/functions/public/Get-Context.ps1 b/src/functions/public/Get-Context.ps1 index 3d044f0..9bc6d94 100644 --- a/src/functions/public/Get-Context.ps1 +++ b/src/functions/public/Get-Context.ps1 @@ -1,4 +1,4 @@ -#Requires -Modules @{ ModuleName = 'Sodium'; RequiredVersion = '2.2.2' } +#Requires -Modules @{ ModuleName = 'Sodium'; RequiredVersion = '2.2.5' } function Get-Context { <# diff --git a/src/functions/public/Set-Context.ps1 b/src/functions/public/Set-Context.ps1 index bc3d7d6..7914c7d 100644 --- a/src/functions/public/Set-Context.ps1 +++ b/src/functions/public/Set-Context.ps1 @@ -1,4 +1,4 @@ -#Requires -Modules @{ ModuleName = 'Sodium'; RequiredVersion = '2.2.2' } +#Requires -Modules @{ ModuleName = 'Sodium'; RequiredVersion = '2.2.5' } function Set-Context { <# From ab921a516b19a4ececd1e0607cc8d0c399805d3d Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Thu, 23 Jul 2026 18:21:23 +0200 Subject: [PATCH 02/21] Add performance benchmark script for crypto operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measures median µs per iteration for the three hot paths: New-SodiumKeyPair seed derivation, Set-Context (seal), and Get-Context (open/decrypt). Script is self-contained, cleans up after itself, and outputs a formatted table plus objects for downstream capture. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/Context.Benchmark.ps1 | 111 ++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 tests/Context.Benchmark.ps1 diff --git a/tests/Context.Benchmark.ps1 b/tests/Context.Benchmark.ps1 new file mode 100644 index 0000000..7d9ab93 --- /dev/null +++ b/tests/Context.Benchmark.ps1 @@ -0,0 +1,111 @@ +#Requires -Modules @{ ModuleName = 'Sodium'; RequiredVersion = '2.2.5' } + +<# + .SYNOPSIS + Performance benchmark for the Context module's core crypto operations. + + .DESCRIPTION + Measures the time (in microseconds) per iteration for the three operations that every + Set-Context / Get-Context call executes: key-pair derivation, seal (encrypt), and + open (decrypt). Each scenario is repeated $Iterations times and the median is reported. + + Run from the repo root after importing the module: + + Import-Module ./src -Force + pwsh -NoProfile -File tests/Context.Benchmark.ps1 + + .OUTPUTS + PSCustomObject - one row per scenario with Scenario, Iterations, Median_µs, Min_µs, Max_µs. +#> +[CmdletBinding()] +param( + # Number of iterations per scenario. + [Parameter()] + [int] $Iterations = 1000, + + # Vault name used during the benchmark (cleaned up automatically). + [Parameter()] + [string] $BenchmarkVault = 'Benchmark-Perf-Vault' +) + +Set-StrictMode -Version Latest + +function Measure-MedianMicroseconds { + param( + [scriptblock] $ScriptBlock, + [int] $Iterations + ) + + $samples = [System.Collections.Generic.List[double]]::new($Iterations) + for ($i = 0; $i -lt $Iterations; $i++) { + $sw = [System.Diagnostics.Stopwatch]::StartNew() + & $ScriptBlock | Out-Null + $sw.Stop() + $samples.Add($sw.Elapsed.TotalMilliseconds * 1000) + } + $sorted = $samples | Sort-Object + $mid = [int]($Iterations / 2) + return [pscustomobject]@{ + Median_µs = [Math]::Round($sorted[$mid], 1) + Min_µs = [Math]::Round(($sorted | Select-Object -First 1), 1) + Max_µs = [Math]::Round(($sorted | Select-Object -Last 1), 1) + } +} + +# Ensure bench vault exists and is clean +try { + Remove-ContextVault -Name $BenchmarkVault -Confirm:$false -ErrorAction SilentlyContinue +} catch {} +Set-ContextVault -Name $BenchmarkVault | Out-Null + +$results = [System.Collections.Generic.List[pscustomobject]]::new() + +Write-Host "Running Context benchmark ($Iterations iterations each)..." -ForegroundColor Cyan + +# --- Key-pair derivation (New-SodiumKeyPair with seed) --- +$seed = 'BenchmarkSeedValue' +$kpStats = Measure-MedianMicroseconds -Iterations $Iterations -ScriptBlock { + New-SodiumKeyPair -Seed $seed +} +$results.Add([pscustomobject]@{ + Scenario = 'New-SodiumKeyPair -Seed' + Iterations = $Iterations + Median_µs = $kpStats.Median_µs + Min_µs = $kpStats.Min_µs + Max_µs = $kpStats.Max_µs +}) + +# --- Seal (encrypt via Set-Context) --- +$sealStats = Measure-MedianMicroseconds -Iterations $Iterations -ScriptBlock { + Set-Context -ID 'bench-ctx' -Context @{ Value = 'benchmark' } -Vault $BenchmarkVault +} +$results.Add([pscustomobject]@{ + Scenario = 'Set-Context (seal)' + Iterations = $Iterations + Median_µs = $sealStats.Median_µs + Min_µs = $sealStats.Min_µs + Max_µs = $sealStats.Max_µs +}) + +# --- Open (decrypt via Get-Context) --- +Set-Context -ID 'bench-ctx' -Context @{ Value = 'benchmark' } -Vault $BenchmarkVault +$openStats = Measure-MedianMicroseconds -Iterations $Iterations -ScriptBlock { + Get-Context -ID 'bench-ctx' -Vault $BenchmarkVault +} +$results.Add([pscustomobject]@{ + Scenario = 'Get-Context (open/decrypt)' + Iterations = $Iterations + Median_µs = $openStats.Median_µs + Min_µs = $openStats.Min_µs + Max_µs = $openStats.Max_µs +}) + +# Cleanup +try { + Remove-ContextVault -Name $BenchmarkVault -Confirm:$false -ErrorAction SilentlyContinue +} catch {} + +Write-Host '' +Write-Host '=== Context Benchmark Results ===' -ForegroundColor Green +$results | Format-Table -AutoSize +$results From 8fcff7b2117a2f7519cafd86740375b9f49b1035 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Thu, 23 Jul 2026 18:42:33 +0200 Subject: [PATCH 03/21] Add cross-version compatibility test for Sodium 2.2.2 to 2.2.5 Two-part test confirms the on-disk contract is fully preserved: - Part 1: New-SodiumKeyPair -Seed produces byte-identical keys in both Sodium versions; a sealed box from 2.2.2 is decryptable by 2.2.5. - Part 2: vault files written by Context 8.1.3 (Sodium 2.2.2) are correctly decrypted using Sodium 2.2.5 key derivation directly, including SecureString-prefixed values and null fields. All 9 assertions pass. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/Context.CrossVersionCompat.ps1 | 229 +++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 tests/Context.CrossVersionCompat.ps1 diff --git a/tests/Context.CrossVersionCompat.ps1 b/tests/Context.CrossVersionCompat.ps1 new file mode 100644 index 0000000..2d68346 --- /dev/null +++ b/tests/Context.CrossVersionCompat.ps1 @@ -0,0 +1,229 @@ +<# + .SYNOPSIS + Cross-version compatibility test: Sodium 2.2.2 written data readable by Sodium 2.2.5. + + .DESCRIPTION + Two-part test: + + PART 1 — Crypto stability + Verifies that New-SodiumKeyPair -Seed produces identical keys in both Sodium versions, + and that a sealed box produced by 2.2.2 is decryptable by 2.2.5 (and vice versa). + + PART 2 — Full stack (Context on-disk format) + Uses Context 8.1.3 (Sodium 2.2.2) to write a vault + contexts to disk, then uses + Sodium 2.2.5 directly to re-derive the same keys, read the raw JSON files, and + decrypt the stored sealed boxes — confirming the on-disk contract is preserved. + + Each phase runs in a child pwsh process to ensure clean module state. +#> +[CmdletBinding()] +param( + [string] $VaultName = 'XVersionCompat-Test' +) + +$ErrorActionPreference = 'Stop' +$tempDir = Join-Path $env:TEMP "sodium-compat-$(Get-Random)" +New-Item -ItemType Directory -Path $tempDir | Out-Null + +$cryptoResultFile = Join-Path $tempDir 'crypto.json' +$writeResultFile = Join-Path $tempDir 'write.json' +$readResultFile = Join-Path $tempDir 'read.json' + +$pass = 0; $fail = 0 + +function Assert-Equal { + param($Name, $Expected, $Actual) + if ("$Actual" -eq "$Expected") { + Write-Host " [PASS] $Name" -ForegroundColor Green + $script:pass++ + } else { + Write-Host " [FAIL] $Name" -ForegroundColor Red + Write-Host " expected : $Expected" -ForegroundColor Red + Write-Host " actual : $Actual" -ForegroundColor Red + $script:fail++ + } +} +function Assert-Null { + param($Name, $Actual) + if ($null -eq $Actual -or "$Actual" -eq '') { + Write-Host " [PASS] $Name (null/empty as expected)" -ForegroundColor Green + $script:pass++ + } else { + Write-Host " [FAIL] $Name — expected null/empty, got: [$Actual]" -ForegroundColor Red + $script:fail++ + } +} + +Write-Host '' +Write-Host '=== Cross-Version Sodium Compatibility Test ===' -ForegroundColor Cyan +Write-Host "Temp dir : $tempDir" +Write-Host '' + +# --------------------------------------------------------------------------- +# PART 1 — CRYPTO STABILITY +# Confirms New-SodiumKeyPair -Seed is deterministic across versions, +# and that sealed boxes produced by 2.2.2 are openable by 2.2.5. +# --------------------------------------------------------------------------- +Write-Host '--- Part 1a: Seal message + derive keys with Sodium 2.2.2 ---' -ForegroundColor Yellow + +$part1aScript = @" +`$ErrorActionPreference = 'Stop' +Import-Module Sodium -RequiredVersion 2.2.2 -Force +`$seed = 'CompatibilityTestSeedValue' +`$plaintext = 'Hello from Sodium 2.2.2' +`$kp = New-SodiumKeyPair -Seed `$seed +`$box22 = ConvertTo-SodiumSealedBox -Message `$plaintext -PublicKey `$kp.PublicKey +`$result = @{ + PublicKey = `$kp.PublicKey + PrivateKey = `$kp.PrivateKey + SealedBox22 = `$box22 + Plaintext = `$plaintext +} +`$result | ConvertTo-Json | Set-Content '$cryptoResultFile' +Write-Host "Keys derived and message sealed with Sodium 2.2.2" +"@ + +pwsh -NoProfile -Command $part1aScript +if (-not (Test-Path $cryptoResultFile)) { throw 'Part 1a failed: no result file' } +$c22 = Get-Content $cryptoResultFile -Raw | ConvertFrom-Json +Write-Host " PublicKey (2.2.2): $($c22.PublicKey)" +Write-Host " PrivateKey (2.2.2): $($c22.PrivateKey)" + +Write-Host '' +Write-Host '--- Part 1b: Verify keys + unseal 2.2.2 message with Sodium 2.2.5 ---' -ForegroundColor Yellow + +$part1bScript = @" +`$ErrorActionPreference = 'Stop' +Import-Module Sodium -RequiredVersion 2.2.5 -Force +`$seed = 'CompatibilityTestSeedValue' +`$kp25 = New-SodiumKeyPair -Seed `$seed +`$box22 = '$($c22.SealedBox22)' +`$pubKey = '$($c22.PublicKey)' +`$privKey = '$($c22.PrivateKey)' + +# Decrypt the 2.2.2 ciphertext with 2.2.5 +`$decrypted = ConvertFrom-SodiumSealedBox -SealedBox `$box22 -PublicKey `$pubKey -PrivateKey `$privKey + +# Also seal a new message with 2.2.5 +`$box25 = ConvertTo-SodiumSealedBox -Message 'Hello from Sodium 2.2.5' -PublicKey `$kp25.PublicKey +# Decrypt the 2.2.5 message immediately (roundtrip check) +`$rt25 = ConvertFrom-SodiumSealedBox -SealedBox `$box25 -PublicKey `$kp25.PublicKey -PrivateKey `$kp25.PrivateKey + +`$result = @{ + PublicKey25 = `$kp25.PublicKey + PrivateKey25 = `$kp25.PrivateKey + Decrypted22msg = `$decrypted + Roundtrip25msg = `$rt25 +} +`$result | ConvertTo-Json | Set-Content ('$cryptoResultFile' -replace '\.json','-25.json') +Write-Host "Keys derived and 2.2.2 box decrypted with Sodium 2.2.5" +"@ + +$cryptoResult25File = $cryptoResultFile -replace '\.json', '-25.json' +pwsh -NoProfile -Command $part1bScript +if (-not (Test-Path $cryptoResult25File)) { throw 'Part 1b failed: no result file' } +$c25 = Get-Content $cryptoResult25File -Raw | ConvertFrom-Json + +Write-Host " PublicKey (2.2.5): $($c25.PublicKey25)" +Write-Host " PrivateKey (2.2.5): $($c25.PrivateKey25)" +Write-Host " Decrypted 2.2.2 msg: $($c25.Decrypted22msg)" +Write-Host " 2.2.5 roundtrip msg: $($c25.Roundtrip25msg)" +Write-Host '' + +Write-Host '--- Part 1 Assertions ---' -ForegroundColor Yellow +Assert-Equal 'Key derivation: PublicKey identical' $c22.PublicKey $c25.PublicKey25 +Assert-Equal 'Key derivation: PrivateKey identical' $c22.PrivateKey $c25.PrivateKey25 +Assert-Equal '2.2.2 sealed box decryptable by 2.2.5' $c22.Plaintext $c25.Decrypted22msg +Assert-Equal '2.2.5 roundtrip works' 'Hello from Sodium 2.2.5' $c25.Roundtrip25msg + +# --------------------------------------------------------------------------- +# PART 2 — FULL STACK: Context 8.1.3 writes, Sodium 2.2.5 reads raw files +# --------------------------------------------------------------------------- +Write-Host '' +Write-Host '--- Part 2a: Write vault + contexts with Context 8.1.3 (Sodium 2.2.2) ---' -ForegroundColor Yellow + +$part2aScript = @" +`$ErrorActionPreference = 'Stop' +Import-Module Sodium -RequiredVersion 2.2.2 -Force +Import-Module Context -Force + +Get-ContextVault -Name '$VaultName' -ErrorAction SilentlyContinue | Remove-ContextVault -Confirm:`$false -ErrorAction SilentlyContinue +Set-ContextVault -Name '$VaultName' | Out-Null + +Set-Context -ID 'compat-simple' -Context @{ Greeting = 'Hello'; Number = 42 } -Vault '$VaultName' +Set-Context -ID 'compat-secure' -Context @{ Token = ('secret123' | ConvertTo-SecureString -AsPlainText -Force) } -Vault '$VaultName' +Set-Context -ID 'compat-nulls' -Context @{ Present = 'yes'; Absent = `$null } -Vault '$VaultName' + +`$vault = Get-ContextVault -Name '$VaultName' +`$shardPath = Join-Path `$vault.Path 'shard' +`$result = @{ + VaultPath = `$vault.Path + ShardPath = `$shardPath + ShardContent = (Get-Content `$shardPath -Raw).Trim() + MachineName = [System.Environment]::MachineName + UserName = [System.Environment]::UserName + ContextFiles = (Get-ChildItem `$vault.Path -Filter '*.json' | Select-Object -ExpandProperty FullName) +} +`$result | ConvertTo-Json -Depth 5 | Set-Content '$writeResultFile' +Write-Host "Vault and contexts written with Context 8.1.3 / Sodium 2.2.2" +"@ + +pwsh -NoProfile -Command $part2aScript +if (-not (Test-Path $writeResultFile)) { throw 'Part 2a failed: no result file' } +$wr = Get-Content $writeResultFile -Raw | ConvertFrom-Json + +Write-Host " VaultPath : $($wr.VaultPath)" +Write-Host " ContextFiles : $($wr.ContextFiles.Count) json files" +Write-Host '' +Write-Host '--- Part 2b: Decrypt raw vault files using Sodium 2.2.5 directly ---' -ForegroundColor Yellow + +$contextFilesJson = ($wr.ContextFiles | ConvertTo-Json -Compress) + +$part2bScript = @" +`$ErrorActionPreference = 'Stop' +Import-Module Sodium -RequiredVersion 2.2.5 -Force + +# Re-derive the same keys as Context would +`$seed = '$($wr.MachineName)' + '$($wr.UserName)' + '$($wr.ShardContent)' +`$kp = New-SodiumKeyPair -Seed `$seed + +`$contextFiles = '$($wr.ContextFiles -join "','")' -split "','" + +`$results = @{} +foreach (`$file in `$contextFiles) { + `$json = Get-Content `$file -Raw | ConvertFrom-Json + `$plain = ConvertFrom-SodiumSealedBox -SealedBox `$json.Context -PublicKey `$kp.PublicKey -PrivateKey `$kp.PrivateKey + `$results[`$json.ID] = `$plain | ConvertFrom-Json -AsHashtable +} + +`$results | ConvertTo-Json -Depth 10 | Set-Content '$readResultFile' +Write-Host "Raw vault files decrypted with Sodium 2.2.5" +"@ + +pwsh -NoProfile -Command $part2bScript +if (-not (Test-Path $readResultFile)) { throw 'Part 2b failed: no result file' } +$rd = Get-Content $readResultFile -Raw | ConvertFrom-Json + +Write-Host '--- Part 2 Assertions ---' -ForegroundColor Yellow +Assert-Equal 'compat-simple: Greeting' 'Hello' $rd.'compat-simple'.Greeting +Assert-Equal 'compat-simple: Number' '42' $rd.'compat-simple'.Number +Assert-Equal 'compat-secure: Token (SecureString prefix)' '[SECURESTRING]secret123' $rd.'compat-secure'.Token +Assert-Equal 'compat-nulls: Present' 'yes' $rd.'compat-nulls'.Present +Assert-Null 'compat-nulls: Absent' $rd.'compat-nulls'.Absent + +# Cleanup +Write-Host '' +try { + pwsh -NoProfile -Command "Import-Module Sodium -RequiredVersion 2.2.2 -Force; Import-Module Context -Force; Get-ContextVault -Name '$VaultName' -ErrorAction SilentlyContinue | Remove-ContextVault -Confirm:`$false -ErrorAction SilentlyContinue" 2>&1 | Out-Null +} catch {} +Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue + +$total = $pass + $fail +Write-Host '' +if ($fail -eq 0) { + Write-Host "=== RESULT: ALL $total ASSERTIONS PASSED ===" -ForegroundColor Green + Write-Host " Sodium 2.2.2 → 2.2.5: key derivation is identical, on-disk contract is preserved." -ForegroundColor Green +} else { + Write-Host "=== RESULT: $fail/$total ASSERTIONS FAILED — BREAKING CHANGE DETECTED ===" -ForegroundColor Red +} + From 8ca5cc6f39038d09717fdacdd160048aaaf4d6fb Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 25 Jul 2026 23:25:26 +0200 Subject: [PATCH 04/21] Resolve analyzer warnings in new test scripts Keep the benchmark and compatibility scripts lint-clean by fixing cleanup handlers, normalizing formatting, and adding narrow script-level suppressions where visible console output is intentional. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/Context.Benchmark.ps1 | 63 +++++---- tests/Context.CrossVersionCompat.ps1 | 183 +++++++++++++++------------ 2 files changed, 131 insertions(+), 115 deletions(-) diff --git a/tests/Context.Benchmark.ps1 b/tests/Context.Benchmark.ps1 index 7d9ab93..31096ee 100644 --- a/tests/Context.Benchmark.ps1 +++ b/tests/Context.Benchmark.ps1 @@ -1,22 +1,19 @@ #Requires -Modules @{ ModuleName = 'Sodium'; RequiredVersion = '2.2.5' } -<# - .SYNOPSIS - Performance benchmark for the Context module's core crypto operations. - - .DESCRIPTION - Measures the time (in microseconds) per iteration for the three operations that every - Set-Context / Get-Context call executes: key-pair derivation, seal (encrypt), and - open (decrypt). Each scenario is repeated $Iterations times and the median is reported. - - Run from the repo root after importing the module: - - Import-Module ./src -Force - pwsh -NoProfile -File tests/Context.Benchmark.ps1 - - .OUTPUTS - PSCustomObject - one row per scenario with Scenario, Iterations, Median_µs, Min_µs, Max_µs. -#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingWriteHost', '', + Justification = 'Benchmark scripts should emit visible progress and result output.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSProvideCommentHelp', '', + Scope = 'Function', + Target = 'Measure-BenchmarkMeasurement', + Justification = 'Private helper function in a test script.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseConsistentIndentation', '', + Justification = 'Aligned hashtable literals trigger a false positive in this script.' +)] [CmdletBinding()] param( # Number of iterations per scenario. @@ -30,7 +27,7 @@ param( Set-StrictMode -Version Latest -function Measure-MedianMicroseconds { +function Measure-BenchmarkMeasurement { param( [scriptblock] $ScriptBlock, [int] $Iterations @@ -38,13 +35,15 @@ function Measure-MedianMicroseconds { $samples = [System.Collections.Generic.List[double]]::new($Iterations) for ($i = 0; $i -lt $Iterations; $i++) { - $sw = [System.Diagnostics.Stopwatch]::StartNew() + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() & $ScriptBlock | Out-Null - $sw.Stop() - $samples.Add($sw.Elapsed.TotalMilliseconds * 1000) + $stopwatch.Stop() + $samples.Add($stopwatch.Elapsed.TotalMilliseconds * 1000) } + $sorted = $samples | Sort-Object - $mid = [int]($Iterations / 2) + $mid = [int] ($Iterations / 2) + return [pscustomobject]@{ Median_µs = [Math]::Round($sorted[$mid], 1) Min_µs = [Math]::Round(($sorted | Select-Object -First 1), 1) @@ -52,19 +51,20 @@ function Measure-MedianMicroseconds { } } -# Ensure bench vault exists and is clean try { Remove-ContextVault -Name $BenchmarkVault -Confirm:$false -ErrorAction SilentlyContinue -} catch {} +} catch { + Write-Verbose "Benchmark setup cleanup skipped: $($_.Exception.Message)" +} + Set-ContextVault -Name $BenchmarkVault | Out-Null $results = [System.Collections.Generic.List[pscustomobject]]::new() Write-Host "Running Context benchmark ($Iterations iterations each)..." -ForegroundColor Cyan -# --- Key-pair derivation (New-SodiumKeyPair with seed) --- $seed = 'BenchmarkSeedValue' -$kpStats = Measure-MedianMicroseconds -Iterations $Iterations -ScriptBlock { +$kpStats = Measure-BenchmarkMeasurement -Iterations $Iterations -ScriptBlock { New-SodiumKeyPair -Seed $seed } $results.Add([pscustomobject]@{ @@ -75,8 +75,7 @@ $results.Add([pscustomobject]@{ Max_µs = $kpStats.Max_µs }) -# --- Seal (encrypt via Set-Context) --- -$sealStats = Measure-MedianMicroseconds -Iterations $Iterations -ScriptBlock { +$sealStats = Measure-BenchmarkMeasurement -Iterations $Iterations -ScriptBlock { Set-Context -ID 'bench-ctx' -Context @{ Value = 'benchmark' } -Vault $BenchmarkVault } $results.Add([pscustomobject]@{ @@ -87,9 +86,8 @@ $results.Add([pscustomobject]@{ Max_µs = $sealStats.Max_µs }) -# --- Open (decrypt via Get-Context) --- Set-Context -ID 'bench-ctx' -Context @{ Value = 'benchmark' } -Vault $BenchmarkVault -$openStats = Measure-MedianMicroseconds -Iterations $Iterations -ScriptBlock { +$openStats = Measure-BenchmarkMeasurement -Iterations $Iterations -ScriptBlock { Get-Context -ID 'bench-ctx' -Vault $BenchmarkVault } $results.Add([pscustomobject]@{ @@ -100,10 +98,11 @@ $results.Add([pscustomobject]@{ Max_µs = $openStats.Max_µs }) -# Cleanup try { Remove-ContextVault -Name $BenchmarkVault -Confirm:$false -ErrorAction SilentlyContinue -} catch {} +} catch { + Write-Verbose "Benchmark cleanup skipped: $($_.Exception.Message)" +} Write-Host '' Write-Host '=== Context Benchmark Results ===' -ForegroundColor Green diff --git a/tests/Context.CrossVersionCompat.ps1 b/tests/Context.CrossVersionCompat.ps1 index 2d68346..12df737 100644 --- a/tests/Context.CrossVersionCompat.ps1 +++ b/tests/Context.CrossVersionCompat.ps1 @@ -1,18 +1,38 @@ -<# +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingWriteHost', '', + Justification = 'Compatibility test scripts should emit visible progress and result output.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidLongLines', '', + Justification = 'Embedded child-process commands are clearer when kept intact.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSProvideCommentHelp', '', + Scope = 'Function', + Target = 'Assert-Equal', + Justification = 'Private assertion helper in a test script.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSProvideCommentHelp', '', + Scope = 'Function', + Target = 'Assert-Null', + Justification = 'Private assertion helper in a test script.' +)] +<# .SYNOPSIS Cross-version compatibility test: Sodium 2.2.2 written data readable by Sodium 2.2.5. .DESCRIPTION Two-part test: - PART 1 — Crypto stability + PART 1 - Crypto stability Verifies that New-SodiumKeyPair -Seed produces identical keys in both Sodium versions, - and that a sealed box produced by 2.2.2 is decryptable by 2.2.5 (and vice versa). + and that a sealed box produced by 2.2.2 is decryptable by 2.2.5. - PART 2 — Full stack (Context on-disk format) - Uses Context 8.1.3 (Sodium 2.2.2) to write a vault + contexts to disk, then uses + PART 2 - Full stack (Context on-disk format) + Uses Context 8.1.3 (Sodium 2.2.2) to write a vault and contexts to disk, then uses Sodium 2.2.5 directly to re-derive the same keys, read the raw JSON files, and - decrypt the stored sealed boxes — confirming the on-disk contract is preserved. + decrypt the stored sealed boxes. Each phase runs in a child pwsh process to ensure clean module state. #> @@ -25,31 +45,35 @@ $ErrorActionPreference = 'Stop' $tempDir = Join-Path $env:TEMP "sodium-compat-$(Get-Random)" New-Item -ItemType Directory -Path $tempDir | Out-Null -$cryptoResultFile = Join-Path $tempDir 'crypto.json' -$writeResultFile = Join-Path $tempDir 'write.json' -$readResultFile = Join-Path $tempDir 'read.json' +$cryptoResultFile = Join-Path $tempDir 'crypto.json' +$writeResultFile = Join-Path $tempDir 'write.json' +$readResultFile = Join-Path $tempDir 'read.json' -$pass = 0; $fail = 0 +$pass = 0 +$fail = 0 function Assert-Equal { param($Name, $Expected, $Actual) + if ("$Actual" -eq "$Expected") { Write-Host " [PASS] $Name" -ForegroundColor Green $script:pass++ } else { Write-Host " [FAIL] $Name" -ForegroundColor Red Write-Host " expected : $Expected" -ForegroundColor Red - Write-Host " actual : $Actual" -ForegroundColor Red + Write-Host " actual : $Actual" -ForegroundColor Red $script:fail++ } } + function Assert-Null { param($Name, $Actual) + if ($null -eq $Actual -or "$Actual" -eq '') { Write-Host " [PASS] $Name (null/empty as expected)" -ForegroundColor Green $script:pass++ } else { - Write-Host " [FAIL] $Name — expected null/empty, got: [$Actual]" -ForegroundColor Red + Write-Host " [FAIL] $Name - expected null/empty, got: [$Actual]" -ForegroundColor Red $script:fail++ } } @@ -59,32 +83,30 @@ Write-Host '=== Cross-Version Sodium Compatibility Test ===' -ForegroundColor Cy Write-Host "Temp dir : $tempDir" Write-Host '' -# --------------------------------------------------------------------------- -# PART 1 — CRYPTO STABILITY -# Confirms New-SodiumKeyPair -Seed is deterministic across versions, -# and that sealed boxes produced by 2.2.2 are openable by 2.2.5. -# --------------------------------------------------------------------------- Write-Host '--- Part 1a: Seal message + derive keys with Sodium 2.2.2 ---' -ForegroundColor Yellow $part1aScript = @" `$ErrorActionPreference = 'Stop' Import-Module Sodium -RequiredVersion 2.2.2 -Force -`$seed = 'CompatibilityTestSeedValue' +`$seed = 'CompatibilityTestSeedValue' `$plaintext = 'Hello from Sodium 2.2.2' -`$kp = New-SodiumKeyPair -Seed `$seed +`$kp = New-SodiumKeyPair -Seed `$seed `$box22 = ConvertTo-SodiumSealedBox -Message `$plaintext -PublicKey `$kp.PublicKey `$result = @{ - PublicKey = `$kp.PublicKey - PrivateKey = `$kp.PrivateKey + PublicKey = `$kp.PublicKey + PrivateKey = `$kp.PrivateKey SealedBox22 = `$box22 - Plaintext = `$plaintext + Plaintext = `$plaintext } `$result | ConvertTo-Json | Set-Content '$cryptoResultFile' Write-Host "Keys derived and message sealed with Sodium 2.2.2" "@ pwsh -NoProfile -Command $part1aScript -if (-not (Test-Path $cryptoResultFile)) { throw 'Part 1a failed: no result file' } +if (-not (Test-Path $cryptoResultFile)) { + throw 'Part 1a failed: no result file' +} + $c22 = Get-Content $cryptoResultFile -Raw | ConvertFrom-Json Write-Host " PublicKey (2.2.2): $($c22.PublicKey)" Write-Host " PrivateKey (2.2.2): $($c22.PrivateKey)" @@ -95,35 +117,31 @@ Write-Host '--- Part 1b: Verify keys + unseal 2.2.2 message with Sodium 2.2.5 -- $part1bScript = @" `$ErrorActionPreference = 'Stop' Import-Module Sodium -RequiredVersion 2.2.5 -Force -`$seed = 'CompatibilityTestSeedValue' -`$kp25 = New-SodiumKeyPair -Seed `$seed -`$box22 = '$($c22.SealedBox22)' -`$pubKey = '$($c22.PublicKey)' +`$seed = 'CompatibilityTestSeedValue' +`$kp25 = New-SodiumKeyPair -Seed `$seed +`$box22 = '$($c22.SealedBox22)' +`$pubKey = '$($c22.PublicKey)' `$privKey = '$($c22.PrivateKey)' - -# Decrypt the 2.2.2 ciphertext with 2.2.5 `$decrypted = ConvertFrom-SodiumSealedBox -SealedBox `$box22 -PublicKey `$pubKey -PrivateKey `$privKey - -# Also seal a new message with 2.2.5 `$box25 = ConvertTo-SodiumSealedBox -Message 'Hello from Sodium 2.2.5' -PublicKey `$kp25.PublicKey -# Decrypt the 2.2.5 message immediately (roundtrip check) -`$rt25 = ConvertFrom-SodiumSealedBox -SealedBox `$box25 -PublicKey `$kp25.PublicKey -PrivateKey `$kp25.PrivateKey - +`$rt25 = ConvertFrom-SodiumSealedBox -SealedBox `$box25 -PublicKey `$kp25.PublicKey -PrivateKey `$kp25.PrivateKey `$result = @{ - PublicKey25 = `$kp25.PublicKey - PrivateKey25 = `$kp25.PrivateKey - Decrypted22msg = `$decrypted - Roundtrip25msg = `$rt25 + PublicKey25 = `$kp25.PublicKey + PrivateKey25 = `$kp25.PrivateKey + Decrypted22msg = `$decrypted + Roundtrip25msg = `$rt25 } -`$result | ConvertTo-Json | Set-Content ('$cryptoResultFile' -replace '\.json','-25.json') +`$result | ConvertTo-Json | Set-Content ('$cryptoResultFile' -replace '\.json', '-25.json') Write-Host "Keys derived and 2.2.2 box decrypted with Sodium 2.2.5" "@ $cryptoResult25File = $cryptoResultFile -replace '\.json', '-25.json' pwsh -NoProfile -Command $part1bScript -if (-not (Test-Path $cryptoResult25File)) { throw 'Part 1b failed: no result file' } -$c25 = Get-Content $cryptoResult25File -Raw | ConvertFrom-Json +if (-not (Test-Path $cryptoResult25File)) { + throw 'Part 1b failed: no result file' +} +$c25 = Get-Content $cryptoResult25File -Raw | ConvertFrom-Json Write-Host " PublicKey (2.2.5): $($c25.PublicKey25)" Write-Host " PrivateKey (2.2.5): $($c25.PrivateKey25)" Write-Host " Decrypted 2.2.2 msg: $($c25.Decrypted22msg)" @@ -131,14 +149,11 @@ Write-Host " 2.2.5 roundtrip msg: $($c25.Roundtrip25msg)" Write-Host '' Write-Host '--- Part 1 Assertions ---' -ForegroundColor Yellow -Assert-Equal 'Key derivation: PublicKey identical' $c22.PublicKey $c25.PublicKey25 -Assert-Equal 'Key derivation: PrivateKey identical' $c22.PrivateKey $c25.PrivateKey25 -Assert-Equal '2.2.2 sealed box decryptable by 2.2.5' $c22.Plaintext $c25.Decrypted22msg -Assert-Equal '2.2.5 roundtrip works' 'Hello from Sodium 2.2.5' $c25.Roundtrip25msg - -# --------------------------------------------------------------------------- -# PART 2 — FULL STACK: Context 8.1.3 writes, Sodium 2.2.5 reads raw files -# --------------------------------------------------------------------------- +Assert-Equal 'Key derivation: PublicKey identical' $c22.PublicKey $c25.PublicKey25 +Assert-Equal 'Key derivation: PrivateKey identical' $c22.PrivateKey $c25.PrivateKey25 +Assert-Equal '2.2.2 sealed box decryptable by 2.2.5' $c22.Plaintext $c25.Decrypted22msg +Assert-Equal '2.2.5 roundtrip works' 'Hello from Sodium 2.2.5' $c25.Roundtrip25msg + Write-Host '' Write-Host '--- Part 2a: Write vault + contexts with Context 8.1.3 (Sodium 2.2.2) ---' -ForegroundColor Yellow @@ -146,84 +161,86 @@ $part2aScript = @" `$ErrorActionPreference = 'Stop' Import-Module Sodium -RequiredVersion 2.2.2 -Force Import-Module Context -Force - Get-ContextVault -Name '$VaultName' -ErrorAction SilentlyContinue | Remove-ContextVault -Confirm:`$false -ErrorAction SilentlyContinue Set-ContextVault -Name '$VaultName' | Out-Null - -Set-Context -ID 'compat-simple' -Context @{ Greeting = 'Hello'; Number = 42 } -Vault '$VaultName' +Set-Context -ID 'compat-simple' -Context @{ Greeting = 'Hello'; Number = 42 } -Vault '$VaultName' Set-Context -ID 'compat-secure' -Context @{ Token = ('secret123' | ConvertTo-SecureString -AsPlainText -Force) } -Vault '$VaultName' -Set-Context -ID 'compat-nulls' -Context @{ Present = 'yes'; Absent = `$null } -Vault '$VaultName' - +Set-Context -ID 'compat-nulls' -Context @{ Present = 'yes'; Absent = `$null } -Vault '$VaultName' `$vault = Get-ContextVault -Name '$VaultName' `$shardPath = Join-Path `$vault.Path 'shard' `$result = @{ - VaultPath = `$vault.Path - ShardPath = `$shardPath - ShardContent = (Get-Content `$shardPath -Raw).Trim() - MachineName = [System.Environment]::MachineName - UserName = [System.Environment]::UserName - ContextFiles = (Get-ChildItem `$vault.Path -Filter '*.json' | Select-Object -ExpandProperty FullName) + VaultPath = `$vault.Path + ShardPath = `$shardPath + ShardContent = (Get-Content `$shardPath -Raw).Trim() + MachineName = [System.Environment]::MachineName + UserName = [System.Environment]::UserName + ContextFiles = (Get-ChildItem `$vault.Path -Filter '*.json' | Select-Object -ExpandProperty FullName) } `$result | ConvertTo-Json -Depth 5 | Set-Content '$writeResultFile' Write-Host "Vault and contexts written with Context 8.1.3 / Sodium 2.2.2" "@ pwsh -NoProfile -Command $part2aScript -if (-not (Test-Path $writeResultFile)) { throw 'Part 2a failed: no result file' } -$wr = Get-Content $writeResultFile -Raw | ConvertFrom-Json +if (-not (Test-Path $writeResultFile)) { + throw 'Part 2a failed: no result file' +} +$wr = Get-Content $writeResultFile -Raw | ConvertFrom-Json Write-Host " VaultPath : $($wr.VaultPath)" Write-Host " ContextFiles : $($wr.ContextFiles.Count) json files" Write-Host '' Write-Host '--- Part 2b: Decrypt raw vault files using Sodium 2.2.5 directly ---' -ForegroundColor Yellow -$contextFilesJson = ($wr.ContextFiles | ConvertTo-Json -Compress) - $part2bScript = @" `$ErrorActionPreference = 'Stop' Import-Module Sodium -RequiredVersion 2.2.5 -Force - -# Re-derive the same keys as Context would `$seed = '$($wr.MachineName)' + '$($wr.UserName)' + '$($wr.ShardContent)' -`$kp = New-SodiumKeyPair -Seed `$seed - +`$kp = New-SodiumKeyPair -Seed `$seed `$contextFiles = '$($wr.ContextFiles -join "','")' -split "','" - `$results = @{} foreach (`$file in `$contextFiles) { - `$json = Get-Content `$file -Raw | ConvertFrom-Json - `$plain = ConvertFrom-SodiumSealedBox -SealedBox `$json.Context -PublicKey `$kp.PublicKey -PrivateKey `$kp.PrivateKey + `$json = Get-Content `$file -Raw | ConvertFrom-Json + `$plain = ConvertFrom-SodiumSealedBox -SealedBox `$json.Context -PublicKey `$kp.PublicKey -PrivateKey `$kp.PrivateKey `$results[`$json.ID] = `$plain | ConvertFrom-Json -AsHashtable } - `$results | ConvertTo-Json -Depth 10 | Set-Content '$readResultFile' Write-Host "Raw vault files decrypted with Sodium 2.2.5" "@ pwsh -NoProfile -Command $part2bScript -if (-not (Test-Path $readResultFile)) { throw 'Part 2b failed: no result file' } +if (-not (Test-Path $readResultFile)) { + throw 'Part 2b failed: no result file' +} + $rd = Get-Content $readResultFile -Raw | ConvertFrom-Json Write-Host '--- Part 2 Assertions ---' -ForegroundColor Yellow -Assert-Equal 'compat-simple: Greeting' 'Hello' $rd.'compat-simple'.Greeting -Assert-Equal 'compat-simple: Number' '42' $rd.'compat-simple'.Number +Assert-Equal 'compat-simple: Greeting' 'Hello' $rd.'compat-simple'.Greeting +Assert-Equal 'compat-simple: Number' '42' $rd.'compat-simple'.Number Assert-Equal 'compat-secure: Token (SecureString prefix)' '[SECURESTRING]secret123' $rd.'compat-secure'.Token -Assert-Equal 'compat-nulls: Present' 'yes' $rd.'compat-nulls'.Present -Assert-Null 'compat-nulls: Absent' $rd.'compat-nulls'.Absent +Assert-Equal 'compat-nulls: Present' 'yes' $rd.'compat-nulls'.Present +Assert-Null 'compat-nulls: Absent' $rd.'compat-nulls'.Absent -# Cleanup Write-Host '' try { - pwsh -NoProfile -Command "Import-Module Sodium -RequiredVersion 2.2.2 -Force; Import-Module Context -Force; Get-ContextVault -Name '$VaultName' -ErrorAction SilentlyContinue | Remove-ContextVault -Confirm:`$false -ErrorAction SilentlyContinue" 2>&1 | Out-Null -} catch {} + $cleanupCommand = @( + "Import-Module Sodium -RequiredVersion 2.2.2 -Force; " + "Import-Module Context -Force; " + "Get-ContextVault -Name '$VaultName' -ErrorAction SilentlyContinue | " + "Remove-ContextVault -Confirm:`$false -ErrorAction SilentlyContinue" + ) -join '' + pwsh -NoProfile -Command $cleanupCommand 2>&1 | Out-Null +} catch { + Write-Verbose "Compatibility cleanup skipped: $($_.Exception.Message)" +} + Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue $total = $pass + $fail Write-Host '' if ($fail -eq 0) { Write-Host "=== RESULT: ALL $total ASSERTIONS PASSED ===" -ForegroundColor Green - Write-Host " Sodium 2.2.2 → 2.2.5: key derivation is identical, on-disk contract is preserved." -ForegroundColor Green + Write-Host ' Sodium 2.2.2 -> 2.2.5: key derivation is identical, on-disk contract is preserved.' -ForegroundColor Green } else { - Write-Host "=== RESULT: $fail/$total ASSERTIONS FAILED — BREAKING CHANGE DETECTED ===" -ForegroundColor Red + Write-Host "=== RESULT: $fail/$total ASSERTIONS FAILED - BREAKING CHANGE DETECTED ===" -ForegroundColor Red } - From 3e9384ea138c97f40d0959e0ee08e2270069344c Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Tue, 7 Jul 2026 16:39:30 +0200 Subject: [PATCH 05/21] Lock Pester test dependency to the 6.x major version --- tests/Context.Tests.ps1 | 2 +- tests/ContextVaults.Tests.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Context.Tests.ps1 b/tests/Context.Tests.ps1 index 36e4c23..c361148 100644 --- a/tests/Context.Tests.ps1 +++ b/tests/Context.Tests.ps1 @@ -1,4 +1,4 @@ -#Requires -Modules @{ ModuleName = 'Pester'; RequiredVersion = '5.8.0'; GUID = 'a699dea5-2c73-4616-a270-1f7abb777e71' } +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.999.999'; GUID = 'a699dea5-2c73-4616-a270-1f7abb777e71' } [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseDeclaredVarsMoreThanAssignments', '', diff --git a/tests/ContextVaults.Tests.ps1 b/tests/ContextVaults.Tests.ps1 index 1a12299..c400795 100644 --- a/tests/ContextVaults.Tests.ps1 +++ b/tests/ContextVaults.Tests.ps1 @@ -1,4 +1,4 @@ -#Requires -Modules @{ ModuleName = 'Pester'; RequiredVersion = '5.8.0'; GUID = 'a699dea5-2c73-4616-a270-1f7abb777e71' } +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.999.999'; GUID = 'a699dea5-2c73-4616-a270-1f7abb777e71' } [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseDeclaredVarsMoreThanAssignments', '', From c4bf50e9ef274409a522ad9220b81390a73d51f2 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Tue, 7 Jul 2026 17:36:33 +0200 Subject: [PATCH 06/21] Use the 6.* wildcard for the Pester major-version ceiling --- tests/Context.Tests.ps1 | 2 +- tests/ContextVaults.Tests.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Context.Tests.ps1 b/tests/Context.Tests.ps1 index c361148..ba0e00f 100644 --- a/tests/Context.Tests.ps1 +++ b/tests/Context.Tests.ps1 @@ -1,4 +1,4 @@ -#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.999.999'; GUID = 'a699dea5-2c73-4616-a270-1f7abb777e71' } +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*'; GUID = 'a699dea5-2c73-4616-a270-1f7abb777e71' } [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseDeclaredVarsMoreThanAssignments', '', diff --git a/tests/ContextVaults.Tests.ps1 b/tests/ContextVaults.Tests.ps1 index c400795..41abb63 100644 --- a/tests/ContextVaults.Tests.ps1 +++ b/tests/ContextVaults.Tests.ps1 @@ -1,4 +1,4 @@ -#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.999.999'; GUID = 'a699dea5-2c73-4616-a270-1f7abb777e71' } +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*'; GUID = 'a699dea5-2c73-4616-a270-1f7abb777e71' } [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseDeclaredVarsMoreThanAssignments', '', From 192b50e76f6703c164ef2d0f02191de7ec4a4e03 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Tue, 7 Jul 2026 21:10:27 +0200 Subject: [PATCH 07/21] Drop the GUID from the Pester requirement The GUID pins module identity (precise pinning), a stricter control than the lock-to-major risk appetite. Keep only the version range. --- tests/Context.Tests.ps1 | 2 +- tests/ContextVaults.Tests.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Context.Tests.ps1 b/tests/Context.Tests.ps1 index ba0e00f..f29a1ba 100644 --- a/tests/Context.Tests.ps1 +++ b/tests/Context.Tests.ps1 @@ -1,4 +1,4 @@ -#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*'; GUID = 'a699dea5-2c73-4616-a270-1f7abb777e71' } +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseDeclaredVarsMoreThanAssignments', '', diff --git a/tests/ContextVaults.Tests.ps1 b/tests/ContextVaults.Tests.ps1 index 41abb63..2bf573b 100644 --- a/tests/ContextVaults.Tests.ps1 +++ b/tests/ContextVaults.Tests.ps1 @@ -1,4 +1,4 @@ -#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*'; GUID = 'a699dea5-2c73-4616-a270-1f7abb777e71' } +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseDeclaredVarsMoreThanAssignments', '', From 289f2adbd77411bb25e3a6a9519c0c1f29bda1ea Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 12 Jul 2026 11:53:54 +0200 Subject: [PATCH 08/21] Adopt Process-PSModule v6.1.4 --- .github/workflows/Process-PSModule.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Process-PSModule.yml b/.github/workflows/Process-PSModule.yml index 9462763..a8e37ad 100644 --- a/.github/workflows/Process-PSModule.yml +++ b/.github/workflows/Process-PSModule.yml @@ -27,5 +27,5 @@ permissions: jobs: Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@205d193f34cbbaf9992955c21d842bcf98a1859f # v5.4.6 + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@da180bac16b13bfbcdf08b2e4e221b5b49e5ff28 # v6.1.4 secrets: inherit From 22b4648e79bbdd73b429dee8320fcc04828d0f83 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Thu, 23 Jul 2026 18:08:07 +0200 Subject: [PATCH 09/21] Speed recursive context conversion Replace pipeline-heavy recursion with direct .NET collection and string operations while preserving secure-string and nested-object behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...vert-ContextHashtableToObjectRecursive.ps1 | 73 +++++++++------ ...vert-ContextObjectToHashtableRecursive.ps1 | 92 +++++++++++-------- 2 files changed, 99 insertions(+), 66 deletions(-) diff --git a/src/functions/private/JsonToObject/Convert-ContextHashtableToObjectRecursive.ps1 b/src/functions/private/JsonToObject/Convert-ContextHashtableToObjectRecursive.ps1 index 3b259a0..4e26900 100644 --- a/src/functions/private/JsonToObject/Convert-ContextHashtableToObjectRecursive.ps1 +++ b/src/functions/private/JsonToObject/Convert-ContextHashtableToObjectRecursive.ps1 @@ -50,7 +50,7 @@ param ( # Hashtable to convert into a structured context object [Parameter(Mandatory)] - [hashtable] $Hashtable + [System.Collections.IDictionary] $Hashtable ) begin { @@ -60,42 +60,61 @@ process { try { - $result = [pscustomobject]@{} + $result = [ordered]@{} foreach ($key in $Hashtable.Keys) { $value = $Hashtable[$key] - Write-Debug "Processing [$key]" - Write-Debug "Value: $value" + if ($null -eq $value) { - Write-Debug "- as null value" - $result | Add-Member -NotePropertyName $key -NotePropertyValue $null + $result[$key] = $null + continue + } + + if ($value -is [string] -and $value.StartsWith('[SECURESTRING]', [System.StringComparison]::Ordinal)) { + $secureValue = $value.Substring(14) + $result[$key] = ConvertTo-SecureString -String $secureValue -AsPlainText -Force + continue + } + + if ($value -is [System.Collections.IDictionary]) { + $result[$key] = Convert-ContextHashtableToObjectRecursive $value continue } - Write-Debug "Type: $($value.GetType().Name)" - if ($value -is [string] -and $value -like '`[SECURESTRING`]*') { - Write-Debug "Converting [$key] as [SecureString]" - $secureValue = $value -replace '^\[SECURESTRING\]', '' - $result | Add-Member -NotePropertyName $key -NotePropertyValue ($secureValue | ConvertTo-SecureString -AsPlainText -Force) - } elseif ($value -is [hashtable]) { - Write-Debug "Converting [$key] as [hashtable]" - $result | Add-Member -NotePropertyName $key -NotePropertyValue (Convert-ContextHashtableToObjectRecursive $value) - } elseif ($value -is [array]) { - Write-Debug "Converting [$key] as [array], processing elements individually" - $result | Add-Member -NotePropertyName $key -NotePropertyValue @( - $value | ForEach-Object { - if ($_ -is [hashtable]) { - Convert-ContextHashtableToObjectRecursive $_ - } else { - $_ - } + + if ($value -is [System.Collections.IEnumerable] -and $value -isnot [string]) { + $requiresDeepConversion = $false + foreach ($item in $value) { + if ( + $item -is [System.Collections.IDictionary] -or + ($item -is [string] -and $item.StartsWith('[SECURESTRING]', [System.StringComparison]::Ordinal)) + ) { + $requiresDeepConversion = $true + break + } + } + + if (-not $requiresDeepConversion) { + $result[$key] = @($value) + continue + } + + $arrayResult = [System.Collections.Generic.List[object]]::new() + foreach ($item in $value) { + if ($item -is [System.Collections.IDictionary]) { + $null = $arrayResult.Add((Convert-ContextHashtableToObjectRecursive $item)) + } elseif ($item -is [string] -and $item.StartsWith('[SECURESTRING]', [System.StringComparison]::Ordinal)) { + $null = $arrayResult.Add((ConvertTo-SecureString -String $item.Substring(14) -AsPlainText -Force)) + } else { + $null = $arrayResult.Add($item) } - ) + } + $result[$key] = $arrayResult.ToArray() } else { - Write-Debug "Adding [$key] as a standard value" - $result | Add-Member -NotePropertyName $key -NotePropertyValue $value + $result[$key] = $value } } - return $result + + return [pscustomobject]$result } catch { Write-Error $_ throw 'Failed to convert hashtable to object' diff --git a/src/functions/private/ObjectToJson/Convert-ContextObjectToHashtableRecursive.ps1 b/src/functions/private/ObjectToJson/Convert-ContextObjectToHashtableRecursive.ps1 index 5a07831..f03ddcf 100644 --- a/src/functions/private/ObjectToJson/Convert-ContextObjectToHashtableRecursive.ps1 +++ b/src/functions/private/ObjectToJson/Convert-ContextObjectToHashtableRecursive.ps1 @@ -53,52 +53,66 @@ process { try { - $result = @{} + if ($null -eq $Object) { + return $null + } + + if ($Object -is [datetime]) { + return $Object.ToString('o') + } + + if ($Object -is [System.Security.SecureString]) { + $plainTextValue = [System.Net.NetworkCredential]::new('', $Object).Password + return "[SECURESTRING]$plainTextValue" + } - if ($Object -is [hashtable]) { - Write-Debug 'Converting [hashtable] to [PSCustomObject]' - $Object = [PSCustomObject]$Object - } elseif ($Object -is [string] -or $Object -is [int] -or $Object -is [bool]) { - Write-Debug 'returning as string' + if ($Object -is [string] -or $Object -is [ValueType]) { return $Object } - foreach ($property in $Object.PSObject.Properties) { - $name = $property.Name - $value = $property.Value - Write-Debug "Processing [$name]" - Write-Debug "Value: $value" - if ($null -eq $value) { - Write-Debug '- as null value' - $result[$property.Name] = $null - continue + if ($Object -is [System.Collections.IDictionary]) { + $dictionaryResult = @{} + foreach ($entry in $Object.GetEnumerator()) { + $dictionaryResult[[string]$entry.Key] = Convert-ContextObjectToHashtableRecursive $entry.Value } - Write-Debug "Type: $($value.GetType().Name)" - if ($value -is [datetime]) { - Write-Debug '- as DateTime' - $result[$property.Name] = $value.ToString('o') - } elseif ($value -is [string] -or $Object -is [int] -or $Object -is [bool]) { - Write-Debug '- as string, int, bool' - $result[$property.Name] = $value - } elseif ($value -is [System.Security.SecureString]) { - Write-Debug '- as SecureString' - $value = $value | ConvertFrom-SecureString -AsPlainText - $result[$property.Name] = "[SECURESTRING]$value" - } elseif ($value -is [psobject] -or $value -is [PSCustomObject] -or $value -is [hashtable]) { - Write-Debug '- as PSObject, PSCustomObject or hashtable' - $result[$property.Name] = Convert-ContextObjectToHashtableRecursive $value - } elseif ($value -is [System.Collections.IEnumerable]) { - Write-Debug '- as IEnumerable, including arrays and hashtables' - $result[$property.Name] = @( - $value | ForEach-Object { - Convert-ContextObjectToHashtableRecursive $_ - } - ) - } else { - Write-Debug '- as regular value' - $result[$property.Name] = $value + return $dictionaryResult + } + + if ($Object -is [System.Collections.IEnumerable]) { + $requiresDeepConversion = $false + foreach ($item in $Object) { + if ($null -eq $item) { + continue + } + + if ( + $item -is [System.Security.SecureString] -or + $item -is [datetime] -or + $item -is [System.Collections.IDictionary] -or + ($item -is [System.Collections.IEnumerable] -and $item -isnot [string]) -or + ($item -isnot [string] -and $item -isnot [ValueType]) + ) { + $requiresDeepConversion = $true + break + } } + + if (-not $requiresDeepConversion) { + return @($Object) + } + + $listResult = [System.Collections.Generic.List[object]]::new() + foreach ($item in $Object) { + $null = $listResult.Add((Convert-ContextObjectToHashtableRecursive $item)) + } + return $listResult.ToArray() } + + $result = @{} + foreach ($property in $Object.PSObject.Properties) { + $result[$property.Name] = Convert-ContextObjectToHashtableRecursive $property.Value + } + return $result } catch { Write-Error $_ From 100925d2ca8d7a884def1ccaf75c9a544af90626 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Thu, 23 Jul 2026 18:08:24 +0200 Subject: [PATCH 10/21] Accelerate indexed context operations Cache exact-ID file lookups and vault keypairs, defer Sodium loading until cryptography is used, and harden paths used for vault file operations. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../private/Assert-ContextSodiumModule.ps1 | 57 +++++ .../private/ContextFileIndexCache.ps1 | 197 ++++++++++++++++++ .../private/Get-ContextInfoFromFile.ps1 | 54 +++++ .../private/Get-ContextVaultKeyPair.ps1 | 17 +- src/functions/public/Get-Context.ps1 | 8 +- src/functions/public/Get-ContextInfo.ps1 | 99 +++++++-- src/functions/public/Remove-Context.ps1 | 3 +- src/functions/public/Set-Context.ps1 | 40 ++-- .../public/Vault/Remove-ContextVault.ps1 | 4 + .../public/Vault/Set-ContextVault.ps1 | 20 +- 10 files changed, 458 insertions(+), 41 deletions(-) create mode 100644 src/functions/private/Assert-ContextSodiumModule.ps1 create mode 100644 src/functions/private/ContextFileIndexCache.ps1 create mode 100644 src/functions/private/Get-ContextInfoFromFile.ps1 diff --git a/src/functions/private/Assert-ContextSodiumModule.ps1 b/src/functions/private/Assert-ContextSodiumModule.ps1 new file mode 100644 index 0000000..a8d13da --- /dev/null +++ b/src/functions/private/Assert-ContextSodiumModule.ps1 @@ -0,0 +1,57 @@ +function Assert-ContextSodiumModule { + <# + .SYNOPSIS + Ensures the required Sodium module version is available for context cryptography. + + .DESCRIPTION + Validates that Sodium v2.2.4 or newer is loaded in the current session. + Imports Sodium v2.2.4 if needed, and verifies required commands exist. + + .EXAMPLE + Assert-ContextSodiumModule + + Ensures Sodium cryptography commands are available before encryption or decryption. + #> + [CmdletBinding()] + param() + + begin { + $stackPath = Get-PSCallStackPath + Write-Debug "[$stackPath] - Begin" + } + + process { + if ($script:ContextSodiumModuleReady) { + return + } + + $minimumVersion = [version]'2.2.4' + $loadedSodium = Get-Module -Name Sodium | Sort-Object Version -Descending | Select-Object -First 1 + + if ($loadedSodium -and $loadedSodium.Version -lt $minimumVersion) { + throw "Loaded Sodium version [$($loadedSodium.Version)] is older than required version [$minimumVersion]. Start a new PowerShell session and import Sodium $minimumVersion." + } + + if (-not $loadedSodium) { + Import-Module -Name Sodium -RequiredVersion $minimumVersion -ErrorAction Stop + } + + $requiredCommands = @( + 'New-SodiumKeyPair', + 'ConvertTo-SodiumSealedBox', + 'ConvertFrom-SodiumSealedBox' + ) + + foreach ($commandName in $requiredCommands) { + if (-not (Get-Command -Name $commandName -ErrorAction SilentlyContinue)) { + throw "Required Sodium command '$commandName' is not available after loading the module." + } + } + + $script:ContextSodiumModuleReady = $true + } + + end { + Write-Debug "[$stackPath] - End" + } +} diff --git a/src/functions/private/ContextFileIndexCache.ps1 b/src/functions/private/ContextFileIndexCache.ps1 new file mode 100644 index 0000000..d04ca63 --- /dev/null +++ b/src/functions/private/ContextFileIndexCache.ps1 @@ -0,0 +1,197 @@ +function Get-ContextFileIndex { + <# + .SYNOPSIS + Gets a cached context file index for a vault. + + .DESCRIPTION + Builds (or returns) an in-memory index of context IDs to metadata file paths for a vault. + This avoids repeated full vault scans for exact-ID lookups in hot paths. + + .EXAMPLE + Get-ContextFileIndex -Vault 'MyVault' -VaultPath 'C:\Users\Jane\.contextvaults\MyVault' + + Returns a dictionary where keys are context IDs and values are metadata file paths. + + .OUTPUTS + [System.Collections.Generic.Dictionary[string,string]] + #> + [OutputType([System.Collections.Generic.Dictionary[string, string]])] + [CmdletBinding()] + param( + # The vault name. + [Parameter(Mandatory)] + [string] $Vault, + + # The full path to the vault folder. + [Parameter(Mandatory)] + [string] $VaultPath, + + # Rebuilds the index from disk even if cached. + [Parameter()] + [switch] $Refresh + ) + + begin { + $stackPath = Get-PSCallStackPath + Write-Debug "[$stackPath] - Begin" + if ($null -eq $script:ContextFileIndexCache) { + $script:ContextFileIndexCache = @{} + } + } + + process { + if (-not $Refresh -and $script:ContextFileIndexCache.ContainsKey($Vault)) { + return $script:ContextFileIndexCache[$Vault] + } + + $index = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $files = Get-ChildItem -Path $VaultPath -Filter *.json -File -ErrorAction SilentlyContinue + + foreach ($file in $files) { + try { + $contextInfo = Get-ContextInfoFromFile -Path $file.FullName -Vault $Vault -ErrorAction Stop + $index[$contextInfo.ID] = $contextInfo.Path + } catch { + Write-Warning "[$stackPath] - Failed to index context file '$($file.FullName)': $($_.Exception.Message)" + } + } + + $script:ContextFileIndexCache[$Vault] = $index + return $index + } + + end { + Write-Debug "[$stackPath] - End" + } +} + +function Set-ContextFileIndexEntry { + <# + .SYNOPSIS + Adds or updates a context entry in the in-memory vault index. + + .DESCRIPTION + Stores or updates an ID-to-file-path mapping in the in-memory cache for a vault. + + .EXAMPLE + Set-ContextFileIndexEntry -Vault 'MyVault' -ID 'MyContext' -Path 'C:\vault\abc.json' + + Updates the in-memory index for the given vault. + #> + [CmdletBinding()] + param( + # The vault name. + [Parameter(Mandatory)] + [string] $Vault, + + # The context ID. + [Parameter(Mandatory)] + [string] $ID, + + # The metadata file path. + [Parameter(Mandatory)] + [string] $Path + ) + + begin { + $stackPath = Get-PSCallStackPath + Write-Debug "[$stackPath] - Begin" + if ($null -eq $script:ContextFileIndexCache) { + $script:ContextFileIndexCache = @{} + } + } + + process { + if (-not $script:ContextFileIndexCache.ContainsKey($Vault)) { + $script:ContextFileIndexCache[$Vault] = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) + } + + $script:ContextFileIndexCache[$Vault][$ID] = $Path + } + + end { + Write-Debug "[$stackPath] - End" + } +} + +function Remove-ContextFileIndexEntry { + <# + .SYNOPSIS + Removes a context entry from the in-memory vault index. + + .DESCRIPTION + Removes an ID mapping from the in-memory index for a vault. + + .EXAMPLE + Remove-ContextFileIndexEntry -Vault 'MyVault' -ID 'MyContext' + + Removes the mapping for the specified context ID. + #> + [CmdletBinding()] + param( + # The vault name. + [Parameter(Mandatory)] + [string] $Vault, + + # The context ID. + [Parameter(Mandatory)] + [string] $ID + ) + + begin { + $stackPath = Get-PSCallStackPath + Write-Debug "[$stackPath] - Begin" + } + + process { + if ($null -eq $script:ContextFileIndexCache -or -not $script:ContextFileIndexCache.ContainsKey($Vault)) { + return + } + + $null = $script:ContextFileIndexCache[$Vault].Remove($ID) + } + + end { + Write-Debug "[$stackPath] - End" + } +} + +function Clear-ContextFileIndex { + <# + .SYNOPSIS + Clears in-memory vault index entries. + + .DESCRIPTION + Removes one or more vault entries from the in-memory context file index cache. + + .EXAMPLE + Clear-ContextFileIndex -Vault 'MyVault' + + Clears the in-memory index for 'MyVault'. + #> + [CmdletBinding()] + param( + # One or more vault names to clear from cache. + [Parameter(Mandatory)] + [string[]] $Vault + ) + + begin { + $stackPath = Get-PSCallStackPath + Write-Debug "[$stackPath] - Begin" + } + + process { + if ($null -eq $script:ContextFileIndexCache) { + return + } + + foreach ($vaultName in $Vault) { + $null = $script:ContextFileIndexCache.Remove($vaultName) + } + } + + end { + Write-Debug "[$stackPath] - End" + } +} diff --git a/src/functions/private/Get-ContextInfoFromFile.ps1 b/src/functions/private/Get-ContextInfoFromFile.ps1 new file mode 100644 index 0000000..b0dd6f9 --- /dev/null +++ b/src/functions/private/Get-ContextInfoFromFile.ps1 @@ -0,0 +1,54 @@ +function Get-ContextInfoFromFile { + <# + .SYNOPSIS + Reads and parses a context metadata file. + + .DESCRIPTION + Reads a context metadata file from disk and returns a ContextInfo object. + The returned Path always points to the actual file on disk, not the metadata payload. + + .EXAMPLE + Get-ContextInfoFromFile -Path 'C:\Users\Jane\.contextvaults\MyVault\abc.json' -Vault 'MyVault' + + Parses the context metadata file and returns a ContextInfo instance. + + .OUTPUTS + [ContextInfo] + #> + [OutputType([ContextInfo])] + [CmdletBinding()] + param( + # Full path to the context metadata file. + [Parameter(Mandatory)] + [string] $Path, + + # The vault name the file belongs to. + [Parameter(Mandatory)] + [string] $Vault + ) + + begin { + $stackPath = Get-PSCallStackPath + Write-Debug "[$stackPath] - Begin" + } + + process { + $content = Get-ContentNonLocking -Path $Path + $rawContextInfo = $content | ConvertFrom-Json -ErrorAction Stop + + if ([string]::IsNullOrWhiteSpace($rawContextInfo.ID)) { + throw "Context metadata file '$Path' does not contain a valid ID." + } + + [ContextInfo]::new([pscustomobject]@{ + ID = [string]$rawContextInfo.ID + Path = $Path + Vault = $Vault + Context = [string]$rawContextInfo.Context + }) + } + + end { + Write-Debug "[$stackPath] - End" + } +} diff --git a/src/functions/private/Get-ContextVaultKeyPair.ps1 b/src/functions/private/Get-ContextVaultKeyPair.ps1 index ef51819..03e103a 100644 --- a/src/functions/private/Get-ContextVaultKeyPair.ps1 +++ b/src/functions/private/Get-ContextVaultKeyPair.ps1 @@ -29,6 +29,10 @@ begin { $stackPath = Get-PSCallStackPath Write-Debug "[$stackPath] - Start" + Assert-ContextSodiumModule + if ($null -eq $script:ContextVaultKeyPairCache) { + $script:ContextVaultKeyPairCache = @{} + } } process { @@ -42,12 +46,23 @@ throw "[$stackPath] - Unable to read shard file '$shardPath': $($_.Exception.Message)" } + if ($script:ContextVaultKeyPairCache.ContainsKey($vaultObject.Name)) { + $cachedEntry = $script:ContextVaultKeyPairCache[$vaultObject.Name] + if ($cachedEntry.Shard -eq $fileShard) { + return $cachedEntry.Keys + } + } + $machineShard = [System.Environment]::MachineName $userShard = [System.Environment]::UserName #$userInputShard = Read-Host -Prompt 'Enter a seed shard' # Eventually 4 shards. +1 for user input. $seed = $machineShard + $userShard + $fileShard # + $userInputShard $keys = New-SodiumKeyPair -Seed $seed - $keys + $script:ContextVaultKeyPairCache[$vaultObject.Name] = [pscustomobject]@{ + Shard = $fileShard + Keys = $keys + } + return $keys } end { diff --git a/src/functions/public/Get-Context.ps1 b/src/functions/public/Get-Context.ps1 index 9bc6d94..66dba66 100644 --- a/src/functions/public/Get-Context.ps1 +++ b/src/functions/public/Get-Context.ps1 @@ -1,5 +1,4 @@ #Requires -Modules @{ ModuleName = 'Sodium'; RequiredVersion = '2.2.5' } - function Get-Context { <# .SYNOPSIS @@ -107,6 +106,8 @@ function Get-Context { begin { $stackPath = Get-PSCallStackPath Write-Debug "[$stackPath] - Begin" + $vaultKeyCache = @{} + Assert-ContextSodiumModule } process { @@ -118,7 +119,10 @@ function Get-Context { Write-Warning "Context file does not exist: $($contextInfo.Path)" continue } - $keys = Get-ContextVaultKeyPair -Vault $contextInfo.Vault + if (-not $vaultKeyCache.ContainsKey($contextInfo.Vault)) { + $vaultKeyCache[$contextInfo.Vault] = Get-ContextVaultKeyPair -Vault $contextInfo.Vault + } + $keys = $vaultKeyCache[$contextInfo.Vault] $params = @{ SealedBox = $contextInfo.Context PublicKey = $keys.PublicKey diff --git a/src/functions/public/Get-ContextInfo.ps1 b/src/functions/public/Get-ContextInfo.ps1 index bdf26a2..7de2157 100644 --- a/src/functions/public/Get-ContextInfo.ps1 +++ b/src/functions/public/Get-ContextInfo.ps1 @@ -79,32 +79,93 @@ } process { + $idPatterns = [System.Collections.Generic.List[System.Management.Automation.WildcardPattern]]::new() + $exactIds = [System.Collections.Generic.List[string]]::new() + $hasWildcardId = $false + + foreach ($idItem in $ID) { + if ([string]::IsNullOrWhiteSpace($idItem)) { + continue + } + + $wildcardPattern = [System.Management.Automation.WildcardPattern]::new($idItem, [System.Management.Automation.WildcardOptions]::IgnoreCase) + $null = $idPatterns.Add($wildcardPattern) + + if ([System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters($idItem)) { + $hasWildcardId = $true + continue + } + + if (-not $exactIds.Contains($idItem)) { + $null = $exactIds.Add($idItem) + } + } + + if ($idPatterns.Count -eq 0) { + return + } + $vaults = foreach ($vaultName in $Vault) { Get-ContextVault -Name $vaultName -ErrorAction Stop } Write-Verbose "[$stackPath] - Found $($vaults.Count) vault(s) matching '$($Vault -join ', ')'." - $files = foreach ($vaultObject in $vaults) { - Get-ChildItem -Path $vaultObject.Path -Filter *.json -File - } - Write-Verbose "[$stackPath] - Found $($files.Count) context file(s) in vault(s)." - - foreach ($file in $files) { - # Use non-locking file reading to allow concurrent access - try { - $content = Get-ContentNonLocking -Path $file.FullName - $contextInfo = $content | ConvertFrom-Json - } catch { - Write-Warning "[$stackPath] - Error reading context file '$($file.FullName)': $($_.Exception.Message)" + foreach ($vaultObject in $vaults) { + $contextIndex = Get-ContextFileIndex -Vault $vaultObject.Name -VaultPath $vaultObject.Path + + if (-not $hasWildcardId -and $exactIds.Count -gt 0) { + foreach ($exactId in $exactIds) { + $contextPath = $null + if (-not $contextIndex.TryGetValue($exactId, [ref]$contextPath)) { + continue + } + + if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { + Remove-ContextFileIndexEntry -Vault $vaultObject.Name -ID $exactId + continue + } + + try { + $contextInfo = Get-ContextInfoFromFile -Path $contextPath -Vault $vaultObject.Name -ErrorAction Stop + Set-ContextFileIndexEntry -Vault $vaultObject.Name -ID $contextInfo.ID -Path $contextInfo.Path + } catch { + Write-Warning "[$stackPath] - Error reading context file '$contextPath': $($_.Exception.Message)" + Remove-ContextFileIndexEntry -Vault $vaultObject.Name -ID $exactId + continue + } + + if ($contextInfo.ID -like $exactId) { + $contextInfo + } else { + Remove-ContextFileIndexEntry -Vault $vaultObject.Name -ID $exactId + } + } + continue } - if ($VerbosePreference -eq 'Continue') { - Write-Verbose "[$stackPath] - Processing file: $($file.FullName)" - $contextInfo | Format-List | Out-String -Stream | ForEach-Object { Write-Verbose "[$stackPath] $_" } - } - foreach ($IDItem in $ID) { - if ($contextInfo.ID -like $IDItem) { - [ContextInfo]::new($contextInfo) + + $files = Get-ChildItem -Path $vaultObject.Path -Filter *.json -File + Write-Verbose "[$stackPath] - Found $($files.Count) context file(s) in vault [$($vaultObject.Name)]." + + foreach ($file in $files) { + try { + $contextInfo = Get-ContextInfoFromFile -Path $file.FullName -Vault $vaultObject.Name -ErrorAction Stop + Set-ContextFileIndexEntry -Vault $vaultObject.Name -ID $contextInfo.ID -Path $contextInfo.Path + } catch { + Write-Warning "[$stackPath] - Error reading context file '$($file.FullName)': $($_.Exception.Message)" + continue + } + + if ($VerbosePreference -eq 'Continue') { + Write-Verbose "[$stackPath] - Processing file: $($file.FullName)" + $contextInfo | Format-List | Out-String -Stream | ForEach-Object { Write-Verbose "[$stackPath] $_" } + } + + foreach ($pattern in $idPatterns) { + if ($pattern.IsMatch($contextInfo.ID)) { + $contextInfo + break + } } } } diff --git a/src/functions/public/Remove-Context.ps1 b/src/functions/public/Remove-Context.ps1 index c9ac418..af69ebe 100644 --- a/src/functions/public/Remove-Context.ps1 +++ b/src/functions/public/Remove-Context.ps1 @@ -102,7 +102,8 @@ if ($PSCmdlet.ShouldProcess("Context '$contextId'", 'Remove')) { Write-Verbose "[$stackPath] - Removing context [$contextId]" - $contextInfo.Path | Remove-Item -Force -ErrorAction Stop + Remove-Item -LiteralPath $contextInfo.Path -Force -ErrorAction Stop + Remove-ContextFileIndexEntry -Vault $contextInfo.Vault -ID $contextInfo.ID Write-Verbose "[$stackPath] - Removed item: $contextId" } } diff --git a/src/functions/public/Set-Context.ps1 b/src/functions/public/Set-Context.ps1 index 7914c7d..f8efdb7 100644 --- a/src/functions/public/Set-Context.ps1 +++ b/src/functions/public/Set-Context.ps1 @@ -1,5 +1,4 @@ #Requires -Modules @{ ModuleName = 'Sodium'; RequiredVersion = '2.2.5' } - function Set-Context { <# .SYNOPSIS @@ -76,11 +75,14 @@ function Set-Context { begin { $stackPath = Get-PSCallStackPath Write-Debug "[$stackPath] - Begin" + Assert-ContextSodiumModule } process { $vaultObject = Set-ContextVault -Name $Vault -PassThru - $vaultObject | Format-List | Out-String -Stream | ForEach-Object { Write-Verbose "[$stackPath] $_" } + if ($VerbosePreference -eq 'Continue') { + $vaultObject | Format-List | Out-String -Stream | ForEach-Object { Write-Verbose "[$stackPath] $_" } + } if ($context -is [System.Collections.IDictionary]) { $Context = [PSCustomObject]$Context @@ -93,16 +95,21 @@ function Set-Context { throw 'An ID is required, either as a parameter or as a property of the context object.' } - $contextInfo = Get-ContextInfo -ID $ID -Vault $Vault - Write-Verbose 'Context info:' - $contextInfo | Format-List | Out-String -Stream | ForEach-Object { Write-Verbose "[$stackPath] $_" } - if (-not $contextInfo) { + $contextPath = $null + $contextIndex = Get-ContextFileIndex -Vault $vaultObject.Name -VaultPath $vaultObject.Path + $existingContextPath = $null + if ($contextIndex.TryGetValue($ID, [ref]$existingContextPath)) { + if (Test-Path -LiteralPath $existingContextPath -PathType Leaf) { + Write-Verbose "[$stackPath] - Context [$ID] found in [$Vault]" + $contextPath = $existingContextPath + } else { + Remove-ContextFileIndexEntry -Vault $vaultObject.Name -ID $ID + } + } + + if ($null -eq $contextPath) { Write-Verbose "[$stackPath] - Creating context [$ID] in [$Vault]" - $guid = [Guid]::NewGuid().Guid - $contextPath = Join-Path -Path $vaultObject.Path -ChildPath "$guid.json" - } else { - Write-Verbose "[$stackPath] - Context [$ID] found in [$Vault]" - $contextPath = $contextInfo.Path + $contextPath = [System.IO.Path]::Combine($vaultObject.Path, "$([Guid]::NewGuid().Guid).json") } Write-Verbose "[$stackPath] - Context path: [$contextPath]" @@ -113,13 +120,16 @@ function Set-Context { Path = $contextPath Vault = $Vault Context = ConvertTo-SodiumSealedBox -Message $contextJson -PublicKey $keys.PublicKey - } | ConvertTo-Json -Depth 5 - Write-Verbose 'Content:' - $content | ConvertTo-Json -Depth 5 | Out-String -Stream | ForEach-Object { Write-Verbose "[$stackPath] $_" } + } | ConvertTo-Json -Depth 5 -Compress + if ($VerbosePreference -eq 'Continue') { + Write-Verbose 'Content:' + $content | Out-String -Stream | ForEach-Object { Write-Verbose "[$stackPath] $_" } + } if ($PSCmdlet.ShouldProcess("file: [$contextPath]", 'Set content')) { Write-Verbose "[$stackPath] - Setting context [$ID] in vault [$Vault]" - Set-Content -Path $contextPath -Value $content + [System.IO.File]::WriteAllText($contextPath, $content, [System.Text.UTF8Encoding]::new($false)) + Set-ContextFileIndexEntry -Vault $vaultObject.Name -ID $ID -Path $contextPath } if ($PassThru) { diff --git a/src/functions/public/Vault/Remove-ContextVault.ps1 b/src/functions/public/Vault/Remove-ContextVault.ps1 index 1d7cf32..d6a3381 100644 --- a/src/functions/public/Vault/Remove-ContextVault.ps1 +++ b/src/functions/public/Vault/Remove-ContextVault.ps1 @@ -41,6 +41,10 @@ Write-Verbose "Removing ContextVault [$($vault.Name)] at path [$($vault.Path)]" if ($PSCmdlet.ShouldProcess("ContextVault: [$($vault.Name)]", 'Remove')) { Remove-Item -Path $vault.Path -Recurse -Force + Clear-ContextFileIndex -Vault $vault.Name + if ($null -ne $script:ContextVaultKeyPairCache) { + $null = $script:ContextVaultKeyPairCache.Remove($vault.Name) + } Write-Verbose "ContextVault [$($vault.Name)] removed successfully." } } diff --git a/src/functions/public/Vault/Set-ContextVault.ps1 b/src/functions/public/Vault/Set-ContextVault.ps1 index 4d40e2b..5dca90c 100644 --- a/src/functions/public/Vault/Set-ContextVault.ps1 +++ b/src/functions/public/Vault/Set-ContextVault.ps1 @@ -38,20 +38,34 @@ function Set-ContextVault { process { foreach ($vaultName in $Name) { + if ([string]::IsNullOrWhiteSpace($vaultName)) { + throw 'Vault name cannot be null, empty, or whitespace.' + } + if ( + [System.IO.Path]::IsPathRooted($vaultName) -or + $vaultName.Contains([System.IO.Path]::DirectorySeparatorChar) -or + $vaultName.Contains([System.IO.Path]::AltDirectorySeparatorChar) -or + $vaultName -eq '.' -or + $vaultName -eq '..' -or + $vaultName.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) -ge 0 + ) { + throw "Vault name '$vaultName' is invalid. Use a simple folder name without path separators." + } + Write-Verbose "Processing vault: $vaultName" $vaultPath = Join-Path -Path $script:Config.RootPath -ChildPath $vaultName if (-not (Test-Path $vaultPath)) { - Write-Verbose "Creating new vault [$($vault.Name)]" + Write-Verbose "Creating new vault [$vaultName]" if ($PSCmdlet.ShouldProcess("context vault folder $vaultName", 'Set')) { $null = New-Item -Path $vaultPath -ItemType Directory -Force } } $fileShardPath = Join-Path -Path $vaultPath -ChildPath $script:Config.ShardFileName if (-not (Test-Path $fileShardPath)) { - Write-Verbose "Generating encryption keys for vault [$($vault.Name)]" + Write-Verbose "Generating encryption keys for vault [$vaultName]" if ($PSCmdlet.ShouldProcess("shard file $fileShardPath", 'Set')) { - Set-Content -Path $fileShardPath -Value ([System.Guid]::NewGuid().ToString()) + [System.IO.File]::WriteAllText($fileShardPath, [System.Guid]::NewGuid().ToString(), [System.Text.UTF8Encoding]::new($false)) } } From 56cee0e478155bebb42fda34cf5fc992bb655174 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Thu, 23 Jul 2026 18:08:30 +0200 Subject: [PATCH 11/21] Add heavy performance and path safety tests Run the suite on Pester 6 and cover populated-vault workloads, repeated context roundtrips, cross-vault exact-ID results, and path traversal protections. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/Context.Tests.ps1 | 105 ++++++++++++++++++++++++++++++++++ tests/ContextVaults.Tests.ps1 | 4 ++ 2 files changed, 109 insertions(+) diff --git a/tests/Context.Tests.ps1 b/tests/Context.Tests.ps1 index f29a1ba..aae79b8 100644 --- a/tests/Context.Tests.ps1 +++ b/tests/Context.Tests.ps1 @@ -370,6 +370,13 @@ Describe 'Context' { $result.ID | Should -Be 'TestID1' } + It 'Should return matching ID from all vaults when vault is not specified' { + $results = Get-ContextInfo -ID 'TestID1' + $results | Should -HaveCount 2 + $results.Vault | Should -Contain 'VaultA' + $results.Vault | Should -Contain 'VaultB' + } + It 'Should return multiple contexts matching wildcard ID in VaultA' { $results = Get-ContextInfo -ID 'TestID*' -Vault 'VaultA' $results | Should -HaveCount 3 @@ -392,5 +399,103 @@ Describe 'Context' { $results.ID | Should -Contain 'TestID2' $results.ID | Should -Not -Contain 'TestID3' } + + It 'Should use the metadata file path on disk when Path value is tampered' { + $vaultPath = (Get-ContextVault -Name 'VaultA').Path + $file = Get-ChildItem -Path $vaultPath -Filter *.json -File | Select-Object -First 1 + $contextInfo = Get-Content -Path $file.FullName -Raw | ConvertFrom-Json + $contextInfo.Path = 'C:\tampered\path.json' + $contextInfo | ConvertTo-Json -Depth 5 -Compress | Set-Content -Path $file.FullName + + $result = Get-ContextInfo -ID $contextInfo.ID -Vault 'VaultA' + + $result | Should -Not -BeNullOrEmpty + $result.Path | Should -Be $file.FullName + } + } + + Context 'Performance' { + BeforeAll { + $perfVault = 'Perf-Heavy' + Get-ContextVault -Name $perfVault | Remove-ContextVault -Confirm:$false + Set-ContextVault -Name $perfVault | Out-Null + } + + AfterAll { + Get-ContextVault -Name 'Perf-Heavy' | Remove-ContextVault -Confirm:$false + } + + It 'Handles heavy set/get workload with large payloads' { + $perfVault = 'Perf-Heavy' + $contextCount = 150 + $payload = [PSCustomObject]@{ + Username = 'perf-user' + AuthToken = 'token-123' | ConvertTo-SecureString -AsPlainText -Force + LoginTime = Get-Date + IsTwoFactorAuth = $true + TwoFactorMethods = @('TOTP', 'SMS', 'WebAuthN') + Repositories = @( + [PSCustomObject]@{ Name = 'Repo1'; IsPrivate = $true; Stars = 42; Languages = @('PowerShell', 'C#') }, + [PSCustomObject]@{ Name = 'Repo2'; IsPrivate = $false; Stars = 130; Languages = @('C#', 'HTML', 'CSS') } + ) + UserPreferences = [PSCustomObject]@{ + Theme = 'dark' + DefaultBranch = 'main' + Notifications = [PSCustomObject]@{ Email = $true; Push = $false; SMS = $true } + } + SessionMetaData = [PSCustomObject]@{ + SessionID = 'sess_perf' + Device = 'Windows-PC' + Location = [PSCustomObject]@{ Country = 'NO'; City = 'Bergen' } + } + LargeArray = 1..500 + } + + $setWatch = [System.Diagnostics.Stopwatch]::StartNew() + foreach ($i in 1..$contextCount) { + Set-Context -ID "perf-$i" -Context $payload -Vault $perfVault | Out-Null + } + $setWatch.Stop() + + $getWatch = [System.Diagnostics.Stopwatch]::StartNew() + $contexts = Get-Context -ID 'perf-*' -Vault $perfVault + $getWatch.Stop() + + Write-Host ("Performance workload: set={0}ms get={1}ms count={2}" -f [math]::Round($setWatch.Elapsed.TotalMilliseconds, 2), [math]::Round($getWatch.Elapsed.TotalMilliseconds, 2), $contexts.Count) + + Should-Be $contextCount $contexts.Count + $setWatch.Elapsed.TotalSeconds | Should-BeLessThan 90 + $getWatch.Elapsed.TotalSeconds | Should-BeLessThan 20 + } + + It 'Handles heavy context roundtrips with unique IDs' { + $perfVault = 'Perf-Heavy' + $roundtripCount = 100 + $payload = [PSCustomObject]@{ + UserName = 'perf-json' + Token = 'json-token' | ConvertTo-SecureString -AsPlainText -Force + Created = Get-Date + Flags = @($true, $false, $true) + Nested = [PSCustomObject]@{ + Names = @('alpha', 'beta', 'gamma') + Data = @( + [PSCustomObject]@{ Key = 'k1'; Value = 1 }, + [PSCustomObject]@{ Key = 'k2'; Value = 2 } + ) + } + Values = 1..1000 + } + + $watch = [System.Diagnostics.Stopwatch]::StartNew() + foreach ($i in 1..$roundtripCount) { + Set-Context -ID "json-$i" -Context $payload -Vault $perfVault | Out-Null + $obj = Get-Context -ID "json-$i" -Vault $perfVault + Should-Be "json-$i" $obj.ID + } + $watch.Stop() + + Write-Host ("Performance context roundtrip: count={0} total={1}ms" -f $roundtripCount, [math]::Round($watch.Elapsed.TotalMilliseconds, 2)) + $watch.Elapsed.TotalSeconds | Should-BeLessThan 90 + } } } diff --git a/tests/ContextVaults.Tests.ps1 b/tests/ContextVaults.Tests.ps1 index 2bf573b..bc6dc7e 100644 --- a/tests/ContextVaults.Tests.ps1 +++ b/tests/ContextVaults.Tests.ps1 @@ -64,6 +64,10 @@ Describe 'ContextVault' { It 'Should not throw when setting an existing vault' { { Set-ContextVault -Name 'test-vault1' } | Should -Not -Throw } + + It 'Should throw for vault names that include path separators' { + { Set-ContextVault -Name '..\outside-root' } | Should -Throw + } } Context 'Get-ContextVault' { From 32ec01bbff44d8a51b70f336a44310887c9c00a7 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Thu, 23 Jul 2026 18:09:52 +0200 Subject: [PATCH 12/21] Align performance helpers with analyzer rules Wrap long calls, declare recursive output types, and document why in-memory cache helpers do not use ShouldProcess. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../private/Assert-ContextSodiumModule.ps1 | 4 +++- src/functions/private/ContextFileIndexCache.ps1 | 13 ++++++++++++- src/functions/private/Get-ContextInfoFromFile.ps1 | 10 +++++----- .../Convert-ContextObjectToHashtableRecursive.ps1 | 2 +- src/functions/public/Get-ContextInfo.ps1 | 5 ++++- 5 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/functions/private/Assert-ContextSodiumModule.ps1 b/src/functions/private/Assert-ContextSodiumModule.ps1 index a8d13da..5e64304 100644 --- a/src/functions/private/Assert-ContextSodiumModule.ps1 +++ b/src/functions/private/Assert-ContextSodiumModule.ps1 @@ -29,7 +29,9 @@ function Assert-ContextSodiumModule { $loadedSodium = Get-Module -Name Sodium | Sort-Object Version -Descending | Select-Object -First 1 if ($loadedSodium -and $loadedSodium.Version -lt $minimumVersion) { - throw "Loaded Sodium version [$($loadedSodium.Version)] is older than required version [$minimumVersion]. Start a new PowerShell session and import Sodium $minimumVersion." + $message = "Loaded Sodium version [$($loadedSodium.Version)] is older than required version [$minimumVersion]. " + $message += "Start a new PowerShell session and import Sodium $minimumVersion." + throw $message } if (-not $loadedSodium) { diff --git a/src/functions/private/ContextFileIndexCache.ps1 b/src/functions/private/ContextFileIndexCache.ps1 index d04ca63..3eaa748 100644 --- a/src/functions/private/ContextFileIndexCache.ps1 +++ b/src/functions/private/ContextFileIndexCache.ps1 @@ -78,6 +78,10 @@ function Set-ContextFileIndexEntry { Updates the in-memory index for the given vault. #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'This private helper only mutates an in-memory cache.' + )] [CmdletBinding()] param( # The vault name. @@ -103,7 +107,10 @@ function Set-ContextFileIndexEntry { process { if (-not $script:ContextFileIndexCache.ContainsKey($Vault)) { - $script:ContextFileIndexCache[$Vault] = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $vaultIndex = [System.Collections.Generic.Dictionary[string, string]]::new( + [System.StringComparer]::OrdinalIgnoreCase + ) + $script:ContextFileIndexCache[$Vault] = $vaultIndex } $script:ContextFileIndexCache[$Vault][$ID] = $Path @@ -127,6 +134,10 @@ function Remove-ContextFileIndexEntry { Removes the mapping for the specified context ID. #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'This private helper only mutates an in-memory cache.' + )] [CmdletBinding()] param( # The vault name. diff --git a/src/functions/private/Get-ContextInfoFromFile.ps1 b/src/functions/private/Get-ContextInfoFromFile.ps1 index b0dd6f9..a1c401b 100644 --- a/src/functions/private/Get-ContextInfoFromFile.ps1 +++ b/src/functions/private/Get-ContextInfoFromFile.ps1 @@ -41,11 +41,11 @@ function Get-ContextInfoFromFile { } [ContextInfo]::new([pscustomobject]@{ - ID = [string]$rawContextInfo.ID - Path = $Path - Vault = $Vault - Context = [string]$rawContextInfo.Context - }) + ID = [string]$rawContextInfo.ID + Path = $Path + Vault = $Vault + Context = [string]$rawContextInfo.Context + }) } end { diff --git a/src/functions/private/ObjectToJson/Convert-ContextObjectToHashtableRecursive.ps1 b/src/functions/private/ObjectToJson/Convert-ContextObjectToHashtableRecursive.ps1 index f03ddcf..1a0f3c4 100644 --- a/src/functions/private/ObjectToJson/Convert-ContextObjectToHashtableRecursive.ps1 +++ b/src/functions/private/ObjectToJson/Convert-ContextObjectToHashtableRecursive.ps1 @@ -38,7 +38,7 @@ .LINK https://psmodule.io/Context/Functions/Convert-ContextObjectToHashtableRecursive #> - [OutputType([hashtable])] + [OutputType([string], [ValueType], [hashtable], [object[]])] [CmdletBinding()] param ( # The object to convert. diff --git a/src/functions/public/Get-ContextInfo.ps1 b/src/functions/public/Get-ContextInfo.ps1 index 7de2157..f1ff0cd 100644 --- a/src/functions/public/Get-ContextInfo.ps1 +++ b/src/functions/public/Get-ContextInfo.ps1 @@ -88,7 +88,10 @@ continue } - $wildcardPattern = [System.Management.Automation.WildcardPattern]::new($idItem, [System.Management.Automation.WildcardOptions]::IgnoreCase) + $wildcardPattern = [System.Management.Automation.WildcardPattern]::new( + $idItem, + [System.Management.Automation.WildcardOptions]::IgnoreCase + ) $null = $idPatterns.Add($wildcardPattern) if ([System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters($idItem)) { From 43542eff4792b4de0bf8d4bda82c0a42c6753411 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Thu, 23 Jul 2026 18:19:46 +0200 Subject: [PATCH 13/21] Align CI to Process-PSModule v6.1.13 - Pin workflow to v6.1.13 (SHA fb1bdb8fefd243292f779d2a856a38db6fe6daf4) - Replace secrets: inherit with explicit APIKey secret per v6.0.0 breaking change - Context has no test secrets so TestData is omitted Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/Process-PSModule.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/Process-PSModule.yml b/.github/workflows/Process-PSModule.yml index a8e37ad..3130683 100644 --- a/.github/workflows/Process-PSModule.yml +++ b/.github/workflows/Process-PSModule.yml @@ -27,5 +27,6 @@ permissions: jobs: Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@da180bac16b13bfbcdf08b2e4e221b5b49e5ff28 # v6.1.4 - secrets: inherit + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@fb1bdb8fefd243292f779d2a856a38db6fe6daf4 # v6.1.13 + secrets: + APIKey: ${{ secrets.APIKey }} From 4d067555dbce45d9067702f1198d58e9ad5bf758 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Thu, 23 Jul 2026 18:25:15 +0200 Subject: [PATCH 14/21] Add zensical.toml to replace mkdocs.yml for docs site Translates all mkdocs.yml settings to Zensical TOML format: - Preserves palette colors (black/green dark, indigo/green light) - Updates palette toggle icons from material/ to lucide/ - Retains all navigation features and markdown extensions - Adds content.code.copy and pymdownx.superfences (Zensical standard) - Social links and cookie consent carried over Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/zensical.toml | 69 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 .github/zensical.toml diff --git a/.github/zensical.toml b/.github/zensical.toml new file mode 100644 index 0000000..8740837 --- /dev/null +++ b/.github/zensical.toml @@ -0,0 +1,69 @@ +[project] +site_name = "Context" +repo_name = "PSModule/Context" +repo_url = "https://github.com/PSModule/Context" + +[project.theme] +variant = "classic" +language = "en" +logo = "Assets/icon.png" +favicon = "Assets/icon.png" +features = [ + "navigation.instant", + "navigation.instant.progress", + "navigation.indexes", + "navigation.top", + "navigation.tracking", + "navigation.expand", + "search.suggest", + "search.highlight", + "content.code.copy" +] + +[[project.theme.palette]] +media = "(prefers-color-scheme)" +toggle.icon = "lucide/sun-moon" +toggle.name = "Switch to dark mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: dark)" +scheme = "slate" +primary = "black" +accent = "green" +toggle.icon = "lucide/moon" +toggle.name = "Switch to light mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: light)" +scheme = "default" +primary = "indigo" +accent = "green" +toggle.icon = "lucide/sun" +toggle.name = "Switch to system preference" + +[project.theme.icon] +repo = "fontawesome/brands/github" + +[project.markdown_extensions.toc] +permalink = true + +[project.markdown_extensions.attr_list] +[project.markdown_extensions.admonition] +[project.markdown_extensions.md_in_html] +[project.markdown_extensions.pymdownx.details] +[project.markdown_extensions.pymdownx.superfences] + +[[project.extra.social]] +icon = "fontawesome/brands/discord" +link = "https://discord.gg/jedJWCPAhD" +name = "PSModule on Discord" + +[[project.extra.social]] +icon = "fontawesome/brands/github" +link = "https://github.com/PSModule/" +name = "PSModule on GitHub" + +[project.extra.consent] +title = "Cookie consent" +description = "We use cookies to recognize your repeated visits and preferences, as well as to measure the effectiveness of our documentation and whether users find what they're searching for. With your consent, you're helping us to make our documentation better." +actions = ["accept", "reject"] From 00fa36089108ab9cd9938b73c4a38d0b61d33675 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 26 Jul 2026 01:18:25 +0200 Subject: [PATCH 15/21] Refresh exact-ID cache and harden vault names Refresh the on-disk index before treating exact-ID lookups as missing, reject wildcard metacharacters in vault names, and use literal paths for vault deletion. Split cache helpers into one function per file so source-code CI recognizes them correctly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../private/Clear-ContextFileIndex.ps1 | 39 ++++ .../private/ContextFileIndexCache.ps1 | 208 ------------------ .../private/Get-ContextFileIndex.ps1 | 66 ++++++ .../private/Remove-ContextFileIndexEntry.ps1 | 45 ++++ .../private/Set-ContextFileIndexEntry.ps1 | 55 +++++ src/functions/public/Get-ContextInfo.ps1 | 5 +- src/functions/public/Set-Context.ps1 | 6 + .../public/Vault/Remove-ContextVault.ps1 | 2 +- .../public/Vault/Set-ContextVault.ps1 | 3 +- tests/Context.Tests.ps1 | 21 ++ tests/ContextVaults.Tests.ps1 | 4 + 11 files changed, 243 insertions(+), 211 deletions(-) create mode 100644 src/functions/private/Clear-ContextFileIndex.ps1 delete mode 100644 src/functions/private/ContextFileIndexCache.ps1 create mode 100644 src/functions/private/Get-ContextFileIndex.ps1 create mode 100644 src/functions/private/Remove-ContextFileIndexEntry.ps1 create mode 100644 src/functions/private/Set-ContextFileIndexEntry.ps1 diff --git a/src/functions/private/Clear-ContextFileIndex.ps1 b/src/functions/private/Clear-ContextFileIndex.ps1 new file mode 100644 index 0000000..0dd76ae --- /dev/null +++ b/src/functions/private/Clear-ContextFileIndex.ps1 @@ -0,0 +1,39 @@ +function Clear-ContextFileIndex { + <# + .SYNOPSIS + Clears in-memory vault index entries. + + .DESCRIPTION + Removes one or more vault entries from the in-memory context file index cache. + + .EXAMPLE + Clear-ContextFileIndex -Vault 'MyVault' + + Clears the in-memory index for 'MyVault'. + #> + [CmdletBinding()] + param( + # One or more vault names to clear from cache. + [Parameter(Mandatory)] + [string[]] $Vault + ) + + begin { + $stackPath = Get-PSCallStackPath + Write-Debug "[$stackPath] - Begin" + } + + process { + if ($null -eq $script:ContextFileIndexCache) { + return + } + + foreach ($vaultName in $Vault) { + $null = $script:ContextFileIndexCache.Remove($vaultName) + } + } + + end { + Write-Debug "[$stackPath] - End" + } +} diff --git a/src/functions/private/ContextFileIndexCache.ps1 b/src/functions/private/ContextFileIndexCache.ps1 deleted file mode 100644 index 3eaa748..0000000 --- a/src/functions/private/ContextFileIndexCache.ps1 +++ /dev/null @@ -1,208 +0,0 @@ -function Get-ContextFileIndex { - <# - .SYNOPSIS - Gets a cached context file index for a vault. - - .DESCRIPTION - Builds (or returns) an in-memory index of context IDs to metadata file paths for a vault. - This avoids repeated full vault scans for exact-ID lookups in hot paths. - - .EXAMPLE - Get-ContextFileIndex -Vault 'MyVault' -VaultPath 'C:\Users\Jane\.contextvaults\MyVault' - - Returns a dictionary where keys are context IDs and values are metadata file paths. - - .OUTPUTS - [System.Collections.Generic.Dictionary[string,string]] - #> - [OutputType([System.Collections.Generic.Dictionary[string, string]])] - [CmdletBinding()] - param( - # The vault name. - [Parameter(Mandatory)] - [string] $Vault, - - # The full path to the vault folder. - [Parameter(Mandatory)] - [string] $VaultPath, - - # Rebuilds the index from disk even if cached. - [Parameter()] - [switch] $Refresh - ) - - begin { - $stackPath = Get-PSCallStackPath - Write-Debug "[$stackPath] - Begin" - if ($null -eq $script:ContextFileIndexCache) { - $script:ContextFileIndexCache = @{} - } - } - - process { - if (-not $Refresh -and $script:ContextFileIndexCache.ContainsKey($Vault)) { - return $script:ContextFileIndexCache[$Vault] - } - - $index = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) - $files = Get-ChildItem -Path $VaultPath -Filter *.json -File -ErrorAction SilentlyContinue - - foreach ($file in $files) { - try { - $contextInfo = Get-ContextInfoFromFile -Path $file.FullName -Vault $Vault -ErrorAction Stop - $index[$contextInfo.ID] = $contextInfo.Path - } catch { - Write-Warning "[$stackPath] - Failed to index context file '$($file.FullName)': $($_.Exception.Message)" - } - } - - $script:ContextFileIndexCache[$Vault] = $index - return $index - } - - end { - Write-Debug "[$stackPath] - End" - } -} - -function Set-ContextFileIndexEntry { - <# - .SYNOPSIS - Adds or updates a context entry in the in-memory vault index. - - .DESCRIPTION - Stores or updates an ID-to-file-path mapping in the in-memory cache for a vault. - - .EXAMPLE - Set-ContextFileIndexEntry -Vault 'MyVault' -ID 'MyContext' -Path 'C:\vault\abc.json' - - Updates the in-memory index for the given vault. - #> - [Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseShouldProcessForStateChangingFunctions', '', - Justification = 'This private helper only mutates an in-memory cache.' - )] - [CmdletBinding()] - param( - # The vault name. - [Parameter(Mandatory)] - [string] $Vault, - - # The context ID. - [Parameter(Mandatory)] - [string] $ID, - - # The metadata file path. - [Parameter(Mandatory)] - [string] $Path - ) - - begin { - $stackPath = Get-PSCallStackPath - Write-Debug "[$stackPath] - Begin" - if ($null -eq $script:ContextFileIndexCache) { - $script:ContextFileIndexCache = @{} - } - } - - process { - if (-not $script:ContextFileIndexCache.ContainsKey($Vault)) { - $vaultIndex = [System.Collections.Generic.Dictionary[string, string]]::new( - [System.StringComparer]::OrdinalIgnoreCase - ) - $script:ContextFileIndexCache[$Vault] = $vaultIndex - } - - $script:ContextFileIndexCache[$Vault][$ID] = $Path - } - - end { - Write-Debug "[$stackPath] - End" - } -} - -function Remove-ContextFileIndexEntry { - <# - .SYNOPSIS - Removes a context entry from the in-memory vault index. - - .DESCRIPTION - Removes an ID mapping from the in-memory index for a vault. - - .EXAMPLE - Remove-ContextFileIndexEntry -Vault 'MyVault' -ID 'MyContext' - - Removes the mapping for the specified context ID. - #> - [Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseShouldProcessForStateChangingFunctions', '', - Justification = 'This private helper only mutates an in-memory cache.' - )] - [CmdletBinding()] - param( - # The vault name. - [Parameter(Mandatory)] - [string] $Vault, - - # The context ID. - [Parameter(Mandatory)] - [string] $ID - ) - - begin { - $stackPath = Get-PSCallStackPath - Write-Debug "[$stackPath] - Begin" - } - - process { - if ($null -eq $script:ContextFileIndexCache -or -not $script:ContextFileIndexCache.ContainsKey($Vault)) { - return - } - - $null = $script:ContextFileIndexCache[$Vault].Remove($ID) - } - - end { - Write-Debug "[$stackPath] - End" - } -} - -function Clear-ContextFileIndex { - <# - .SYNOPSIS - Clears in-memory vault index entries. - - .DESCRIPTION - Removes one or more vault entries from the in-memory context file index cache. - - .EXAMPLE - Clear-ContextFileIndex -Vault 'MyVault' - - Clears the in-memory index for 'MyVault'. - #> - [CmdletBinding()] - param( - # One or more vault names to clear from cache. - [Parameter(Mandatory)] - [string[]] $Vault - ) - - begin { - $stackPath = Get-PSCallStackPath - Write-Debug "[$stackPath] - Begin" - } - - process { - if ($null -eq $script:ContextFileIndexCache) { - return - } - - foreach ($vaultName in $Vault) { - $null = $script:ContextFileIndexCache.Remove($vaultName) - } - } - - end { - Write-Debug "[$stackPath] - End" - } -} diff --git a/src/functions/private/Get-ContextFileIndex.ps1 b/src/functions/private/Get-ContextFileIndex.ps1 new file mode 100644 index 0000000..fc2149e --- /dev/null +++ b/src/functions/private/Get-ContextFileIndex.ps1 @@ -0,0 +1,66 @@ +function Get-ContextFileIndex { + <# + .SYNOPSIS + Gets a cached context file index for a vault. + + .DESCRIPTION + Builds (or returns) an in-memory index of context IDs to metadata file paths for a vault. + This avoids repeated full vault scans for exact-ID lookups in hot paths. + + .EXAMPLE + Get-ContextFileIndex -Vault 'MyVault' -VaultPath 'C:\Users\Jane\.contextvaults\MyVault' + + Returns a dictionary where keys are context IDs and values are metadata file paths. + + .OUTPUTS + [System.Collections.Generic.Dictionary[string,string]] + #> + [OutputType([System.Collections.Generic.Dictionary[string, string]])] + [CmdletBinding()] + param( + # The vault name. + [Parameter(Mandatory)] + [string] $Vault, + + # The full path to the vault folder. + [Parameter(Mandatory)] + [string] $VaultPath, + + # Rebuilds the index from disk even if cached. + [Parameter()] + [switch] $Refresh + ) + + begin { + $stackPath = Get-PSCallStackPath + Write-Debug "[$stackPath] - Begin" + if ($null -eq $script:ContextFileIndexCache) { + $script:ContextFileIndexCache = @{} + } + } + + process { + if (-not $Refresh -and $script:ContextFileIndexCache.ContainsKey($Vault)) { + return $script:ContextFileIndexCache[$Vault] + } + + $index = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $files = Get-ChildItem -Path $VaultPath -Filter *.json -File -ErrorAction SilentlyContinue + + foreach ($file in $files) { + try { + $contextInfo = Get-ContextInfoFromFile -Path $file.FullName -Vault $Vault -ErrorAction Stop + $index[$contextInfo.ID] = $contextInfo.Path + } catch { + Write-Warning "[$stackPath] - Failed to index context file '$($file.FullName)': $($_.Exception.Message)" + } + } + + $script:ContextFileIndexCache[$Vault] = $index + return $index + } + + end { + Write-Debug "[$stackPath] - End" + } +} diff --git a/src/functions/private/Remove-ContextFileIndexEntry.ps1 b/src/functions/private/Remove-ContextFileIndexEntry.ps1 new file mode 100644 index 0000000..ac68f1d --- /dev/null +++ b/src/functions/private/Remove-ContextFileIndexEntry.ps1 @@ -0,0 +1,45 @@ +function Remove-ContextFileIndexEntry { + <# + .SYNOPSIS + Removes a context entry from the in-memory vault index. + + .DESCRIPTION + Removes an ID mapping from the in-memory index for a vault. + + .EXAMPLE + Remove-ContextFileIndexEntry -Vault 'MyVault' -ID 'MyContext' + + Removes the mapping for the specified context ID. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'This private helper only mutates an in-memory cache.' + )] + [CmdletBinding()] + param( + # The vault name. + [Parameter(Mandatory)] + [string] $Vault, + + # The context ID. + [Parameter(Mandatory)] + [string] $ID + ) + + begin { + $stackPath = Get-PSCallStackPath + Write-Debug "[$stackPath] - Begin" + } + + process { + if ($null -eq $script:ContextFileIndexCache -or -not $script:ContextFileIndexCache.ContainsKey($Vault)) { + return + } + + $null = $script:ContextFileIndexCache[$Vault].Remove($ID) + } + + end { + Write-Debug "[$stackPath] - End" + } +} diff --git a/src/functions/private/Set-ContextFileIndexEntry.ps1 b/src/functions/private/Set-ContextFileIndexEntry.ps1 new file mode 100644 index 0000000..b423252 --- /dev/null +++ b/src/functions/private/Set-ContextFileIndexEntry.ps1 @@ -0,0 +1,55 @@ +function Set-ContextFileIndexEntry { + <# + .SYNOPSIS + Adds or updates a context entry in the in-memory vault index. + + .DESCRIPTION + Stores or updates an ID-to-file-path mapping in the in-memory cache for a vault. + + .EXAMPLE + Set-ContextFileIndexEntry -Vault 'MyVault' -ID 'MyContext' -Path 'C:\vault\abc.json' + + Updates the in-memory index for the given vault. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'This private helper only mutates an in-memory cache.' + )] + [CmdletBinding()] + param( + # The vault name. + [Parameter(Mandatory)] + [string] $Vault, + + # The context ID. + [Parameter(Mandatory)] + [string] $ID, + + # The metadata file path. + [Parameter(Mandatory)] + [string] $Path + ) + + begin { + $stackPath = Get-PSCallStackPath + Write-Debug "[$stackPath] - Begin" + if ($null -eq $script:ContextFileIndexCache) { + $script:ContextFileIndexCache = @{} + } + } + + process { + if (-not $script:ContextFileIndexCache.ContainsKey($Vault)) { + $vaultIndex = [System.Collections.Generic.Dictionary[string, string]]::new( + [System.StringComparer]::OrdinalIgnoreCase + ) + $script:ContextFileIndexCache[$Vault] = $vaultIndex + } + + $script:ContextFileIndexCache[$Vault][$ID] = $Path + } + + end { + Write-Debug "[$stackPath] - End" + } +} diff --git a/src/functions/public/Get-ContextInfo.ps1 b/src/functions/public/Get-ContextInfo.ps1 index f1ff0cd..7312ac2 100644 --- a/src/functions/public/Get-ContextInfo.ps1 +++ b/src/functions/public/Get-ContextInfo.ps1 @@ -120,7 +120,10 @@ foreach ($exactId in $exactIds) { $contextPath = $null if (-not $contextIndex.TryGetValue($exactId, [ref]$contextPath)) { - continue + $contextIndex = Get-ContextFileIndex -Vault $vaultObject.Name -VaultPath $vaultObject.Path -Refresh + if (-not $contextIndex.TryGetValue($exactId, [ref]$contextPath)) { + continue + } } if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { diff --git a/src/functions/public/Set-Context.ps1 b/src/functions/public/Set-Context.ps1 index f8efdb7..ac6e9d6 100644 --- a/src/functions/public/Set-Context.ps1 +++ b/src/functions/public/Set-Context.ps1 @@ -105,6 +105,12 @@ function Set-Context { } else { Remove-ContextFileIndexEntry -Vault $vaultObject.Name -ID $ID } + } else { + $contextIndex = Get-ContextFileIndex -Vault $vaultObject.Name -VaultPath $vaultObject.Path -Refresh + if ($contextIndex.TryGetValue($ID, [ref]$existingContextPath) -and (Test-Path -LiteralPath $existingContextPath -PathType Leaf)) { + Write-Verbose "[$stackPath] - Context [$ID] found in [$Vault] after refreshing cache" + $contextPath = $existingContextPath + } } if ($null -eq $contextPath) { diff --git a/src/functions/public/Vault/Remove-ContextVault.ps1 b/src/functions/public/Vault/Remove-ContextVault.ps1 index d6a3381..cf7117c 100644 --- a/src/functions/public/Vault/Remove-ContextVault.ps1 +++ b/src/functions/public/Vault/Remove-ContextVault.ps1 @@ -40,7 +40,7 @@ foreach ($vault in ($vaults | Where-Object { $_.Name -like $vaultName })) { Write-Verbose "Removing ContextVault [$($vault.Name)] at path [$($vault.Path)]" if ($PSCmdlet.ShouldProcess("ContextVault: [$($vault.Name)]", 'Remove')) { - Remove-Item -Path $vault.Path -Recurse -Force + Remove-Item -LiteralPath $vault.Path -Recurse -Force Clear-ContextFileIndex -Vault $vault.Name if ($null -ne $script:ContextVaultKeyPairCache) { $null = $script:ContextVaultKeyPairCache.Remove($vault.Name) diff --git a/src/functions/public/Vault/Set-ContextVault.ps1 b/src/functions/public/Vault/Set-ContextVault.ps1 index 5dca90c..d128cb3 100644 --- a/src/functions/public/Vault/Set-ContextVault.ps1 +++ b/src/functions/public/Vault/Set-ContextVault.ps1 @@ -43,13 +43,14 @@ function Set-ContextVault { } if ( [System.IO.Path]::IsPathRooted($vaultName) -or + [System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters($vaultName) -or $vaultName.Contains([System.IO.Path]::DirectorySeparatorChar) -or $vaultName.Contains([System.IO.Path]::AltDirectorySeparatorChar) -or $vaultName -eq '.' -or $vaultName -eq '..' -or $vaultName.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) -ge 0 ) { - throw "Vault name '$vaultName' is invalid. Use a simple folder name without path separators." + throw "Vault name '$vaultName' is invalid. Use a simple folder name without path separators or wildcard characters." } Write-Verbose "Processing vault: $vaultName" diff --git a/tests/Context.Tests.ps1 b/tests/Context.Tests.ps1 index aae79b8..1a84aef 100644 --- a/tests/Context.Tests.ps1 +++ b/tests/Context.Tests.ps1 @@ -412,6 +412,27 @@ Describe 'Context' { $result | Should -Not -BeNullOrEmpty $result.Path | Should -Be $file.FullName } + + It 'Should refresh the exact-ID cache before treating a context as missing' { + $contextId = 'external-cache-test' + $vaultName = 'VaultA' + $modulePath = (Get-Module -Name Context | Select-Object -First 1).Path + + $null = Get-ContextInfo -ID $contextId -Vault $vaultName + + $externalWriteScript = @" +Import-Module -Name '$modulePath' -Force +Set-Context -ID '$contextId' -Context @{ Value = 'external' } -Vault '$vaultName' +"@ + pwsh -NoProfile -Command $externalWriteScript + + $refreshedResult = Get-ContextInfo -ID $contextId -Vault $vaultName + $refreshedResult | Should -HaveCount 1 + + { Set-Context -ID $contextId -Context @{ Value = 'updated' } -Vault $vaultName } | Should -Not -Throw + (Get-ContextInfo -ID $contextId -Vault $vaultName) | Should -HaveCount 1 + (Get-Context -ID $contextId -Vault $vaultName).Value | Should -Be 'updated' + } } Context 'Performance' { diff --git a/tests/ContextVaults.Tests.ps1 b/tests/ContextVaults.Tests.ps1 index bc6dc7e..173f0a7 100644 --- a/tests/ContextVaults.Tests.ps1 +++ b/tests/ContextVaults.Tests.ps1 @@ -68,6 +68,10 @@ Describe 'ContextVault' { It 'Should throw for vault names that include path separators' { { Set-ContextVault -Name '..\outside-root' } | Should -Throw } + + It 'Should throw for vault names that include wildcard characters' { + { Set-ContextVault -Name 'vault[1]' } | Should -Throw + } } Context 'Get-ContextVault' { From af45059c8e1928938d77fab92eef3a3f2b0ba50d Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 26 Jul 2026 01:18:29 +0200 Subject: [PATCH 16/21] Pin compatibility helpers to explicit module targets Require Sodium 2.2.5 consistently in the lazy-load guard, make the benchmark script run only against an explicitly imported Context module, and pin the cross-version compatibility script to Context 8.1.3 when validating historical vault data. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../private/Assert-ContextSodiumModule.ps1 | 6 +++--- tests/Context.Benchmark.ps1 | 16 +++++++++++++++- tests/Context.CrossVersionCompat.ps1 | 12 +++++++----- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/functions/private/Assert-ContextSodiumModule.ps1 b/src/functions/private/Assert-ContextSodiumModule.ps1 index 5e64304..5c4dc21 100644 --- a/src/functions/private/Assert-ContextSodiumModule.ps1 +++ b/src/functions/private/Assert-ContextSodiumModule.ps1 @@ -4,8 +4,8 @@ function Assert-ContextSodiumModule { Ensures the required Sodium module version is available for context cryptography. .DESCRIPTION - Validates that Sodium v2.2.4 or newer is loaded in the current session. - Imports Sodium v2.2.4 if needed, and verifies required commands exist. + Validates that Sodium v2.2.5 or newer is loaded in the current session. + Imports Sodium v2.2.5 if needed, and verifies required commands exist. .EXAMPLE Assert-ContextSodiumModule @@ -25,7 +25,7 @@ function Assert-ContextSodiumModule { return } - $minimumVersion = [version]'2.2.4' + $minimumVersion = [version]'2.2.5' $loadedSodium = Get-Module -Name Sodium | Sort-Object Version -Descending | Select-Object -First 1 if ($loadedSodium -and $loadedSodium.Version -lt $minimumVersion) { diff --git a/tests/Context.Benchmark.ps1 b/tests/Context.Benchmark.ps1 index 31096ee..30517ed 100644 --- a/tests/Context.Benchmark.ps1 +++ b/tests/Context.Benchmark.ps1 @@ -22,11 +22,24 @@ param( # Vault name used during the benchmark (cleaned up automatically). [Parameter()] - [string] $BenchmarkVault = 'Benchmark-Perf-Vault' + [string] $BenchmarkVault = 'Benchmark-Perf-Vault', + + # Optional module path to import before benchmarking. + [Parameter()] + [string] $ContextModulePath ) Set-StrictMode -Version Latest +if ($ContextModulePath) { + Import-Module -Name $ContextModulePath -Force -ErrorAction Stop +} + +$loadedContextModule = Get-Module -Name Context | Sort-Object Version -Descending | Select-Object -First 1 +if (-not $loadedContextModule) { + throw 'Import the Context module under test before running this benchmark, or pass -ContextModulePath.' +} + function Measure-BenchmarkMeasurement { param( [scriptblock] $ScriptBlock, @@ -62,6 +75,7 @@ Set-ContextVault -Name $BenchmarkVault | Out-Null $results = [System.Collections.Generic.List[pscustomobject]]::new() Write-Host "Running Context benchmark ($Iterations iterations each)..." -ForegroundColor Cyan +Write-Host "Benchmarking Context module: $($loadedContextModule.Path)" -ForegroundColor Cyan $seed = 'BenchmarkSeedValue' $kpStats = Measure-BenchmarkMeasurement -Iterations $Iterations -ScriptBlock { diff --git a/tests/Context.CrossVersionCompat.ps1 b/tests/Context.CrossVersionCompat.ps1 index 12df737..811e5bb 100644 --- a/tests/Context.CrossVersionCompat.ps1 +++ b/tests/Context.CrossVersionCompat.ps1 @@ -38,7 +38,9 @@ #> [CmdletBinding()] param( - [string] $VaultName = 'XVersionCompat-Test' + [string] $VaultName = 'XVersionCompat-Test', + + [version] $ContextVersion = '8.1.3' ) $ErrorActionPreference = 'Stop' @@ -155,12 +157,12 @@ Assert-Equal '2.2.2 sealed box decryptable by 2.2.5' $c22.Plaintext $c25.Decrypt Assert-Equal '2.2.5 roundtrip works' 'Hello from Sodium 2.2.5' $c25.Roundtrip25msg Write-Host '' -Write-Host '--- Part 2a: Write vault + contexts with Context 8.1.3 (Sodium 2.2.2) ---' -ForegroundColor Yellow +Write-Host "--- Part 2a: Write vault + contexts with Context $ContextVersion (Sodium 2.2.2) ---" -ForegroundColor Yellow $part2aScript = @" `$ErrorActionPreference = 'Stop' Import-Module Sodium -RequiredVersion 2.2.2 -Force -Import-Module Context -Force +Import-Module Context -RequiredVersion $ContextVersion -Force Get-ContextVault -Name '$VaultName' -ErrorAction SilentlyContinue | Remove-ContextVault -Confirm:`$false -ErrorAction SilentlyContinue Set-ContextVault -Name '$VaultName' | Out-Null Set-Context -ID 'compat-simple' -Context @{ Greeting = 'Hello'; Number = 42 } -Vault '$VaultName' @@ -177,7 +179,7 @@ Set-Context -ID 'compat-nulls' -Context @{ Present = 'yes'; Absent = `$null } -V ContextFiles = (Get-ChildItem `$vault.Path -Filter '*.json' | Select-Object -ExpandProperty FullName) } `$result | ConvertTo-Json -Depth 5 | Set-Content '$writeResultFile' -Write-Host "Vault and contexts written with Context 8.1.3 / Sodium 2.2.2" +Write-Host "Vault and contexts written with Context $ContextVersion / Sodium 2.2.2" "@ pwsh -NoProfile -Command $part2aScript @@ -225,7 +227,7 @@ Write-Host '' try { $cleanupCommand = @( "Import-Module Sodium -RequiredVersion 2.2.2 -Force; " - "Import-Module Context -Force; " + "Import-Module Context -RequiredVersion $ContextVersion -Force; " "Get-ContextVault -Name '$VaultName' -ErrorAction SilentlyContinue | " "Remove-ContextVault -Confirm:`$false -ErrorAction SilentlyContinue" ) -join '' From 2e9ca98d250700e3bd75aa6ee1efc56053dccf4a Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 26 Jul 2026 01:22:35 +0200 Subject: [PATCH 17/21] Escape child-process arguments in compatibility script Quote vault names and file paths safely before embedding them in the cross-version helper's child pwsh commands so the script still works when paths or vault names contain apostrophes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/Context.CrossVersionCompat.ps1 | 30 +++++++++++++++++----------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/tests/Context.CrossVersionCompat.ps1 b/tests/Context.CrossVersionCompat.ps1 index 811e5bb..506860b 100644 --- a/tests/Context.CrossVersionCompat.ps1 +++ b/tests/Context.CrossVersionCompat.ps1 @@ -51,6 +51,12 @@ $cryptoResultFile = Join-Path $tempDir 'crypto.json' $writeResultFile = Join-Path $tempDir 'write.json' $readResultFile = Join-Path $tempDir 'read.json' +$escapedVaultName = $VaultName.Replace("'", "''") +$escapedContextVersion = "$ContextVersion".Replace("'", "''") +$escapedCryptoResultFile = $cryptoResultFile.Replace("'", "''") +$escapedWriteResultFile = $writeResultFile.Replace("'", "''") +$escapedReadResultFile = $readResultFile.Replace("'", "''") + $pass = 0 $fail = 0 @@ -100,7 +106,7 @@ Import-Module Sodium -RequiredVersion 2.2.2 -Force SealedBox22 = `$box22 Plaintext = `$plaintext } -`$result | ConvertTo-Json | Set-Content '$cryptoResultFile' +`$result | ConvertTo-Json | Set-Content '$escapedCryptoResultFile' Write-Host "Keys derived and message sealed with Sodium 2.2.2" "@ @@ -162,13 +168,13 @@ Write-Host "--- Part 2a: Write vault + contexts with Context $ContextVersion (So $part2aScript = @" `$ErrorActionPreference = 'Stop' Import-Module Sodium -RequiredVersion 2.2.2 -Force -Import-Module Context -RequiredVersion $ContextVersion -Force -Get-ContextVault -Name '$VaultName' -ErrorAction SilentlyContinue | Remove-ContextVault -Confirm:`$false -ErrorAction SilentlyContinue -Set-ContextVault -Name '$VaultName' | Out-Null -Set-Context -ID 'compat-simple' -Context @{ Greeting = 'Hello'; Number = 42 } -Vault '$VaultName' -Set-Context -ID 'compat-secure' -Context @{ Token = ('secret123' | ConvertTo-SecureString -AsPlainText -Force) } -Vault '$VaultName' -Set-Context -ID 'compat-nulls' -Context @{ Present = 'yes'; Absent = `$null } -Vault '$VaultName' -`$vault = Get-ContextVault -Name '$VaultName' +Import-Module Context -RequiredVersion $escapedContextVersion -Force +Get-ContextVault -Name '$escapedVaultName' -ErrorAction SilentlyContinue | Remove-ContextVault -Confirm:`$false -ErrorAction SilentlyContinue +Set-ContextVault -Name '$escapedVaultName' | Out-Null +Set-Context -ID 'compat-simple' -Context @{ Greeting = 'Hello'; Number = 42 } -Vault '$escapedVaultName' +Set-Context -ID 'compat-secure' -Context @{ Token = ('secret123' | ConvertTo-SecureString -AsPlainText -Force) } -Vault '$escapedVaultName' +Set-Context -ID 'compat-nulls' -Context @{ Present = 'yes'; Absent = `$null } -Vault '$escapedVaultName' +`$vault = Get-ContextVault -Name '$escapedVaultName' `$shardPath = Join-Path `$vault.Path 'shard' `$result = @{ VaultPath = `$vault.Path @@ -178,7 +184,7 @@ Set-Context -ID 'compat-nulls' -Context @{ Present = 'yes'; Absent = `$null } -V UserName = [System.Environment]::UserName ContextFiles = (Get-ChildItem `$vault.Path -Filter '*.json' | Select-Object -ExpandProperty FullName) } -`$result | ConvertTo-Json -Depth 5 | Set-Content '$writeResultFile' +`$result | ConvertTo-Json -Depth 5 | Set-Content '$escapedWriteResultFile' Write-Host "Vault and contexts written with Context $ContextVersion / Sodium 2.2.2" "@ @@ -205,7 +211,7 @@ foreach (`$file in `$contextFiles) { `$plain = ConvertFrom-SodiumSealedBox -SealedBox `$json.Context -PublicKey `$kp.PublicKey -PrivateKey `$kp.PrivateKey `$results[`$json.ID] = `$plain | ConvertFrom-Json -AsHashtable } -`$results | ConvertTo-Json -Depth 10 | Set-Content '$readResultFile' +`$results | ConvertTo-Json -Depth 10 | Set-Content '$escapedReadResultFile' Write-Host "Raw vault files decrypted with Sodium 2.2.5" "@ @@ -227,8 +233,8 @@ Write-Host '' try { $cleanupCommand = @( "Import-Module Sodium -RequiredVersion 2.2.2 -Force; " - "Import-Module Context -RequiredVersion $ContextVersion -Force; " - "Get-ContextVault -Name '$VaultName' -ErrorAction SilentlyContinue | " + "Import-Module Context -RequiredVersion $escapedContextVersion -Force; " + "Get-ContextVault -Name '$escapedVaultName' -ErrorAction SilentlyContinue | " "Remove-ContextVault -Confirm:`$false -ErrorAction SilentlyContinue" ) -join '' pwsh -NoProfile -Command $cleanupCommand 2>&1 | Out-Null From 46b4835ff9cb7e3d1182ae0321d5cab5af6c69a6 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 26 Jul 2026 01:47:19 +0200 Subject: [PATCH 18/21] Bump tests to Pester 6.0.1 Update both Pester-backed test files to require the latest published Pester 6.0.1 release, and make vault-name validation reject both forward and backslashes on every platform so the Linux/macOS module- local jobs match Windows behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/functions/public/Vault/Set-ContextVault.ps1 | 4 ++-- tests/Context.Tests.ps1 | 2 +- tests/ContextVaults.Tests.ps1 | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/functions/public/Vault/Set-ContextVault.ps1 b/src/functions/public/Vault/Set-ContextVault.ps1 index d128cb3..6bea3b9 100644 --- a/src/functions/public/Vault/Set-ContextVault.ps1 +++ b/src/functions/public/Vault/Set-ContextVault.ps1 @@ -44,8 +44,8 @@ function Set-ContextVault { if ( [System.IO.Path]::IsPathRooted($vaultName) -or [System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters($vaultName) -or - $vaultName.Contains([System.IO.Path]::DirectorySeparatorChar) -or - $vaultName.Contains([System.IO.Path]::AltDirectorySeparatorChar) -or + $vaultName.Contains('/') -or + $vaultName.Contains('\') -or $vaultName -eq '.' -or $vaultName -eq '..' -or $vaultName.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) -ge 0 diff --git a/tests/Context.Tests.ps1 b/tests/Context.Tests.ps1 index 1a84aef..2e96643 100644 --- a/tests/Context.Tests.ps1 +++ b/tests/Context.Tests.ps1 @@ -1,4 +1,4 @@ -#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.1'; MaximumVersion = '6.*' } [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseDeclaredVarsMoreThanAssignments', '', diff --git a/tests/ContextVaults.Tests.ps1 b/tests/ContextVaults.Tests.ps1 index 173f0a7..cf646a8 100644 --- a/tests/ContextVaults.Tests.ps1 +++ b/tests/ContextVaults.Tests.ps1 @@ -1,4 +1,4 @@ -#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.1'; MaximumVersion = '6.*' } [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseDeclaredVarsMoreThanAssignments', '', From d0f8b9a0f492378c57433377ee2548777a892c8a Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 26 Jul 2026 01:54:19 +0200 Subject: [PATCH 19/21] Use named assertions in compat script Update the compatibility helper to use named parameters for its local assertion helpers so repository-wide PowerShell lint treats the script as clean. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/Context.CrossVersionCompat.ps1 | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/Context.CrossVersionCompat.ps1 b/tests/Context.CrossVersionCompat.ps1 index 506860b..0662e7c 100644 --- a/tests/Context.CrossVersionCompat.ps1 +++ b/tests/Context.CrossVersionCompat.ps1 @@ -157,10 +157,10 @@ Write-Host " 2.2.5 roundtrip msg: $($c25.Roundtrip25msg)" Write-Host '' Write-Host '--- Part 1 Assertions ---' -ForegroundColor Yellow -Assert-Equal 'Key derivation: PublicKey identical' $c22.PublicKey $c25.PublicKey25 -Assert-Equal 'Key derivation: PrivateKey identical' $c22.PrivateKey $c25.PrivateKey25 -Assert-Equal '2.2.2 sealed box decryptable by 2.2.5' $c22.Plaintext $c25.Decrypted22msg -Assert-Equal '2.2.5 roundtrip works' 'Hello from Sodium 2.2.5' $c25.Roundtrip25msg +Assert-Equal -Name 'Key derivation: PublicKey identical' -Expected $c22.PublicKey -Actual $c25.PublicKey25 +Assert-Equal -Name 'Key derivation: PrivateKey identical' -Expected $c22.PrivateKey -Actual $c25.PrivateKey25 +Assert-Equal -Name '2.2.2 sealed box decryptable by 2.2.5' -Expected $c22.Plaintext -Actual $c25.Decrypted22msg +Assert-Equal -Name '2.2.5 roundtrip works' -Expected 'Hello from Sodium 2.2.5' -Actual $c25.Roundtrip25msg Write-Host '' Write-Host "--- Part 2a: Write vault + contexts with Context $ContextVersion (Sodium 2.2.2) ---" -ForegroundColor Yellow @@ -223,11 +223,11 @@ if (-not (Test-Path $readResultFile)) { $rd = Get-Content $readResultFile -Raw | ConvertFrom-Json Write-Host '--- Part 2 Assertions ---' -ForegroundColor Yellow -Assert-Equal 'compat-simple: Greeting' 'Hello' $rd.'compat-simple'.Greeting -Assert-Equal 'compat-simple: Number' '42' $rd.'compat-simple'.Number -Assert-Equal 'compat-secure: Token (SecureString prefix)' '[SECURESTRING]secret123' $rd.'compat-secure'.Token -Assert-Equal 'compat-nulls: Present' 'yes' $rd.'compat-nulls'.Present -Assert-Null 'compat-nulls: Absent' $rd.'compat-nulls'.Absent +Assert-Equal -Name 'compat-simple: Greeting' -Expected 'Hello' -Actual $rd.'compat-simple'.Greeting +Assert-Equal -Name 'compat-simple: Number' -Expected '42' -Actual $rd.'compat-simple'.Number +Assert-Equal -Name 'compat-secure: Token (SecureString prefix)' -Expected '[SECURESTRING]secret123' -Actual $rd.'compat-secure'.Token +Assert-Equal -Name 'compat-nulls: Present' -Expected 'yes' -Actual $rd.'compat-nulls'.Present +Assert-Null -Name 'compat-nulls: Absent' -Actual $rd.'compat-nulls'.Absent Write-Host '' try { From 62d80157218be75e32b903faf4416eae19555e00 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 26 Jul 2026 01:56:36 +0200 Subject: [PATCH 20/21] Cover cache and vault regressions Add regression tests for stale exact-ID cache entries, on-disk metadata ID drift, decrypt failures, and stricter vault-name validation so the new cache and path-hardening logic stays above the repository coverage threshold. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/Context.Tests.ps1 | 49 +++++++++++++++++++++++++++++++++++ tests/ContextVaults.Tests.ps1 | 10 +++++++ 2 files changed, 59 insertions(+) diff --git a/tests/Context.Tests.ps1 b/tests/Context.Tests.ps1 index 2e96643..01718cc 100644 --- a/tests/Context.Tests.ps1 +++ b/tests/Context.Tests.ps1 @@ -255,6 +255,22 @@ Describe 'Context' { $results = Get-Context -ID $null -Vault 'VaultA' $results | Should -BeNullOrEmpty } + + It 'Get-Context - Should warn and skip invalid ciphertext' { + $contextId = 'decrypt-error' + Set-Context -ID $contextId -Context @{ Value = 'original' } -Vault 'VaultA' | Out-Null + $contextInfo = Get-ContextInfo -ID $contextId -Vault 'VaultA' + $contextMetadata = Get-Content -LiteralPath $contextInfo.Path -Raw | ConvertFrom-Json + $contextMetadata.Context = 'invalid-sealed-box' + $contextMetadata | ConvertTo-Json -Depth 5 -Compress | Set-Content -LiteralPath $contextInfo.Path + + $warnings = @() + $result = Get-Context -ID $contextId -Vault 'VaultA' -WarningVariable warnings + + $result | Should -BeNullOrEmpty + $warnings | Should -Not -BeNullOrEmpty + $warnings[0] | Should -Match 'Failed to read or decrypt context file' + } } Context 'Remove-Context' { @@ -433,6 +449,39 @@ Set-Context -ID '$contextId' -Context @{ Value = 'external' } -Vault '$vaultName (Get-ContextInfo -ID $contextId -Vault $vaultName) | Should -HaveCount 1 (Get-Context -ID $contextId -Vault $vaultName).Value | Should -Be 'updated' } + + It 'Should recreate a context when the cached file path was deleted' { + $contextId = 'deleted-cache-test' + $vaultName = 'VaultA' + + Set-Context -ID $contextId -Context @{ Value = 'before-delete' } -Vault $vaultName | Out-Null + $originalContextInfo = Get-ContextInfo -ID $contextId -Vault $vaultName + Remove-Item -LiteralPath $originalContextInfo.Path -Force + + { Set-Context -ID $contextId -Context @{ Value = 'after-delete' } -Vault $vaultName } | Should -Not -Throw + + $recreatedContextInfo = Get-ContextInfo -ID $contextId -Vault $vaultName + $recreatedContextInfo | Should -HaveCount 1 + $recreatedContextInfo.Path | Should -Not -Be $originalContextInfo.Path + (Get-Context -ID $contextId -Vault $vaultName).Value | Should -Be 'after-delete' + } + + It 'Should discard cached exact-ID entries when the on-disk metadata ID changes' { + $originalId = 'cached-id-mismatch' + $updatedId = 'cached-id-renamed' + + Set-Context -ID $originalId -Context @{ Value = 'rename-test' } -Vault 'VaultA' | Out-Null + $contextInfo = Get-ContextInfo -ID $originalId -Vault 'VaultA' + $contextMetadata = Get-Content -LiteralPath $contextInfo.Path -Raw | ConvertFrom-Json + $contextMetadata.ID = $updatedId + $contextMetadata | ConvertTo-Json -Depth 5 -Compress | Set-Content -LiteralPath $contextInfo.Path + + (Get-ContextInfo -ID $originalId -Vault 'VaultA') | Should -BeNullOrEmpty + + $renamedContextInfo = Get-ContextInfo -ID $updatedId -Vault 'VaultA' + $renamedContextInfo | Should -HaveCount 1 + $renamedContextInfo.ID | Should -Be $updatedId + } } Context 'Performance' { diff --git a/tests/ContextVaults.Tests.ps1 b/tests/ContextVaults.Tests.ps1 index cf646a8..85ad850 100644 --- a/tests/ContextVaults.Tests.ps1 +++ b/tests/ContextVaults.Tests.ps1 @@ -69,6 +69,16 @@ Describe 'ContextVault' { { Set-ContextVault -Name '..\outside-root' } | Should -Throw } + It 'Should throw for vault names that are dot path segments' { + { Set-ContextVault -Name '.' } | Should -Throw + { Set-ContextVault -Name '..' } | Should -Throw + } + + It 'Should throw for vault names that are absolute paths' { + $absoluteVaultPath = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath 'context-absolute-path' + { Set-ContextVault -Name $absoluteVaultPath } | Should -Throw + } + It 'Should throw for vault names that include wildcard characters' { { Set-ContextVault -Name 'vault[1]' } | Should -Throw } From 9928c1fd37e3141375e68467040b404b478f1f72 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 26 Jul 2026 09:05:38 +0200 Subject: [PATCH 21/21] Report uncovered coverage paths Add a post-workflow coverage reporting job that downloads the generated code-coverage artifacts, summarizes missed command groups in the run logs and step summary, and uploads a CodeCoverage-MissedPaths artifact for follow-up analysis. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../scripts/Write-CodeCoverageGapReport.ps1 | 363 ++++++++++++++++++ .github/workflows/Process-PSModule.yml | 33 ++ 2 files changed, 396 insertions(+) create mode 100644 .github/scripts/Write-CodeCoverageGapReport.ps1 diff --git a/.github/scripts/Write-CodeCoverageGapReport.ps1 b/.github/scripts/Write-CodeCoverageGapReport.ps1 new file mode 100644 index 0000000..97c6633 --- /dev/null +++ b/.github/scripts/Write-CodeCoverageGapReport.ps1 @@ -0,0 +1,363 @@ +<# + .SYNOPSIS + Creates a coverage-gap report from Process-PSModule code coverage artifacts. + + .DESCRIPTION + Reads all *-CodeCoverage-Report.json files under a root folder, summarizes the + current coverage status, groups missed commands into high-level categories, and + writes both markdown and JSON outputs for workflow logs, step summaries, and + uploaded artifacts. +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidLongLines', '', + Justification = 'Report recommendation strings are clearer when kept intact.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSProvideCommentHelp', '', + Scope = 'Function', + Target = 'Get-MissedCommandCategory', + Justification = 'Private helper in a workflow reporting script.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSProvideCommentHelp', '', + Scope = 'Function', + Target = 'Get-RecommendedScenario', + Justification = 'Private helper in a workflow reporting script.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSProvideCommentHelp', '', + Scope = 'Function', + Target = 'ConvertTo-MarkdownTable', + Justification = 'Private helper in a workflow reporting script.' +)] +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $CoverageRoot, + + [Parameter(Mandatory)] + [string] $ReportOutputPath, + + [Parameter()] + [string] $RunId +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-MissedCommandCategory { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [pscustomobject] $Command + ) + + $commandText = [string]$Command.Command + + if ( + $commandText -match 'Write-Debug' -or + $commandText -match 'Import-PowerShellDataFile' -or + $commandText -match '\$script:PSModuleInfo' -or + $commandText -match '\[classes\]' -or + $commandText -match 'return \$this\.Name' -or + $commandText -match '\$this\.(ID|Path|Vault|Context|Name)\s*=' + ) { + return 'Module bootstrap and type initialization' + } + + if ( + $commandText -match 'Get-Module -Name Sodium' -or + $commandText -match 'Import-Module -Name Sodium' -or + $commandText -match 'Get-Command -Name \$commandName' -or + $commandText -match 'Loaded Sodium version' -or + $commandText -match 'Required Sodium command' + ) { + return 'Dependency loading and validation edge cases' + } + + if ( + $commandText -match 'Get-ContentNonLocking' -or + $commandText -match 'IO error reading file' -or + $commandText -match 'Fallback also failed' -or + $commandText -match 'Error reading file' + ) { + return 'File I/O recovery and error handling' + } + + if ( + $commandText -match 'ContextFileIndexCache' -or + $commandText -match '\.Remove\(' -or + $commandText -match 'if \(\$null -eq \$script:ContextFileIndexCache' + ) { + return 'Cache initialization and eviction edge cases' + } + + if ( + $commandText -match 'CompletionResult' -or + $commandText -match 'wordToComplete' -or + $commandText -match 'fakeBoundParameter' -or + $commandText -match 'Get-ContextInfo -Vault \$vault' -or + $commandText -match 'Get-ContextVault -ErrorAction SilentlyContinue' + ) { + return 'Shell completion and discovery helpers' + } + + if ( + $commandText -match 'Write-Verbose' -or + $commandText -match 'Format-List' -or + $commandText -match 'Out-String -Stream' + ) { + return 'Verbose and diagnostic-only output' + } + + if ( + $commandText -match 'ConvertTo-SecureString -String' -or + $commandText -match 'Convert-ContextHashtableToObjectRecursive' -or + $commandText -match 'Failed to convert hashtable to object' -or + $commandText -match 'Failed to convert JSON to object' -or + $commandText -match 'Failed to convert context object to hashtable' -or + $commandText -match 'Failed to convert object to JSON' + ) { + return 'Serialization and conversion edge cases' + } + + if ( + $commandText -match 'An ID is required' -or + $commandText -match 'Get-Context -ID \$ID -Vault \$Vault' -or + $commandText -match 'Context file does not exist' -or + $commandText -match 'Write-Warning' -or + $commandText -match 'continue$' -or + $commandText -match 'throw ' + ) { + return 'Validation, warnings, and pass-thru user flows' + } + + return 'Residual potential user-facing gap' +} + +function Get-RecommendedScenario { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Category + ) + + switch ($Category) { + 'Module bootstrap and type initialization' { + 'Verify whether module import/type-construction paths need explicit coverage or should be excluded as non-user bootstrap code.' + } + 'Dependency loading and validation edge cases' { + 'Add isolated tests for no-Sodium, older-Sodium, and missing-command sessions if those environment guards are intended to stay under coverage.' + } + 'File I/O recovery and error handling' { + 'Add fault-injection tests for shard/context read failures to cover the non-locking fallback and terminal throw branches.' + } + 'Cache initialization and eviction edge cases' { + 'Add direct or indirect tests that hit empty-cache clear/remove paths and cache invalidation without prior warmup.' + } + default { + 'Add a user-flow test that reaches this missed command through a public cmdlet rather than a private helper.' + } + } +} + +function ConvertTo-MarkdownTable { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]] $Rows, + + [Parameter(Mandatory)] + [string[]] $Columns + ) + + if (-not $Rows -or $Rows.Count -eq 0) { + return @('| Status | Details |', '| --- | --- |', '| info | No rows |') + } + + $header = '| ' + ($Columns -join ' | ') + ' |' + $separator = '| ' + (($Columns | ForEach-Object { '---' }) -join ' | ') + ' |' + $lines = foreach ($row in $Rows) { + $values = foreach ($column in $Columns) { + $value = $row.$column + if ($null -eq $value) { + '' + } else { + ([string]$value).Replace("`r", ' ').Replace("`n", '
') + } + } + '| ' + ($values -join ' | ') + ' |' + } + + return @($header, $separator) + $lines +} + +$reportRoot = Resolve-Path -Path $CoverageRoot -ErrorAction SilentlyContinue +$reportDirectory = New-Item -Path $ReportOutputPath -ItemType Directory -Force +$markdownPath = Join-Path -Path $reportDirectory.FullName -ChildPath 'CodeCoverage-MissedPaths.md' +$jsonPath = Join-Path -Path $reportDirectory.FullName -ChildPath 'CodeCoverage-MissedPaths.json' + +$reportFiles = @() +if ($reportRoot) { + $reportFiles = Get-ChildItem -Path $reportRoot.Path -Recurse -Filter '*-CodeCoverage-Report.json' | Sort-Object FullName +} + +$report = [ordered]@{ + RunId = $RunId + GeneratedAtUtc = [DateTime]::UtcNow.ToString('o') + CoverageArtifacts = @() + MissedCommandGroups = @() + Recommendation = '' +} + +if ($reportFiles.Count -eq 0) { + $report.Recommendation = ( + 'No code coverage artifacts were found. ' + + 'Investigate the coverage upload path before changing the threshold.' + ) + $runLabel = 'Run: unknown' + if ($RunId) { + $runLabel = "Run: $RunId" + } + $markdown = @( + '# Code coverage missed-path report', + '', + $runLabel, + '', + 'No `*-CodeCoverage-Report.json` artifacts were found.', + '', + '**Recommendation:** Investigate artifact publication before adjusting coverage requirements.' + ) + + $report | ConvertTo-Json -Depth 10 | Set-Content -Path $jsonPath -Encoding utf8NoBOM + $markdown | Set-Content -Path $markdownPath -Encoding utf8NoBOM + $markdown | ForEach-Object { Write-Output $_ } + if ($env:GITHUB_STEP_SUMMARY) { + $markdown | Add-Content -Path $env:GITHUB_STEP_SUMMARY -Encoding utf8NoBOM + } + return +} + +$coverageRows = [System.Collections.Generic.List[object]]::new() +$uniqueMissedCommands = @{} + +foreach ($reportFile in $reportFiles) { + $coverage = Get-Content -Path $reportFile.FullName -Raw | ConvertFrom-Json + $artifactName = $reportFile.Directory.Name + $missedCommands = @($coverage.CommandsMissed) + + $artifactRecord = [pscustomobject]@{ + Artifact = $artifactName + CoveragePercent = [math]::Round([double]$coverage.CoveragePercent, 2) + CoveragePercentTarget = [double]$coverage.CoveragePercentTarget + CommandsAnalyzed = [int]$coverage.CommandsAnalyzedCount + CommandsExecuted = [int]$coverage.CommandsExecutedCount + CommandsMissed = [int]$coverage.CommandsMissedCount + FilesAnalyzed = [int]$coverage.FilesAnalyzedCount + } + $report.CoverageArtifacts += $artifactRecord + $coverageRows.Add([pscustomobject]@{ + Artifact = $artifactRecord.Artifact + Coverage = '{0}%' -f $artifactRecord.CoveragePercent + Target = '{0}%' -f $artifactRecord.CoveragePercentTarget + Missed = $artifactRecord.CommandsMissed + Files = $artifactRecord.FilesAnalyzed + }) + + foreach ($missedCommand in $missedCommands) { + $key = '{0}|{1}|{2}' -f $artifactName, $missedCommand.Line, $missedCommand.Command + if (-not $uniqueMissedCommands.ContainsKey($key)) { + $category = Get-MissedCommandCategory -Command $missedCommand + $uniqueMissedCommands[$key] = [pscustomobject]@{ + Artifact = $artifactName + Line = [int]$missedCommand.Line + Command = [string]$missedCommand.Command + Category = $category + } + } + } +} + +$groupedMisses = $uniqueMissedCommands.Values | Group-Object Category | Sort-Object Name +$groupRows = [System.Collections.Generic.List[object]]::new() +$candidateScenarioRows = [System.Collections.Generic.List[object]]::new() + +foreach ($group in $groupedMisses) { + $sampleCommands = $group.Group | + Sort-Object Artifact, Line | + Select-Object -First 5 | + ForEach-Object { '{0}:{1} {2}' -f $_.Artifact, $_.Line, $_.Command } + + $reportGroup = [pscustomobject]@{ + Category = $group.Name + Count = $group.Count + SampleCommands = $sampleCommands + Recommendation = Get-RecommendedScenario -Category $group.Name + } + $report.MissedCommandGroups += $reportGroup + + $groupRows.Add([pscustomobject]@{ + Category = $reportGroup.Category + Count = $reportGroup.Count + Examples = ($sampleCommands -join '
') + }) + + $candidateScenarioRows.Add([pscustomobject]@{ + Category = $reportGroup.Category + NextScenario = $reportGroup.Recommendation + }) +} + +$userFlowGroup = $report.MissedCommandGroups | Where-Object { + $_.Category -in @( + 'Validation, warnings, and pass-thru user flows', + 'Residual potential user-facing gap' + ) +} +if ($null -ne $userFlowGroup -and @($userFlowGroup).Count -gt 0) { + $report.Recommendation = 'Keep the 85% threshold and add tests for the remaining potential user-facing gaps before considering any reduction.' +} else { + $report.Recommendation = ( + 'The remaining misses are concentrated in bootstrap, dependency-guard, cache-edge, ' + + 'and fault-injection paths. If those are not considered user flows, consider lowering ' + + 'the threshold or excluding bootstrap/debug-only code from coverage.' + ) +} + +$markdown = [System.Collections.Generic.List[string]]::new() +$null = $markdown.Add('# Code coverage missed-path report') +$null = $markdown.Add('') +if ($RunId) { + $null = $markdown.Add("Run: $RunId") + $null = $markdown.Add('') +} +$null = $markdown.Add('## Coverage artifacts') +$tableLines = ConvertTo-MarkdownTable -Rows $coverageRows.ToArray() -Columns @('Artifact', 'Coverage', 'Target', 'Missed', 'Files') +foreach ($line in $tableLines) { + $null = $markdown.Add([string]$line) +} +$null = $markdown.Add('') +$null = $markdown.Add('## Missed command groups') +$tableLines = ConvertTo-MarkdownTable -Rows $groupRows.ToArray() -Columns @('Category', 'Count', 'Examples') +foreach ($line in $tableLines) { + $null = $markdown.Add([string]$line) +} +$null = $markdown.Add('') +$null = $markdown.Add('## Candidate next scenarios') +$tableLines = ConvertTo-MarkdownTable -Rows $candidateScenarioRows.ToArray() -Columns @('Category', 'NextScenario') +foreach ($line in $tableLines) { + $null = $markdown.Add([string]$line) +} +$null = $markdown.Add('') +$null = $markdown.Add('## Recommendation') +$null = $markdown.Add($report.Recommendation) + +$report | ConvertTo-Json -Depth 10 | Set-Content -Path $jsonPath -Encoding utf8NoBOM +$markdown | Set-Content -Path $markdownPath -Encoding utf8NoBOM + +Write-Output '=== Code coverage missed-path report ===' +$markdown | ForEach-Object { Write-Output $_ } + +if ($env:GITHUB_STEP_SUMMARY) { + $markdown | Add-Content -Path $env:GITHUB_STEP_SUMMARY -Encoding utf8NoBOM +} diff --git a/.github/workflows/Process-PSModule.yml b/.github/workflows/Process-PSModule.yml index 3130683..10e3608 100644 --- a/.github/workflows/Process-PSModule.yml +++ b/.github/workflows/Process-PSModule.yml @@ -30,3 +30,36 @@ jobs: uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@fb1bdb8fefd243292f779d2a856a38db6fe6daf4 # v6.1.13 secrets: APIKey: ${{ secrets.APIKey }} + + Report-CodeCoveragePaths: + if: ${{ always() }} + needs: Process-PSModule + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download code coverage artifacts + uses: actions/download-artifact@v4 + with: + pattern: '*CodeCoverage' + path: coverage-artifacts + if-no-files-found: warn + + - name: Build code coverage missed-path report + shell: pwsh + run: > + ./.github/scripts/Write-CodeCoverageGapReport.ps1 + -CoverageRoot 'coverage-artifacts' + -ReportOutputPath 'coverage-report' + -RunId '${{ github.run_id }}' + + - name: Upload code coverage missed-path report + uses: actions/upload-artifact@v4 + with: + name: CodeCoverage-MissedPaths + path: coverage-report + if-no-files-found: error