PSInfisical.Extension/PSInfisical.Extension.psm1

# PSInfisical.Extension.psm1
# SecretManagement vault extension for PSInfisical.
# Provides the 5 required functions: Get-Secret, Set-Secret, Remove-Secret,
# Get-SecretInfo, Test-SecretVault.
# Called by: Microsoft.PowerShell.SecretManagement when a registered vault is accessed.
# Dependencies: PSInfisical module (parent), Microsoft.PowerShell.SecretManagement

#Requires -Version 5.1

Set-StrictMode -Version Latest

# The parent PSInfisical module's functions (Connect-Infisical, Get-InfisicalSecret, etc.)
# are available because this module is loaded as a NestedModule of PSInfisical.psd1.
# Do NOT import the parent here — that would create a circular dependency.

# Session cache: keyed by vault name, value is an InfisicalSession object.
# Avoids re-authenticating on every SecretManagement call.
$script:SessionCache = @{}

# Reserved metadata key used to record the original SecretManagement SecretType.
# Lets Get-Secret deserialize a stored secret back into the same shape (e.g.
# PSCredential, Hashtable) the caller originally handed to Set-Secret.
$script:TypeTagKey = 'PSInfisicalSecretType'

# Marker placed in serialised Hashtable values so nested SecureStrings can be
# rebuilt during ConvertFrom-InfisicalSecretPayload.
$script:SecureStringMarkerKey = '__PSInfisicalSecureString__'

# ---------------------------------------------------------------------------
# Internal helpers (not exported)
# ---------------------------------------------------------------------------

