src/PSGemini.psm1

Function Invoke-GeminiRequest
{
    [CmdletBinding(DefaultParameterSetName='ToScreen')]
    [OutputType([PSCustomObject], ParameterSetName='ToScreen')]
    [OutputType([Void], ParameterSetName='OutFile')]
    [Alias('igemr', 'Invoke-GemRequest')]
    Param(
        [Parameter(Mandatory, Position=0)]
        [Alias('Url')]
        [ValidateNotNullOrEmpty()]
        [Uri] $Uri,

        [Security.Cryptography.X509Certificates.X509Certificate] $Certificate,

        [Switch] $FavIcon,

        [Parameter(ValueFromPipeline)]
        [Alias('Input')]
        [AllowNull()]
        [String] $InputObject,

        [Parameter(ParameterSetName='OutFile')]
        [ValidateNotNullOrEmpty()]
        [String] $OutFile,

        [Switch] $SkipCertificateCheck
    )

    # Version 3.0 was the latest version available at this time.
    Set-StrictMode -Version 3.0

    # PowerShell doesn't recognize the gemini:// scheme. Thus, we need to
    # re-define our URI object with the port explicitly mentioned.
    If ($Uri.Port -eq -1)
    {
        $newURI = "gemini://" + $Uri.Host + ":1965" + $Uri.AbsolutePath + ($Uri.Query ? "?$($Uri.Query)" : '')
        $Uri = $Uri::new($newURI)
    }

    #region Establish TCP connection.
    Write-Verbose "Connecting to $($Uri.Host)"
    Try
    {
        $TcpSocket = [Net.Sockets.TcpClient]::new($Uri.Host, $Uri.Port)
        $TcpStream = $TcpSocket.GetStream()
        $TcpStream.ReadTimeout = 2000 #milliseconds
        Write-Debug 'TCP socket open'

        # Bring up our secure stream.
        # The third parameter disables .NET Core's built-in certificate validation.
        # Trust-on-first-use logic happens a little later on.
        $secureStream = [Net.Security.SslStream]::new($TcpStream, $false, {$true}, $null)

        If ($null -ne $Certificate)
        {
            Write-Verbose "Using a client certificate."
            Write-Debug $Certificate
        }
        Else
        {
            Write-Debug "Not using a client certificate."
        }
        $secureStream.AuthenticateAsClient(
            $Uri.Host,
            $Certificate,
            [Net.SecurityProtocolType]::Tls13 -bor [Net.SecurityProtocolType]::Tls12,
            ($null -ne $Certificate)
        )
        $TcpStream = $secureStream
        Write-Verbose "Connected to $($Uri.Host) with $($TcpStream.SslProtocol)."
        Write-Debug "Using $($TcpStream.SslProtocol) with ciphersuite $($TcpStream.NegotiatedCipherSuite)."
    }
    Catch
    {
        # Throw a non-terminating error so that $? is set properly and the
        # pipeline can continue. This will allow chaining operators to work as
        # intended. Should a future version of this module support pipeline
        # input, that will let this cmdlet keep running with other input URIs.
        $er = [Management.Automation.ErrorRecord]::new(
            [Net.WebException]::new("Could not connect to $($Uri.Host):$($Uri.Port). Aborting."),
            'TlsConnectionFailed',
            [Management.Automation.ErrorCategory]::ConnectionError,
            $Uri
        )
        $er.CategoryInfo.Activity = 'NegotiateTlsConnection'
        $PSCmdlet.WriteError($er)
        Return $null
    }
    #endregion (Establish TCP connection)

    #region Certificate TOFU validation
    If ($SkipCertificateCheck)
    {
        Write-Warning 'Skipping certificate validation. This is not secure!'
    }
    Else
    {
        $cert = $TcpStream.RemoteCertificate
        Write-Debug "This certificate: Fingerprint=$($cert.GetPublicKeyString()) Expires=$(Get-Date $cert.GetExpirationDateString())"
        $trustedCert = Get-PSGeminiKnownCertificate -HostName $Uri.Host
    
        If ($null -ne $trustedCert)
        {
            Write-Debug "Trusted certificate: Fingerprint=$($trustedCert.Fingerprint) Expires=$($trustedCert.ExpirationDate)"
    
            # We've connected to this server before, and this is the same certificate.
            If ($cert.GetPublicKeyString() -eq $trustedCert.Fingerprint)
            {
                Write-Verbose "Certificate validation succeeded."
            }
    
            # We've connected to this server before, but the old certificate expired.
            ElseIf ($trustedCert.ExpirationDate -lt (Get-Date))
            {
                Remove-PSGeminiKnownCertificate -HostName $Uri.Host -Confirm:$false
                Write-Warning "Subsequent visit. Memorizing new certificate for $($Uri.Host)"
                Add-PSGeminiKnownCertificate -HostName $Uri.Host -Fingerprint $cert.GetPublicKeyString() -ExpirationDate (Get-Date $cert.GetExpirationDateString())
            }
    
            # We've connected to the server before, but the old certificate should
            # still be valid.
            Else
            {
                Write-Error "$($Uri.Host) presented a new certificate, and the memorized one is still valid. Failing the connection for your own safety."
                Write-Debug "This: $($trustedCert.Fingerprint))"
                Write-Debug "That: $($cert.GetPublicKeyString())"
                Return $null
            }
        }
        Else
        {
            Write-Warning "First visit. Memorizing new certificate for $($Uri.Host)"
            Add-PSGeminiKnownCertificate -HostName $Uri.Host -Fingerprint $cert.GetPublicKeyString() -ExpirationDate (Get-Date $cert.GetExpirationDateString())
        }    
    }
    #endregion SSL validation

    #region Send data
    # The Gemini specification (Section 2) requires the GET request to be
    # limited to 1024 bytes, so that's what we're doing here.
    $ToSend = $Uri.AbsoluteUri.Substring(0, [Math]::Min(1024, $Uri.AbsoluteUri.Length)) + "`r`n"

    Write-Verbose "Sending $($ToSend.Length) bytes to server."
    Write-Debug   "Sending $($ToSend.Length) bytes to server: $($ToSend -Replace "`r",'\r' -Replace "`n",'\n' -Replace "`t",'\t')"
    
    $writer = [IO.StreamWriter]::new($TcpStream)
    $writer.WriteLine($ToSend)
    $writer.Flush()
    #endregion (send data)

    #region Get response header
    $response = ''
    $Encoder = [Text.UTF8Encoding]::new()
    $buffer = New-Object Byte[] 1029    # <STATUS><SPACE><META><CR><LF>

    While (($response -NotLike "*`r`n") -and (0 -ne ($bytesRead = $TcpStream.Read($buffer, 0, 1))))
    {
        Write-Debug "`tReading a byte from the server."
        $response += $Encoder.GetString($buffer, 0, $bytesRead)
    }
    Write-Verbose "Received $($Encoder.GetByteCount($response)) bytes from server."

    # Extract only the first line.
    $Status, $Meta = ($response -Split "`r`n")[0] -Split ' ',2
    $Meta = $Meta.Trim()
    Write-Debug "Response: $response"
    Write-Verbose "Recieved a status $Status with meta: $Meta"

    # This Switch statement will handle the Gemini server's response. In all
    # error cases, we will throw a non-terminating error so that $? is set
    # properly and the pipeline can continue. This will allow chaining operators
    # to work as intended. Should a future version of this module support
    # pipeline input, that will let this cmdlet keep running with other input
    # URIs.
    Switch ($Status)
    { 
        10    # input
        {
            $InputObject ??= Read-Host -Prompt ($Meta ?? 'The server is requesting input: ')
            Return (Invoke-GeminiRequest "$Uri?$InputObject" -OutFile $OutFile)
        }

        11    # sensitive input
        {
            $InputObject ??= Read-Host -Prompt ($Meta ?? 'The server is requesting input: ') -MaskInput
            Return (Invoke-GeminiRequest "$Uri?$InputObject" -OutFile $OutFile)
        }

        20    # success
        {
            # If the server didn't send a MIME type, assume it to be this.
            # See section 3.3 of the Gemini specification.
            $Meta ??= 'text/gemini; charset=utf-8'
            Break
        }

        30    # temporary redirect
        {
            Write-Warning "Temporary redirect encountered. Redirecting to $Meta"
            Return (Invoke-GeminiRequest $Meta -OutFile:$OutFile)
        }

        31    # permanent redirect
        {
            Write-Warning "Permanent redirect encountered. Redirecting to $Meta"
            Return (Invoke-GeminiRequest $Meta -OutFile:$OutFile)
        }

        40    # temporary failure
        {
            $er = [Management.Automation.ErrorRecord]::new(
                [Net.ProtocolViolationException]::new("A temporary failure occurred. The server said: $Meta"),
                'TemporaryFailure',
                [Management.Automation.ErrorCategory]::ConnectionError,
                $Uri
            )
            $er.CategoryInfo.Activity = 'ParseResponseHeader'
            $PSCmdlet.WriteError($er)
            Return $null
        }

        41    # server not available
        {
            $er = [Management.Automation.ErrorRecord]::new(
                [Net.ProtocolViolationException]::new("The server is not available. The server said: $Meta"),
                'ServerUnavailable',
                [Management.Automation.ErrorCategory]::ConnectionError,
                $Uri
            )
            $er.CategoryInfo.Activity = 'ParseResponseHeader'
            $PSCmdlet.WriteError($er)
            Return $null
        }

        42    # CGI error
        {
            $er = [Management.Automation.ErrorRecord]::new(
                [Net.ProtocolViolationException]::new("A CGI error occurred. The server said: $Meta"),
                'CGIError',
                [Management.Automation.ErrorCategory]::ConnectionError,
                $Uri
            )
            $er.CategoryInfo.Activity = 'ParseResponseHeader'
            $PSCmdlet.WriteError($er)
            Return $null
        }

        43    # proxy error
        {
            $er = [Management.Automation.ErrorRecord]::new(
                [Net.ProtocolViolationException]::new("A proxy error occurred. The server said: $Meta"),
                'ProxyError',
                [Management.Automation.ErrorCategory]::ConnectionError,
                $Uri
            )
            $er.CategoryInfo.Activity = 'ParseResponseHeader'
            $PSCmdlet.WriteError($er)
            Return $null
        }

        44    # rate-limiting
        {
            Write-Warning "Slow down. The server provided this error: $Meta"
            For ($i = $Meta; $i -lt 0; $i++) {
                Write-Progress -Activity 'Waiting to retry this request.' -SecondsRemaining $i
                Start-Sleep 1
            }
            Invoke-GeminiRequest -InputObject:$InputObject -OutFile:$OutFile -SslVersion:$SslVersion -Uri:$Uri
        }

        50    # request failed
        {
            $er = [Management.Automation.ErrorRecord]::new(
                [Net.ProtocolViolationException]::new("The request failed. The server said: $Meta"),
                'PermanentFailure',
                [Management.Automation.ErrorCategory]::ConnectionError,
                $Uri
            )
            $er.CategoryInfo.Activity = 'ParseResponseHeader'
            $PSCmdlet.WriteError($er)
            Return $null
        }

        51    # resource not found
        {
            $er = [Management.Automation.ErrorRecord]::new(
                [Net.ProtocolViolationException]::new("The resource was not found. The server said: $Meta"),
                'ResourceNotFound',
                [Management.Automation.ErrorCategory]::ConnectionError,
                $Uri
            )
            $er.CategoryInfo.Activity = 'ParseResponseHeader'
            $PSCmdlet.WriteError($er)
            Return $null
        }

        52    # resource gone
        {
            $er = [Management.Automation.ErrorRecord]::new(
                [Net.ProtocolViolationException]::new("The resource is permanently gone. The server said: $Meta"),
                'ResourceGone',
                [Management.Automation.ErrorCategory]::ConnectionError,
                $Uri
            )
            $er.CategoryInfo.Activity = 'ParseResponseHeader'
            $PSCmdlet.WriteError($er)
            Return $null
        }

        53    # proxy request refused
        {
            $er = [Management.Automation.ErrorRecord]::new(
                [Net.ProtocolViolationException]::new("The proxy request was refused. The server said: $Meta"),
                'ProxyRequestRefused',
                [Management.Automation.ErrorCategory]::ConnectionError,
                $Uri
            )
            $er.CategoryInfo.Activity = 'ParseResponseHeader'
            $PSCmdlet.WriteError($er)
            Return $null
        }

        59    # bad request
        {
            $er = [Management.Automation.ErrorRecord]::new(
                [Net.ProtocolViolationException]::new("The request was invalid. The server said: $Meta"),
                'BadRequest',
                [Management.Automation.ErrorCategory]::ConnectionError,
                $Uri
            )
            $er.CategoryInfo.Activity = 'ParseResponseHeader'
            $PSCmdlet.WriteError($er)
            Return $null
        }

        60    # client certificate required
        {
            $er = [Management.Automation.ErrorRecord]::new(
                [Net.ProtocolViolationException]::new("A client certificate is required. The server said: $Meta"),
                'ClientCertificateRequired',
                [Management.Automation.ErrorCategory]::ConnectionError,
                $Uri
            )
            $er.CategoryInfo.Activity = 'ParseResponseHeader'
            $PSCmdlet.WriteError($er)
            Return $null
        }

        61    # client certificate rejected
        {
            $er = [Management.Automation.ErrorRecord]::new(
                [Net.ProtocolViolationException]::new("The client certificate is not authorized. The server said: $Meta"),
                'ClientCertificateUnauthorized',
                [Management.Automation.ErrorCategory]::ConnectionError,
                $Uri
            )
            $er.CategoryInfo.Activity = 'ParseResponseHeader'
            $PSCmdlet.WriteError($er)
            Return $null
        }

        62    # client certificate invalid
        {
            $er = [Management.Automation.ErrorRecord]::new(
                [Net.ProtocolViolationException]::new("The client certificate is not valid. The server said: $Meta"),
                'ClientCertificateInvalid',
                [Management.Automation.ErrorCategory]::ConnectionError,
                $Uri
            )
            $er.CategoryInfo.Activity = 'ParseResponseHeader'
            $PSCmdlet.WriteError($er)
            Return $null
        }

        default {
            $er = [Management.Automation.ErrorRecord]::new(
                [Net.ProtocolViolationException]::new("An invalid status code was received: $Response"),
                'InvalidGeminiStatusCode',
                [Management.Automation.ErrorCategory]::ConnectionError,
                $Uri
            )
            $er.CategoryInfo.Activity = 'ParseResponseHeader'
            $PSCmdlet.WriteError($er)
            Return $null
        }
    }

    # If we made it this far, then we've encountered status code 20 (success).
    # Check the <META> to see our MIME type. If we have a text type, or if we
    # have an application that's text-based (e.g., JSON, XML), let's process
    # this as text instead. This mainly affects the output, but not the content
    # of the saved and/or displayed resource.
    $BINARY_TRANSFER = -Not (
        $Meta -Like 'text/*' -or
        $Meta -eq 'application/json' -or $Meta -Like '*/*+json' -or
        $Meta -eq 'application/xml' -or $Meta -Like '*/*+xml'
    )
    
    # Do we have MIME parameters (e.g., "text/gemini; charset=UTF-8")?
    # We will include these in a Headers object.
    $Headers = @()
    $Parameters = $Meta -Split ";\s*"
    If ($Parameters.Length -gt 1)
    {
        $Parameter, $Value = $Parameters[1].Trim() -Split '='
        Write-Debug "Found `"$Parameter`" with value `"$Value`""
        $Headers += [PSCustomObject]@{$Parameter.Trim() = $Value.Trim()}
    }

    # Start reading the response body. Save the response header first.
    $RawContent = $response
    $response   = ''

    If (-Not $BINARY_TRANSFER)
    {
        $BufferSize = 2048
        $Buffer     = New-Object Byte[] $BufferSize

        Write-Debug 'Beginning to read textual data.'
        While (0 -ne ($bytesRead = $TcpStream.Read($buffer, 0, $BufferSize)))
        {
            Write-Debug "`tReading ≤$BufferSize bytes from the server."
            $response += $Encoder.GetString($buffer, 0, $bytesRead)
        }
        Write-Verbose "Received $($Encoder.GetByteCount($response)) bytes from server."
    }
    Else # it is a binary transfer #
    {
        $BufferSize = 51200
        $Buffer     = New-Object Byte[] $BufferSize
        $Response   = [IO.MemoryStream]::new()

        Write-Debug 'Beginning to read binary data.'
        While (0 -ne ($bytesRead = $TcpStream.Read($buffer, 0, $BufferSize)))
        {
            Write-Debug "`tReading ≤$BufferSize bytes from the server."
            $response.Write($buffer, 0, $bytesRead)
        }
        $response.Flush()
        Write-Verbose "Received $($response.Length) bytes from server."
    }
    $writer.Close()
    $TCPSocket.Close()
    #endregion (Receive data)

    #region Parse response
    $Content  = ''
    $Headings = @()
    $Links    = @()

    If ($BINARY_TRANSFER)
    {
        $Content = $response.ToArray()
    }
    Else
    {
        $response -Split "([`r`n]+)" | ForEach-Object {
            #Write-Debug "OUTPUT: $($_ -Replace "`r",'' -Replace "`n",'')"

            # Build content variable
            If ($_.Length -gt 0)
            {
                $Content += $_
            }

            # Look for links and headings.
            If (-Not $OutFile -and $Meta -CLike 'text/gemini*')
            {
                If ($_.Length -gt 1  -and  $_[0] -eq '#')
                {
                    $Line = $_ -Split "\s+",2
                    $Headings += [PSCustomObject]@{
                        'Level'   = $Line[0].Trim().Length
                        'Content' = $Line[1].Trim()
                    }
                }

                # Links
                ElseIf ($_.Length -gt 2  -and  $_.Substring(0,2) -eq '=>')
                {
                    Write-Debug "Found a link: $_"
                    $foo, $href, $title = $_ -Split "\s+",3

                    # Because PowerShell doesn't recognize the gemini:// scheme,
                    # we need to go through the painstaking process of building
                    # our System.Uri object manually.
                    $detectedURI = [Uri]::new($Uri, $href)
                    <#Write-Verbose "URI: $detectedURI"
                    $builtURI = ''

                    # Absolute URIs in Gemtext will be assumed to be of the
                    # file:// scheme. In that case, we can simply rename it.
                    $builtURI += ($detectedURI.Scheme -eq 'file' ? 'gemini' : $detectedURI.Scheme) + '://'

                    $builtURI += ($detectedURI.Host -eq '' ? $Uri.Host : $detectedURI.Host)

                    # Add the port if need be.
                    If ($detectedURI.IsDefaultPort -eq $false) {
                        $builtURI += ':' + ($detectedURI.Port -eq -1 ? 1965 : $detectedURI.Port)
                    }

                    $builtURI += $detectedURI.AbsolutePath

                    If ($Uri.Query) {
                        $builtURI += '?' + $detectedURI.Query
                    }
                    $builtURI += ($Uri.Query)
                    #>


                    Write-Debug "=> Link=`"$href`", title=`"$title`", href=$detectedURI"
                    $Links += [PSCustomObject]@{
                        'href'  = $detectedURI -Replace ":$($Uri.Port)"
                        'title' = $title
                    }
                }
            }
        }
    }
    #endregion

    #region Deliver response
    If ($OutFile)
    {
        Write-Verbose "Writing $($response.Length) bytes to $OutFile"
        Set-Content -Path $OutFile -Value $Content -AsByteStream
    }
    Else    # not writing to a file
    {
        $retval = [PSCustomObject]@{
            'StatusCode' = $Status
            'StatusDescription' = $Meta
            'Content' = $Content
            'RawContent' = $RawContent + ($BINARY_TRANSFER ? $response.ToArray() : $response)
            'Headers' = $Headers
            'Headings' = $Headings
            'Links' = $Links
            'RawContentLength' = $RawContent.Length + $response.Length
        }

        #region Experimental favicon support.
        # In the spirit of Gemini, this is disabled by default.
        # PowerShell doesn't seem capable of combining emojis and modifiers, but
        # we'll give it the ol' college try and show *something* to the user.
        If ($FavIcon)
        {
            $faviconUri = "gemini://$($Uri.Host):$($Uri.Port)/favicon.txt"
            $icon       = $null

            Try
            {
                $icon = (Invoke-GeminiRequest -Uri $faviconUri -Certificate:$Certificate -SkipCertificateCheck:$SkipCertificateCheck).Content
            }
            Catch
            {
                Write-Verbose "Could not get a favicon from $faviconUri."
            }

            $retval | Add-Member -NotePropertyName 'FavIcon' -NotePropertyValue $icon
        }
        #endregion (favicon)

        Return $retval
    }
    #endregion
}

