From 56162a299402e85e7e34d66adfb5d5ea997b623c Mon Sep 17 00:00:00 2001 From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:12:55 -0700 Subject: [PATCH] Prevent Windows ISO drive-letter conflicts in VHDX builds Remap conflicting source ISOs to an unused, unreserved letter. Verify the new mount, preserve pre-mounted media, and document the behavior. --- FFUDevelopment/BuildFFUVM.ps1 | 137 +++++++++++++++++++++++++++------- docs/hyperv_settings.md | 2 + 2 files changed, 114 insertions(+), 25 deletions(-) diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1 index dd01120..d475270 100644 --- a/FFUDevelopment/BuildFFUVM.ps1 +++ b/FFUDevelopment/BuildFFUVM.ps1 @@ -2821,22 +2821,101 @@ function Test-AppsIsoRefreshRequired { return $false } +function Dismount-WindowsIsoSource { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$ImagePath + ) + + $diskImage = Get-DiskImage -ImagePath $ImagePath -ErrorAction SilentlyContinue + if ($null -eq $diskImage -or -not $diskImage.Attached) { + return + } + + WriteLog 'Dismounting Windows ISO' + Dismount-DiskImage -ImagePath $ImagePath -ErrorAction Stop | Out-Null + WriteLog 'Done' +} function Get-WimFromISO { - #Mount ISO, get Wim file - $mountResult = Mount-DiskImage -ImagePath $isoPath -PassThru - $sourcesFolder = ($mountResult | Get-Volume).DriveLetter + ":\sources\" + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$ImagePath, - # Check for install.wim or install.esd - $wimPath = (Get-ChildItem $sourcesFolder\install.* | Where-Object { $_.Name -match "install\.(wim|esd)" }).FullName + [string[]]$ExcludedDriveLetters = @() + ) - if ($wimPath) { - WriteLog "The path to the install file is: $wimPath" - } - else { - WriteLog "No install.wim or install.esd file found in: $sourcesFolder" + $existingDiskImage = Get-DiskImage -ImagePath $ImagePath -ErrorAction Stop + if ($existingDiskImage.Attached) { + throw "Windows ISO '$ImagePath' is already mounted. Dismount it before starting the build." } - return $wimPath + $mountedByBuild = $false + try { + $mountResult = Mount-DiskImage -ImagePath $ImagePath -PassThru -ErrorAction Stop + $mountedByBuild = $true + $mountedVolumes = @($mountResult | Get-Volume -ErrorAction Stop) + if ($mountedVolumes.Count -ne 1) { + throw "Expected one mounted volume for Windows ISO '$ImagePath', found $($mountedVolumes.Count)." + } + + $mountedVolume = $mountedVolumes[0] + $isoDriveLetter = ([string]$mountedVolume.DriveLetter).Trim().TrimEnd(':').ToUpperInvariant() + if ($isoDriveLetter -notmatch '^[A-Z]$') { + throw "Windows ISO '$ImagePath' was mounted without a usable drive letter." + } + + $normalizedExcludedDriveLetters = @($ExcludedDriveLetters | ForEach-Object { + ([string]$_).Trim().TrimEnd(':').ToUpperInvariant() + } | Where-Object { $_ -match '^[A-Z]$' } | Select-Object -Unique) + if ($normalizedExcludedDriveLetters -contains $isoDriveLetter) { + $replacementDriveLetter = Get-AvailableDriveLetter -ExcludedDriveLetters $normalizedExcludedDriveLetters + if ($null -eq $replacementDriveLetter) { + throw 'No drive letter is available for the mounted Windows ISO outside the configured build partition letters.' + } + + $isoVolume = Get-CimInstance -ClassName Win32_Volume -Filter "DriveLetter = '${isoDriveLetter}:' AND DriveType = 5" -ErrorAction Stop + if ($null -eq $isoVolume) { + throw "Unable to resolve the mounted Windows ISO volume at ${isoDriveLetter}:." + } + + WriteLog "Windows ISO drive ${isoDriveLetter}: conflicts with the configured build partition layout. Moving it to ${replacementDriveLetter}:." + Set-CimInstance -InputObject $isoVolume -Property @{ DriveLetter = "${replacementDriveLetter}:" } -ErrorAction Stop | Out-Null + + $mountedVolumes = @($mountResult | Get-Volume -ErrorAction Stop) + $verifiedDriveLetter = if ($mountedVolumes.Count -eq 1) { + ([string]$mountedVolumes[0].DriveLetter).Trim().TrimEnd(':').ToUpperInvariant() + } + if ($verifiedDriveLetter -ne $replacementDriveLetter) { + throw "Windows ISO drive-letter verification failed after assigning ${replacementDriveLetter}:." + } + $isoDriveLetter = $verifiedDriveLetter + } + + $sourcesFolder = "${isoDriveLetter}:\sources" + $windowsImage = Get-ChildItem -LiteralPath $sourcesFolder -Filter 'install.*' -File -ErrorAction Stop | Where-Object { + $_.Name -match '^install\.(wim|esd)$' + } | Select-Object -First 1 + if ($null -eq $windowsImage) { + throw "No install.wim or install.esd file was found in '$sourcesFolder'." + } + + WriteLog "The path to the install file is: $($windowsImage.FullName)" + return $windowsImage.FullName + } + catch { + $mountError = $_ + if ($mountedByBuild) { + try { + Dismount-WindowsIsoSource -ImagePath $ImagePath + } + catch { + WriteLog "Failed to dismount Windows ISO after a mount error: $($_.Exception.Message)" + } + } + throw $mountError + } } function Get-ResolvedWindowsSKUFromImage { @@ -4225,11 +4304,18 @@ function Get-PrivateProfileSection { } function Get-AvailableDriveLetter { - # Get an unused drive letter for temporary SUBST mappings - $usedLetters = (Get-PSDrive -PSProvider FileSystem).Name | ForEach-Object { $_.ToUpperInvariant() } + param( + [string[]]$ExcludedDriveLetters = @() + ) + + # Get an unused drive letter for temporary ISO and SUBST mappings. + $usedLetters = @((Get-PSDrive -PSProvider FileSystem).Name | ForEach-Object { $_.ToUpperInvariant() }) + $excludedLetters = @($ExcludedDriveLetters | ForEach-Object { + ([string]$_).Trim().TrimEnd(':').ToUpperInvariant() + } | Where-Object { $_ -match '^[A-Z]$' } | Select-Object -Unique) for ($ascii = [int][char]'Z'; $ascii -ge [int][char]'A'; $ascii--) { - $candidate = [char]$ascii - if ($usedLetters -notcontains $candidate) { + $candidate = [string][char]$ascii + if ($usedLetters -notcontains $candidate -and $excludedLetters -notcontains $candidate) { return $candidate } } @@ -7884,6 +7970,7 @@ if ($InstallApps) { } #Create VHDX +$windowsIsoMountedByBuild = $false try { Set-Progress -Percentage 11 -Message "Checking for required Windows Updates..." $requiredUpdates = [System.Collections.Generic.List[pscustomobject]]::new() @@ -8364,7 +8451,9 @@ try { if (-Not $cachedVHDXFileFound) { Set-Progress -Percentage 15 -Message "Creating VHDX and applying base Windows image..." if ($ISOPath) { - $wimPath = Get-WimFromISO + $reservedBuildDriveLetters = @($partitionDriveLetters.PSObject.Properties | ForEach-Object { [string]$_.Value }) + $wimPath = Get-WimFromISO -ImagePath $ISOPath -ExcludedDriveLetters $reservedBuildDriveLetters + $windowsIsoMountedByBuild = $true } else { $wimPath = Get-WindowsESD -WindowsRelease $WindowsRelease -WindowsArch $WindowsArch -WindowsLang $WindowsLang -MediaType $mediaType -Metadata $esdMetadata @@ -8538,10 +8627,9 @@ try { $Source = Join-Path (Split-Path $wimpath) 'sxs' Enable-WindowsFeaturesByName -FeatureNames $OptionalFeatures -Source $Source } - If ($ISOPath) { - WriteLog 'Dismounting Windows ISO' - Dismount-DiskImage -ImagePath $ISOPath | Out-null - WriteLog 'Done' + If ($windowsIsoMountedByBuild) { + Dismount-WindowsIsoSource -ImagePath $ISOPath + $windowsIsoMountedByBuild = $false } # If $wimPath is an ESD file, remove it only when configured If ($wimPath -match '\.esd$') { @@ -8631,12 +8719,11 @@ catch { WriteLog "Removing $VMPath" Remove-Item -Path $VMPath -Force -Recurse | Out-Null WriteLog 'Removal complete' - If ($ISOPath) { - WriteLog 'Dismounting Windows ISO' - Dismount-DiskImage -ImagePath $ISOPath | Out-null - WriteLog 'Done' + If ($windowsIsoMountedByBuild) { + Dismount-WindowsIsoSource -ImagePath $ISOPath + $windowsIsoMountedByBuild = $false } - else { + if (-not $ISOPath) { # Remove ESD file only when configured if ($RemoveDownloadedESD -and -not [string]::IsNullOrWhiteSpace($wimPath) -and ($wimPath -match '\.esd$') -and (Test-Path -Path $wimPath)) { WriteLog "Deleting ESD file $wimPath" diff --git a/docs/hyperv_settings.md b/docs/hyperv_settings.md index 4411616..e347149 100644 --- a/docs/hyperv_settings.md +++ b/docs/hyperv_settings.md @@ -57,6 +57,8 @@ The Recovery partition can be removed by selecting its row checkbox and using ** Each data partition has a name, a drive letter from `D:` through `Z:`, and either a size in GB or **Fill Remaining**. When FFU Builder installs applications in a build VM, the partition uses this configured letter before any application scripts run. Only one Windows or data partition can use **Fill Remaining**. Data partitions can be reordered with the arrow buttons. **Clear** removes only data partitions, not the base partition rows. +When you provide a Windows ISO, Windows temporarily assigns the mounted ISO a host drive letter. If that letter is configured for a build partition, FFU Builder moves only the Windows ISO to another unused letter before creating the VHDX partitions. An ISO that does not conflict keeps its original mounted letter. + Use **Reset to Default** to restore the System, MSR, Windows, and Recovery rows and remove all additional data partitions. The reset restores the default `S:`, `W:`, and `R:` build letters, makes Windows use **Fill Remaining**, and returns Recovery to automatic sizing. It does not change **Disk Size** or **Logical Sector Size**. **Persist Drive Letter** is off by default and controls whether the configured letter is also required on a physical device. The build VM uses the configured letter whether or not this option is selected. When selected, FFU Builder requires the same letter when a deployed device enters the Windows `specialize` pass. When cleared, the partition receives the lowest available letter from `D:` upward in data-partition order.