Public/Save-InforcerReportOutput.ps1

<#
.SYNOPSIS
    Downloads a report output to disk.

    Required API scope(s): Reports.Read
.DESCRIPTION
    GET /beta/reports/runs/{runId}/outputs/{outputId} returns the raw bytes plus a
    Content-Disposition filename. This cmdlet writes those bytes to -OutputPath using the
    server-suggested filename (sanitized) and emits a result object per saved file.

    Pipeline-friendly: pipe output records from Invoke-InforcerReport -NoSave or
    Get-InforcerReportRun -IncludeOutputs and every output is downloaded.
.PARAMETER RunId
    The run identifier (GUID). Pipeline-bindable by property name.
.PARAMETER OutputId
    The output identifier (string) — the id field on each output record. Pipeline-bindable
    by property name (also accepts -Id as an alias).
.PARAMETER ReportType
    Pipeline-bindable. When piped from Invoke-InforcerReport -NoSave or Get-InforcerReportRun
    -IncludeOutputs, this is auto-populated from the upstream record and surfaced on the
    result object so output formatting matches Invoke-InforcerReport. Otherwise $null.
.PARAMETER OutputFormat
    Pipeline-bindable. Same as ReportType — propagated from the upstream record when piped.
.PARAMETER TenantId
    Pipeline-bindable. Same as ReportType — propagated from the upstream record when piped.
.PARAMETER OutputPath
    Directory where downloaded outputs are written. Defaults to the current working directory.
    Created if it doesn't exist.
.PARAMETER FileName
    Override the server-suggested filename. Sanitized for cross-platform safety.
.PARAMETER OutputType
    PowerShellObject (default) or JsonObject.
.EXAMPLE
    Save-InforcerReportOutput -RunId <run> -OutputId <output>
    Saves a single output to the current directory.
.EXAMPLE
    Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId 482 -NoSave |
        Save-InforcerReportOutput -OutputPath ./reports
    Queues + polls a report without saving, then downloads every output to ./reports.
.EXAMPLE
    Get-InforcerReportRun -IncludeOutputs |
        ForEach-Object { $_.outputs } |
        Save-InforcerReportOutput -OutputPath ./bulk
    Bulk-downloads every output from every visible run.
.OUTPUTS
    PSObject or String — per saved file, with { RunId, OutputId, TenantId, ReportType,
    OutputFormat, FilePath, FileName, FileSize, ContentType, CorrelationId }. TenantId,
    ReportType, and OutputFormat are $null unless populated via pipeline binding.
.LINK
    https://github.com/royklo/InforcerCommunity/blob/main/docs/CMDLET-REFERENCE.md#save-inforcerreportoutput
.LINK
    Invoke-InforcerReport
.LINK
    Get-InforcerReportRun
#>