function Get-OrCreateSession {
    <#
    .SYNOPSIS
        Ensures a valid PSInfisical session exists for the specified vault.

    .DESCRIPTION
        Checks the session cache for an existing, non-expired session for the
        given vault name. If found, injects it into the PSInfisical module scope.
        If not found or expired, authenticates using credentials from
        AdditionalParameters and caches the new session.

    .OUTPUTS
        [void]
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)]
        [string] $VaultName,

        [Parameter(Mandatory)]
        [hashtable] $AdditionalParameters
    )

    # Check for a cached session that is still valid
    if ($script:SessionCache.ContainsKey($VaultName)) {
        $cachedSession = $script:SessionCache[$VaultName]
        $cachedSession.UpdateConnectionStatus()
        if ($cachedSession.Connected -and -not $cachedSession.IsTokenExpiringSoon()) {
            # Inject cached session into PSInfisical module scope
            & (Get-Module PSInfisical) { param($s) $script:InfisicalSession = $s } $cachedSession
            return
        }
        # Session expired or expiring — will re-authenticate below
    }

    # Extract connection parameters with defaults
    $apiUrl      = if ($AdditionalParameters.ContainsKey('ApiUrl'))      { $AdditionalParameters['ApiUrl'] }      else { 'https://app.infisical.com' }
    $projectId   = $AdditionalParameters['ProjectId']
    $environment = if ($AdditionalParameters.ContainsKey('Environment')) { $AdditionalParameters['Environment'] } else { 'prod' }

    # Validate required parameters
    if ([string]::IsNullOrEmpty($projectId)) {
        $errorRecord = [System.Management.Automation.ErrorRecord]::new(
            [System.ArgumentException]::new("Vault '$VaultName': VaultParameters must include 'ProjectId'."),
            'InfisicalVaultMissingProjectId',
            [System.Management.Automation.ErrorCategory]::InvalidArgument,
            $VaultName
        )
        $PSCmdlet.ThrowTerminatingError($errorRecord)
    }

    # Build Connect-Infisical parameters
    $connectParams = @{
        ApiUrl      = $apiUrl
        ProjectId   = $projectId
        Environment = $environment
        PassThru    = $true
    }

    # Determine auth method from provided parameters
    if ($AdditionalParameters.ContainsKey('ClientId') -and $AdditionalParameters.ContainsKey('ClientSecret')) {
        $connectParams['ClientId'] = $AdditionalParameters['ClientId']
        $connectParams['ClientSecret'] = ConvertTo-SessionSecureString -Value $AdditionalParameters['ClientSecret']
    }
    elseif ($AdditionalParameters.ContainsKey('Token')) {
        $connectParams['Token'] = ConvertTo-SessionSecureString -Value $AdditionalParameters['Token']
    }
    elseif ($AdditionalParameters.ContainsKey('AccessToken')) {
        $connectParams['AccessToken'] = ConvertTo-SessionSecureString -Value $AdditionalParameters['AccessToken']
    }
    elseif ($AdditionalParameters.ContainsKey('AWSIdentityDocument')) {
        $connectParams['AWSIdentityDocument'] = $AdditionalParameters['AWSIdentityDocument']
    }
    elseif ($AdditionalParameters.ContainsKey('AzureJwt')) {
        $connectParams['AzureJwt'] = ConvertTo-SessionSecureString -Value $AdditionalParameters['AzureJwt']
    }
    elseif ($AdditionalParameters.ContainsKey('GCPIdentityToken')) {
        $connectParams['GCPIdentityToken'] = ConvertTo-SessionSecureString -Value $AdditionalParameters['GCPIdentityToken']
    }
    elseif ($AdditionalParameters.ContainsKey('KubernetesServiceAccountToken') -and $AdditionalParameters.ContainsKey('KubernetesIdentityId')) {
        $connectParams['KubernetesServiceAccountToken'] = ConvertTo-SessionSecureString -Value $AdditionalParameters['KubernetesServiceAccountToken']
        $connectParams['KubernetesIdentityId'] = $AdditionalParameters['KubernetesIdentityId']
    }
    elseif ($AdditionalParameters.ContainsKey('OIDCToken') -and $AdditionalParameters.ContainsKey('OIDCIdentityId')) {
        $connectParams['OIDCToken'] = ConvertTo-SessionSecureString -Value $AdditionalParameters['OIDCToken']
        $connectParams['OIDCIdentityId'] = $AdditionalParameters['OIDCIdentityId']
    }
    elseif ($AdditionalParameters.ContainsKey('Jwt') -and $AdditionalParameters.ContainsKey('JwtIdentityId')) {
        $connectParams['Jwt'] = ConvertTo-SessionSecureString -Value $AdditionalParameters['Jwt']
        $connectParams['JwtIdentityId'] = $AdditionalParameters['JwtIdentityId']
    }
    elseif ($AdditionalParameters.ContainsKey('LDAPUsername') -and $AdditionalParameters.ContainsKey('LDAPPassword')) {
        $connectParams['LDAPUsername'] = $AdditionalParameters['LDAPUsername']
        $connectParams['LDAPPassword'] = ConvertTo-SessionSecureString -Value $AdditionalParameters['LDAPPassword']
    }
    else {
        $errorRecord = [System.Management.Automation.ErrorRecord]::new(
            [System.ArgumentException]::new("Vault '$VaultName': VaultParameters must include authentication credentials (ClientId+ClientSecret, Token, or AccessToken)."),
            'InfisicalVaultMissingCredentials',
            [System.Management.Automation.ErrorCategory]::InvalidArgument,
            $VaultName
        )
        $PSCmdlet.ThrowTerminatingError($errorRecord)
    }

    # Null out the PSInfisical module's session to prevent Connect-Infisical
    # from disposing a cached session belonging to a different vault.
    & (Get-Module PSInfisical) { $script:InfisicalSession = $null }

    $session = Connect-Infisical @connectParams
    $script:SessionCache[$VaultName] = $session
}

function ConvertTo-SessionSecureString {
    <#
    .SYNOPSIS
        Converts a value to SecureString if it is not already one.

    .DESCRIPTION
        Register-SecretVault -VaultParameters stores values as their original
        types. Users may pass plain strings or SecureStrings for credentials.
        This helper normalises both to SecureString.

    .OUTPUTS
        [System.Security.SecureString]
    #>

    [CmdletBinding()]
    [OutputType([System.Security.SecureString])]
    param(
        [Parameter(Mandatory)]
        [object] $Value
    )

    if ($Value -is [System.Security.SecureString]) {
        return $Value
    }

    $secureString = [System.Security.SecureString]::new()
    foreach ($char in $Value.ToString().ToCharArray()) {
        $secureString.AppendChar($char)
    }
    $secureString.MakeReadOnly()
    return $secureString
}