Function Get-PSGeminiKnownCertificate
{
    [CmdletBinding()]
    [Alias('Get-PSGeminiKnownCertificates')]
    [OutputType([PSCustomObject[]])]
    Param(
        [AllowNull()]
        [String] $HostName
    )

    Write-Debug "Looking for a certificate for $HostName."
    $env:PSGeminiTOFUPath ??= (Join-Path -Path $HOME -ChildPath '.PSGemini_known_hosts.csv')

    If (Test-Path -Path $env:PSGeminiTOFUPath -PathType Leaf)
    {
        Write-Debug "Found a certificate store at ${env:PSGeminiTOFUPath}."
        $AllCerts = @()
        Import-CSV -Path $env:PSGeminiTOFUPath | ForEach-Object    {
            If ($null -eq $HostName  -or  $HostName -In @('', $_.HostName))
            {
                $NotAfter = [DateTime]::FromFileTimeUTC($_.ExpirationDate)
                Write-Debug "In our store, we have a matching certificate for $($_.HostName), good until $NotAfter, fingerprint $($_.Fingerprint)."
                $AllCerts += [PSCustomObject]@{
                    HostName = $_.HostName
                    Fingerprint = $_.Fingerprint
                    ExpirationDate = $NotAfter
                }
            }
        }
        Return $AllCerts
    }
    Else
    {
        Write-Verbose "The certificate store ${env:PSGeminiTOFUPath} does not exist."
        Return @()
    }
}

