Modules/IdLE.Core/Private/Copy-IdleRedactedObject.ps1

function Copy-IdleRedactedObject {
    [CmdletBinding()]
    param(
        [Parameter()]
        [AllowNull()]
        [object] $Value,

        [Parameter()]
        [AllowNull()]
        [string[]] $RedactedKeys,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string] $RedactionMarker = '[REDACTED]'
    )

    # Default key list aligned with Issue #48 acceptance criteria.
    # Keep this list conservative (exact match) to avoid accidental over-redaction.
    $defaultKeys = @(
        'password',
        'passphrase',
        'secret',
        'token',
        'apikey',
        'apiKey',
        'clientSecret',
        'accessToken',
        'refreshToken',
        'credential',
        'privateKey'
    )

    $effectiveKeys = if ($null -ne $RedactedKeys -and $RedactedKeys.Count -gt 0) {
        $RedactedKeys
    }
    else {
        $defaultKeys
    }

    # Use a reference-based visit set to avoid runaway recursion for cyclic graphs.
    $visited = [System.Collections.Generic.HashSet[int]]::new()

    function Test-IdleRedactionKeyMatch {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string] $Name
        )

        foreach ($k in $effectiveKeys) {
            if ($null -eq $k) {
                continue
            }

            if ([string]::Equals($Name, $k, [System.StringComparison]::OrdinalIgnoreCase)) {
                return $true
            }
        }

        return $false
    }

    function Get-IdlePrimaryTypeName {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [ValidateNotNull()]
            [object] $Object
        )

        # Preserve the original PSTypeName when present (e.g., 'IdLE.Event').
        # We intentionally skip default CLR / PowerShell type names.
        foreach ($t in $Object.PSObject.TypeNames) {
            if ([string]::IsNullOrWhiteSpace($t)) {
                continue
            }

            if ($t -eq 'System.Object' -or
                $t -eq 'System.Management.Automation.PSCustomObject' -or
                $t -like 'System.*') {
                continue
            }

            return $t
        }

        return $null
    }

    function Copy-IdleRedactedInternal {
        [CmdletBinding()]
        param(
            [Parameter()]
            [AllowNull()]
            [object] $InnerValue
        )

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

        # Always redact sensitive runtime types regardless of key name.
        if ($InnerValue -is [pscredential] -or $InnerValue -is [securestring]) {
            return $RedactionMarker
        }

        # Primitive / immutable-ish types can be returned as-is.
        if ($InnerValue -is [string] -or
            $InnerValue -is [int] -or
            $InnerValue -is [long] -or
            $InnerValue -is [double] -or
            $InnerValue -is [decimal] -or
            $InnerValue -is [bool] -or
            $InnerValue -is [datetime] -or
            $InnerValue -is [guid]) {
            return $InnerValue
        }

        # Cycle protection for reference types that may contain nested structures.
        if (-not ($InnerValue -is [ValueType]) -and -not ($InnerValue -is [string])) {
            $refHash = [System.Runtime.CompilerServices.RuntimeHelpers]::GetHashCode($InnerValue)
            if ($visited.Contains($refHash)) {
                # Conservative: do not try to represent cycles in exports/events.
                return $RedactionMarker
            }

            [void]$visited.Add($refHash)
        }

        # IDictionary -> clone recursively. Keep deterministic ordering where possible.
        if ($InnerValue -is [System.Collections.IDictionary]) {
            $isOrdered = $InnerValue -is [System.Collections.Specialized.OrderedDictionary]
            $copy = if ($isOrdered) { [ordered]@{} } else { @{} }

            $keys = @($InnerValue.Keys)

            if (-not $isOrdered) {
                # Deterministic ordering for regular dictionaries / hashtables.
                $keys = $keys | Sort-Object -Property { [string] $_ }
            }

            foreach ($k in $keys) {
                $keyName = [string] $k
                if (Test-IdleRedactionKeyMatch -Name $keyName) {
                    $copy[$k] = $RedactionMarker
                    continue
                }

                $copy[$k] = Copy-IdleRedactedInternal -InnerValue $InnerValue[$k]
            }

            return $copy
        }

        # Enumerables (except string) -> clone recursively.
        if ($InnerValue -is [System.Collections.IEnumerable] -and -not ($InnerValue -is [string])) {
            $items = @()
            foreach ($item in $InnerValue) {
                $items += Copy-IdleRedactedInternal -InnerValue $item
            }
            return $items
        }

        # Objects -> copy public properties into a stable PSCustomObject.
        $props = $InnerValue.PSObject.Properties |
            Where-Object { $_.MemberType -eq 'NoteProperty' -or $_.MemberType -eq 'Property' }

        if ($null -ne $props -and @($props).Count -gt 0) {
            $map = [ordered]@{}

            # Preserve PSTypeName for objects like IdLE.Event to keep tests and consumers stable.
            $primaryType = Get-IdlePrimaryTypeName -Object $InnerValue
            if ($null -ne $primaryType) {
                $map.PSTypeName = $primaryType
            }

            # Deterministic property order.
            foreach ($p in ($props | Sort-Object -Property Name)) {
                if (Test-IdleRedactionKeyMatch -Name $p.Name) {
                    $map[$p.Name] = $RedactionMarker
                    continue
                }

                $map[$p.Name] = Copy-IdleRedactedInternal -InnerValue $p.Value
            }

            return [pscustomobject] $map
        }

        # Fallback: keep representation stable without exporting runtime handles.
        return [string] $InnerValue
    }

    return Copy-IdleRedactedInternal -InnerValue $Value
}