function Resolve-SecretPath {
    <#
    .SYNOPSIS
        Returns the secret path from AdditionalParameters or the default '/'.

    .OUTPUTS
        [string]
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [hashtable] $AdditionalParameters
    )

    if ($AdditionalParameters.ContainsKey('SecretPath') -and -not [string]::IsNullOrEmpty($AdditionalParameters['SecretPath'])) {
        return $AdditionalParameters['SecretPath']
    }
    return '/'
}

function Resolve-InfisicalName {
    <#
    .SYNOPSIS
        Splits a SecretManagement -Name into an Infisical key + secret path.

    .DESCRIPTION
        SecretManagement's API exposes only a single -Name parameter, but Infisical
        models secrets as (key, secretPath). Callers that want hierarchical naming
        (e.g. 'team/role/host') would otherwise have the slash-bearing name rejected
        by the Infisical API, whose keys must be identifier-shaped.

        This helper splits -Name on its last '/'. The prefix (if any) is appended
        to the vault's configured SecretPath; the final segment becomes the bare
        key. A leading '/' on -Name is treated as 'relative to BasePath' rather
        than absolute — vault isolation always wins.

    .OUTPUTS
        [hashtable] with keys Name (bare key) and SecretPath (absolute folder path).
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [string] $Name,

        [Parameter(Mandatory)]
        [string] $BasePath
    )

    $base = $BasePath.TrimEnd('/')
    if ([string]::IsNullOrEmpty($base)) { $base = '' }

    $lastSlash = $Name.LastIndexOf('/')
    if ($lastSlash -lt 0) {
        $resolvedPath = if ([string]::IsNullOrEmpty($base)) { '/' } else { $base }
        return @{ Name = $Name; SecretPath = $resolvedPath }
    }

    $key = $Name.Substring($lastSlash + 1)
    if ([string]::IsNullOrEmpty($key)) {
        throw [System.ArgumentException]::new(
            "Secret name '$Name' is invalid: it ends with '/'. The final segment must be a non-empty key.",
            'Name'
        )
    }

    $relPrefix = $Name.Substring(0, $lastSlash).Trim('/')
    if ([string]::IsNullOrEmpty($relPrefix)) {
        $resolvedPath = if ([string]::IsNullOrEmpty($base)) { '/' } else { $base }
    }
    elseif ([string]::IsNullOrEmpty($base)) {
        $resolvedPath = "/$relPrefix"
    }
    else {
        $resolvedPath = "$base/$relPrefix"
    }

    return @{ Name = $key; SecretPath = $resolvedPath }
}

function Initialize-InfisicalFolderPath {
    <#
    .SYNOPSIS
        Idempotently creates each folder segment along an absolute Infisical path.

    .DESCRIPTION
        Walks $TargetPath segment by segment, calling New-InfisicalFolder for each.
        Existing folders surface as API errors which are downgraded to verbose
        messages — any genuine failure (e.g. permissions) is re-surfaced when the
        secret write itself fails. Idempotent, so Set-Secret can call it on every
        write without worrying about pre-existing paths.

    .OUTPUTS
        [void]
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)]
        [string] $TargetPath
    )

    if ([string]::IsNullOrEmpty($TargetPath) -or $TargetPath -eq '/') {
        return
    }

    $segments = $TargetPath.Trim('/').Split('/', [System.StringSplitOptions]::RemoveEmptyEntries)
    $currentPath = '/'
    foreach ($segment in $segments) {
        try {
            New-InfisicalFolder -Name $segment -SecretPath $currentPath -Confirm:$false -ErrorAction Stop | Out-Null
        }
        catch {
            # Only swallow conflict-shaped errors (folder already exists); rethrow
            # everything else so the caller sees permission/network/server
            # failures at the right layer instead of as a cryptic downstream
            # secret-write error.
            $msg = [string]$_.Exception.Message
            if ($msg -notmatch '(?i)already\s*exists|conflict|duplicate|HTTP\s*409|HTTP\s*400') {
                throw
            }
            Write-Verbose "Initialize-InfisicalFolderPath: folder '$segment' at '$currentPath' already exists; skipping. ($msg)"
        }
        $currentPath = if ($currentPath -eq '/') { "/$segment" } else { "$currentPath/$segment" }
    }
}