Function Add-PSGeminiKnownCertificate
{
    [CmdletBinding()]
    [OutputType([Void])]
    Param(
        [Parameter(Mandatory, Position=0)]
        [ValidateNotNullOrEmpty()]
        [String] $HostName,

        [Parameter(Mandatory, Position=1)]
        [ValidateNotNullOrEmpty()]
        [String] $Fingerprint,

        [Parameter(Mandatory, Position=2)]
        [ValidateNotNullOrEmpty()]
        [Alias('NotAfter', 'ExpiryDate')]
        [DateTime] $ExpirationDate
    )

    Write-Verbose "Memorizing certificate for $HostName with fingerprint $Fingerprint and expiration date $ExpirationDate."

    $env:PSGeminiTOFUPath ??= (Join-Path -Path $HOME -ChildPath '.PSGemini_known_hosts.csv')
    Export-CSV -Path $env:PSGeminiTOFUPath -Append -Delimiter ',' -InputObject ([PSCustomObject]@{
        HostName = $HostName
        Fingerprint = $Fingerprint
        ExpirationDate = $ExpirationDate.ToFileTimeUTC()
    })
    #Add-Content -Path $env:PSGeminiTOFUPath -Value "$HostName,$Fingerprint,$($ExpirationDate.ToFileTimeUTC())"
}

