Private/ConvertFrom-AuthenticationResults.ps1

# Authentication-Results (RFC 8601), Received-SPF, DKIM/ARC tag lists.

function Split-TopLevel {
    <#
    .SYNOPSIS
        Splits at a separator, ignoring separators inside parentheses and quotes.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Text,
        [Parameter(Mandatory)][char]$Separator
    )

    $parts = New-Object System.Collections.Generic.List[string]
    $depth = 0
    $inQuote = $false
    $buffer = ''
    foreach ($ch in $Text.ToCharArray()) {
        if ($inQuote) { $buffer += $ch; if ($ch -eq '"') { $inQuote = $false }; continue }
        if ($ch -eq '"') { $inQuote = $true; $buffer += $ch; continue }
        if ($ch -eq '(') { $depth++ }
        if ($ch -eq ')') { $depth = [math]::Max(0, $depth - 1) }
        if ($ch -eq $Separator -and $depth -eq 0) { $parts.Add($buffer); $buffer = ''; continue }
        $buffer += $ch
    }
    if ($buffer.Trim()) { $parts.Add($buffer) }
    return ,$parts.ToArray()
}

function Split-HeaderComment {
    <#
    .SYNOPSIS
        Strips parenthesised comments and collects them into the given list.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Text,
        [Parameter(Mandatory)][AllowEmptyCollection()][System.Collections.Generic.List[string]]$Comments
    )

    $depth = 0
    $out = ''
    $comment = ''
    foreach ($ch in $Text.ToCharArray()) {
        if ($ch -eq '(') {
            if ($depth -eq 0) { $comment = '' } else { $comment += $ch }
            $depth++
            continue
        }
        if ($ch -eq ')') {
            $depth = [math]::Max(0, $depth - 1)
            if ($depth -eq 0) { if ($comment.Trim()) { $Comments.Add(([regex]::Replace($comment, '\s+', ' ')).Trim()) } }
            else { $comment += $ch }
            continue
        }
        if ($depth -gt 0) { $comment += $ch } else { $out += $ch }
    }
    return $out
}

function ConvertFrom-AuthenticationResults {
    <#
    .SYNOPSIS
        Parses an Authentication-Results field (also the Microsoft variant without authserv-id).
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Named after the Authentication-Results header field.')]
    param([Parameter(Mandatory)]$Field)

    $parts = @(Split-TopLevel -Text $Field.Value -Separator ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
    $authserv = $null
    $start = 0
    if ($parts.Count -gt 0) {
        $headComments = New-Object System.Collections.Generic.List[string]
        $head = (Split-HeaderComment -Text $parts[0] -Comments $headComments).Trim()
        # Only an authserv-id when the first part is no method=result pattern
        # (Microsoft omits the authserv-id and starts directly with spf=...).
        if ($head) {
            $firstToken = ($head -split '\s+')[0]
            if ($firstToken -notmatch '=') { $authserv = $firstToken; $start = 1 }
        }
    }

    $methods = New-Object System.Collections.Generic.List[object]
    for ($i = $start; $i -lt $parts.Count; $i++) {
        $comments = New-Object System.Collections.Generic.List[string]
        $clean = (Split-HeaderComment -Text $parts[$i] -Comments $comments).Trim()
        if (-not $clean -or $clean.ToLowerInvariant() -eq 'none') { continue }
        $m = [regex]::Match($clean, '^([\w/.-]+)\s*=\s*(\S+)\s*(.*)$', 'Singleline')
        if (-not $m.Success) { continue }
        $props = [ordered]@{}
        foreach ($pm in [regex]::Matches($m.Groups[3].Value, '([\w-]+(?:\.[\w-]+)?)\s*=\s*("[^"]*"|[^\s;]+)')) {
            $props[$pm.Groups[1].Value.ToLowerInvariant()] = $pm.Groups[2].Value -replace '^"|"$', ''
        }
        $methods.Add([pscustomobject]@{
            PSTypeName = 'MailHeaderAnalyzer.AuthMethod'
            Method     = ($m.Groups[1].Value -split '/')[0].ToLowerInvariant()
            Result     = [regex]::Replace($m.Groups[2].Value.ToLowerInvariant(), '[.,]+$', '')
            Properties = $props
            Comments   = $comments.ToArray()
        })
    }

    $trust = 'Absent'
    if ($authserv) { $trust = 'Unmatched' }
    [pscustomobject]@{
        PSTypeName = 'MailHeaderAnalyzer.AuthenticationResults'
        AuthServId = $authserv
        Methods    = $methods.ToArray()
        Trust      = $trust
        Raw        = $Field.Raw
    }
}

function Get-AuthTrust {
    <#
    .SYNOPSIS
        Does a verification line come from the receiving system? Its authserv-id must match a
        station of the delivery chain (RFC 8601 section 5); otherwise it is an unverified claim.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()][AllowEmptyString()][string]$AuthServId,
        [AllowEmptyCollection()][string[]]$ByHosts
    )

    if (-not (ConvertTo-NormalizedDomain -Domain $AuthServId)) { return 'Absent' }
    foreach ($h in $ByHosts) {
        if (Test-HostMatch -A $AuthServId -B $h) { return 'Matched' }
    }
    return 'Unmatched'
}

function ConvertFrom-ReceivedSpf {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param([Parameter(Mandatory)]$Field)

    $comments = New-Object System.Collections.Generic.List[string]
    $clean = (Split-HeaderComment -Text $Field.Value -Comments $comments).Trim()
    $result = ''
    if ($clean) { $result = ($clean -split '\s+')[0].ToLowerInvariant() }
    $props = [ordered]@{}
    foreach ($pm in [regex]::Matches($clean, '([\w-]+)\s*=\s*("[^"]*"|[^\s;]+)')) {
        $props[$pm.Groups[1].Value.ToLowerInvariant()] = (($pm.Groups[2].Value -replace '^"|"$', '') -replace ';$', '')
    }
    [pscustomobject]@{
        PSTypeName = 'MailHeaderAnalyzer.ReceivedSpf'
        Result     = $result
        Properties = $props
        Comments   = $comments.ToArray()
        Raw        = $Field.Raw
    }
}

function ConvertFrom-TagList {
    <#
    .SYNOPSIS
        Parses a DKIM/ARC tag list (k=v; k=v; ...) into an ordered dictionary.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][AllowEmptyString()][string]$Value)

    $tags = [ordered]@{}
    foreach ($part in (Split-TopLevel -Text $Value -Separator ';')) {
        $m = [regex]::Match($part, '^\s*([a-z][a-z0-9_-]*)\s*=\s*([\s\S]*)$', 'IgnoreCase')
        if ($m.Success) {
            $key = $m.Groups[1].Value.ToLowerInvariant()
            $replacement = ' '
            if ($key -eq 'h') { $replacement = '' }
            $tags[$key] = ([regex]::Replace($m.Groups[2].Value, '\s+', $replacement)).Trim()
        }
    }
    return $tags
}