Public/Test-OSDAppDeployment.ps1

function Test-OSDAppDeployment {
    [CmdletBinding()]
    param(
        [string]$WindowsPath,

        [Alias('RequireCache')]
        [switch]$RequireOfflineReady,

        [ValidateRange(0, 1024)]
        [double]$MinimumFreeSpaceGB = 0,

        [switch]$ThrowOnFailure
    )

    $target = Resolve-OSDAppWindowsPath -WindowsPath $WindowsPath
    $stageRoot = Join-Path $target 'Windows\Temp\OSDApps'
    $manifestPath = Join-Path $stageRoot 'DeviceManifest.json'
    $setupCompletePath = Join-Path $target 'Windows\Setup\Scripts\SetupComplete.cmd'
    $checks = [System.Collections.Generic.List[object]]::new()

    function Add-DeploymentCheck {
        param(
            [Parameter(Mandatory)][string]$Name,
            [Parameter(Mandatory)][bool]$Passed,
            [Parameter(Mandatory)][bool]$Required,
            [Parameter(Mandatory)][string]$Message,
            [string]$ApplicationId
        )

        $checks.Add([pscustomobject]@{
            PSTypeName    = 'OSDApps.DeploymentCheck'
            Name          = $Name
            ApplicationId = $ApplicationId
            Required      = $Required
            Passed        = $Passed
            Message       = $Message
        })
    }

    function Test-SafeRelativePath {
        param([string]$Path)

        if ([string]::IsNullOrWhiteSpace($Path) -or
            [IO.Path]::IsPathRooted($Path) -or
            $Path -match '[:*?"<>|\x00-\x1F]') {
            return $false
        }

        $segments = @($Path -split '[\\/]')
        return ($segments.Count -gt 0 -and @($segments | Where-Object { $_ -in @('', '.', '..') }).Count -eq 0)
    }

    function Get-BuiltInPayloadState {
        param([Parameter(Mandatory)]$App)

        $requiredPaths = @(switch ([string]$App.Type) {
            'OfficeDeploymentTool' {
                @(
                    @{ Path = [string]$App.Setup; Type = 'Leaf' },
                    @{ Path = [string]$App.Configuration; Type = 'Leaf' },
                    @{ Path = 'BuiltIn\Microsoft365Apps\Office\Data'; Type = 'Container' }
                )
                break
            }
            'MicrosoftTeamsBootstrapper' {
                @(
                    @{ Path = [string]$App.Setup; Type = 'Leaf' },
                    @{ Path = [string]$App.OfflinePackage; Type = 'Leaf' }
                )
                break
            }
            'AdobeAcrobatUnifiedZip' {
                @(@{ Path = [string]$App.Package; Type = 'Leaf' })
                break
            }
            'VendorMsi' {
                @(@{ Path = [string]$App.Package; Type = 'Leaf' })
                break
            }
            default { @() }
        })

        $invalidPaths = @($requiredPaths | Where-Object { -not (Test-SafeRelativePath -Path $_.Path) })
        $missingPaths = @($requiredPaths | ForEach-Object {
            $requiredPath = [string]$_.Path
            $requiredPathType = [string]$_.Type
            if ((Test-SafeRelativePath -Path $requiredPath) -and
                -not (Test-Path -LiteralPath (Join-Path $stageRoot $requiredPath) -PathType $requiredPathType)) {
                $requiredPath
            }
        })

        [pscustomobject]@{
            DefinitionValid = ($requiredPaths.Count -gt 0 -and $invalidPaths.Count -eq 0)
            PayloadReady    = ($requiredPaths.Count -gt 0 -and $invalidPaths.Count -eq 0 -and $missingPaths.Count -eq 0)
            MissingPaths    = @($missingPaths)
        }
    }

    function Complete-DeploymentValidation {
        param(
            $Manifest,
            [object[]]$Applications,
            [bool]$OfflineReady,
            [bool]$NetworkRequired,
            $FreeSpaceGB
        )

        $failedRequired = @($checks | Where-Object { $_.Required -and -not $_.Passed })
        $result = [pscustomobject]@{
            PSTypeName       = 'OSDApps.DeploymentValidation'
            WindowsPath      = $target
            StagedPath       = $stageRoot
            ManifestPath     = $manifestPath
            SetupCompletePath= $setupCompletePath
            DeploymentId     = if ($Manifest -and $Manifest.DeploymentId) { [string]$Manifest.DeploymentId } else { $null }
            ApplicationCount = @($Applications).Count
            Applications     = @($Applications)
            RequireOfflineReady = [bool]$RequireOfflineReady
            OfflineReady     = $OfflineReady
            NetworkRequired  = $NetworkRequired
            FreeSpaceGB      = $FreeSpaceGB
            Checks           = @($checks)
            Valid            = ($failedRequired.Count -eq 0)
        }

        if ($ThrowOnFailure -and -not $result.Valid) {
            $messages = @($failedRequired | ForEach-Object { $_.Message })
            throw "OSDApps deployment validation failed: $($messages -join ' | ')"
        }

        $result
    }

    $manifestExists = Test-Path -LiteralPath $manifestPath -PathType Leaf
    Add-DeploymentCheck -Name 'DeviceManifest' -Passed $manifestExists -Required $true -Message $(
        if ($manifestExists) { 'DeviceManifest.json is present.' } else { "DeviceManifest.json is missing: $manifestPath" }
    )
    if (-not $manifestExists) {
        return (Complete-DeploymentValidation -Manifest $null -Applications @() -OfflineReady $false -NetworkRequired $false -FreeSpaceGB $null)
    }

    $manifest = $null
    try {
        $manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 |
            ConvertFrom-Json -ErrorAction Stop
        Add-DeploymentCheck -Name 'ManifestJson' -Passed $true -Required $true -Message 'DeviceManifest.json contains valid JSON.'
    }
    catch {
        Add-DeploymentCheck -Name 'ManifestJson' -Passed $false -Required $true -Message "DeviceManifest.json is invalid: $($_.Exception.Message)"
        return (Complete-DeploymentValidation -Manifest $null -Applications @() -OfflineReady $false -NetworkRequired $false -FreeSpaceGB $null)
    }

    $apps = if ($manifest.PSObject.Properties.Name -contains 'Apps') { @($manifest.Apps | Where-Object { $null -ne $_ }) } else { @() }
    $parsedDeploymentId = [guid]::Empty
    $deploymentIdValid = [guid]::TryParse([string]$manifest.DeploymentId, [ref]$parsedDeploymentId)
    Add-DeploymentCheck -Name 'DeploymentId' -Passed $deploymentIdValid -Required $true -Message $(
        if ($deploymentIdValid) { 'DeploymentId is a valid GUID.' } else { 'DeploymentId is missing or invalid; restage an application with the current module.' }
    )

    $runtimeValid = (
        $manifest.Runtime -and
        ([string]$manifest.Runtime.CleanupMode) -in @('OnSuccess', 'Never') -and
        -not [string]::IsNullOrWhiteSpace([string]$manifest.Runtime.LogPath) -and
        -not [string]::IsNullOrWhiteSpace([string]$manifest.Runtime.ResultPath) -and
        -not [string]::IsNullOrWhiteSpace([string]$manifest.Runtime.CacheVolumeLabel)
    )
    Add-DeploymentCheck -Name 'RuntimeConfiguration' -Passed $runtimeValid -Required $true -Message $(
        if ($runtimeValid) { 'Runtime configuration is complete.' } else { 'Runtime configuration is incomplete or unsupported.' }
    )

    $ids = @($apps | ForEach-Object { [string]$_.Id })
    $validIds = @($ids | Where-Object { $_ -match '^[A-Za-z0-9][A-Za-z0-9._-]*$' -and $_ -notmatch '\.\.' })
    $uniqueIds = @($ids | Select-Object -Unique)
    $queueValid = ($apps.Count -gt 0 -and $validIds.Count -eq $apps.Count -and $uniqueIds.Count -eq $apps.Count)
    Add-DeploymentCheck -Name 'ApplicationQueue' -Passed $queueValid -Required $true -Message $(
        if ($queueValid) { "Application queue contains $($apps.Count) unique valid entries." } else { 'Application queue is empty, contains duplicate IDs, or contains an invalid ID.' }
    )

    $runtimeFiles = @('Invoke-OSDAppPreInstall.ps1', 'Invoke-OSDAppRunner.ps1')
    $runtimeMissing = @($runtimeFiles | Where-Object { -not (Test-Path -LiteralPath (Join-Path $stageRoot $_) -PathType Leaf) })
    Add-DeploymentCheck -Name 'RuntimeScripts' -Passed ($runtimeMissing.Count -eq 0) -Required $true -Message $(
        if ($runtimeMissing.Count -eq 0) { 'Both standalone runtime scripts are staged.' } else { "Missing runtime script(s): $($runtimeMissing -join ', ')." }
    )

    $moduleRoot = Split-Path $PSScriptRoot -Parent
    $mismatchedRuntime = @()
    if ($runtimeMissing.Count -eq 0) {
        foreach ($file in $runtimeFiles) {
            $source = Join-Path $moduleRoot (Join-Path 'Runtime' $file)
            $staged = Join-Path $stageRoot $file
            if (-not (Test-Path -LiteralPath $source -PathType Leaf) -or
                (Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash -ne (Get-FileHash -LiteralPath $staged -Algorithm SHA256).Hash) {
                $mismatchedRuntime += $file
            }
        }
    }
    Add-DeploymentCheck -Name 'RuntimeVersion' -Passed ($runtimeMissing.Count -eq 0 -and $mismatchedRuntime.Count -eq 0) -Required $true -Message $(
        if ($runtimeMissing.Count -eq 0 -and $mismatchedRuntime.Count -eq 0) { 'Staged runtime scripts match the loaded module.' } else { "Staged runtime does not match the loaded module: $($mismatchedRuntime -join ', ')." }
    )

    $setupValid = $false
    $setupMessage = "SetupComplete.cmd is missing: $setupCompletePath"
    if (Test-Path -LiteralPath $setupCompletePath -PathType Leaf) {
        $setupText = Get-Content -LiteralPath $setupCompletePath -Raw -ErrorAction Stop
        $preCount = [regex]::Matches($setupText, '(?m)^:: OSDApps PreInstall\r?$').Count
        $beginCount = [regex]::Matches($setupText, '(?m)^:: OSDApps Begin\r?$').Count
        $endCount = [regex]::Matches($setupText, '(?m)^:: OSDApps End\r?$').Count
        $preIndex = $setupText.IndexOf(':: OSDApps PreInstall', [StringComparison]::Ordinal)
        $beginIndex = $setupText.IndexOf(':: OSDApps Begin', [StringComparison]::Ordinal)
        $endIndex = $setupText.IndexOf(':: OSDApps End', [StringComparison]::Ordinal)
        $setupValid = (
            $preCount -eq 1 -and $beginCount -eq 1 -and $endCount -eq 1 -and
            $preIndex -lt $beginIndex -and $beginIndex -lt $endIndex -and
            $setupText -match 'Invoke-OSDAppPreInstall\.ps1' -and
            $setupText -match 'OSDAPPS_PREINSTALL_EXITCODE' -and
            $setupText -match 'Invoke-OSDAppRunner\.ps1' -and
            $setupText -match 'OSDAPPS_EXITCODE'
        )
        $setupMessage = if ($setupValid) { 'SetupComplete contains one ordered, exit-code-guarded OSDApps sequence.' } else { 'SetupComplete OSDApps markers, order, script calls, or exit-code guards are invalid.' }
    }
    Add-DeploymentCheck -Name 'SetupComplete' -Passed $setupValid -Required $true -Message $setupMessage

    $applicationStates = [System.Collections.Generic.List[object]]::new()
    $allPayloadsReady = $apps.Count -gt 0
    $networkRequired = $false
    foreach ($app in $apps) {
        $id = [string]$app.Id
        $source = [string]$app.Source
        $definitionValid = $false
        $payloadReady = $false
        $integrityValid = $null
        $detail = $null

        if ($source -eq 'Repository') {
            $archivePath = Join-Path $stageRoot (Join-Path 'Packages' (Join-Path $id 'Package.zip'))
            $definitionValid = ($app.Archive -and [string]$app.Archive.Sha256 -match '^[A-Fa-f0-9]{64}$')
            $payloadReady = Test-Path -LiteralPath $archivePath -PathType Leaf
            $integrityValid = $false
            if ($definitionValid -and $payloadReady) {
                $integrityValid = Test-OSDAppFileHash -Path $archivePath -ExpectedSha256 ([string]$app.Archive.Sha256)
            }
            $payloadReady = ($payloadReady -and $integrityValid)
            $detail = if ($payloadReady) { 'Repository archive is present and SHA-256 validated.' } else { 'Repository archive is missing, malformed, or failed SHA-256 validation.' }
        }
        elseif ($source -eq 'BuiltIn') {
            $state = Get-BuiltInPayloadState -App $app
            $definitionValid = $state.DefinitionValid
            $payloadReady = $state.PayloadReady
            if ($definitionValid -and -not $payloadReady) { $networkRequired = $true }
            $detail = if ($payloadReady) { 'Complete built-in payload is staged locally.' } elseif ($definitionValid) { "Built-in acquisition is required for: $($state.MissingPaths -join ', ')." } else { 'Built-in manifest paths or type are invalid.' }
        }
        else {
            $detail = "Unsupported application source '$source'."
        }

        if (-not $payloadReady) { $allPayloadsReady = $false }
        $applicationRequiredStateValid = ($definitionValid -and ($source -ne 'Repository' -or $payloadReady))
        Add-DeploymentCheck -Name 'ApplicationDefinition' -ApplicationId $id -Passed $applicationRequiredStateValid -Required $true -Message "${id}: $detail"
        Add-DeploymentCheck -Name 'OfflinePayload' -ApplicationId $id -Passed $payloadReady -Required ([bool]$RequireOfflineReady) -Message "${id}: $detail"

        $applicationStates.Add([pscustomobject]@{
            Id              = $id
            Source          = $source
            Type            = [string]$app.Type
            Version         = if ($app.Version) { [string]$app.Version } else { $null }
            Architecture    = if ($app.Architecture) { [string]$app.Architecture } else { $null }
            DefinitionValid = $definitionValid
            PayloadReady    = $payloadReady
            IntegrityValid  = $integrityValid
            Detail          = $detail
        })
    }

    $freeSpaceGB = $null
    try {
        $driveRoot = [IO.Path]::GetPathRoot([IO.Path]::GetFullPath($target))
        $drive = Get-PSDrive -Name $driveRoot.TrimEnd('\').TrimEnd(':') -ErrorAction Stop
        $freeSpaceGB = [math]::Round([double]$drive.Free / 1GB, 2)
        $freeSpaceValid = ($MinimumFreeSpaceGB -le 0 -or $freeSpaceGB -ge $MinimumFreeSpaceGB)
        Add-DeploymentCheck -Name 'FreeSpace' -Passed $freeSpaceValid -Required ($MinimumFreeSpaceGB -gt 0) -Message $(
            if ($freeSpaceValid) { "Offline Windows volume has $freeSpaceGB GB free." } else { "Offline Windows volume has $freeSpaceGB GB free; $MinimumFreeSpaceGB GB is required." }
        )
    }
    catch {
        Add-DeploymentCheck -Name 'FreeSpace' -Passed $false -Required ($MinimumFreeSpaceGB -gt 0) -Message "Free space could not be determined: $($_.Exception.Message)"
    }

    Complete-DeploymentValidation `
        -Manifest $manifest `
        -Applications @($applicationStates) `
        -OfflineReady $allPayloadsReady `
        -NetworkRequired $networkRequired `
        -FreeSpaceGB $freeSpaceGB
}