SophosFirewall.Core.psm1

#requires -Version 5.1
<#
    SophosFirewall.Core
    ====================
    PowerShell module that carries the transport layer for every Sophos Firewall (SFOS)
    domain module: connection and session state, HTTP(S) communication, the XML request
    envelope, XML escaping, and evaluation of the API response status. It has no knowledge
    of firewall entities such as hosts, services or rules; that lives in the domain modules
    that depend on this one.
 
    Total Functions: 12 (9 exported, 3 internal helpers) - see README.md for the full
    cmdlet table.
 
    API reference:
    https://docs.sophos.com/nsg/sophos-firewall/22.0/api/
#>


#region Module Variables

# Default Sophos Firewall API port
[int]$script:DefaultSfosPort = 4444

# Session context for connection reuse across cmdlets
$script:SfosConnection = $null

# Named session registry for Connect-SfosFirewall -Name / -Session everywhere. A plain
# @{} hashtable literal's case sensitivity is not guaranteed across PowerShell versions,
# so the comparer is set explicitly - session names are looked up case-insensitively.
$script:SfosSessions = [System.Collections.Hashtable]::new([StringComparer]::OrdinalIgnoreCase)

# Guards the process-wide certificate callback under PS 5.1. ServicePointManager is static,
# so two calls in parallel runspaces could each save the other's temporary "accept all"
# callback as the original and leave validation permanently disabled.
$script:CertCallbackLock = [object]::new()

#endregion

#region XML Helper Functions

<#
.SYNOPSIS
    Escapes XML special characters in a text string.
 
.DESCRIPTION
    Replaces the five XML special characters (&, <, >, ", ') with their entity form, so the
    text can be interpolated into request XML without breaking the document or letting the
    value inject extra elements. Every domain module passes every value it interpolates into
    XML through this cmdlet first, without exception - names, descriptions, filter values,
    member names.
 
.PARAMETER Text
    Required. The text to escape. Accepts pipeline input. An empty string is allowed.
 
.INPUTS
    System.String. Text can be piped in.
 
.OUTPUTS
    System.String. The XML-escaped text.
 
.EXAMPLE
    ConvertTo-SfosXmlEscaped -Text 'Smith & Sons'
 
    Returns 'Smith &amp; Sons'.
 
.EXAMPLE
    'Smith & Sons', 'A "B" C' | ConvertTo-SfosXmlEscaped
 
    Escapes each piped-in string in turn.
 
.LINK
    https://docs.sophos.com/nsg/sophos-firewall/22.0/api/
#>

function ConvertTo-SfosXmlEscaped {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [AllowEmptyString()]
        [string]$Text
    )
    
    process {
        return ($Text `
                -replace '&', '&amp;' `
                -replace '<', '&lt;' `
                -replace '>', '&gt;' `
                -replace '"', '&quot;' `
                -replace "'", '&apos;')
    }
}

<#
.SYNOPSIS
    Builds a multipart/form-data request body. Internal helper, not exported.
 
.DESCRIPTION
    Builds the byte-exact body Invoke-SfosApi sends when a call carries a file upload: the
    request XML as its own raw (not URL-encoded) 'reqxml' part, followed by one part per
    file. Kept separate from Invoke-SfosApi so the body can be tested without a network
    call - measured for FormTemplate against a live firewall, and reused unmeasured for the
    other multipart operations that share the same field-name-equals-XML-element contract
    (Certificate, CertificateAuthority, CRL, TrustedMAC list, VPN client config).
 
    Built by hand rather than with Invoke-WebRequest -Form so the same code path runs under
    PowerShell 5.1, which has no -Form parameter. File content is read as bytes and written
    straight into the body stream, never through a string, so a binary upload such as a
    certificate is never subjected to character encoding.
 
.PARAMETER RequestXml
    Required. The complete request envelope, exactly as Invoke-SfosApi would otherwise
    URL-encode into the non-multipart body.
 
.PARAMETER MultipartFile
    Required. Hashtable of multipart field name to one file path or an array of file paths.
    The field name must match the XML element in RequestXml that references the upload; the
    element's text content must be the file's base name.
#>

function New-SfosMultipartRequestBody {
    # PSUseShouldProcessForStateChangingFunctions: the New- verb here builds an in-memory
    # byte array and reads local files; it changes nothing on the firewall or the caller's
    # system, so ShouldProcess would be a no-op prompt with nothing meaningful to confirm.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)]
        [string]$RequestXml,

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

    $boundary = 'SfosBoundary' + [guid]::NewGuid().ToString('N')
    $crlf = "`r`n"
    # No BOM: a BOM at the start of the 'reqxml' part would be sent to the firewall as part
    # of the XML text.
    $utf8NoBom = [System.Text.UTF8Encoding]::new($false)

    $extensionContentType = @{
        '.xml'  = 'text/xml'
        '.html' = 'text/html'
        '.htm'  = 'text/html'
        '.txt'  = 'text/plain'
        '.csv'  = 'text/csv'
    }

    $stream = [System.IO.MemoryStream]::new()
    try {
        # reqxml is a form field like any other here, but its value is the raw request XML -
        # it must not be URL-encoded the way the non-multipart body encodes it, or the
        # firewall receives literal percent-escapes instead of the request.
        $reqxmlHeader = "--$boundary$crlf" +
        "Content-Disposition: form-data; name=`"reqxml`"$crlf" +
        "Content-Type: text/xml$crlf$crlf"
        $bytes = $utf8NoBom.GetBytes($reqxmlHeader)
        $stream.Write($bytes, 0, $bytes.Length)
        $bytes = $utf8NoBom.GetBytes($RequestXml)
        $stream.Write($bytes, 0, $bytes.Length)
        $bytes = $utf8NoBom.GetBytes($crlf)
        $stream.Write($bytes, 0, $bytes.Length)

        foreach ($fieldName in $MultipartFile.Keys) {
            foreach ($path in @($MultipartFile[$fieldName])) {
                if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
                    throw "MultipartFile field '$fieldName' refers to a file that does not exist: $path"
                }

                $fileName = Split-Path -Path $path -Leaf
                $extension = [System.IO.Path]::GetExtension($fileName).ToLowerInvariant()
                $contentType = 'application/octet-stream'
                if ($extensionContentType.ContainsKey($extension)) {
                    $contentType = $extensionContentType[$extension]
                }

                $fileHeader = "--$boundary$crlf" +
                "Content-Disposition: form-data; name=`"$fieldName`"; filename=`"$fileName`"$crlf" +
                "Content-Type: $contentType$crlf$crlf"
                $bytes = $utf8NoBom.GetBytes($fileHeader)
                $stream.Write($bytes, 0, $bytes.Length)

                # Binary-safe: file bytes go from disk straight into the stream, never
                # through a string, so a certificate or other binary upload is never run
                # through character encoding.
                $fileBytes = [System.IO.File]::ReadAllBytes($path)
                $stream.Write($fileBytes, 0, $fileBytes.Length)

                $bytes = $utf8NoBom.GetBytes($crlf)
                $stream.Write($bytes, 0, $bytes.Length)
            }
        }

        $bytes = $utf8NoBom.GetBytes("--$boundary--$crlf")
        $stream.Write($bytes, 0, $bytes.Length)

        return [PSCustomObject]@{
            Body        = $stream.ToArray()
            ContentType = "multipart/form-data; boundary=$boundary"
        }
    }
    finally {
        $stream.Dispose()
    }
}