Function Remove-PSGeminiKnownCertificate
{
    [CmdletBinding(
        SupportsShouldProcess, ConfirmImpact='Low',
        DefaultParameterSetName='HostName'
    )]
    [OutputType([Void])]
    Param(
        [Parameter(Mandatory, ParameterSetName='HostName')]
        [ValidateNotNullOrEmpty()]
        [String] $HostName,

        [Parameter(Mandatory, ParameterSetName='Fingerprint')]
        [ValidateNotNullOrEmpty()]
        [String] $Fingerprint
    )

    $env:PSGeminiTOFUPath ??= (Join-Path -Path $HOME -ChildPath '.PSGemini_known_hosts.csv')

    If (-Not (Test-Path -Path $env:PSGeminiTOFUPath -PathType Leaf))
    {
        Return $null
    }

    $AllCerts = Import-CSV -Path $env:PSGeminiTOFUPath
    
    If ($PSCmdlet.ParameterSetName -eq 'HostName')
    {
        $FoundCert = $AllCerts | Where-Object HostName -eq $HostName | Select-Object -First 1
        If ($null -eq $FoundCert)
        {
            Write-Warning "No certificate for $HostName was found."
        }
    }
    ElseIf ($PSCmdlet.ParameterSetName -eq 'Fingerprint')
    {
        $FoundCert = $AllCerts | Where-Object Fingerprint -eq $Fingerprint | Select-Object -First 1
        If ($null -eq $FoundCert)
        {
            Write-Warning "No certificate $Fingerprint was found."
        }
    }

    If ($null -ne $FoundCert)
    {
        Write-Debug "Removing certificate for $($FoundCert.HostName): expires=$([DateTime]::FromFileTimeUtc($FoundCert.ExpirationDate)), fingerprint=$($FoundCert.Fingerprint)"
        If ($PSCmdlet.ShouldProcess($FoundCert.Fingerprint, 'Remove from TOFU store'))
        {
            $AllCerts | Where-Object {$_ -ne $FoundCert} | Export-CSV -Path $env:PSGeminiTOFUPath -Force
        }
    }
}