Private/Errors.ps1

# Every failure the module reports is a VMetricException. The public cmdlet that hit it turns it into an
# ErrorRecord (Write-VMetricCmdletError), so the record names that cmdlet, carries the API's code as its error
# id, and has a category chosen from the HTTP status.

class VMetricException : System.Exception {
    # The HTTP status of the answer, or 0 when the failure did not come from one.
    [int] $StatusCode
    # The API's code (DEPLOYMENT_IN_PROGRESS), or the module's own id (NotConnected).
    [string] $Code = 'VMetricError'
    # The server's trace id, when the answer carried one. Support can find the request by it.
    [string] $TraceId
    # The error body beside the message: diagnostics, missingPermissions, missingFeatures.
    [object] $Details
    [System.Management.Automation.ErrorCategory] $Category = [System.Management.Automation.ErrorCategory]::NotSpecified
    [string] $RecommendedAction
    # Set for a failure every later pipeline input would hit too (not connected, a session that ended): the
    # cmdlet stops rather than report the same error once per input.
    [bool] $IsTerminating

    VMetricException([string] $message) : base($message) {
    }

    VMetricException([string] $message, [System.Exception] $innerException) : base($message, $innerException) {
    }
}

function New-VMetricException {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates an exception object; changes no state.')]
    [CmdletBinding()]
    [OutputType([System.Exception])]
    param(
        [Parameter(Mandatory)]
        [string] $Message,

        [string] $Code = 'VMetricError',

        [System.Management.Automation.ErrorCategory] $Category = [System.Management.Automation.ErrorCategory]::NotSpecified,

        [int] $StatusCode,

        [string] $TraceId,

        [object] $Details,

        [string] $RecommendedAction,

        [System.Exception] $InnerException,

        [switch] $Terminating
    )

    if ($InnerException) {
        $exception = [VMetricException]::new($Message, $InnerException)
    }
    else {
        $exception = [VMetricException]::new($Message)
    }
    $exception.Code = $Code
    $exception.Category = $Category
    $exception.StatusCode = $StatusCode
    $exception.TraceId = $TraceId
    $exception.Details = $Details
    $exception.RecommendedAction = $RecommendedAction
    $exception.IsTerminating = $Terminating.IsPresent
    $exception
}

function Get-VMetricErrorCategory {
    [CmdletBinding()]
    [OutputType([System.Management.Automation.ErrorCategory])]
    param(
        [int] $StatusCode,

        [string] $Code
    )

    switch ($StatusCode) {
        400 { return [System.Management.Automation.ErrorCategory]::InvalidArgument }
        401 { return [System.Management.Automation.ErrorCategory]::AuthenticationError }
        403 { return [System.Management.Automation.ErrorCategory]::PermissionDenied }
        404 { return [System.Management.Automation.ErrorCategory]::ObjectNotFound }
        408 { return [System.Management.Automation.ErrorCategory]::OperationTimeout }
        409 {
            if ($Code -eq 'DEPLOYMENT_IN_PROGRESS') {
                return [System.Management.Automation.ErrorCategory]::ResourceBusy
            }
            return [System.Management.Automation.ErrorCategory]::ResourceExists
        }
        413 { return [System.Management.Automation.ErrorCategory]::LimitsExceeded }
        422 { return [System.Management.Automation.ErrorCategory]::InvalidData }
        429 { return [System.Management.Automation.ErrorCategory]::LimitsExceeded }
        501 { return [System.Management.Automation.ErrorCategory]::NotImplemented }
        502 { return [System.Management.Automation.ErrorCategory]::ConnectionError }
        503 { return [System.Management.Automation.ErrorCategory]::ResourceUnavailable }
        504 { return [System.Management.Automation.ErrorCategory]::OperationTimeout }
    }
    if ($StatusCode -ge 500) {
        return [System.Management.Automation.ErrorCategory]::InvalidResult
    }
    if ($StatusCode -ge 400) {
        return [System.Management.Automation.ErrorCategory]::InvalidOperation
    }
    return [System.Management.Automation.ErrorCategory]::NotSpecified
}

