Private/ConvertFrom-OpmXmlString.ps1

<#
.SYNOPSIS
  Parse a PacKit application-fragment XML string into typed objects.
 
.DESCRIPTION
  The inverse of ConvertTo-OpmXmlString. Loads the fragment with
  System.Xml.XmlDocument (which, like PacKit's Expat reader, accepts any
  well-formed XML and auto-unescapes attribute values), then maps it back onto
  the PacKit object model using the same schema that drives emission. Attribute
  values are converted to their typed form (Bool/TimeT/Double) on the way in, so
  a load->save round-trip reproduces the original bytes.
 
  Accepts either a <FRAGMENT> root containing one <ITEM> (the externalized
  fragment file) or a bare <ITEM> root.
#>


# Convert an on-disk attribute string to its typed value per schema Type.
function ConvertFrom-OpmAttributeText {
    [CmdletBinding()]
    param(
        [Parameter()] [AllowEmptyString()] [AllowNull()] [string] $Value,
        [Parameter(Mandatory)] [string] $Type
    )
    switch ($Type) {
        'Bool' {
            # PacKit reads only the literal lowercase "true" as true (== L"true").
            return [bool]($Value -ceq 'true')
        }
        'TimeT' {
            $n = [long]0
            [void][long]::TryParse($Value, [ref] $n)
            return $n
        }
        'Double' {
            $d = [double]0
            [void][double]::TryParse(
                $Value,
                [System.Globalization.NumberStyles]::Float,
                [System.Globalization.CultureInfo]::InvariantCulture,
                [ref] $d)
            return $d
        }
        default {
            if ($null -eq $Value) { return '' }
            return [string]$Value
        }
    }
}

# Assign a value to a dotted property path (e.g. 'DetectionRule.Format').
function Set-OpmValueByPath {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Object,
        [Parameter(Mandatory)] [string] $Path,
        [Parameter()] [AllowNull()] $Value
    )
    $parts = $Path.Split('.')
    $current = $Object
    for ($i = 0; $i -lt ($parts.Count - 1); $i++) {
        $current = $current.$($parts[$i])
    }
    $current.$($parts[-1]) = $Value
}

# Populate $Object from $XmlItem's attributes using the supplied schema descriptors.
function Set-OpmObjectFromXmlItem {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Object,
        [Parameter(Mandatory)] $XmlItem,
        [Parameter(Mandatory)] [object[]] $Descriptors
    )
    foreach ($d in $Descriptors) {
        # GetAttribute returns '' for an absent attribute, which maps to the default.
        $raw = $XmlItem.GetAttribute($d.Name)
        $typed = ConvertFrom-OpmAttributeText -Value $raw -Type $d.Type
        Set-OpmValueByPath -Object $Object -Path $d.Path -Value $typed
    }
}

function Assert-OpmXmlItemHasAttributes {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $XmlItem,
        [Parameter(Mandatory)] [string[]] $AttributeNames,
        [Parameter(Mandatory)] [string] $Context
    )
    foreach ($name in $AttributeNames) {
        if (-not $XmlItem.HasAttribute($name)) {
            throw "$Context is missing required attribute '$name'."
        }
    }
}

