Private/Compare-IslRuleValue.ps1

function Compare-IslRuleValue {
    <#
    .SYNOPSIS
        Compares a value read from the device with a rule's comparison value the way the agent does.
 
    .DESCRIPTION
        The typed comparison behind the file, registry and product code rules (W32-FILE-*, W32-REG-*,
        W32-MSI-* experiments): strings compare case-insensitively (W32-REG-STR-CASE), integers are
        parsed from whatever the value is, a REG_SZ "42" included (W32-REG-INT-ONSZ), versions are
        compared as versions so 10.0.1 is above 9.0 (W32-REG-VER-GE, W32-FILE-VER-GE, W32-MSI-VER-GE)
        and, as the Graph documentation states, fall back to a string comparison when either side is
        not a version, and dates are compared in UTC (W32-FILE-DATE-GT).
 
    .PARAMETER Actual
        The value found on the device: a string, number, version or date.
 
    .PARAMETER Expected
        The rule's comparison value as typed in the portal.
 
    .PARAMETER Type
        String, Integer, Version or DateTime.
 
    .PARAMETER Operator
        Equal, NotEqual, GreaterThan, GreaterThanOrEqual, LessThan or LessThanOrEqual.
 
    .EXAMPLE
        Compare-IslRuleValue -Actual '10.0.1' -Expected '9.0' -Type Version -Operator GreaterThanOrEqual
 
        Met: the values are compared as versions, not as text.
    #>

    [CmdletBinding()]
    [OutputType('IntuneScriptLab.RuleComparison')]
    param(
        [AllowNull()]
        $Actual,

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Expected,

        [Parameter(Mandatory)]
        [ValidateSet('String', 'Integer', 'Version', 'DateTime')]
        [string]$Type,

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

    $result = [pscustomobject]@{
        PSTypeName = 'IntuneScriptLab.RuleComparison'
        Met        = $false
        Actual     = "$Actual"
        Expected   = $Expected
        Reason     = ''
    }
    $culture = [System.Globalization.CultureInfo]::InvariantCulture
    $left = $null
    $right = $null
    $comparedAs = $Type

    switch ($Type) {
        'String' {
            $left = "$Actual"
            $right = $Expected
        }
        'Integer' {
            $number = 0L
            if ($Actual -is [int] -or $Actual -is [long] -or $Actual -is [uint32] -or $Actual -is [int16]) {
                $left = [long]$Actual
            }
            elseif ([long]::TryParse("$Actual".Trim(), [ref]$number)) { $left = $number }
            else {
                $result.Reason = "Value '$Actual' is not an integer, so the rule is not met"
                return $result
            }
            if (-not [long]::TryParse($Expected.Trim(), [ref]$number)) {
                $result.Reason = "Comparison value '$Expected' is not an integer, so the rule is not met"
                return $result
            }
            $right = $number
        }
        'Version' {
            $version = $null
            if ([version]::TryParse("$Actual".Trim(), [ref]$version)) { $left = $version }
            if ([version]::TryParse($Expected.Trim(), [ref]$version)) { $right = $version }
            if ($null -eq $left -or $null -eq $right) {
                # Graph documents the fallback: a value that is not version-shaped is compared as text
                $left = "$Actual"
                $right = $Expected
                $comparedAs = 'String'
            }
        }
        'DateTime' {
            $stamp = [datetime]::MinValue
            $styles = [System.Globalization.DateTimeStyles]::AssumeUniversal -bor
                [System.Globalization.DateTimeStyles]::AdjustToUniversal
            if ($Actual -is [datetime]) { $left = $Actual.ToUniversalTime() }
            elseif ([datetime]::TryParse("$Actual".Trim(), $culture, $styles, [ref]$stamp)) { $left = $stamp }
            else {
                $result.Reason = "Value '$Actual' is not a date/time, so the rule is not met"
                return $result
            }
            if (-not [datetime]::TryParse($Expected.Trim(), $culture, $styles, [ref]$stamp)) {
                $result.Reason = "Comparison value '$Expected' is not a date/time, so the rule is not met"
                return $result
            }
            $right = $stamp
        }
    }

    $comparison = if ($comparedAs -eq 'String') {
        [string]::Compare($left, $right, [System.StringComparison]::InvariantCultureIgnoreCase)
    }
    else { $left.CompareTo($right) }

    $result.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 ($result.Met) { 'meets' } else { 'does not meet' }
    $fallback = if ($comparedAs -ne $Type) { " (compared as text: not both values are versions)" } else { '' }
    $result.Reason = "'$Actual' $relation $Type $Operator '$Expected'$fallback"
    $result
}