function Unprotect-SecureString {
    <#
    .SYNOPSIS
        Returns the plaintext value of a SecureString and zeros the unmanaged
        buffer immediately after the copy.

    .DESCRIPTION
        SecretManagement values pass through plaintext on their way into
        Infisical's JSON storage model — there is no avoiding the managed string
        allocation. The next-best thing is to ensure the unmanaged buffer that
        backed the SecureString is wiped right away rather than waiting for
        NetworkCredential's finalizer to run on a future GC pass.

        The managed string returned by this function still lives until garbage
        collection (PowerShell's string interning makes it impossible to wipe
        deterministically), but the window between extraction and managed-heap
        clearing is narrowed, and the unmanaged buffer is never reachable after
        return.

    .OUTPUTS
        [string]
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [System.Security.SecureString] $Secret
    )

    $bstr = [System.IntPtr]::Zero
    try {
        $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToGlobalAllocUnicode($Secret)
        return [System.Runtime.InteropServices.Marshal]::PtrToStringUni($bstr)
    }
    finally {
        if ($bstr -ne [System.IntPtr]::Zero) {
            [System.Runtime.InteropServices.Marshal]::ZeroFreeGlobalAllocUnicode($bstr)
        }
    }
}

function ConvertTo-HashtableTree {
    <#
    .SYNOPSIS
        Recursively converts PSCustomObject nodes in a deserialised JSON graph
        into Hashtables, preserving arrays and primitives in place.

    .DESCRIPTION
        ConvertFrom-Json emits PSCustomObject for every JSON object. When a
        caller stored @{ a = @{ b = 1 } } on Set-Secret, Get-Secret should give
        them a Hashtable of Hashtables back, not a Hashtable wrapping a
        PSCustomObject. This walks the graph and rebuilds nested objects as
        Hashtables, leaving the SecureString marker sentinel untouched (its
        caller in ConvertFrom-InfisicalSecretPayload inspects it directly).

    .OUTPUTS
        [object] — same shape as the input with PSCustomObject nodes replaced
        by Hashtables.
    #>

    [CmdletBinding()]
    [OutputType([object])]
    param(
        [Parameter(Mandatory)]
        [AllowNull()]
        [object] $InputObject
    )

    if ($null -eq $InputObject) { return $null }

    if ($InputObject -is [System.Management.Automation.PSCustomObject]) {
        $ht = @{}
        foreach ($prop in $InputObject.PSObject.Properties) {
            $ht[$prop.Name] = ConvertTo-HashtableTree -InputObject $prop.Value
        }
        return $ht
    }

    if ($InputObject -is [System.Collections.IList] -and -not ($InputObject -is [string])) {
        $list = foreach ($item in $InputObject) {
            ConvertTo-HashtableTree -InputObject $item
        }
        return ,@($list)
    }

    return $InputObject
}

