diff --git a/.github/workflows/Process-PSModule.yml b/.github/workflows/Process-PSModule.yml index 9462763..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@205d193f34cbbaf9992955c21d842bcf98a1859f # v5.4.6 - secrets: inherit + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@fb1bdb8fefd243292f779d2a856a38db6fe6daf4 # v6.1.13 + 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..5e64304 --- /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.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) { + $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/ContextFileIndexCache.ps1 b/src/functions/private/ContextFileIndexCache.ps1 new file mode 100644 index 0000000..3eaa748 --- /dev/null +++ b/src/functions/private/ContextFileIndexCache.ps1 @@ -0,0 +1,208 @@ +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-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/public/Get-Context.ps1 b/src/functions/public/Get-Context.ps1 index 3d044f0..84bb598 100644 --- a/src/functions/public/Get-Context.ps1 +++ b/src/functions/public/Get-Context.ps1 @@ -1,5 +1,3 @@ -#Requires -Modules @{ ModuleName = 'Sodium'; RequiredVersion = '2.2.2' } - function Get-Context { <# .SYNOPSIS @@ -107,6 +105,8 @@ function Get-Context { begin { $stackPath = Get-PSCallStackPath Write-Debug "[$stackPath] - Begin" + $vaultKeyCache = @{} + Assert-ContextSodiumModule } process { @@ -118,7 +118,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..f1ff0cd 100644 --- a/src/functions/public/Get-ContextInfo.ps1 +++ b/src/functions/public/Get-ContextInfo.ps1 @@ -79,32 +79,96 @@ } 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 bc3d7d6..a3c3015 100644 --- a/src/functions/public/Set-Context.ps1 +++ b/src/functions/public/Set-Context.ps1 @@ -1,5 +1,3 @@ -#Requires -Modules @{ ModuleName = 'Sodium'; RequiredVersion = '2.2.2' } - function Set-Context { <# .SYNOPSIS @@ -76,11 +74,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 +94,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 +119,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)) } } diff --git a/tests/Context.Tests.ps1 b/tests/Context.Tests.ps1 index 36e4c23..2810565 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.*'; GUID = 'a699dea5-2c73-4616-a270-1f7abb777e71' } [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseDeclaredVarsMoreThanAssignments', '', @@ -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 1a12299..0d77473 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.*'; GUID = 'a699dea5-2c73-4616-a270-1f7abb777e71' } [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseDeclaredVarsMoreThanAssignments', '', @@ -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' {