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.
This commit is contained in:
rbalsleyMSFT
2026-08-18 19:12:55 -07:00
parent 0454311d20
commit 56162a2994
2 changed files with 114 additions and 25 deletions
+112 -25
View File
@@ -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"
+2
View File
@@ -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.