function ConvertTo-InfisicalSecretPayload {
    <#
    .SYNOPSIS
        Serialises a SecretManagement -Secret value to Infisical's single-string
        storage model, returning the payload and a type tag for round-tripping.

    .DESCRIPTION
        Infisical secrets are a single opaque string per key. SecretManagement
        allows five SecretTypes (ByteArray, String, SecureString, PSCredential,
        Hashtable). This helper normalises whichever the caller passed into a
        plaintext string plus a type tag, so ConvertFrom-InfisicalSecretPayload
        can rebuild the original type on read.

        For Hashtable inputs, nested SecureString values are converted to
        plaintext with a marker object so they too can be rebuilt.

    .OUTPUTS
        [hashtable] with keys Value (string) and Type (one of String,
        SecureString, PSCredential, Hashtable, ByteArray).
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'SecureString contents are intentionally serialised for storage; protection is provided by Infisical at rest.')]
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [object] $Secret
    )

    if ($Secret -is [System.Security.SecureString]) {
        return @{
            Value = Unprotect-SecureString -Secret $Secret
            Type  = 'SecureString'
        }
    }

    if ($Secret -is [System.Management.Automation.PSCredential]) {
        $pwPlain = Unprotect-SecureString -Secret $Secret.Password
        $json = @{ UserName = $Secret.UserName; Password = $pwPlain } | ConvertTo-Json -Compress
        return @{ Value = $json; Type = 'PSCredential' }
    }

    if ($Secret -is [byte[]]) {
        return @{
            Value = [System.Convert]::ToBase64String($Secret)
            Type  = 'ByteArray'
        }
    }

    if ($Secret -is [System.Collections.IDictionary]) {
        $serialised = @{}
        foreach ($key in $Secret.Keys) {
            $value = $Secret[$key]
            if ($value -is [System.Security.SecureString]) {
                $serialised[[string]$key] = @{
                    $script:SecureStringMarkerKey = $true
                    v = Unprotect-SecureString -Secret $value
                }
            }
            else {
                $serialised[[string]$key] = $value
            }
        }
        $json = $serialised | ConvertTo-Json -Compress -Depth 10
        return @{ Value = $json; Type = 'Hashtable' }
    }

    if ($Secret -is [string]) {
        return @{ Value = $Secret; Type = 'String' }
    }

    throw [System.ArgumentException]::new(
        "Unsupported -Secret type '$($Secret.GetType().FullName)'. SecretManagement supports byte[], String, SecureString, PSCredential, and Hashtable.",
        'Secret'
    )
}

function ConvertFrom-InfisicalSecretPayload {
    <#
    .SYNOPSIS
        Rebuilds a SecretManagement-typed value from a stored Infisical secret.

    .DESCRIPTION
        Inverse of ConvertTo-InfisicalSecretPayload. When the type tag is missing
        (e.g. legacy or UI-created secrets) the value is returned as a
        SecureString — that was the extension's behaviour before typed payloads
        were introduced and preserves backward compatibility.

        For 'Hashtable' payloads, nested JSON objects are recursively converted
        back into Hashtables so the input/output shape is symmetric: what the
        caller handed to Set-Secret is what they get back from Get-Secret.

    .OUTPUTS
        [object] — concrete type depends on $Type.
    #>

    [CmdletBinding()]
    [OutputType([object])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $Value,

        [Parameter()]
        [string] $Type
    )

    if ([string]::IsNullOrEmpty($Type) -or $Type -eq 'SecureString') {
        return ConvertTo-PlainSecureString -Plain $Value
    }

    switch ($Type) {
        'String'    { return $Value }
        'ByteArray' {
            # Unary comma prevents PowerShell from unrolling the byte[] return
            # value into a generic Object[] across the function boundary.
            return ,[System.Convert]::FromBase64String($Value)
        }
        'PSCredential' {
            $obj = $Value | ConvertFrom-Json
            $pw = ConvertTo-PlainSecureString -Plain ([string]$obj.Password)
            return [System.Management.Automation.PSCredential]::new([string]$obj.UserName, $pw)
        }
        'Hashtable' {
            $obj = $Value | ConvertFrom-Json
            $ht = @{}
            foreach ($prop in $obj.PSObject.Properties) {
                $entry = $prop.Value
                if ($entry -is [PSCustomObject] -and
                    $entry.PSObject.Properties[$script:SecureStringMarkerKey] -and
                    $entry.$($script:SecureStringMarkerKey)) {
                    # Sentinel-wrapped SecureString — rebuild it directly without
                    # descending further into the marker object.
                    $ht[$prop.Name] = ConvertTo-PlainSecureString -Plain ([string]$entry.v)
                }
                else {
                    # Recursively rebuild nested PSCustomObjects as Hashtables so
                    # the in/out shape is symmetric for arbitrary depth.
                    $ht[$prop.Name] = ConvertTo-HashtableTree -InputObject $entry
                }
            }
            return $ht
        }
        default {
            Write-Warning "Unknown $script:TypeTagKey value '$Type' on stored secret; returning raw value as SecureString."
            return ConvertTo-PlainSecureString -Plain $Value
        }
    }
}