function ConvertTo-VMetricSafeText {
    # Server text on its way to the terminal (a name in a message, an error, a preview line): what could make
    # a terminal do rather than show is neutralised, so a device or tenant name cannot write the clipboard
    # (OSC 52), retitle the window, hide or rewrite lines, or reorder the text around it. C0 and C1 controls
    # other than the tab go (ESC with them, so an escape sequence is left as harmless text), and so do the
    # format characters, bidirectional controls among them; a line break becomes a space, so the text stays
    # on its own line, unless -AllowNewLine keeps it. The objects the cmdlets emit keep their data as it came.
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()]
        [AllowEmptyString()]
        [string] $Text,

        [switch] $AllowNewLine
    )

    if ([string]::IsNullOrEmpty($Text)) {
        return ''
    }
    if ($AllowNewLine) {
        $Text = [regex]::Replace($Text, '\r\n?|[\p{Zl}\p{Zp}]', "`n")
        return [regex]::Replace($Text, '[\p{Cc}\p{Cf}-[\t\n]]', '')
    }
    $Text = [regex]::Replace($Text, '[\r\n\p{Zl}\p{Zp}]+', ' ')
    return [regex]::Replace($Text, '[\p{Cc}\p{Cf}-[\t]]', '')
}

function ConvertFrom-VMetricErrorResponse {
    # Reads an error answer: the public format {code, message, traceId} with the detail beside it, the RFC 8628
    # device errors {code, message}, and anything else (a proxy's HTML page) as the bare status.
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [int] $StatusCode,

        [AllowNull()]
        [AllowEmptyString()]
        [string] $Content
    )

    $body = $null
    if (-not [string]::IsNullOrWhiteSpace($Content)) {
        try {
            $body = ConvertFrom-VMetricJson -Json $Content
        }
        catch {
            $body = $null
        }
    }
    if ($body -isnot [System.Management.Automation.PSObject] -or $body.psobject.BaseObject -isnot [System.Management.Automation.PSCustomObject]) {
        $body = $null
    }

    $code = $null
    $message = $null
    $traceId = $null
    if ($body) {
        foreach ($name in @('code', 'item', 'error')) {
            $value = Get-VMetricMember -InputObject $body -Name $name
            if ($value -is [string] -and $value) {
                $code = $value
                break
            }
        }
        foreach ($name in @('message', 'description', 'error_description')) {
            $value = Get-VMetricMember -InputObject $body -Name $name
            if ($value -is [string] -and $value) {
                $message = $value
                break
            }
        }
        $traceId = Get-VMetricMember -InputObject $body -Name 'traceId'
    }
    if (-not $code) {
        $code = "HTTP_$StatusCode"
    }
    if (-not $message) {
        $reason = ''
        if ([System.Enum]::IsDefined([System.Net.HttpStatusCode], $StatusCode)) {
            $reason = ' ' + [regex]::Replace(([System.Net.HttpStatusCode]$StatusCode).ToString(), '(?<=[a-z])(?=[A-Z])', ' ')
        }
        $message = "The server answered $StatusCode$reason."
    }

    [pscustomobject]@{
        Code    = ConvertTo-VMetricSafeText -Text $code
        Message = ConvertTo-VMetricSafeText -Text $message
        TraceId = ConvertTo-VMetricSafeText -Text ([string]$traceId)
        Details = $body
    }
}

