Private/Compare-IslRequirementOutput.ps1

function Compare-IslRequirementOutput {
    <#
    .SYNOPSIS
        Applies a Win32 requirement rule to a script's captured run the way the agent was observed to.
 
    .DESCRIPTION
        Observed on the test devices (W32-REQ-* experiments): the rule is evaluated only when the
        script exits 0 and writes nothing to stderr; the whole stdout, minus its final line break,
        is the value compared, so a second line or trailing spaces never match (Write-Host text
        counts, as it does for detection); string comparison ignores case; integer, float,
        version, boolean and dateTime outputs are parsed as that type and an unparseable output
        fails the rule.
 
    .PARAMETER StdOut
        The captured standard output.
 
    .PARAMETER StdErr
        The captured standard error.
 
    .PARAMETER ExitCode
        The script's exit code; $null when it was killed.
 
    .PARAMETER TimedOut
        Whether the script was killed at the timeout.
 
    .PARAMETER OutputType
        The rule's output data type: String, DateTime, Integer, Float, Version or Boolean.
 
    .PARAMETER Operator
        Equal, NotEqual, GreaterThan, GreaterThanOrEqual, LessThan or LessThanOrEqual.
 
    .PARAMETER Value
        The rule's comparison value, as typed in the portal.
 
    .EXAMPLE
        $ruleSplat = @{ OutputType = 'Version'; Operator = 'GreaterThanOrEqual'; Value = '2.9.0' }
        Compare-IslRequirementOutput -StdOut "2.10.0`r`n" -ExitCode 0 @ruleSplat
 
        Met, because the output is parsed as a version rather than compared as text.
    #>

    [CmdletBinding()]
    [OutputType('IntuneScriptLab.RequirementVerdict')]
    param(
        [AllowEmptyString()]
        [string]$StdOut = '',

        [AllowEmptyString()]
        [string]$StdErr = '',

        [AllowNull()]
        [nullable[int]]$ExitCode,

        [bool]$TimedOut,

        [Parameter(Mandatory)]
        [ValidateSet('String', 'DateTime', 'Integer', 'Float', 'Version', 'Boolean')]
        [string]$OutputType,

        [Parameter(Mandatory)]
        [ValidateSet('Equal', 'NotEqual', 'GreaterThan', 'GreaterThanOrEqual', 'LessThan', 'LessThanOrEqual')]
        [string]$Operator,

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

    # Only the final line break is removed, CRLF from Write-Output or the bare LF Write-Host ends
    # with (both met the rule on the device); "ok " and "first`r`nok" both failed
    $output = $StdOut -replace '\r?\n\z', ''
    $verdict = [pscustomobject]@{
        PSTypeName = 'IntuneScriptLab.RequirementVerdict'
        Met        = $false
        Output     = $output
        Reason     = ''
    }

    if ($TimedOut) {
        $verdict.Reason = 'Timed out; the agent kills the script at its 60-minute timeout and the rule fails'
        return $verdict
    }
    if ($ExitCode -ne 0) {
        $verdict.Reason = "Exit code ${ExitCode}: the output is only evaluated on exit 0, so the rule fails"
        return $verdict
    }
    if (-not [string]::IsNullOrWhiteSpace($StdErr)) {
        $verdict.Reason = 'Output on stderr: the rule fails even with exit 0 and matching stdout'
        return $verdict
    }

    $culture = [System.Globalization.CultureInfo]::InvariantCulture
    $parsed = $null
    $expected = $null
    switch ($OutputType) {
        'String' {
            $parsed = $output
            $expected = $Value
        }
        'Integer' {
            $number = 0L
            if (-not [long]::TryParse($output.Trim(), [ref]$number)) {
                $verdict.Reason = "Output '$output' is not an integer, so the rule fails"
                return $verdict
            }
            $parsed = $number
            if (-not [long]::TryParse($Value.Trim(), [ref]$number)) {
                $verdict.Reason = "Comparison value '$Value' is not an integer, so the rule fails"
                return $verdict
            }
            $expected = $number
        }
        'Float' {
            $number = 0.0
            $style = [System.Globalization.NumberStyles]::Float
            if (-not [double]::TryParse($output.Trim(), $style, $culture, [ref]$number)) {
                $verdict.Reason = "Output '$output' is not a number, so the rule fails"
                return $verdict
            }
            $parsed = $number
            if (-not [double]::TryParse($Value.Trim(), $style, $culture, [ref]$number)) {
                $verdict.Reason = "Comparison value '$Value' is not a number, so the rule fails"
                return $verdict
            }
            $expected = $number
        }
        'Version' {
            $version = $null
            if (-not [version]::TryParse($output.Trim(), [ref]$version)) {
                $verdict.Reason = "Output '$output' is not a version (a.b[.c[.d]]), so the rule fails"
                return $verdict
            }
            $parsed = $version
            if (-not [version]::TryParse($Value.Trim(), [ref]$version)) {
                $verdict.Reason = "Comparison value '$Value' is not a version, so the rule fails"
                return $verdict
            }
            $expected = $version
        }
        'Boolean' {
            $flag = $false
            if (-not [bool]::TryParse($output.Trim(), [ref]$flag)) {
                $verdict.Reason = "Output '$output' is not True or False, so the rule fails"
                return $verdict
            }
            $parsed = $flag
            if (-not [bool]::TryParse($Value.Trim(), [ref]$flag)) {
                $verdict.Reason = "Comparison value '$Value' is not True or False, so the rule fails"
                return $verdict
            }
            $expected = $flag
            if ($Operator -notin 'Equal', 'NotEqual') {
                $verdict.Reason = "Operator $Operator does not apply to a boolean, so the rule fails"
                return $verdict
            }
        }
        'DateTime' {
            $stamp = [datetime]::MinValue
            $styles = [System.Globalization.DateTimeStyles]::AssumeUniversal -bor
                [System.Globalization.DateTimeStyles]::AdjustToUniversal
            if (-not [datetime]::TryParse($output.Trim(), $culture, $styles, [ref]$stamp)) {
                $verdict.Reason = "Output '$output' is not a date/time, so the rule fails"
                return $verdict
            }
            $parsed = $stamp
            if (-not [datetime]::TryParse($Value.Trim(), $culture, $styles, [ref]$stamp)) {
                $verdict.Reason = "Comparison value '$Value' is not a date/time, so the rule fails"
                return $verdict
            }
            $expected = $stamp
        }
    }

    $comparison = if ($OutputType -eq 'String') {
        [string]::Compare($parsed, $expected, [System.StringComparison]::InvariantCultureIgnoreCase)
    }
    elseif ($OutputType -eq 'Boolean') {
        if ($parsed -eq $expected) { 0 } else { 1 }
    }
    else { $parsed.CompareTo($expected) }

    $verdict.Met = switch ($Operator) {
        'Equal' { $comparison -eq 0 }
        'NotEqual' { $comparison -ne 0 }
        'GreaterThan' { $comparison -gt 0 }
        'GreaterThanOrEqual' { $comparison -ge 0 }
        'LessThan' { $comparison -lt 0 }
        'LessThanOrEqual' { $comparison -le 0 }
    }
    $relation = if ($verdict.Met) { 'meets' } else { 'does not meet' }
    $verdict.Reason = "Output '$output' $relation $OutputType $Operator '$Value'"
    $verdict
}