function ConvertTo-PlainSecureString {
    <#
    .SYNOPSIS
        Wraps a plaintext string in a read-only SecureString.

    .OUTPUTS
        [System.Security.SecureString]
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'Plaintext originated from an authenticated Infisical fetch; wrapping it in a SecureString is the safest representation we can offer the caller.')]
    [CmdletBinding()]
    [OutputType([System.Security.SecureString])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $Plain
    )

    $ss = [System.Security.SecureString]::new()
    foreach ($c in $Plain.ToCharArray()) { $ss.AppendChar($c) }
    $ss.MakeReadOnly()
    return $ss
}

function ConvertTo-SecretManagementType {
    <#
    .SYNOPSIS
        Maps a stored type tag to a SecretManagement.SecretType enum value.

    .OUTPUTS
        [Microsoft.PowerShell.SecretManagement.SecretType]
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [string] $Tag
    )

    # Resolve at runtime — see Get-SecretInfo for why we avoid parse-time literals.
    $SecretTypeEnum = 'Microsoft.PowerShell.SecretManagement.SecretType' -as [type]

    switch ($Tag) {
        'String'       { return $SecretTypeEnum::String }
        'PSCredential' { return $SecretTypeEnum::PSCredential }
        'Hashtable'    { return $SecretTypeEnum::Hashtable }
        'ByteArray'    { return $SecretTypeEnum::ByteArray }
        default        { return $SecretTypeEnum::SecureString }
    }
}

function Get-RelativeSecretName {
    <#
    .SYNOPSIS
        Builds the SecretManagement-visible name for a secret stored at a sub-path.

    .DESCRIPTION
        Given the vault's configured BasePath, the secret's absolute SecretPath,
        and its bare key, returns either '<key>' (when at the base) or
        '<relative>/<key>' (when nested below the base). This is the inverse of
        Resolve-InfisicalName for outbound naming.

    .OUTPUTS
        [string]
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string] $BasePath,

        [Parameter(Mandatory)]
        [string] $SecretPath,

        [Parameter(Mandatory)]
        [string] $Name
    )

    $base = $BasePath.TrimEnd('/')
    if ([string]::IsNullOrEmpty($base)) { $base = '' }

    $abs = $SecretPath.TrimEnd('/')
    if ([string]::IsNullOrEmpty($abs)) { $abs = '' }

    if ($abs -eq $base) {
        return $Name
    }

    $relative = $abs
    if (-not [string]::IsNullOrEmpty($base) -and $abs.StartsWith("$base/")) {
        $relative = $abs.Substring($base.Length + 1)
    }
    elseif ([string]::IsNullOrEmpty($base) -and $abs.StartsWith('/')) {
        $relative = $abs.Substring(1)
    }

    if ([string]::IsNullOrEmpty($relative)) {
        return $Name
    }
    return "$relative/$Name"
}

# ---------------------------------------------------------------------------
# Required SecretManagement extension functions
# ---------------------------------------------------------------------------