function New-VMetricApiException {
    # The exception for an API error answer: the message, then the code and trace id in parentheses, then the
    # error diagnostics a refused template carries, one per line.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates an exception object; changes no state.')]
    [CmdletBinding()]
    [OutputType([System.Exception])]
    param(
        [Parameter(Mandatory)]
        [int] $StatusCode,

        [Parameter(Mandatory)]
        [pscustomobject] $ApiError,

        # The text of the secrets the request carried: masked in the message, and the error body is dropped
        # when it echoed one, before the exception exists (a transcript logs every terminating error as it is
        # thrown, caught or not).
        [AllowNull()]
        [string[]] $Secret
    )

    $suffix = if ($ApiError.TraceId) { "($($ApiError.Code), trace $($ApiError.TraceId))" } else { "($($ApiError.Code))" }
    $lines = [System.Collections.Generic.List[string]]::new()
    $lines.Add("$($ApiError.Message) $suffix")

    $shown = 0
    foreach ($diagnostic in (ConvertTo-VMetricArray -InputObject (Get-VMetricMember -InputObject $ApiError.Details -Name 'diagnostics'))) {
        if ($null -eq $diagnostic -or (Get-VMetricMember -InputObject $diagnostic -Name 'severity') -ne 'error') {
            continue
        }
        if ($shown -eq 10) {
            $lines.Add(' ...')
            break
        }
        $lines.Add(' ' + (Format-VMetricDiagnosticText -Diagnostic $diagnostic))
        $shown++
    }

    $recommended = $null
    if ($StatusCode -eq 401) {
        $recommended = 'Run Connect-VMetric to sign in again.'
    }

    $message = $lines -join [Environment]::NewLine
    $details = $ApiError.Details
    if ($Secret) {
        $masked = Hide-VMetricSecretText -Text $message -Secret $Secret
        $bodyText = ''
        if ($null -ne $details) {
            $bodyText = ConvertTo-Json -InputObject $details -Depth 20 -Compress
        }
        if ($masked -ne $message -or (Hide-VMetricSecretText -Text $bodyText -Secret $Secret) -ne $bodyText) {
            $message = $masked
            $details = $null
        }
    }

    New-VMetricException -Message $message -Code $ApiError.Code `
        -Category (Get-VMetricErrorCategory -StatusCode $StatusCode -Code $ApiError.Code) `
        -StatusCode $StatusCode -TraceId $ApiError.TraceId -Details $details -RecommendedAction $recommended
}

function Format-VMetricDiagnosticText {
    # One diagnostic as a line: "BCP018 (line 3, column 5): Expected the = character."
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [object] $Diagnostic
    )

    $code = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $Diagnostic -Name 'code'))
    $message = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $Diagnostic -Name 'message'))
    $range = Get-VMetricMember -InputObject $Diagnostic -Name 'range'
    $line = [int](Get-VMetricMember -InputObject $range -Name 'startLine')
    $column = [int](Get-VMetricMember -InputObject $range -Name 'startColumn')
    if ($line -gt 0) {
        return "${code} (line $line, column $column): $message"
    }
    return "${code}: $message"
}

function New-VMetricErrorRecord {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates an error record object; changes no state.')]
    [CmdletBinding()]
    [OutputType([System.Management.Automation.ErrorRecord])]
    param(
        [Parameter(Mandatory)]
        [System.Exception] $Exception,

        [AllowNull()]
        [object] $TargetObject
    )

    $errorId = $Exception.GetType().Name
    $category = [System.Management.Automation.ErrorCategory]::NotSpecified
    $recommended = $null
    if ($Exception -is [VMetricException]) {
        $errorId = $Exception.Code
        $category = $Exception.Category
        $recommended = $Exception.RecommendedAction
    }
    $record = [System.Management.Automation.ErrorRecord]::new($Exception, $errorId, $category, $TargetObject)
    if ($recommended) {
        $record.ErrorDetails = [System.Management.Automation.ErrorDetails]::new($Exception.Message)
        $record.ErrorDetails.RecommendedAction = $recommended
    }
    $record
}

