From 2e1237b36fea22a0b3353b9b26971e500b9d5265 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Thu, 23 Jul 2026 18:08:07 +0200 Subject: [PATCH 1/6] Speed recursive context conversion Replace pipeline-heavy recursion with direct .NET collection and string operations while preserving secure-string and nested-object behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...vert-ContextHashtableToObjectRecursive.ps1 | 73 +++++++++------ ...vert-ContextObjectToHashtableRecursive.ps1 | 92 +++++++++++-------- 2 files changed, 99 insertions(+), 66 deletions(-) diff --git a/src/functions/private/JsonToObject/Convert-ContextHashtableToObjectRecursive.ps1 b/src/functions/private/JsonToObject/Convert-ContextHashtableToObjectRecursive.ps1 index 3b259a0..4e26900 100644 --- a/src/functions/private/JsonToObject/Convert-ContextHashtableToObjectRecursive.ps1 +++ b/src/functions/private/JsonToObject/Convert-ContextHashtableToObjectRecursive.ps1 @@ -50,7 +50,7 @@ param ( # Hashtable to convert into a structured context object [Parameter(Mandatory)] - [hashtable] $Hashtable + [System.Collections.IDictionary] $Hashtable ) begin { @@ -60,42 +60,61 @@ process { try { - $result = [pscustomobject]@{} + $result = [ordered]@{} foreach ($key in $Hashtable.Keys) { $value = $Hashtable[$key] - Write-Debug "Processing [$key]" - Write-Debug "Value: $value" + if ($null -eq $value) { - Write-Debug "- as null value" - $result | Add-Member -NotePropertyName $key -NotePropertyValue $null + $result[$key] = $null + continue + } + + if ($value -is [string] -and $value.StartsWith('[SECURESTRING]', [System.StringComparison]::Ordinal)) { + $secureValue = $value.Substring(14) + $result[$key] = ConvertTo-SecureString -String $secureValue -AsPlainText -Force + continue + } + + if ($value -is [System.Collections.IDictionary]) { + $result[$key] = Convert-ContextHashtableToObjectRecursive $value continue } - Write-Debug "Type: $($value.GetType().Name)" - if ($value -is [string] -and $value -like '`[SECURESTRING`]*') { - Write-Debug "Converting [$key] as [SecureString]" - $secureValue = $value -replace '^\[SECURESTRING\]', '' - $result | Add-Member -NotePropertyName $key -NotePropertyValue ($secureValue | ConvertTo-SecureString -AsPlainText -Force) - } elseif ($value -is [hashtable]) { - Write-Debug "Converting [$key] as [hashtable]" - $result | Add-Member -NotePropertyName $key -NotePropertyValue (Convert-ContextHashtableToObjectRecursive $value) - } elseif ($value -is [array]) { - Write-Debug "Converting [$key] as [array], processing elements individually" - $result | Add-Member -NotePropertyName $key -NotePropertyValue @( - $value | ForEach-Object { - if ($_ -is [hashtable]) { - Convert-ContextHashtableToObjectRecursive $_ - } else { - $_ - } + + if ($value -is [System.Collections.IEnumerable] -and $value -isnot [string]) { + $requiresDeepConversion = $false + foreach ($item in $value) { + if ( + $item -is [System.Collections.IDictionary] -or + ($item -is [string] -and $item.StartsWith('[SECURESTRING]', [System.StringComparison]::Ordinal)) + ) { + $requiresDeepConversion = $true + break + } + } + + if (-not $requiresDeepConversion) { + $result[$key] = @($value) + continue + } + + $arrayResult = [System.Collections.Generic.List[object]]::new() + foreach ($item in $value) { + if ($item -is [System.Collections.IDictionary]) { + $null = $arrayResult.Add((Convert-ContextHashtableToObjectRecursive $item)) + } elseif ($item -is [string] -and $item.StartsWith('[SECURESTRING]', [System.StringComparison]::Ordinal)) { + $null = $arrayResult.Add((ConvertTo-SecureString -String $item.Substring(14) -AsPlainText -Force)) + } else { + $null = $arrayResult.Add($item) } - ) + } + $result[$key] = $arrayResult.ToArray() } else { - Write-Debug "Adding [$key] as a standard value" - $result | Add-Member -NotePropertyName $key -NotePropertyValue $value + $result[$key] = $value } } - return $result + + return [pscustomobject]$result } catch { Write-Error $_ throw 'Failed to convert hashtable to object' diff --git a/src/functions/private/ObjectToJson/Convert-ContextObjectToHashtableRecursive.ps1 b/src/functions/private/ObjectToJson/Convert-ContextObjectToHashtableRecursive.ps1 index 5a07831..f03ddcf 100644 --- a/src/functions/private/ObjectToJson/Convert-ContextObjectToHashtableRecursive.ps1 +++ b/src/functions/private/ObjectToJson/Convert-ContextObjectToHashtableRecursive.ps1 @@ -53,52 +53,66 @@ process { try { - $result = @{} + if ($null -eq $Object) { + return $null + } + + if ($Object -is [datetime]) { + return $Object.ToString('o') + } + + if ($Object -is [System.Security.SecureString]) { + $plainTextValue = [System.Net.NetworkCredential]::new('', $Object).Password + return "[SECURESTRING]$plainTextValue" + } - if ($Object -is [hashtable]) { - Write-Debug 'Converting [hashtable] to [PSCustomObject]' - $Object = [PSCustomObject]$Object - } elseif ($Object -is [string] -or $Object -is [int] -or $Object -is [bool]) { - Write-Debug 'returning as string' + if ($Object -is [string] -or $Object -is [ValueType]) { return $Object } - foreach ($property in $Object.PSObject.Properties) { - $name = $property.Name - $value = $property.Value - Write-Debug "Processing [$name]" - Write-Debug "Value: $value" - if ($null -eq $value) { - Write-Debug '- as null value' - $result[$property.Name] = $null - continue + if ($Object -is [System.Collections.IDictionary]) { + $dictionaryResult = @{} + foreach ($entry in $Object.GetEnumerator()) { + $dictionaryResult[[string]$entry.Key] = Convert-ContextObjectToHashtableRecursive $entry.Value } - Write-Debug "Type: $($value.GetType().Name)" - if ($value -is [datetime]) { - Write-Debug '- as DateTime' - $result[$property.Name] = $value.ToString('o') - } elseif ($value -is [string] -or $Object -is [int] -or $Object -is [bool]) { - Write-Debug '- as string, int, bool' - $result[$property.Name] = $value - } elseif ($value -is [System.Security.SecureString]) { - Write-Debug '- as SecureString' - $value = $value | ConvertFrom-SecureString -AsPlainText - $result[$property.Name] = "[SECURESTRING]$value" - } elseif ($value -is [psobject] -or $value -is [PSCustomObject] -or $value -is [hashtable]) { - Write-Debug '- as PSObject, PSCustomObject or hashtable' - $result[$property.Name] = Convert-ContextObjectToHashtableRecursive $value - } elseif ($value -is [System.Collections.IEnumerable]) { - Write-Debug '- as IEnumerable, including arrays and hashtables' - $result[$property.Name] = @( - $value | ForEach-Object { - Convert-ContextObjectToHashtableRecursive $_ - } - ) - } else { - Write-Debug '- as regular value' - $result[$property.Name] = $value + return $dictionaryResult + } + + if ($Object -is [System.Collections.IEnumerable]) { + $requiresDeepConversion = $false + foreach ($item in $Object) { + if ($null -eq $item) { + continue + } + + if ( + $item -is [System.Security.SecureString] -or + $item -is [datetime] -or + $item -is [System.Collections.IDictionary] -or + ($item -is [System.Collections.IEnumerable] -and $item -isnot [string]) -or + ($item -isnot [string] -and $item -isnot [ValueType]) + ) { + $requiresDeepConversion = $true + break + } } + + if (-not $requiresDeepConversion) { + return @($Object) + } + + $listResult = [System.Collections.Generic.List[object]]::new() + foreach ($item in $Object) { + $null = $listResult.Add((Convert-ContextObjectToHashtableRecursive $item)) + } + return $listResult.ToArray() } + + $result = @{} + foreach ($property in $Object.PSObject.Properties) { + $result[$property.Name] = Convert-ContextObjectToHashtableRecursive $property.Value + } + return $result } catch { Write-Error $_ From f89ad8a4dd4aa536086be68645acdf4d97848cf8 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Thu, 23 Jul 2026 18:08:24 +0200 Subject: [PATCH 2/6] Accelerate indexed context operations Cache exact-ID file lookups and vault keypairs, defer Sodium loading until cryptography is used, and harden paths used for vault file operations. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../private/Assert-ContextSodiumModule.ps1 | 57 +++++ .../private/ContextFileIndexCache.ps1 | 197 ++++++++++++++++++ .../private/Get-ContextInfoFromFile.ps1 | 54 +++++ .../private/Get-ContextVaultKeyPair.ps1 | 17 +- src/functions/public/Get-Context.ps1 | 9 +- src/functions/public/Get-ContextInfo.ps1 | 99 +++++++-- src/functions/public/Remove-Context.ps1 | 3 +- src/functions/public/Set-Context.ps1 | 41 ++-- .../public/Vault/Remove-ContextVault.ps1 | 4 + .../public/Vault/Set-ContextVault.ps1 | 20 +- 10 files changed, 458 insertions(+), 43 deletions(-) create mode 100644 src/functions/private/Assert-ContextSodiumModule.ps1 create mode 100644 src/functions/private/ContextFileIndexCache.ps1 create mode 100644 src/functions/private/Get-ContextInfoFromFile.ps1 diff --git a/src/functions/private/Assert-ContextSodiumModule.ps1 b/src/functions/private/Assert-ContextSodiumModule.ps1 new file mode 100644 index 0000000..a8d13da --- /dev/null +++ b/src/functions/private/Assert-ContextSodiumModule.ps1 @@ -0,0 +1,57 @@ +function Assert-ContextSodiumModule { + <# + .SYNOPSIS + Ensures the required Sodium module version is available for context cryptography. + + .DESCRIPTION + Validates that Sodium v2.2.4 or newer is loaded in the current session. + Imports Sodium v2.2.4 if needed, and verifies required commands exist. + + .EXAMPLE + Assert-ContextSodiumModule + + Ensures Sodium cryptography commands are available before encryption or decryption. + #> + [CmdletBinding()] + param() + + begin { + $stackPath = Get-PSCallStackPath + Write-Debug "[$stackPath] - Begin" + } + + process { + if ($script:ContextSodiumModuleReady) { + return + } + + $minimumVersion = [version]'2.2.4' + $loadedSodium = Get-Module -Name Sodium | Sort-Object Version -Descending | Select-Object -First 1 + + if ($loadedSodium -and $loadedSodium.Version -lt $minimumVersion) { + throw "Loaded Sodium version [$($loadedSodium.Version)] is older than required version [$minimumVersion]. Start a new PowerShell session and import Sodium $minimumVersion." + } + + if (-not $loadedSodium) { + Import-Module -Name Sodium -RequiredVersion $minimumVersion -ErrorAction Stop + } + + $requiredCommands = @( + 'New-SodiumKeyPair', + 'ConvertTo-SodiumSealedBox', + 'ConvertFrom-SodiumSealedBox' + ) + + foreach ($commandName in $requiredCommands) { + if (-not (Get-Command -Name $commandName -ErrorAction SilentlyContinue)) { + throw "Required Sodium command '$commandName' is not available after loading the module." + } + } + + $script:ContextSodiumModuleReady = $true + } + + end { + Write-Debug "[$stackPath] - End" + } +} diff --git a/src/functions/private/ContextFileIndexCache.ps1 b/src/functions/private/ContextFileIndexCache.ps1 new file mode 100644 index 0000000..d04ca63 --- /dev/null +++ b/src/functions/private/ContextFileIndexCache.ps1 @@ -0,0 +1,197 @@ +function Get-ContextFileIndex { + <# + .SYNOPSIS + Gets a cached context file index for a vault. + + .DESCRIPTION + Builds (or returns) an in-memory index of context IDs to metadata file paths for a vault. + This avoids repeated full vault scans for exact-ID lookups in hot paths. + + .EXAMPLE + Get-ContextFileIndex -Vault 'MyVault' -VaultPath 'C:\Users\Jane\.contextvaults\MyVault' + + Returns a dictionary where keys are context IDs and values are metadata file paths. + + .OUTPUTS + [System.Collections.Generic.Dictionary[string,string]] + #> + [OutputType([System.Collections.Generic.Dictionary[string, string]])] + [CmdletBinding()] + param( + # The vault name. + [Parameter(Mandatory)] + [string] $Vault, + + # The full path to the vault folder. + [Parameter(Mandatory)] + [string] $VaultPath, + + # Rebuilds the index from disk even if cached. + [Parameter()] + [switch] $Refresh + ) + + begin { + $stackPath = Get-PSCallStackPath + Write-Debug "[$stackPath] - Begin" + if ($null -eq $script:ContextFileIndexCache) { + $script:ContextFileIndexCache = @{} + } + } + + process { + if (-not $Refresh -and $script:ContextFileIndexCache.ContainsKey($Vault)) { + return $script:ContextFileIndexCache[$Vault] + } + + $index = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $files = Get-ChildItem -Path $VaultPath -Filter *.json -File -ErrorAction SilentlyContinue + + foreach ($file in $files) { + try { + $contextInfo = Get-ContextInfoFromFile -Path $file.FullName -Vault $Vault -ErrorAction Stop + $index[$contextInfo.ID] = $contextInfo.Path + } catch { + Write-Warning "[$stackPath] - Failed to index context file '$($file.FullName)': $($_.Exception.Message)" + } + } + + $script:ContextFileIndexCache[$Vault] = $index + return $index + } + + end { + Write-Debug "[$stackPath] - End" + } +} + +function Set-ContextFileIndexEntry { + <# + .SYNOPSIS + Adds or updates a context entry in the in-memory vault index. + + .DESCRIPTION + Stores or updates an ID-to-file-path mapping in the in-memory cache for a vault. + + .EXAMPLE + Set-ContextFileIndexEntry -Vault 'MyVault' -ID 'MyContext' -Path 'C:\vault\abc.json' + + Updates the in-memory index for the given vault. + #> + [CmdletBinding()] + param( + # The vault name. + [Parameter(Mandatory)] + [string] $Vault, + + # The context ID. + [Parameter(Mandatory)] + [string] $ID, + + # The metadata file path. + [Parameter(Mandatory)] + [string] $Path + ) + + begin { + $stackPath = Get-PSCallStackPath + Write-Debug "[$stackPath] - Begin" + if ($null -eq $script:ContextFileIndexCache) { + $script:ContextFileIndexCache = @{} + } + } + + process { + if (-not $script:ContextFileIndexCache.ContainsKey($Vault)) { + $script:ContextFileIndexCache[$Vault] = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) + } + + $script:ContextFileIndexCache[$Vault][$ID] = $Path + } + + end { + Write-Debug "[$stackPath] - End" + } +} + +function Remove-ContextFileIndexEntry { + <# + .SYNOPSIS + Removes a context entry from the in-memory vault index. + + .DESCRIPTION + Removes an ID mapping from the in-memory index for a vault. + + .EXAMPLE + Remove-ContextFileIndexEntry -Vault 'MyVault' -ID 'MyContext' + + Removes the mapping for the specified context ID. + #> + [CmdletBinding()] + param( + # The vault name. + [Parameter(Mandatory)] + [string] $Vault, + + # The context ID. + [Parameter(Mandatory)] + [string] $ID + ) + + begin { + $stackPath = Get-PSCallStackPath + Write-Debug "[$stackPath] - Begin" + } + + process { + if ($null -eq $script:ContextFileIndexCache -or -not $script:ContextFileIndexCache.ContainsKey($Vault)) { + return + } + + $null = $script:ContextFileIndexCache[$Vault].Remove($ID) + } + + end { + Write-Debug "[$stackPath] - End" + } +} + +function Clear-ContextFileIndex { + <# + .SYNOPSIS + Clears in-memory vault index entries. + + .DESCRIPTION + Removes one or more vault entries from the in-memory context file index cache. + + .EXAMPLE + Clear-ContextFileIndex -Vault 'MyVault' + + Clears the in-memory index for 'MyVault'. + #> + [CmdletBinding()] + param( + # One or more vault names to clear from cache. + [Parameter(Mandatory)] + [string[]] $Vault + ) + + begin { + $stackPath = Get-PSCallStackPath + Write-Debug "[$stackPath] - Begin" + } + + process { + if ($null -eq $script:ContextFileIndexCache) { + return + } + + foreach ($vaultName in $Vault) { + $null = $script:ContextFileIndexCache.Remove($vaultName) + } + } + + end { + Write-Debug "[$stackPath] - End" + } +} diff --git a/src/functions/private/Get-ContextInfoFromFile.ps1 b/src/functions/private/Get-ContextInfoFromFile.ps1 new file mode 100644 index 0000000..b0dd6f9 --- /dev/null +++ b/src/functions/private/Get-ContextInfoFromFile.ps1 @@ -0,0 +1,54 @@ +function Get-ContextInfoFromFile { + <# + .SYNOPSIS + Reads and parses a context metadata file. + + .DESCRIPTION + Reads a context metadata file from disk and returns a ContextInfo object. + The returned Path always points to the actual file on disk, not the metadata payload. + + .EXAMPLE + Get-ContextInfoFromFile -Path 'C:\Users\Jane\.contextvaults\MyVault\abc.json' -Vault 'MyVault' + + Parses the context metadata file and returns a ContextInfo instance. + + .OUTPUTS + [ContextInfo] + #> + [OutputType([ContextInfo])] + [CmdletBinding()] + param( + # Full path to the context metadata file. + [Parameter(Mandatory)] + [string] $Path, + + # The vault name the file belongs to. + [Parameter(Mandatory)] + [string] $Vault + ) + + begin { + $stackPath = Get-PSCallStackPath + Write-Debug "[$stackPath] - Begin" + } + + process { + $content = Get-ContentNonLocking -Path $Path + $rawContextInfo = $content | ConvertFrom-Json -ErrorAction Stop + + if ([string]::IsNullOrWhiteSpace($rawContextInfo.ID)) { + throw "Context metadata file '$Path' does not contain a valid ID." + } + + [ContextInfo]::new([pscustomobject]@{ + ID = [string]$rawContextInfo.ID + Path = $Path + Vault = $Vault + Context = [string]$rawContextInfo.Context + }) + } + + end { + Write-Debug "[$stackPath] - End" + } +} diff --git a/src/functions/private/Get-ContextVaultKeyPair.ps1 b/src/functions/private/Get-ContextVaultKeyPair.ps1 index ef51819..03e103a 100644 --- a/src/functions/private/Get-ContextVaultKeyPair.ps1 +++ b/src/functions/private/Get-ContextVaultKeyPair.ps1 @@ -29,6 +29,10 @@ begin { $stackPath = Get-PSCallStackPath Write-Debug "[$stackPath] - Start" + Assert-ContextSodiumModule + if ($null -eq $script:ContextVaultKeyPairCache) { + $script:ContextVaultKeyPairCache = @{} + } } process { @@ -42,12 +46,23 @@ throw "[$stackPath] - Unable to read shard file '$shardPath': $($_.Exception.Message)" } + if ($script:ContextVaultKeyPairCache.ContainsKey($vaultObject.Name)) { + $cachedEntry = $script:ContextVaultKeyPairCache[$vaultObject.Name] + if ($cachedEntry.Shard -eq $fileShard) { + return $cachedEntry.Keys + } + } + $machineShard = [System.Environment]::MachineName $userShard = [System.Environment]::UserName #$userInputShard = Read-Host -Prompt 'Enter a seed shard' # Eventually 4 shards. +1 for user input. $seed = $machineShard + $userShard + $fileShard # + $userInputShard $keys = New-SodiumKeyPair -Seed $seed - $keys + $script:ContextVaultKeyPairCache[$vaultObject.Name] = [pscustomobject]@{ + Shard = $fileShard + Keys = $keys + } + return $keys } end { diff --git a/src/functions/public/Get-Context.ps1 b/src/functions/public/Get-Context.ps1 index 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..7de2157 100644 --- a/src/functions/public/Get-ContextInfo.ps1 +++ b/src/functions/public/Get-ContextInfo.ps1 @@ -79,32 +79,93 @@ } process { + $idPatterns = [System.Collections.Generic.List[System.Management.Automation.WildcardPattern]]::new() + $exactIds = [System.Collections.Generic.List[string]]::new() + $hasWildcardId = $false + + foreach ($idItem in $ID) { + if ([string]::IsNullOrWhiteSpace($idItem)) { + continue + } + + $wildcardPattern = [System.Management.Automation.WildcardPattern]::new($idItem, [System.Management.Automation.WildcardOptions]::IgnoreCase) + $null = $idPatterns.Add($wildcardPattern) + + if ([System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters($idItem)) { + $hasWildcardId = $true + continue + } + + if (-not $exactIds.Contains($idItem)) { + $null = $exactIds.Add($idItem) + } + } + + if ($idPatterns.Count -eq 0) { + return + } + $vaults = foreach ($vaultName in $Vault) { Get-ContextVault -Name $vaultName -ErrorAction Stop } Write-Verbose "[$stackPath] - Found $($vaults.Count) vault(s) matching '$($Vault -join ', ')'." - $files = foreach ($vaultObject in $vaults) { - Get-ChildItem -Path $vaultObject.Path -Filter *.json -File - } - Write-Verbose "[$stackPath] - Found $($files.Count) context file(s) in vault(s)." - - foreach ($file in $files) { - # Use non-locking file reading to allow concurrent access - try { - $content = Get-ContentNonLocking -Path $file.FullName - $contextInfo = $content | ConvertFrom-Json - } catch { - Write-Warning "[$stackPath] - Error reading context file '$($file.FullName)': $($_.Exception.Message)" + foreach ($vaultObject in $vaults) { + $contextIndex = Get-ContextFileIndex -Vault $vaultObject.Name -VaultPath $vaultObject.Path + + if (-not $hasWildcardId -and $exactIds.Count -gt 0) { + foreach ($exactId in $exactIds) { + $contextPath = $null + if (-not $contextIndex.TryGetValue($exactId, [ref]$contextPath)) { + continue + } + + if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { + Remove-ContextFileIndexEntry -Vault $vaultObject.Name -ID $exactId + continue + } + + try { + $contextInfo = Get-ContextInfoFromFile -Path $contextPath -Vault $vaultObject.Name -ErrorAction Stop + Set-ContextFileIndexEntry -Vault $vaultObject.Name -ID $contextInfo.ID -Path $contextInfo.Path + } catch { + Write-Warning "[$stackPath] - Error reading context file '$contextPath': $($_.Exception.Message)" + Remove-ContextFileIndexEntry -Vault $vaultObject.Name -ID $exactId + continue + } + + if ($contextInfo.ID -like $exactId) { + $contextInfo + } else { + Remove-ContextFileIndexEntry -Vault $vaultObject.Name -ID $exactId + } + } + continue } - if ($VerbosePreference -eq 'Continue') { - Write-Verbose "[$stackPath] - Processing file: $($file.FullName)" - $contextInfo | Format-List | Out-String -Stream | ForEach-Object { Write-Verbose "[$stackPath] $_" } - } - foreach ($IDItem in $ID) { - if ($contextInfo.ID -like $IDItem) { - [ContextInfo]::new($contextInfo) + + $files = Get-ChildItem -Path $vaultObject.Path -Filter *.json -File + Write-Verbose "[$stackPath] - Found $($files.Count) context file(s) in vault [$($vaultObject.Name)]." + + foreach ($file in $files) { + try { + $contextInfo = Get-ContextInfoFromFile -Path $file.FullName -Vault $vaultObject.Name -ErrorAction Stop + Set-ContextFileIndexEntry -Vault $vaultObject.Name -ID $contextInfo.ID -Path $contextInfo.Path + } catch { + Write-Warning "[$stackPath] - Error reading context file '$($file.FullName)': $($_.Exception.Message)" + continue + } + + if ($VerbosePreference -eq 'Continue') { + Write-Verbose "[$stackPath] - Processing file: $($file.FullName)" + $contextInfo | Format-List | Out-String -Stream | ForEach-Object { Write-Verbose "[$stackPath] $_" } + } + + foreach ($pattern in $idPatterns) { + if ($pattern.IsMatch($contextInfo.ID)) { + $contextInfo + break + } } } } diff --git a/src/functions/public/Remove-Context.ps1 b/src/functions/public/Remove-Context.ps1 index c9ac418..af69ebe 100644 --- a/src/functions/public/Remove-Context.ps1 +++ b/src/functions/public/Remove-Context.ps1 @@ -102,7 +102,8 @@ if ($PSCmdlet.ShouldProcess("Context '$contextId'", 'Remove')) { Write-Verbose "[$stackPath] - Removing context [$contextId]" - $contextInfo.Path | Remove-Item -Force -ErrorAction Stop + Remove-Item -LiteralPath $contextInfo.Path -Force -ErrorAction Stop + Remove-ContextFileIndexEntry -Vault $contextInfo.Vault -ID $contextInfo.ID Write-Verbose "[$stackPath] - Removed item: $contextId" } } diff --git a/src/functions/public/Set-Context.ps1 b/src/functions/public/Set-Context.ps1 index 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)) } } From 45acbc2ec910c4f9d08287469bb06610fc102b2e Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Thu, 23 Jul 2026 18:08:30 +0200 Subject: [PATCH 3/6] Add heavy performance and path safety tests Run the suite on Pester 6 and cover populated-vault workloads, repeated context roundtrips, cross-vault exact-ID results, and path traversal protections. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/Context.Tests.ps1 | 107 +++++++++++++++++++++++++++++++++- tests/ContextVaults.Tests.ps1 | 6 +- 2 files changed, 111 insertions(+), 2 deletions(-) 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' { From b48a15dc56fed14baacc8acf7ead2dd9cb69519a Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Thu, 23 Jul 2026 18:09:52 +0200 Subject: [PATCH 4/6] Align performance helpers with analyzer rules Wrap long calls, declare recursive output types, and document why in-memory cache helpers do not use ShouldProcess. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../private/Assert-ContextSodiumModule.ps1 | 4 +++- src/functions/private/ContextFileIndexCache.ps1 | 13 ++++++++++++- src/functions/private/Get-ContextInfoFromFile.ps1 | 10 +++++----- .../Convert-ContextObjectToHashtableRecursive.ps1 | 2 +- src/functions/public/Get-ContextInfo.ps1 | 5 ++++- 5 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/functions/private/Assert-ContextSodiumModule.ps1 b/src/functions/private/Assert-ContextSodiumModule.ps1 index a8d13da..5e64304 100644 --- a/src/functions/private/Assert-ContextSodiumModule.ps1 +++ b/src/functions/private/Assert-ContextSodiumModule.ps1 @@ -29,7 +29,9 @@ function Assert-ContextSodiumModule { $loadedSodium = Get-Module -Name Sodium | Sort-Object Version -Descending | Select-Object -First 1 if ($loadedSodium -and $loadedSodium.Version -lt $minimumVersion) { - throw "Loaded Sodium version [$($loadedSodium.Version)] is older than required version [$minimumVersion]. Start a new PowerShell session and import Sodium $minimumVersion." + $message = "Loaded Sodium version [$($loadedSodium.Version)] is older than required version [$minimumVersion]. " + $message += "Start a new PowerShell session and import Sodium $minimumVersion." + throw $message } if (-not $loadedSodium) { diff --git a/src/functions/private/ContextFileIndexCache.ps1 b/src/functions/private/ContextFileIndexCache.ps1 index d04ca63..3eaa748 100644 --- a/src/functions/private/ContextFileIndexCache.ps1 +++ b/src/functions/private/ContextFileIndexCache.ps1 @@ -78,6 +78,10 @@ function Set-ContextFileIndexEntry { Updates the in-memory index for the given vault. #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'This private helper only mutates an in-memory cache.' + )] [CmdletBinding()] param( # The vault name. @@ -103,7 +107,10 @@ function Set-ContextFileIndexEntry { process { if (-not $script:ContextFileIndexCache.ContainsKey($Vault)) { - $script:ContextFileIndexCache[$Vault] = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $vaultIndex = [System.Collections.Generic.Dictionary[string, string]]::new( + [System.StringComparer]::OrdinalIgnoreCase + ) + $script:ContextFileIndexCache[$Vault] = $vaultIndex } $script:ContextFileIndexCache[$Vault][$ID] = $Path @@ -127,6 +134,10 @@ function Remove-ContextFileIndexEntry { Removes the mapping for the specified context ID. #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'This private helper only mutates an in-memory cache.' + )] [CmdletBinding()] param( # The vault name. diff --git a/src/functions/private/Get-ContextInfoFromFile.ps1 b/src/functions/private/Get-ContextInfoFromFile.ps1 index b0dd6f9..a1c401b 100644 --- a/src/functions/private/Get-ContextInfoFromFile.ps1 +++ b/src/functions/private/Get-ContextInfoFromFile.ps1 @@ -41,11 +41,11 @@ function Get-ContextInfoFromFile { } [ContextInfo]::new([pscustomobject]@{ - ID = [string]$rawContextInfo.ID - Path = $Path - Vault = $Vault - Context = [string]$rawContextInfo.Context - }) + ID = [string]$rawContextInfo.ID + Path = $Path + Vault = $Vault + Context = [string]$rawContextInfo.Context + }) } end { diff --git a/src/functions/private/ObjectToJson/Convert-ContextObjectToHashtableRecursive.ps1 b/src/functions/private/ObjectToJson/Convert-ContextObjectToHashtableRecursive.ps1 index f03ddcf..1a0f3c4 100644 --- a/src/functions/private/ObjectToJson/Convert-ContextObjectToHashtableRecursive.ps1 +++ b/src/functions/private/ObjectToJson/Convert-ContextObjectToHashtableRecursive.ps1 @@ -38,7 +38,7 @@ .LINK https://psmodule.io/Context/Functions/Convert-ContextObjectToHashtableRecursive #> - [OutputType([hashtable])] + [OutputType([string], [ValueType], [hashtable], [object[]])] [CmdletBinding()] param ( # The object to convert. diff --git a/src/functions/public/Get-ContextInfo.ps1 b/src/functions/public/Get-ContextInfo.ps1 index 7de2157..f1ff0cd 100644 --- a/src/functions/public/Get-ContextInfo.ps1 +++ b/src/functions/public/Get-ContextInfo.ps1 @@ -88,7 +88,10 @@ continue } - $wildcardPattern = [System.Management.Automation.WildcardPattern]::new($idItem, [System.Management.Automation.WildcardOptions]::IgnoreCase) + $wildcardPattern = [System.Management.Automation.WildcardPattern]::new( + $idItem, + [System.Management.Automation.WildcardOptions]::IgnoreCase + ) $null = $idPatterns.Add($wildcardPattern) if ([System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters($idItem)) { From 9120271397453545d98200fab777aa2624ff5fa9 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Thu, 23 Jul 2026 18:19:46 +0200 Subject: [PATCH 5/6] Align CI to Process-PSModule v6.1.13 - Pin workflow to v6.1.13 (SHA fb1bdb8fefd243292f779d2a856a38db6fe6daf4) - Replace secrets: inherit with explicit APIKey secret per v6.0.0 breaking change - Context has no test secrets so TestData is omitted Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/Process-PSModule.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/Process-PSModule.yml b/.github/workflows/Process-PSModule.yml index 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 }} From 81ce160b3740cfa36ec82a5eb71dd27f736f831f Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Thu, 23 Jul 2026 18:25:15 +0200 Subject: [PATCH 6/6] Add zensical.toml to replace mkdocs.yml for docs site Translates all mkdocs.yml settings to Zensical TOML format: - Preserves palette colors (black/green dark, indigo/green light) - Updates palette toggle icons from material/ to lucide/ - Retains all navigation features and markdown extensions - Adds content.code.copy and pymdownx.superfences (Zensical standard) - Social links and cookie consent carried over Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/zensical.toml | 69 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 .github/zensical.toml diff --git a/.github/zensical.toml b/.github/zensical.toml new file mode 100644 index 0000000..8740837 --- /dev/null +++ b/.github/zensical.toml @@ -0,0 +1,69 @@ +[project] +site_name = "Context" +repo_name = "PSModule/Context" +repo_url = "https://github.com/PSModule/Context" + +[project.theme] +variant = "classic" +language = "en" +logo = "Assets/icon.png" +favicon = "Assets/icon.png" +features = [ + "navigation.instant", + "navigation.instant.progress", + "navigation.indexes", + "navigation.top", + "navigation.tracking", + "navigation.expand", + "search.suggest", + "search.highlight", + "content.code.copy" +] + +[[project.theme.palette]] +media = "(prefers-color-scheme)" +toggle.icon = "lucide/sun-moon" +toggle.name = "Switch to dark mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: dark)" +scheme = "slate" +primary = "black" +accent = "green" +toggle.icon = "lucide/moon" +toggle.name = "Switch to light mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: light)" +scheme = "default" +primary = "indigo" +accent = "green" +toggle.icon = "lucide/sun" +toggle.name = "Switch to system preference" + +[project.theme.icon] +repo = "fontawesome/brands/github" + +[project.markdown_extensions.toc] +permalink = true + +[project.markdown_extensions.attr_list] +[project.markdown_extensions.admonition] +[project.markdown_extensions.md_in_html] +[project.markdown_extensions.pymdownx.details] +[project.markdown_extensions.pymdownx.superfences] + +[[project.extra.social]] +icon = "fontawesome/brands/discord" +link = "https://discord.gg/jedJWCPAhD" +name = "PSModule on Discord" + +[[project.extra.social]] +icon = "fontawesome/brands/github" +link = "https://github.com/PSModule/" +name = "PSModule on GitHub" + +[project.extra.consent] +title = "Cookie consent" +description = "We use cookies to recognize your repeated visits and preferences, as well as to measure the effectiveness of our documentation and whether users find what they're searching for. With your consent, you're helping us to make our documentation better." +actions = ["accept", "reject"]