<#
.SYNOPSIS
    Throws when an API response reports a failed login. Internal helper, not exported.
 
.DESCRIPTION
    A failed login is reported outside the entity status: a lowercase status element
    directly under Login, with no code attribute, in an otherwise empty HTTP 200 body. Left
    unchecked, such a response looks like "no records" to a Get and like success to every
    write.
 
.PARAMETER Content
    Required. Raw response body to check. Anything that is not text is ignored: a few
    operations answer with a file instead of XML, and a rejected login is always XML.
#>

function Assert-SfosApiLoginSuccess {
    [CmdletBinding()]
    param(
        [AllowEmptyString()]
        [AllowNull()]
        [object]$Content
    )

    # Certificate, certificate authority, CRL and form template answer with a downloaded
    # file, so the body arrives as a byte array. Declaring this parameter as a string made
    # the call fail on the binding, long before anything was read, and the caller saw a
    # type conversion error instead of the response.
    if ($Content -isnot [string]) {
        return
    }

    if (-not $Content) {
        return
    }

    # A non-XML body is not this function's problem - the caller parses and reports it.
    $xml = $null
    try {
        $xml = [xml]$Content
    }
    catch {
        return
    }

    $loginNode = $xml.SelectSingleNode('/Response/Login/status')
    if (-not $loginNode) {
        return
    }

    $loginStatus = [string]$loginNode.InnerText
    if ($loginStatus -and $loginStatus -notmatch 'Success') {
        throw "Sophos API login failed: $loginStatus"
    }
}

<#
.SYNOPSIS
    Sends a request to the Sophos Firewall XML API and returns the raw response.
 
.DESCRIPTION
    Wraps the caller-supplied inner XML in the API request envelope, together with the
    login credentials, and posts it to the firewall's API endpoint. Domain modules use this
    cmdlet for every read and write; it is the only place in the module suite that opens an
    HTTP(S) connection. The response is returned unparsed; use Get-SfosApiStatus or
    Assert-SfosApiReturnSuccess to evaluate it.
 
    Call this cmdlet directly only for troubleshooting or for XML the shipped domain modules
    do not yet cover. Pass either the individual connection parameters, or -Session to reuse
    a connection from Connect-SfosFirewall.
 
.PARAMETER Firewall
    Required in the default parameter set. Host name or IP address of the firewall.
 
.PARAMETER Port
    Optional. TCP port of the management API. Default 4444.
 
.PARAMETER Username
    Required in the default parameter set. User name for the API login, as plain text.
 
.PARAMETER Password
    Required in the default parameter set. Password for the API login, as a SecureString.
 
.PARAMETER InnerXml
    Required. The request body without the surrounding Request/Login envelope, for example
    '<Get><IPHost></IPHost></Get>'.
 
.PARAMETER ApiVersion
    Optional. APIVersion attribute for the Request element, for example '2200.1'. If
    omitted, the firewall processes the request using its own current schema version, which
    keeps one module compatible with several firmware levels.
 
.PARAMETER TimeoutSec
    Optional. Maximum time in seconds to wait for the HTTP response. Default 30. Pass 0 to
    fall back to the default of the underlying web request instead of enforcing a limit.
 
.PARAMETER SkipCertificateCheck
    Optional. Accepts the firewall certificate without validating it. Use this only for
    appliances that still present a self-signed certificate. Belongs to the default
    parameter set; when calling with -Session, the session's own value is used instead.
 
.PARAMETER Session
    Required when used instead of the individual connection parameters. A session object
    from Connect-SfosFirewall, or the name of a session that was registered with
    Connect-SfosFirewall -Name. Firewall, Port, Username, Password and
    SkipCertificateCheck are all taken from it.
 
.PARAMETER MultipartFile
    Optional. Hashtable of multipart field name to one file path or an array of file paths,
    for the handful of operations that upload a file alongside the request XML (for example
    FormTemplate, Certificate, CertificateAuthority, CRL). The field name must match the XML
    element in -InnerXml that references the upload, and that element's text content must be
    the file's base name - the match between the two is how the firewall connects the
    uploaded file to the request. When omitted, the request is sent exactly as before this
    parameter existed: a single URL-encoded form field.
 
.INPUTS
    None. This cmdlet does not accept pipeline input.
 
.OUTPUTS
    Microsoft.PowerShell.Commands.WebResponseObject. The unparsed HTTP response from the
    firewall.
 
.EXAMPLE
    $securePw = Read-Host -AsSecureString
    $inner = '<Get><IPHost></IPHost></Get>'
    Invoke-SfosApi -Firewall 'firewall.example.com' -Port 4444 -Username 'admin' -Password $securePw -InnerXml $inner -SkipCertificateCheck
 
    Sends a raw Get request and returns the unparsed response.
 
.EXAMPLE
    Invoke-SfosApi -Firewall 'firewall.example.com' -Username 'admin' -Password $securePw -InnerXml $inner -TimeoutSec 5
 
    Fails fast against a host that might be unreachable, instead of waiting out the
    operating system's own default timeout.
 
.EXAMPLE
    Invoke-SfosApi -Session 'fw2' -InnerXml $inner
 
    Sends a raw request against a session that was registered earlier with
    Connect-SfosFirewall -Firewall 'fw2.example.test' -Credential $cred -Name 'fw2'.
 
.EXAMPLE
    $uploadInner = Get-Content .\add-formtemplate.xml -Raw
    Invoke-SfosApi -Session 'fw2' -InnerXml $uploadInner -MultipartFile @{ Template = 'C:\templates\portal.html' }
 
    Uploads a file alongside the request XML. The XML element referencing the upload
    ('Template') must equal the multipart field name, and its text must be the file's base
    name ('portal.html'), matching the file at the given path. The inner XML is read from a
    file here because the help renderer discards raw angle brackets together with the rest
    of the line.
 
.LINK
    https://docs.sophos.com/nsg/sophos-firewall/22.0/api/
 
.LINK
    Get-SfosApiStatus
 
.LINK
    Assert-SfosApiReturnSuccess
#>