function Get-Secret {
    <#
    .SYNOPSIS
        Retrieves a secret value from Infisical.

    .DESCRIPTION
        Called by Microsoft.PowerShell.SecretManagement when the user runs
        Get-Secret -Vault <VaultName>. Returns the secret value as a
        SecureString, or $null if the secret does not exist.

    .OUTPUTS
        [System.Security.SecureString]
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $Name,

        [Parameter(Mandatory)]
        [string] $VaultName,

        [Parameter(Mandatory)]
        [hashtable] $AdditionalParameters
    )

    Get-OrCreateSession -VaultName $VaultName -AdditionalParameters $AdditionalParameters
    $basePath = Resolve-SecretPath -AdditionalParameters $AdditionalParameters
    $resolved = Resolve-InfisicalName -Name $Name -BasePath $basePath

    $secret = Get-InfisicalSecret -Name $resolved.Name -SecretPath $resolved.SecretPath -ErrorAction SilentlyContinue
    if ($null -eq $secret) {
        return $null
    }

    $typeTag = $null
    if ($null -ne $secret.Metadata -and $secret.Metadata.ContainsKey($script:TypeTagKey)) {
        $typeTag = [string]$secret.Metadata[$script:TypeTagKey]
    }

    # Fast path: legacy (untagged) and SecureString secrets bypass the
    # plaintext round-trip — InfisicalSecret.Value is already a SecureString.
    if ([string]::IsNullOrEmpty($typeTag) -or $typeTag -eq 'SecureString') {
        return $secret.Value
    }

    return ConvertFrom-InfisicalSecretPayload -Value $secret.GetValue() -Type $typeTag
}

function Set-Secret {
    <#
    .SYNOPSIS
        Creates or updates a secret in Infisical.

    .DESCRIPTION
        Called by Microsoft.PowerShell.SecretManagement when the user runs
        Set-Secret -Vault <VaultName>. Checks whether the secret exists first,
        then updates or creates accordingly.

    .OUTPUTS
        [bool]
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Function signature is defined by SecretManagement')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $Name,

        [Parameter(Mandatory)]
        [object] $Secret,

        [Parameter(Mandatory)]
        [string] $VaultName,

        [Parameter(Mandatory)]
        [hashtable] $AdditionalParameters
    )

    Get-OrCreateSession -VaultName $VaultName -AdditionalParameters $AdditionalParameters
    $basePath = Resolve-SecretPath -AdditionalParameters $AdditionalParameters
    $resolved = Resolve-InfisicalName -Name $Name -BasePath $basePath
    Write-Verbose "Set-Secret: '$Name' resolved to key='$($resolved.Name)' path='$($resolved.SecretPath)' (base='$basePath')"

    # Serialise the typed -Secret to Infisical's single-string storage model and
    # capture the original SecretType for round-trip on read.
    $payload = ConvertTo-InfisicalSecretPayload -Secret $Secret
    $secureValue = ConvertTo-PlainSecureString -Plain $payload.Value

    # Check existence so we can choose create vs update and preserve any custom
    # metadata the user attached via the Infisical UI.
    $existing = Get-InfisicalSecret -Name $resolved.Name -SecretPath $resolved.SecretPath -ErrorAction SilentlyContinue

    $metadata = @{}
    if ($null -ne $existing -and $null -ne $existing.Metadata) {
        foreach ($k in $existing.Metadata.Keys) { $metadata[[string]$k] = $existing.Metadata[$k] }
    }
    $metadata[$script:TypeTagKey] = $payload.Type

    if ($null -ne $existing) {
        Set-InfisicalSecret -Name $resolved.Name -Value $secureValue -SecretPath $resolved.SecretPath -Metadata $metadata -Confirm:$false -ErrorAction Stop
    }
    else {
        # New secret at a sub-path: ensure the folder hierarchy exists first.
        if ($resolved.SecretPath -ne $basePath -and $resolved.SecretPath -ne '/') {
            Initialize-InfisicalFolderPath -TargetPath $resolved.SecretPath
        }
        New-InfisicalSecret -Name $resolved.Name -Value $secureValue -SecretPath $resolved.SecretPath -Metadata $metadata -Confirm:$false -ErrorAction Stop
    }

    return $true
}

