Private/ConfigStore.ps1
|
<# .SYNOPSIS Internal helpers for reading and writing tcs module configuration files. .NOTES Private helpers for the tcs.core module. #> # Per-session cache of resolved module configuration, keyed by module name. # Populated by Get-ModuleConfig / Set-ModuleConfig and read by Invoke-TelemetryCollection. $script:ModuleConfigCache = @{} # Module names become folder names, so only allow a single, safe path segment $script:ModuleNamePattern = '^[A-Za-z0-9][A-Za-z0-9._-]*$' # Names of values that describe the loaded module; they are never stored in a settings file $script:ReservedConfigKeys = @('ModuleName', 'ModulePath', 'ModuleVersion', 'ModuleConfigPath', 'ModuleConfigFilePath') function Read-JsonFileAsHashtable { [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [string]$Path ) $result = @{} $json = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop if ([string]::IsNullOrWhiteSpace($json)) { return $result } $object = $json | ConvertFrom-Json -ErrorAction Stop foreach ($property in $object.PSObject.Properties) { $result[$property.Name] = $property.Value } return $result } function Write-JsonFile { [CmdletBinding()] [OutputType([void])] param( [Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [System.Collections.IDictionary]$Data ) $directory = Split-Path -Path $Path -Parent if ($directory -and -not (Test-Path -LiteralPath $directory)) { $null = New-Item -Path $directory -ItemType Directory -Force -ErrorAction Stop } # Sort keys so the file is stable and readable $ordered = [ordered]@{} foreach ($key in ($Data.Keys | Sort-Object)) { $ordered[$key] = $Data[$key] } $ordered | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $Path -Encoding UTF8 -ErrorAction Stop } function Get-DefaultModuleConfig { <# .SYNOPSIS Returns the tcs.core defaults, overlaid with the calling module's own Config/Module.Defaults.json when one exists. #> [CmdletBinding()] [OutputType([hashtable])] param( [string]$ModulePath ) $coreDefaultsPath = Join-Path -Path (Join-Path -Path $script:TcsCoreModuleRoot -ChildPath 'Config') -ChildPath 'Module.Defaults.json' $defaults = Read-JsonFileAsHashtable -Path $coreDefaultsPath if ($ModulePath) { $moduleDefaultsPath = Join-Path -Path (Join-Path -Path $ModulePath -ChildPath 'Config') -ChildPath 'Module.Defaults.json' if ((Test-Path -LiteralPath $moduleDefaultsPath) -and ((Resolve-Path -LiteralPath $moduleDefaultsPath).Path -ne (Resolve-Path -LiteralPath $coreDefaultsPath).Path)) { try { $moduleDefaults = Read-JsonFileAsHashtable -Path $moduleDefaultsPath foreach ($key in $moduleDefaults.Keys) { $defaults[$key] = $moduleDefaults[$key] } } catch { Write-Warning "Ignoring invalid defaults file '$moduleDefaultsPath': $($_.Exception.Message)" } } } return $defaults } function ConvertTo-ConfigValueType { <# .SYNOPSIS Converts a stored value to the type of its default (e.g. "True" -> $true), so values written by older versions of tcs.core are read consistently. .DESCRIPTION Values that cannot be converted, or that are out of range for a known setting (for example UpdateCheckIntervalHours -5), fall back to the default for that key only, so one bad value never discards the rest of the settings file. With -Strict the function throws instead of falling back. Set-ModuleConfig uses this to reject invalid input. #> [CmdletBinding()] [OutputType([object])] param( [AllowNull()] [object]$Value, [AllowNull()] [object]$DefaultValue, [string]$Key, [switch]$Strict ) $label = if ($Key) { "Setting '$Key'" } else { 'The value' } $result = $Value if ($null -ne $Value -and $null -ne $DefaultValue) { if ($DefaultValue -is [bool] -and $Value -isnot [bool]) { $parsedBool = $false if ([bool]::TryParse([string]$Value, [ref]$parsedBool)) { $result = $parsedBool } elseif ($Strict) { throw "$label must be `$true or `$false (got '$Value')." } else { Write-Verbose "$label has an invalid value '$Value'; using the default." $result = $DefaultValue } } elseif ($DefaultValue -is [int] -or $DefaultValue -is [long]) { $parsedNumber = [double]0 $isNumber = $Value -is [int] -or $Value -is [long] -or $Value -is [double] -or $Value -is [decimal] -or $Value -is [single] if (-not $isNumber -and $Value -is [string]) { $isNumber = [double]::TryParse($Value, [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$parsedNumber) } elseif ($isNumber) { $parsedNumber = [double]$Value } if ($isNumber -and $parsedNumber -eq [Math]::Floor($parsedNumber) -and $parsedNumber -ge [int]::MinValue -and $parsedNumber -le [int]::MaxValue) { $result = [int]$parsedNumber } elseif ($Strict) { throw "$label must be a whole number (got '$Value')." } else { Write-Verbose "$label has an invalid value '$Value'; using the default." $result = $DefaultValue } } } # Range and format checks for settings that tcs.core itself uses switch ($Key) { 'UpdateCheckIntervalHours' { if ($null -ne $result -and ($result -isnot [int] -or $result -lt 1 -or $result -gt 8760)) { if ($Strict) { throw "$label must be between 1 and 8760 (got '$Value')." } Write-Verbose "$label is out of range ('$Value'); using the default." $result = $DefaultValue } } 'TelemetryUri' { if ($Strict -and -not [string]::IsNullOrEmpty([string]$result) -and [string]$result -notmatch '^https://') { throw "$label must be an https:// address or empty (got '$Value')." } } } return $result } function Get-MaskedModuleConfig { <# .SYNOPSIS Returns a copy of a settings hashtable with secrets (the telemetry API key) masked, for output. #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [hashtable]$Config ) $copy = @{} foreach ($key in $Config.Keys) { $copy[$key] = $Config[$key] } if (-not [string]::IsNullOrEmpty([string]$copy['TelemetryApiKey'])) { $copy['TelemetryApiKey'] = '********' } return $copy } function Test-ModuleNameValid { <# .SYNOPSIS Returns $true when a module name is safe to use as a single folder name. #> [CmdletBinding()] [OutputType([bool])] param( [AllowNull()] [AllowEmptyString()] [string]$Name ) return (-not [string]::IsNullOrEmpty($Name) -and $Name -match $script:ModuleNamePattern) } function Resolve-ModuleBasePath { <# .SYNOPSIS Finds the folder of an installed or loaded module, so its own defaults can be read. Returns $null when the module cannot be found. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [string]$ModuleName ) $cached = $script:ModuleConfigCache[$ModuleName] if ($cached -and $cached['ModulePath']) { return [string]$cached['ModulePath'] } $module = Get-Module -Name $ModuleName -ErrorAction SilentlyContinue | Sort-Object -Property Version -Descending | Select-Object -First 1 if (-not $module) { $module = Get-Module -Name $ModuleName -ListAvailable -ErrorAction SilentlyContinue | Sort-Object -Property Version -Descending | Select-Object -First 1 } if ($module) { return [string]$module.ModuleBase } return $null } function Find-ModuleManifestFile { <# .SYNOPSIS Walks up from a path to the nearest folder containing a module manifest (.psd1 with ModuleVersion). #> [CmdletBinding()] [OutputType([System.IO.FileInfo])] param( [Parameter(Mandatory)] [string]$StartPath ) $searchPath = if (Test-Path -LiteralPath $StartPath -PathType Container) { $StartPath } else { Split-Path -Path $StartPath -Parent } while (-not [string]::IsNullOrEmpty($searchPath)) { $candidates = Get-ChildItem -LiteralPath $searchPath -Filter '*.psd1' -File -ErrorAction SilentlyContinue | Where-Object { $_.BaseName -ne 'PSScriptAnalyzerSettings' } foreach ($candidate in $candidates) { try { $data = Import-PowerShellDataFile -LiteralPath $candidate.FullName -ErrorAction Stop if ($data.ContainsKey('ModuleVersion')) { return $candidate } } catch { Write-Verbose "Skipping '$($candidate.FullName)': $($_.Exception.Message)" } } $parent = Split-Path -Path $searchPath -Parent if ($parent -eq $searchPath) { break } $searchPath = $parent } return $null } |