function Invoke-SfosApi {
    [CmdletBinding(DefaultParameterSetName = 'Explicit')]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Explicit')]
        [string]$Firewall,

        [Parameter(ParameterSetName = 'Explicit')]
        [int]$Port = $script:DefaultSfosPort,

        [Parameter(Mandatory, ParameterSetName = 'Explicit')]
        [string]$Username,

        [Parameter(Mandatory, ParameterSetName = 'Explicit')]
        [SecureString]$Password,

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

        [string]$ApiVersion,

        [int]$TimeoutSec = 30,

        [Parameter(ParameterSetName = 'Explicit')]
        [switch]$SkipCertificateCheck,

        [hashtable]$MultipartFile,

        [Parameter(Mandatory, ParameterSetName = 'Session')]
        [object]$Session
    )

    if ($PSCmdlet.ParameterSetName -eq 'Session') {
        $resolvedSession = Resolve-SfosSessionArgument -Session $Session
        if (-not $resolvedSession) {
            throw 'Invoke-SfosApi -Session did not resolve to a usable session. Pass a registered session name or the object returned by Connect-SfosFirewall.'
        }
        $Firewall = $resolvedSession.Firewall
        $Port = $resolvedSession.Port
        $Username = $resolvedSession.Username
        $Password = $resolvedSession.Password
        $SkipCertificateCheck = [bool]$resolvedSession.SkipCertificateCheck
    }

    # Variables for secure handling and cleanup
    $plainPassword = $null
    $passwordBstr = $null
    $savedCertCallback = $null
    $certCallbackChanged = $false
    $certLockTaken = $false

    try {
        # Security: XML-escape credentials to prevent injection attacks
        $usernameEscaped = ConvertTo-SfosXmlEscaped -Text $Username
        
        # Convert Password SecureString to plaintext with BSTR cleanup.
        # PtrToStringBSTR, not PtrToStringAuto: a BSTR is length-prefixed and may contain
        # embedded null characters, which PtrToStringAuto would silently truncate at.
        $passwordBstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Password)
        $plainPassword = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($passwordBstr)
        $passwordEscaped = ConvertTo-SfosXmlEscaped -Text $plainPassword
        
        $uri = ("https://{0}:{1}/webconsole/APIController" -f $Firewall, $Port)

        # APIVersion is optional. When omitted the firewall answers using its own current
        # schema version, which keeps a single module usable across firmware levels.
        $versionAttribute = ''
        if ($ApiVersion) {
            $versionAttribute = " APIVersion=`"$ApiVersion`""
        }
        $requestXml = "<Request$versionAttribute><Login><Username>$usernameEscaped</Username><Password>$passwordEscaped</Password></Login>$InnerXml</Request>"

        if ($MultipartFile -and $MultipartFile.Count -gt 0) {
            # Multipart transport: reqxml travels as its own raw part, and the file(s) as
            # further parts. Built by hand so the same code path runs under PS 5.1, which
            # has no -Form parameter on Invoke-WebRequest.
            $multipart = New-SfosMultipartRequestBody -RequestXml $requestXml -MultipartFile $MultipartFile

            $invokeParams = @{
                Uri         = $uri
                Method      = 'Post'
                Body        = $multipart.Body
                ContentType = $multipart.ContentType
                ErrorAction = 'Stop'
            }
        }
        else {
            # The body is form-encoded, so the XML has to be URL-encoded. Left unencoded, any
            # '&' - including every '&amp;' produced by XML escaping - terminates the reqxml
            # field and SFOS rejects the request with code 529 'Input request file is Invalid'.
            $body = 'reqxml=' + [uri]::EscapeDataString($requestXml)

            $invokeParams = @{
                Uri         = $uri
                Method      = 'Post'
                Body        = $body
                ErrorAction = 'Stop'
            }
        }

        # -TimeoutSec is identical on Invoke-WebRequest under PS 5.1 and PS 7+, so no version
        # branch is needed the way there is for -UseBasicParsing/-SkipCertificateCheck below.
        # 0 means "no enforced limit" - omit the parameter and let Invoke-WebRequest use its
        # own default rather than passing a literal 0, which Invoke-WebRequest would reject.
        if ($TimeoutSec -gt 0) {
            $invokeParams['TimeoutSec'] = $TimeoutSec
        }

        # -UseBasicParsing under PS 5.1: without it Invoke-WebRequest hands the response to
        # the Internet Explorer DOM parser, which throws NullReferenceException on any
        # machine that has no IE engine - Windows Server included. Every call would fail.
        # PS 7 dropped the parameter; passing it there is harmless but pointless.
        if ($PSVersionTable.PSVersion.Major -le 5) {
            $invokeParams['UseBasicParsing'] = $true
        }

        # Handle certificate validation for PS 5.1 vs PS 7+
        if ($SkipCertificateCheck) {
            if ($PSVersionTable.PSVersion.Major -le 5) {
                # Serialise the swap: the callback is process-wide, so a concurrent call
                # must not observe - and later restore - this call's temporary value.
                [System.Threading.Monitor]::Enter($script:CertCallbackLock)
                $certLockTaken = $true
                $savedCertCallback = [Net.ServicePointManager]::ServerCertificateValidationCallback
                $certCallbackChanged = $true
                [Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
            }
            elseif ($PSVersionTable.PSVersion.Major -gt 5) {
                # PS 7+: Use parameter instead of global callback
                $invokeParams['SkipCertificateCheck'] = $true
            }
        }

        try {
            $response = Invoke-WebRequest @invokeParams
        }
        catch {
            # Flatten the exception chain. PowerShell reports "The SSL connection could not
            # be established, see inner exception", and the domain functions re-throw only
            # that top-level text - the inner exception naming the actual cause
            # (RemoteCertificateNameMismatch, connection refused, ...) never reaches the
            # caller. Doing it here fixes it for all 53 of them at once.
            $messages = @()
            $current = $_.Exception
            while ($current) {
                if ($current.Message -and $messages -notcontains $current.Message) {
                    $messages += $current.Message
                }
                $current = $current.InnerException
            }
            throw ($messages -join ' -> ')
        }

        # Every response passes through here, so this is the one place that can catch a
        # failed login. SFOS answers it with HTTP 200 and nothing but the lowercase
        # <status> under <Login> - no entity, no status code. Left unchecked, Get-* would
        # return an empty result and every write would report success.
        Assert-SfosApiLoginSuccess -Content $response.Content

        return $response
    }
    finally {
        # Restore previous certificate validation callback. The flag is required: the
        # saved callback is normally $null, so a null check would skip the restore and
        # leave certificate validation disabled for the rest of the process.
        if ($certCallbackChanged) {
            [Net.ServicePointManager]::ServerCertificateValidationCallback = $savedCertCallback
        }

        if ($certLockTaken) {
            [System.Threading.Monitor]::Exit($script:CertCallbackLock)
        }
        
        # Free BSTR memory to prevent leaks
        if ($passwordBstr -ne [IntPtr]::Zero) {
            [Runtime.InteropServices.Marshal]::FreeBSTR($passwordBstr)
        }
        
        # Clear plaintext variables from memory
        $plainPassword = $null
    }
}

#endregion

#region Response Parsing

<#
.SYNOPSIS
    Extracts the status information from a Sophos Firewall API response.
 
.DESCRIPTION
    Reads one or more status elements from a parsed API response and returns their code and
    message. Looks first under /Response/ObjectName/Status when -ObjectName is given, then
    falls back to /Response/Status. Assert-SfosApiReturnSuccess uses this cmdlet internally;
    call it directly when troubleshooting a response, to see every status the firewall
    actually returned.
 
.PARAMETER Xml
    Required. The parsed XML response from the firewall.
 
.PARAMETER ObjectName
    Optional. Name of the entity element to look under for the status, for example 'Zone'.
    If omitted, only the top-level /Response/Status is checked.
 
.INPUTS
    None. This cmdlet does not accept pipeline input.
 
.OUTPUTS
    System.Management.Automation.PSCustomObject. One object per status found, with the
    properties Code, Message and XPathHint. Returns nothing when the response carries no
    status at all.
 
.EXAMPLE
    Get-SfosApiStatus -Xml $response -ObjectName 'Zone'
 
    Reads the status of a Zone response.
 
.LINK
    https://docs.sophos.com/nsg/sophos-firewall/22.0/api/
 
.LINK
    Assert-SfosApiReturnSuccess
#>

function Get-SfosApiStatus {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)]
        [xml]$Xml,
        
        [string]$ObjectName
    )
    
    # SelectNodes, not property access: $Xml.Response.$ObjectName silently returns the CLR
    # member of XmlElement when the entity is called Name, Item or Count, and it collapses
    # several <Status> siblings - a bulk delete returns one per object - into a single
    # value whose .code reads "200 529".
    $statusNodes = @()
    $hint = $null

    if ($ObjectName) {
        # '<Status>' is not always an API status. Some entities carry a field of that name:
        # a FirewallRule and a NATRule both hold <Status>Enable</Status> as their enabled
        # flag, so a plain /Response/FirewallRule/Status matches six data fields on a
        # six-rule response and none of them says anything about the request.
        #
        # A node counts as an API status when it carries a 'code' attribute, or when its
        # parent is not a data object - data objects have a <Name>, status containers do
        # not. Both halves matter: dropping the @code test would hide a real error that
        # arrives alongside a named object, and dropping the Name test brings the
        # Enable/Disable fields back.
        $statusNodes = @($Xml.SelectNodes("/Response/$ObjectName/Status[@code or not(../Name)]"))
        $hint = "/Response/$ObjectName/Status"
    }

    if (-not $statusNodes.Count) {
        $statusNodes = @($Xml.SelectNodes('/Response/Status'))
        $hint = '/Response/Status'
    }

    if (-not $statusNodes.Count) {
        # A bare 'return', not 'return $null': the caller almost always wraps this in @(),
        # and @($null) is a one-element array holding $null rather than an empty one, which
        # reads as "one unreadable status" instead of "no status at all".
        return
    }

    # One object per status node, so a caller can tell which entity failed
    foreach ($statusNode in $statusNodes) {
        [PSCustomObject]@{
            Code      = [string]$statusNode.GetAttribute('code')
            Message   = [string]$statusNode.InnerText
            XPathHint = $hint
        }
    }
}

<#
.SYNOPSIS
    Throws when a Sophos Firewall API response does not report success.
 
.DESCRIPTION
    Checks the login status and the entity status codes of a parsed API response and throws
    a clear error naming the action and target if the request did not succeed. Codes 200
    and 216 are treated as success, 201/203/211-215 as success with a warning, and every
    other code in the documented range as a failure. Codes 217 and 222 are also treated as a
    warning; every other undocumented code throws, so an unrecognised status is never
    mistaken for success.
 
    When no status is found at the path derived from -ObjectName, and none at the general
    fallback path either, the cmdlet searches the rest of the response once for any node
    that still looks like a status. A status found this way still throws on a failure code
    and still succeeds on a success code, with a warning that names the path so -ObjectName
    can be corrected for that operation. A response with genuinely no status anywhere is
    unaffected.
 
    Domain modules call this cmdlet after every read and write to turn a failed request into
    a thrown error instead of a silently wrong result.
 
.PARAMETER Xml
    Required. The parsed XML response from the firewall.
 
.PARAMETER ObjectName
    Optional. Name of the entity element the status is expected under, for example 'Zone'.
    If omitted, only the top-level /Response/Status is checked before the fallback search.
 
.PARAMETER Action
    Optional. Short description of the action being performed, used in the error message,
    for example 'create'. Default 'execute request'.
 
.PARAMETER Target
    Optional. Name of the target object, used in the error message.
 
.INPUTS
    None. This cmdlet does not accept pipeline input.
 
.OUTPUTS
    None. The cmdlet writes no output and throws an exception if the response reports a
    failure or an unrecognised status.
 
.EXAMPLE
    Assert-SfosApiReturnSuccess -Xml $response -ObjectName 'Zone' -Action 'create' -Target 'DMZ'
 
    Throws a descriptive error if creating the Zone 'DMZ' failed; otherwise returns nothing.
 
.LINK
    https://docs.sophos.com/nsg/sophos-firewall/22.0/api/
 
.LINK
    Get-SfosApiStatus
#>

function Assert-SfosApiReturnSuccess {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [xml]$Xml,
        
        [string]$ObjectName,
        
        [string]$Action,
        
        [string]$Target
    )
    
    $actionPart = if ($Action) { $Action } else { 'execute request' }
    $targetPart = if ($Target) { " for '$Target'" } else { '' }

    # Authentication is reported outside the entity status and would otherwise slip past
    # every code check below. Invoke-SfosApi already catches this for live calls; the check
    # is repeated here for callers that hand in a parsed response directly.
    $loginNode = $Xml.SelectSingleNode('/Response/Login/status')
    if ($loginNode) {
        $loginStatus = [string]$loginNode.InnerText
        if ($loginStatus -and $loginStatus -notmatch 'Success') {
            throw "Sophos API login failed while trying to $actionPart$targetPart. $loginStatus"
        }
    }

    # Where-Object, not just @(): Get-SfosApiStatus returns $null when the response carries
    # no <Status> at all, and @($null) is a one-element array holding $null - not an empty
    # one. Without the filter the loop below inspects that $null and reports a status-less
    # response as a broken status.
    $statusList = @(Get-SfosApiStatus -Xml $Xml -ObjectName $ObjectName | Where-Object { $_ })

    # Fail-open guard: some operations land their status FLAT at a different path than the
    # nested Get/Remove shape -ObjectName points at, so the lookup above finds nothing there
    # and nothing at the /Response/Status fallback either. Returning at that point would
    # report success for a write that may have failed. Before treating "nothing at the
    # expected path" as "no status anywhere" (which is legitimately what an empty Get result
    # looks like), search the rest of the response once for any node the existing heuristic still
    # recognises as an API status: a Status carrying a code attribute, or a code-less Status
    # whose parent has no <Name> child. That second half is the same data-field exclusion
    # used above - a FirewallRule/NATRule's <Status>Enable</Status> sits next to <Name> and
    # stays excluded here too.
    $fallbackUsed = $false
    if (-not $statusList.Count) {
        $fallbackNodes = @($Xml.SelectNodes('//Status[@code or not(../Name)]') | Where-Object { $_ })
        if ($fallbackNodes.Count) {
            $fallbackUsed = $true
            $statusList = @($fallbackNodes | ForEach-Object {
                    $pathParts = @()
                    $ancestor = $_
                    while ($ancestor -and $ancestor.NodeType -eq [System.Xml.XmlNodeType]::Element) {
                        $pathParts = , $ancestor.Name + $pathParts
                        $ancestor = $ancestor.ParentNode
                    }
                    $actualPath = '/' + ($pathParts -join '/')
                    [PSCustomObject]@{
                        Code      = [string]$_.GetAttribute('code')
                        Message   = [string]$_.InnerText
                        XPathHint = "$actualPath (found outside the expected path for -ObjectName '$ObjectName' - measure and correct -ObjectName for this operation)"
                    }
                })
        }
    }

    if (-not $statusList.Count) {
        return
    }

    foreach ($status in $statusList) {
        # An empty result is reported as <Status>No. of records Zero.</Status> without a
        # code attribute. That is not a failure, so Get-* must not throw on it.
        #
        # Only that one wording is waved through. Treating *every* code-less status as an
        # empty result fails open: a filtered Get on ContentConditionList answers
        # <Status>Transaction fail</Status>, also without a code, and the caller would have
        # seen an empty list while matching objects existed. Same class of defect as the
        # login failure that used to read as success - so anything unrecognised throws.
        if (-not $status.Code) {
            if ($status.Message -match 'records\s+Zero') {
                continue
            }

            throw "Sophos API returned a status without a code while trying to $actionPart$targetPart. '$($status.Message)' (StatusPath=$($status.XPathHint))"
        }

        $code = 0
        if (-not [int]::TryParse($status.Code, [ref]$code)) {
            throw "Sophos API returned an unreadable status code while trying to $actionPart$targetPart. Code '$($status.Code)' - $($status.Message) (StatusPath=$($status.XPathHint))"
        }

        # Status codes per the table published by Sophos
        if ($code -eq 200 -or $code -eq 216) {
            if ($fallbackUsed) {
                Write-Warning "Sophos API reported success (code $code) while trying to $actionPart$targetPart, but the status was found outside the expected path for -ObjectName '$ObjectName'. The operation likely succeeded; measure and correct -ObjectName for this operation. (StatusPath=$($status.XPathHint))"
            }
            continue
        }

        if ($code -eq 201 -or $code -eq 203 -or ($code -ge 211 -and $code -le 215)) {
            Write-Warning "Sophos API reported code $code while trying to $actionPart$targetPart. $($status.Message)"
            continue
        }

        # The published table runs 200-216 and then resumes at 500, so 217-499 is undefined.
        # Only 217 and 222 are let through as a warning, because they occur on writes that
        # otherwise complete correctly, for example a WebFilterCategory created with an
        # external URL list, which answers 217 or 222 'Unable to get status message'.
        #
        # The rest of that range still throws. Waving through every undocumented code would
        # fail open - an unrecognised code would be reported as success while the firewall
        # did nothing, which is exactly the defect class this module has been bitten by
        # before. A wrongly reported failure is visible and harmless; a wrongly reported
        # success is neither.
        if ($code -eq 217 -or $code -eq 222) {
            Write-Warning "Sophos API returned code $code while trying to $actionPart$targetPart, which the published status table does not describe. The operation is expected to have succeeded, but verify the result on the firewall. $($status.Message)"
            continue
        }

        throw "Sophos API error while trying to $actionPart$targetPart. Code $code - $($status.Message) (StatusPath=$($status.XPathHint))"
    }
}

#endregion

#region Session Management

<#
.SYNOPSIS
    Resolves a -Session argument to a session object. Internal helper, not exported.
 
.DESCRIPTION
    Accepts the same shapes a domain cmdlet's -Session parameter can receive: $null (passed
    straight through, meaning "no session"), a registered session name (looked up in the
    named-session registry, case-insensitively), an object already tagged as a session
    returned by Connect-SfosFirewall, or - as a fallback - any other object that at least has
    a Firewall property, so a caller who built a compatible object by hand is not blocked.
    Anything else throws.
 
.PARAMETER Session
    Required. The raw value bound to a cmdlet's -Session parameter.
#>

function Resolve-SfosSessionArgument {
    [CmdletBinding()]
    [OutputType([object])]
    param(
        [AllowNull()]
        [object]$Session
    )

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

    if ($Session -is [string]) {
        if ($script:SfosSessions.ContainsKey($Session)) {
            return $script:SfosSessions[$Session]
        }
        throw "No session named '$Session' is registered. Use Get-SfosSession to list registered sessions, or Connect-SfosFirewall -Name '$Session' to register one."
    }

    if ($Session.PSObject.TypeNames -contains 'SophosFirewall.Session') {
        return $Session
    }

    # Duck-typing fallback: accept anything that looks like a session object rather than
    # requiring the exact type, so a hand-built compatible object still works.
    if ($Session.PSObject.Properties.Match('Firewall').Count -gt 0) {
        return $Session
    }

    throw 'The value passed to -Session is neither a registered session name nor a session object. Use Get-SfosSession to list registered sessions, or pass the object returned by Connect-SfosFirewall.'
}

<#
.SYNOPSIS
    Resolves the connection parameters a cmdlet should use for an API call.
 
.DESCRIPTION
    Fills in Firewall, Port, Username, Password and SkipCertificateCheck from the active
    connection wherever the caller did not supply them explicitly, and throws a clear error
    if no connection information is available at all. Every domain cmdlet calls this once,
    at the start of its body, instead of reading the connection state itself.
 
    When the caller passed -Session, that session is used as the base instead of the default
    connection set by Connect-SfosFirewall; passing -Session $null explicitly switches the
    default connection off rather than keeping it. A value the caller supplied directly for
    Firewall, Port, Username, Password or SkipCertificateCheck always takes precedence over
    both the session and the default connection.
 
.PARAMETER BoundParameters
    Required. The calling cmdlet's own $PSBoundParameters.
 
.INPUTS
    None. This cmdlet does not accept pipeline input.
 
.OUTPUTS
    System.Collections.Hashtable. Contains the resolved Firewall, Port, Username, Password
    and SkipCertificateCheck, ready to splat into Invoke-SfosApi.
 
.EXAMPLE
    $params = Resolve-SfosParameters -BoundParameters $PSBoundParameters
 
    Resolves the connection to use, typically called once in a cmdlet's begin block.
 
.LINK
    https://docs.sophos.com/nsg/sophos-firewall/22.0/api/
 
.LINK
    Connect-SfosFirewall
#>

function Resolve-SfosParameters {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [hashtable]$BoundParameters
    )

    $base = $script:SfosConnection
    if ($BoundParameters.ContainsKey('Session')) {
        # ContainsKey, not a truthiness test on the value: an explicit -Session $null must
        # switch off the default-session fallback, not be treated as "not supplied".
        $base = Resolve-SfosSessionArgument -Session $BoundParameters['Session']
    }

    $resolved = @{
        Firewall             = $BoundParameters.Firewall
        Port                 = $BoundParameters.Port
        Username             = $BoundParameters.Username
        Password             = $BoundParameters.Password
        SkipCertificateCheck = $BoundParameters.SkipCertificateCheck
    }

    if ($base) {
        if (-not $resolved.Firewall) {
            $resolved.Firewall = $base.Firewall
        }
        # ContainsKey again: 0 is falsy, so -not would treat an explicit -Port 0 as
        # "not supplied" and quietly substitute another port instead of rejecting it.
        if (-not $BoundParameters.ContainsKey('Port')) {
            $resolved.Port = $base.Port
        }
        if (-not $resolved.Username) {
            $resolved.Username = $base.Username
        }
        if (-not $resolved.Password) {
            $resolved.Password = $base.Password
        }
        # ContainsKey, not -not: an explicit -SkipCertificateCheck:$false must win over
        # a session that was opened with the switch enabled.
        if (-not $BoundParameters.ContainsKey('SkipCertificateCheck')) {
            $resolved.SkipCertificateCheck = $base.SkipCertificateCheck
        }
    }

    if (-not $resolved.Firewall -or -not $resolved.Username -or -not $resolved.Password) {
        throw 'No active Sophos Firewall connection found. Use Connect-SfosFirewall to establish a connection, pass -Session, or provide Firewall, Username, and Password explicitly.'
    }

    if (-not $BoundParameters.ContainsKey('Port') -and -not $resolved.Port) {
        $resolved.Port = $script:DefaultSfosPort
    }

    # Connect-SfosFirewall validates the range, this path did not: a negative port used to
    # travel all the way into the URI and surface as an opaque UriFormatException.
    if ($resolved.Port -lt 1 -or $resolved.Port -gt 65535) {
        throw "Port $($resolved.Port) is outside the valid range 1-65535."
    }

    return $resolved
}

<#
.SYNOPSIS
    Opens a connection to a Sophos Firewall for use by every other cmdlet.
 
.DESCRIPTION
    Stores the firewall address, credentials and connection options as the default
    connection, so subsequent cmdlets from any module in this suite can be called without
    connection parameters. The password is kept as a SecureString, never as plain text. This
    cmdlet does not itself send a request; the credentials are only validated on first use.
 
    Call it once at the start of a session. Use -Name to hold more than one connection at
    the same time and address a specific one later with a cmdlet's own -Session parameter.
 
.PARAMETER Firewall
    Required. Host name or IP address of the firewall.
 
.PARAMETER Port
    Optional. TCP port of the management API. Default 4444.
 
.PARAMETER Credential
    Required. A PSCredential holding the API user name and password.
 
.PARAMETER SkipCertificateCheck
    Optional. Accepts the firewall certificate without validating it. Use this only for
    appliances that still present a self-signed certificate.
 
.PARAMETER Name
    Optional. Registers this connection under a name in the session registry, so it can be
    referenced later as -Session '<Name>' from any cmdlet, or listed with Get-SfosSession,
    without holding a reference to the returned object. Lookup is case-insensitive. A
    connection made without -Name still becomes the default session, just without a registry
    entry.
 
.PARAMETER NoDefault
    Optional. Keeps the current default session unchanged instead of replacing it with this
    connection. Only meaningful together with -Name; without -Name this switch has no effect
    and the connection still becomes the default, because there would otherwise be no way to
    reach it again.
 
.INPUTS
    None. This cmdlet does not accept pipeline input.
 
.OUTPUTS
    System.Management.Automation.PSCustomObject. The session object, with the properties
    Firewall, Port, Username, Password and SkipCertificateCheck. Pass it to another cmdlet's
    -Session parameter, or splat it directly.
 
.EXAMPLE
    $cred = Get-Credential -Message 'Sophos Firewall Admin'
    Connect-SfosFirewall -Firewall '192.168.1.1' -Port 4444 -Credential $cred -SkipCertificateCheck
 
    Opens a connection and makes it the default for every subsequent cmdlet call.
 
.EXAMPLE
    $cred = Get-Credential -Message 'Sophos Firewall Admin'
    Connect-SfosFirewall -Firewall 'fw1.example.test' -Credential $cred -Name 'fw1'
    Connect-SfosFirewall -Firewall 'fw2.example.test' -Credential $cred -Name 'fw2' -NoDefault
    Get-SfosSession
 
    Holds two connections at once: fw1 becomes the default session, fw2 is registered but
    does not replace it.
 
.LINK
    https://docs.sophos.com/nsg/sophos-firewall/22.0/api/
 
.LINK
    Disconnect-SfosFirewall
 
.LINK
    Get-SfosSession
#>

function Connect-SfosFirewall {
    # PSUseShouldProcessForStateChangingFunctions is suppressed on purpose. Connecting only
    # stores session data in this process and reads from the firewall to verify it; nothing
    # on the appliance changes, so there is nothing for ShouldProcess to confirm.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Firewall,

        [ValidateRange(1, 65535)]
        [int]$Port = $script:DefaultSfosPort,

        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [pscredential]$Credential,

        [switch]$SkipCertificateCheck,

        [ValidateNotNullOrEmpty()]
        [string]$Name,

        [switch]$NoDefault
    )

    $session = [PSCustomObject]@{
        Firewall             = $Firewall
        Port                 = $Port
        Username             = $Credential.UserName
        Password             = $Credential.Password
        SkipCertificateCheck = [bool]$SkipCertificateCheck
    }
    $session.PSObject.TypeNames.Insert(0, 'SophosFirewall.Session')

    if ($Name) {
        $script:SfosSessions[$Name] = $session
    }

    # -NoDefault only has an effect together with -Name: without a registered name there is
    # no other way to reach this connection again, so treating a bare -NoDefault as "make no
    # default at all" would just lose the session. See .PARAMETER NoDefault.
    if (-not ($NoDefault -and $Name)) {
        $script:SfosConnection = $session
    }

    Write-Verbose "Connected to Sophos Firewall at $Firewall`:$Port as $($Credential.UserName)"
    return $session
}

