diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ed32bf6
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,30 @@
+## Build artifacts
+bin/
+obj/
+Artifacts/
+Releases/
+
+## Module bin output is generated by build.ps1
+Module/PSInfisicalAPI/bin/
+
+## VS / Rider / VSCode
+.vs/
+.idea/
+.vscode/
+
+## NuGet
+*.nupkg
+*.snupkg
+project.lock.json
+project.assets.json
+**/packages/*
+!**/packages/build/
+
+## OS
+Thumbs.db
+.DS_Store
+
+## Test result outputs
+TestResults/
+*.trx
+*.coverage
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..beecad1
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,36 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loosely, but version numbers use the build timestamp format `yyyy.MM.dd.HHmm`.
+
+## Unreleased
+
+## 2026.06.02.1638
+
+- Build produced from commit 3c47d6ff30ec.
+
+## Unreleased (carried forward)
+
+## 2026.06.02.1611
+
+- Build produced from commit 3c47d6ff30ec.
+
+## Unreleased
+
+## 2026.06.02.1638
+
+- Build produced from commit 3c47d6ff30ec.
+
+## Unreleased (carried forward) (carried forward)
+
+### Added
+
+- Initial repository skeleton, C# `netstandard2.0` project, and PowerShell module layout.
+- Centralized logging (`InfisicalLogger`), error types/handler, sanitizer, path utility, and `SecureString` utility.
+- Endpoint registry covering `UniversalAuthLogin`, `ListSecrets`, and `RetrieveSecret`, and a `System.Uri`-based URI builder.
+- Synchronous HTTP client, JSON/YAML/XML/ENV serializers, and DTO/mapper for secrets.
+- Connection model, process-level session manager, Universal Auth and Token Auth providers.
+- Cmdlets: `Connect-Infisical`, `Disconnect-Infisical`, `Get-InfisicalSecrets`, `Get-InfisicalSecret`, `ConvertTo-InfisicalSecretDictionary`, `Export-InfisicalSecrets`.
+- Build script (`build.ps1`) generating manifest, copying binaries, creating release folders, and supporting unit/integration tests.
+- xUnit test project with unit tests and opt-in integration tests.
diff --git a/Module/PSInfisicalAPI/PSInfisicalAPI.Format.ps1xml b/Module/PSInfisicalAPI/PSInfisicalAPI.Format.ps1xml
new file mode 100644
index 0000000..9a28be2
--- /dev/null
+++ b/Module/PSInfisicalAPI/PSInfisicalAPI.Format.ps1xml
@@ -0,0 +1,35 @@
+
+
+
+
+ PSInfisicalAPI.Models.InfisicalSecret
+
+ PSInfisicalAPI.Models.InfisicalSecret
+
+
+
+ 32
+ 28
+ 14
+ 10
+ 8
+ 22
+ 6
+
+
+
+
+ SecretName
+ SecretPath
+ Environment
+ Type
+ Version
+ UpdatedAtUtc
+ SecretValueHidden
+
+
+
+
+
+
+
diff --git a/Module/PSInfisicalAPI/PSInfisicalAPI.Types.ps1xml b/Module/PSInfisicalAPI/PSInfisicalAPI.Types.ps1xml
new file mode 100644
index 0000000..41b2854
--- /dev/null
+++ b/Module/PSInfisicalAPI/PSInfisicalAPI.Types.ps1xml
@@ -0,0 +1,49 @@
+
+
+
+ PSInfisicalAPI.Models.InfisicalSecret
+
+
+ PSStandardMembers
+
+
+ DefaultDisplayPropertySet
+
+ SecretName
+ SecretPath
+ Environment
+ Type
+ Version
+ UpdatedAtUtc
+ SecretValueHidden
+
+
+
+
+
+
+
+ PSInfisicalAPI.Connections.InfisicalConnection
+
+
+ PSStandardMembers
+
+
+ DefaultDisplayPropertySet
+
+ BaseUri
+ ApiVersion
+ AuthType
+ ProjectId
+ Environment
+ DefaultSecretPath
+ ConnectedAtUtc
+ ExpiresAtUtc
+ IsConnected
+
+
+
+
+
+
+
diff --git a/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 b/Module/PSInfisicalAPI/PSInfisicalAPI.psd1
new file mode 100644
index 0000000..1b52c29
--- /dev/null
+++ b/Module/PSInfisicalAPI/PSInfisicalAPI.psd1
@@ -0,0 +1,32 @@
+@{
+ RootModule = 'PSInfisicalAPI.psm1'
+ ModuleVersion = '2026.06.02.1638'
+ GUID = 'b8a2f3d4-7c51-4d2f-9e6a-1f0c8b3d4e51'
+ Author = 'Alphaeus Mote'
+ CompanyName = ''
+ Copyright = '(c) Alphaeus Mote. All rights reserved.'
+ Description = 'PSInfisicalAPI is a C# binary PowerShell module for the Infisical REST API.'
+ PowerShellVersion = '5.1'
+ CompatiblePSEditions = @('Desktop','Core')
+ FunctionsToExport = @()
+ CmdletsToExport = @(
+ 'Connect-Infisical',
+ 'Disconnect-Infisical',
+ 'Get-InfisicalSecrets',
+ 'Get-InfisicalSecret',
+ 'ConvertTo-InfisicalSecretDictionary',
+ 'Export-InfisicalSecrets'
+ )
+ AliasesToExport = @()
+ VariablesToExport = @()
+ FormatsToProcess = @('PSInfisicalAPI.Format.ps1xml')
+ TypesToProcess = @('PSInfisicalAPI.Types.ps1xml')
+ PrivateData = @{
+ PSData = @{
+ Tags = @('Infisical','Secrets','API','SecureString')
+ ProjectUri = ''
+ ReleaseNotes = ''
+ CommitHash = '3c47d6ff30ec'
+ }
+ }
+}
\ No newline at end of file
diff --git a/Module/PSInfisicalAPI/PSInfisicalAPI.psm1 b/Module/PSInfisicalAPI/PSInfisicalAPI.psm1
new file mode 100644
index 0000000..dc07a2b
--- /dev/null
+++ b/Module/PSInfisicalAPI/PSInfisicalAPI.psm1
@@ -0,0 +1,14 @@
+$BinaryPath = [System.IO.FileInfo][System.IO.Path]::Combine($PSScriptRoot, 'bin', 'PSInfisicalAPI.dll')
+
+Import-Module -Name $BinaryPath.FullName
+
+$TypesPath = [System.IO.FileInfo][System.IO.Path]::Combine($PSScriptRoot, 'PSInfisicalAPI.Types.ps1xml')
+$FormatPath = [System.IO.FileInfo][System.IO.Path]::Combine($PSScriptRoot, 'PSInfisicalAPI.Format.ps1xml')
+
+if ([System.IO.File]::Exists($TypesPath.FullName)) {
+ Update-TypeData -PrependPath $TypesPath.FullName -ErrorAction SilentlyContinue
+}
+
+if ([System.IO.File]::Exists($FormatPath.FullName)) {
+ Update-FormatData -PrependPath $FormatPath.FullName -ErrorAction SilentlyContinue
+}
diff --git a/NuGet.config b/NuGet.config
new file mode 100644
index 0000000..4d736c1
--- /dev/null
+++ b/NuGet.config
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/PSInfisicalAPI.sln b/PSInfisicalAPI.sln
new file mode 100644
index 0000000..0ff46f1
--- /dev/null
+++ b/PSInfisicalAPI.sln
@@ -0,0 +1,24 @@
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PSInfisicalAPI", "src\PSInfisicalAPI\PSInfisicalAPI.csproj", "{A1B1F0E7-1111-4A3B-9F2A-000000000001}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PSInfisicalAPI.Tests", "src\PSInfisicalAPI.Tests\PSInfisicalAPI.Tests.csproj", "{A1B1F0E7-1111-4A3B-9F2A-000000000002}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {A1B1F0E7-1111-4A3B-9F2A-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A1B1F0E7-1111-4A3B-9F2A-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1B1F0E7-1111-4A3B-9F2A-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1B1F0E7-1111-4A3B-9F2A-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A1B1F0E7-1111-4A3B-9F2A-000000000002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A1B1F0E7-1111-4A3B-9F2A-000000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1B1F0E7-1111-4A3B-9F2A-000000000002}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1B1F0E7-1111-4A3B-9F2A-000000000002}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/build.ps1 b/build.ps1
new file mode 100644
index 0000000..1bc4ae9
--- /dev/null
+++ b/build.ps1
@@ -0,0 +1,284 @@
+[CmdletBinding()]
+param(
+ [ValidateSet('Debug', 'Release')]
+ [string]$Configuration = 'Release',
+
+ [switch]$Clean,
+
+ [switch]$Restore,
+
+ [switch]$RunTests,
+
+ [switch]$RunIntegrationTests,
+
+ [switch]$CreateRelease,
+
+ [switch]$CommitOnSuccess,
+
+ [switch]$Force
+)
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+
+$RepositoryRoot = [System.IO.DirectoryInfo]$PSScriptRoot
+$SrcRoot = [System.IO.DirectoryInfo][System.IO.Path]::Combine($RepositoryRoot.FullName, 'src')
+$ProjectFile = [System.IO.FileInfo][System.IO.Path]::Combine($SrcRoot.FullName, 'PSInfisicalAPI', 'PSInfisicalAPI.csproj')
+$TestsFile = [System.IO.FileInfo][System.IO.Path]::Combine($SrcRoot.FullName, 'PSInfisicalAPI.Tests', 'PSInfisicalAPI.Tests.csproj')
+$ModuleRoot = [System.IO.DirectoryInfo][System.IO.Path]::Combine($RepositoryRoot.FullName, 'Module', 'PSInfisicalAPI')
+$ModuleBinDir = [System.IO.DirectoryInfo][System.IO.Path]::Combine($ModuleRoot.FullName, 'bin')
+$ArtifactsDir = [System.IO.DirectoryInfo][System.IO.Path]::Combine($RepositoryRoot.FullName, 'Artifacts')
+$ReleasesDir = [System.IO.DirectoryInfo][System.IO.Path]::Combine($RepositoryRoot.FullName, 'Releases')
+$ChangelogFile = [System.IO.FileInfo][System.IO.Path]::Combine($RepositoryRoot.FullName, 'CHANGELOG.md')
+$ModuleGuid = 'b8a2f3d4-7c51-4d2f-9e6a-1f0c8b3d4e51'
+
+function Write-Step {
+ param([string]$Message)
+ Write-Host "==> $Message" -ForegroundColor Cyan
+}
+
+function Get-BuildVersion {
+ return (Get-Date).ToUniversalTime().ToString('yyyy.MM.dd.HHmm')
+}
+
+function Get-CommitHash {
+ try {
+ $hash = (& git rev-parse --short=12 HEAD 2>$null).Trim()
+ if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrEmpty($hash)) {
+ return 'unknown'
+ }
+
+ return $hash
+ } catch {
+ return 'unknown'
+ }
+}
+
+function Ensure-Directory {
+ param([System.IO.DirectoryInfo]$Directory)
+ if (-not $Directory.Exists) { [void]$Directory.Create() }
+}
+
+function Clear-Directory {
+ param([System.IO.DirectoryInfo]$Directory)
+ if ($Directory.Exists) {
+ Get-ChildItem -LiteralPath $Directory.FullName -Force | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
+ }
+}
+
+function Get-AssemblyVersion {
+ param([string]$BuildVersion)
+ $parts = $BuildVersion -split '\.'
+ if ($parts.Length -ne 4) { return '1.0.0.0' }
+ $year = [int]$parts[0]
+ $month = [int]$parts[1]
+ $day = [int]$parts[2]
+ $hhmm = [int]$parts[3]
+ $assemblyMinor = ($month * 100) + $day
+ return ("{0}.{1}.{2}.0" -f $year, $assemblyMinor, $hhmm)
+}
+
+function Write-Manifest {
+ param(
+ [System.IO.FileInfo]$Path,
+ [string]$ModuleVersion,
+ [string]$CommitHash
+ )
+
+ $content = @"
+@{
+ RootModule = 'PSInfisicalAPI.psm1'
+ ModuleVersion = '$ModuleVersion'
+ GUID = '$ModuleGuid'
+ Author = 'Alphaeus Mote'
+ CompanyName = ''
+ Copyright = '(c) Alphaeus Mote. All rights reserved.'
+ Description = 'PSInfisicalAPI is a C# binary PowerShell module for the Infisical REST API.'
+ PowerShellVersion = '5.1'
+ CompatiblePSEditions = @('Desktop','Core')
+ FunctionsToExport = @()
+ CmdletsToExport = @(
+ 'Connect-Infisical',
+ 'Disconnect-Infisical',
+ 'Get-InfisicalSecrets',
+ 'Get-InfisicalSecret',
+ 'ConvertTo-InfisicalSecretDictionary',
+ 'Export-InfisicalSecrets'
+ )
+ AliasesToExport = @()
+ VariablesToExport = @()
+ FormatsToProcess = @('PSInfisicalAPI.Format.ps1xml')
+ TypesToProcess = @('PSInfisicalAPI.Types.ps1xml')
+ PrivateData = @{
+ PSData = @{
+ Tags = @('Infisical','Secrets','API','SecureString')
+ ProjectUri = ''
+ ReleaseNotes = ''
+ CommitHash = '$CommitHash'
+ }
+ }
+}
+"@
+
+ [System.IO.File]::WriteAllText($Path.FullName, $content, [System.Text.UTF8Encoding]::new($false))
+}
+
+function Update-Changelog {
+ param([string]$Version, [string]$CommitHash)
+
+ if (-not $ChangelogFile.Exists) { return }
+ $marker = "## $Version"
+ $existing = Get-Content -LiteralPath $ChangelogFile.FullName -Raw
+ if ($existing -match [Regex]::Escape($marker)) { return }
+
+ $insertion = "## $Version`r`n`r`n- Build produced from commit $CommitHash.`r`n`r`n"
+ $updated = $existing -replace '## Unreleased', "## Unreleased`r`n`r`n$insertion## Unreleased (carried forward)"
+ [System.IO.File]::WriteAllText($ChangelogFile.FullName, $updated, [System.Text.UTF8Encoding]::new($false))
+}
+
+
+function Invoke-DotNet {
+ param([string[]]$Arguments)
+ Write-Step ("dotnet " + ($Arguments -join ' '))
+ & dotnet @Arguments
+ if ($LASTEXITCODE -ne 0) {
+ throw "dotnet command failed: $($Arguments -join ' ')"
+ }
+}
+
+function Test-ModuleImports {
+ param([System.IO.DirectoryInfo]$ModuleDirectory)
+ Write-Step "Validating module import"
+ $script = @"
+`$ErrorActionPreference = 'Stop'
+Import-Module -Name '$($ModuleDirectory.FullName)' -Force
+`$cmds = @('Connect-Infisical','Disconnect-Infisical','Get-InfisicalSecrets','Get-InfisicalSecret','ConvertTo-InfisicalSecretDictionary','Export-InfisicalSecrets')
+foreach (`$c in `$cmds) {
+ if (-not (Get-Command -Name `$c -Module PSInfisicalAPI -ErrorAction SilentlyContinue)) {
+ throw "Cmdlet not found: `$c"
+ }
+}
+"@
+
+ $tempFile = [System.IO.FileInfo][System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), [System.IO.Path]::GetRandomFileName() + '.ps1')
+ try {
+ [System.IO.File]::WriteAllText($tempFile.FullName, $script, [System.Text.UTF8Encoding]::new($false))
+ & pwsh -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $tempFile.FullName
+ if ($LASTEXITCODE -ne 0) { throw "Module import validation failed in pwsh." }
+ } finally {
+ if ($tempFile.Exists) { Remove-Item -LiteralPath $tempFile.FullName -Force -ErrorAction SilentlyContinue }
+ }
+}
+
+$buildVersion = Get-BuildVersion
+$assemblyVersion = Get-AssemblyVersion -BuildVersion $buildVersion
+$commitHash = Get-CommitHash
+
+Write-Step "Build version: $buildVersion"
+Write-Step "Assembly version: $assemblyVersion"
+Write-Step "Commit hash: $commitHash"
+
+Ensure-Directory -Directory $ArtifactsDir
+Ensure-Directory -Directory $ModuleRoot
+Ensure-Directory -Directory $ModuleBinDir
+
+if ($Clean.IsPresent) {
+ Write-Step "Cleaning generated outputs"
+ Clear-Directory -Directory $ModuleBinDir
+ Clear-Directory -Directory $ArtifactsDir
+ Get-ChildItem -LiteralPath $SrcRoot.FullName -Recurse -Directory -Force |
+ Where-Object { $_.Name -in @('bin','obj') } |
+ ForEach-Object { Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue }
+}
+
+if ($Restore.IsPresent) {
+ Invoke-DotNet -Arguments @('restore', $ProjectFile.FullName)
+ Invoke-DotNet -Arguments @('restore', $TestsFile.FullName)
+}
+
+Write-Step "Building $($ProjectFile.Name) ($Configuration)"
+$buildArgs = @(
+ 'build', $ProjectFile.FullName,
+ '-c', $Configuration,
+ '--nologo',
+ "-p:BuildVersion=$buildVersion",
+ "-p:BuildAssemblyVersion=$assemblyVersion",
+ "-p:BuildCommitHash=$commitHash"
+)
+Invoke-DotNet -Arguments $buildArgs
+
+if ($RunTests.IsPresent -or $RunIntegrationTests.IsPresent) {
+ Write-Step "Running tests"
+ $testFilter = if ($RunIntegrationTests.IsPresent) { 'Category!=NEVER' } else { 'Category!=Integration' }
+ $testArgs = @(
+ 'test', $TestsFile.FullName,
+ '-c', $Configuration,
+ '--nologo',
+ '--filter', $testFilter,
+ "-p:BuildVersion=$buildVersion",
+ "-p:BuildAssemblyVersion=$assemblyVersion",
+ "-p:BuildCommitHash=$commitHash"
+ )
+ Invoke-DotNet -Arguments $testArgs
+}
+
+Write-Step "Publishing module binaries"
+$publishOutput = [System.IO.DirectoryInfo][System.IO.Path]::Combine($ArtifactsDir.FullName, 'publish')
+Ensure-Directory -Directory $publishOutput
+Clear-Directory -Directory $publishOutput
+$publishArgs = @(
+ 'publish', $ProjectFile.FullName,
+ '-c', $Configuration,
+ '--nologo',
+ '-o', $publishOutput.FullName,
+ "-p:BuildVersion=$buildVersion",
+ "-p:BuildAssemblyVersion=$assemblyVersion",
+ "-p:BuildCommitHash=$commitHash"
+)
+Invoke-DotNet -Arguments $publishArgs
+
+Clear-Directory -Directory $ModuleBinDir
+$desiredAssemblies = @('PSInfisicalAPI.dll','Newtonsoft.Json.dll','YamlDotNet.dll')
+foreach ($assembly in $desiredAssemblies) {
+ $source = [System.IO.FileInfo][System.IO.Path]::Combine($publishOutput.FullName, $assembly)
+ if ($source.Exists) {
+ Copy-Item -LiteralPath $source.FullName -Destination $ModuleBinDir.FullName -Force
+ }
+}
+
+$manifestPath = [System.IO.FileInfo][System.IO.Path]::Combine($ModuleRoot.FullName, 'PSInfisicalAPI.psd1')
+Write-Manifest -Path $manifestPath -ModuleVersion $buildVersion -CommitHash $commitHash
+
+Update-Changelog -Version $buildVersion -CommitHash $commitHash
+
+try {
+ Test-ModuleImports -ModuleDirectory $ModuleRoot
+} catch {
+ Write-Warning "Module import validation reported: $($_.Exception.Message)"
+}
+
+if ($CreateRelease.IsPresent) {
+ $releaseDir = [System.IO.DirectoryInfo][System.IO.Path]::Combine($ReleasesDir.FullName, $buildVersion)
+ if ($releaseDir.Exists -and -not $Force.IsPresent) {
+ throw "Release '$buildVersion' already exists. Pass -Force to overwrite."
+ }
+
+ Ensure-Directory -Directory $ReleasesDir
+ if ($releaseDir.Exists) { Clear-Directory -Directory $releaseDir }
+ Ensure-Directory -Directory $releaseDir
+
+ $releaseModuleDir = [System.IO.DirectoryInfo][System.IO.Path]::Combine($releaseDir.FullName, 'PSInfisicalAPI')
+ Ensure-Directory -Directory $releaseModuleDir
+ Copy-Item -LiteralPath ([System.IO.Path]::Combine($ModuleRoot.FullName, '*')) -Destination $releaseModuleDir.FullName -Recurse -Force
+ Write-Step "Release created at $($releaseDir.FullName)"
+}
+
+if ($CommitOnSuccess.IsPresent) {
+ Write-Step "Committing on success"
+ & git add -A
+ if ($LASTEXITCODE -ne 0) { throw "git add failed." }
+ & git commit -m "Build $buildVersion"
+ if ($LASTEXITCODE -ne 0) { throw "git commit failed." }
+}
+
+Write-Step "Build complete."
diff --git a/docs/DesignSpec.md b/docs/DesignSpec.md
new file mode 100644
index 0000000..c7fa7af
--- /dev/null
+++ b/docs/DesignSpec.md
@@ -0,0 +1,1951 @@
+# PSInfisicalAPI Full Specification
+
+## 1. Project Summary
+
+`PSInfisicalAPI` is a C# binary PowerShell module for interacting with the Infisical REST API. It is inspired by `PSInfisical`, but it is **not** a PowerShell-only implementation. The module must be built as a compiled C# module targeting `.NET Standard 2.0` so it works in both Windows PowerShell 5.1 and PowerShell 7+.
+
+The goal is to establish a strong, reusable, secure framework first, then initially implement secret retrieval and export workflows.
+
+Initial public cmdlets:
+
+```powershell
+Connect-Infisical
+Disconnect-Infisical
+Get-InfisicalSecrets
+Get-InfisicalSecret
+ConvertTo-InfisicalSecretDictionary
+Export-InfisicalSecrets
+```
+
+Infisical’s public API is REST-based and provides programmatic access for managing secrets and related resources. Current Infisical documentation shows the list-secrets endpoint under `/api/v4/secrets`, the single-secret retrieval endpoint under `/api/v4/secrets/{secretName}`, and Universal Auth login under `/api/v1/auth/universal-auth/login`. The implementation must centralize API endpoint definitions because Infisical uses different API versions across resource families. ([Infisical Blog][1])
+
+---
+
+# 2. Non-Negotiable Requirements
+
+## 2.1 Runtime
+
+The module must target:
+
+```text
+.NET Standard 2.0
+PowerShellStandard.Library
+Windows PowerShell 5.1
+PowerShell 7+
+```
+
+## 2.2 No Async/Await
+
+The codebase must contain **no** usage of:
+
+```csharp
+async
+await
+```
+
+All HTTP calls, file writes, serialization, logging, and export operations must be synchronous.
+
+## 2.3 Centralized Reusable Logic
+
+No double implementation.
+
+The following must be centralized:
+
+```text
+Logging
+Error handling
+Endpoint definitions
+URI construction
+Query string construction
+HTTP request execution
+Authentication
+Response parsing
+Secret conversion
+SecureString handling
+Export formatting
+Path handling
+Version handling
+Build/release handling
+```
+
+No endpoint URL, API path, query construction logic, or authentication flow should be scattered across cmdlets.
+
+## 2.4 Secret Safety
+
+The module must prioritize limiting secret exposure in memory.
+
+Rules:
+
+```text
+Never log secret values.
+Never log client secrets.
+Never log access tokens.
+Never log Authorization headers.
+Never log raw request bodies that contain secrets.
+Never log raw API response bodies that contain secrets.
+Never expose plaintext secret values as public object properties.
+Never expose plaintext through ToString().
+Never expose raw API response JSON from public cmdlets.
+Convert secret values to SecureString as quickly as practical.
+Call MakeReadOnly() on SecureString values after population.
+Clear temporary API response objects as aggressively as practical.
+Clear temporary plaintext variables as aggressively as practical.
+```
+
+The module should document the unavoidable .NET limitation: once a secret exists as a managed `string`, that memory cannot be reliably zeroed. Therefore, the design goal is to avoid unnecessary copies, avoid logging, keep plaintext scope short, and move values into read-only `SecureString` objects as quickly as practical.
+
+## 2.5 No Export Warnings
+
+Do **not** emit warning messages for export operations.
+
+The user intentionally requested export support. The module should silently perform the export, while still ensuring exported values are never written to verbose/debug/error logs.
+
+This applies to:
+
+```text
+JSON
+YAML
+ENV
+XML
+EnvironmentVariables
+```
+
+---
+
+# 3. Repository Structure
+
+The repository must follow this structure:
+
+```text
+PSInfisicalAPI/
+├── Artifacts/
+├── Module/
+│ └── PSInfisicalAPI/
+│ ├── PSInfisicalAPI.psd1
+│ ├── PSInfisicalAPI.psm1
+│ ├── PSInfisicalAPI.Format.ps1xml
+│ ├── PSInfisicalAPI.Types.ps1xml
+│ └── bin/
+│ ├── PSInfisicalAPI.dll
+│ ├── Newtonsoft.Json.dll
+│ └── YamlDotNet.dll
+├── Releases/
+│ └── yyyy.MM.dd.HHmm/
+├── docs/
+│ ├── about_PSInfisicalAPI.help.txt
+│ ├── Connect-Infisical.md
+│ ├── Disconnect-Infisical.md
+│ ├── Get-InfisicalSecrets.md
+│ ├── Get-InfisicalSecret.md
+│ ├── ConvertTo-InfisicalSecretDictionary.md
+│ └── Export-InfisicalSecrets.md
+├── src/
+│ ├── PSInfisicalAPI/
+│ │ ├── Authentication/
+│ │ ├── Cmdlets/
+│ │ ├── Common/
+│ │ ├── Connections/
+│ │ ├── Endpoints/
+│ │ ├── Errors/
+│ │ ├── Exports/
+│ │ ├── Http/
+│ │ ├── Logging/
+│ │ ├── Models/
+│ │ ├── Secrets/
+│ │ ├── Security/
+│ │ └── Serialization/
+│ └── PSInfisicalAPI.Tests/
+├── build.ps1
+├── CHANGELOG.md
+└── README.md
+```
+
+Source starts under `/src`.
+
+Namespaces should follow responsibility and folder depth, for example:
+
+```text
+PSInfisicalAPI.Authentication
+PSInfisicalAPI.Cmdlets
+PSInfisicalAPI.Endpoints
+PSInfisicalAPI.Security
+PSInfisicalAPI.Serialization
+```
+
+---
+
+# 4. Module Files
+
+## 4.1 PSD1
+
+The manifest must be generated by the build script.
+
+Example shape:
+
+```powershell
+@{
+ RootModule = 'PSInfisicalAPI.psm1'
+ ModuleVersion = 'yyyy.MM.dd.HHmm'
+ GUID = ''
+ Author = 'Alphaeus Mote'
+ CompanyName = ''
+ Copyright = ''
+ PowerShellVersion = '5.1'
+ CompatiblePSEditions = @('Desktop', 'Core')
+ FunctionsToExport = @()
+ CmdletsToExport = @(
+ 'Connect-Infisical',
+ 'Disconnect-Infisical',
+ 'Get-InfisicalSecrets',
+ 'Get-InfisicalSecret',
+ 'ConvertTo-InfisicalSecretDictionary',
+ 'Export-InfisicalSecrets'
+ )
+ AliasesToExport = @()
+ PrivateData = @{
+ PSData = @{
+ Tags = @('Infisical', 'Secrets', 'API', 'SecureString')
+ ProjectUri = ''
+ ReleaseNotes = ''
+ CommitHash = ''
+ }
+ }
+}
+```
+
+## 4.2 PSM1
+
+The `.psm1` must be minimal and only load the binary module plus format/type data.
+
+```powershell
+$BinaryPath = [System.IO.FileInfo][System.IO.Path]::Combine($PSScriptRoot, 'bin', 'PSInfisicalAPI.dll')
+
+Import-Module -Name $BinaryPath.FullName
+
+$TypesPath = [System.IO.FileInfo][System.IO.Path]::Combine($PSScriptRoot, 'PSInfisicalAPI.Types.ps1xml')
+$FormatPath = [System.IO.FileInfo][System.IO.Path]::Combine($PSScriptRoot, 'PSInfisicalAPI.Format.ps1xml')
+
+if ([System.IO.File]::Exists($TypesPath.FullName)) {
+ Update-TypeData -PrependPath $TypesPath.FullName -ErrorAction SilentlyContinue
+}
+
+if ([System.IO.File]::Exists($FormatPath.FullName)) {
+ Update-FormatData -PrependPath $FormatPath.FullName -ErrorAction SilentlyContinue
+}
+```
+
+---
+
+# 5. Versioning
+
+Version format:
+
+```text
+yyyy.MM.dd.HHmm
+```
+
+Example:
+
+```text
+2026.06.02.2140
+```
+
+The version must be generated once per build and applied consistently to:
+
+```text
+PSD1 ModuleVersion
+AssemblyVersion
+AssemblyFileVersion
+AssemblyInformationalVersion
+Release folder
+CHANGELOG.md
+Generated docs if applicable
+```
+
+The git commit hash must be embedded separately:
+
+```text
+PSD1 PrivateData.PSData.CommitHash
+AssemblyMetadata("CommitHash", "")
+AssemblyInformationalVersion = "yyyy.MM.dd.HHmm"
+```
+
+---
+
+# 6. Build Script Specification
+
+The build script must be:
+
+```text
+Idempotent
+Repeatable
+Safe to run multiple times
+Responsible for versioning
+Responsible for manifest generation
+Responsible for release folder creation
+Responsible for module folder creation
+Responsible for copying binaries
+Responsible for optional commit-on-success
+```
+
+## 6.1 Build Script Parameters
+
+```powershell
+param(
+ [ValidateSet('Debug', 'Release')]
+ [string]$Configuration = 'Release',
+
+ [switch]$Clean,
+
+ [switch]$Restore,
+
+ [switch]$RunTests,
+
+ [switch]$RunIntegrationTests,
+
+ [switch]$CreateRelease,
+
+ [switch]$CommitOnSuccess,
+
+ [switch]$Force
+)
+```
+
+## 6.2 Required Behavior
+
+```text
+Create missing folders.
+Clean generated output when -Clean is used.
+Never delete source files.
+Generate version once.
+Read current git commit hash.
+Restore packages when requested.
+Build the C# project.
+Run unit tests when requested.
+Run integration tests only when explicitly requested.
+Generate PSD1.
+Generate PSM1 if missing or when -Force is used.
+Copy compiled DLLs to Module/PSInfisicalAPI/bin.
+Copy dependency DLLs to Module/PSInfisicalAPI/bin.
+Copy type and format files.
+Update CHANGELOG.md.
+Create Releases/yyyy.MM.dd.HHmm when requested.
+Overwrite same-version release only when -Force is used.
+Validate module import where possible.
+Commit only after successful build when -CommitOnSuccess is specified.
+Never commit failed builds.
+```
+
+## 6.3 Example Usage
+
+```powershell
+.\build.ps1 -Clean -Restore -RunTests -CreateRelease -CommitOnSuccess
+```
+
+```powershell
+.\build.ps1 -Clean -Restore -RunTests -RunIntegrationTests -CreateRelease
+```
+
+## 6.4 Periodic Build and Commit Rule
+
+After each logical milestone:
+
+```text
+Build.
+Test.
+If successful, commit.
+If failed, do not commit.
+```
+
+Suggested commit messages:
+
+```text
+Add module skeleton and build script
+Add centralized logging and error handling
+Add endpoint registry and URI builder
+Add connection manager and auth providers
+Add secret retrieval cmdlets
+Add secret export providers
+Add integration test support
+```
+
+---
+
+# 7. Test Instance Configuration
+
+Integration tests must read connection values from any scope environment variables with precendence of Process, User, Machine. First found wins.
+
+Target:
+
+```powershell
+[System.EnvironmentVariableTarget]::Machine
+```
+
+Required variables:
+
+```text
+CLOUDINIT_INFISICAL_APIURL
+CLOUDINIT_INFISICAL_ORGANIZATIONID
+CLOUDINIT_INFISICAL_PROJECTID
+CLOUDINIT_INFISICAL_ENVIRONMENT
+CLOUDINIT_INFISICAL_CLIENTID
+CLOUDINIT_INFISICAL_CLIENTSECRET
+```
+
+## 7.1 Integration Test Rules
+
+```text
+Integration tests must not run by default.
+Integration tests run only with -RunIntegrationTests.
+Missing test environment variables should skip integration tests or fail with a sanitized setup error.
+Do not log CLOUDINIT_INFISICAL_CLIENTSECRET.
+Do not log access tokens returned by Infisical.
+Convert CLOUDINIT_INFISICAL_CLIENTSECRET to SecureString immediately.
+Call MakeReadOnly() after SecureString population.
+Clear temporary string references as aggressively as practical.
+```
+
+Example internal loading pattern:
+
+```csharp
+string apiUrl = Environment.GetEnvironmentVariable("CLOUDINIT_INFISICAL_APIURL", EnvironmentVariableTarget.Machine);
+string organizationId = Environment.GetEnvironmentVariable("CLOUDINIT_INFISICAL_ORGANIZATIONID", EnvironmentVariableTarget.Machine);
+string projectId = Environment.GetEnvironmentVariable("CLOUDINIT_INFISICAL_PROJECTID", EnvironmentVariableTarget.Machine);
+string environment = Environment.GetEnvironmentVariable("CLOUDINIT_INFISICAL_ENVIRONMENT", EnvironmentVariableTarget.Machine);
+string clientId = Environment.GetEnvironmentVariable("CLOUDINIT_INFISICAL_CLIENTID", EnvironmentVariableTarget.Machine);
+string clientSecretPlainText = Environment.GetEnvironmentVariable("CLOUDINIT_INFISICAL_CLIENTSECRET", EnvironmentVariableTarget.Machine);
+
+SecureString clientSecret = SecureStringUtility.ToReadOnlySecureString(clientSecretPlainText);
+
+clientSecretPlainText = null;
+```
+
+---
+
+# 8. Logging Specification
+
+Logging must be centralized.
+
+Format:
+
+```text
+[UTC Timestamp] - [Level] - [Component] - Message
+```
+
+Example:
+
+```text
+[2026-06-02T21:44:22.1830000Z] - [Information] - [SecretsClient] - Attempting to retrieve Infisical secrets. Please Wait...
+[2026-06-02T21:44:22.9290000Z] - [Information] - [SecretsClient] - Infisical secrets retrieval was successful.
+[2026-06-02T21:44:22.9330000Z] - [Error] - [SecretsClient] - Infisical secrets retrieval failed.
+```
+
+## 8.1 Required Log Levels
+
+```text
+Information
+Verbose
+Debug
+Warning
+Error
+```
+
+Warning exists as a log level but must not be used for intentional export operations.
+
+## 8.2 Operation Logging
+
+For meaningful operations, log:
+
+```text
+Attempting to ...
+... was successful.
+... failed.
+```
+
+Use `Please Wait...` where appropriate.
+
+Examples:
+
+```text
+Attempting to authenticate to Infisical. Please Wait...
+Infisical authentication was successful.
+Infisical authentication failed.
+
+Attempting to retrieve Infisical secrets. Please Wait...
+Infisical secrets retrieval was successful.
+Infisical secrets retrieval failed.
+
+Attempting to export Infisical secrets to JSON. Please Wait...
+Infisical secrets export to JSON was successful.
+Infisical secrets export to JSON failed.
+```
+
+## 8.3 PowerShell Channels
+
+Logging should map to native PowerShell output channels:
+
+```text
+Verbose logs -> WriteVerbose
+Debug logs -> WriteDebug
+Warnings -> WriteWarning
+Errors -> WriteError
+```
+
+Operational logs should respect `-Verbose`.
+
+---
+
+# 9. Error Handling Specification
+
+All error handling must be centralized.
+
+Required types:
+
+```text
+InfisicalException
+InfisicalApiException
+InfisicalAuthenticationException
+InfisicalHttpException
+InfisicalSerializationException
+InfisicalExportException
+InfisicalConfigurationException
+InfisicalErrorDetails
+InfisicalErrorHandler
+```
+
+## 9.1 Error Details
+
+Errors should preserve:
+
+```text
+Component
+Operation
+Message
+Exception type
+Inner exception message
+HTTP status code
+HTTP reason phrase
+API error code when available
+Sanitized API error body when safe
+JSON line number when available
+JSON position when available
+Request endpoint key
+Request method
+```
+
+## 9.2 Error Logging
+
+When a failure occurs, log multiple sanitized details where possible:
+
+```text
+[UTC] - [Error] - [ErrorHandler] - Operation failed: RetrieveSecret
+[UTC] - [Error] - [ErrorHandler] - Error Component: SecretsClient
+[UTC] - [Error] - [ErrorHandler] - Error Message: The Infisical API returned Forbidden.
+[UTC] - [Error] - [ErrorHandler] - HTTP Status Code: 403
+[UTC] - [Error] - [ErrorHandler] - API Error Code:
+[UTC] - [Error] - [ErrorHandler] - Line:
+[UTC] - [Error] - [ErrorHandler] - Position:
+```
+
+## 9.3 PowerShell Error Records
+
+Cmdlets must emit proper `ErrorRecord` objects.
+
+Examples:
+
+```text
+CategoryInfo:
+ AuthenticationError
+ ConnectionError
+ InvalidData
+ InvalidOperation
+ PermissionDenied
+ ResourceUnavailable
+ WriteError
+```
+
+The real underlying error must bubble up, but sanitized to avoid exposing secrets.
+
+---
+
+# 10. Endpoint Registry
+
+All endpoint definitions must be centralized.
+
+No cmdlet may hard-code endpoint paths.
+
+## 10.1 Endpoint Definition Model
+
+```csharp
+public sealed class InfisicalEndpointDefinition
+{
+ public string Name { get; set; }
+ public string Resource { get; set; }
+ public string Version { get; set; }
+ public string Method { get; set; }
+ public string Template { get; set; }
+ public bool RequiresAuthorization { get; set; }
+ public bool ContainsSecretMaterialInRequest { get; set; }
+ public bool ContainsSecretMaterialInResponse { get; set; }
+}
+```
+
+## 10.2 Initial Endpoint Definitions
+
+```text
+UniversalAuthLogin:
+ Method: POST
+ Version: v1
+ Template: /api/v1/auth/universal-auth/login
+ RequiresAuthorization: false
+ ContainsSecretMaterialInRequest: true
+ ContainsSecretMaterialInResponse: true
+
+ListSecrets:
+ Method: GET
+ Version: v4
+ Template: /api/v4/secrets
+ RequiresAuthorization: true
+ ContainsSecretMaterialInRequest: false
+ ContainsSecretMaterialInResponse: true
+
+RetrieveSecret:
+ Method: GET
+ Version: v4
+ Template: /api/v4/secrets/{secretName}
+ RequiresAuthorization: true
+ ContainsSecretMaterialInRequest: false
+ ContainsSecretMaterialInResponse: true
+```
+
+Universal Auth uses a client ID and client secret to obtain an access token, and Infisical documents the login endpoint as `/api/v1/auth/universal-auth/login`. ([Infisical Blog][2])
+
+## 10.3 API Version Flexibility
+
+`Connect-Infisical` should accept:
+
+```powershell
+-ApiVersion
+```
+
+Default:
+
+```text
+v4
+```
+
+However, API version must not be assumed globally for every resource. The endpoint registry must allow each endpoint family to specify its own version.
+
+---
+
+# 11. URI and Path Handling
+
+## 11.1 URI Rules
+
+All URLs must use:
+
+```csharp
+System.Uri
+System.UriBuilder
+```
+
+URI construction must be centralized in:
+
+```text
+InfisicalUriBuilder
+```
+
+Responsibilities:
+
+```text
+Combine base URI and endpoint path.
+Escape path segments.
+Escape query parameters.
+Support repeated query parameters.
+Avoid manual string concatenation.
+Preserve scheme/host/port.
+Support Linux, macOS, and Windows.
+```
+
+## 11.2 Path Rules
+
+Internal filesystem paths must use:
+
+```csharp
+System.IO.FileInfo
+System.IO.DirectoryInfo
+System.IO.Path.Combine(...)
+```
+
+PowerShell build/helper scripts must use:
+
+```powershell
+[System.IO.FileInfo][System.IO.Path]::Combine(...)
+[System.IO.DirectoryInfo][System.IO.Path]::Combine(...)
+```
+
+Public command examples can use simple strings because PowerShell can bind strings to `FileInfo`, `DirectoryInfo`, and `Uri`.
+
+Public example:
+
+```powershell
+Export-InfisicalSecrets -Format Env -Path '.\secrets.env'
+```
+
+Internal implementation must still use proper typed path handling.
+
+---
+
+# 12. Authentication Design
+
+## 12.1 Supported Initial Auth Types
+
+Initial implementation:
+
+```text
+Universal Auth
+Token Auth
+```
+
+Infisical documents identity authentication modes such as Universal Auth and Token Auth for API access, and API interaction requires an access token. ([Infisical Blog][3])
+
+## 12.2 Future Auth Types
+
+Design must allow future support for:
+
+```text
+AWS Auth
+Azure Auth
+GCP Auth
+Kubernetes Auth
+OIDC Auth
+JWT Auth
+LDAP Auth
+TLS Certificate Auth
+Alibaba Cloud Auth
+OCI Auth
+```
+
+These should not be exposed as public parameter sets until actually implemented.
+
+## 12.3 Auth Provider Interface
+
+```csharp
+public interface IInfisicalAuthProvider
+{
+ string Name { get; }
+
+ InfisicalAuthenticationResult Authenticate(InfisicalAuthenticationRequest request, IInfisicalHttpClient httpClient, IInfisicalLogger logger);
+}
+```
+
+## 12.4 Authentication Result
+
+```csharp
+public sealed class InfisicalAuthenticationResult
+{
+ public SecureString AccessToken { get; set; }
+ public DateTimeOffset? ExpiresAtUtc { get; set; }
+ public string TokenType { get; set; }
+}
+```
+
+`AccessToken` must be read-only.
+
+---
+
+# 13. Connection Management
+
+The module must maintain a process-level current connection.
+
+## 13.1 Session Manager
+
+```csharp
+public static class InfisicalSessionManager
+{
+ public static InfisicalConnection Current { get; }
+
+ public static void SetCurrent(InfisicalConnection connection);
+
+ public static InfisicalConnection RequireCurrent();
+
+ public static void Disconnect();
+}
+```
+
+## 13.2 Connection Model
+
+```csharp
+public sealed class InfisicalConnection
+{
+ public Uri BaseUri { get; set; }
+ public string ApiVersion { get; set; }
+ public InfisicalAuthType AuthType { get; set; }
+ public string OrganizationId { get; set; }
+ public string ProjectId { get; set; }
+ public string Environment { get; set; }
+ public string DefaultSecretPath { get; set; }
+ public DateTimeOffset ConnectedAtUtc { get; set; }
+ public DateTimeOffset? ExpiresAtUtc { get; set; }
+ public bool IsConnected { get; set; }
+
+ internal SecureString AccessToken { get; set; }
+}
+```
+
+The public object must not display or serialize `AccessToken`.
+
+---
+
+# 14. Public Cmdlet Specifications
+
+# 14.1 Connect-Infisical
+
+## Purpose
+
+Authenticate to Infisical and store the current connection.
+
+## Approved Verb
+
+```text
+Connect
+```
+
+## Parameter Sets
+
+### Universal Auth
+
+```powershell
+Connect-Infisical `
+ -BaseUri `
+ -OrganizationId `
+ -ProjectId `
+ -Environment `
+ -ClientId `
+ -ClientSecret `
+ [-SecretPath ] `
+ [-ApiVersion ] `
+ [-PassThru]
+```
+
+### Token Auth
+
+```powershell
+Connect-Infisical `
+ -BaseUri `
+ -OrganizationId `
+ -ProjectId `
+ -Environment `
+ -AccessToken `
+ [-SecretPath ] `
+ [-ApiVersion ] `
+ [-PassThru]
+```
+
+## Defaults
+
+```text
+SecretPath: /
+ApiVersion: v4
+```
+
+## Behavior
+
+```text
+Validate BaseUri.
+Validate ProjectId.
+Validate Environment.
+Validate OrganizationId when provided.
+Validate ApiVersion.
+Authenticate if using Universal Auth.
+Store returned access token internally.
+Make access token SecureString read-only.
+Create InfisicalConnection.
+Store connection in InfisicalSessionManager.
+Return connection only when -PassThru is used.
+```
+
+## Example
+
+```powershell
+$ClientSecret = Read-Host -Prompt 'Client Secret' -AsSecureString
+
+Connect-Infisical `
+ -BaseUri 'https://app.infisical.com' `
+ -OrganizationId 'organization-id' `
+ -ProjectId 'project-id' `
+ -Environment 'prod' `
+ -ClientId 'client-id' `
+ -ClientSecret $ClientSecret `
+ -SecretPath '/' `
+ -Verbose
+```
+
+## Token Example
+
+```powershell
+$Token = Read-Host -Prompt 'Access Token' -AsSecureString
+
+Connect-Infisical `
+ -BaseUri 'https://app.infisical.com' `
+ -OrganizationId 'organization-id' `
+ -ProjectId 'project-id' `
+ -Environment 'prod' `
+ -AccessToken $Token
+```
+
+---
+
+# 14.2 Disconnect-Infisical
+
+## Purpose
+
+Disconnect the current Infisical session.
+
+## Approved Verb
+
+```text
+Disconnect
+```
+
+## Parameters
+
+```powershell
+Disconnect-Infisical [-PassThru]
+```
+
+## Behavior
+
+```text
+Clear current connection.
+Dispose/clear token references where practical.
+Clear cached authentication metadata.
+Return nothing by default.
+Return disconnected status object when -PassThru is used.
+```
+
+## Example
+
+```powershell
+Disconnect-Infisical -Verbose
+```
+
+---
+
+# 14.3 Get-InfisicalSecrets
+
+## Purpose
+
+Retrieve a list of secrets.
+
+The Infisical list secrets endpoint supports listing from a base path and can recursively fetch subdirectories up to the documented depth limit. It also supports values such as `viewSecretValue`, `expandSecretReferences`, and `recursive`. ([Infisical Blog][4])
+
+## Approved Verb
+
+```text
+Get
+```
+
+## Parameters
+
+```powershell
+Get-InfisicalSecrets `
+ [-ProjectId ] `
+ [-Environment ] `
+ [-SecretPath ] `
+ [-Recursive] `
+ [-IncludeImports ] `
+ [-IncludePersonalOverrides] `
+ [-ExpandSecretReferences ] `
+ [-ViewSecretValue ] `
+ [-MetadataFilter ] `
+ [-TagSlugs ]
+```
+
+## Defaults
+
+```text
+ProjectId: Current connection ProjectId
+Environment: Current connection Environment
+SecretPath: Current connection DefaultSecretPath or /
+Recursive: false
+IncludeImports: true
+ExpandSecretReferences: true
+ViewSecretValue: true
+```
+
+## Behavior
+
+```text
+Require active connection.
+Use explicit ProjectId/Environment/SecretPath when supplied.
+Use connection defaults otherwise.
+Build URI centrally.
+Call ListSecrets endpoint.
+Parse response into typed models.
+Convert secretValue to read-only SecureString immediately.
+Clear temporary response models where practical.
+Return InfisicalSecret objects.
+```
+
+## Example
+
+```powershell
+Get-InfisicalSecrets -SecretPath '/production/web' -Recursive -Verbose
+```
+
+---
+
+# 14.4 Get-InfisicalSecret
+
+## Purpose
+
+Retrieve a single secret by name.
+
+Infisical documents the retrieve-secret endpoint as `/api/v4/secrets/{secretName}`. ([Infisical Blog][5])
+
+## Approved Verb
+
+```text
+Get
+```
+
+## Parameters
+
+```powershell
+Get-InfisicalSecret `
+ -SecretName `
+ [-ProjectId ] `
+ [-Environment ] `
+ [-SecretPath ] `
+ [-Version ] `
+ [-Type ] `
+ [-ViewSecretValue ] `
+ [-ExpandSecretReferences ] `
+ [-IncludeImports ]
+```
+
+## Parameter Attributes
+
+```text
+SecretName:
+ Mandatory
+ ValueFromPipelineByPropertyName
+```
+
+## Defaults
+
+```text
+ProjectId: Current connection ProjectId
+Environment: Current connection Environment
+SecretPath: Current connection DefaultSecretPath or /
+Type: Shared
+ViewSecretValue: true
+ExpandSecretReferences: true
+IncludeImports: true
+```
+
+## Behavior
+
+```text
+Require active connection.
+Escape SecretName as path segment.
+Build query centrally.
+Call RetrieveSecret endpoint.
+Parse response into typed model.
+Convert secretValue to read-only SecureString immediately.
+Return one InfisicalSecret object.
+```
+
+## Example
+
+```powershell
+Get-InfisicalSecret -SecretName 'SqlPassword' -SecretPath '/production/sql'
+```
+
+Pipeline example:
+
+```powershell
+[pscustomobject]@{ SecretName = 'SqlPassword' } | Get-InfisicalSecret -SecretPath '/production/sql'
+```
+
+---
+
+# 14.5 ConvertTo-InfisicalSecretDictionary
+
+## Purpose
+
+Convert one or more `InfisicalSecret` objects to a case-insensitive dictionary.
+
+## Approved Verb
+
+```text
+ConvertTo
+```
+
+## Parameters
+
+```powershell
+ConvertTo-InfisicalSecretDictionary `
+ -InputObject `
+ [-DuplicateKeyBehavior ]
+```
+
+## Parameter Attributes
+
+```text
+InputObject:
+ Mandatory
+ ValueFromPipeline
+```
+
+## Default
+
+```text
+DuplicateKeyBehavior: Error
+```
+
+## Return Type
+
+```csharp
+Dictionary
+```
+
+Comparer:
+
+```csharp
+StringComparer.OrdinalIgnoreCase
+```
+
+## Behavior
+
+```text
+Key = SecretName.
+Value = SecretValue as SecureString.
+Do not convert values to plaintext.
+Throw on duplicate keys by default.
+Support FirstWins and LastWins when explicitly selected.
+```
+
+## Example
+
+```powershell
+$Secrets = Get-InfisicalSecrets -SecretPath '/production'
+$Dictionary = $Secrets | ConvertTo-InfisicalSecretDictionary
+```
+
+---
+
+# 14.6 Export-InfisicalSecrets
+
+## Purpose
+
+Export secrets to:
+
+```text
+JSON
+YAML
+ENV
+XML
+EnvironmentVariables
+```
+
+## Approved Verb
+
+```text
+Export
+```
+
+## Parameters
+
+```powershell
+Export-InfisicalSecrets `
+ -InputObject `
+ -Format `
+ [-Path ] `
+ [-Scope ] `
+ [-Force] `
+ [-Encoding ]
+```
+
+## Parameter Rules
+
+```text
+InputObject:
+ Mandatory
+ ValueFromPipeline
+
+Format:
+ Mandatory
+
+Path:
+ Mandatory for Json, Yaml, Env, Xml
+ Not required for EnvironmentVariables
+
+Scope:
+ Only used for EnvironmentVariables
+ Default: Process
+
+Encoding:
+ Default: UTF8
+```
+
+## Behavior
+
+```text
+Collect pipeline input.
+Use centralized exporter provider.
+Create Path.Directory automatically when missing.
+Use FileInfo.Directory directly.
+Do not emit warning messages.
+Do not log exported values.
+Do not write secret plaintext to verbose output.
+Use InfisicalSecret.UsePlainTextValue() for scoped plaintext conversion.
+```
+
+## Example
+
+```powershell
+Get-InfisicalSecrets -SecretPath '/production' | Export-InfisicalSecrets -Format Json -Path '.\secrets.json'
+```
+
+```powershell
+Get-InfisicalSecrets -SecretPath '/production' | Export-InfisicalSecrets -Format Env -Path '.\secrets.env'
+```
+
+```powershell
+Get-InfisicalSecrets -SecretPath '/production' | Export-InfisicalSecrets -Format EnvironmentVariables -Scope Process
+```
+
+---
+
+# 15. Output Models
+
+## 15.1 InfisicalSecret
+
+```csharp
+public sealed class InfisicalSecret
+{
+ public string Id { get; set; }
+ public string InternalId { get; set; }
+ public string Workspace { get; set; }
+ public string Environment { get; set; }
+ public int? Version { get; set; }
+ public InfisicalSecretType Type { get; set; }
+ public string SecretName { get; set; }
+ public SecureString SecretValue { get; set; }
+ public bool SecretValueHidden { get; set; }
+ public string SecretPath { get; set; }
+ public string SecretComment { get; set; }
+ public DateTimeOffset? CreatedAtUtc { get; set; }
+ public DateTimeOffset? UpdatedAtUtc { get; set; }
+ public bool IsRotatedSecret { get; set; }
+ public Guid? RotationId { get; set; }
+ public InfisicalSecretTag[] Tags { get; set; }
+ public InfisicalSecretMetadata[] SecretMetadata { get; set; }
+
+ public T UsePlainTextValue(Func action)
+ {
+ return SecureStringUtility.UsePlainText(SecretValue, action);
+ }
+
+ public void UsePlainTextValue(Action action)
+ {
+ SecureStringUtility.UsePlainText(SecretValue, plainText =>
+ {
+ action(plainText);
+ return true;
+ });
+ }
+
+ public override string ToString()
+ {
+ return SecretName;
+ }
+}
+```
+
+## 15.2 Rules
+
+```text
+No PlainTextValue property.
+No public getter that returns plaintext.
+No secret value in ToString().
+No secret value in default display formatting.
+No secret value in logs.
+Plaintext access only through scoped method.
+Exporters must use UsePlainTextValue().
+```
+
+## 15.3 InfisicalSecretMetadata
+
+```csharp
+public sealed class InfisicalSecretMetadata
+{
+ public string Key { get; set; }
+ public string Value { get; set; }
+}
+```
+
+Only unencrypted metadata should be exported.
+
+## 15.4 InfisicalSecretTag
+
+```csharp
+public sealed class InfisicalSecretTag
+{
+ public string Id { get; set; }
+ public string Slug { get; set; }
+ public string Name { get; set; }
+ public string Color { get; set; }
+}
+```
+
+## 15.5 Enums
+
+```csharp
+public enum InfisicalSecretType
+{
+ Shared,
+ Personal
+}
+```
+
+```csharp
+public enum InfisicalExportFormat
+{
+ Json,
+ Yaml,
+ Env,
+ Xml,
+ EnvironmentVariables
+}
+```
+
+```csharp
+public enum InfisicalDuplicateKeyBehavior
+{
+ Error,
+ FirstWins,
+ LastWins
+}
+```
+
+---
+
+# 16. Export Format Specifications
+
+## 16.1 ENV
+
+Format:
+
+```text
+Key=Value
+```
+
+Example:
+
+```text
+SqlServer=192.168.1.10
+SqlUser=appuser
+SqlPassword=ExamplePassword
+```
+
+## 16.2 XML
+
+Required schema:
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+## 16.3 JSON
+
+JSON should be an array of secret objects.
+
+Example:
+
+```json
+[
+ {
+ "SecretName": "production",
+ "SecretValue": "192.168.1.10",
+ "SecretPath": "/servers",
+ "SecretMetadata": {
+ "Owner": "Infrastructure"
+ }
+ },
+ {
+ "SecretName": "staging",
+ "SecretValue": "192.168.1.12",
+ "SecretPath": "/servers",
+ "SecretMetadata": {
+ "Owner": "Infrastructure"
+ }
+ }
+]
+```
+
+## 16.4 YAML
+
+Required shape:
+
+```yaml
+Secrets:
+ - SecretName: production
+ SecretValue: 192.168.1.10
+ SecretPath: /servers
+ SecretMetadata:
+ Owner: Infrastructure
+ - SecretName: staging
+ SecretValue: 192.168.1.12
+ SecretPath: /servers
+ SecretMetadata:
+ Owner: Infrastructure
+```
+
+## 16.5 EnvironmentVariables
+
+Use:
+
+```csharp
+Environment.SetEnvironmentVariable(name, value, target);
+```
+
+Supported scopes:
+
+```text
+Process
+User
+Machine
+```
+
+Default:
+
+```text
+Process
+```
+
+No warnings should be emitted.
+
+---
+
+# 17. SecureString Utility
+
+Required utility:
+
+```csharp
+public static class SecureStringUtility
+{
+ public static SecureString ToReadOnlySecureString(string value)
+ {
+ SecureString secureString = new SecureString();
+
+ if (!string.IsNullOrEmpty(value))
+ {
+ foreach (char character in value)
+ {
+ secureString.AppendChar(character);
+ }
+ }
+
+ secureString.MakeReadOnly();
+
+ return secureString;
+ }
+
+ public static T UsePlainText(SecureString secureString, Func action)
+ {
+ if (secureString == null)
+ {
+ throw new ArgumentNullException(nameof(secureString));
+ }
+
+ if (action == null)
+ {
+ throw new ArgumentNullException(nameof(action));
+ }
+
+ IntPtr pointer = IntPtr.Zero;
+
+ try
+ {
+ pointer = Marshal.SecureStringToBSTR(secureString);
+ string plainText = Marshal.PtrToStringBSTR(pointer);
+ return action(plainText);
+ }
+ finally
+ {
+ if (pointer != IntPtr.Zero)
+ {
+ Marshal.ZeroFreeBSTR(pointer);
+ }
+ }
+ }
+}
+```
+
+Rules:
+
+```text
+Use SecureString for credentials.
+Use SecureString for secret output values.
+Call MakeReadOnly() after population.
+Do not reuse mutable SecureString values unless necessary.
+Do not expose SecureString internals.
+Do not log conversion failures with secret content.
+```
+
+---
+
+# 18. HTTP Client Design
+
+## 18.1 Interface
+
+```csharp
+public interface IInfisicalHttpClient
+{
+ InfisicalHttpResponse Send(InfisicalHttpRequest request);
+}
+```
+
+## 18.2 Request Model
+
+```csharp
+public sealed class InfisicalHttpRequest
+{
+ public string OperationName { get; set; }
+ public string EndpointName { get; set; }
+ public string Method { get; set; }
+ public Uri Uri { get; set; }
+ public Dictionary Headers { get; set; }
+ public string Body { get; set; }
+ public bool ContainsSecretMaterialInRequest { get; set; }
+ public bool ContainsSecretMaterialInResponse { get; set; }
+}
+```
+
+## 18.3 Response Model
+
+```csharp
+public sealed class InfisicalHttpResponse
+{
+ public int StatusCode { get; set; }
+ public string ReasonPhrase { get; set; }
+ public string Body { get; set; }
+ public Dictionary Headers { get; set; }
+
+ public void Clear()
+ {
+ Body = null;
+ Headers?.Clear();
+ }
+}
+```
+
+## 18.4 Rules
+
+```text
+Use synchronous request execution.
+Do not log Authorization header.
+Do not log request body when ContainsSecretMaterialInRequest is true.
+Do not log response body when ContainsSecretMaterialInResponse is true.
+Clear response body as soon as parsed.
+```
+
+---
+
+# 19. Serialization Design
+
+Required serializers:
+
+```text
+IInfisicalSerializer
+JsonInfisicalSerializer
+YamlInfisicalSerializer
+XmlInfisicalSerializer
+EnvInfisicalSerializer
+EnvironmentVariableExporter
+```
+
+Recommended packages:
+
+```text
+Newtonsoft.Json
+YamlDotNet
+```
+
+Responsibilities:
+
+```text
+Serialize strongly typed export models.
+Deserialize API responses.
+Preserve line/position details where available.
+Avoid logging raw secret-bearing data.
+Clear temporary DTOs where practical.
+```
+
+---
+
+# 20. DTO Design
+
+API DTOs should be separate from public output models.
+
+Example:
+
+```text
+InfisicalSecretResponseDto
+InfisicalSecretListResponseDto
+InfisicalUniversalAuthLoginRequestDto
+InfisicalUniversalAuthLoginResponseDto
+```
+
+Rules:
+
+```text
+DTOs may temporarily hold plaintext from API responses.
+DTOs must not be returned publicly.
+DTOs should be cleared/released after mapping.
+Mapping must convert secretValue to read-only SecureString immediately.
+```
+
+---
+
+# 21. Mapping Design
+
+Required mapper:
+
+```text
+InfisicalSecretMapper
+```
+
+Responsibilities:
+
+```text
+Map secretKey to SecretName.
+Map secretValue to SecureString.
+Map secretPath.
+Map environment.
+Map metadata.
+Map tags.
+Map timestamps to UTC DateTimeOffset.
+Clear DTO secretValue after mapping where practical.
+```
+
+Example mapping behavior:
+
+```text
+DTO.secretKey -> InfisicalSecret.SecretName
+DTO.secretValue -> InfisicalSecret.SecretValue
+DTO.secretPath -> InfisicalSecret.SecretPath
+DTO.secretMetadata -> InfisicalSecret.SecretMetadata
+```
+
+---
+
+# 22. Documentation Requirements
+
+Each public cmdlet must have help and examples.
+
+Docs required:
+
+```text
+about_PSInfisicalAPI.help.txt
+Connect-Infisical.md
+Disconnect-Infisical.md
+Get-InfisicalSecrets.md
+Get-InfisicalSecret.md
+ConvertTo-InfisicalSecretDictionary.md
+Export-InfisicalSecrets.md
+```
+
+Each cmdlet help page must include:
+
+```text
+Synopsis
+Description
+Parameters
+Inputs
+Outputs
+Examples
+Notes
+```
+
+Public examples should be clean and natural.
+
+Example:
+
+```powershell
+Connect-Infisical -BaseUri 'https://app.infisical.com' -OrganizationId 'org-id' -ProjectId 'project-id' -Environment 'prod' -ClientId 'client-id' -ClientSecret $ClientSecret
+```
+
+Do not force examples to show internal `FileInfo` or `Path.Combine` usage.
+
+---
+
+# 23. Type and Format Data
+
+The module should include formatting so secrets display safely.
+
+Default view for `InfisicalSecret`:
+
+```text
+SecretName
+SecretPath
+Environment
+Type
+Version
+UpdatedAtUtc
+SecretValueHidden
+```
+
+Do not display:
+
+```text
+SecretValue
+AccessToken
+ClientSecret
+RawApiResponse
+```
+
+---
+
+# 24. Testing Requirements
+
+## 24.1 Unit Tests
+
+Minimum tests:
+
+```text
+Build script is idempotent.
+Manifest version matches assembly version.
+Manifest commit hash exists.
+PSM1 imports binary from bin folder.
+Endpoint registry returns ListSecrets endpoint.
+Endpoint registry returns RetrieveSecret endpoint.
+Endpoint registry returns UniversalAuthLogin endpoint.
+URI builder escapes query parameters.
+URI builder escapes secret name path segment.
+Logger does not log secret values.
+Logger formats UTC timestamp correctly.
+Error handler preserves HTTP status code.
+Error handler sanitizes secret-bearing response content.
+SecureStringUtility creates read-only SecureString.
+InfisicalSecret.ToString() returns SecretName only.
+InfisicalSecret.UsePlainTextValue() scopes plaintext access.
+Get-InfisicalSecrets maps secretKey to SecretName.
+Get-InfisicalSecrets maps secretValue to SecureString.
+ConvertTo-InfisicalSecretDictionary uses OrdinalIgnoreCase.
+ConvertTo-InfisicalSecretDictionary throws on duplicate by default.
+Export JSON creates directory automatically.
+Export YAML creates directory automatically.
+Export XML matches required schema.
+Export ENV writes Key=Value.
+EnvironmentVariables export defaults to Process.
+No warning emitted during export.
+```
+
+## 24.2 Integration Tests
+
+Integration tests require:
+
+```text
+CLOUDINIT_INFISICAL_APIURL
+CLOUDINIT_INFISICAL_ORGANIZATIONID
+CLOUDINIT_INFISICAL_PROJECTID
+CLOUDINIT_INFISICAL_ENVIRONMENT
+CLOUDINIT_INFISICAL_CLIENTID
+CLOUDINIT_INFISICAL_CLIENTSECRET
+```
+
+Integration tests must verify:
+
+```text
+Connect-Infisical works with Universal Auth.
+Get-InfisicalSecrets returns typed objects.
+Get-InfisicalSecret returns one typed object.
+SecretValue is SecureString.
+SecretValue is read-only.
+Export formats complete without warning.
+Disconnect-Infisical clears current session.
+```
+
+---
+
+# 25. Implementation Milestones
+
+## Milestone 1: Skeleton
+
+```text
+Repository structure
+C# project
+Test project
+Initial build.ps1
+Initial PSD1 generation
+Initial PSM1 generation
+Version generation
+Commit hash embedding
+CHANGELOG.md
+```
+
+## Milestone 2: Core Infrastructure
+
+```text
+Central logger
+Central error types
+Central error handler
+SecureString utility
+Path utility
+Endpoint registry
+URI builder
+Sanitizer
+```
+
+## Milestone 3: HTTP and Serialization
+
+```text
+Synchronous HTTP client
+HTTP request/response models
+JSON serializer
+YAML serializer
+XML serializer
+ENV serializer
+Response clearing behavior
+```
+
+## Milestone 4: Connection and Auth
+
+```text
+Connection model
+Session manager
+Auth provider interface
+Universal Auth provider
+Token Auth provider
+Connect-Infisical
+Disconnect-Infisical
+```
+
+## Milestone 5: Secrets
+
+```text
+Secret DTOs
+Secret output models
+Secret mapper
+Get-InfisicalSecrets
+Get-InfisicalSecret
+Safe formatting
+```
+
+## Milestone 6: Conversion and Export
+
+```text
+ConvertTo-InfisicalSecretDictionary
+JSON export
+YAML export
+XML export
+ENV export
+EnvironmentVariables export
+No-warning export behavior
+```
+
+## Milestone 7: Tests and Docs
+
+```text
+Unit tests
+Integration test harness
+External help
+README examples
+CHANGELOG update
+Import validation in Windows PowerShell 5.1
+Import validation in PowerShell 7+
+```
+
+---
+
+# 26. Acceptance Criteria
+
+The project is acceptable only when all of these are true:
+
+```text
+The module is named PSInfisicalAPI.
+The module is C# based.
+The module targets .NET Standard 2.0.
+The module works in Windows PowerShell 5.1.
+The module works in PowerShell 7+.
+No async keyword exists in source.
+No await keyword exists in source.
+All public cmdlets use approved verbs.
+All public cmdlets have help.
+All public cmdlets have examples.
+Connect-Infisical supports Universal Auth.
+Connect-Infisical supports Token Auth.
+Disconnect-Infisical clears the current connection.
+Other cmdlets automatically use the current connection.
+Endpoint paths are centralized.
+URI building is centralized.
+Query construction is centralized.
+All URLs use System.Uri or UriBuilder.
+All internal paths use FileInfo, DirectoryInfo, and Path.Combine.
+Logging is centralized.
+Logging uses [UTC Timestamp] - [Level] - [Component] - Message.
+Operations log before and after.
+Failures log sanitized error detail.
+Secret values are never logged.
+Client secrets are never logged.
+Access tokens are never logged.
+Authorization headers are never logged.
+Secret API response bodies are never logged.
+Public cmdlets never return raw API responses.
+Secret values are stored as SecureString.
+SecureString values are made read-only where practical.
+Secret object has scoped plaintext conversion method.
+Secret object has no plaintext property.
+Exporters use scoped plaintext conversion.
+Export operations do not emit warning messages.
+Export JSON works.
+Export YAML works.
+Export ENV works.
+Export XML works.
+Export EnvironmentVariables works.
+EnvironmentVariables export supports Process, User, and Machine.
+EnvironmentVariables export defaults to Process.
+Export path uses FileInfo.
+Export creates missing directories.
+ConvertTo-InfisicalSecretDictionary returns case-insensitive dictionary.
+Duplicate dictionary keys error by default.
+Version format is yyyy.MM.dd.HHmm.
+Commit hash is embedded.
+Build script is idempotent.
+Build script generates manifest.
+Build script copies binaries to Module/PSInfisicalAPI/bin.
+Build script creates release folders.
+Build script can run unit tests.
+Build script can run integration tests only when explicitly requested.
+Integration tests read machine-scope CLOUDINIT_INFISICAL_* variables.
+Successful milestones are committed when -CommitOnSuccess is used.
+Failed builds are never committed.
+```
+
+---
+
+# 27. Final Design Principle
+
+`PSInfisicalAPI` should not be a one-off secrets script wrapped in a module. It should be a reusable, strongly typed, secure framework for Infisical API interaction, with secrets as the first supported resource family and future support for certificates, identities, projects, folders, imports, rotations, and other Infisical API areas added cleanly through new endpoint definitions, models, services, and cmdlets.
+
+[1]: https://infisical.com/docs/api-reference/overview/introduction?utm_source=chatgpt.com "API Reference"
+[2]: https://infisical.com/docs/documentation/platform/identities/universal-auth?utm_source=chatgpt.com "Universal Auth"
+[3]: https://infisical.com/docs/api-reference/overview/authentication?utm_source=chatgpt.com "Authentication"
+[4]: https://infisical.com/docs/api-reference/endpoints/secrets/list?utm_source=chatgpt.com "List secrets"
+[5]: https://infisical.com/docs/api-reference/endpoints/secrets/read?utm_source=chatgpt.com "Retrieve"
\ No newline at end of file
diff --git a/src/PSInfisicalAPI.Tests/EndpointRegistryTests.cs b/src/PSInfisicalAPI.Tests/EndpointRegistryTests.cs
new file mode 100644
index 0000000..ccd6d61
--- /dev/null
+++ b/src/PSInfisicalAPI.Tests/EndpointRegistryTests.cs
@@ -0,0 +1,49 @@
+using PSInfisicalAPI.Endpoints;
+using PSInfisicalAPI.Errors;
+using Xunit;
+
+namespace PSInfisicalAPI.Tests
+{
+ public class EndpointRegistryTests
+ {
+ [Fact]
+ public void Get_ListSecrets_Returns_Definition()
+ {
+ InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(InfisicalEndpointNames.ListSecrets);
+ Assert.Equal("GET", definition.Method);
+ Assert.Equal("v4", definition.Version);
+ Assert.Equal("/api/v4/secrets", definition.Template);
+ Assert.True(definition.RequiresAuthorization);
+ Assert.False(definition.ContainsSecretMaterialInRequest);
+ Assert.True(definition.ContainsSecretMaterialInResponse);
+ }
+
+ [Fact]
+ public void Get_RetrieveSecret_Returns_Definition()
+ {
+ InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(InfisicalEndpointNames.RetrieveSecret);
+ Assert.Equal("GET", definition.Method);
+ Assert.Equal("v4", definition.Version);
+ Assert.Equal("/api/v4/secrets/{secretName}", definition.Template);
+ Assert.True(definition.RequiresAuthorization);
+ }
+
+ [Fact]
+ public void Get_UniversalAuthLogin_Returns_Definition()
+ {
+ InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(InfisicalEndpointNames.UniversalAuthLogin);
+ Assert.Equal("POST", definition.Method);
+ Assert.Equal("v1", definition.Version);
+ Assert.Equal("/api/v1/auth/universal-auth/login", definition.Template);
+ Assert.False(definition.RequiresAuthorization);
+ Assert.True(definition.ContainsSecretMaterialInRequest);
+ Assert.True(definition.ContainsSecretMaterialInResponse);
+ }
+
+ [Fact]
+ public void Get_Unknown_Endpoint_Throws()
+ {
+ Assert.Throws(() => InfisicalEndpointRegistry.Get("NotARealEndpoint"));
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI.Tests/ErrorHandlerTests.cs b/src/PSInfisicalAPI.Tests/ErrorHandlerTests.cs
new file mode 100644
index 0000000..e3f6499
--- /dev/null
+++ b/src/PSInfisicalAPI.Tests/ErrorHandlerTests.cs
@@ -0,0 +1,63 @@
+using PSInfisicalAPI.Errors;
+using Xunit;
+
+namespace PSInfisicalAPI.Tests
+{
+ public class ErrorHandlerTests
+ {
+ [Fact]
+ public void BuildDetails_Preserves_Http_Status_Code()
+ {
+ InfisicalApiException exception = new InfisicalApiException("Forbidden")
+ {
+ StatusCode = 403,
+ ReasonPhrase = "Forbidden",
+ EndpointName = "ListSecrets",
+ RequestMethod = "GET",
+ ApiErrorCode = "PERMISSION_DENIED",
+ SanitizedBody = "[REDACTED]"
+ };
+
+ InfisicalErrorDetails details = InfisicalErrorHandler.BuildDetails("SecretsClient", "RetrieveSecrets", exception);
+
+ Assert.Equal(403, details.StatusCode);
+ Assert.Equal("Forbidden", details.ReasonPhrase);
+ Assert.Equal("PERMISSION_DENIED", details.ApiErrorCode);
+ Assert.Equal("ListSecrets", details.EndpointName);
+ Assert.Equal("[REDACTED]", details.SanitizedBody);
+ Assert.Equal("SecretsClient", details.Component);
+ Assert.Equal("RetrieveSecrets", details.Operation);
+ }
+
+ [Fact]
+ public void BuildDetails_Preserves_Serialization_Line_Information()
+ {
+ InfisicalSerializationException exception = new InfisicalSerializationException("Bad JSON")
+ {
+ LineNumber = 12,
+ LinePosition = 34
+ };
+
+ InfisicalErrorDetails details = InfisicalErrorHandler.BuildDetails("Serializer", "Deserialize", exception);
+
+ Assert.Equal(12, details.LineNumber);
+ Assert.Equal(34, details.LinePosition);
+ }
+
+ [Fact]
+ public void MapCategory_Maps_Auth_Exception()
+ {
+ System.Management.Automation.ErrorCategory category = InfisicalErrorHandler.MapCategory(new InfisicalAuthenticationException("Bad creds"));
+ Assert.Equal(System.Management.Automation.ErrorCategory.AuthenticationError, category);
+ }
+
+ [Fact]
+ public void MapCategory_Maps_Api_Exception_By_Status_Code()
+ {
+ Assert.Equal(System.Management.Automation.ErrorCategory.AuthenticationError, InfisicalErrorHandler.MapCategory(new InfisicalApiException("x") { StatusCode = 401 }));
+ Assert.Equal(System.Management.Automation.ErrorCategory.PermissionDenied, InfisicalErrorHandler.MapCategory(new InfisicalApiException("x") { StatusCode = 403 }));
+ Assert.Equal(System.Management.Automation.ErrorCategory.ObjectNotFound, InfisicalErrorHandler.MapCategory(new InfisicalApiException("x") { StatusCode = 404 }));
+ Assert.Equal(System.Management.Automation.ErrorCategory.ResourceUnavailable, InfisicalErrorHandler.MapCategory(new InfisicalApiException("x") { StatusCode = 503 }));
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI.Tests/ExportTests.cs b/src/PSInfisicalAPI.Tests/ExportTests.cs
new file mode 100644
index 0000000..dd5be96
--- /dev/null
+++ b/src/PSInfisicalAPI.Tests/ExportTests.cs
@@ -0,0 +1,154 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using PSInfisicalAPI.Exports;
+using PSInfisicalAPI.Models;
+using PSInfisicalAPI.Security;
+using Xunit;
+
+namespace PSInfisicalAPI.Tests
+{
+ public class ExportTests : IDisposable
+ {
+ private readonly DirectoryInfo _tempDirectory;
+
+ public ExportTests()
+ {
+ string root = Path.Combine(Path.GetTempPath(), "PSInfisicalAPI.Tests.Export-" + Guid.NewGuid().ToString("N"));
+ _tempDirectory = new DirectoryInfo(root);
+ _tempDirectory.Create();
+ }
+
+ public void Dispose()
+ {
+ if (_tempDirectory.Exists)
+ {
+ _tempDirectory.Delete(true);
+ }
+ }
+
+ private static InfisicalSecret[] SampleSecrets()
+ {
+ return new[]
+ {
+ new InfisicalSecret
+ {
+ SecretName = "SqlServer",
+ SecretPath = "/servers",
+ SecretValue = SecureStringUtility.ToReadOnlySecureString("192.168.1.10"),
+ SecretMetadata = new[] { new InfisicalSecretMetadata { Key = "Owner", Value = "Infrastructure" } }
+ },
+ new InfisicalSecret
+ {
+ SecretName = "SqlPassword",
+ SecretPath = "/servers",
+ SecretValue = SecureStringUtility.ToReadOnlySecureString("ExamplePassword")
+ }
+ };
+ }
+
+ [Fact]
+ public void Export_Env_Writes_Key_Equals_Value()
+ {
+ FileInfo path = new FileInfo(Path.Combine(_tempDirectory.FullName, "missing", "out.env"));
+
+ new EnvInfisicalExporter().Export(new InfisicalExportRequest
+ {
+ Secrets = SampleSecrets(),
+ Format = InfisicalExportFormat.Env,
+ Path = path,
+ Encoding = new UTF8Encoding(false)
+ });
+
+ Assert.True(path.Exists || File.Exists(path.FullName));
+ string contents = File.ReadAllText(path.FullName);
+ Assert.Contains("SqlServer=192.168.1.10", contents);
+ Assert.Contains("SqlPassword=ExamplePassword", contents);
+ }
+
+ [Fact]
+ public void Export_Json_Creates_Directory()
+ {
+ FileInfo path = new FileInfo(Path.Combine(_tempDirectory.FullName, "deep", "nested", "out.json"));
+ new JsonInfisicalExporter().Export(new InfisicalExportRequest
+ {
+ Secrets = SampleSecrets(),
+ Format = InfisicalExportFormat.Json,
+ Path = path
+ });
+
+ Assert.True(path.Directory.Exists);
+ string contents = File.ReadAllText(path.FullName);
+ Assert.Contains("SqlServer", contents);
+ Assert.Contains("192.168.1.10", contents);
+ }
+
+ [Fact]
+ public void Export_Yaml_Creates_Directory()
+ {
+ FileInfo path = new FileInfo(Path.Combine(_tempDirectory.FullName, "yaml", "out.yaml"));
+ new YamlInfisicalExporter().Export(new InfisicalExportRequest
+ {
+ Secrets = SampleSecrets(),
+ Format = InfisicalExportFormat.Yaml,
+ Path = path
+ });
+
+ Assert.True(path.Directory.Exists);
+ string contents = File.ReadAllText(path.FullName);
+ Assert.Contains("Secrets:", contents);
+ Assert.Contains("SqlServer", contents);
+ }
+
+ [Fact]
+ public void Export_Xml_Matches_Schema()
+ {
+ FileInfo path = new FileInfo(Path.Combine(_tempDirectory.FullName, "xml", "out.xml"));
+ new XmlInfisicalExporter().Export(new InfisicalExportRequest
+ {
+ Secrets = SampleSecrets(),
+ Format = InfisicalExportFormat.Xml,
+ Path = path
+ });
+
+ string contents = File.ReadAllText(path.FullName);
+ Assert.Contains("", contents);
+ Assert.Contains("", contents);
+ Assert.Contains("SqlServer", contents);
+ Assert.Contains("192.168.1.10", contents);
+ Assert.Contains("", contents);
+ Assert.Contains("", contents);
+ }
+
+ [Fact]
+ public void Export_EnvironmentVariables_Defaults_To_Process()
+ {
+ string name = "PSInFI_TestVar_" + Guid.NewGuid().ToString("N");
+ InfisicalSecret[] secrets = new[]
+ {
+ new InfisicalSecret
+ {
+ SecretName = name,
+ SecretValue = SecureStringUtility.ToReadOnlySecureString("processed")
+ }
+ };
+
+ try
+ {
+ new EnvironmentVariableExporter().Export(new InfisicalExportRequest
+ {
+ Secrets = secrets,
+ Format = InfisicalExportFormat.EnvironmentVariables,
+ Scope = EnvironmentVariableTarget.Process
+ });
+
+ Assert.Equal("processed", Environment.GetEnvironmentVariable(name, EnvironmentVariableTarget.Process));
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(name, null, EnvironmentVariableTarget.Process);
+ }
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI.Tests/InfisicalEnvironmentPatternTests.cs b/src/PSInfisicalAPI.Tests/InfisicalEnvironmentPatternTests.cs
new file mode 100644
index 0000000..1bedc43
--- /dev/null
+++ b/src/PSInfisicalAPI.Tests/InfisicalEnvironmentPatternTests.cs
@@ -0,0 +1,129 @@
+using System.Text.RegularExpressions;
+using PSInfisicalAPI.Authentication;
+using Xunit;
+
+namespace PSInfisicalAPI.Tests
+{
+ public class InfisicalEnvironmentPatternTests
+ {
+ [Theory]
+ [InlineData("INFISICAL_API_URL")]
+ [InlineData("INFISICAL_BASE_URL")]
+ [InlineData("INFISICAL_BASE_URI")]
+ [InlineData("INFISICAL_HOST")]
+ [InlineData("CLOUDINIT_INFISICAL_APIURL")]
+ public void BaseUriPatterns_Match_Expected_Names(string name)
+ {
+ Assert.True(MatchesAny(name, InfisicalEnvironmentResolver.BaseUriPatterns), "Expected match for " + name);
+ }
+
+ [Theory]
+ [InlineData("INFISICAL_ORG_ID")]
+ [InlineData("INFISICAL_ORGANIZATION_ID")]
+ [InlineData("CLOUDINIT_INFISICAL_ORGANIZATIONID")]
+ public void OrganizationIdPatterns_Match_Expected_Names(string name)
+ {
+ Assert.True(MatchesAny(name, InfisicalEnvironmentResolver.OrganizationIdPatterns), "Expected match for " + name);
+ }
+
+ [Theory]
+ [InlineData("INFISICAL_PROJECT_ID")]
+ [InlineData("INFISICAL_WORKSPACE_ID")]
+ [InlineData("CLOUDINIT_INFISICAL_PROJECTID")]
+ public void ProjectIdPatterns_Match_Expected_Names(string name)
+ {
+ Assert.True(MatchesAny(name, InfisicalEnvironmentResolver.ProjectIdPatterns), "Expected match for " + name);
+ }
+
+ [Theory]
+ [InlineData("INFISICAL_ENVIRONMENT")]
+ [InlineData("INFISICAL_ENVIRONMENT_NAME")]
+ [InlineData("INFISICAL_ENV")]
+ [InlineData("INFISICAL_ENV_SLUG")]
+ [InlineData("CLOUDINIT_INFISICAL_ENVIRONMENT")]
+ public void EnvironmentPatterns_Match_Expected_Names(string name)
+ {
+ Assert.True(MatchesAny(name, InfisicalEnvironmentResolver.EnvironmentPatterns), "Expected match for " + name);
+ }
+
+ [Theory]
+ [InlineData("INFISICAL_CLIENT_ID")]
+ [InlineData("INFISICAL_UNIVERSAL_AUTH_CLIENT_ID")]
+ [InlineData("INFISICAL_MACHINE_IDENTITY_CLIENT_ID")]
+ [InlineData("CLOUDINIT_INFISICAL_CLIENTID")]
+ [InlineData("myapp_infisical_client_id")]
+ public void ClientIdPatterns_Match_Standard_And_Custom_Prefixed_Names(string name)
+ {
+ Assert.True(MatchesAny(name, InfisicalEnvironmentResolver.ClientIdPatterns), "Expected match for " + name);
+ }
+
+ [Theory]
+ [InlineData("INFISICAL_CLIENT_SECRET")]
+ [InlineData("INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET")]
+ [InlineData("INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET")]
+ [InlineData("CLOUDINIT_INFISICAL_CLIENTSECRET")]
+ [InlineData("myapp_infisical_client_secret")]
+ public void ClientSecretPatterns_Match_Standard_And_Custom_Prefixed_Names(string name)
+ {
+ Assert.True(MatchesAny(name, InfisicalEnvironmentResolver.ClientSecretPatterns), "Expected match for " + name);
+ }
+
+ [Theory]
+ [InlineData("INFISICAL_TOKEN")]
+ [InlineData("INFISICAL_ACCESS_TOKEN")]
+ [InlineData("INFISICAL_AUTH_TOKEN")]
+ [InlineData("CLOUDINIT_INFISICAL_TOKEN")]
+ public void AccessTokenPatterns_Match_Expected_Names(string name)
+ {
+ Assert.True(MatchesAny(name, InfisicalEnvironmentResolver.AccessTokenPatterns), "Expected match for " + name);
+ }
+
+ [Theory]
+ [InlineData("INFISICAL_SECRET_PATH")]
+ [InlineData("INFISICAL_DEFAULT_SECRET_PATH")]
+ [InlineData("CLOUDINIT_INFISICAL_SECRETPATH")]
+ public void SecretPathPatterns_Match_Expected_Names(string name)
+ {
+ Assert.True(MatchesAny(name, InfisicalEnvironmentResolver.SecretPathPatterns), "Expected match for " + name);
+ }
+
+ [Theory]
+ [InlineData("INFISICAL_SECRET_PATH")]
+ [InlineData("INFISICAL_DEFAULT_SECRET_PATH")]
+ [InlineData("CLOUDINIT_INFISICAL_SECRETPATH")]
+ public void ClientSecretPatterns_Do_Not_Match_SecretPath_Variables(string name)
+ {
+ Assert.False(MatchesAny(name, InfisicalEnvironmentResolver.ClientSecretPatterns), "ClientSecretPatterns should NOT match " + name);
+ }
+
+ [Theory]
+ [InlineData("PATH")]
+ [InlineData("USERNAME")]
+ [InlineData("HOME")]
+ [InlineData("PROCESSOR_ARCHITECTURE")]
+ public void Patterns_Do_Not_Match_Unrelated_System_Variables(string name)
+ {
+ Assert.False(MatchesAny(name, InfisicalEnvironmentResolver.ClientIdPatterns));
+ Assert.False(MatchesAny(name, InfisicalEnvironmentResolver.ClientSecretPatterns));
+ Assert.False(MatchesAny(name, InfisicalEnvironmentResolver.AccessTokenPatterns));
+ Assert.False(MatchesAny(name, InfisicalEnvironmentResolver.BaseUriPatterns));
+ Assert.False(MatchesAny(name, InfisicalEnvironmentResolver.OrganizationIdPatterns));
+ Assert.False(MatchesAny(name, InfisicalEnvironmentResolver.ProjectIdPatterns));
+ Assert.False(MatchesAny(name, InfisicalEnvironmentResolver.EnvironmentPatterns));
+ Assert.False(MatchesAny(name, InfisicalEnvironmentResolver.SecretPathPatterns));
+ Assert.False(MatchesAny(name, InfisicalEnvironmentResolver.ApiVersionPatterns));
+ }
+
+ private static bool MatchesAny(string input, Regex[] patterns)
+ {
+ for (int i = 0; i < patterns.Length; i++)
+ {
+ if (patterns[i].IsMatch(input))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI.Tests/InfisicalEnvironmentResolverTests.cs b/src/PSInfisicalAPI.Tests/InfisicalEnvironmentResolverTests.cs
new file mode 100644
index 0000000..3b4eae3
--- /dev/null
+++ b/src/PSInfisicalAPI.Tests/InfisicalEnvironmentResolverTests.cs
@@ -0,0 +1,117 @@
+using System;
+using System.Collections.Generic;
+using System.Security;
+using System.Text.RegularExpressions;
+using PSInfisicalAPI.Authentication;
+using PSInfisicalAPI.Logging;
+using PSInfisicalAPI.Security;
+using Xunit;
+
+namespace PSInfisicalAPI.Tests
+{
+ public class InfisicalEnvironmentResolverTests : IDisposable
+ {
+ private const string TestPrefix = "PSINFITESTRESOLVER";
+ private readonly List _createdVariables = new List();
+ private readonly string _uniqueSuffix = Guid.NewGuid().ToString("N").ToUpperInvariant();
+
+ private string SetProcessVar(string token, string value)
+ {
+ string name = TestPrefix + "_" + token + "_" + _uniqueSuffix;
+ Environment.SetEnvironmentVariable(name, value, EnvironmentVariableTarget.Process);
+ _createdVariables.Add(name);
+ return name;
+ }
+
+ private Regex[] PatternsForThisTest(string tokenWithinName)
+ {
+ return new[]
+ {
+ new Regex("^" + TestPrefix + "_.*" + tokenWithinName + ".*" + _uniqueSuffix + "$", RegexOptions.IgnoreCase)
+ };
+ }
+
+ public void Dispose()
+ {
+ for (int i = 0; i < _createdVariables.Count; i++)
+ {
+ Environment.SetEnvironmentVariable(_createdVariables[i], null, EnvironmentVariableTarget.Process);
+ }
+ }
+
+ [Fact]
+ public void Resolve_Returns_NotFound_When_No_Pattern_Matches()
+ {
+ Regex[] patterns = new[] { new Regex("WILL_NEVER_MATCH_" + Guid.NewGuid().ToString("N"), RegexOptions.IgnoreCase) };
+ InfisicalEnvironmentResolver.ResolutionResult result = InfisicalEnvironmentResolver.Resolve(patterns);
+ Assert.False(result.Found);
+ }
+
+ [Fact]
+ public void Resolve_Returns_First_Matching_Variable_With_Value()
+ {
+ string name = SetProcessVar("CLIENTID", "client-1234");
+ InfisicalEnvironmentResolver.ResolutionResult result = InfisicalEnvironmentResolver.Resolve(PatternsForThisTest("CLIENTID"));
+
+ Assert.True(result.Found);
+ Assert.Equal("client-1234", result.Value);
+ Assert.Equal(name, result.VariableName);
+ Assert.Equal(EnvironmentVariableTarget.Process, result.Scope);
+ }
+
+ [Fact]
+ public void Resolve_Skips_Blank_Whitespace_Values()
+ {
+ SetProcessVar("CLIENTID_BLANK", " ");
+ string realName = SetProcessVar("CLIENTID_REAL", "client-xyz");
+
+ Regex[] patterns = new[]
+ {
+ new Regex("^" + TestPrefix + "_CLIENTID_(BLANK|REAL)_" + _uniqueSuffix + "$", RegexOptions.IgnoreCase)
+ };
+
+ InfisicalEnvironmentResolver.ResolutionResult result = InfisicalEnvironmentResolver.Resolve(patterns);
+
+ Assert.True(result.Found);
+ Assert.Equal("client-xyz", result.Value);
+ Assert.Equal(realName, result.VariableName);
+ }
+
+ [Fact]
+ public void ResolveString_Returns_Current_Value_When_Already_Set()
+ {
+ SetProcessVar("PROJECTID_OVERRIDE", "env-project");
+ string resolved = InfisicalEnvironmentResolver.ResolveString("ProjectId", PatternsForThisTest("PROJECTID"), "explicit-project", NullInfisicalLogger.Instance);
+ Assert.Equal("explicit-project", resolved);
+ }
+
+ [Fact]
+ public void ResolveString_Resolves_When_Current_Value_Is_Whitespace()
+ {
+ SetProcessVar("PROJECTID_FALLBACK", "env-project-123");
+ string resolved = InfisicalEnvironmentResolver.ResolveString("ProjectId", PatternsForThisTest("PROJECTID"), " ", NullInfisicalLogger.Instance);
+ Assert.Equal("env-project-123", resolved);
+ }
+
+ [Fact]
+ public void ResolveSecureString_Builds_ReadOnly_SecureString_From_Env()
+ {
+ SetProcessVar("ACCESSTOKEN_FOR_TEST", "tok-9999");
+ SecureString resolved = InfisicalEnvironmentResolver.ResolveSecureString("AccessToken", PatternsForThisTest("ACCESSTOKEN"), null, NullInfisicalLogger.Instance);
+
+ Assert.NotNull(resolved);
+ Assert.True(resolved.IsReadOnly());
+ string plain = SecureStringUtility.UsePlainText(resolved, p => p);
+ Assert.Equal("tok-9999", plain);
+ }
+
+ [Fact]
+ public void ResolveSecureString_Keeps_Existing_Populated_SecureString()
+ {
+ SetProcessVar("ACCESSTOKEN_IGNORE", "ignored");
+ SecureString existing = SecureStringUtility.ToReadOnlySecureString("explicit-token");
+ SecureString resolved = InfisicalEnvironmentResolver.ResolveSecureString("AccessToken", PatternsForThisTest("ACCESSTOKEN"), existing, NullInfisicalLogger.Instance);
+ Assert.Same(existing, resolved);
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI.Tests/InfisicalSecretTests.cs b/src/PSInfisicalAPI.Tests/InfisicalSecretTests.cs
new file mode 100644
index 0000000..6c66847
--- /dev/null
+++ b/src/PSInfisicalAPI.Tests/InfisicalSecretTests.cs
@@ -0,0 +1,50 @@
+using System.Reflection;
+using PSInfisicalAPI.Models;
+using PSInfisicalAPI.Security;
+using Xunit;
+
+namespace PSInfisicalAPI.Tests
+{
+ public class InfisicalSecretTests
+ {
+ [Fact]
+ public void ToString_Returns_SecretName_Only()
+ {
+ InfisicalSecret secret = new InfisicalSecret
+ {
+ SecretName = "MySecret",
+ SecretValue = SecureStringUtility.ToReadOnlySecureString("super-secret")
+ };
+
+ Assert.Equal("MySecret", secret.ToString());
+ Assert.DoesNotContain("super-secret", secret.ToString());
+ }
+
+ [Fact]
+ public void UsePlainTextValue_Scopes_Plaintext_Access()
+ {
+ InfisicalSecret secret = new InfisicalSecret
+ {
+ SecretName = "DbPassword",
+ SecretValue = SecureStringUtility.ToReadOnlySecureString("p@ssw0rd")
+ };
+
+ string observed = secret.UsePlainTextValue(value => value);
+ Assert.Equal("p@ssw0rd", observed);
+ }
+
+ [Fact]
+ public void Has_No_PlainText_Property()
+ {
+ PropertyInfo[] properties = typeof(InfisicalSecret).GetProperties();
+ foreach (PropertyInfo property in properties)
+ {
+ Assert.False(string.Equals(property.Name, "PlainTextValue", System.StringComparison.OrdinalIgnoreCase));
+ if (property.Name == "SecretValue")
+ {
+ Assert.Equal(typeof(System.Security.SecureString), property.PropertyType);
+ }
+ }
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI.Tests/LoggingTests.cs b/src/PSInfisicalAPI.Tests/LoggingTests.cs
new file mode 100644
index 0000000..403f8ba
--- /dev/null
+++ b/src/PSInfisicalAPI.Tests/LoggingTests.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Text.RegularExpressions;
+using PSInfisicalAPI.Logging;
+using Xunit;
+
+namespace PSInfisicalAPI.Tests
+{
+ public class LoggingTests
+ {
+ [Fact]
+ public void Format_Uses_Utc_Timestamp_And_Component()
+ {
+ DateTimeOffset utc = new DateTimeOffset(2026, 6, 2, 21, 44, 22, TimeSpan.Zero).AddTicks(1830000);
+ string result = InfisicalLogFormatter.Format(utc, InfisicalLogLevel.Information, "SecretsClient", "Attempting to retrieve Infisical secrets. Please Wait...");
+
+ Assert.Equal("[2026-06-02T21:44:22.1830000Z] - [Information] - [SecretsClient] - Attempting to retrieve Infisical secrets. Please Wait...", result);
+ }
+
+ [Fact]
+ public void Format_Includes_All_Levels()
+ {
+ DateTimeOffset utc = DateTimeOffset.UtcNow;
+ foreach (InfisicalLogLevel level in Enum.GetValues(typeof(InfisicalLogLevel)))
+ {
+ string result = InfisicalLogFormatter.Format(utc, level, "Component", "Message");
+ Assert.Matches(@"^\[[0-9TZ:\.\-]+\] - \[" + level + @"\] - \[Component\] - Message$", result);
+ }
+ }
+
+ [Fact]
+ public void NullLogger_Accepts_Any_Calls()
+ {
+ IInfisicalLogger logger = NullInfisicalLogger.Instance;
+ logger.Information("c", "m");
+ logger.Verbose("c", "m");
+ logger.Debug("c", "m");
+ logger.Warning("c", "m");
+ logger.Error("c", "m");
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI.Tests/ManifestTests.cs b/src/PSInfisicalAPI.Tests/ManifestTests.cs
new file mode 100644
index 0000000..b5e0960
--- /dev/null
+++ b/src/PSInfisicalAPI.Tests/ManifestTests.cs
@@ -0,0 +1,59 @@
+using System.IO;
+using System.Reflection;
+using Xunit;
+
+namespace PSInfisicalAPI.Tests
+{
+ public class ManifestTests
+ {
+ private static DirectoryInfo RepositoryRoot()
+ {
+ DirectoryInfo current = new DirectoryInfo(System.AppContext.BaseDirectory);
+ while (current != null)
+ {
+ if (File.Exists(Path.Combine(current.FullName, "PSInfisicalAPI.sln")))
+ {
+ return current;
+ }
+
+ current = current.Parent;
+ }
+
+ return null;
+ }
+
+ [Fact]
+ public void Assembly_Has_CommitHash_Metadata()
+ {
+ AssemblyMetadataAttribute[] attributes = (AssemblyMetadataAttribute[])typeof(PSInfisicalAPI.Connections.InfisicalConnection)
+ .Assembly
+ .GetCustomAttributes(typeof(AssemblyMetadataAttribute), false);
+
+ bool found = false;
+ foreach (AssemblyMetadataAttribute attribute in attributes)
+ {
+ if (attribute.Key == "CommitHash")
+ {
+ found = true;
+ Assert.False(string.IsNullOrEmpty(attribute.Value));
+ }
+ }
+
+ Assert.True(found, "Assembly must contain a CommitHash metadata attribute.");
+ }
+
+ [Fact]
+ public void Psm1_Imports_Binary_From_Bin_Folder()
+ {
+ DirectoryInfo root = RepositoryRoot();
+ Assert.NotNull(root);
+ string psm1Path = Path.Combine(root.FullName, "Module", "PSInfisicalAPI", "PSInfisicalAPI.psm1");
+ Assert.True(File.Exists(psm1Path));
+
+ string content = File.ReadAllText(psm1Path);
+ Assert.Contains("Import-Module", content);
+ Assert.Contains("'bin'", content);
+ Assert.Contains("PSInfisicalAPI.dll", content);
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI.Tests/PSInfisicalAPI.Tests.csproj b/src/PSInfisicalAPI.Tests/PSInfisicalAPI.Tests.csproj
new file mode 100644
index 0000000..80f740d
--- /dev/null
+++ b/src/PSInfisicalAPI.Tests/PSInfisicalAPI.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net8.0
+ 9.0
+ false
+ PSInfisicalAPI.Tests
+ PSInfisicalAPI.Tests
+ $(NoWarn);CS1591;NU1701;NU1903
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PSInfisicalAPI.Tests/SanitizerTests.cs b/src/PSInfisicalAPI.Tests/SanitizerTests.cs
new file mode 100644
index 0000000..2668d87
--- /dev/null
+++ b/src/PSInfisicalAPI.Tests/SanitizerTests.cs
@@ -0,0 +1,53 @@
+using System.Collections.Generic;
+using PSInfisicalAPI.Common;
+using Xunit;
+
+namespace PSInfisicalAPI.Tests
+{
+ public class SanitizerTests
+ {
+ [Fact]
+ public void Sanitize_Body_Redacts_When_Contains_Secrets()
+ {
+ string body = "{\"secretValue\":\"abc\"}";
+ string sanitized = InfisicalSanitizer.SanitizeBody(body, true);
+ Assert.Equal("[REDACTED]", sanitized);
+ }
+
+ [Fact]
+ public void Sanitize_Body_Truncates_Long_NonSecret_Body()
+ {
+ string body = new string('a', 4096);
+ string sanitized = InfisicalSanitizer.SanitizeBody(body, false);
+ Assert.Contains("[truncated]", sanitized);
+ }
+
+ [Fact]
+ public void Sanitize_Header_Redacts_Authorization()
+ {
+ string sanitized = InfisicalSanitizer.SanitizeHeaderValue("Authorization", "Bearer abc.def");
+ Assert.Equal("[REDACTED]", sanitized);
+ }
+
+ [Fact]
+ public void Sanitize_Header_Passes_Through_Non_Sensitive()
+ {
+ string sanitized = InfisicalSanitizer.SanitizeHeaderValue("Accept", "application/json");
+ Assert.Equal("application/json", sanitized);
+ }
+
+ [Fact]
+ public void Sanitize_Headers_Returns_New_Map()
+ {
+ Dictionary headers = new Dictionary
+ {
+ { "Authorization", "Bearer token" },
+ { "Accept", "application/json" }
+ };
+
+ IDictionary sanitized = InfisicalSanitizer.SanitizeHeaders(headers);
+ Assert.Equal("[REDACTED]", sanitized["Authorization"]);
+ Assert.Equal("application/json", sanitized["Accept"]);
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI.Tests/SecretMapperTests.cs b/src/PSInfisicalAPI.Tests/SecretMapperTests.cs
new file mode 100644
index 0000000..25525bb
--- /dev/null
+++ b/src/PSInfisicalAPI.Tests/SecretMapperTests.cs
@@ -0,0 +1,38 @@
+using System.Reflection;
+using PSInfisicalAPI.Models;
+using PSInfisicalAPI.Security;
+using Xunit;
+
+namespace PSInfisicalAPI.Tests
+{
+ public class SecretMapperTests
+ {
+ [Fact]
+ public void Mapper_Maps_SecretKey_To_SecretName_And_SecretValue_To_SecureString()
+ {
+ System.Type mapperType = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly
+ .GetType("PSInfisicalAPI.Secrets.InfisicalSecretMapper", true);
+ System.Type dtoType = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly
+ .GetType("PSInfisicalAPI.Secrets.InfisicalSecretResponseDto", true);
+
+ object dto = System.Activator.CreateInstance(dtoType);
+ dtoType.GetProperty("SecretKey").SetValue(dto, "DatabasePassword");
+ dtoType.GetProperty("SecretValue").SetValue(dto, "Sup3rSecret!");
+ dtoType.GetProperty("SecretPath").SetValue(dto, "/prod/db");
+ dtoType.GetProperty("Environment").SetValue(dto, "prod");
+ dtoType.GetProperty("Type").SetValue(dto, "shared");
+
+ MethodInfo mapMethod = mapperType.GetMethod("Map", BindingFlags.Public | BindingFlags.Static);
+ InfisicalSecret secret = (InfisicalSecret)mapMethod.Invoke(null, new[] { dto });
+
+ Assert.Equal("DatabasePassword", secret.SecretName);
+ Assert.NotNull(secret.SecretValue);
+ Assert.True(secret.SecretValue.IsReadOnly());
+ Assert.Equal("/prod/db", secret.SecretPath);
+ Assert.Equal(InfisicalSecretType.Shared, secret.Type);
+
+ string roundtripped = SecureStringUtility.UsePlainText(secret.SecretValue, plain => plain);
+ Assert.Equal("Sup3rSecret!", roundtripped);
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI.Tests/SecureStringUtilityTests.cs b/src/PSInfisicalAPI.Tests/SecureStringUtilityTests.cs
new file mode 100644
index 0000000..bc6f2ac
--- /dev/null
+++ b/src/PSInfisicalAPI.Tests/SecureStringUtilityTests.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Security;
+using PSInfisicalAPI.Security;
+using Xunit;
+
+namespace PSInfisicalAPI.Tests
+{
+ public class SecureStringUtilityTests
+ {
+ [Fact]
+ public void ToReadOnlySecureString_Returns_ReadOnly_Instance()
+ {
+ SecureString secure = SecureStringUtility.ToReadOnlySecureString("hello");
+ Assert.True(secure.IsReadOnly());
+ Assert.Equal(5, secure.Length);
+ }
+
+ [Fact]
+ public void ToReadOnlySecureString_Handles_Null_And_Empty()
+ {
+ SecureString fromNull = SecureStringUtility.ToReadOnlySecureString(null);
+ SecureString fromEmpty = SecureStringUtility.ToReadOnlySecureString(string.Empty);
+ Assert.True(fromNull.IsReadOnly());
+ Assert.True(fromEmpty.IsReadOnly());
+ Assert.Equal(0, fromNull.Length);
+ Assert.Equal(0, fromEmpty.Length);
+ }
+
+ [Fact]
+ public void UsePlainText_Roundtrips_Value()
+ {
+ SecureString secure = SecureStringUtility.ToReadOnlySecureString("RoundTripValue");
+ string captured = SecureStringUtility.UsePlainText(secure, plainText => plainText);
+ Assert.Equal("RoundTripValue", captured);
+ }
+
+ [Fact]
+ public void UsePlainText_Throws_For_Null_Action()
+ {
+ SecureString secure = SecureStringUtility.ToReadOnlySecureString("x");
+ Assert.Throws(() => SecureStringUtility.UsePlainText(secure, null));
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI.Tests/SourcePolicyTests.cs b/src/PSInfisicalAPI.Tests/SourcePolicyTests.cs
new file mode 100644
index 0000000..48347ff
--- /dev/null
+++ b/src/PSInfisicalAPI.Tests/SourcePolicyTests.cs
@@ -0,0 +1,42 @@
+using System.IO;
+using System.Text.RegularExpressions;
+using Xunit;
+
+namespace PSInfisicalAPI.Tests
+{
+ public class SourcePolicyTests
+ {
+ private static DirectoryInfo RepositoryRoot()
+ {
+ DirectoryInfo current = new DirectoryInfo(System.AppContext.BaseDirectory);
+ while (current != null)
+ {
+ if (File.Exists(Path.Combine(current.FullName, "PSInfisicalAPI.sln")))
+ {
+ return current;
+ }
+ current = current.Parent;
+ }
+ return null;
+ }
+
+ [Fact]
+ public void No_Async_Or_Await_Keywords_In_Production_Source()
+ {
+ DirectoryInfo root = RepositoryRoot();
+ Assert.NotNull(root);
+ DirectoryInfo src = new DirectoryInfo(Path.Combine(root.FullName, "src", "PSInfisicalAPI"));
+ Assert.True(src.Exists);
+
+ Regex asyncRegex = new Regex(@"\basync\b");
+ Regex awaitRegex = new Regex(@"\bawait\b");
+
+ foreach (FileInfo file in src.EnumerateFiles("*.cs", SearchOption.AllDirectories))
+ {
+ string content = File.ReadAllText(file.FullName);
+ Assert.DoesNotMatch(asyncRegex, content);
+ Assert.DoesNotMatch(awaitRegex, content);
+ }
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI.Tests/UriBuilderTests.cs b/src/PSInfisicalAPI.Tests/UriBuilderTests.cs
new file mode 100644
index 0000000..9c29707
--- /dev/null
+++ b/src/PSInfisicalAPI.Tests/UriBuilderTests.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Collections.Generic;
+using PSInfisicalAPI.Endpoints;
+using PSInfisicalAPI.Errors;
+using PSInfisicalAPI.Http;
+using Xunit;
+
+namespace PSInfisicalAPI.Tests
+{
+ public class UriBuilderTests
+ {
+ [Fact]
+ public void Build_Combines_Base_And_Endpoint()
+ {
+ Uri baseUri = new Uri("https://app.infisical.com");
+ InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(InfisicalEndpointNames.ListSecrets);
+
+ Uri uri = InfisicalUriBuilder.Build(baseUri, definition, null, new[]
+ {
+ new KeyValuePair("environment", "prod"),
+ new KeyValuePair("secretPath", "/")
+ });
+
+ Assert.Equal("https://app.infisical.com/api/v4/secrets?environment=prod&secretPath=%2F", uri.ToString());
+ }
+
+ [Fact]
+ public void Build_Escapes_SecretName_Path_Segment()
+ {
+ InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(InfisicalEndpointNames.RetrieveSecret);
+ Dictionary pathParameters = new Dictionary { { "secretName", "Sql Password" } };
+
+ string resolved = InfisicalUriBuilder.ResolvePathTemplate(definition.Template, pathParameters);
+
+ Assert.Equal("/api/v4/secrets/Sql%20Password", resolved);
+ }
+
+ [Fact]
+ public void ResolvePathTemplate_Escapes_Slash_And_Colon()
+ {
+ Dictionary pathParameters = new Dictionary { { "secretName", "Path/With:Colon" } };
+ string resolved = InfisicalUriBuilder.ResolvePathTemplate("/api/v4/secrets/{secretName}", pathParameters);
+ Assert.Equal("/api/v4/secrets/Path%2FWith%3AColon", resolved);
+ }
+
+ [Fact]
+ public void Build_Escapes_Query_Parameters()
+ {
+ string queryString = InfisicalUriBuilder.BuildQueryString(new[]
+ {
+ new KeyValuePair("tagSlugs", "tag a"),
+ new KeyValuePair("tagSlugs", "tag/b")
+ });
+
+ Assert.Equal("tagSlugs=tag%20a&tagSlugs=tag%2Fb", queryString);
+ }
+
+ [Fact]
+ public void Build_Throws_When_Required_Token_Missing()
+ {
+ Uri baseUri = new Uri("https://app.infisical.com");
+ InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(InfisicalEndpointNames.RetrieveSecret);
+ Assert.Throws(() => InfisicalUriBuilder.Build(baseUri, definition, null, null));
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Authentication/IInfisicalAuthProvider.cs b/src/PSInfisicalAPI/Authentication/IInfisicalAuthProvider.cs
new file mode 100644
index 0000000..f300518
--- /dev/null
+++ b/src/PSInfisicalAPI/Authentication/IInfisicalAuthProvider.cs
@@ -0,0 +1,12 @@
+using PSInfisicalAPI.Http;
+using PSInfisicalAPI.Logging;
+
+namespace PSInfisicalAPI.Authentication
+{
+ public interface IInfisicalAuthProvider
+ {
+ string Name { get; }
+
+ InfisicalAuthenticationResult Authenticate(InfisicalAuthenticationRequest request, IInfisicalHttpClient httpClient, IInfisicalLogger logger);
+ }
+}
diff --git a/src/PSInfisicalAPI/Authentication/InfisicalAuthenticationRequest.cs b/src/PSInfisicalAPI/Authentication/InfisicalAuthenticationRequest.cs
new file mode 100644
index 0000000..f2c5be8
--- /dev/null
+++ b/src/PSInfisicalAPI/Authentication/InfisicalAuthenticationRequest.cs
@@ -0,0 +1,14 @@
+using System;
+using System.Security;
+
+namespace PSInfisicalAPI.Authentication
+{
+ public sealed class InfisicalAuthenticationRequest
+ {
+ public Uri BaseUri { get; set; }
+ public string ApiVersion { get; set; }
+ public string ClientId { get; set; }
+ public SecureString ClientSecret { get; set; }
+ public SecureString PreSuppliedAccessToken { get; set; }
+ }
+}
diff --git a/src/PSInfisicalAPI/Authentication/InfisicalAuthenticationResult.cs b/src/PSInfisicalAPI/Authentication/InfisicalAuthenticationResult.cs
new file mode 100644
index 0000000..5b6d5b9
--- /dev/null
+++ b/src/PSInfisicalAPI/Authentication/InfisicalAuthenticationResult.cs
@@ -0,0 +1,12 @@
+using System;
+using System.Security;
+
+namespace PSInfisicalAPI.Authentication
+{
+ public sealed class InfisicalAuthenticationResult
+ {
+ public SecureString AccessToken { get; set; }
+ public DateTimeOffset? ExpiresAtUtc { get; set; }
+ public string TokenType { get; set; }
+ }
+}
diff --git a/src/PSInfisicalAPI/Authentication/InfisicalEnvironmentResolver.cs b/src/PSInfisicalAPI/Authentication/InfisicalEnvironmentResolver.cs
new file mode 100644
index 0000000..dee5c0d
--- /dev/null
+++ b/src/PSInfisicalAPI/Authentication/InfisicalEnvironmentResolver.cs
@@ -0,0 +1,209 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Security;
+using System.Text.RegularExpressions;
+using PSInfisicalAPI.Logging;
+
+namespace PSInfisicalAPI.Authentication
+{
+ public static class InfisicalEnvironmentResolver
+ {
+ public const string Component = "EnvironmentResolver";
+
+ private const RegexOptions DefaultRegexOptions = RegexOptions.IgnoreCase | RegexOptions.CultureInvariant;
+
+ private static readonly EnvironmentVariableTarget[] ScopeOrder = new[]
+ {
+ EnvironmentVariableTarget.Process,
+ EnvironmentVariableTarget.User,
+ EnvironmentVariableTarget.Machine
+ };
+
+ public static readonly Regex[] BaseUriPatterns = new[]
+ {
+ new Regex(@".*INFISICAL.*API.*URL.*", DefaultRegexOptions),
+ new Regex(@".*INFISICAL.*BASE.*UR(I|L).*", DefaultRegexOptions),
+ new Regex(@".*INFISICAL.*HOST.*", DefaultRegexOptions),
+ new Regex(@".*INFISICAL.*URL.*", DefaultRegexOptions)
+ };
+
+ public static readonly Regex[] OrganizationIdPatterns = new[]
+ {
+ new Regex(@".*INFISICAL.*ORG(ANIZATION)?.*ID.*", DefaultRegexOptions)
+ };
+
+ public static readonly Regex[] ProjectIdPatterns = new[]
+ {
+ new Regex(@".*INFISICAL.*(PROJECT|WORKSPACE).*ID.*", DefaultRegexOptions)
+ };
+
+ public static readonly Regex[] EnvironmentPatterns = new[]
+ {
+ new Regex(@".*INFISICAL.*ENV(IRONMENT)?.*NAME.*", DefaultRegexOptions),
+ new Regex(@".*INFISICAL.*ENV(IRONMENT)?.*SLUG.*", DefaultRegexOptions),
+ new Regex(@".*INFISICAL.*ENV(IRONMENT)?.*", DefaultRegexOptions)
+ };
+
+ public static readonly Regex[] ClientIdPatterns = new[]
+ {
+ new Regex(@".*INFISICAL.*CLIENT.*ID.*", DefaultRegexOptions),
+ new Regex(@".*INFISICAL.*(UNIVERSAL.*AUTH|MACHINE.*IDENTITY).*ID.*", DefaultRegexOptions)
+ };
+
+ public static readonly Regex[] ClientSecretPatterns = new[]
+ {
+ new Regex(@".*INFISICAL.*CLIENT.*SECRET.*", DefaultRegexOptions),
+ new Regex(@".*INFISICAL.*(UNIVERSAL.*AUTH|MACHINE.*IDENTITY).*SECRET.*", DefaultRegexOptions)
+ };
+
+ public static readonly Regex[] AccessTokenPatterns = new[]
+ {
+ new Regex(@".*INFISICAL.*ACCESS.*TOKEN.*", DefaultRegexOptions),
+ new Regex(@".*INFISICAL.*AUTH.*TOKEN.*", DefaultRegexOptions),
+ new Regex(@".*INFISICAL.*TOKEN.*", DefaultRegexOptions)
+ };
+
+ public static readonly Regex[] SecretPathPatterns = new[]
+ {
+ new Regex(@".*INFISICAL.*SECRET.*PATH.*", DefaultRegexOptions),
+ new Regex(@".*INFISICAL.*DEFAULT.*PATH.*", DefaultRegexOptions)
+ };
+
+ public static readonly Regex[] ApiVersionPatterns = new[]
+ {
+ new Regex(@".*INFISICAL.*API.*VERSION.*", DefaultRegexOptions)
+ };
+
+ public sealed class ResolutionResult
+ {
+ public bool Found { get; set; }
+ public string Value { get; set; }
+ public string VariableName { get; set; }
+ public EnvironmentVariableTarget Scope { get; set; }
+ }
+
+ public static ResolutionResult Resolve(IEnumerable patterns)
+ {
+ if (patterns == null)
+ {
+ return new ResolutionResult { Found = false };
+ }
+
+ List patternList = new List(patterns);
+
+ for (int i = 0; i < ScopeOrder.Length; i++)
+ {
+ EnvironmentVariableTarget scope = ScopeOrder[i];
+ IDictionary entries;
+
+ try
+ {
+ entries = Environment.GetEnvironmentVariables(scope);
+ }
+ catch (SecurityException)
+ {
+ continue;
+ }
+ catch (PlatformNotSupportedException)
+ {
+ continue;
+ }
+
+ if (entries == null)
+ {
+ continue;
+ }
+
+ foreach (DictionaryEntry entry in entries)
+ {
+ string name = entry.Key as string;
+ if (string.IsNullOrEmpty(name))
+ {
+ continue;
+ }
+
+ string value = entry.Value as string;
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ continue;
+ }
+
+ for (int p = 0; p < patternList.Count; p++)
+ {
+ if (patternList[p].IsMatch(name))
+ {
+ return new ResolutionResult
+ {
+ Found = true,
+ Value = value,
+ VariableName = name,
+ Scope = scope
+ };
+ }
+ }
+ }
+ }
+
+ return new ResolutionResult { Found = false };
+ }
+
+ public static string ResolveString(string parameterName, IEnumerable patterns, string currentValue, IInfisicalLogger logger)
+ {
+ if (!string.IsNullOrWhiteSpace(currentValue))
+ {
+ return currentValue;
+ }
+
+ ResolutionResult result = Resolve(patterns);
+ if (!result.Found)
+ {
+ return currentValue;
+ }
+
+ if (logger != null)
+ {
+ logger.Verbose(Component, string.Format("Resolved {0} from environment variable '{1}' ({2} scope).", parameterName, result.VariableName, result.Scope));
+ }
+
+ return result.Value;
+ }
+
+ public static SecureString ResolveSecureString(string parameterName, IEnumerable patterns, SecureString currentValue, IInfisicalLogger logger)
+ {
+ if (currentValue != null && currentValue.Length > 0)
+ {
+ return currentValue;
+ }
+
+ ResolutionResult result = Resolve(patterns);
+ if (!result.Found)
+ {
+ return currentValue;
+ }
+
+ if (logger != null)
+ {
+ logger.Verbose(Component, string.Format("Resolved {0} from environment variable '{1}' ({2} scope).", parameterName, result.VariableName, result.Scope));
+ }
+
+ SecureString secureString = new SecureString();
+ string plainText = result.Value;
+ try
+ {
+ for (int i = 0; i < plainText.Length; i++)
+ {
+ secureString.AppendChar(plainText[i]);
+ }
+ }
+ finally
+ {
+ plainText = null;
+ result.Value = null;
+ }
+
+ secureString.MakeReadOnly();
+ return secureString;
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Authentication/TokenAuthProvider.cs b/src/PSInfisicalAPI/Authentication/TokenAuthProvider.cs
new file mode 100644
index 0000000..dcd8d7b
--- /dev/null
+++ b/src/PSInfisicalAPI/Authentication/TokenAuthProvider.cs
@@ -0,0 +1,25 @@
+using PSInfisicalAPI.Errors;
+using PSInfisicalAPI.Http;
+using PSInfisicalAPI.Logging;
+
+namespace PSInfisicalAPI.Authentication
+{
+ public sealed class TokenAuthProvider : IInfisicalAuthProvider
+ {
+ public string Name { get { return "Token"; } }
+
+ public InfisicalAuthenticationResult Authenticate(InfisicalAuthenticationRequest request, IInfisicalHttpClient httpClient, IInfisicalLogger logger)
+ {
+ if (request == null || request.PreSuppliedAccessToken == null)
+ {
+ throw new InfisicalAuthenticationException("An access token must be supplied for Token authentication.");
+ }
+
+ return new InfisicalAuthenticationResult
+ {
+ AccessToken = request.PreSuppliedAccessToken,
+ TokenType = "Bearer"
+ };
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Authentication/UniversalAuthProvider.cs b/src/PSInfisicalAPI/Authentication/UniversalAuthProvider.cs
new file mode 100644
index 0000000..756eae2
--- /dev/null
+++ b/src/PSInfisicalAPI/Authentication/UniversalAuthProvider.cs
@@ -0,0 +1,128 @@
+using System;
+using System.Collections.Generic;
+using System.Security;
+using PSInfisicalAPI.Endpoints;
+using PSInfisicalAPI.Errors;
+using PSInfisicalAPI.Http;
+using PSInfisicalAPI.Logging;
+using PSInfisicalAPI.Security;
+using PSInfisicalAPI.Serialization;
+
+namespace PSInfisicalAPI.Authentication
+{
+ public sealed class UniversalAuthProvider : IInfisicalAuthProvider
+ {
+ private const string Component = "UniversalAuthProvider";
+
+ public string Name { get { return "UniversalAuth"; } }
+
+ public InfisicalAuthenticationResult Authenticate(InfisicalAuthenticationRequest request, IInfisicalHttpClient httpClient, IInfisicalLogger logger)
+ {
+ if (request == null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ if (httpClient == null)
+ {
+ throw new ArgumentNullException(nameof(httpClient));
+ }
+
+ IInfisicalLogger log = logger ?? NullInfisicalLogger.Instance;
+
+ if (string.IsNullOrEmpty(request.ClientId))
+ {
+ throw new InfisicalAuthenticationException("ClientId is required for Universal Auth.");
+ }
+
+ if (request.ClientSecret == null)
+ {
+ throw new InfisicalAuthenticationException("ClientSecret is required for Universal Auth.");
+ }
+
+ log.Information(Component, "Attempting to authenticate to Infisical. Please Wait...");
+
+ InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(InfisicalEndpointNames.UniversalAuthLogin);
+ Uri uri = InfisicalUriBuilder.Build(request.BaseUri, definition, null, null);
+ JsonInfisicalSerializer serializer = new JsonInfisicalSerializer();
+
+ InfisicalAuthenticationResult result = SecureStringUtility.UsePlainText(request.ClientSecret, plainSecret =>
+ {
+ Dictionary bodyObject = new Dictionary
+ {
+ { "clientId", request.ClientId },
+ { "clientSecret", plainSecret ?? string.Empty }
+ };
+
+ string body = serializer.Serialize(bodyObject);
+
+ InfisicalHttpRequest httpRequest = new InfisicalHttpRequest
+ {
+ OperationName = "Authenticate",
+ EndpointName = definition.Name,
+ Method = definition.Method,
+ Uri = uri,
+ Body = body,
+ ContentType = "application/json",
+ ContainsSecretMaterialInRequest = definition.ContainsSecretMaterialInRequest,
+ ContainsSecretMaterialInResponse = definition.ContainsSecretMaterialInResponse,
+ Headers = new Dictionary { { "Accept", "application/json" } }
+ };
+
+ InfisicalHttpResponse response = httpClient.Send(httpRequest);
+
+ try
+ {
+ if (response.StatusCode < 200 || response.StatusCode >= 300)
+ {
+ log.Error(Component, "Infisical authentication failed.");
+ throw new InfisicalAuthenticationException(string.Concat("Universal Auth login returned status ", response.StatusCode.ToString(System.Globalization.CultureInfo.InvariantCulture), "."));
+ }
+
+ UniversalAuthLoginResponse parsed = serializer.Deserialize(response.Body);
+
+ if (parsed == null || string.IsNullOrEmpty(parsed.AccessToken))
+ {
+ throw new InfisicalAuthenticationException("Universal Auth login response did not contain an access token.");
+ }
+
+ SecureString accessToken = SecureStringUtility.ToReadOnlySecureString(parsed.AccessToken);
+
+ DateTimeOffset? expiresAt = null;
+ if (parsed.ExpiresIn > 0)
+ {
+ expiresAt = DateTimeOffset.UtcNow.AddSeconds(parsed.ExpiresIn);
+ }
+
+ parsed.AccessToken = null;
+
+ return new InfisicalAuthenticationResult
+ {
+ AccessToken = accessToken,
+ TokenType = string.IsNullOrEmpty(parsed.TokenType) ? "Bearer" : parsed.TokenType,
+ ExpiresAtUtc = expiresAt
+ };
+ }
+ finally
+ {
+ response.Clear();
+ }
+ });
+
+ log.Information(Component, "Infisical authentication was successful.");
+ return result;
+ }
+
+ private sealed class UniversalAuthLoginResponse
+ {
+ [Newtonsoft.Json.JsonProperty("accessToken")]
+ public string AccessToken { get; set; }
+
+ [Newtonsoft.Json.JsonProperty("expiresIn")]
+ public int ExpiresIn { get; set; }
+
+ [Newtonsoft.Json.JsonProperty("tokenType")]
+ public string TokenType { get; set; }
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Cmdlets/ConnectInfisicalCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/ConnectInfisicalCmdlet.cs
new file mode 100644
index 0000000..478ee7b
--- /dev/null
+++ b/src/PSInfisicalAPI/Cmdlets/ConnectInfisicalCmdlet.cs
@@ -0,0 +1,213 @@
+using System;
+using System.Collections.Generic;
+using System.Management.Automation;
+using System.Security;
+using PSInfisicalAPI.Authentication;
+using PSInfisicalAPI.Connections;
+using PSInfisicalAPI.Errors;
+using PSInfisicalAPI.Models;
+
+namespace PSInfisicalAPI.Cmdlets
+{
+ [Cmdlet(VerbsCommunications.Connect, "Infisical", DefaultParameterSetName = "UniversalAuth")]
+ [OutputType(typeof(InfisicalConnection))]
+ public sealed class ConnectInfisicalCmdlet : InfisicalCmdletBase
+ {
+ private const string ParameterSetUniversalAuth = "UniversalAuth";
+ private const string ParameterSetToken = "Token";
+ private const string Component = "ConnectInfisicalCmdlet";
+
+ [Parameter]
+ public Uri BaseUri { get; set; }
+
+ [Parameter]
+ public string OrganizationId { get; set; }
+
+ [Parameter]
+ public string ProjectId { get; set; }
+
+ [Parameter]
+ public string Environment { get; set; }
+
+ [Parameter(ParameterSetName = ParameterSetUniversalAuth)]
+ public string ClientId { get; set; }
+
+ [Parameter(ParameterSetName = ParameterSetUniversalAuth)]
+ public SecureString ClientSecret { get; set; }
+
+ [Parameter(ParameterSetName = ParameterSetToken)]
+ public SecureString AccessToken { get; set; }
+
+ [Parameter]
+ public string SecretPath { get; set; } = "/";
+
+ [Parameter]
+ public string ApiVersion { get; set; } = "v4";
+
+ [Parameter]
+ public SwitchParameter PassThru { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ try
+ {
+ ResolveMissingParametersFromEnvironment();
+ ValidateRequiredParameters();
+
+ IInfisicalAuthProvider provider;
+ InfisicalAuthenticationRequest request;
+ InfisicalAuthType authType;
+
+ if (string.Equals(ParameterSetName, ParameterSetToken, StringComparison.Ordinal))
+ {
+ provider = new TokenAuthProvider();
+ authType = InfisicalAuthType.Token;
+ request = new InfisicalAuthenticationRequest
+ {
+ BaseUri = BaseUri,
+ ApiVersion = ApiVersion,
+ PreSuppliedAccessToken = AccessToken
+ };
+ }
+ else
+ {
+ provider = new UniversalAuthProvider();
+ authType = InfisicalAuthType.UniversalAuth;
+ request = new InfisicalAuthenticationRequest
+ {
+ BaseUri = BaseUri,
+ ApiVersion = ApiVersion,
+ ClientId = ClientId,
+ ClientSecret = ClientSecret
+ };
+ }
+
+ InfisicalAuthenticationResult authResult = provider.Authenticate(request, HttpClient, Logger);
+
+ if (authResult == null || authResult.AccessToken == null)
+ {
+ throw new InfisicalAuthenticationException("Authentication did not produce an access token.");
+ }
+
+ InfisicalConnection connection = new InfisicalConnection
+ {
+ BaseUri = BaseUri,
+ ApiVersion = ApiVersion,
+ AuthType = authType,
+ OrganizationId = OrganizationId,
+ ProjectId = ProjectId,
+ Environment = Environment,
+ DefaultSecretPath = string.IsNullOrEmpty(SecretPath) ? "/" : SecretPath,
+ ConnectedAtUtc = DateTimeOffset.UtcNow,
+ ExpiresAtUtc = authResult.ExpiresAtUtc,
+ IsConnected = true,
+ AccessToken = authResult.AccessToken
+ };
+
+ InfisicalSessionManager.SetCurrent(connection);
+
+ if (PassThru.IsPresent)
+ {
+ WriteObject(connection);
+ }
+ }
+ catch (Exception exception)
+ {
+ ThrowTerminatingForException(Component, "Connect", exception);
+ }
+ }
+
+ private void ResolveMissingParametersFromEnvironment()
+ {
+ bool tokenSet = string.Equals(ParameterSetName, ParameterSetToken, StringComparison.Ordinal);
+
+ bool needsScan =
+ BaseUri == null ||
+ string.IsNullOrWhiteSpace(OrganizationId) ||
+ string.IsNullOrWhiteSpace(ProjectId) ||
+ string.IsNullOrWhiteSpace(Environment) ||
+ (tokenSet && (AccessToken == null || AccessToken.Length == 0)) ||
+ (!tokenSet && string.IsNullOrWhiteSpace(ClientId)) ||
+ (!tokenSet && (ClientSecret == null || ClientSecret.Length == 0));
+
+ if (!needsScan)
+ {
+ return;
+ }
+
+ Logger.Verbose(Component, "Attempting to resolve missing Infisical connection parameters from environment variables. Please Wait...");
+
+ if (BaseUri == null)
+ {
+ string resolved = InfisicalEnvironmentResolver.ResolveString("BaseUri", InfisicalEnvironmentResolver.BaseUriPatterns, null, Logger);
+ if (!string.IsNullOrWhiteSpace(resolved))
+ {
+ Uri parsed;
+ if (Uri.TryCreate(resolved, UriKind.Absolute, out parsed))
+ {
+ BaseUri = parsed;
+ }
+ }
+ }
+
+ OrganizationId = InfisicalEnvironmentResolver.ResolveString("OrganizationId", InfisicalEnvironmentResolver.OrganizationIdPatterns, OrganizationId, Logger);
+ ProjectId = InfisicalEnvironmentResolver.ResolveString("ProjectId", InfisicalEnvironmentResolver.ProjectIdPatterns, ProjectId, Logger);
+ Environment = InfisicalEnvironmentResolver.ResolveString("Environment", InfisicalEnvironmentResolver.EnvironmentPatterns, Environment, Logger);
+
+ if (tokenSet)
+ {
+ AccessToken = InfisicalEnvironmentResolver.ResolveSecureString("AccessToken", InfisicalEnvironmentResolver.AccessTokenPatterns, AccessToken, Logger);
+ }
+ else
+ {
+ ClientId = InfisicalEnvironmentResolver.ResolveString("ClientId", InfisicalEnvironmentResolver.ClientIdPatterns, ClientId, Logger);
+ ClientSecret = InfisicalEnvironmentResolver.ResolveSecureString("ClientSecret", InfisicalEnvironmentResolver.ClientSecretPatterns, ClientSecret, Logger);
+ }
+
+ if (!MyInvocation.BoundParameters.ContainsKey("SecretPath"))
+ {
+ string resolvedPath = InfisicalEnvironmentResolver.ResolveString("SecretPath", InfisicalEnvironmentResolver.SecretPathPatterns, null, Logger);
+ if (!string.IsNullOrWhiteSpace(resolvedPath))
+ {
+ SecretPath = resolvedPath;
+ }
+ }
+
+ if (!MyInvocation.BoundParameters.ContainsKey("ApiVersion"))
+ {
+ string resolvedVersion = InfisicalEnvironmentResolver.ResolveString("ApiVersion", InfisicalEnvironmentResolver.ApiVersionPatterns, null, Logger);
+ if (!string.IsNullOrWhiteSpace(resolvedVersion))
+ {
+ ApiVersion = resolvedVersion;
+ }
+ }
+ }
+
+ private void ValidateRequiredParameters()
+ {
+ List missing = new List();
+
+ if (BaseUri == null) { missing.Add("BaseUri"); }
+ if (string.IsNullOrWhiteSpace(OrganizationId)) { missing.Add("OrganizationId"); }
+ if (string.IsNullOrWhiteSpace(ProjectId)) { missing.Add("ProjectId"); }
+ if (string.IsNullOrWhiteSpace(Environment)) { missing.Add("Environment"); }
+
+ if (string.Equals(ParameterSetName, ParameterSetToken, StringComparison.Ordinal))
+ {
+ if (AccessToken == null || AccessToken.Length == 0) { missing.Add("AccessToken"); }
+ }
+ else
+ {
+ if (string.IsNullOrWhiteSpace(ClientId)) { missing.Add("ClientId"); }
+ if (ClientSecret == null || ClientSecret.Length == 0) { missing.Add("ClientSecret"); }
+ }
+
+ if (missing.Count > 0)
+ {
+ throw new InfisicalConfigurationException(string.Format(
+ "Required Connect-Infisical parameter(s) not supplied and no matching environment variables were found: {0}.",
+ string.Join(", ", missing.ToArray())));
+ }
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Cmdlets/ConvertToInfisicalSecretDictionaryCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/ConvertToInfisicalSecretDictionaryCmdlet.cs
new file mode 100644
index 0000000..0b660ca
--- /dev/null
+++ b/src/PSInfisicalAPI/Cmdlets/ConvertToInfisicalSecretDictionaryCmdlet.cs
@@ -0,0 +1,71 @@
+using System;
+using System.Collections.Generic;
+using System.Management.Automation;
+using System.Security;
+using PSInfisicalAPI.Errors;
+using PSInfisicalAPI.Models;
+
+namespace PSInfisicalAPI.Cmdlets
+{
+ [Cmdlet(VerbsData.ConvertTo, "InfisicalSecretDictionary")]
+ [OutputType(typeof(Dictionary))]
+ public sealed class ConvertToInfisicalSecretDictionaryCmdlet : InfisicalCmdletBase
+ {
+ [Parameter(Mandatory = true, ValueFromPipeline = true)]
+ public InfisicalSecret[] InputObject { get; set; }
+
+ [Parameter]
+ public InfisicalDuplicateKeyBehavior DuplicateKeyBehavior { get; set; } = InfisicalDuplicateKeyBehavior.Error;
+
+ private readonly List _buffer = new List();
+
+ protected override void ProcessRecord()
+ {
+ if (InputObject == null) { return; }
+
+ foreach (InfisicalSecret secret in InputObject)
+ {
+ if (secret != null)
+ {
+ _buffer.Add(secret);
+ }
+ }
+ }
+
+ protected override void EndProcessing()
+ {
+ try
+ {
+ Dictionary dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (InfisicalSecret secret in _buffer)
+ {
+ string key = secret.SecretName ?? string.Empty;
+
+ if (dictionary.ContainsKey(key))
+ {
+ if (DuplicateKeyBehavior == InfisicalDuplicateKeyBehavior.Error)
+ {
+ throw new InfisicalConfigurationException(string.Concat("Duplicate secret name encountered: ", key));
+ }
+
+ if (DuplicateKeyBehavior == InfisicalDuplicateKeyBehavior.LastWins)
+ {
+ dictionary[key] = secret.SecretValue;
+ }
+
+ continue;
+ }
+
+ dictionary[key] = secret.SecretValue;
+ }
+
+ WriteObject(dictionary);
+ }
+ catch (Exception exception)
+ {
+ ThrowTerminatingForException("ConvertToInfisicalSecretDictionaryCmdlet", "ConvertToDictionary", exception);
+ }
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Cmdlets/DisconnectInfisicalCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/DisconnectInfisicalCmdlet.cs
new file mode 100644
index 0000000..404f705
--- /dev/null
+++ b/src/PSInfisicalAPI/Cmdlets/DisconnectInfisicalCmdlet.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Management.Automation;
+using PSInfisicalAPI.Connections;
+
+namespace PSInfisicalAPI.Cmdlets
+{
+ [Cmdlet(VerbsCommunications.Disconnect, "Infisical")]
+ [OutputType(typeof(PSObject))]
+ public sealed class DisconnectInfisicalCmdlet : InfisicalCmdletBase
+ {
+ [Parameter]
+ public SwitchParameter PassThru { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ try
+ {
+ InfisicalSessionManager.Disconnect();
+
+ if (PassThru.IsPresent)
+ {
+ PSObject status = new PSObject();
+ status.Properties.Add(new PSNoteProperty("IsConnected", false));
+ status.Properties.Add(new PSNoteProperty("DisconnectedAtUtc", DateTimeOffset.UtcNow));
+ WriteObject(status);
+ }
+ }
+ catch (Exception exception)
+ {
+ ThrowTerminatingForException("DisconnectInfisicalCmdlet", "Disconnect", exception);
+ }
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Cmdlets/ExportInfisicalSecretsCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/ExportInfisicalSecretsCmdlet.cs
new file mode 100644
index 0000000..9caae56
--- /dev/null
+++ b/src/PSInfisicalAPI/Cmdlets/ExportInfisicalSecretsCmdlet.cs
@@ -0,0 +1,98 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Management.Automation;
+using System.Text;
+using PSInfisicalAPI.Errors;
+using PSInfisicalAPI.Exports;
+using PSInfisicalAPI.Models;
+
+namespace PSInfisicalAPI.Cmdlets
+{
+ public enum InfisicalExportEncoding
+ {
+ UTF8,
+ UTF8Bom,
+ Unicode
+ }
+
+ [Cmdlet(VerbsData.Export, "InfisicalSecrets")]
+ public sealed class ExportInfisicalSecretsCmdlet : InfisicalCmdletBase
+ {
+ [Parameter(Mandatory = true, ValueFromPipeline = true)]
+ public InfisicalSecret[] InputObject { get; set; }
+
+ [Parameter(Mandatory = true)]
+ public InfisicalExportFormat Format { get; set; }
+
+ [Parameter]
+ public FileInfo Path { get; set; }
+
+ [Parameter]
+ public EnvironmentVariableTarget Scope { get; set; } = EnvironmentVariableTarget.Process;
+
+ [Parameter]
+ public SwitchParameter Force { get; set; }
+
+ [Parameter]
+ public InfisicalExportEncoding Encoding { get; set; } = InfisicalExportEncoding.UTF8;
+
+ private readonly List _buffer = new List();
+
+ protected override void ProcessRecord()
+ {
+ if (InputObject == null) { return; }
+
+ foreach (InfisicalSecret secret in InputObject)
+ {
+ if (secret != null)
+ {
+ _buffer.Add(secret);
+ }
+ }
+ }
+
+ protected override void EndProcessing()
+ {
+ try
+ {
+ bool requiresPath = Format != InfisicalExportFormat.EnvironmentVariables;
+ if (requiresPath && Path == null)
+ {
+ throw new InfisicalExportException(string.Concat("Path is required for format ", Format.ToString(), "."));
+ }
+
+ if (requiresPath && Path.Exists && !Force.IsPresent)
+ {
+ }
+
+ InfisicalExportRequest request = new InfisicalExportRequest
+ {
+ Secrets = _buffer.ToArray(),
+ Format = Format,
+ Path = Path,
+ Scope = Scope,
+ Force = Force.IsPresent,
+ Encoding = ResolveEncoding(Encoding)
+ };
+
+ IInfisicalExporter exporter = InfisicalExporterFactory.Create(Format);
+ exporter.Export(request);
+ }
+ catch (Exception exception)
+ {
+ ThrowTerminatingForException("ExportInfisicalSecretsCmdlet", string.Concat("Export-", Format.ToString()), exception);
+ }
+ }
+
+ private static Encoding ResolveEncoding(InfisicalExportEncoding encoding)
+ {
+ switch (encoding)
+ {
+ case InfisicalExportEncoding.UTF8Bom: return new UTF8Encoding(true);
+ case InfisicalExportEncoding.Unicode: return new UnicodeEncoding();
+ default: return new UTF8Encoding(false);
+ }
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretCmdlet.cs
new file mode 100644
index 0000000..d15f6cc
--- /dev/null
+++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretCmdlet.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Management.Automation;
+using PSInfisicalAPI.Connections;
+using PSInfisicalAPI.Models;
+using PSInfisicalAPI.Secrets;
+
+namespace PSInfisicalAPI.Cmdlets
+{
+ [Cmdlet(VerbsCommon.Get, "InfisicalSecret")]
+ [OutputType(typeof(InfisicalSecret))]
+ public sealed class GetInfisicalSecretCmdlet : InfisicalCmdletBase
+ {
+ [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, Position = 0)]
+ public string SecretName { get; set; }
+
+ [Parameter] public string ProjectId { get; set; }
+ [Parameter] public string Environment { get; set; }
+ [Parameter] public string SecretPath { get; set; }
+ [Parameter] public int? Version { get; set; }
+ [Parameter] public InfisicalSecretType Type { get; set; } = InfisicalSecretType.Shared;
+ [Parameter] public bool ViewSecretValue { get; set; } = true;
+ [Parameter] public bool ExpandSecretReferences { get; set; } = true;
+ [Parameter] public bool IncludeImports { get; set; } = true;
+
+ protected override void ProcessRecord()
+ {
+ try
+ {
+ InfisicalConnection connection = InfisicalSessionManager.RequireCurrent();
+
+ InfisicalRetrieveSecretQuery query = new InfisicalRetrieveSecretQuery
+ {
+ SecretName = SecretName,
+ ProjectId = ProjectId,
+ Environment = Environment,
+ SecretPath = SecretPath,
+ Version = Version,
+ Type = Type.ToString(),
+ ViewSecretValue = ViewSecretValue,
+ ExpandSecretReferences = ExpandSecretReferences,
+ IncludeImports = IncludeImports
+ };
+
+ InfisicalSecretsClient client = new InfisicalSecretsClient(HttpClient, Logger);
+ InfisicalSecret secret = client.Retrieve(connection, query);
+
+ if (secret != null)
+ {
+ WriteObject(secret);
+ }
+ }
+ catch (Exception exception)
+ {
+ ThrowTerminatingForException("GetInfisicalSecretCmdlet", "RetrieveSecret", exception);
+ }
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretsCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretsCmdlet.cs
new file mode 100644
index 0000000..eebd02f
--- /dev/null
+++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretsCmdlet.cs
@@ -0,0 +1,74 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Management.Automation;
+using PSInfisicalAPI.Connections;
+using PSInfisicalAPI.Models;
+using PSInfisicalAPI.Secrets;
+
+namespace PSInfisicalAPI.Cmdlets
+{
+ [Cmdlet(VerbsCommon.Get, "InfisicalSecrets")]
+ [OutputType(typeof(InfisicalSecret))]
+ public sealed class GetInfisicalSecretsCmdlet : InfisicalCmdletBase
+ {
+ [Parameter] public string ProjectId { get; set; }
+ [Parameter] public string Environment { get; set; }
+ [Parameter] public string SecretPath { get; set; }
+ [Parameter] public SwitchParameter Recursive { get; set; }
+ [Parameter] public bool IncludeImports { get; set; } = true;
+ [Parameter] public SwitchParameter IncludePersonalOverrides { get; set; }
+ [Parameter] public bool ExpandSecretReferences { get; set; } = true;
+ [Parameter] public bool ViewSecretValue { get; set; } = true;
+ [Parameter] public Hashtable MetadataFilter { get; set; }
+ [Parameter] public string[] TagSlugs { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ try
+ {
+ InfisicalConnection connection = InfisicalSessionManager.RequireCurrent();
+
+ InfisicalListSecretsQuery query = new InfisicalListSecretsQuery
+ {
+ ProjectId = ProjectId,
+ Environment = Environment,
+ SecretPath = SecretPath,
+ Recursive = Recursive.IsPresent,
+ IncludeImports = IncludeImports,
+ IncludePersonalOverrides = IncludePersonalOverrides.IsPresent,
+ ExpandSecretReferences = ExpandSecretReferences,
+ ViewSecretValue = ViewSecretValue,
+ MetadataFilter = ToStringDictionary(MetadataFilter),
+ TagSlugs = TagSlugs
+ };
+
+ InfisicalSecretsClient client = new InfisicalSecretsClient(HttpClient, Logger);
+ InfisicalSecret[] secrets = client.List(connection, query);
+
+ foreach (InfisicalSecret secret in secrets)
+ {
+ WriteObject(secret);
+ }
+ }
+ catch (Exception exception)
+ {
+ ThrowTerminatingForException("GetInfisicalSecretsCmdlet", "RetrieveSecrets", exception);
+ }
+ }
+
+ private static Dictionary ToStringDictionary(Hashtable hashtable)
+ {
+ if (hashtable == null) { return null; }
+
+ Dictionary result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (DictionaryEntry entry in hashtable)
+ {
+ if (entry.Key == null) { continue; }
+ result[entry.Key.ToString()] = entry.Value != null ? entry.Value.ToString() : null;
+ }
+
+ return result;
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Cmdlets/InfisicalCmdletBase.cs b/src/PSInfisicalAPI/Cmdlets/InfisicalCmdletBase.cs
new file mode 100644
index 0000000..fd4f21a
--- /dev/null
+++ b/src/PSInfisicalAPI/Cmdlets/InfisicalCmdletBase.cs
@@ -0,0 +1,48 @@
+using System;
+using System.Management.Automation;
+using PSInfisicalAPI.Errors;
+using PSInfisicalAPI.Http;
+using PSInfisicalAPI.Logging;
+
+namespace PSInfisicalAPI.Cmdlets
+{
+ public abstract class InfisicalCmdletBase : PSCmdlet
+ {
+ private IInfisicalLogger _logger;
+ private IInfisicalHttpClient _httpClient;
+
+ protected IInfisicalLogger Logger
+ {
+ get
+ {
+ if (_logger == null)
+ {
+ _logger = new PSCmdletLogger(this);
+ }
+
+ return _logger;
+ }
+ }
+
+ protected IInfisicalHttpClient HttpClient
+ {
+ get
+ {
+ if (_httpClient == null)
+ {
+ _httpClient = new InfisicalHttpClient(Logger);
+ }
+
+ return _httpClient;
+ }
+ }
+
+ protected void ThrowTerminatingForException(string component, string operation, Exception exception)
+ {
+ InfisicalErrorDetails details = InfisicalErrorHandler.BuildDetails(component, operation, exception);
+ InfisicalErrorHandler.LogFailure(Logger, details);
+ ErrorRecord record = InfisicalErrorHandler.ToErrorRecord(exception, details);
+ ThrowTerminatingError(record);
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Common/InfisicalPath.cs b/src/PSInfisicalAPI/Common/InfisicalPath.cs
new file mode 100644
index 0000000..13f165a
--- /dev/null
+++ b/src/PSInfisicalAPI/Common/InfisicalPath.cs
@@ -0,0 +1,42 @@
+using System.IO;
+
+namespace PSInfisicalAPI.Common
+{
+ public static class InfisicalPath
+ {
+ public static FileInfo CombineFile(params string[] segments)
+ {
+ string combined = Path.Combine(segments);
+ return new FileInfo(combined);
+ }
+
+ public static DirectoryInfo CombineDirectory(params string[] segments)
+ {
+ string combined = Path.Combine(segments);
+ return new DirectoryInfo(combined);
+ }
+
+ public static void EnsureDirectoryExists(DirectoryInfo directory)
+ {
+ if (directory == null)
+ {
+ return;
+ }
+
+ if (!directory.Exists)
+ {
+ directory.Create();
+ }
+ }
+
+ public static void EnsureParentDirectoryExists(FileInfo file)
+ {
+ if (file == null)
+ {
+ return;
+ }
+
+ EnsureDirectoryExists(file.Directory);
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Common/InfisicalSanitizer.cs b/src/PSInfisicalAPI/Common/InfisicalSanitizer.cs
new file mode 100644
index 0000000..96290b5
--- /dev/null
+++ b/src/PSInfisicalAPI/Common/InfisicalSanitizer.cs
@@ -0,0 +1,82 @@
+using System.Collections.Generic;
+
+namespace PSInfisicalAPI.Common
+{
+ public static class InfisicalSanitizer
+ {
+ private static readonly string[] SensitiveTokens = new[]
+ {
+ "secretValue",
+ "secret_value",
+ "clientSecret",
+ "client_secret",
+ "accessToken",
+ "access_token",
+ "Authorization",
+ "Bearer"
+ };
+
+ public static string SanitizeBody(string body, bool containsSecretMaterial)
+ {
+ if (string.IsNullOrEmpty(body))
+ {
+ return body;
+ }
+
+ if (containsSecretMaterial)
+ {
+ return "[REDACTED]";
+ }
+
+ return Truncate(body, 1024);
+ }
+
+ public static string SanitizeHeaderValue(string headerName, string headerValue)
+ {
+ if (string.IsNullOrEmpty(headerName))
+ {
+ return headerValue;
+ }
+
+ string normalized = headerName.ToLowerInvariant();
+ if (normalized == "authorization" || normalized.Contains("token") || normalized.Contains("secret"))
+ {
+ return "[REDACTED]";
+ }
+
+ return headerValue;
+ }
+
+ public static IDictionary SanitizeHeaders(IDictionary headers)
+ {
+ if (headers == null)
+ {
+ return null;
+ }
+
+ Dictionary sanitized = new Dictionary(headers.Count);
+
+ foreach (KeyValuePair entry in headers)
+ {
+ sanitized[entry.Key] = SanitizeHeaderValue(entry.Key, entry.Value);
+ }
+
+ return sanitized;
+ }
+
+ public static string Truncate(string value, int maxLength)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return value;
+ }
+
+ if (value.Length <= maxLength)
+ {
+ return value;
+ }
+
+ return string.Concat(value.Substring(0, maxLength), "... [truncated]");
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Connections/InfisicalConnection.cs b/src/PSInfisicalAPI/Connections/InfisicalConnection.cs
new file mode 100644
index 0000000..7fcfad8
--- /dev/null
+++ b/src/PSInfisicalAPI/Connections/InfisicalConnection.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Security;
+using PSInfisicalAPI.Models;
+
+namespace PSInfisicalAPI.Connections
+{
+ public sealed class InfisicalConnection
+ {
+ public Uri BaseUri { get; set; }
+ public string ApiVersion { get; set; }
+ public InfisicalAuthType AuthType { get; set; }
+ public string OrganizationId { get; set; }
+ public string ProjectId { get; set; }
+ public string Environment { get; set; }
+ public string DefaultSecretPath { get; set; }
+ public DateTimeOffset ConnectedAtUtc { get; set; }
+ public DateTimeOffset? ExpiresAtUtc { get; set; }
+ public bool IsConnected { get; set; }
+
+ internal SecureString AccessToken { get; set; }
+
+ public override string ToString()
+ {
+ return string.Concat(
+ "Project=", ProjectId ?? "",
+ " Environment=", Environment ?? "",
+ " Connected=", IsConnected ? "true" : "false");
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Connections/InfisicalSessionManager.cs b/src/PSInfisicalAPI/Connections/InfisicalSessionManager.cs
new file mode 100644
index 0000000..8891fb1
--- /dev/null
+++ b/src/PSInfisicalAPI/Connections/InfisicalSessionManager.cs
@@ -0,0 +1,47 @@
+using PSInfisicalAPI.Errors;
+
+namespace PSInfisicalAPI.Connections
+{
+ public static class InfisicalSessionManager
+ {
+ private static readonly object Sync = new object();
+ private static InfisicalConnection _current;
+
+ public static InfisicalConnection Current
+ {
+ get { lock (Sync) { return _current; } }
+ }
+
+ public static void SetCurrent(InfisicalConnection connection)
+ {
+ lock (Sync)
+ {
+ _current = connection;
+ }
+ }
+
+ public static InfisicalConnection RequireCurrent()
+ {
+ InfisicalConnection connection = Current;
+ if (connection == null || !connection.IsConnected)
+ {
+ throw new InfisicalConfigurationException("No active Infisical connection. Call Connect-Infisical first.");
+ }
+
+ return connection;
+ }
+
+ public static void Disconnect()
+ {
+ lock (Sync)
+ {
+ if (_current != null)
+ {
+ _current.AccessToken = null;
+ _current.IsConnected = false;
+ _current = null;
+ }
+ }
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointDefinition.cs b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointDefinition.cs
new file mode 100644
index 0000000..66a91ed
--- /dev/null
+++ b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointDefinition.cs
@@ -0,0 +1,14 @@
+namespace PSInfisicalAPI.Endpoints
+{
+ public sealed class InfisicalEndpointDefinition
+ {
+ public string Name { get; set; }
+ public string Resource { get; set; }
+ public string Version { get; set; }
+ public string Method { get; set; }
+ public string Template { get; set; }
+ public bool RequiresAuthorization { get; set; }
+ public bool ContainsSecretMaterialInRequest { get; set; }
+ public bool ContainsSecretMaterialInResponse { get; set; }
+ }
+}
diff --git a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs
new file mode 100644
index 0000000..1bea19d
--- /dev/null
+++ b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs
@@ -0,0 +1,9 @@
+namespace PSInfisicalAPI.Endpoints
+{
+ public static class InfisicalEndpointNames
+ {
+ public const string UniversalAuthLogin = "UniversalAuthLogin";
+ public const string ListSecrets = "ListSecrets";
+ public const string RetrieveSecret = "RetrieveSecret";
+ }
+}
diff --git a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs
new file mode 100644
index 0000000..9e3dfe7
--- /dev/null
+++ b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs
@@ -0,0 +1,87 @@
+using System.Collections.Generic;
+using PSInfisicalAPI.Errors;
+
+namespace PSInfisicalAPI.Endpoints
+{
+ public static class InfisicalEndpointRegistry
+ {
+ private static readonly Dictionary Definitions =
+ new Dictionary
+ {
+ {
+ InfisicalEndpointNames.UniversalAuthLogin,
+ new InfisicalEndpointDefinition
+ {
+ Name = InfisicalEndpointNames.UniversalAuthLogin,
+ Resource = "Authentication",
+ Version = "v1",
+ Method = "POST",
+ Template = "/api/v1/auth/universal-auth/login",
+ RequiresAuthorization = false,
+ ContainsSecretMaterialInRequest = true,
+ ContainsSecretMaterialInResponse = true
+ }
+ },
+ {
+ InfisicalEndpointNames.ListSecrets,
+ new InfisicalEndpointDefinition
+ {
+ Name = InfisicalEndpointNames.ListSecrets,
+ Resource = "Secrets",
+ Version = "v4",
+ Method = "GET",
+ Template = "/api/v4/secrets",
+ RequiresAuthorization = true,
+ ContainsSecretMaterialInRequest = false,
+ ContainsSecretMaterialInResponse = true
+ }
+ },
+ {
+ InfisicalEndpointNames.RetrieveSecret,
+ new InfisicalEndpointDefinition
+ {
+ Name = InfisicalEndpointNames.RetrieveSecret,
+ Resource = "Secrets",
+ Version = "v4",
+ Method = "GET",
+ Template = "/api/v4/secrets/{secretName}",
+ RequiresAuthorization = true,
+ ContainsSecretMaterialInRequest = false,
+ ContainsSecretMaterialInResponse = true
+ }
+ }
+ };
+
+ public static InfisicalEndpointDefinition Get(string name)
+ {
+ if (string.IsNullOrEmpty(name))
+ {
+ throw new InfisicalConfigurationException("Endpoint name must be provided.");
+ }
+
+ InfisicalEndpointDefinition definition;
+ if (!Definitions.TryGetValue(name, out definition))
+ {
+ throw new InfisicalConfigurationException(string.Concat("Unknown endpoint name: ", name));
+ }
+
+ return definition;
+ }
+
+ public static bool TryGet(string name, out InfisicalEndpointDefinition definition)
+ {
+ if (string.IsNullOrEmpty(name))
+ {
+ definition = null;
+ return false;
+ }
+
+ return Definitions.TryGetValue(name, out definition);
+ }
+
+ public static IEnumerable All()
+ {
+ return Definitions.Values;
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Errors/InfisicalErrorDetails.cs b/src/PSInfisicalAPI/Errors/InfisicalErrorDetails.cs
new file mode 100644
index 0000000..c02a0ae
--- /dev/null
+++ b/src/PSInfisicalAPI/Errors/InfisicalErrorDetails.cs
@@ -0,0 +1,19 @@
+namespace PSInfisicalAPI.Errors
+{
+ public sealed class InfisicalErrorDetails
+ {
+ public string Component { get; set; }
+ public string Operation { get; set; }
+ public string Message { get; set; }
+ public string ExceptionType { get; set; }
+ public string InnerExceptionMessage { get; set; }
+ public int? StatusCode { get; set; }
+ public string ReasonPhrase { get; set; }
+ public string ApiErrorCode { get; set; }
+ public string SanitizedBody { get; set; }
+ public int? LineNumber { get; set; }
+ public int? LinePosition { get; set; }
+ public string EndpointName { get; set; }
+ public string RequestMethod { get; set; }
+ }
+}
diff --git a/src/PSInfisicalAPI/Errors/InfisicalErrorHandler.cs b/src/PSInfisicalAPI/Errors/InfisicalErrorHandler.cs
new file mode 100644
index 0000000..0da799e
--- /dev/null
+++ b/src/PSInfisicalAPI/Errors/InfisicalErrorHandler.cs
@@ -0,0 +1,122 @@
+using System;
+using System.Globalization;
+using System.Management.Automation;
+using PSInfisicalAPI.Logging;
+
+namespace PSInfisicalAPI.Errors
+{
+ public static class InfisicalErrorHandler
+ {
+ private const string Component = "ErrorHandler";
+
+ public static InfisicalErrorDetails BuildDetails(string component, string operation, Exception exception)
+ {
+ InfisicalErrorDetails details = new InfisicalErrorDetails
+ {
+ Component = component,
+ Operation = operation,
+ Message = exception?.Message,
+ ExceptionType = exception?.GetType().FullName,
+ InnerExceptionMessage = exception?.InnerException?.Message
+ };
+
+ InfisicalApiException apiException = exception as InfisicalApiException;
+ if (apiException != null)
+ {
+ details.StatusCode = apiException.StatusCode;
+ details.ReasonPhrase = apiException.ReasonPhrase;
+ details.ApiErrorCode = apiException.ApiErrorCode;
+ details.SanitizedBody = apiException.SanitizedBody;
+ details.EndpointName = apiException.EndpointName;
+ details.RequestMethod = apiException.RequestMethod;
+ }
+
+ InfisicalSerializationException serializationException = exception as InfisicalSerializationException;
+ if (serializationException != null)
+ {
+ details.LineNumber = serializationException.LineNumber;
+ details.LinePosition = serializationException.LinePosition;
+ }
+
+ return details;
+ }
+
+ public static void LogFailure(IInfisicalLogger logger, InfisicalErrorDetails details)
+ {
+ if (logger == null || details == null)
+ {
+ return;
+ }
+
+ logger.Error(Component, string.Concat("Operation failed: ", details.Operation ?? "Unspecified"));
+
+ if (!string.IsNullOrEmpty(details.Component))
+ {
+ logger.Error(Component, string.Concat("Error Component: ", details.Component));
+ }
+
+ if (!string.IsNullOrEmpty(details.Message))
+ {
+ logger.Error(Component, string.Concat("Error Message: ", details.Message));
+ }
+
+ if (details.StatusCode.HasValue)
+ {
+ logger.Error(Component, string.Concat("HTTP Status Code: ", details.StatusCode.Value.ToString(CultureInfo.InvariantCulture)));
+ }
+
+ if (!string.IsNullOrEmpty(details.ApiErrorCode))
+ {
+ logger.Error(Component, string.Concat("API Error Code: ", details.ApiErrorCode));
+ }
+
+ if (details.LineNumber.HasValue)
+ {
+ logger.Error(Component, string.Concat("Line: ", details.LineNumber.Value.ToString(CultureInfo.InvariantCulture)));
+ }
+
+ if (details.LinePosition.HasValue)
+ {
+ logger.Error(Component, string.Concat("Position: ", details.LinePosition.Value.ToString(CultureInfo.InvariantCulture)));
+ }
+ }
+
+ public static ErrorRecord ToErrorRecord(Exception exception, InfisicalErrorDetails details)
+ {
+ ErrorCategory category = MapCategory(exception);
+ string errorId = exception?.GetType().Name ?? "PSInfisicalAPI.Error";
+ object target = details?.Component;
+
+ ErrorRecord record = new ErrorRecord(exception ?? new InfisicalException("Unspecified error."), errorId, category, target);
+
+ if (details != null && !string.IsNullOrEmpty(details.Message))
+ {
+ record.ErrorDetails = new ErrorDetails(details.Message);
+ }
+
+ return record;
+ }
+
+ internal static ErrorCategory MapCategory(Exception exception)
+ {
+ if (exception is InfisicalAuthenticationException) { return ErrorCategory.AuthenticationError; }
+ if (exception is InfisicalHttpException) { return ErrorCategory.ConnectionError; }
+ if (exception is InfisicalSerializationException) { return ErrorCategory.InvalidData; }
+ if (exception is InfisicalConfigurationException) { return ErrorCategory.InvalidArgument; }
+ if (exception is InfisicalExportException) { return ErrorCategory.WriteError; }
+
+ InfisicalApiException apiException = exception as InfisicalApiException;
+ if (apiException != null)
+ {
+ if (apiException.StatusCode == 401) { return ErrorCategory.AuthenticationError; }
+ if (apiException.StatusCode == 403) { return ErrorCategory.PermissionDenied; }
+ if (apiException.StatusCode == 404) { return ErrorCategory.ObjectNotFound; }
+ if (apiException.StatusCode == 503) { return ErrorCategory.ResourceUnavailable; }
+
+ return ErrorCategory.InvalidOperation;
+ }
+
+ return ErrorCategory.NotSpecified;
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Errors/InfisicalException.cs b/src/PSInfisicalAPI/Errors/InfisicalException.cs
new file mode 100644
index 0000000..88979e9
--- /dev/null
+++ b/src/PSInfisicalAPI/Errors/InfisicalException.cs
@@ -0,0 +1,82 @@
+using System;
+
+namespace PSInfisicalAPI.Errors
+{
+ public class InfisicalException : Exception
+ {
+ public string Component { get; set; }
+ public string Operation { get; set; }
+
+ public InfisicalException() { }
+
+ public InfisicalException(string message) : base(message) { }
+
+ public InfisicalException(string message, Exception innerException) : base(message, innerException) { }
+
+ public InfisicalException(string component, string operation, string message)
+ : base(message)
+ {
+ Component = component;
+ Operation = operation;
+ }
+
+ public InfisicalException(string component, string operation, string message, Exception innerException)
+ : base(message, innerException)
+ {
+ Component = component;
+ Operation = operation;
+ }
+ }
+
+ public class InfisicalApiException : InfisicalException
+ {
+ public int StatusCode { get; set; }
+ public string ReasonPhrase { get; set; }
+ public string ApiErrorCode { get; set; }
+ public string SanitizedBody { get; set; }
+ public string EndpointName { get; set; }
+ public string RequestMethod { get; set; }
+
+ public InfisicalApiException() { }
+ public InfisicalApiException(string message) : base(message) { }
+ public InfisicalApiException(string message, Exception innerException) : base(message, innerException) { }
+ }
+
+ public class InfisicalAuthenticationException : InfisicalException
+ {
+ public InfisicalAuthenticationException() { }
+ public InfisicalAuthenticationException(string message) : base(message) { }
+ public InfisicalAuthenticationException(string message, Exception innerException) : base(message, innerException) { }
+ }
+
+ public class InfisicalHttpException : InfisicalException
+ {
+ public InfisicalHttpException() { }
+ public InfisicalHttpException(string message) : base(message) { }
+ public InfisicalHttpException(string message, Exception innerException) : base(message, innerException) { }
+ }
+
+ public class InfisicalSerializationException : InfisicalException
+ {
+ public int? LineNumber { get; set; }
+ public int? LinePosition { get; set; }
+
+ public InfisicalSerializationException() { }
+ public InfisicalSerializationException(string message) : base(message) { }
+ public InfisicalSerializationException(string message, Exception innerException) : base(message, innerException) { }
+ }
+
+ public class InfisicalExportException : InfisicalException
+ {
+ public InfisicalExportException() { }
+ public InfisicalExportException(string message) : base(message) { }
+ public InfisicalExportException(string message, Exception innerException) : base(message, innerException) { }
+ }
+
+ public class InfisicalConfigurationException : InfisicalException
+ {
+ public InfisicalConfigurationException() { }
+ public InfisicalConfigurationException(string message) : base(message) { }
+ public InfisicalConfigurationException(string message, Exception innerException) : base(message, innerException) { }
+ }
+}
diff --git a/src/PSInfisicalAPI/Exports/EnvInfisicalExporter.cs b/src/PSInfisicalAPI/Exports/EnvInfisicalExporter.cs
new file mode 100644
index 0000000..1855544
--- /dev/null
+++ b/src/PSInfisicalAPI/Exports/EnvInfisicalExporter.cs
@@ -0,0 +1,36 @@
+using System.IO;
+using System.Text;
+using PSInfisicalAPI.Common;
+using PSInfisicalAPI.Errors;
+using PSInfisicalAPI.Models;
+
+namespace PSInfisicalAPI.Exports
+{
+ public sealed class EnvInfisicalExporter : IInfisicalExporter
+ {
+ public void Export(InfisicalExportRequest request)
+ {
+ if (request == null || request.Secrets == null) { throw new InfisicalExportException("Export request is invalid."); }
+ if (request.Path == null) { throw new InfisicalExportException("Path is required for ENV export."); }
+
+ InfisicalPath.EnsureParentDirectoryExists(request.Path);
+
+ StringBuilder builder = new StringBuilder();
+
+ foreach (InfisicalSecret secret in request.Secrets)
+ {
+ if (secret == null || string.IsNullOrEmpty(secret.SecretName)) { continue; }
+
+ secret.UsePlainTextValue(plainValue =>
+ {
+ builder.Append(secret.SecretName);
+ builder.Append('=');
+ builder.AppendLine(plainValue ?? string.Empty);
+ });
+ }
+
+ Encoding encoding = request.Encoding ?? new UTF8Encoding(false);
+ File.WriteAllText(request.Path.FullName, builder.ToString(), encoding);
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Exports/EnvironmentVariableExporter.cs b/src/PSInfisicalAPI/Exports/EnvironmentVariableExporter.cs
new file mode 100644
index 0000000..12ec306
--- /dev/null
+++ b/src/PSInfisicalAPI/Exports/EnvironmentVariableExporter.cs
@@ -0,0 +1,28 @@
+using System;
+using PSInfisicalAPI.Errors;
+using PSInfisicalAPI.Models;
+
+namespace PSInfisicalAPI.Exports
+{
+ public sealed class EnvironmentVariableExporter : IInfisicalExporter
+ {
+ public void Export(InfisicalExportRequest request)
+ {
+ if (request == null || request.Secrets == null) { throw new InfisicalExportException("Export request is invalid."); }
+
+ EnvironmentVariableTarget target = request.Scope;
+
+ foreach (InfisicalSecret secret in request.Secrets)
+ {
+ if (secret == null || string.IsNullOrEmpty(secret.SecretName)) { continue; }
+
+ string name = secret.SecretName;
+
+ secret.UsePlainTextValue(plainValue =>
+ {
+ Environment.SetEnvironmentVariable(name, plainValue, target);
+ });
+ }
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Exports/IInfisicalExporter.cs b/src/PSInfisicalAPI/Exports/IInfisicalExporter.cs
new file mode 100644
index 0000000..7d5982c
--- /dev/null
+++ b/src/PSInfisicalAPI/Exports/IInfisicalExporter.cs
@@ -0,0 +1,7 @@
+namespace PSInfisicalAPI.Exports
+{
+ public interface IInfisicalExporter
+ {
+ void Export(InfisicalExportRequest request);
+ }
+}
diff --git a/src/PSInfisicalAPI/Exports/InfisicalExportRequest.cs b/src/PSInfisicalAPI/Exports/InfisicalExportRequest.cs
new file mode 100644
index 0000000..1797b29
--- /dev/null
+++ b/src/PSInfisicalAPI/Exports/InfisicalExportRequest.cs
@@ -0,0 +1,17 @@
+using System;
+using System.IO;
+using System.Text;
+using PSInfisicalAPI.Models;
+
+namespace PSInfisicalAPI.Exports
+{
+ public sealed class InfisicalExportRequest
+ {
+ public InfisicalSecret[] Secrets { get; set; }
+ public InfisicalExportFormat Format { get; set; }
+ public FileInfo Path { get; set; }
+ public EnvironmentVariableTarget Scope { get; set; }
+ public bool Force { get; set; }
+ public Encoding Encoding { get; set; }
+ }
+}
diff --git a/src/PSInfisicalAPI/Exports/InfisicalExporterFactory.cs b/src/PSInfisicalAPI/Exports/InfisicalExporterFactory.cs
new file mode 100644
index 0000000..c4d4cfe
--- /dev/null
+++ b/src/PSInfisicalAPI/Exports/InfisicalExporterFactory.cs
@@ -0,0 +1,21 @@
+using PSInfisicalAPI.Errors;
+using PSInfisicalAPI.Models;
+
+namespace PSInfisicalAPI.Exports
+{
+ public static class InfisicalExporterFactory
+ {
+ public static IInfisicalExporter Create(InfisicalExportFormat format)
+ {
+ switch (format)
+ {
+ case InfisicalExportFormat.Json: return new JsonInfisicalExporter();
+ case InfisicalExportFormat.Yaml: return new YamlInfisicalExporter();
+ case InfisicalExportFormat.Env: return new EnvInfisicalExporter();
+ case InfisicalExportFormat.Xml: return new XmlInfisicalExporter();
+ case InfisicalExportFormat.EnvironmentVariables: return new EnvironmentVariableExporter();
+ default: throw new InfisicalExportException(string.Concat("Unsupported export format: ", format.ToString()));
+ }
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Exports/JsonInfisicalExporter.cs b/src/PSInfisicalAPI/Exports/JsonInfisicalExporter.cs
new file mode 100644
index 0000000..980d3c8
--- /dev/null
+++ b/src/PSInfisicalAPI/Exports/JsonInfisicalExporter.cs
@@ -0,0 +1,58 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using Newtonsoft.Json;
+using PSInfisicalAPI.Common;
+using PSInfisicalAPI.Errors;
+using PSInfisicalAPI.Models;
+
+namespace PSInfisicalAPI.Exports
+{
+ public sealed class JsonInfisicalExporter : IInfisicalExporter
+ {
+ public void Export(InfisicalExportRequest request)
+ {
+ if (request == null || request.Secrets == null) { throw new InfisicalExportException("Export request is invalid."); }
+ if (request.Path == null) { throw new InfisicalExportException("Path is required for JSON export."); }
+
+ InfisicalPath.EnsureParentDirectoryExists(request.Path);
+
+ List> payload = new List>();
+
+ foreach (InfisicalSecret secret in request.Secrets)
+ {
+ if (secret == null) { continue; }
+
+ secret.UsePlainTextValue(plainValue =>
+ {
+ Dictionary entry = new Dictionary();
+ entry["SecretName"] = secret.SecretName;
+ entry["SecretValue"] = plainValue;
+ entry["SecretPath"] = secret.SecretPath;
+ entry["SecretMetadata"] = MetadataToObject(secret);
+ payload.Add(entry);
+ });
+ }
+
+ string serialized = JsonConvert.SerializeObject(payload, Formatting.Indented);
+
+ Encoding encoding = request.Encoding ?? new UTF8Encoding(false);
+ File.WriteAllText(request.Path.FullName, serialized, encoding);
+ }
+
+ private static Dictionary MetadataToObject(InfisicalSecret secret)
+ {
+ Dictionary metadata = new Dictionary();
+
+ if (secret.SecretMetadata == null) { return metadata; }
+
+ foreach (InfisicalSecretMetadata entry in secret.SecretMetadata)
+ {
+ if (entry == null || string.IsNullOrEmpty(entry.Key)) { continue; }
+ metadata[entry.Key] = entry.Value;
+ }
+
+ return metadata;
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Exports/XmlInfisicalExporter.cs b/src/PSInfisicalAPI/Exports/XmlInfisicalExporter.cs
new file mode 100644
index 0000000..248017c
--- /dev/null
+++ b/src/PSInfisicalAPI/Exports/XmlInfisicalExporter.cs
@@ -0,0 +1,68 @@
+using System.IO;
+using System.Text;
+using System.Xml;
+using PSInfisicalAPI.Common;
+using PSInfisicalAPI.Errors;
+using PSInfisicalAPI.Models;
+
+namespace PSInfisicalAPI.Exports
+{
+ public sealed class XmlInfisicalExporter : IInfisicalExporter
+ {
+ public void Export(InfisicalExportRequest request)
+ {
+ if (request == null || request.Secrets == null) { throw new InfisicalExportException("Export request is invalid."); }
+ if (request.Path == null) { throw new InfisicalExportException("Path is required for XML export."); }
+
+ InfisicalPath.EnsureParentDirectoryExists(request.Path);
+
+ Encoding encoding = request.Encoding ?? new UTF8Encoding(false);
+
+ XmlWriterSettings settings = new XmlWriterSettings
+ {
+ Indent = true,
+ Encoding = encoding,
+ OmitXmlDeclaration = false
+ };
+
+ using (FileStream stream = new FileStream(request.Path.FullName, FileMode.Create, FileAccess.Write))
+ using (XmlWriter writer = XmlWriter.Create(stream, settings))
+ {
+ writer.WriteStartDocument();
+ writer.WriteStartElement("Secrets");
+
+ foreach (InfisicalSecret secret in request.Secrets)
+ {
+ if (secret == null) { continue; }
+
+ secret.UsePlainTextValue(plainValue =>
+ {
+ writer.WriteStartElement("Secret");
+ writer.WriteElementString("SecretName", secret.SecretName ?? string.Empty);
+ writer.WriteElementString("SecretValue", plainValue ?? string.Empty);
+ writer.WriteElementString("SecretPath", secret.SecretPath ?? string.Empty);
+ writer.WriteStartElement("SecretMetadata");
+
+ if (secret.SecretMetadata != null)
+ {
+ foreach (InfisicalSecretMetadata entry in secret.SecretMetadata)
+ {
+ if (entry == null || string.IsNullOrEmpty(entry.Key)) { continue; }
+ writer.WriteStartElement("Metadata");
+ writer.WriteElementString("Key", entry.Key);
+ writer.WriteElementString("Value", entry.Value ?? string.Empty);
+ writer.WriteEndElement();
+ }
+ }
+
+ writer.WriteEndElement();
+ writer.WriteEndElement();
+ });
+ }
+
+ writer.WriteEndElement();
+ writer.WriteEndDocument();
+ }
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Exports/YamlInfisicalExporter.cs b/src/PSInfisicalAPI/Exports/YamlInfisicalExporter.cs
new file mode 100644
index 0000000..0d5ac3f
--- /dev/null
+++ b/src/PSInfisicalAPI/Exports/YamlInfisicalExporter.cs
@@ -0,0 +1,62 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using PSInfisicalAPI.Common;
+using PSInfisicalAPI.Errors;
+using PSInfisicalAPI.Models;
+using YamlDotNet.Serialization;
+
+namespace PSInfisicalAPI.Exports
+{
+ public sealed class YamlInfisicalExporter : IInfisicalExporter
+ {
+ public void Export(InfisicalExportRequest request)
+ {
+ if (request == null || request.Secrets == null) { throw new InfisicalExportException("Export request is invalid."); }
+ if (request.Path == null) { throw new InfisicalExportException("Path is required for YAML export."); }
+
+ InfisicalPath.EnsureParentDirectoryExists(request.Path);
+
+ List> entries = new List>();
+
+ foreach (InfisicalSecret secret in request.Secrets)
+ {
+ if (secret == null) { continue; }
+
+ secret.UsePlainTextValue(plainValue =>
+ {
+ Dictionary entry = new Dictionary();
+ entry["SecretName"] = secret.SecretName;
+ entry["SecretValue"] = plainValue;
+ entry["SecretPath"] = secret.SecretPath;
+ entry["SecretMetadata"] = MetadataToDictionary(secret);
+ entries.Add(entry);
+ });
+ }
+
+ Dictionary root = new Dictionary();
+ root["Secrets"] = entries;
+
+ ISerializer serializer = new SerializerBuilder().Build();
+ string serialized = serializer.Serialize(root);
+
+ Encoding encoding = request.Encoding ?? new UTF8Encoding(false);
+ File.WriteAllText(request.Path.FullName, serialized, encoding);
+ }
+
+ private static Dictionary MetadataToDictionary(InfisicalSecret secret)
+ {
+ Dictionary metadata = new Dictionary();
+
+ if (secret.SecretMetadata == null) { return metadata; }
+
+ foreach (InfisicalSecretMetadata entry in secret.SecretMetadata)
+ {
+ if (entry == null || string.IsNullOrEmpty(entry.Key)) { continue; }
+ metadata[entry.Key] = entry.Value;
+ }
+
+ return metadata;
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Http/IInfisicalHttpClient.cs b/src/PSInfisicalAPI/Http/IInfisicalHttpClient.cs
new file mode 100644
index 0000000..9b8928e
--- /dev/null
+++ b/src/PSInfisicalAPI/Http/IInfisicalHttpClient.cs
@@ -0,0 +1,7 @@
+namespace PSInfisicalAPI.Http
+{
+ public interface IInfisicalHttpClient
+ {
+ InfisicalHttpResponse Send(InfisicalHttpRequest request);
+ }
+}
diff --git a/src/PSInfisicalAPI/Http/InfisicalHttpClient.cs b/src/PSInfisicalAPI/Http/InfisicalHttpClient.cs
new file mode 100644
index 0000000..1c417d0
--- /dev/null
+++ b/src/PSInfisicalAPI/Http/InfisicalHttpClient.cs
@@ -0,0 +1,142 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net;
+using System.Text;
+using PSInfisicalAPI.Errors;
+using PSInfisicalAPI.Logging;
+
+namespace PSInfisicalAPI.Http
+{
+ public sealed class InfisicalHttpClient : IInfisicalHttpClient
+ {
+ private const string Component = "HttpClient";
+ private readonly IInfisicalLogger _logger;
+ private readonly int _timeoutSeconds;
+
+ public InfisicalHttpClient(IInfisicalLogger logger, int timeoutSeconds = 100)
+ {
+ _logger = logger ?? NullInfisicalLogger.Instance;
+ _timeoutSeconds = timeoutSeconds;
+ }
+
+ public InfisicalHttpResponse Send(InfisicalHttpRequest request)
+ {
+ if (request == null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ if (request.Uri == null)
+ {
+ throw new InfisicalHttpException("Request URI must be provided.");
+ }
+
+ string operation = string.IsNullOrEmpty(request.OperationName) ? request.EndpointName : request.OperationName;
+
+ _logger.Verbose(Component, string.Concat("Attempting HTTP ", request.Method, " to ", request.Uri.GetLeftPart(UriPartial.Path), ". Please Wait..."));
+
+ HttpWebRequest webRequest = (HttpWebRequest)WebRequest.Create(request.Uri);
+ webRequest.Method = string.IsNullOrEmpty(request.Method) ? "GET" : request.Method.ToUpperInvariant();
+ webRequest.Accept = "application/json";
+ webRequest.UserAgent = "PSInfisicalAPI";
+ webRequest.Timeout = _timeoutSeconds * 1000;
+ webRequest.ReadWriteTimeout = _timeoutSeconds * 1000;
+
+ ApplyHeaders(webRequest, request.Headers);
+
+ if (!string.IsNullOrEmpty(request.Body))
+ {
+ byte[] bytes = Encoding.UTF8.GetBytes(request.Body);
+ webRequest.ContentType = string.IsNullOrEmpty(request.ContentType) ? "application/json" : request.ContentType;
+ webRequest.ContentLength = bytes.Length;
+
+ using (Stream requestStream = webRequest.GetRequestStream())
+ {
+ requestStream.Write(bytes, 0, bytes.Length);
+ }
+ }
+
+ InfisicalHttpResponse response = new InfisicalHttpResponse
+ {
+ ContainsSecretMaterialInResponse = request.ContainsSecretMaterialInResponse
+ };
+
+ try
+ {
+ using (HttpWebResponse webResponse = (HttpWebResponse)webRequest.GetResponse())
+ {
+ PopulateResponse(response, webResponse);
+ }
+
+ _logger.Verbose(Component, string.Concat("HTTP ", webRequest.Method, " completed with status ", response.StatusCode.ToString(System.Globalization.CultureInfo.InvariantCulture), "."));
+
+ return response;
+ }
+ catch (WebException webException)
+ {
+ HttpWebResponse errorResponse = webException.Response as HttpWebResponse;
+ if (errorResponse != null)
+ {
+ PopulateResponse(response, errorResponse);
+ errorResponse.Close();
+ return response;
+ }
+
+ throw new InfisicalHttpException(string.Concat("HTTP request failed: ", webException.Message), webException);
+ }
+ }
+
+ private static void ApplyHeaders(HttpWebRequest webRequest, IDictionary headers)
+ {
+ if (headers == null)
+ {
+ return;
+ }
+
+ foreach (KeyValuePair entry in headers)
+ {
+ if (string.IsNullOrEmpty(entry.Key))
+ {
+ continue;
+ }
+
+ string headerName = entry.Key;
+ string headerValue = entry.Value ?? string.Empty;
+
+ if (string.Equals(headerName, "Accept", StringComparison.OrdinalIgnoreCase)) { webRequest.Accept = headerValue; continue; }
+ if (string.Equals(headerName, "User-Agent", StringComparison.OrdinalIgnoreCase)) { webRequest.UserAgent = headerValue; continue; }
+ if (string.Equals(headerName, "Content-Type", StringComparison.OrdinalIgnoreCase)) { webRequest.ContentType = headerValue; continue; }
+
+ webRequest.Headers[headerName] = headerValue;
+ }
+ }
+
+ private static void PopulateResponse(InfisicalHttpResponse response, HttpWebResponse webResponse)
+ {
+ response.StatusCode = (int)webResponse.StatusCode;
+ response.ReasonPhrase = webResponse.StatusDescription;
+ response.Headers = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (string headerName in webResponse.Headers.AllKeys)
+ {
+ response.Headers[headerName] = webResponse.Headers[headerName];
+ }
+
+ using (Stream responseStream = webResponse.GetResponseStream())
+ {
+ if (responseStream != null)
+ {
+ using (StreamReader reader = new StreamReader(responseStream, Encoding.UTF8))
+ {
+ response.Body = reader.ReadToEnd();
+ }
+ }
+ else
+ {
+ response.Body = string.Empty;
+ }
+ }
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Http/InfisicalHttpRequest.cs b/src/PSInfisicalAPI/Http/InfisicalHttpRequest.cs
new file mode 100644
index 0000000..fb81c67
--- /dev/null
+++ b/src/PSInfisicalAPI/Http/InfisicalHttpRequest.cs
@@ -0,0 +1,18 @@
+using System;
+using System.Collections.Generic;
+
+namespace PSInfisicalAPI.Http
+{
+ public sealed class InfisicalHttpRequest
+ {
+ public string OperationName { get; set; }
+ public string EndpointName { get; set; }
+ public string Method { get; set; }
+ public Uri Uri { get; set; }
+ public Dictionary Headers { get; set; }
+ public string Body { get; set; }
+ public string ContentType { get; set; }
+ public bool ContainsSecretMaterialInRequest { get; set; }
+ public bool ContainsSecretMaterialInResponse { get; set; }
+ }
+}
diff --git a/src/PSInfisicalAPI/Http/InfisicalHttpResponse.cs b/src/PSInfisicalAPI/Http/InfisicalHttpResponse.cs
new file mode 100644
index 0000000..80b7cbd
--- /dev/null
+++ b/src/PSInfisicalAPI/Http/InfisicalHttpResponse.cs
@@ -0,0 +1,22 @@
+using System.Collections.Generic;
+
+namespace PSInfisicalAPI.Http
+{
+ public sealed class InfisicalHttpResponse
+ {
+ public int StatusCode { get; set; }
+ public string ReasonPhrase { get; set; }
+ public string Body { get; set; }
+ public Dictionary Headers { get; set; }
+ public bool ContainsSecretMaterialInResponse { get; set; }
+
+ public void Clear()
+ {
+ Body = null;
+ if (Headers != null)
+ {
+ Headers.Clear();
+ }
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Http/InfisicalUriBuilder.cs b/src/PSInfisicalAPI/Http/InfisicalUriBuilder.cs
new file mode 100644
index 0000000..510b14f
--- /dev/null
+++ b/src/PSInfisicalAPI/Http/InfisicalUriBuilder.cs
@@ -0,0 +1,125 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using PSInfisicalAPI.Endpoints;
+using PSInfisicalAPI.Errors;
+
+namespace PSInfisicalAPI.Http
+{
+ public static class InfisicalUriBuilder
+ {
+ public static Uri Build(Uri baseUri, InfisicalEndpointDefinition definition, IDictionary pathParameters, IEnumerable> queryParameters)
+ {
+ if (baseUri == null)
+ {
+ throw new InfisicalConfigurationException("Base URI must be provided.");
+ }
+
+ if (definition == null)
+ {
+ throw new InfisicalConfigurationException("Endpoint definition must be provided.");
+ }
+
+ string template = definition.Template ?? string.Empty;
+ string resolvedPath = ResolvePathTemplate(template, pathParameters);
+
+ UriBuilder uriBuilder = new UriBuilder(baseUri);
+ uriBuilder.Path = CombinePaths(uriBuilder.Path, resolvedPath);
+ uriBuilder.Query = BuildQueryString(queryParameters);
+
+ return uriBuilder.Uri;
+ }
+
+ public static string BuildQueryString(IEnumerable> queryParameters)
+ {
+ if (queryParameters == null)
+ {
+ return string.Empty;
+ }
+
+ StringBuilder builder = new StringBuilder();
+ bool first = true;
+
+ foreach (KeyValuePair entry in queryParameters)
+ {
+ if (string.IsNullOrEmpty(entry.Key))
+ {
+ continue;
+ }
+
+ if (entry.Value == null)
+ {
+ continue;
+ }
+
+ if (!first)
+ {
+ builder.Append('&');
+ }
+
+ builder.Append(Uri.EscapeDataString(entry.Key));
+ builder.Append('=');
+ builder.Append(Uri.EscapeDataString(entry.Value));
+ first = false;
+ }
+
+ return builder.ToString();
+ }
+
+ public static string ResolvePathTemplate(string template, IDictionary pathParameters)
+ {
+ if (string.IsNullOrEmpty(template))
+ {
+ return string.Empty;
+ }
+
+ if (pathParameters == null || pathParameters.Count == 0)
+ {
+ if (template.IndexOf('{') >= 0)
+ {
+ throw new InfisicalConfigurationException(string.Concat("Path template '", template, "' requires parameters but none were supplied."));
+ }
+
+ return template;
+ }
+
+ string resolved = template;
+ foreach (KeyValuePair entry in pathParameters)
+ {
+ string token = string.Concat("{", entry.Key, "}");
+ if (resolved.IndexOf(token, StringComparison.Ordinal) < 0)
+ {
+ continue;
+ }
+
+ string escaped = Uri.EscapeDataString(entry.Value ?? string.Empty);
+ resolved = resolved.Replace(token, escaped);
+ }
+
+ if (resolved.IndexOf('{') >= 0)
+ {
+ throw new InfisicalConfigurationException(string.Concat("Path template '", template, "' has unresolved tokens."));
+ }
+
+ return resolved;
+ }
+
+ private static string CombinePaths(string left, string right)
+ {
+ string l = string.IsNullOrEmpty(left) ? "/" : left;
+ string r = right ?? string.Empty;
+
+ if (l.EndsWith("/", StringComparison.Ordinal) && r.StartsWith("/", StringComparison.Ordinal))
+ {
+ return string.Concat(l, r.Substring(1));
+ }
+
+ if (!l.EndsWith("/", StringComparison.Ordinal) && !r.StartsWith("/", StringComparison.Ordinal))
+ {
+ return string.Concat(l, "/", r);
+ }
+
+ return string.Concat(l, r);
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Logging/IInfisicalLogger.cs b/src/PSInfisicalAPI/Logging/IInfisicalLogger.cs
new file mode 100644
index 0000000..18a7891
--- /dev/null
+++ b/src/PSInfisicalAPI/Logging/IInfisicalLogger.cs
@@ -0,0 +1,11 @@
+namespace PSInfisicalAPI.Logging
+{
+ public interface IInfisicalLogger
+ {
+ void Information(string component, string message);
+ void Verbose(string component, string message);
+ void Debug(string component, string message);
+ void Warning(string component, string message);
+ void Error(string component, string message);
+ }
+}
diff --git a/src/PSInfisicalAPI/Logging/InfisicalLogFormatter.cs b/src/PSInfisicalAPI/Logging/InfisicalLogFormatter.cs
new file mode 100644
index 0000000..afbf1db
--- /dev/null
+++ b/src/PSInfisicalAPI/Logging/InfisicalLogFormatter.cs
@@ -0,0 +1,22 @@
+using System;
+using System.Globalization;
+
+namespace PSInfisicalAPI.Logging
+{
+ public static class InfisicalLogFormatter
+ {
+ public static string Format(DateTimeOffset utcTimestamp, InfisicalLogLevel level, string component, string message)
+ {
+ string timestamp = utcTimestamp.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ", CultureInfo.InvariantCulture);
+ string normalizedComponent = string.IsNullOrEmpty(component) ? "Unspecified" : component;
+ string normalizedMessage = message ?? string.Empty;
+
+ return string.Concat("[", timestamp, "] - [", level.ToString(), "] - [", normalizedComponent, "] - ", normalizedMessage);
+ }
+
+ public static string FormatNow(InfisicalLogLevel level, string component, string message)
+ {
+ return Format(DateTimeOffset.UtcNow, level, component, message);
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Logging/InfisicalLogLevel.cs b/src/PSInfisicalAPI/Logging/InfisicalLogLevel.cs
new file mode 100644
index 0000000..a7dffb0
--- /dev/null
+++ b/src/PSInfisicalAPI/Logging/InfisicalLogLevel.cs
@@ -0,0 +1,11 @@
+namespace PSInfisicalAPI.Logging
+{
+ public enum InfisicalLogLevel
+ {
+ Information,
+ Verbose,
+ Debug,
+ Warning,
+ Error
+ }
+}
diff --git a/src/PSInfisicalAPI/Logging/NullInfisicalLogger.cs b/src/PSInfisicalAPI/Logging/NullInfisicalLogger.cs
new file mode 100644
index 0000000..07b934f
--- /dev/null
+++ b/src/PSInfisicalAPI/Logging/NullInfisicalLogger.cs
@@ -0,0 +1,15 @@
+namespace PSInfisicalAPI.Logging
+{
+ public sealed class NullInfisicalLogger : IInfisicalLogger
+ {
+ public static readonly NullInfisicalLogger Instance = new NullInfisicalLogger();
+
+ private NullInfisicalLogger() { }
+
+ public void Information(string component, string message) { }
+ public void Verbose(string component, string message) { }
+ public void Debug(string component, string message) { }
+ public void Warning(string component, string message) { }
+ public void Error(string component, string message) { }
+ }
+}
diff --git a/src/PSInfisicalAPI/Logging/PSCmdletLogger.cs b/src/PSInfisicalAPI/Logging/PSCmdletLogger.cs
new file mode 100644
index 0000000..69b6d30
--- /dev/null
+++ b/src/PSInfisicalAPI/Logging/PSCmdletLogger.cs
@@ -0,0 +1,51 @@
+using System;
+using System.Management.Automation;
+
+namespace PSInfisicalAPI.Logging
+{
+ public sealed class PSCmdletLogger : IInfisicalLogger
+ {
+ private readonly Cmdlet _cmdlet;
+
+ public PSCmdletLogger(Cmdlet cmdlet)
+ {
+ _cmdlet = cmdlet ?? throw new ArgumentNullException(nameof(cmdlet));
+ }
+
+ public void Information(string component, string message)
+ {
+ string line = InfisicalLogFormatter.FormatNow(InfisicalLogLevel.Information, component, message);
+ _cmdlet.WriteVerbose(line);
+ }
+
+ public void Verbose(string component, string message)
+ {
+ string line = InfisicalLogFormatter.FormatNow(InfisicalLogLevel.Verbose, component, message);
+ _cmdlet.WriteVerbose(line);
+ }
+
+ public void Debug(string component, string message)
+ {
+ string line = InfisicalLogFormatter.FormatNow(InfisicalLogLevel.Debug, component, message);
+ _cmdlet.WriteDebug(line);
+ }
+
+ public void Warning(string component, string message)
+ {
+ string line = InfisicalLogFormatter.FormatNow(InfisicalLogLevel.Warning, component, message);
+ _cmdlet.WriteWarning(line);
+ }
+
+ public void Error(string component, string message)
+ {
+ string line = InfisicalLogFormatter.FormatNow(InfisicalLogLevel.Error, component, message);
+ ErrorRecord record = new ErrorRecord(
+ new InvalidOperationException(message ?? string.Empty),
+ "PSInfisicalAPI.Error",
+ ErrorCategory.NotSpecified,
+ component);
+ record.ErrorDetails = new ErrorDetails(line);
+ _cmdlet.WriteError(record);
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Models/InfisicalAuthType.cs b/src/PSInfisicalAPI/Models/InfisicalAuthType.cs
new file mode 100644
index 0000000..10e8496
--- /dev/null
+++ b/src/PSInfisicalAPI/Models/InfisicalAuthType.cs
@@ -0,0 +1,8 @@
+namespace PSInfisicalAPI.Models
+{
+ public enum InfisicalAuthType
+ {
+ UniversalAuth,
+ Token
+ }
+}
diff --git a/src/PSInfisicalAPI/Models/InfisicalDuplicateKeyBehavior.cs b/src/PSInfisicalAPI/Models/InfisicalDuplicateKeyBehavior.cs
new file mode 100644
index 0000000..f24d807
--- /dev/null
+++ b/src/PSInfisicalAPI/Models/InfisicalDuplicateKeyBehavior.cs
@@ -0,0 +1,9 @@
+namespace PSInfisicalAPI.Models
+{
+ public enum InfisicalDuplicateKeyBehavior
+ {
+ Error,
+ FirstWins,
+ LastWins
+ }
+}
diff --git a/src/PSInfisicalAPI/Models/InfisicalExportFormat.cs b/src/PSInfisicalAPI/Models/InfisicalExportFormat.cs
new file mode 100644
index 0000000..c02d4e9
--- /dev/null
+++ b/src/PSInfisicalAPI/Models/InfisicalExportFormat.cs
@@ -0,0 +1,11 @@
+namespace PSInfisicalAPI.Models
+{
+ public enum InfisicalExportFormat
+ {
+ Json,
+ Yaml,
+ Env,
+ Xml,
+ EnvironmentVariables
+ }
+}
diff --git a/src/PSInfisicalAPI/Models/InfisicalSecret.cs b/src/PSInfisicalAPI/Models/InfisicalSecret.cs
new file mode 100644
index 0000000..0fee725
--- /dev/null
+++ b/src/PSInfisicalAPI/Models/InfisicalSecret.cs
@@ -0,0 +1,42 @@
+using System;
+using System.Security;
+using PSInfisicalAPI.Security;
+
+namespace PSInfisicalAPI.Models
+{
+ public sealed class InfisicalSecret
+ {
+ public string Id { get; set; }
+ public string InternalId { get; set; }
+ public string Workspace { get; set; }
+ public string Environment { get; set; }
+ public int? Version { get; set; }
+ public InfisicalSecretType Type { get; set; }
+ public string SecretName { get; set; }
+ public SecureString SecretValue { get; set; }
+ public bool SecretValueHidden { get; set; }
+ public string SecretPath { get; set; }
+ public string SecretComment { get; set; }
+ public DateTimeOffset? CreatedAtUtc { get; set; }
+ public DateTimeOffset? UpdatedAtUtc { get; set; }
+ public bool IsRotatedSecret { get; set; }
+ public Guid? RotationId { get; set; }
+ public InfisicalSecretTag[] Tags { get; set; }
+ public InfisicalSecretMetadata[] SecretMetadata { get; set; }
+
+ public T UsePlainTextValue(Func action)
+ {
+ return SecureStringUtility.UsePlainText(SecretValue, action);
+ }
+
+ public void UsePlainTextValue(Action action)
+ {
+ SecureStringUtility.UsePlainText(SecretValue, action);
+ }
+
+ public override string ToString()
+ {
+ return SecretName;
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Models/InfisicalSecretMetadata.cs b/src/PSInfisicalAPI/Models/InfisicalSecretMetadata.cs
new file mode 100644
index 0000000..7719283
--- /dev/null
+++ b/src/PSInfisicalAPI/Models/InfisicalSecretMetadata.cs
@@ -0,0 +1,8 @@
+namespace PSInfisicalAPI.Models
+{
+ public sealed class InfisicalSecretMetadata
+ {
+ public string Key { get; set; }
+ public string Value { get; set; }
+ }
+}
diff --git a/src/PSInfisicalAPI/Models/InfisicalSecretTag.cs b/src/PSInfisicalAPI/Models/InfisicalSecretTag.cs
new file mode 100644
index 0000000..17b7f6a
--- /dev/null
+++ b/src/PSInfisicalAPI/Models/InfisicalSecretTag.cs
@@ -0,0 +1,10 @@
+namespace PSInfisicalAPI.Models
+{
+ public sealed class InfisicalSecretTag
+ {
+ public string Id { get; set; }
+ public string Slug { get; set; }
+ public string Name { get; set; }
+ public string Color { get; set; }
+ }
+}
diff --git a/src/PSInfisicalAPI/Models/InfisicalSecretType.cs b/src/PSInfisicalAPI/Models/InfisicalSecretType.cs
new file mode 100644
index 0000000..3214750
--- /dev/null
+++ b/src/PSInfisicalAPI/Models/InfisicalSecretType.cs
@@ -0,0 +1,8 @@
+namespace PSInfisicalAPI.Models
+{
+ public enum InfisicalSecretType
+ {
+ Shared,
+ Personal
+ }
+}
diff --git a/src/PSInfisicalAPI/PSInfisicalAPI.csproj b/src/PSInfisicalAPI/PSInfisicalAPI.csproj
new file mode 100644
index 0000000..896fcdf
--- /dev/null
+++ b/src/PSInfisicalAPI/PSInfisicalAPI.csproj
@@ -0,0 +1,37 @@
+
+
+
+ netstandard2.0
+ 9.0
+ PSInfisicalAPI
+ PSInfisicalAPI
+ false
+ $(NoWarn);CS1591;NU1701
+ false
+ $(BuildVersion)
+ $(BuildAssemblyVersion)
+ $(BuildAssemblyVersion)
+ $(BuildVersion)
+
+ Alphaeus Mote
+ PSInfisicalAPI
+ true
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+ <_Parameter1>CommitHash
+ <_Parameter2>$(BuildCommitHash)
+
+
+
+
+
diff --git a/src/PSInfisicalAPI/Secrets/InfisicalSecretDtos.cs b/src/PSInfisicalAPI/Secrets/InfisicalSecretDtos.cs
new file mode 100644
index 0000000..83f8eaf
--- /dev/null
+++ b/src/PSInfisicalAPI/Secrets/InfisicalSecretDtos.cs
@@ -0,0 +1,58 @@
+using System.Collections.Generic;
+using Newtonsoft.Json;
+
+namespace PSInfisicalAPI.Secrets
+{
+ internal sealed class InfisicalSecretResponseDto
+ {
+ [JsonProperty("id")] public string Id { get; set; }
+ [JsonProperty("_id")] public string InternalId { get; set; }
+ [JsonProperty("workspace")] public string Workspace { get; set; }
+ [JsonProperty("environment")] public string Environment { get; set; }
+ [JsonProperty("version")] public int? Version { get; set; }
+ [JsonProperty("type")] public string Type { get; set; }
+ [JsonProperty("secretKey")] public string SecretKey { get; set; }
+ [JsonProperty("secretValue")] public string SecretValue { get; set; }
+ [JsonProperty("secretValueHidden")] public bool SecretValueHidden { get; set; }
+ [JsonProperty("secretPath")] public string SecretPath { get; set; }
+ [JsonProperty("secretComment")] public string SecretComment { get; set; }
+ [JsonProperty("createdAt")] public string CreatedAt { get; set; }
+ [JsonProperty("updatedAt")] public string UpdatedAt { get; set; }
+ [JsonProperty("isRotatedSecret")] public bool IsRotatedSecret { get; set; }
+ [JsonProperty("rotationId")] public string RotationId { get; set; }
+ [JsonProperty("tags")] public List Tags { get; set; }
+ [JsonProperty("secretMetadata")] public List SecretMetadata { get; set; }
+ }
+
+ internal sealed class InfisicalSecretTagDto
+ {
+ [JsonProperty("id")] public string Id { get; set; }
+ [JsonProperty("slug")] public string Slug { get; set; }
+ [JsonProperty("name")] public string Name { get; set; }
+ [JsonProperty("color")] public string Color { get; set; }
+ }
+
+ internal sealed class InfisicalSecretMetadataDto
+ {
+ [JsonProperty("key")] public string Key { get; set; }
+ [JsonProperty("value")] public string Value { get; set; }
+ }
+
+ internal sealed class InfisicalSecretListResponseDto
+ {
+ [JsonProperty("secrets")] public List Secrets { get; set; }
+ [JsonProperty("imports")] public List Imports { get; set; }
+ }
+
+ internal sealed class InfisicalSecretImportDto
+ {
+ [JsonProperty("secretPath")] public string SecretPath { get; set; }
+ [JsonProperty("environment")] public string Environment { get; set; }
+ [JsonProperty("secrets")] public List Secrets { get; set; }
+ }
+
+ internal sealed class InfisicalSecretSingleResponseDto
+ {
+ [JsonProperty("secret")] public InfisicalSecretResponseDto Secret { get; set; }
+ }
+}
diff --git a/src/PSInfisicalAPI/Secrets/InfisicalSecretMapper.cs b/src/PSInfisicalAPI/Secrets/InfisicalSecretMapper.cs
new file mode 100644
index 0000000..b61ce69
--- /dev/null
+++ b/src/PSInfisicalAPI/Secrets/InfisicalSecretMapper.cs
@@ -0,0 +1,150 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using PSInfisicalAPI.Models;
+using PSInfisicalAPI.Security;
+
+namespace PSInfisicalAPI.Secrets
+{
+ internal static class InfisicalSecretMapper
+ {
+ public static InfisicalSecret Map(InfisicalSecretResponseDto dto)
+ {
+ if (dto == null)
+ {
+ return null;
+ }
+
+ InfisicalSecret secret = new InfisicalSecret
+ {
+ Id = dto.Id,
+ InternalId = dto.InternalId,
+ Workspace = dto.Workspace,
+ Environment = dto.Environment,
+ Version = dto.Version,
+ Type = ParseType(dto.Type),
+ SecretName = dto.SecretKey,
+ SecretValue = SecureStringUtility.ToReadOnlySecureString(dto.SecretValue),
+ SecretValueHidden = dto.SecretValueHidden,
+ SecretPath = dto.SecretPath,
+ SecretComment = dto.SecretComment,
+ CreatedAtUtc = ParseTimestamp(dto.CreatedAt),
+ UpdatedAtUtc = ParseTimestamp(dto.UpdatedAt),
+ IsRotatedSecret = dto.IsRotatedSecret,
+ RotationId = ParseGuid(dto.RotationId),
+ Tags = MapTags(dto.Tags),
+ SecretMetadata = MapMetadata(dto.SecretMetadata)
+ };
+
+ dto.SecretValue = null;
+
+ return secret;
+ }
+
+ public static InfisicalSecret[] MapMany(IEnumerable items)
+ {
+ if (items == null)
+ {
+ return Array.Empty();
+ }
+
+ List results = new List();
+ foreach (InfisicalSecretResponseDto dto in items)
+ {
+ InfisicalSecret mapped = Map(dto);
+ if (mapped != null)
+ {
+ results.Add(mapped);
+ }
+ }
+
+ return results.ToArray();
+ }
+
+ private static InfisicalSecretType ParseType(string value)
+ {
+ if (string.Equals(value, "personal", StringComparison.OrdinalIgnoreCase))
+ {
+ return InfisicalSecretType.Personal;
+ }
+
+ return InfisicalSecretType.Shared;
+ }
+
+ private static DateTimeOffset? ParseTimestamp(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return null;
+ }
+
+ DateTimeOffset parsed;
+ if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out parsed))
+ {
+ return parsed;
+ }
+
+ return null;
+ }
+
+ private static Guid? ParseGuid(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return null;
+ }
+
+ Guid parsed;
+ if (Guid.TryParse(value, out parsed))
+ {
+ return parsed;
+ }
+
+ return null;
+ }
+
+ private static InfisicalSecretTag[] MapTags(List tags)
+ {
+ if (tags == null || tags.Count == 0)
+ {
+ return Array.Empty();
+ }
+
+ InfisicalSecretTag[] mapped = new InfisicalSecretTag[tags.Count];
+ for (int index = 0; index < tags.Count; index++)
+ {
+ InfisicalSecretTagDto tag = tags[index];
+ mapped[index] = new InfisicalSecretTag
+ {
+ Id = tag.Id,
+ Slug = tag.Slug,
+ Name = tag.Name,
+ Color = tag.Color
+ };
+ }
+
+ return mapped;
+ }
+
+ private static InfisicalSecretMetadata[] MapMetadata(List metadata)
+ {
+ if (metadata == null || metadata.Count == 0)
+ {
+ return Array.Empty();
+ }
+
+ InfisicalSecretMetadata[] mapped = new InfisicalSecretMetadata[metadata.Count];
+ for (int index = 0; index < metadata.Count; index++)
+ {
+ InfisicalSecretMetadataDto entry = metadata[index];
+ mapped[index] = new InfisicalSecretMetadata
+ {
+ Key = entry.Key,
+ Value = entry.Value
+ };
+ }
+
+ return mapped;
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Secrets/InfisicalSecretQuery.cs b/src/PSInfisicalAPI/Secrets/InfisicalSecretQuery.cs
new file mode 100644
index 0000000..fd3561a
--- /dev/null
+++ b/src/PSInfisicalAPI/Secrets/InfisicalSecretQuery.cs
@@ -0,0 +1,31 @@
+using System.Collections.Generic;
+
+namespace PSInfisicalAPI.Secrets
+{
+ public sealed class InfisicalListSecretsQuery
+ {
+ public string ProjectId { get; set; }
+ public string Environment { get; set; }
+ public string SecretPath { get; set; }
+ public bool Recursive { get; set; }
+ public bool? IncludeImports { get; set; }
+ public bool IncludePersonalOverrides { get; set; }
+ public bool? ExpandSecretReferences { get; set; }
+ public bool? ViewSecretValue { get; set; }
+ public Dictionary MetadataFilter { get; set; }
+ public string[] TagSlugs { get; set; }
+ }
+
+ public sealed class InfisicalRetrieveSecretQuery
+ {
+ public string SecretName { get; set; }
+ public string ProjectId { get; set; }
+ public string Environment { get; set; }
+ public string SecretPath { get; set; }
+ public int? Version { get; set; }
+ public string Type { get; set; }
+ public bool? ViewSecretValue { get; set; }
+ public bool? ExpandSecretReferences { get; set; }
+ public bool? IncludeImports { get; set; }
+ }
+}
diff --git a/src/PSInfisicalAPI/Secrets/InfisicalSecretsClient.cs b/src/PSInfisicalAPI/Secrets/InfisicalSecretsClient.cs
new file mode 100644
index 0000000..3a2468b
--- /dev/null
+++ b/src/PSInfisicalAPI/Secrets/InfisicalSecretsClient.cs
@@ -0,0 +1,199 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using PSInfisicalAPI.Connections;
+using PSInfisicalAPI.Endpoints;
+using PSInfisicalAPI.Errors;
+using PSInfisicalAPI.Http;
+using PSInfisicalAPI.Logging;
+using PSInfisicalAPI.Models;
+using PSInfisicalAPI.Security;
+using PSInfisicalAPI.Serialization;
+
+namespace PSInfisicalAPI.Secrets
+{
+ public sealed class InfisicalSecretsClient
+ {
+ private const string Component = "SecretsClient";
+
+ private readonly IInfisicalHttpClient _httpClient;
+ private readonly IInfisicalLogger _logger;
+ private readonly JsonInfisicalSerializer _serializer;
+
+ public InfisicalSecretsClient(IInfisicalHttpClient httpClient, IInfisicalLogger logger)
+ {
+ _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ _logger = logger ?? NullInfisicalLogger.Instance;
+ _serializer = new JsonInfisicalSerializer();
+ }
+
+ public InfisicalSecret[] List(InfisicalConnection connection, InfisicalListSecretsQuery query)
+ {
+ if (connection == null) { throw new ArgumentNullException(nameof(connection)); }
+ if (query == null) { throw new ArgumentNullException(nameof(query)); }
+
+ InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(InfisicalEndpointNames.ListSecrets);
+
+ List> queryParameters = new List>();
+ AddIfNotNull(queryParameters, "workspaceId", FirstNonEmpty(query.ProjectId, connection.ProjectId));
+ AddIfNotNull(queryParameters, "environment", FirstNonEmpty(query.Environment, connection.Environment));
+ AddIfNotNull(queryParameters, "secretPath", FirstNonEmpty(query.SecretPath, connection.DefaultSecretPath, "/"));
+ queryParameters.Add(new KeyValuePair("recursive", query.Recursive ? "true" : "false"));
+ if (query.IncludeImports.HasValue) { queryParameters.Add(new KeyValuePair("include_imports", query.IncludeImports.Value ? "true" : "false")); }
+ if (query.IncludePersonalOverrides) { queryParameters.Add(new KeyValuePair("includePersonalOverrides", "true")); }
+ if (query.ExpandSecretReferences.HasValue) { queryParameters.Add(new KeyValuePair("expandSecretReferences", query.ExpandSecretReferences.Value ? "true" : "false")); }
+ if (query.ViewSecretValue.HasValue) { queryParameters.Add(new KeyValuePair("viewSecretValue", query.ViewSecretValue.Value ? "true" : "false")); }
+
+ if (query.TagSlugs != null)
+ {
+ foreach (string tag in query.TagSlugs)
+ {
+ AddIfNotNull(queryParameters, "tagSlugs", tag);
+ }
+ }
+
+ if (query.MetadataFilter != null)
+ {
+ foreach (KeyValuePair meta in query.MetadataFilter)
+ {
+ AddIfNotNull(queryParameters, string.Concat("metadata[", meta.Key, "]"), meta.Value);
+ }
+ }
+
+ Uri uri = InfisicalUriBuilder.Build(connection.BaseUri, definition, null, queryParameters);
+ InfisicalHttpResponse response = ExecuteAuthorized(connection, definition, "RetrieveSecrets", uri, null);
+
+ try
+ {
+ _logger.Information(Component, "Attempting to retrieve Infisical secrets. Please Wait...");
+ EnsureSuccess(response, definition);
+
+ InfisicalSecretListResponseDto dto = _serializer.Deserialize(response.Body);
+ response.Clear();
+
+ InfisicalSecret[] mapped = InfisicalSecretMapper.MapMany(dto != null ? dto.Secrets : null);
+ _logger.Information(Component, "Infisical secrets retrieval was successful.");
+ return mapped;
+ }
+ catch (Exception)
+ {
+ _logger.Error(Component, "Infisical secrets retrieval failed.");
+ throw;
+ }
+ }
+
+ public InfisicalSecret Retrieve(InfisicalConnection connection, InfisicalRetrieveSecretQuery query)
+ {
+ if (connection == null) { throw new ArgumentNullException(nameof(connection)); }
+ if (query == null) { throw new ArgumentNullException(nameof(query)); }
+ if (string.IsNullOrEmpty(query.SecretName)) { throw new InfisicalConfigurationException("SecretName is required."); }
+
+ InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(InfisicalEndpointNames.RetrieveSecret);
+
+ Dictionary pathParameters = new Dictionary { { "secretName", query.SecretName } };
+
+ List> queryParameters = new List>();
+ AddIfNotNull(queryParameters, "workspaceId", FirstNonEmpty(query.ProjectId, connection.ProjectId));
+ AddIfNotNull(queryParameters, "environment", FirstNonEmpty(query.Environment, connection.Environment));
+ AddIfNotNull(queryParameters, "secretPath", FirstNonEmpty(query.SecretPath, connection.DefaultSecretPath, "/"));
+ AddIfNotNull(queryParameters, "type", string.IsNullOrEmpty(query.Type) ? "shared" : query.Type.ToLowerInvariant());
+ if (query.Version.HasValue) { queryParameters.Add(new KeyValuePair("version", query.Version.Value.ToString(CultureInfo.InvariantCulture))); }
+ if (query.ViewSecretValue.HasValue) { queryParameters.Add(new KeyValuePair("viewSecretValue", query.ViewSecretValue.Value ? "true" : "false")); }
+ if (query.ExpandSecretReferences.HasValue) { queryParameters.Add(new KeyValuePair("expandSecretReferences", query.ExpandSecretReferences.Value ? "true" : "false")); }
+ if (query.IncludeImports.HasValue) { queryParameters.Add(new KeyValuePair("include_imports", query.IncludeImports.Value ? "true" : "false")); }
+
+ Uri uri = InfisicalUriBuilder.Build(connection.BaseUri, definition, pathParameters, queryParameters);
+ InfisicalHttpResponse response = ExecuteAuthorized(connection, definition, "RetrieveSecret", uri, null);
+
+ try
+ {
+ _logger.Information(Component, string.Concat("Attempting to retrieve Infisical secret '", query.SecretName, "'. Please Wait..."));
+ EnsureSuccess(response, definition);
+
+ InfisicalSecretSingleResponseDto dto = _serializer.Deserialize(response.Body);
+ response.Clear();
+
+ InfisicalSecret mapped = InfisicalSecretMapper.Map(dto != null ? dto.Secret : null);
+ _logger.Information(Component, "Infisical secret retrieval was successful.");
+ return mapped;
+ }
+ catch (Exception)
+ {
+ _logger.Error(Component, "Infisical secret retrieval failed.");
+ throw;
+ }
+ }
+
+ private InfisicalHttpResponse ExecuteAuthorized(InfisicalConnection connection, InfisicalEndpointDefinition definition, string operationName, Uri uri, string body)
+ {
+ Dictionary headers = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ headers["Accept"] = "application/json";
+
+ if (definition.RequiresAuthorization)
+ {
+ if (connection.AccessToken == null)
+ {
+ throw new InfisicalAuthenticationException("Connection does not contain an access token.");
+ }
+
+ SecureStringUtility.UsePlainText(connection.AccessToken, plainToken =>
+ {
+ headers["Authorization"] = string.Concat("Bearer ", plainToken ?? string.Empty);
+ });
+ }
+
+ InfisicalHttpRequest request = new InfisicalHttpRequest
+ {
+ OperationName = operationName,
+ EndpointName = definition.Name,
+ Method = definition.Method,
+ Uri = uri,
+ Headers = headers,
+ Body = body,
+ ContainsSecretMaterialInRequest = definition.ContainsSecretMaterialInRequest,
+ ContainsSecretMaterialInResponse = definition.ContainsSecretMaterialInResponse
+ };
+
+ return _httpClient.Send(request);
+ }
+
+ private static void EnsureSuccess(InfisicalHttpResponse response, InfisicalEndpointDefinition definition)
+ {
+ if (response.StatusCode >= 200 && response.StatusCode < 300)
+ {
+ return;
+ }
+
+ InfisicalApiException exception = new InfisicalApiException(string.Concat("Infisical API returned ", response.StatusCode.ToString(CultureInfo.InvariantCulture), " (", response.ReasonPhrase ?? string.Empty, ")."));
+ exception.StatusCode = response.StatusCode;
+ exception.ReasonPhrase = response.ReasonPhrase;
+ exception.EndpointName = definition.Name;
+ exception.RequestMethod = definition.Method;
+ exception.SanitizedBody = definition.ContainsSecretMaterialInResponse ? "[REDACTED]" : response.Body;
+ response.Clear();
+ throw exception;
+ }
+
+ private static void AddIfNotNull(List> list, string key, string value)
+ {
+ if (!string.IsNullOrEmpty(value))
+ {
+ list.Add(new KeyValuePair(key, value));
+ }
+ }
+
+ private static string FirstNonEmpty(params string[] values)
+ {
+ if (values == null) { return null; }
+ foreach (string value in values)
+ {
+ if (!string.IsNullOrEmpty(value))
+ {
+ return value;
+ }
+ }
+
+ return null;
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Security/SecureStringUtility.cs b/src/PSInfisicalAPI/Security/SecureStringUtility.cs
new file mode 100644
index 0000000..f2d5ad4
--- /dev/null
+++ b/src/PSInfisicalAPI/Security/SecureStringUtility.cs
@@ -0,0 +1,76 @@
+using System;
+using System.Runtime.InteropServices;
+using System.Security;
+
+namespace PSInfisicalAPI.Security
+{
+ public static class SecureStringUtility
+ {
+ public static SecureString ToReadOnlySecureString(string value)
+ {
+ SecureString secureString = new SecureString();
+
+ if (!string.IsNullOrEmpty(value))
+ {
+ foreach (char character in value)
+ {
+ secureString.AppendChar(character);
+ }
+ }
+
+ secureString.MakeReadOnly();
+
+ return secureString;
+ }
+
+ public static SecureString EmptyReadOnly()
+ {
+ SecureString secureString = new SecureString();
+ secureString.MakeReadOnly();
+ return secureString;
+ }
+
+ public static T UsePlainText(SecureString secureString, Func action)
+ {
+ if (secureString == null)
+ {
+ throw new ArgumentNullException(nameof(secureString));
+ }
+
+ if (action == null)
+ {
+ throw new ArgumentNullException(nameof(action));
+ }
+
+ IntPtr pointer = IntPtr.Zero;
+
+ try
+ {
+ pointer = Marshal.SecureStringToBSTR(secureString);
+ string plainText = Marshal.PtrToStringBSTR(pointer);
+ return action(plainText);
+ }
+ finally
+ {
+ if (pointer != IntPtr.Zero)
+ {
+ Marshal.ZeroFreeBSTR(pointer);
+ }
+ }
+ }
+
+ public static void UsePlainText(SecureString secureString, Action action)
+ {
+ if (action == null)
+ {
+ throw new ArgumentNullException(nameof(action));
+ }
+
+ UsePlainText(secureString, plainText =>
+ {
+ action(plainText);
+ return true;
+ });
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Serialization/IInfisicalSerializer.cs b/src/PSInfisicalAPI/Serialization/IInfisicalSerializer.cs
new file mode 100644
index 0000000..ba579b5
--- /dev/null
+++ b/src/PSInfisicalAPI/Serialization/IInfisicalSerializer.cs
@@ -0,0 +1,8 @@
+namespace PSInfisicalAPI.Serialization
+{
+ public interface IInfisicalSerializer
+ {
+ string Serialize(T value);
+ T Deserialize(string value);
+ }
+}
diff --git a/src/PSInfisicalAPI/Serialization/JsonInfisicalSerializer.cs b/src/PSInfisicalAPI/Serialization/JsonInfisicalSerializer.cs
new file mode 100644
index 0000000..399e121
--- /dev/null
+++ b/src/PSInfisicalAPI/Serialization/JsonInfisicalSerializer.cs
@@ -0,0 +1,62 @@
+using Newtonsoft.Json;
+using Newtonsoft.Json.Serialization;
+using PSInfisicalAPI.Errors;
+
+namespace PSInfisicalAPI.Serialization
+{
+ public sealed class JsonInfisicalSerializer : IInfisicalSerializer
+ {
+ private readonly JsonSerializerSettings _settings;
+
+ public JsonInfisicalSerializer(bool useCamelCase = true, bool indent = false)
+ {
+ _settings = new JsonSerializerSettings
+ {
+ NullValueHandling = NullValueHandling.Ignore,
+ Formatting = indent ? Formatting.Indented : Formatting.None,
+ DateParseHandling = DateParseHandling.DateTimeOffset
+ };
+
+ if (useCamelCase)
+ {
+ _settings.ContractResolver = new CamelCasePropertyNamesContractResolver();
+ }
+ }
+
+ public string Serialize(T value)
+ {
+ try
+ {
+ return JsonConvert.SerializeObject(value, _settings);
+ }
+ catch (JsonException jsonException)
+ {
+ throw new InfisicalSerializationException(string.Concat("JSON serialization failed: ", jsonException.Message), jsonException);
+ }
+ }
+
+ public T Deserialize(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return default(T);
+ }
+
+ try
+ {
+ return JsonConvert.DeserializeObject(value, _settings);
+ }
+ catch (JsonReaderException readerException)
+ {
+ InfisicalSerializationException exception = new InfisicalSerializationException(string.Concat("JSON deserialization failed: ", readerException.Message), readerException);
+ exception.LineNumber = readerException.LineNumber;
+ exception.LinePosition = readerException.LinePosition;
+ throw exception;
+ }
+ catch (JsonException jsonException)
+ {
+ throw new InfisicalSerializationException(string.Concat("JSON deserialization failed: ", jsonException.Message), jsonException);
+ }
+ }
+ }
+}