Private/Resolve-InforcerReportOutputFileName.ps1

function Resolve-InforcerReportOutputFileName {
    <#
    .SYNOPSIS
        Parses a Content-Disposition header to extract a safe output filename (Private helper).
    .DESCRIPTION
        Handles both filename= and the RFC 5987 filename*=charset'lang'percent-encoded form,
        preferring the latter when present (it can carry non-ASCII characters that filename=
        cannot). The returned name is sanitized: path components stripped, reserved characters
        removed, leading/trailing whitespace trimmed.

        When the header is absent, empty, or contains no usable filename, falls back to
        -DefaultName (or 'output' if also unset).
    .PARAMETER ContentDisposition
        The raw Content-Disposition header value. May be $null/empty.
    .PARAMETER DefaultName
        Fallback name when no usable filename can be parsed. Default: 'output'.
    .OUTPUTS
        String — a sanitized filename safe for cross-platform filesystem use.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $false, Position = 0)]
        [string]$ContentDisposition,

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

    $parsed = $null

    if (-not [string]::IsNullOrWhiteSpace($ContentDisposition)) {
        # RFC 5987 form takes precedence (supports non-ASCII).
        # Grammar: filename*=<charset>'<lang>'<percent-encoded-value>
        # We accept any charset; default to UTF-8 when missing or unknown.
        $rfc5987 = [regex]::Match(
            $ContentDisposition,
            "filename\*\s*=\s*(?<charset>[^']*)'(?<lang>[^']*)'(?<value>[^;]+)",
            [System.Text.RegularExpressions.RegexOptions]::IgnoreCase
        )
        if ($rfc5987.Success) {
            $charsetName = $rfc5987.Groups['charset'].Value
            $encodedValue = $rfc5987.Groups['value'].Value.Trim()
            $encoding = [System.Text.Encoding]::UTF8
            if (-not [string]::IsNullOrWhiteSpace($charsetName)) {
                try {
                    $encoding = [System.Text.Encoding]::GetEncoding($charsetName)
                } catch {
                    $encoding = [System.Text.Encoding]::UTF8
                }
            }
            try {
                $parsed = [System.Web.HttpUtility]::UrlDecode($encodedValue, $encoding)
            } catch {
                $parsed = [System.Uri]::UnescapeDataString($encodedValue)
            }
        }

        # Fallback to plain filename=
        if ([string]::IsNullOrWhiteSpace($parsed)) {
            $plain = [regex]::Match(
                $ContentDisposition,
                'filename\s*=\s*(?<quoted>"(?<q>[^"]*)"|(?<raw>[^;]+))',
                [System.Text.RegularExpressions.RegexOptions]::IgnoreCase
            )
            if ($plain.Success) {
                $value = if ($plain.Groups['q'].Success) { $plain.Groups['q'].Value } else { $plain.Groups['raw'].Value }
                $parsed = $value.Trim()
            }
        }
    }

    # Guarantee we have *something* even if both ContentDisposition and DefaultName are unusable.
    if ([string]::IsNullOrWhiteSpace($parsed)) { $parsed = $DefaultName }
    if ([string]::IsNullOrWhiteSpace($parsed)) { $parsed = 'output' }

    # Strip any directory components — only keep the leaf name.
    $parsed = [System.IO.Path]::GetFileName($parsed)

    # Replace control + cross-platform-unsafe characters with underscore. Applied on every
    # platform (defense-in-depth so files written on macOS / Linux are safely copied to a
    # Windows host later). The set is the Windows-reserved character class plus 0x00-0x1F
    # control chars.
    $unsafe = '[<>:"|?*\\/\x00-\x1F]'
    $parsed = [regex]::Replace($parsed, $unsafe, '_')

    # Trim trailing dots and spaces (illegal on Windows). Avoid stripping the only dot
    # in the name — "report." (which would otherwise eat the extension separator) is
    # preserved when the name has actual non-dot content.
    $beforeTrim = $parsed
    $candidate = $parsed.Trim().TrimEnd('.', ' ')
    $beforeTrimStripped = $beforeTrim.Trim().Trim('.', ' ')
    if ($candidate.Length -eq 0) {
        # Path was entirely dots/spaces — fall through to default.
        $parsed = ''
    } elseif (-not $candidate.Contains('.') -and $beforeTrimStripped.Length -gt 0 -and $beforeTrim.Trim() -ne $candidate) {
        # Trim removed a meaningful trailing dot. Keep it.
        $parsed = $beforeTrim.Trim()
    } else {
        $parsed = $candidate
    }

    if ([string]::IsNullOrWhiteSpace($parsed)) { $parsed = 'output' }

    # Prefix Windows reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9 with or without
    # extension) to avoid OS-level redirection on Windows. Cross-platform safe; just a leading '_'.
    $stem = [System.IO.Path]::GetFileNameWithoutExtension($parsed)
    $reserved = @('CON','PRN','AUX','NUL','COM1','COM2','COM3','COM4','COM5','COM6','COM7','COM8','COM9',
                  'LPT1','LPT2','LPT3','LPT4','LPT5','LPT6','LPT7','LPT8','LPT9')
    if ($reserved -contains $stem.ToUpperInvariant()) {
        $parsed = '_' + $parsed
    }

    # Cap leaf length at 200 chars (HFS+/APFS allows 255 bytes; multibyte names hit that fast).
    # Preserve the extension. Walk back from the cut point to a grapheme-cluster boundary so a
    # combining mark isn't orphaned from its base char.
    if ($parsed.Length -gt 200) {
        $ext = [System.IO.Path]::GetExtension($parsed)
        $base = [System.IO.Path]::GetFileNameWithoutExtension($parsed)
        $maxBase = 200 - $ext.Length
        if ($maxBase -lt 1) { $maxBase = 1 }
        $cut = [Math]::Min($base.Length, $maxBase)
        # Pull the cut back until it sits on a grapheme cluster boundary (handles combining marks).
        if ($cut -lt $base.Length) {
            $elements = [System.Globalization.StringInfo]::ParseCombiningCharacters($base)
            $safe = $cut
            foreach ($e in $elements) {
                if ($e -ge $cut) { break }
                $safe = $e
            }
            $cut = $safe
            if ($cut -lt 1) { $cut = 1 }
        }
        $parsed = $base.Substring(0, $cut) + $ext
    }

    return $parsed
}