<#
.SYNOPSIS
    Closes one, several, or all Sophos Firewall sessions.
 
.DESCRIPTION
    Removes stored connection state so it can no longer be used by other cmdlets. Nothing is
    sent to the firewall; this only clears local session data. Four ways to select what to
    close, mutually exclusive: no parameter clears the default session; -Name removes the one
    named session from the registry, and also clears the default session if that named
    session is currently the default; -Session does the same, but takes the session object
    itself, so Get-SfosSession | Disconnect-SfosFirewall works; -All clears the default
    session and empties the entire registry.
 
.PARAMETER Name
    Required in this parameter set. The registered name of the session to remove.
 
.PARAMETER Session
    Required in this parameter set. A session object from Connect-SfosFirewall, or the name
    of a session that was registered with Connect-SfosFirewall -Name. Accepts pipeline input.
 
.PARAMETER All
    Required in this parameter set. Closes the default session and every registered named
    session.
 
.INPUTS
    System.Object. Session can be piped in, for example from Get-SfosSession.
 
.OUTPUTS
    None.
 
.EXAMPLE
    Disconnect-SfosFirewall
 
    Closes the default session.
 
.EXAMPLE
    Disconnect-SfosFirewall -Name 'fw2'
 
    Closes the session registered under the name 'fw2'.
 
