Private/steps-osd/Step-OSDCloudSaveOperatingSystemCloudObject.ps1

function Step-OSDCloudSaveOperatingSystemCloudObject {
    [CmdletBinding()]
    param (
        [Parameter()]
        $OperatingSystemCloudObject = $global:OSDCoreOperatingSystemCloudObject,

        [Parameter()]
        [ValidateRange(0, 1)]
        [int]$HashRetryCount = 0
    )
    #=================================================
    Write-Host -ForegroundColor DarkGray "[$(Get-Date -format s)] [$($MyInvocation.MyCommand.Name)]"
    #=================================================
    # Is it online?
    if (-not ($global:RecastOSDCloud.OperatingSystemCloudObjectTest)) {
        return
    }
    Write-Host -ForegroundColor DarkGray "[$(Get-Date -format s)] Save OperatingSystemCloudObject Online:"
    #=================================================
    # Is there an OperatingSystem Object?
    if (-not ($OperatingSystemCloudObject)) {
        throw "[$(Get-Date -format s)] [$($MyInvocation.MyCommand.Name)] OSDCoreOperatingSystemCloudObject is not set"
    }

    if (-not $OperatingSystemCloudObject.FileName) {
        throw "[$(Get-Date -format s)] [$($MyInvocation.MyCommand.Name)] OSDCoreOperatingSystemCloudObject.FileName is not set"
    }
    #=================================================
    # Destination settings for the local deployment workspace.
    # This is the final on-disk path expected by downstream deployment steps.
    $DownloadPath = 'C:\OSDCloud\OS'
    $LocalDestinationPath = Join-Path -Path $DownloadPath -ChildPath $OperatingSystemCloudObject.FileName
    #=================================================
    # Does the destination already exist? If so, validate hash before returning.
    if (Test-Path -LiteralPath $LocalDestinationPath) {
        Write-Host -ForegroundColor DarkGray "[$(Get-Date -format s)] Destination already exists: $LocalDestinationPath"
        $DestinationFile = Get-Item -LiteralPath $LocalDestinationPath -ErrorAction SilentlyContinue

        # Track whether existing content is reusable or must be removed/re-downloaded.
        $HashMismatch = $false
        if ($OperatingSystemCloudObject.SHA1) {
            # Legacy metadata path: compare destination hash to Microsoft-published SHA1.
            $ExistingFileHash = Get-FileHash -Path $LocalDestinationPath -Algorithm SHA1
            Write-Host -ForegroundColor DarkGray "[$(Get-Date -format s)] Existing ESD SHA1: $($ExistingFileHash.Hash)"
            if ($ExistingFileHash.Hash -ne $OperatingSystemCloudObject.SHA1) {
                $HashMismatch = $true
            }
            else {
                Write-Host -ForegroundColor Green "[$(Get-Date -format s)] Existing ESD SHA1 matches the verified Microsoft ESD SHA1. OK."
                # Get-Item for $DestinationFile
                $global:RecastOSDCloud.OperatingSystemFileObject = $DestinationFile
            }
        }
        elseif ($OperatingSystemCloudObject.SHA256) {
            # Preferred metadata path: compare destination hash to Microsoft SHA256.
            $ExistingFileHash = Get-FileHash -Path $LocalDestinationPath -Algorithm SHA256
            Write-Host -ForegroundColor DarkGray "[$(Get-Date -format s)] Existing ESD SHA256: $($ExistingFileHash.Hash)"
            if ($ExistingFileHash.Hash -ne $OperatingSystemCloudObject.SHA256) {
                $HashMismatch = $true
            }
            else {
                Write-Host -ForegroundColor Green "[$(Get-Date -format s)] Existing ESD SHA256 matches the verified Microsoft ESD SHA256. OK."
                $global:RecastOSDCloud.OperatingSystemFileObject = $DestinationFile
            }
        }

        if ($HashMismatch) {
            # Remove bad content before retry so the next attempt starts clean.
            try {
                Remove-Item -LiteralPath $LocalDestinationPath -Force -ErrorAction Stop
                Write-Host -ForegroundColor Yellow "[$(Get-Date -format s)] Existing file hash mismatch. Removed: $LocalDestinationPath"
            }
            catch {
                throw "[$(Get-Date -format s)] [$($MyInvocation.MyCommand.Name)] Failed to remove hash-mismatched destination file: $LocalDestinationPath. $($_.Exception.Message)"
            }

            if ($HashRetryCount -lt 1) {
                # Single retry guard prevents unbounded recursion on persistent failures.
                Write-Host -ForegroundColor Yellow "[$(Get-Date -format s)] Retrying download after removing hash-mismatched file"
                Step-OSDCloudSaveOperatingSystemCloudObject -OperatingSystemCloudObject $OperatingSystemCloudObject -HashRetryCount ($HashRetryCount + 1)
                return
            }

            # If retry already occurred, fail fast so caller can decide next action.
            throw "[$(Get-Date -format s)] [$($MyInvocation.MyCommand.Name)] Hash mismatch persists after retry for destination file: $LocalDestinationPath"
        }

        return
    }
    #=================================================
    # Is there a Url?
    if (-not ($OperatingSystemCloudObject.Url)) {
        throw "[$(Get-Date -format s)] [$($MyInvocation.MyCommand.Name)] OSDCoreOperatingSystemCloudObject does not have a Url"
    }
    #=================================================
    # Is the Url reachable?
    # Use a HEAD request as a lightweight reachability/content check.
    $OnlineCheckUri = if ($OperatingSystemCloudObject.Url) { $OperatingSystemCloudObject.Url } else { $OperatingSystemCloudObject.FilePath }

    if ($OnlineCheckUri) {
        try {
            $WebRequest = Invoke-WebRequest -Uri $OnlineCheckUri -UseBasicParsing -Method Head -ErrorAction Stop
            if ($WebRequest.StatusCode -in 200, 206) {
                Write-Host -ForegroundColor DarkGray "[$(Get-Date -format s)] OSDCoreOperatingSystemCloudObject URI is reachable (HEAD $($WebRequest.StatusCode)). OK."
            }
        }
        catch {
            throw "[$(Get-Date -format s)] [$($MyInvocation.MyCommand.Name)] OSDCoreOperatingSystemCloudObject URI is not reachable."
        }
    }
    else {
        throw "[$(Get-Date -format s)] [$($MyInvocation.MyCommand.Name)] OSDCoreOperatingSystemCloudObject URI is not set."
    }
    #=================================================
    # Create destination directory if needed
    # Directory creation is idempotent and safe to call repeatedly.
    $ItemParams = @{
        ErrorAction = 'Stop'
        Force       = $true
        ItemType    = 'Directory'
        Path        = $DownloadPath
    }
    if (-not (Test-Path -LiteralPath $ItemParams.Path -ErrorAction SilentlyContinue)) {
        New-Item @ItemParams | Out-Null
    }
    #=================================================
    # Is there a USB drive available to cache?
    # Prefer removable media cache when available to reduce repeated WAN downloads.
    $USBDrive = $null
    if ($OSDCoreDevice.USBVolumes) {
        $USBDrive = $OSDCoreDevice.USBVolumes | Where-Object { ($_.FileSystemLabel -match "OSDCloud|USB-DATA") } | `
                    Where-Object { $_.SizeGB -ge 16 } | Where-Object { $_.SizeRemainingGB -ge 10 } | Select-Object -First 1
    }

    if ($USBDrive) {
        # USB path groups content by operating system family for reuse.
        $USBDownloadPath = "$($USBDrive.DriveLetter):\OSDCloud\OS\$($OperatingSystemCloudObject.Version) $($OperatingSystemCloudObject.ReleaseID)"
        Write-Host -ForegroundColor DarkGray "[$(Get-Date -format s)] DownloadPath: $USBDownloadPath"

        if (-not (Test-Path -LiteralPath $USBDownloadPath -ErrorAction SilentlyContinue)) {
            $null = New-Item -Path $USBDownloadPath -ItemType Directory -Force
        }
        # Download once to USB cache, then copy local for active deployment usage.
        $SaveWebFile = Invoke-RecastOSDDownloadFile -SourceUrl $OperatingSystemCloudObject.Url -DestinationDirectory "$USBDownloadPath" -DestinationName $FileName

        if ($SaveWebFile) {
            Write-Host -ForegroundColor DarkGray "[$(Get-Date -format s)] Copy Offline OS to $DownloadPath"
            $null = Copy-Item -Path $SaveWebFile.FullName -Destination $DownloadPath -Force
            $DestinationFile = Get-Item "$DownloadPath\$($SaveWebFile.Name)"
        }
    }
    else {
        # $SaveWebFile is a DestinationFile Object, not a path
        # Direct-to-local fallback when no suitable USB cache is present.
        Write-Host -ForegroundColor DarkGray "[$(Get-Date -format s)] DownloadPath: $DownloadPath"
        $SaveWebFile = Invoke-RecastOSDDownloadFile -SourceUrl $OperatingSystemCloudObject.Url -DestinationDirectory $DownloadPath -ErrorAction Stop
        $DestinationFile = $SaveWebFile
    }
    #=================================================
    # Confirm destination exists and cache file info for downstream workflow.
    if (-not ($DestinationFile)) {
        throw "[$(Get-Date -format s)] [$($MyInvocation.MyCommand.Name)] Unable to download the WindowsImage from the Url"
    }
    # Re-reading FileInfo ensures size/path metadata reflects the actual destination.
    if (Test-Path -LiteralPath $LocalDestinationPath) {
        $DestinationFile = Get-Item -LiteralPath $LocalDestinationPath -ErrorAction SilentlyContinue
        Write-Host -ForegroundColor DarkGray "[$(Get-Date -format s)] Destination file exists: $($DestinationFile.FullName)"
        Write-Host -ForegroundColor DarkGray "[$(Get-Date -format s)] Size: $($DestinationFile.Length) bytes"
    }
    else {
        throw "[$(Get-Date -format s)] [$($MyInvocation.MyCommand.Name)] Destination file does not exist after copy: $LocalDestinationPath"
    }
    #=================================================
    # Final integrity check on the destination file.
    # Metadata includes either SHA1 or SHA256, never both.
    if ($OperatingSystemCloudObject.SHA1) {
        # Destination hash is the final trust gate before deployment can continue.
        $DestinationFileHash = Get-FileHash -Path $LocalDestinationPath -Algorithm SHA1
        Write-Host -ForegroundColor DarkGray "[$(Get-Date -format s)] Downloaded ESD SHA1: $($DestinationFileHash.Hash)"
        if ($DestinationFileHash.Hash -ne $OperatingSystemCloudObject.SHA1) {
            throw "[$(Get-Date -format s)] [$($MyInvocation.MyCommand.Name)] SHA1 hash mismatch for destination file: $LocalDestinationPath"
        }
        else {
            Write-Host -ForegroundColor Green "[$(Get-Date -format s)] Downloaded ESD SHA1 matches the verified Microsoft ESD SHA1. OK."
            # Persist verified destination for subsequent steps that consume this file.
            $global:RecastOSDCloud.OperatingSystemFileObject = $DestinationFile
        }
    }
    if ($OperatingSystemCloudObject.SHA256) {
        # SHA256 path mirrors SHA1 behavior for consistency across metadata versions.
        $DestinationFileHash = Get-FileHash -Path $LocalDestinationPath -Algorithm SHA256
        Write-Host -ForegroundColor DarkGray "[$(Get-Date -format s)] Downloaded ESD SHA256: $($DestinationFileHash.Hash)"
        if ($DestinationFileHash.Hash -ne $OperatingSystemCloudObject.SHA256) {
            throw "[$(Get-Date -format s)] [$($MyInvocation.MyCommand.Name)] SHA256 hash mismatch for destination file: $LocalDestinationPath"
        }
        else {
            Write-Host -ForegroundColor Green "[$(Get-Date -format s)] Downloaded ESD SHA256 matches the verified Microsoft ESD SHA256. OK."
            # Persist verified destination for subsequent steps that consume this file.
            $global:RecastOSDCloud.OperatingSystemFileObject = $DestinationFile
        }
    }
    #=================================================
    Write-Verbose "[$(Get-Date -format s)] [$($MyInvocation.MyCommand.Name)] End"
    #=================================================
}