function Save-InforcerReportOutput {
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
[OutputType([PSObject], [string])]
param(
    [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, Position = 0)]
    [guid]$RunId,

    [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, Position = 1)]
    [Alias('Id')]
    [string]$OutputId,

    # Pipeline pass-through properties from upstream cmdlets (Invoke-InforcerReport -NoSave,
    # Get-InforcerReportRun -IncludeOutputs). Surfaced on the result so it matches the
    # InforcerCommunity.ReportRunResult format view; left $null when called standalone.
    [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
    [string]$ReportType,

    [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
    [string]$OutputFormat,

    [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
    [Alias('ClientTenantId')]
    [object]$TenantId,

    [Parameter(Mandatory = $false)]
    [string]$OutputPath = $PWD.Path,

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

    [Parameter(Mandatory = $false)]
    [ValidateSet('PowerShellObject', 'JsonObject')]
    [string]$OutputType = 'PowerShellObject'
)

begin {
    # PowerShell quirk: `return` inside `begin` does NOT prevent `process` from firing for
    # piped items. Gate `process` on a "begin succeeded" flag instead.
    # Locals (NOT $script:*) so two parallel invocations sharing one module import don't
    # clobber each other's OutputPath.
    $beginOk = $false
    $resolvedOutputPath = $null

    if (-not (Test-InforcerSession)) {
        Write-Error -Message 'Not connected yet. Please run Connect-Inforcer first.' `
            -ErrorId 'NotConnected' -Category ConnectionError
        return
    }
    try {
        # Defense-in-depth: refuse system paths.
        $null = Test-InforcerSafeOutputPath -Path $OutputPath
        if (-not (Test-Path -LiteralPath $OutputPath -PathType Container)) {
            $null = New-Item -Path $OutputPath -ItemType Directory -Force -ErrorAction Stop
        }
        if (Test-Path -LiteralPath $OutputPath -PathType Container) {
            $resolvedOutputPath = (Resolve-Path -LiteralPath $OutputPath).Path
        } elseif (-not $WhatIfPreference) {
            throw "Output directory '$OutputPath' was not created."
        }
    } catch {
        Write-Error -Message "Cannot prepare output directory '$OutputPath': $($_.Exception.Message)" `
            -ErrorId 'OutputPathFailed' -Category InvalidArgument
        return
    }
    $beginOk = $true
}

process {
    if (-not $beginOk) { return }
    if ([string]::IsNullOrWhiteSpace($OutputId)) {
        Write-Error -Message 'OutputId is empty.' -ErrorId 'InvalidOutputId' -Category InvalidArgument
        return
    }

    $runIdStr = $RunId.ToString()
    $endpoint = "/beta/reports/runs/$runIdStr/outputs/$OutputId"

    # When -FileName is supplied the user's choice wins (after filesystem-safety sanitization).
    # Otherwise the server's Content-Disposition filename is used (via Invoke-InforcerRawDownload).
    # -FileName has ValidateNotNullOrEmpty so we won't get '' here, but still guard against whitespace.
    $userSuppliedName = $PSBoundParameters.ContainsKey('FileName') -and -not [string]::IsNullOrWhiteSpace($FileName)
    $defaultName = if ($userSuppliedName) { $FileName } else { ('{0}-{1}' -f $runIdStr, $OutputId) }

    $target = "$runIdStr / $OutputId → $resolvedOutputPath"
    if (-not $PSCmdlet.ShouldProcess($target, 'Download report output')) { return }

    Write-Verbose "Downloading: $endpoint"
    try {
        # Stream straight to disk so multi-GB report outputs don't materialize in memory.
        # The helper renames the temp file to the Content-Disposition filename for us.
        $download = Invoke-InforcerRawDownload -Endpoint $endpoint -DefaultFileName $defaultName `
            -DestinationDirectory $resolvedOutputPath -ErrorAction Stop
    } catch {
        Write-Error -Message "Failed to download output ${OutputId}: $($_.Exception.Message)" `
            -ErrorId 'DownloadFailed' -Category ReadError
        return
    }
    if ($null -eq $download) { return }

    $filePath = $download.FilePath
    $effectiveName = $download.FileName

    # When the user supplied an explicit -FileName, rename from the server-derived name to theirs.
    if ($userSuppliedName) {
        $overrideName = Resolve-InforcerReportOutputFileName -ContentDisposition $null -DefaultName $FileName
        $overridePath = Join-Path -Path $resolvedOutputPath -ChildPath $overrideName
        if ($overridePath -ne $filePath) {
            try {
                Move-Item -LiteralPath $filePath -Destination $overridePath -Force -ErrorAction Stop
                $filePath = $overridePath
                $effectiveName = $overrideName
            } catch {
                Write-Warning "Could not rename to user-requested '$overrideName': $($_.Exception.Message)"
            }
        }
    }

    $fileSize = if ($download.PSObject.Properties['FileSize']) { $download.FileSize } else { (Get-Item -LiteralPath $filePath -ErrorAction SilentlyContinue).Length }

    $result = [PSCustomObject][ordered]@{
        RunId         = $runIdStr
        OutputId      = $OutputId
        TenantId      = if ($PSBoundParameters.ContainsKey('TenantId')) { $TenantId } else { $null }
        ReportType    = if ($PSBoundParameters.ContainsKey('ReportType')) { $ReportType } else { $null }
        OutputFormat  = if ($PSBoundParameters.ContainsKey('OutputFormat')) { $OutputFormat } else { $null }
        FilePath      = $filePath
        FileName      = $effectiveName
        FileSize      = $fileSize
        ContentType   = $download.ContentType
        CorrelationId = $download.CorrelationId
    }
    $result.PSObject.TypeNames.Insert(0, 'InforcerCommunity.ReportRunResult')

    if ($OutputType -eq 'JsonObject') {
        $result | ConvertTo-Json -Depth 100
    } else {
        $result
    }
}
}