Private/Sync-OSDAppCache.ps1

function Sync-OSDAppCache {
    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Uri')]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Uri')]
        [uri]$CatalogUri,

        [Parameter(Mandatory, ParameterSetName = 'Path')]
        [string]$CatalogPath,

        [Parameter(Mandatory)]
        [string]$CachePath,

        [string[]]$Name
    )

    $logPath = Join-Path $CachePath 'Logs\\Client.log'

    if ($PSCmdlet.ParameterSetName -eq 'Uri') {
        $sourceCatalog = Get-OSDAppCatalogPackages -CatalogUri $CatalogUri -Name $Name
        $catalogSourceDescription = $CatalogUri.AbsoluteUri
        $repositoryRoot = $null
        $repositoryBaseUri = [uri]::new($CatalogUri, '.')
    }
    else {
        $resolvedCatalogPath = (Resolve-Path -LiteralPath $CatalogPath -ErrorAction Stop).Path
        $sourceCatalog = Get-OSDAppCatalogPackages -CatalogPath $resolvedCatalogPath -Name $Name
        $catalogSourceDescription = $resolvedCatalogPath
        $repositoryRoot = Split-Path -Path $resolvedCatalogPath -Parent
        $repositoryBaseUri = $null
    }

    $hostArchitecture = Get-OSDAppHostArchitecture
    $allPackages = @($sourceCatalog.Packages)

    # Resolve one compatible package variant per application.
    # Exact architecture always wins; 'any' is the fallback.
    $packages = @(
        foreach ($group in ($allPackages | Group-Object Id)) {
            $exact = @(
                $group.Group |
                    Where-Object { ([string]$_.Architecture).ToLowerInvariant() -eq $hostArchitecture }
            )

            $neutral = @(
                $group.Group |
                    Where-Object { ([string]$_.Architecture).ToLowerInvariant() -eq 'any' }
            )

            if ($exact.Count -gt 0) {
                $exact | Select-Object -First 1
            }
            elseif ($neutral.Count -gt 0) {
                $neutral | Select-Object -First 1
            }
            else {
                throw "No compatible package variant for '$($group.Name)' on '$hostArchitecture'. Available: $((@($group.Group.Architecture) -join ', '))"
            }
        }
    )


    $packagesRoot = Join-Path $CachePath 'Packages'
    $cacheCatalogPath = Join-Path $CachePath 'CacheCatalog.json'
    $stagingRoot = Join-Path $CachePath '.staging'

    # Validate ALL entries before touching the persistent cache.
    # IDs are directory names, so arbitrary paths and traversal are forbidden.
    foreach ($package in $packages) {
        if (-not $package.Id -or -not $package.Archive -or -not $package.Archive.Sha256) {
            throw "Package '$($package.Id)' has an incomplete Archive definition."
        }
        if ([string]$package.Id -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or
            [string]$package.Id -match '\.\.') {
            throw "Invalid package Id '$($package.Id)' in repository catalog."
        }
        if ($package.Archive.FileName -and $package.Archive.FileName -ne 'Package.zip') {
            throw "Package '$($package.Id)' must use the fixed archive name Package.zip."
        }
        if ([string]$package.Archive.Sha256 -notmatch '^[A-Fa-f0-9]{64}$') {
            throw "Package '$($package.Id)' has an invalid SHA-256 hash."
        }
    }

    # Never discard existing cache entries because a catalog is unreadable.
    $existingPackages = @()
    if (Test-Path -LiteralPath $cacheCatalogPath -PathType Leaf) {
        try {
            $existingCatalog = Get-OSDAppManifest -Path $cacheCatalogPath
            if ($null -eq $existingCatalog -or
                -not ($existingCatalog.PSObject.Properties.Name -contains 'Packages')) {
                throw 'Cache catalog is missing Packages.'
            }
            $existingPackages = @($existingCatalog.Packages)
        }
        catch {
            throw "Existing cache catalog '$cacheCatalogPath' cannot be read safely: $($_.Exception.Message)"
        }
    }

    $updatedIds = @($packages.Id)
    $mergedPackages = @(
        @($existingPackages | Where-Object { $_.Id -notin $updatedIds }) +
        @($packages)
    )
    $selectedCatalog = [ordered]@{
        SchemaVersion = $sourceCatalog.SchemaVersion
        GeneratedAt = $sourceCatalog.GeneratedAt
        SourceCatalog = $catalogSourceDescription
        Packages = @($mergedPackages)
    }

    $toAcquire = [System.Collections.Generic.List[object]]::new()
    foreach ($package in $packages) {
        $targetRoot = Join-Path $packagesRoot $package.Id
        $targetArchive = Join-Path $targetRoot 'Package.zip'
        if (Test-OSDAppFileHash -Path $targetArchive -ExpectedSha256 $package.Archive.Sha256) {
            Write-Verbose "$($package.Id) is current."
        }
        else {
            $toAcquire.Add([pscustomobject]@{
                Package = $package
                TargetRoot = $targetRoot
            })
        }
    }

    if (-not $PSCmdlet.ShouldProcess($CachePath, "Synchronize $($packages.Count) application(s) as a single cache transaction")) {
        return
    }

    Write-OSDAppLog -LogPath $logPath -Component 'Sync' -Event 'SyncStart' -Message 'Starting transactional repository synchronization.' -Data @{ CachePath = $CachePath; RequestedCount = $packages.Count; AcquireCount = $toAcquire.Count }
    Write-OSDAppLog -LogPath $logPath -Component 'Sync' -Event 'ArchitectureResolved' -Message 'Resolved package variants.' -Data @{ HostArchitecture = $hostArchitecture; Packages = @($packages | ForEach-Object { "$($_.Id):$($_.Architecture)" }) }

    # Everything is downloaded and hash-validated BEFORE an existing package is replaced.
    # All backups live on the same cache volume and are retained if rollback fails.
    $transactionRoot = Join-Path $stagingRoot ("Sync-{0}" -f [guid]::NewGuid().ToString('N'))
    $preparedRoot = Join-Path $transactionRoot 'Prepared'
    $backupRoot = Join-Path $transactionRoot 'Backup'
    $catalogPrepared = Join-Path $transactionRoot 'CacheCatalog.json.new'
    $catalogBackup = Join-Path $transactionRoot 'CacheCatalog.json.old'
    $promoted = [System.Collections.Generic.List[object]]::new()
    $keepRecoveryFiles = $false
    $catalogExisted = Test-Path -LiteralPath $cacheCatalogPath -PathType Leaf
    $catalogCommitAttempted = $false
    $committed = $false

    try {
        New-Item -ItemType Directory -Path $packagesRoot,$preparedRoot,$backupRoot -Force -ErrorAction Stop | Out-Null

        foreach ($entry in $toAcquire) {
            $package = $entry.Package
            $packageTemp = Join-Path $preparedRoot $package.Id
            New-Item -ItemType Directory -Path $packageTemp -Force -ErrorAction Stop | Out-Null
            $tempArchive = Join-Path $packageTemp 'Package.zip'

            if ($package.Archive.Uri) {
                Write-OSDAppLog -LogPath $logPath -Component 'Sync' -Event 'AcquireStart' -Message 'Downloading package archive.' -Data @{ Id = $package.Id; Source = $package.Archive.Uri }
                Invoke-WebRequest -Uri $package.Archive.Uri -OutFile $tempArchive -UseBasicParsing -ErrorAction Stop
            }
            elseif ($package.Archive.SourcePath) {
                $sourceArchive = [string]$package.Archive.SourcePath
                if (-not (Test-Path -LiteralPath $sourceArchive -PathType Leaf)) {
                    throw "Source archive not found: $sourceArchive"
                }
                Write-OSDAppLog -LogPath $logPath -Component 'Sync' -Event 'AcquireStart' -Message 'Copying package archive.' -Data @{ Id = $package.Id; Source = $sourceArchive }
                Copy-Item -LiteralPath $sourceArchive -Destination $tempArchive -Force -ErrorAction Stop
            }
            else {
                throw "Package '$($package.Id)' archive has neither Uri nor SourcePath."
            }

            if (-not (Test-OSDAppFileHash -Path $tempArchive -ExpectedSha256 $package.Archive.Sha256)) {
                throw "SHA-256 validation failed for '$($package.Id)/Package.zip'."
            }
            Write-OSDAppLog -LogPath $logPath -Component 'Sync' -Event 'PackagePrepared' -Message 'Package is downloaded and SHA-256 validated.' -Data @{ Id = $package.Id; Version = $package.Version }
        }

        # Stage the new catalog and save the old one before any package promotion.
        $selectedCatalog | ConvertTo-Json -Depth 20 |
            Set-Content -LiteralPath $catalogPrepared -Encoding UTF8 -ErrorAction Stop
        if ($catalogExisted) {
            Copy-Item -LiteralPath $cacheCatalogPath -Destination $catalogBackup -Force -ErrorAction Stop
        }

        foreach ($entry in $toAcquire) {
            $id = [string]$entry.Package.Id
            $targetRoot = [string]$entry.TargetRoot
            $backupPath = Join-Path $backupRoot $id
            $sourcePath = Join-Path $preparedRoot $id
            $hadPrevious = Test-Path -LiteralPath $targetRoot

            if ($hadPrevious) {
                Move-Item -LiteralPath $targetRoot -Destination $backupPath -ErrorAction Stop
            }
            # Record before promoting: the second move itself could fail.
            $promoted.Add([pscustomobject]@{
                Id = $id
                TargetRoot = $targetRoot
                BackupPath = $backupPath
                HadPrevious = $hadPrevious
            })
            Move-Item -LiteralPath $sourcePath -Destination $targetRoot -ErrorAction Stop
        }

        # This is the commit point: the manifest must describe the new files.
        $catalogCommitAttempted = $true
        Publish-OSDAppCacheCatalog -SourcePath $catalogPrepared -DestinationPath $cacheCatalogPath
        $committed = $true
    }
    catch {
        $syncError = $_.Exception.Message
        $recoveryErrors = [System.Collections.Generic.List[string]]::new()
        if (-not $committed) {
            # Roll back in reverse order, restoring the exact prior directories.
            for ($i = $promoted.Count - 1; $i -ge 0; $i--) {
                $entry = $promoted[$i]
                try {
                    if (Test-Path -LiteralPath $entry.TargetRoot) {
                        Remove-Item -LiteralPath $entry.TargetRoot -Recurse -Force -ErrorAction Stop
                    }
                    if ($entry.HadPrevious) {
                        Move-Item -LiteralPath $entry.BackupPath -Destination $entry.TargetRoot -ErrorAction Stop
                    }
                }
                catch {
                    $recoveryErrors.Add("$($entry.Id): $($_.Exception.Message)")
                }
            }

            if ($catalogCommitAttempted) {
                try {
                    if ($catalogExisted) {
                        Copy-Item -LiteralPath $catalogBackup -Destination $cacheCatalogPath -Force -ErrorAction Stop
                    }
                    elseif (Test-Path -LiteralPath $cacheCatalogPath) {
                        Remove-Item -LiteralPath $cacheCatalogPath -Force -ErrorAction Stop
                    }
                }
                catch {
                    $recoveryErrors.Add("CacheCatalog.json: $($_.Exception.Message)")
                }
            }
        }
        $keepRecoveryFiles = $recoveryErrors.Count -gt 0
        Write-OSDAppLog -LogPath $logPath -Component 'Sync' -Event 'SyncFailed' -Level 'Error' -Message 'Cache transaction failed.' -Data @{
            Error = $syncError
            RollbackErrors = ($recoveryErrors -join ' | ')
            RecoveryPath = if ($keepRecoveryFiles) { $transactionRoot } else { '' }
        }
        if ($keepRecoveryFiles) {
            throw "Cache transaction failed: $syncError. Rollback incomplete ($($recoveryErrors -join '; ')). Preserve recovery data at '$transactionRoot'."
        }
        throw "Cache transaction failed: $syncError. Previous cache preserved."
    }
    finally {
        if (-not $keepRecoveryFiles -and (Test-Path -LiteralPath $transactionRoot)) {
            Remove-Item -LiteralPath $transactionRoot -Recurse -Force -ErrorAction SilentlyContinue
        }
    }

    foreach ($entry in $toAcquire) {
        Write-OSDAppLog -LogPath $logPath -Component 'Sync' -Event 'PackageUpdated' -Message 'Package transaction committed and SHA-256 validated.' -Data @{ Id = $entry.Package.Id; Version = $entry.Package.Version; Sha256 = $entry.Package.Archive.Sha256 }
    }
    Write-OSDAppLog -LogPath $logPath -Component 'Sync' -Event 'SyncComplete' -Message 'Repository synchronization committed.' -Data @{
        Catalog = $cacheCatalogPath
        SyncedPackageCount = $packages.Count
        UpdatedPackageCount = $toAcquire.Count
        CachedPackageCount = @($mergedPackages).Count
    }

    Get-Item -LiteralPath $cacheCatalogPath
}