Private/Initialize-Win32ToolkitDependencyStaging.ps1

function Initialize-Win32ToolkitDependencyStaging {
    <#
    .SYNOPSIS
        Stages the project's declared dependencies into Sandbox\Dependencies\ so the TEST/CAPTURE guest can
        install them BEFORE the app — mirroring what Intune does on a real device.
    .DESCRIPTION
        Intune app dependencies are a DEPLOYMENT-time relationship: the Intune Management Extension installs
        the dependency first on a managed device. The Sandbox / Hyper-V runs never touch Intune — they just
        execute PSADT — so without this the guest is a clean machine with the dependency MISSING. That is
        worst for the documentation capture: the app would install (or half-install) without its runtime and
        the detection rule / uninstall logic would be generated from a broken install.
 
        So each declared dependency is materialized into the project:
          winget:<id> -> the installer is downloaded (latest) to Sandbox\Dependencies\<id>\
          project:<Template>\<Name>-> the packaged project is copied to Sandbox\Dependencies\<Name>\
          intune:<guid> -> CANNOT be staged (we don't have the package) — warned and skipped
 
        Everything lands under Sandbox\, which Optimize-Win32ToolkitProject strips from the Staging copy, so
        none of it ever ships inside the .intunewin.
 
        It then writes:
          Sandbox\Dependencies\dependencies.json the ordered install list (DATA)
          Sandbox\InstallDependencies.ps1 a VALUE-FREE 5.1-safe guest script that reads that JSON
 
        The generated script passes untrusted values (installer paths, winget silent args) to Start-Process
        as PARAMETERS read from JSON — they are never spliced into a code position. Same contract as
        designs/data-driven-generation.md.
    .PARAMETER ProjectPath
        The project whose dependencies should be staged.
    .OUTPUTS
        [int] the number of dependencies staged (0 when none are declared / none could be staged).
    #>

    [CmdletBinding()]
    [OutputType([int])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$ProjectPath
    )

    $deps = @(Get-Win32ToolkitDependencies -ProjectPath $ProjectPath)
    $depRoot    = Join-Path $ProjectPath 'Sandbox\Dependencies'
    $scriptPath = Join-Path $ProjectPath 'Sandbox\InstallDependencies.ps1'

    # ── Staging reuse (hash-validated, HOST-side integrity marker) ────────────────────────────────
    # A full pipeline stages the SAME dependencies 2-3 times (capture + each test), wiping and
    # re-downloading identical binaries each time. Reuse the existing staging when: the cache is on,
    # the DECLARED set is unchanged, the staging is < 6 h old (deps are unpinned 'latest' — bound the
    # staleness), and the staged tree matches the marker EXACTLY. Three properties make this safe on
    # BOTH backends (under Sandbox the whole project — including this staging — was mounted READ-WRITE
    # into a guest that ran an untrusted installer):
    # 1. the marker lives OUTSIDE the project, under <BasePath>\Cache\winget\staging\ — the guest can
    # tamper the staged files but can never forge the record they are checked against;
    # 2. EVERY recorded file (installers, dependencies.json, AND the generated
    # Sandbox\InstallDependencies.ps1 the guest executes) must re-hash to its recorded SHA256;
    # 3. the on-disk file SET must equal the recorded set — a guest-ADDED file is a miss, not a ride-along.
    # Any mismatch or error restages from scratch (today's behavior).
    $declaredJson = ConvertTo-Json -InputObject @($deps | ForEach-Object { "$($_.Source):$($_.Ref)" }) -Compress
    $declaredHash = $null
    try {
        $sha = [System.Security.Cryptography.SHA256]::Create()
        try { $declaredHash = ([System.BitConverter]::ToString($sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($declaredJson))) -replace '-', '') }
        finally { $sha.Dispose() }
    } catch { $declaredHash = $null }

    # Marker path: host-side cache root, keyed by the (case-normalized) project path.
    $stagedMark = $null
    try {
        $cacheRoot = Get-Win32ToolkitPipelineCacheRoot
        if ($cacheRoot -and $declaredHash) {
            $sha = [System.Security.Cryptography.SHA256]::Create()
            try {
                $projKey = ([System.BitConverter]::ToString($sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($ProjectPath.ToLowerInvariant().TrimEnd('\')))) -replace '-', '').Substring(0, 16)
            } finally { $sha.Dispose() }
            $markDir = Join-Path $cacheRoot 'staging'
            if (-not (Test-Path -LiteralPath $markDir)) { New-Item -ItemType Directory -Path $markDir -Force | Out-Null }
            $stagedMark = Join-Path $markDir "$projKey.json"
        }
    } catch { $stagedMark = $null }

    # Enumerates the CURRENT staged tree as project-relative path -> SHA256 (covers the staged payloads,
    # dependencies.json, and the generated guest script — everything the guest run consumes).
    $enumerateStaged = {
        $list = @()
        if (Test-Path -LiteralPath $depRoot) {
            $list += @(Get-ChildItem -LiteralPath $depRoot -Recurse -File -ErrorAction SilentlyContinue)
        }
        if (Test-Path -LiteralPath $scriptPath) { $list += Get-Item -LiteralPath $scriptPath }
        @($list | Sort-Object FullName | ForEach-Object {
            @{ Path = $_.FullName.Substring($ProjectPath.Length + 1); Sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256 -ErrorAction Stop).Hash }
        })
    }

    if ($deps.Count -gt 0 -and $stagedMark -and (Test-Path -LiteralPath $stagedMark)) {
        try {
            $mark = Get-Content -LiteralPath $stagedMark -Raw | ConvertFrom-Json
            $fresh = $mark.DeclaredHash -eq $declaredHash -and
                     $mark.StagedAt -and (((Get-Date) - [datetime]$mark.StagedAt).TotalHours -lt 6)
            if ($fresh) {
                $current  = @(& $enumerateStaged)
                $expected = @($mark.Files)
                if ($current.Count -ne $expected.Count) { $fresh = $false }
                else {
                    for ($i = 0; $i -lt $current.Count; $i++) {
                        if ($current[$i].Path -ne $expected[$i].Path -or $current[$i].Sha256 -ne $expected[$i].Sha256) { $fresh = $false; break }
                    }
                }
            }
            if ($fresh) {
                Write-Host "✓ Dependencies already staged (hash-validated, $([int]$mark.Count) item(s)) — reusing." -ForegroundColor Green
                return [int]$mark.Count
            }
        }
        catch { Write-Verbose "Dependency staging reuse check failed (restaging): $($_.Exception.Message)" }
    }

    # Always start clean: a stale dependency from a previous run would silently install in the guest.
    if (Test-Path -LiteralPath $depRoot)    { Remove-Item -LiteralPath $depRoot -Recurse -Force -ErrorAction SilentlyContinue }
    if (Test-Path -LiteralPath $scriptPath) { Remove-Item -LiteralPath $scriptPath -Force -ErrorAction SilentlyContinue }
    if ($deps.Count -eq 0) { return 0 }

    New-Item -ItemType Directory -Path $depRoot -Force | Out-Null
    Write-Verbose "Staging $($deps.Count) dependency(ies) for the test/capture run..."

    $entries = [System.Collections.Generic.List[object]]::new()

    foreach ($d in $deps) {
        switch ($d.Source) {

            'winget' {
                # Folder name must be filesystem-safe; the winget ID itself is preserved verbatim as DATA.
                $safe    = ($d.Ref -replace '[\\/:*?"<>|]', '_')
                $destDir = Join-Path $depRoot $safe
                New-Item -ItemType Directory -Path $destDir -Force | Out-Null
                try {
                    $dl = Download-OldVersionInstaller -AppId $d.Ref -ProjectPath $ProjectPath -DestinationDir $destDir
                    $entries.Add([pscustomobject]@{
                        Name       = $d.Ref
                        Type       = $dl.InstallerType                                          # exe|msi|msix|appx
                        Path       = "C:\PSADT\Sandbox\Dependencies\$safe\$($dl.InstallerName)" # guest path
                        SilentArgs = $dl.SilentArgs
                    })
                }
                catch {
                    Write-Warning "Could not stage the winget dependency '$($d.Ref)': $($_.Exception.Message). The test/capture run will NOT have it installed."
                }
            }

            'project' {
                $leaf = Split-Path -Leaf $d.Ref
                $src  = Join-Path (Get-Win32ToolkitPaths -BasePath (Get-Win32ToolkitBasePath)).Projects $d.Ref
                if (-not (Test-Path -LiteralPath (Join-Path $src 'Invoke-AppDeployToolkit.ps1'))) {
                    Write-Warning "Dependency project '$($d.Ref)' was not found (no Invoke-AppDeployToolkit.ps1 at $src). The test/capture run will NOT have it installed."
                    break
                }
                $destDir = Join-Path $depRoot $leaf
                New-Item -ItemType Directory -Path $destDir -Force | Out-Null
                # Copy CONTENTS so the PSADT script sits directly under the dependency folder. Its own
                # Sandbox\ artifacts are excluded — we only need it to be installable.
                Copy-Item -Path (Join-Path $src '*') -Destination $destDir -Recurse -Force -Exclude 'Sandbox' -ErrorAction Stop
                $entries.Add([pscustomobject]@{
                    Name       = $d.Ref
                    Type       = 'psadt'
                    Path       = "C:\PSADT\Sandbox\Dependencies\$leaf\Invoke-AppDeployToolkit.ps1"
                    SilentArgs = $null
                })
            }

            'intune' {
                Write-Warning "Dependency 'intune:$($d.Ref)' is an app id in the tenant — the toolkit has no package for it, so it CANNOT be installed in the test/capture guest. The Intune relationship will still be created at publish time. Declare it as 'winget:' or 'project:' if you need it present during testing."
            }
        }
    }

    if ($entries.Count -eq 0) {
        Remove-Item -LiteralPath $depRoot -Recurse -Force -ErrorAction SilentlyContinue
        return 0
    }

    # ── the DATA the guest script consumes ─────────────────────────────────────────────────────────
    $manifest = Join-Path $depRoot 'dependencies.json'
    $json     = ConvertTo-Json -InputObject @($entries) -Depth 5
    [System.IO.File]::WriteAllText($manifest, $json, (New-Object System.Text.UTF8Encoding($false)))

    # ── the VALUE-FREE guest script (Windows PowerShell 5.1, UTF-8 WITH BOM) ───────────────────────
    $guest = @'
# Installs this project's declared dependencies BEFORE the app — the same thing Intune does on a real
# device (the Intune Management Extension installs a mobileAppDependency first). Generated by
# win32-toolkit: VALUE-FREE by design. Every untrusted value (installer path, winget silent args) is READ
# FROM JSON and passed to Start-Process as a PARAMETER — never spliced into a code position.
$ErrorActionPreference = 'Stop'
$manifest = 'C:\PSADT\Sandbox\Dependencies\dependencies.json'
if (-not (Test-Path -LiteralPath $manifest)) { Write-Host 'No dependencies to install.'; exit 0 }
 
$deps = Get-Content -LiteralPath $manifest -Raw -Encoding UTF8 | ConvertFrom-Json
foreach ($d in @($deps)) {
    Write-Host ("Installing dependency: " + $d.Name) -ForegroundColor Cyan
    $rc = 0
    if ($d.Type -eq 'psadt') {
        & $d.Path -DeployMode Silent
        $rc = $LASTEXITCODE
        if ($null -eq $rc) { $rc = 0 }
    }
    elseif ($d.Type -eq 'msix' -or $d.Type -eq 'appx') {
        # Start-Process on a msix opens the App Installer GUI and blocks forever.
        Add-AppxPackage -Path $d.Path
    }
    elseif ($d.SilentArgs) {
        $p = Start-Process -FilePath $d.Path -ArgumentList $d.SilentArgs -Wait -PassThru
        $rc = $p.ExitCode
        Write-Host (" exit code: " + $rc)
    }
    else {
        $p = Start-Process -FilePath $d.Path -Wait -PassThru
        $rc = $p.ExitCode
        Write-Host (" exit code: " + $rc)
    }
    # PROPAGATE failure (0 = success, 3010 = success-needs-reboot; Intune installs dependencies first
    # and a failed dependency blocks the app). Exiting non-zero here is what stops the Hyper-V dep phase
    # from reporting success — and, with the deps-checkpoint feature, from FREEZING a broken image that
    # every later run of the project would silently reuse.
    if ($rc -ne 0 -and $rc -ne 3010) {
        Write-Host (" FAILED: " + $d.Name + " (exit " + $rc + ")") -ForegroundColor Red
        exit $rc
    }
    Write-Host (" installed: " + $d.Name) -ForegroundColor Green
}
Write-Host 'All dependencies installed.' -ForegroundColor Green
exit 0
'@

    [System.IO.File]::WriteAllText($scriptPath, $guest, (New-Object System.Text.UTF8Encoding($true)))

    # Record what was staged (declared-set hash + per-file SHA256 over EVERYTHING the guest consumes,
    # incl. the generated script) in the HOST-side marker so the next call in this pipeline can REUSE it
    # instead of wiping + re-downloading. Best-effort: a failure just means the next call restages.
    if ($stagedMark) {
        try {
            $files = @(& $enumerateStaged)
            $markJson = ConvertTo-Json -InputObject @{ DeclaredHash = $declaredHash; StagedAt = (Get-Date).ToString('o'); Count = $entries.Count; Files = $files } -Depth 4
            [System.IO.File]::WriteAllText($stagedMark, $markJson, (New-Object System.Text.UTF8Encoding($false)))
        }
        catch { Write-Verbose "Could not record the staging marker (reuse disabled for the next call): $($_.Exception.Message)" }
    }

    Write-Host "✓ Dependencies staged: $(($entries | ForEach-Object { $_.Name }) -join ', ')" -ForegroundColor Green
    return $entries.Count
}