.EXAMPLE
    Get-SfosSession -Name 'fw2' | Disconnect-SfosFirewall
 
    Closes the session found by Get-SfosSession.
 
.EXAMPLE
    Disconnect-SfosFirewall -All
 
    Closes every open and registered session.
 
.LINK
    https://docs.sophos.com/nsg/sophos-firewall/22.0/api/
 
.LINK
    Connect-SfosFirewall
 
.LINK
    Get-SfosSession
#>

function Disconnect-SfosFirewall {
    # PSUseShouldProcessForStateChangingFunctions is suppressed on purpose. Disconnecting drops
    # session data held in this process; the firewall is never contacted, so there is nothing
    # for ShouldProcess to confirm.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    # PSReviewUnusedParameter: -All only selects the 'All' parameter set; $PSCmdlet.ParameterSetName
    # drives the body, so the switch's value itself is never read once it has done that job.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'All')]
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Name')]
        [string]$Name,

        [Parameter(Mandatory, ParameterSetName = 'Session', ValueFromPipeline)]
        [object]$Session,

        [Parameter(ParameterSetName = 'All')]
        [switch]$All
    )

    process {
        switch ($PSCmdlet.ParameterSetName) {
            'Default' {
                if ($script:SfosConnection) {
                    Write-Verbose "Disconnected from Sophos Firewall at $($script:SfosConnection.Firewall)"
                    $script:SfosConnection = $null
                }
            }
            'Name' {
                if (-not $script:SfosSessions.ContainsKey($Name)) {
                    throw "No session named '$Name' is registered. Use Get-SfosSession to list registered sessions."
                }
                $removed = $script:SfosSessions[$Name]
                $script:SfosSessions.Remove($Name)
                if ($script:SfosConnection -and $script:SfosConnection -eq $removed) {
                    $script:SfosConnection = $null
                }
                Write-Verbose "Disconnected session '$Name' from Sophos Firewall at $($removed.Firewall)"
            }
            'Session' {
                # Get-SfosSession's own view object (Name/Firewall/Port/Username/
                # SkipCertificateCheck/IsDefault, deliberately without Password) is not the
                # same object reference as the registry entry and carries no
                # 'SophosFirewall.Session' PSTypeName, so Resolve-SfosSessionArgument's
                # duck-typing fallback would pass it through unchanged - and the reference
                # match below would then find nothing and silently disconnect nothing, the
                # exact "answers success, changes nothing" failure this project's rules
                # single out as the worst outcome available. Route it through its own Name
                # instead so 'Get-SfosSession -Name x | Disconnect-SfosFirewall' resolves to
                # the actual registered object.
                $target = $Session
                if ($target -isnot [string] -and
                    $target.PSObject.TypeNames -notcontains 'SophosFirewall.Session' -and
                    $target.PSObject.Properties.Match('Name').Count -gt 0) {
                    $target = [string]$target.Name
                }

                $resolved = Resolve-SfosSessionArgument -Session $target
                if ($resolved) {
                    foreach ($key in @($script:SfosSessions.Keys)) {
                        if ($script:SfosSessions[$key] -eq $resolved) {
                            $script:SfosSessions.Remove($key)
                        }
                    }
                    if ($script:SfosConnection -and $script:SfosConnection -eq $resolved) {
                        $script:SfosConnection = $null
                    }
                    Write-Verbose "Disconnected session from Sophos Firewall at $($resolved.Firewall)"
                }
            }
            'All' {
                $script:SfosConnection = $null
                $script:SfosSessions.Clear()
                Write-Verbose 'Disconnected all Sophos Firewall sessions.'
            }
        }
    }
}

