Private/Copy-Win32ToolkitResultsFromGuest.ps1
|
function Copy-Win32ToolkitResultsFromGuest { <# .SYNOPSIS Copies result files the guest produced back to the host project, preserving structure. .DESCRIPTION The Hyper-V equivalent of the mapped folder's live write-back: after the guest phases run, the outputs (e.g. Documentation\InstallationChanges_*.json, Sandbox\Logs\*) live only on the guest VHDX, so copy them back UNDER the host project at the same relative path. This is what lets the existing host-side consumers (Wait-ForDocumentationAndProcess, Wait-Win32ToolkitUpdateAssertion, New-IntuneRequirementScript, ...) work unchanged — by the time they read, the files are on disk. The transfer is ONE zip, not a Copy-Item -FromSession round trip per file: N sessions round trips (0.5-2 s each) collapse to 3, and -FromSession's per-file attribute replay is sidestepped (the same failure mode the copy-IN zip already avoids). Entries are added PER FILE with try/catch so one locked/unreadable guest file loses only itself — and, unlike the old per-file -ErrorAction SilentlyContinue loop, every skipped file is now REPORTED instead of vanishing. A guest-vs-host entry-count check makes a torn transfer loud. .PARAMETER Session An open PowerShell Direct PSSession. .PARAMETER GuestPath One or more guest paths/globs under C:\PSADT to pull back (e.g. 'C:\PSADT\Documentation\*'). .PARAMETER Destination Host project root that mirrors C:\PSADT. .PARAMETER GuestRoot The guest root that maps to Destination (default 'C:\PSADT'); used to compute relative paths. #> [CmdletBinding()] param( [Parameter(Mandatory)] $Session, [Parameter(Mandatory)] [string[]]$GuestPath, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Destination, [string]$GuestRoot = 'C:\PSADT' ) # In-guest: resolve the globs and zip every match (relative to GuestRoot) into one archive. # 5.1-safe; untrusted values are arguments only. Returns @{ Zip; Count; Failed }. $guestResult = Invoke-Command -Session $Session -ScriptBlock { param($globs, $rootPrefix) # PS Direct doesn't inherit the host's $ProgressPreference (separate runspace); keep this remote # work from relaying any progress onto the host's Spectre TUI. (5.1-safe assignment.) $ProgressPreference = 'SilentlyContinue' $files = @() foreach ($g in $globs) { $files += @(Get-ChildItem -Path $g -Recurse -File -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName }) } $files = @($files | Sort-Object -Unique) if ($files.Count -eq 0) { return @{ Zip = $null; Count = 0; Failed = @() } } Add-Type -AssemblyName 'System.IO.Compression.FileSystem' -ErrorAction SilentlyContinue $zipPath = 'C:\Windows\Temp\w32results_' + [guid]::NewGuid().ToString('N').Substring(0, 8) + '.zip' $failed = @() $added = 0 $archive = [System.IO.Compression.ZipFile]::Open($zipPath, 'Create') try { foreach ($f in $files) { # Entry name = path relative to the guest root ('/' separators per the zip spec). $rel = if ($f.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { $f.Substring($rootPrefix.Length) } else { Split-Path -Leaf $f } try { $null = [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile( $archive, $f, ($rel -replace '\\', '/'), [System.IO.Compression.CompressionLevel]::NoCompression) $added++ } catch { # One locked file must lose only itself (parity with the old per-file loop) — but # visibly, so the host can warn about exactly what is missing. $failed += ($rel + ' (' + $_.Exception.Message + ')') } } } finally { $archive.Dispose() } if ($added -eq 0) { Remove-Item -LiteralPath $zipPath -Force -ErrorAction SilentlyContinue return @{ Zip = $null; Count = 0; Failed = $failed } } return @{ Zip = $zipPath; Count = $added; Failed = $failed } } -ArgumentList (, $GuestPath), ($GuestRoot.TrimEnd('\') + '\') foreach ($miss in @($guestResult.Failed)) { Write-Warning "Result file could not be read in the guest and was NOT copied back: $miss" } if (-not $guestResult.Zip) { Write-Verbose 'No result files matched in the guest — nothing to copy back.' return } # One transfer + host-side extract, then verify the count so a torn transfer cannot pass silently. $hostZip = Join-Path ([System.IO.Path]::GetTempPath()) ('w32results_' + [guid]::NewGuid().ToString('N').Substring(0, 8) + '.zip') try { Copy-Item -FromSession $Session -Path $guestResult.Zip -Destination $hostZip -Force -ErrorAction Stop Add-Type -AssemblyName 'System.IO.Compression.FileSystem' -ErrorAction SilentlyContinue $destRoot = [System.IO.Path]::GetFullPath(($Destination.TrimEnd('\') + '\')) $extracted = 0 $archive = [System.IO.Compression.ZipFile]::OpenRead($hostZip) try { foreach ($entry in $archive.Entries) { $target = [System.IO.Path]::GetFullPath((Join-Path $Destination ($entry.FullName -replace '/', '\'))) # The guest just ran an untrusted installer: never let an entry escape the project root. if (-not $target.StartsWith($destRoot, [System.StringComparison]::OrdinalIgnoreCase)) { Write-Warning "Skipped a result entry that resolves outside the project ('$($entry.FullName)')." continue } $targetDir = Split-Path -Parent $target if ($targetDir -and -not (Test-Path -LiteralPath $targetDir)) { New-Item -ItemType Directory -Path $targetDir -Force | Out-Null } [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $target, $true) $extracted++ } } finally { $archive.Dispose() } if ($extracted -ne [int]$guestResult.Count) { Write-Warning "Result copy-back mismatch: the guest zipped $($guestResult.Count) file(s) but $extracted were extracted — review the warnings above and the guest logs." } } finally { if (Test-Path -LiteralPath $hostZip) { Remove-Item -LiteralPath $hostZip -Force -ErrorAction SilentlyContinue } Invoke-Command -Session $Session -ScriptBlock { param($zip) Remove-Item -LiteralPath $zip -Force -ErrorAction SilentlyContinue } -ArgumentList $guestResult.Zip -ErrorAction SilentlyContinue } } |