diff --git a/.github/workflows/Process-PSModule.yml b/.github/workflows/Process-PSModule.yml index 9462763..defacaf 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@205d193f34cbbaf9992955c21d842bcf98a1859f # v5.4.6 - secrets: inherit + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@c89cae01301e45cdcdf60e37fa3b093b051f5106 # coverage missed-path reporting + secrets: + APIKey: ${{ secrets.APIKey }} 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"] diff --git a/src/functions/private/Assert-ContextSodiumModule.ps1 b/src/functions/private/Assert-ContextSodiumModule.ps1 new file mode 100644 index 0000000..5c4dc21 --- /dev/null +++ b/src/functions/private/Assert-ContextSodiumModule.ps1 @@ -0,0 +1,59 @@ +function Assert-ContextSodiumModule { + <# + .SYNOPSIS + Ensures the required Sodium module version is available for context cryptography. + + .DESCRIPTION + 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 + + 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.5' + $loadedSodium = Get-Module -Name Sodium | Sort-Object Version -Descending | Select-Object -First 1 + + if ($loadedSodium -and $loadedSodium.Version -lt $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) { + 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/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/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/Get-ContextInfoFromFile.ps1 b/src/functions/private/Get-ContextInfoFromFile.ps1 new file mode 100644 index 0000000..a1c401b --- /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/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..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. @@ -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 $_ 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-Context.ps1 b/src/functions/public/Get-Context.ps1 index 3d044f0..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.2' } - +#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..7312ac2 100644 --- a/src/functions/public/Get-ContextInfo.ps1 +++ b/src/functions/public/Get-ContextInfo.ps1 @@ -79,32 +79,99 @@ } 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)) { + $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)) { + 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 bc3d7d6..ac6e9d6 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.2' } - +#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,27 @@ 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) { - Write-Verbose "[$stackPath] - Creating context [$ID] in [$Vault]" - $guid = [Guid]::NewGuid().Guid - $contextPath = Join-Path -Path $vaultObject.Path -ChildPath "$guid.json" + $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 + } } else { - Write-Verbose "[$stackPath] - Context [$ID] found in [$Vault]" - $contextPath = $contextInfo.Path + $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) { + Write-Verbose "[$stackPath] - Creating context [$ID] in [$Vault]" + $contextPath = [System.IO.Path]::Combine($vaultObject.Path, "$([Guid]::NewGuid().Guid).json") } Write-Verbose "[$stackPath] - Context path: [$contextPath]" @@ -113,13 +126,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..cf7117c 100644 --- a/src/functions/public/Vault/Remove-ContextVault.ps1 +++ b/src/functions/public/Vault/Remove-ContextVault.ps1 @@ -40,7 +40,11 @@ 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) + } 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..6bea3b9 100644 --- a/src/functions/public/Vault/Set-ContextVault.ps1 +++ b/src/functions/public/Vault/Set-ContextVault.ps1 @@ -38,20 +38,35 @@ 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 + [System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters($vaultName) -or + $vaultName.Contains('/') -or + $vaultName.Contains('\') -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 or wildcard characters." + } + 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)) } } diff --git a/tests/Context.Benchmark.ps1 b/tests/Context.Benchmark.ps1 new file mode 100644 index 0000000..30517ed --- /dev/null +++ b/tests/Context.Benchmark.ps1 @@ -0,0 +1,124 @@ +#Requires -Modules @{ ModuleName = 'Sodium'; RequiredVersion = '2.2.5' } + +[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. + [Parameter()] + [int] $Iterations = 1000, + + # Vault name used during the benchmark (cleaned up automatically). + [Parameter()] + [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, + [int] $Iterations + ) + + $samples = [System.Collections.Generic.List[double]]::new($Iterations) + for ($i = 0; $i -lt $Iterations; $i++) { + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + & $ScriptBlock | Out-Null + $stopwatch.Stop() + $samples.Add($stopwatch.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) + } +} + +try { + Remove-ContextVault -Name $BenchmarkVault -Confirm:$false -ErrorAction SilentlyContinue +} 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 +Write-Host "Benchmarking Context module: $($loadedContextModule.Path)" -ForegroundColor Cyan + +$seed = 'BenchmarkSeedValue' +$kpStats = Measure-BenchmarkMeasurement -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 +}) + +$sealStats = Measure-BenchmarkMeasurement -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 +}) + +Set-Context -ID 'bench-ctx' -Context @{ Value = 'benchmark' } -Vault $BenchmarkVault +$openStats = Measure-BenchmarkMeasurement -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 +}) + +try { + Remove-ContextVault -Name $BenchmarkVault -Confirm:$false -ErrorAction SilentlyContinue +} catch { + Write-Verbose "Benchmark cleanup skipped: $($_.Exception.Message)" +} + +Write-Host '' +Write-Host '=== Context Benchmark Results ===' -ForegroundColor Green +$results | Format-Table -AutoSize +$results diff --git a/tests/Context.CrossVersionCompat.ps1 b/tests/Context.CrossVersionCompat.ps1 new file mode 100644 index 0000000..0662e7c --- /dev/null +++ b/tests/Context.CrossVersionCompat.ps1 @@ -0,0 +1,254 @@ +[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 + 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. + + 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. + + Each phase runs in a child pwsh process to ensure clean module state. +#> +[CmdletBinding()] +param( + [string] $VaultName = 'XVersionCompat-Test', + + [version] $ContextVersion = '8.1.3' +) + +$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' + +$escapedVaultName = $VaultName.Replace("'", "''") +$escapedContextVersion = "$ContextVersion".Replace("'", "''") +$escapedCryptoResultFile = $cryptoResultFile.Replace("'", "''") +$escapedWriteResultFile = $writeResultFile.Replace("'", "''") +$escapedReadResultFile = $readResultFile.Replace("'", "''") + +$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 '' + +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 '$escapedCryptoResultFile' +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)' +`$decrypted = ConvertFrom-SodiumSealedBox -SealedBox `$box22 -PublicKey `$pubKey -PrivateKey `$privKey +`$box25 = ConvertTo-SodiumSealedBox -Message 'Hello from Sodium 2.2.5' -PublicKey `$kp25.PublicKey +`$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 -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 + +$part2aScript = @" +`$ErrorActionPreference = 'Stop' +Import-Module Sodium -RequiredVersion 2.2.2 -Force +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 + 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 '$escapedWriteResultFile' +Write-Host "Vault and contexts written with Context $ContextVersion / 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 + +$part2bScript = @" +`$ErrorActionPreference = 'Stop' +Import-Module Sodium -RequiredVersion 2.2.5 -Force +`$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 '$escapedReadResultFile' +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 -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 { + $cleanupCommand = @( + "Import-Module Sodium -RequiredVersion 2.2.2 -Force; " + "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 +} 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 +} else { + Write-Host "=== RESULT: $fail/$total ASSERTIONS FAILED - BREAKING CHANGE DETECTED ===" -ForegroundColor Red +} diff --git a/tests/Context.Tests.ps1 b/tests/Context.Tests.ps1 index 36e4c23..01718cc 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.1'; MaximumVersion = '6.*' } [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseDeclaredVarsMoreThanAssignments', '', @@ -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' { @@ -370,6 +386,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 +415,157 @@ 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 + } + + 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' + } + + 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' { + 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 1a12299..85ad850 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.1'; MaximumVersion = '6.*' } [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseDeclaredVarsMoreThanAssignments', '', @@ -64,6 +64,24 @@ 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 + } + + 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 + } } Context 'Get-ContextVault' {