<#
.SYNOPSIS
    Lists registered Sophos Firewall sessions, or one specific session by name.
 
.DESCRIPTION
    Returns a view of the sessions that were registered with Connect-SfosFirewall -Name,
    showing which one is currently the default. The stored password is never part of the
    output.
 
.PARAMETER Name
    Optional. Returns only the session registered under this name. Throws if no session with
    that name is registered. If omitted, every registered session is returned.
 
.INPUTS
    None. This cmdlet does not accept pipeline input.
 
.OUTPUTS
    System.Management.Automation.PSCustomObject. One object per session, with the properties
    Name, Firewall, Port, Username, SkipCertificateCheck and IsDefault.
 
.EXAMPLE
    Get-SfosSession
 
    Lists every registered session.
 
.EXAMPLE
    Get-SfosSession -Name 'fw2'
 
    Returns the session registered under the name 'fw2'.
 
.LINK
    https://docs.sophos.com/nsg/sophos-firewall/22.0/api/
 
.LINK
    Connect-SfosFirewall
#>

function Get-SfosSession {
    # PSUseSingularNouns: 'Session' is already singular - this cmdlet returns either every
    # registered session (no -Name) or exactly one (-Name), same as every other Get-Sfos*.
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [string]$Name
    )

    if ($Name) {
        if (-not $script:SfosSessions.ContainsKey($Name)) {
            throw "No session named '$Name' is registered. Use Connect-SfosFirewall -Name '$Name' to register one."
        }
        $entry = $script:SfosSessions[$Name]
        return [PSCustomObject]@{
            Name                 = $Name
            Firewall             = $entry.Firewall
            Port                 = $entry.Port
            Username             = $entry.Username
            SkipCertificateCheck = $entry.SkipCertificateCheck
            IsDefault            = [bool]($script:SfosConnection -and $script:SfosConnection -eq $entry)
        }
    }

    foreach ($key in @($script:SfosSessions.Keys | Sort-Object)) {
        $entry = $script:SfosSessions[$key]
        [PSCustomObject]@{
            Name                 = $key
            Firewall             = $entry.Firewall
            Port                 = $entry.Port
            Username             = $entry.Username
            SkipCertificateCheck = $entry.SkipCertificateCheck
            IsDefault            = [bool]($script:SfosConnection -and $script:SfosConnection -eq $entry)
        }
    }
}

