Private/Export-IOResult.ps1

function Export-IOResult {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowNull()]
        [AllowEmptyCollection()]
        [object[]]$Data,

        [string]$ToCsv,

        [string]$CommandName = 'IdentityOps'
    )

    if (-not $Data -or $Data.Count -eq 0) {
        Write-IOLog "No results found." -Level Info -Component $CommandName
        return
    }

    Write-IOLog "Found $($Data.Count) result(s)." -Level Info -Component $CommandName

    # ── CSV export ────────────────────────────────────────────────────────────
    if ($ToCsv) {
        $resolvedPath = $null
        try {
            $fileName  = Split-Path $ToCsv -Leaf
            $parentDir = Split-Path $ToCsv -Parent

            # Validate filename characters
            $badChars = [System.IO.Path]::GetInvalidFileNameChars()
            foreach ($c in $badChars) {
                if ($fileName.Contains($c)) {
                    throw "Filename contains invalid character: '$c'"
                }
            }

            # Ensure .csv extension
            if (-not $fileName.EndsWith('.csv', [System.StringComparison]::OrdinalIgnoreCase)) {
                $fileName = "${fileName}.csv"
            }

            # Resolve or create parent directory
            if ([string]::IsNullOrWhiteSpace($parentDir)) {
                $parentDir = (Get-Location).Path
            }
            else {
                if (-not (Test-Path $parentDir)) {
                    New-Item -ItemType Directory -Path $parentDir -Force -ErrorAction Stop | Out-Null
                }
                $parentDir = (Resolve-Path $parentDir -ErrorAction Stop).Path
            }

            $resolvedPath = Join-Path $parentDir $fileName

            # Block UNC paths to prevent data exfiltration to network shares
            if ($resolvedPath -match '^\\\\') {
                throw "Cannot write to UNC/network paths for security reasons: $resolvedPath"
            }

            # Prevent overwriting system files (case-insensitive)
            $normalizedPath = $resolvedPath.ToLowerInvariant()
            if ($normalizedPath -match '^[a-z]:\\(windows|program files|program files \(x86\)|programdata\\microsoft)') {
                throw "Cannot write to protected system directory: $resolvedPath"
            }
        }
        catch {
            throw [System.Management.Automation.ErrorRecord]::new(
                [System.ArgumentException]::new("Invalid CSV path '$ToCsv': $($_.Exception.Message)"),
                'IO_InvalidCsvPath',
                [System.Management.Automation.ErrorCategory]::InvalidArgument,
                $ToCsv
            )
        }

        try {
            $Data | Export-Csv -Path $resolvedPath -NoTypeInformation -Encoding utf8BOM -Force
            Write-Host " [CSV] Exported $($Data.Count) record(s) -> $resolvedPath" -ForegroundColor Green
        }
        catch {
            throw [System.Management.Automation.ErrorRecord]::new(
                [System.IO.IOException]::new("Failed to write CSV: $($_.Exception.Message)"),
                'IO_CsvWriteFailed',
                [System.Management.Automation.ErrorCategory]::WriteError,
                $resolvedPath
            )
        }
    }

    # Always output to pipeline
    return $Data
}