function Remove-Secret {
    <#
    .SYNOPSIS
        Removes a secret from Infisical.

    .DESCRIPTION
        Called by Microsoft.PowerShell.SecretManagement when the user runs
        Remove-Secret -Vault <VaultName>.

    .OUTPUTS
        [bool]
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Function signature is defined by SecretManagement')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $Name,

        [Parameter(Mandatory)]
        [string] $VaultName,

        [Parameter(Mandatory)]
        [hashtable] $AdditionalParameters
    )

    Get-OrCreateSession -VaultName $VaultName -AdditionalParameters $AdditionalParameters
    $basePath = Resolve-SecretPath -AdditionalParameters $AdditionalParameters
    $resolved = Resolve-InfisicalName -Name $Name -BasePath $basePath

    Remove-InfisicalSecret -Name $resolved.Name -SecretPath $resolved.SecretPath -Confirm:$false -ErrorAction Stop

    return $true
}

function Get-SecretInfo {
    <#
    .SYNOPSIS
        Lists secrets in Infisical as SecretInformation objects.

    .DESCRIPTION
        Called by Microsoft.PowerShell.SecretManagement when the user runs
        Get-SecretInfo -Vault <VaultName>. Returns metadata about secrets
        without exposing their values.

    .OUTPUTS
        [Microsoft.PowerShell.SecretManagement.SecretInformation]
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [string] $Filter,

        [Parameter(Mandatory)]
        [string] $VaultName,

        [Parameter(Mandatory)]
        [hashtable] $AdditionalParameters
    )

    Get-OrCreateSession -VaultName $VaultName -AdditionalParameters $AdditionalParameters
    $basePath = Resolve-SecretPath -AdditionalParameters $AdditionalParameters

    # Recurse so secrets nested under sub-paths are discoverable. Each result's
    # name is qualified with its relative path so it can round-trip through
    # Get/Set/Remove-Secret as 'subpath/key'.
    $secrets = Get-InfisicalSecrets -SecretPath $basePath -Recursive -ErrorAction Stop

    if ($null -eq $secrets) {
        return @()
    }

    # Resolve SecretManagement types at runtime rather than parse time.
    # When this module is loaded as a NestedModule of PSInfisical, SecretManagement
    # may not yet be imported, causing parse-time [type] literals to fail.
    $SecretInfoType = 'Microsoft.PowerShell.SecretManagement.SecretInformation' -as [type]

    $named = foreach ($s in $secrets) {
        $tag = $null
        if ($null -ne $s.Metadata -and $s.Metadata.ContainsKey($script:TypeTagKey)) {
            $tag = [string]$s.Metadata[$script:TypeTagKey]
        }
        [pscustomobject]@{
            Secret = $s
            Name   = Get-RelativeSecretName -BasePath $basePath -SecretPath $s.Path -Name $s.Name
            Type   = ConvertTo-SecretManagementType -Tag $tag
        }
    }

    if (-not [string]::IsNullOrEmpty($Filter)) {
        $named = @($named | Where-Object { $_.Name -like $Filter })
    }

    foreach ($entry in $named) {
        $SecretInfoType::new(
            $entry.Name,
            $entry.Type,
            $VaultName
        )
    }
}

function Test-SecretVault {
    <#
    .SYNOPSIS
        Tests whether the Infisical vault is accessible.

    .DESCRIPTION
        Called by Microsoft.PowerShell.SecretManagement when the user runs
        Test-SecretVault -Name <VaultName>. Validates authentication and
        API connectivity by listing secrets.

    .OUTPUTS
        [bool]
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $VaultName,

        [Parameter(Mandatory)]
        [hashtable] $AdditionalParameters
    )

    try {
        Get-OrCreateSession -VaultName $VaultName -AdditionalParameters $AdditionalParameters
        $secretPath = Resolve-SecretPath -AdditionalParameters $AdditionalParameters

        # Attempt a lightweight API call to verify connectivity and permissions
        $null = Get-InfisicalSecrets -SecretPath $secretPath -ErrorAction Stop
        return $true
    }
    catch {
        Write-Verbose "Test-SecretVault: Vault '$VaultName' test failed: $($_.Exception.Message)"
        return $false
    }
}