#endregion

#region Archive Response Parsing

<#
.SYNOPSIS
    Reads a tar archive returned by the Sophos Firewall API as a file response.
 
.DESCRIPTION
    Certificate, CertificateAuthority, CRL and FormTemplate answer a Get with
    application/octet-stream instead of XML: a tar archive holding an Entities.xml (the same
    Response envelope a normal Get returns, describing every matching object) plus one file
    per object. Domain modules pass the raw response bytes to this cmdlet instead of parsing
    the archive themselves.
 
    The firewall double-UTF8-encodes any non-ASCII byte inside a tar header's name field
    (observed on object names containing non-ASCII characters). That shifts every field
    after the name by the extra byte count, so the header's own checksum no longer matches
    and the block chain cannot be walked past that entry - every file after it is
    unreachable through the tar structure, even though its bytes are still present in the
    response. This cmdlet does not throw when that happens: it returns every file entry read
    successfully before the break, sets -Truncated, names the last entry that read cleanly,
    and warns with that name so the caller knows which object's own file could not be read.
 
    Entities.xml is read independently of the block chain, by scanning the raw response for
    its Response...Response envelope rather than walking the tar structure to find it. That
    keeps the metadata for every matching object available even when one object's file
    entry desynchronises everything stored after it in the archive - the corruption is
    confined to individual file headers and does not touch the XML text itself.
 
.PARAMETER Bytes
    Required. The raw response body, exactly as received - never routed through a string or
    a character encoding, so binary file content stays byte-exact.
 
.PARAMETER ExtractTo
    Optional. Directory to write the contained files to, preserving their relative path from
    the archive root. Created if it does not exist. Without this parameter, files are only
    returned in memory via the Bytes property of each entry in .Files.
 
.INPUTS
    None. This cmdlet does not accept pipeline input.
 
.OUTPUTS
    System.Management.Automation.PSCustomObject with the properties:
    Entities (the parsed Entities.xml as an [xml] document, or $null if none was found),
    Files (an array of objects with Name and Bytes, plus Path when -ExtractTo was used;
    empty array if the archive holds no readable file), Truncated (whether the tar block
    chain broke before the end of the archive), and TruncatedAfter (the name of the last
    file entry read cleanly before the break, or $null if nothing broke or the very first
    entry was already unreadable).
 
.EXAMPLE
    $inner = Get-Content .\get-certificateauthority.xml -Raw
    $response = Invoke-SfosApi -Session 'fw1' -InnerXml $inner
    $archive = ConvertFrom-SfosArchive -Bytes $response.Content
 
    Reads every CertificateAuthority object's metadata from $archive.Entities, even if one
    object's certificate file could not be extracted.
 