function ConvertTo-VMetricErrorRecord {
    # A caught error, rebuilt so that the cmdlet writing it is the one the record names.
    [CmdletBinding()]
    [OutputType([System.Management.Automation.ErrorRecord])]
    param(
        [Parameter(Mandatory)]
        [System.Management.Automation.ErrorRecord] $ErrorRecord,

        [AllowNull()]
        [object] $TargetObject
    )

    $source = $ErrorRecord
    if ($source.Exception -is [System.Management.Automation.ActionPreferenceStopException] -and $source.Exception.ErrorRecord) {
        $source = $source.Exception.ErrorRecord
    }
    $target = $source.TargetObject
    if ($PSBoundParameters.ContainsKey('TargetObject')) {
        $target = $TargetObject
    }

    $exception = $source.Exception
    if ($exception -is [VMetricException]) {
        return New-VMetricErrorRecord -Exception $exception -TargetObject $target
    }
    $errorId = ([string]$source.FullyQualifiedErrorId -split ',')[0]
    if (-not $errorId) {
        $errorId = $exception.GetType().Name
    }
    $record = [System.Management.Automation.ErrorRecord]::new($exception, $errorId, $source.CategoryInfo.Category, $target)
    if ($source.ErrorDetails) {
        $record.ErrorDetails = $source.ErrorDetails
    }
    $record
}

function Hide-VMetricSecretInError {
    # An error whose message carries the text of a secret the request held (a server that echoed it), rebuilt
    # with the secret masked and without the inner exception or error body that could carry it too.
    [CmdletBinding()]
    [OutputType([System.Management.Automation.ErrorRecord])]
    param(
        [Parameter(Mandatory)]
        [System.Management.Automation.ErrorRecord] $ErrorRecord,

        [AllowNull()]
        [string[]] $Secret
    )

    if (-not $Secret) {
        return $ErrorRecord
    }
    $source = $ErrorRecord
    if ($source.Exception -is [System.Management.Automation.ActionPreferenceStopException] -and $source.Exception.ErrorRecord) {
        $source = $source.Exception.ErrorRecord
    }
    $message = $source.Exception.Message
    $detail = ''
    if ($source.ErrorDetails) {
        $detail = [string]$source.ErrorDetails.Message
    }
    $masked = Hide-VMetricSecretText -Text $message -Secret $Secret
    if ($masked -eq $message -and (Hide-VMetricSecretText -Text $detail -Secret $Secret) -eq $detail) {
        return $ErrorRecord
    }

    $original = $source.Exception
    if ($original -is [VMetricException]) {
        $copy = New-VMetricException -Message $masked -Code $original.Code -Category $original.Category -StatusCode $original.StatusCode `
            -TraceId $original.TraceId -RecommendedAction $original.RecommendedAction -Terminating:$original.IsTerminating
    }
    else {
        $copy = New-VMetricException -Message $masked -Code (([string]$source.FullyQualifiedErrorId -split ',')[0]) -Category $source.CategoryInfo.Category
    }
    New-VMetricErrorRecord -Exception $copy -TargetObject $source.TargetObject
}

function Test-VMetricFlowControl {
    # Ctrl+C, and a downstream Select-Object -First stopping the pipeline, must pass through the catch-all
    # blocks untouched.
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        [System.Management.Automation.ErrorRecord] $ErrorRecord
    )

    $exception = $ErrorRecord.Exception
    return ($exception -is [System.Management.Automation.PipelineStoppedException] -or
        $exception -is [System.Management.Automation.FlowControlException])
}

function Write-VMetricCmdletError {
    # Reports a caught error through the public cmdlet: stops the cmdlet for a failure every later input would
    # hit too, and writes a non-terminating error otherwise, so the rest of the pipeline input still runs.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Management.Automation.PSCmdlet] $Cmdlet,

        [Parameter(Mandatory)]
        [System.Management.Automation.ErrorRecord] $ErrorRecord,

        [AllowNull()]
        [object] $TargetObject,

        [switch] $Terminating
    )

    if ($PSBoundParameters.ContainsKey('TargetObject')) {
        $record = ConvertTo-VMetricErrorRecord -ErrorRecord $ErrorRecord -TargetObject $TargetObject
    }
    else {
        $record = ConvertTo-VMetricErrorRecord -ErrorRecord $ErrorRecord
    }
    if ($Terminating -or ($record.Exception -is [VMetricException] -and $record.Exception.IsTerminating)) {
        $Cmdlet.ThrowTerminatingError($record)
    }
    $Cmdlet.WriteError($record)
}