Private/Copy-Win32ToolkitProjectToGuest.ps1

function Copy-Win32ToolkitProjectToGuest {
    <#
    .SYNOPSIS
        Copies a PSADT project into the guest at a fixed path over a PowerShell Direct session.
    .DESCRIPTION
        The Hyper-V equivalent of the Windows Sandbox mapped folder: the guest scripts all assume the
        project lives at C:\PSADT (and the update baseline at C:\PSADTOld), so this puts the project
        CONTENTS there. The target is cleared first so no stale files from a previous run linger (the
        warm checkpoint is clean, but this is belt-and-braces). Running on the guest's local VHDX — not
        a VSMB mapped folder — is the performance win over Sandbox.
 
        The transfer is a SINGLE ZIP, not a file-by-file Copy-Item -ToSession. That is not an
        optimisation, it is a correctness fix: -ToSession replays every source file's attributes onto the
        guest, so one file carrying an attribute outside .NET's FileAttributes enum aborts the whole run.
        A file synced/touched by OneDrive carries FILE_ATTRIBUTE_PINNED (0x80000), which produces
        'Cannot convert value "524320" to type "System.IO.FileAttributes"'. Zipping sidesteps attribute
        replay entirely, is faster (one transfer instead of hundreds), and leaves the raw Projects\ copy
        untouched — we never mutate the source to work around it.
    .PARAMETER Session
        An open PowerShell Direct PSSession (from New-Win32ToolkitHyperVSession).
    .PARAMETER ProjectPath
        Host path of the project to copy.
    .PARAMETER GuestPath
        Guest destination (default 'C:\PSADT').
    .PARAMETER ReadOnly
        Lock the copied folder read+execute (icacls) after copying, reproducing the Sandbox mapped folder's
        <ReadOnly>true</ReadOnly> semantics. Used for the update baseline at C:\PSADTOld: without it the
        baseline's own PSADT run could WRITE into it on Hyper-V while the identical run FAILS on Sandbox —
        a silent backend divergence. The VM reverts after every run, so the ACL never persists.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Session,
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$ProjectPath,
        [string]$GuestPath = 'C:\PSADT',
        [switch]$ReadOnly,

        # A zip already built for this project (Invoke-Win32ToolkitHyperVRun pre-builds it CONCURRENTLY
        # with the checkpoint revert — the build needs no session). Consumed: deleted in finally either
        # way. Omitted/missing => built here, exactly as before.
        [string]$PrebuiltZip
    )

    # Transfer as ONE zip rather than file-by-file. Copy-Item -ToSession REPLAYS each source file's
    # attributes onto the guest, so a single file carrying an attribute outside .NET's FileAttributes enum
    # kills the whole run: a file touched by OneDrive gets FILE_ATTRIBUTE_PINNED (0x80000) and the copy
    # throws
    # Cannot convert value "524320" to type "System.IO.FileAttributes" (0x80020 = PINNED|Archive)
    # A zip carries no such attributes. It is also markedly faster (one transfer, not hundreds), and it
    # leaves the raw Projects\ copy untouched — we never mutate the source to work around this.
    $hostZip = if ($PrebuiltZip -and (Test-Path -LiteralPath $PrebuiltZip)) { $PrebuiltZip }
               else { New-Win32ToolkitProjectZip -ProjectPath $ProjectPath }
    $guestZip = 'C:\Windows\Temp\' + (Split-Path -Leaf $hostZip)

    try {
        Copy-Item -ToSession $Session -Path $hostZip -Destination $guestZip -Force -ErrorAction Stop

        Invoke-Command -Session $Session -ScriptBlock {
            param($zip, $dest)
            # PS Direct does NOT inherit the host's $ProgressPreference — each Invoke-Command is its own
            # runspace. Silence at the source so the guest 'Removed N of M files' bar from Remove-Item
            # -Recurse isn't relayed back and painted over the host's Spectre TUI. (5.1-safe assignment.)
            $ProgressPreference = 'SilentlyContinue'
            if (Test-Path -LiteralPath $dest) {
                # A previous -ReadOnly copy of this path grants only RX (no DELETE) — and a deps
                # checkpoint could have frozen such a folder into the image. Reset the ACL first so the
                # cleanup always succeeds (self-heal); harmless when the ACL is normal.
                & icacls.exe $dest /reset /t /c /q 2>$null | Out-Null
                Remove-Item -LiteralPath $dest -Recurse -Force -ErrorAction SilentlyContinue
            }
            New-Item -ItemType Directory -Path $dest -Force | Out-Null
            Add-Type -AssemblyName 'System.IO.Compression.FileSystem' -ErrorAction SilentlyContinue
            [System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $dest)
            Remove-Item -LiteralPath $zip -Force -ErrorAction SilentlyContinue
        } -ArgumentList $guestZip, $GuestPath -ErrorAction Stop
    }
    finally {
        if (Test-Path -LiteralPath $hostZip) { Remove-Item -LiteralPath $hostZip -Force -ErrorAction SilentlyContinue }
    }

    if ($ReadOnly) {
        # Grant read+execute only (no deny ACE — SYSTEM must still READ and EXECUTE the baseline's PSADT).
        Invoke-Command -Session $Session -ScriptBlock {
            param($p)
            & icacls.exe $p /inheritance:r /grant 'SYSTEM:(OI)(CI)(RX)' 'Administrators:(OI)(CI)(RX)' 'Users:(OI)(CI)(RX)' | Out-Null
        } -ArgumentList $GuestPath
    }
}