.EXAMPLE
    $archive = ConvertFrom-SfosArchive -Bytes $response.Content -ExtractTo 'C:\Sfos\Export'
 
    Also writes every file the archive contains to disk and records the written path on
    each entry in .Files.
 
.LINK
    https://docs.sophos.com/nsg/sophos-firewall/22.0/api/
#>

function ConvertFrom-SfosArchive {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)]
        [byte[]]$Bytes,

        [string]$ExtractTo
    )

    $latin1 = [System.Text.Encoding]::GetEncoding(28591)
    $ascii = [System.Text.Encoding]::ASCII

    # Entities.xml is located by scanning the raw bytes for its own envelope rather than by
    # walking the tar block chain, because that chain can desynchronise before reaching it -
    # Entities.xml is always the last entry - while the bytes themselves are still present
    # right where the archive placed them. Latin-1 maps every byte to exactly one character
    # and back, so this search never alters or loses a byte; it only locates the range to
    # decode as UTF-8 afterwards.
    $entities = $null
    if ($Bytes.Length -gt 0) {
        $fullText = $latin1.GetString($Bytes)
        $startIndex = $fullText.IndexOf('<?xml')
        if ($startIndex -lt 0) {
            $startIndex = $fullText.IndexOf('<Response')
        }
        if ($startIndex -ge 0) {
            $endMarker = '</Response>'
            $endIndex = $fullText.LastIndexOf($endMarker)
            if ($endIndex -ge $startIndex) {
                $endIndex = $endIndex + $endMarker.Length
                $entitiesBytes = $Bytes[$startIndex..($endIndex - 1)]
                $entitiesText = [System.Text.Encoding]::UTF8.GetString($entitiesBytes)
                try {
                    $entities = [xml]$entitiesText
                }
                catch {
                    Write-Warning "ConvertFrom-SfosArchive found an Entities.xml block but could not parse it as XML: $($_.Exception.Message)"
                }
            }
        }
    }

    # The tar block chain is walked separately, purely to recover the individual files. A
    # 512-byte block that is entirely zero marks a clean end of archive, not a break.
    $files = [System.Collections.Generic.List[object]]::new()
    $truncated = $false
    $truncatedAfter = $null
    $lastGoodName = $null
    $position = 0

    while (($position + 512) -le $Bytes.Length) {
        $header = $Bytes[$position..($position + 511)]

        $isZeroBlock = $true
        for ($i = 0; $i -lt 512; $i++) {
            if ($header[$i] -ne 0) {
                $isZeroBlock = $false
                break
            }
        }
        if ($isZeroBlock) {
            break
        }

        $name = $ascii.GetString($header[0..99]).TrimEnd([char]0)
        $typeFlag = [char]$header[156]

        $sizeText = $ascii.GetString($header[124..135]).Trim([char]0, ' ')
        $size = -1
        if ($sizeText) {
            try { $size = [Convert]::ToInt64($sizeText, 8) } catch { $size = -1 }
        }
        else {
            $size = 0
        }

        $checksumText = $ascii.GetString($header[148..155]).Trim([char]0, ' ')
        $expectedChecksum = -1
        if ($checksumText) {
            try { $expectedChecksum = [Convert]::ToInt64($checksumText, 8) } catch { $expectedChecksum = -1 }
        }

        # The recorded checksum treats its own 8-byte field as ASCII spaces while summing
        # every other byte of the header - this is what a double-encoded name field throws
        # out of alignment, which is exactly the corruption this cmdlet is built to detect.
        $actualChecksum = 0
        for ($i = 0; $i -lt 512; $i++) {
            if ($i -ge 148 -and $i -lt 156) {
                $actualChecksum += 32
            }
            else {
                $actualChecksum += $header[$i]
            }
        }

        $headerValid = ($size -ge 0) -and ($expectedChecksum -ge 0) -and ($actualChecksum -eq $expectedChecksum)

        if ($headerValid) {
            $dataStart = $position + 512
            if (($dataStart + $size) -gt $Bytes.Length) {
                $headerValid = $false
            }
        }

        if (-not $headerValid) {
            $truncated = $true
            $truncatedAfter = $lastGoodName
            if ($truncatedAfter) {
                Write-Warning "ConvertFrom-SfosArchive: the archive's internal structure could not be read past the entry after '$truncatedAfter' - the firewall produced a malformed tar header there (commonly caused by non-ASCII characters in an object's stored name). Returning every file read successfully before that point; Entities.xml metadata is unaffected."
            }
            else {
                Write-Warning 'ConvertFrom-SfosArchive: the archive starts with a malformed tar header and no file could be read from it. Entities.xml metadata is unaffected.'
            }
            break
        }

        $dataStart = $position + 512
        if ($typeFlag -eq '0' -or $typeFlag -eq [char]0) {
            if ($size -eq 0) {
                $content = [byte[]]@()
            }
            else {
                $content = $Bytes[$dataStart..($dataStart + $size - 1)]
            }
            $files.Add([PSCustomObject]@{ Name = $name; Bytes = $content })
        }

        $lastGoodName = $name
        $dataBlocks = [Math]::Ceiling($size / 512.0)
        $position = $dataStart + ([int64]$dataBlocks * 512)
    }

    if ($ExtractTo) {
        if (-not (Test-Path -LiteralPath $ExtractTo)) {
            New-Item -ItemType Directory -Path $ExtractTo -Force | Out-Null
        }
        $extractRoot = (Resolve-Path -LiteralPath $ExtractTo).ProviderPath

        foreach ($file in $files) {
            $relative = $file.Name -replace '^\./', ''
            $targetPath = [System.IO.Path]::GetFullPath((Join-Path -Path $extractRoot -ChildPath $relative))

            # Refuse to write outside -ExtractTo: a stored name containing '..' would
            # otherwise be able to place a file anywhere the process can write.
            if (-not $targetPath.StartsWith($extractRoot, [StringComparison]::OrdinalIgnoreCase)) {
                Write-Warning "ConvertFrom-SfosArchive: skipped extracting '$($file.Name)' because it resolves outside -ExtractTo."
                continue
            }

            $targetDir = Split-Path -Path $targetPath -Parent
            if ($targetDir -and -not (Test-Path -LiteralPath $targetDir)) {
                New-Item -ItemType Directory -Path $targetDir -Force | Out-Null
            }
            [System.IO.File]::WriteAllBytes($targetPath, $file.Bytes)
            $file | Add-Member -MemberType NoteProperty -Name 'Path' -Value $targetPath -Force
        }
    }

    return [PSCustomObject]@{
        Entities       = $entities
        Files          = $files.ToArray()
        Truncated      = $truncated
        TruncatedAfter = $truncatedAfter
    }
}

#endregion

#region Module Exports

Export-ModuleMember -Function @(
    'Connect-SfosFirewall',
    'Disconnect-SfosFirewall',
    'Get-SfosSession',
    'Invoke-SfosApi',
    'Get-SfosApiStatus',
    'Assert-SfosApiReturnSuccess',
    'Resolve-SfosParameters',
    'ConvertTo-SfosXmlEscaped',
    'ConvertFrom-SfosArchive'
)

#endregion