# Map a single app <ITEM> element into a PacKit.ApplicationFragment.
function ConvertFrom-OpmAppItem {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $XmlItem
    )
    $schema = Get-OpmSchema
    $app = New-OpmApplicationFragmentObject
    Set-OpmObjectFromXmlItem -Object $app -XmlItem $XmlItem -Descriptors $schema.App

    $appCollections = @($XmlItem.SelectNodes('COLLECTION'))
    if ($appCollections.Count -ne 2) {
        throw "PacKit fragment must contain exactly two app collections (Packages and IntuneAssignments), found $($appCollections.Count)."
    }
    $packagesCollectionCount = @($appCollections | Where-Object { $_.GetAttribute('Name') -eq 'Packages' }).Count
    $assignmentsCollectionCount = @($appCollections | Where-Object { $_.GetAttribute('Name') -eq 'IntuneAssignments' }).Count
    if (($packagesCollectionCount -ne 1) -or ($assignmentsCollectionCount -ne 1)) {
        throw 'PacKit fragment must contain exactly one Packages collection and one IntuneAssignments collection.'
    }

    # Packages
    $packages = @()
    $packagesNode = $XmlItem.SelectSingleNode("COLLECTION[@Name='Packages']")
    if ($packagesNode) {
        foreach ($pkgItem in $packagesNode.SelectNodes('ITEM')) {
            Assert-OpmXmlItemHasAttributes -XmlItem $pkgItem -AttributeNames @('PackageId') -Context 'Packages ITEM'
            $pkg = New-OpmPackageObject
            Set-OpmObjectFromXmlItem -Object $pkg -XmlItem $pkgItem -Descriptors $schema.Package

            $scans = @()
            $scanNode = $pkgItem.SelectSingleNode("COLLECTION[@Name='WinGetScanResults']")
            $invalidPkgChildCollections = @($pkgItem.SelectNodes("COLLECTION[not(@Name='WinGetScanResults')]"))
            if ($invalidPkgChildCollections.Count -gt 0) {
                throw "Packages ITEM contains unsupported child collection '$($invalidPkgChildCollections[0].GetAttribute('Name'))'."
            }
            if ($scanNode) {
                foreach ($scanItem in $scanNode.SelectNodes('ITEM')) {
                    $scan = New-OpmWinGetScanResultObject
                    Set-OpmObjectFromXmlItem -Object $scan -XmlItem $scanItem -Descriptors $schema.WinGetScanResult
                    $scans += $scan
                }
            }
            $pkg.WinGetScanResults = @($scans)
            $packages += $pkg
        }
    }
    $app.Packages = @($packages)

    # Intune assignments
    $assignments = @()
    $assignmentsNode = $XmlItem.SelectSingleNode("COLLECTION[@Name='IntuneAssignments']")
    if ($assignmentsNode) {
        foreach ($asgItem in $assignmentsNode.SelectNodes('ITEM')) {
            Assert-OpmXmlItemHasAttributes -XmlItem $asgItem -AttributeNames @('MsEntraGroupId', 'AssignmentType', 'InclusionType') -Context 'IntuneAssignments ITEM'
            if (@($asgItem.SelectNodes('COLLECTION')).Count -gt 0) {
                throw 'IntuneAssignments ITEM must not contain child collections.'
            }
            $asg = New-OpmAssignmentObject
            Set-OpmObjectFromXmlItem -Object $asg -XmlItem $asgItem -Descriptors $schema.Assignment
            $assignments += $asg
        }
    }
    $app.IntuneAssignments = @($assignments)

    return $app
}

function ConvertFrom-OpmXmlString {
    [CmdletBinding()]
    [OutputType('PacKit.ApplicationFragment')]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string] $Xml
    )

    $doc = New-Object System.Xml.XmlDocument
    $doc.PreserveWhitespace = $false
    try {
        $doc.LoadXml($Xml)
    }
    catch {
        throw "PacKit fragment is not well-formed XML: $($_.Exception.Message)"
    }

    $root = $doc.DocumentElement
    if ($null -eq $root) {
        throw 'PacKit fragment is empty.'
    }

    $appItem = $null
    switch ($root.LocalName) {
        'FRAGMENT' { $appItem = $root.SelectSingleNode('ITEM') }
        'ITEM' { $appItem = $root }
        default {
            throw "Unrecognised PacKit fragment root '<$($root.Name)>'. Expected <FRAGMENT> or <ITEM>."
        }
    }

    if ($null -eq $appItem) {
        throw 'PacKit fragment <FRAGMENT> contains no <ITEM> element.'
    }

    return ConvertFrom-OpmAppItem -XmlItem $appItem
}