Private/Invoke-InforcerRawDownload.ps1

function Invoke-InforcerRawDownload {
    <#
    .SYNOPSIS
        Performs a binary-safe GET against an Inforcer API endpoint (Private helper).
    .DESCRIPTION
        Uses Invoke-WebRequest under the hood so the response body is captured as raw bytes
        rather than UTF-8-decoded text. Returns the bytes alongside the server-suggested
        filename (parsed from Content-Disposition) and the x-correlation-id for support.

        Used by Save-InforcerReportOutput to fetch /reports/runs/{id}/outputs/{id} — which can be
        CSV, JSON, HTML, PDF, or other binary content. The URI and auth header come from
        Get-InforcerBaseUrl / Get-InforcerAuthHeader, so it works for API-key (beta) and
        client-credentials (v1) sessions; a v1 401 renews the access token and retries once.

        Error envelope handling mirrors Invoke-InforcerApiRequest: app-layer, APIM, and
        RFC 9110 ProblemDetails shapes are all parsed for a usable error message.
    .PARAMETER Endpoint
        Version-neutral API path (e.g. /reports/runs/.../outputs/...). Leading slash optional.
    .PARAMETER DefaultFileName
        Suggested filename when the server doesn't return a usable Content-Disposition.
        Defaults to 'output'.
    .PARAMETER DestinationDirectory
        When set, response body is streamed directly to a temp file in this directory rather
        than buffered into memory, then renamed to the Content-Disposition filename. Use this
        for large downloads (>100MB) to avoid a 2GB+ in-memory allocation on multi-GB reports.
        The returned object has a `FilePath` instead of `Bytes`. The directory must already exist.
        An existing file is never overwritten: a taken name gets a ' (1)', ' (2)', ... suffix.
        On Windows the saved file is marked as downloaded from the internet (Zone.Identifier).
    .PARAMETER FileName
        Final file name chosen by the caller. When set, the server's Content-Disposition name is
        ignored and never touches the disk. Sanitized like the server name.
    .OUTPUTS
        PSCustomObject with members:
          Bytes — [byte[]] response body (omitted when -DestinationDirectory is used)
          FilePath — final on-disk path (only when -DestinationDirectory is used)
          FileName — sanitized filename (from Content-Disposition or DefaultFileName)
          ContentType — value of Content-Type header (or $null)
          CorrelationId — x-correlation-id of the response (or $null)
          StatusCode — int HTTP status code
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Endpoint,

        [Parameter(Mandatory = $false)]
        [string]$DefaultFileName = 'output',

        [Parameter(Mandatory = $false)]
        [string]$DestinationDirectory,

        [Parameter(Mandatory = $false)]
        [string]$FileName
    )

    if (-not (Test-InforcerSession)) {
        Write-Error -Message 'Not connected. Run Connect-Inforcer first.' `
            -ErrorId 'NotConnected' -Category ConnectionError
        return
    }

    $uri = Get-InforcerBaseUrl -Endpoint $Endpoint

    Write-Verbose "Downloading: GET $uri"

    # When -DestinationDirectory is set, stream the body straight to a temp file via
    # `-OutFile` so we don't materialize a multi-GB body in memory. The body is renamed
    # to the Content-Disposition filename after the response headers are inspected.
    $tempStreamPath = $null
    if ($PSBoundParameters.ContainsKey('DestinationDirectory')) {
        if (-not (Test-Path -LiteralPath $DestinationDirectory -PathType Container)) {
            Write-Error -Message "DestinationDirectory '$DestinationDirectory' does not exist." `
                -ErrorId 'BadDestination' -Category InvalidArgument
            return
        }
        # File.Move resolves relative paths against the process directory, not $PWD.
        $DestinationDirectory = Convert-Path -LiteralPath $DestinationDirectory
        $tempStreamPath = Join-Path -Path $DestinationDirectory -ChildPath ('inforcer-download-' + [guid]::NewGuid().ToString('N') + '.partial')
    }

    $isV1 = $script:InforcerSession.ApiVersion -eq 'v1'
    $forceRefresh = $false
    while ($true) {
        $headers = Get-InforcerAuthHeader -ForceRefresh:$forceRefresh
        if (-not $headers) { return }
        $headers['Accept'] = '*/*'

        try {
            # -SkipHttpErrorCheck lets us inspect status code and body without exception-based flow.
            if ($tempStreamPath) {
                $response = Invoke-WebRequest -Uri $uri -Method GET -Headers $headers -SkipHttpErrorCheck `
                    -MaximumRedirection 0 -UseBasicParsing -Debug:$false -Verbose:$false -OutFile $tempStreamPath -PassThru -ErrorAction Stop
            } else {
                $response = Invoke-WebRequest -Uri $uri -Method GET -Headers $headers -SkipHttpErrorCheck `
                    -MaximumRedirection 0 -UseBasicParsing -Debug:$false -Verbose:$false -ErrorAction Stop
            }
        } catch {
            Clear-InforcerRequestCredential -ErrorRecord $_
            if ($tempStreamPath -and (Test-Path -LiteralPath $tempStreamPath)) {
                Remove-Item -LiteralPath $tempStreamPath -Force -ErrorAction SilentlyContinue
            }
            if ($_.FullyQualifiedErrorId -match 'MaximumRedirect') {
                Write-Error -Message (Get-InforcerRedirectMessage -StatusCode 302 -Headers $null) -ErrorId 'DownloadFailed_302' -Category ConnectionError
                return
            }
            $msg = Protect-InforcerApiKeyInText -Text $_.Exception.Message
            Write-Error -Message "Download request failed: $msg" `
                -ErrorId 'DownloadFailed' -Category ConnectionError
            return
        }

        if ($isV1 -and -not $forceRefresh -and [int]$response.StatusCode -eq 401) {
            Write-Verbose 'HTTP 401: renewing the access token and retrying once.'
            if ($tempStreamPath -and (Test-Path -LiteralPath $tempStreamPath)) {
                Remove-Item -LiteralPath $tempStreamPath -Force -ErrorAction SilentlyContinue
            }
            $forceRefresh = $true
            continue
        }
        break
    }

    $statusCode    = [int]$response.StatusCode
    $correlationId = Get-InforcerHeaderValue -Headers $response.Headers -Name 'x-correlation-id'
    if ($correlationId) {
        Write-Verbose "x-correlation-id: $correlationId"
    }

    if ($statusCode -ge 300 -and $statusCode -lt 400) {
        if ($tempStreamPath) { Remove-Item -LiteralPath $tempStreamPath -Force -ErrorAction SilentlyContinue }
        Write-Error -Message (Get-InforcerRedirectMessage -StatusCode $statusCode -Headers $response.Headers) `
            -ErrorId "DownloadFailed_$statusCode" -Category ConnectionError
        return
    }

    if ($statusCode -lt 200 -or $statusCode -ge 300) {
        # Parse error body for a friendly message — same envelope shapes as Invoke-InforcerApiRequest.
        # If we streamed to disk, the error body is in the temp file, not $response.Content.
        $detail = $null
        $bodyText = $null
        if ($tempStreamPath -and (Test-Path -LiteralPath $tempStreamPath)) {
            try { $bodyText = Get-Content -LiteralPath $tempStreamPath -Raw -ErrorAction Stop } catch { $bodyText = $null }
            Remove-Item -LiteralPath $tempStreamPath -Force -ErrorAction SilentlyContinue
        } elseif ($response.Content) {
            $bodyText = if ($response.Content -is [byte[]]) {
                [System.Text.Encoding]::UTF8.GetString($response.Content)
            } else {
                $response.Content -as [string]
            }
        }
        if ($bodyText) {
            $detail = $bodyText
            try {
                $json = $bodyText | ConvertFrom-Json -ErrorAction Stop
                $apiMessage = $null
                $traceId    = $null
                if ($null -ne $json.PSObject.Properties['message']) {
                    $apiMessage = $json.PSObject.Properties['message'].Value -as [string]
                } elseif ($null -ne $json.PSObject.Properties['title']) {
                    $apiMessage = $json.PSObject.Properties['title'].Value -as [string]
                    $problemDetail = $json.PSObject.Properties['detail'].Value -as [string]
                    if ($problemDetail -and $problemDetail -ne $apiMessage) { $apiMessage = "${apiMessage}: $problemDetail" }
                    if ($null -ne $json.PSObject.Properties['traceId']) {
                        $traceId = $json.PSObject.Properties['traceId'].Value -as [string]
                    }
                }
                if (-not [string]::IsNullOrWhiteSpace($apiMessage)) { $detail = $apiMessage }
                if (-not [string]::IsNullOrWhiteSpace($traceId))    { $detail = "$detail (traceId: $traceId)" }
            } catch {
                Write-Verbose 'Error response body was not JSON; using raw content.'
            }
        }
        if ([string]::IsNullOrWhiteSpace($detail)) { $detail = "HTTP $statusCode" }
        $detail = Protect-InforcerApiKeyInText -Text $detail
        $msg = "Inforcer API download failed (HTTP $statusCode): $detail"
        if ($correlationId) { $msg = "$msg [correlation-id: $correlationId]" }
        Write-Error -Message $msg -ErrorId "DownloadFailed_$statusCode" -Category ConnectionError
        return
    }

    # Extract Content-Disposition / Content-Type header values (cross-shape tolerant).
    $disposition = $null
    $contentType = $null
    if ($response.Headers) {
        $disposition = Get-InforcerHeaderValue -Headers $response.Headers -Name 'Content-Disposition'
        $contentType = Get-InforcerHeaderValue -Headers $response.Headers -Name 'Content-Type'
    }

    $fileName = if ($FileName) {
        Resolve-InforcerReportOutputFileName -DefaultName $FileName
    } else {
        Resolve-InforcerReportOutputFileName -ContentDisposition $disposition -DefaultName $DefaultFileName
    }

    if ($tempStreamPath) {
        # File.Move without overwrite fails when the name is taken, so an existing file is never
        # replaced; the next ' (n)' name is tried instead.
        $stem = [System.IO.Path]::GetFileNameWithoutExtension($fileName)
        $extension = [System.IO.Path]::GetExtension($fileName)
        $candidate = $fileName
        for ($n = 1; ; $n++) {
            $finalPath = Join-Path -Path $DestinationDirectory -ChildPath $candidate
            try {
                [System.IO.File]::Move($tempStreamPath, $finalPath)
                break
            } catch {
                if ($n -le 1000 -and (Test-Path -LiteralPath $finalPath)) {
                    $candidate = '{0} ({1}){2}' -f $stem, $n, $extension
                    continue
                }
                Remove-Item -LiteralPath $tempStreamPath -Force -ErrorAction SilentlyContinue
                Write-Error -Message "Failed to finalize downloaded file at '$finalPath': $($_.Exception.Message)" `
                    -ErrorId 'DownloadFinalizeFailed' -Category WriteError
                return
            }
        }
        $fileName = $candidate
        if ($IsWindows) {
            try {
                Set-Content -LiteralPath $finalPath -Stream 'Zone.Identifier' -Value '[ZoneTransfer]', 'ZoneId=3' -ErrorAction Stop
            } catch {
                Write-Verbose "Could not mark '$finalPath' as downloaded from the internet: $($_.Exception.Message)"
            }
        }
        $size = (Get-Item -LiteralPath $finalPath -ErrorAction SilentlyContinue).Length
        return [PSCustomObject]@{
            FilePath      = $finalPath
            FileName      = $fileName
            FileSize      = $size
            ContentType   = $contentType
            CorrelationId = $correlationId
            StatusCode    = $statusCode
        }
    }

    # In-memory mode (existing behavior — backwards compatible for small payloads).
    # Invoke-WebRequest returns Content as byte[] when -UseBasicParsing and binary response.
    $bytes = if ($response.Content -is [byte[]]) {
        $response.Content
    } elseif ($response.Content -is [string]) {
        [System.Text.Encoding]::UTF8.GetBytes($response.Content)
    } else {
        @()
    }

    [PSCustomObject]@{
        Bytes         = $bytes
        FileName      = $fileName
        ContentType   = $contentType
        CorrelationId = $correlationId
        StatusCode    = $statusCode
    }
}