diff --git a/AGENTS.md b/AGENTS.md index 43aada50..7c37c80d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,6 +177,6 @@ When the user corrects an agent approach, add or tighten one concrete rule here - Keep UI helpers such as `Invoke-WPFUIThread` and `Set-WinUtilTweaksProgressIndicator` safe to call without a window; the `-Preset` and `-Config` paths run the workflows before the form is created and before PresentationCore is loaded. - Log install/uninstall package names and package-manager IDs before queuing background runspace work; do not rely on runspace host output for the package identity. - For Win11 Creator, start each new ISO modification in a fresh `WinUtil_Win11ISO_*` temp directory; existing-work detection is only for resuming/exporting already modified media. -- For Win11 Creator driver injection, keep offline WIM servicing to one mount, one `/Add-Driver`, and one commit; do not export editions or run unrelated WIM cleanup, and reject damaged metadata before ISO export. +- For Win11 Creator driver injection, keep offline WIM servicing to one mount and one commit: add each root package folder with its own `/Add-Driver /Recurse` so a single bad driver cannot fail the rest, and skip any folder whose ancestor is already in the set, since that ancestor's `/Recurse` covers it. Warn per failure and commit only when at least one package was added; when none were, warn and discard rather than throwing, so the run still produces an ISO. The discard in the cleanup block carries both orphaned mounts and that intentional zero-added case; keep it. Do not export editions or run unrelated WIM cleanup, and reject damaged metadata before ISO export. Use `-LiteralPath` for driver export paths, since `%TEMP%` can contain wildcard characters. - For Script Analyzer cleanup, fix actionable source warnings first and do not globally suppress accepted convention warnings such as plural names, `ShouldProcess` on UI helpers, `$global:sync`, or compile-time cross-file false positives. - For DNS DHCP reset, keep the cmdlet reset and explicitly set IPv4 and IPv6 DNS source to DHCP. diff --git a/docs/src/content/docs/guides/win11creator.mdx b/docs/src/content/docs/guides/win11creator.mdx index d05c5586..8f6a95c6 100644 --- a/docs/src/content/docs/guides/win11creator.mdx +++ b/docs/src/content/docs/guides/win11creator.mdx @@ -70,7 +70,7 @@ Click **Run Windows ISO Modification and Creator** to start the customization pr - **Disable Copilot and search box suggestions** **Optional: Driver Injection** -- If enabled, it injects all drivers from your current system into the install.wim and boot.wim — useful for offline installations on machines with missing drivers. This is an optional checkbox in Step 3. +- If enabled, WinUtil exports the drivers from your current system, stages boot-storage drivers for Windows Setup, and injects eligible packages into the selected `install.wim` image. Extension-class packages and stale duplicates are excluded. Each remaining package is added separately, so one incompatible package does not stop the others. If a package fails, WinUtil discards the partial mount and retries the remaining packages against the original image. Check the live log for warnings: if every package fails, WinUtil keeps the original `install.wim` and still produces the ISO. A live log shows progress as each step completes. This stage usually takes **10–30 minutes** depending on disk speed. The WIM dismount near the end is the slowest part, so do not close WinUtil while it is running. @@ -138,6 +138,7 @@ When you install Windows 11 from your modified ISO: | USB drive not showing up | Plug it in, wait a few seconds, then click **Refresh** | | Modification seems stuck | The WIM dismount step is slow — wait at least 10 minutes before assuming it's frozen | | "Access Denied" error | Make sure WinUtil is running as Administrator | +| Driver injection warning | Review the live log for the named package. Other compatible packages continue; if none succeed, the ISO is created with the original `install.wim` | | "Setup has failed to validate the product key" | Recreate the ISO with the latest WinUtil. The creator now removes stale `PID.txt`, writes `sources\ei.cfg`, and pins the selected image in `autounattend.xml` so setup does not use an embedded OEM key for a different edition | --- diff --git a/functions/private/Invoke-WinUtilISO.ps1 b/functions/private/Invoke-WinUtilISO.ps1 index a2cf9c63..cd99e4f2 100644 --- a/functions/private/Invoke-WinUtilISO.ps1 +++ b/functions/private/Invoke-WinUtilISO.ps1 @@ -280,12 +280,14 @@ function Invoke-WinUtilISOModify { $driversInjected = [ref]$false Invoke-WinUtilISOScript -ISOContentsDir $isoContents -AutoUnattendXml $autounattendContent -InjectCurrentSystemDrivers $injectDrivers -InstallImagePath $localWim -InstallImageIndex $selectedWimIndex -InstallEditionId $selectedEditionId -Log { param($m) Log $m } -DriversInjected $driversInjected - SetProgress "Preserving install image..." 70 if ($driversInjected.Value) { + SetProgress "Finalizing install image..." 70 Log "Added current-system drivers to $sourceImageFileName index $selectedWimIndex with one mount and commit." } elseif ($injectDrivers) { - Log "No current-system drivers needed injection into $sourceImageFileName index $selectedWimIndex; install.wim was left unchanged." + SetProgress "Preserving install image..." 70 + Log "No current-system drivers were injected into $sourceImageFileName index $selectedWimIndex; install.wim was left unchanged. Review the warning log entries for details." } else { + SetProgress "Preserving install image..." 70 Log "Preserved the original $sourceImageFileName without mounting, exporting, or modifying it." } diff --git a/functions/private/Invoke-WinUtilISOScript.ps1 b/functions/private/Invoke-WinUtilISOScript.ps1 index fb20f912..d996a816 100644 --- a/functions/private/Invoke-WinUtilISOScript.ps1 +++ b/functions/private/Invoke-WinUtilISOScript.ps1 @@ -28,7 +28,7 @@ function Invoke-WinUtilISOScript { .PARAMETER DriversInjected Optional [ref] set to $true only if driver injection actually mounted and committed - install.wim; stays $false if injection was skipped (disabled, or nothing survived filtering). + install.wim; stays $false if injection was skipped or no package was added successfully. #> param ( [Parameter(Mandatory)][string]$ISOContentsDir, @@ -300,17 +300,18 @@ function Invoke-WinUtilISOScript { $driverExportRoot = Join-Path $env:TEMP "WinUtil_DriverExport_$(Get-Date -Format 'yyyyMMdd_HHmmss')_$(([guid]::NewGuid()).ToString('N').Substring(0, 8))" $mountDir = Join-Path (Split-Path -Path $ContentRoot -Parent) 'wim_mount' New-Item -Path $driverExportRoot -ItemType Directory -Force | Out-Null + + # %TEMP% can be an 8.3 alias, but Get-ChildItem below reports long paths, so the + # exported folders would not share this prefix unless it is expanded first. + $driverExportRoot = (Get-Item -LiteralPath $driverExportRoot).FullName $imageMounted = $false try { & $Logger "Exporting current system drivers before modifying install.wim..." $dismLog = Join-Path $env:TEMP "WinUtil_DismDriverExport_$(Get-Date -Format 'yyyyMMdd_HHmmss').log" - $dismProcess = Start-Process -FilePath "dism.exe" -ArgumentList "/online /export-driver /destination:`"$driverExportRoot`" /LogPath:`"$dismLog`"" -Wait -NoNewWindow -PassThru - if ($dismProcess.ExitCode -ne 0) { - throw "dism.exe driver export failed with exit code $($dismProcess.ExitCode)." - } + Invoke-WinUtilISODism -Arguments @('/English', '/Online', '/Export-Driver', "/Destination:$driverExportRoot", "/LogPath:$dismLog") -Operation 'export-driver' | Out-Null - $driverInfs = @(Get-ChildItem -Path $driverExportRoot -Filter '*.inf' -Recurse -File) + $driverInfs = @(Get-ChildItem -LiteralPath $driverExportRoot -Filter '*.inf' -Recurse -File) if ($driverInfs.Count -eq 0) { throw 'DISM exported no driver INF files.' } @@ -350,8 +351,25 @@ function Invoke-WinUtilISOScript { # isn't a failure: leave install.wim untouched and continue building the ISO. & $Logger 'No drivers found to inject: every exported package was excluded (Extension class or stale duplicate). Skipping driver injection; install.wim is unchanged.' } else { - $excludedFolders = @($driverFolders.Name | Where-Object { $_ -notin $stagedDriverFolders }) - foreach ($excludedFolder in $excludedFolders) { + $excludedDriverFolderGroups = @($driverFolders | Where-Object { $_.Name -notin $stagedDriverFolders }) + foreach ($excludedDriverFolderGroup in $excludedDriverFolderGroups) { + $excludedFolder = [string]$excludedDriverFolderGroup.Name + $hasRetainedDescendant = [bool]@($stagedDriverFolders | Where-Object { + $_.StartsWith("$excludedFolder\", [System.StringComparison]::OrdinalIgnoreCase) + }).Count + if ($hasRetainedDescendant) { + try { + foreach ($excludedInf in $excludedDriverFolderGroup.Group) { + Remove-Item -LiteralPath $excludedInf.FullName -Force -ErrorAction Stop + } + } catch { + throw "Failed to remove excluded driver INF files from package '$excludedFolder' before injection: $_" + } + + & $Logger "Keeping excluded driver package directory '$excludedFolder' because it contains a retained nested package, after removing its excluded INF files." + continue + } + try { Remove-Item -LiteralPath $excludedFolder -Recurse -Force -ErrorAction Stop } catch { @@ -359,25 +377,72 @@ function Invoke-WinUtilISOScript { } } - & $Logger "Exported $($stagedDriverFolders.Count) of $($driverFolders.Count) driver packages ($storageCount staged for WinPE, $($excludedFolders.Count) excluded)." + & $Logger "Exported $($stagedDriverFolders.Count) of $($driverFolders.Count) driver packages ($storageCount staged for WinPE, $($excludedDriverFolderGroups.Count) excluded)." Set-ItemProperty -LiteralPath $InstallImagePath -Name IsReadOnly -Value $false New-Item -Path $mountDir -ItemType Directory -Force | Out-Null - & $Logger "Mounting install.wim index $InstallImageIndex once for driver injection..." - Invoke-WinUtilISODism -Arguments @('/English', '/Mount-Image', "/ImageFile:$InstallImagePath", "/Index:$InstallImageIndex", "/MountDir:$mountDir") -Operation 'mount' | Out-Null - $imageMounted = $true - & $Logger "Adding all exported drivers to the selected Windows image in one DISM operation..." - Invoke-WinUtilISODism -Arguments @('/English', "/Image:$mountDir", '/Add-Driver', "/Driver:$driverExportRoot", '/Recurse') -Operation 'add-driver' | Out-Null + # Add each package separately so one bad driver cannot fail the rest. Because + # /Recurse covers descendants, only the highest surviving folder in each tree + # needs its own DISM call. + $rootPackageFolders = @($stagedDriverFolders | Where-Object { + $candidate = $_ + -not ($stagedDriverFolders | Where-Object { $candidate.StartsWith("$_\", [System.StringComparison]::OrdinalIgnoreCase) }) + }) - & $Logger 'Committing the driver-only install.wim change...' - Invoke-WinUtilISODism -Arguments @('/English', '/Unmount-Image', "/MountDir:$mountDir", '/Commit') -Operation 'commit' | Out-Null - $imageMounted = $false + & $Logger "Adding $($rootPackageFolders.Count) root driver packages to install.wim." + $remainingDriverFolders = @($rootPackageFolders) + while ($remainingDriverFolders.Count -gt 0) { + & $Logger "Mounting install.wim index $InstallImageIndex for driver injection..." + Invoke-WinUtilISODism -Arguments @('/English', '/Mount-Image', "/ImageFile:$InstallImagePath", "/Index:$InstallImageIndex", "/MountDir:$mountDir") -Operation 'mount' | Out-Null + $imageMounted = $true - $metadataAfter = Get-WinUtilISOWimMetadata -ImagePath $InstallImagePath -Index $InstallImageIndex - Assert-WinUtilISOWimMetadata -Before $metadataBefore -After $metadataAfter - & $Logger 'Driver injection complete; install.wim metadata validation passed.' - $DriversInjected.Value = $true + $failedDriverFolder = $null + foreach ($driverFolder in $remainingDriverFolders) { + $driverName = $driverFolder + if ($driverFolder.StartsWith($driverExportRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + $driverName = $driverFolder.Substring($driverExportRoot.Length).TrimStart('\') + } + + try { + Invoke-WinUtilISODism -Arguments @('/English', "/Image:$mountDir", '/Add-Driver', "/Driver:$driverFolder", '/Recurse') -Operation "add-driver:$driverName" | Out-Null + } catch { + & $Logger "Warning: failed to add driver package '$driverName': $_" + $failedDriverFolder = $driverFolder + break + } + } + + if (-not $failedDriverFolder) { + break + } + + & $Logger "Discarding the potentially partial install.wim mount before continuing without '$driverName'." + try { + Invoke-WinUtilISODism -Arguments @('/English', '/Unmount-Image', "/MountDir:$mountDir", '/Discard') -Operation 'discard' | Out-Null + $imageMounted = $false + } catch { + throw "Failed to discard the potentially partial install.wim mount after driver package '$driverName' failed: $_" + } + + $remainingDriverFolders = @($remainingDriverFolders | Where-Object { $_ -ne $failedDriverFolder }) + } + + $addedCount = $remainingDriverFolders.Count + if ($addedCount -eq 0) { + # Boot-storage drivers staged for WinPE remain available to Windows Setup. + & $Logger "Warning: none of the $($rootPackageFolders.Count) exported driver packages could be added; continuing with an unmodified install.wim." + } else { + & $Logger "Added $addedCount of $($rootPackageFolders.Count) driver packages to install.wim." + & $Logger 'Committing the driver-only install.wim change...' + Invoke-WinUtilISODism -Arguments @('/English', '/Unmount-Image', "/MountDir:$mountDir", '/Commit') -Operation 'commit' | Out-Null + $imageMounted = $false + + $metadataAfter = Get-WinUtilISOWimMetadata -ImagePath $InstallImagePath -Index $InstallImageIndex + Assert-WinUtilISOWimMetadata -Before $metadataBefore -After $metadataAfter + & $Logger 'Driver injection complete; install.wim metadata validation passed.' + $DriversInjected.Value = $true + } } } finally { if ($imageMounted -or (Test-WinUtilISOMountedImage -Path $mountDir)) { @@ -387,8 +452,8 @@ function Invoke-WinUtilISOScript { & $Logger "Warning: could not discard the failed install.wim mount: $_" } } - Remove-Item -Path $mountDir -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path $driverExportRoot -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $mountDir -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $driverExportRoot -Recurse -Force -ErrorAction SilentlyContinue } } diff --git a/pester/win11creator.Tests.ps1 b/pester/win11creator.Tests.ps1 index 451f44a4..bdfb70e0 100644 --- a/pester/win11creator.Tests.ps1 +++ b/pester/win11creator.Tests.ps1 @@ -36,12 +36,77 @@ Describe "Win11 Creator setup media" { return $functionAst.Extent.Text } + # Root package folders the fixture below yields, once nested_pkg\x64 is folded into + # its parent. Written out rather than derived, so a broken dedup fails the assertions + # instead of quietly moving the expectation with it. + $script:expectedRootPackages = 8 + $script:resilienceDriverFixtures = @( + @{ Path = 'system_pkg'; Name = 'chipset.inf'; Class = 'System' }, + @{ Path = 'storage_pkg'; Name = 'iaStorAC.inf'; Class = 'System' }, + @{ Path = 'scsi_pkg'; Name = 'controller.inf'; Class = 'SCSIAdapter' }, + @{ Path = 'net_pkg'; Name = 'network.inf'; Class = 'Net' }, + @{ Path = 'group_a\duplicate'; Name = 'audio.inf'; Class = 'Media' }, + @{ Path = 'group_b\duplicate'; Name = 'extension.inf'; Class = 'Media' }, + @{ Path = 'nested_pkg'; Name = 'main.inf'; Class = 'Net' }, + @{ Path = 'nested_pkg\x64'; Name = 'extra.inf'; Class = 'Net' }, + @{ Path = 'net_pkg_v2'; Name = 'network2.inf'; Class = 'Net' } + ) + + # Stands in for `dism /Online /Export-Driver`, which the ISO script now invokes + # through Invoke-WinUtilISODism rather than Start-Process. The two `duplicate` leaf + # names cover per-package failure logging, which must stay unambiguous; `nested_pkg` + # covers a package holding INFs at its root and in a subfolder; and `net_pkg_v2` is a + # sibling whose name starts with `net_pkg`, which the dedup must not swallow. + function Export-WinUtilTestDriverPackage { + param ( + [Parameter(Mandatory)][string[]]$Arguments, + [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Fixtures + ) + + $destination = @($Arguments | Where-Object { $_ -like '/Destination:*' }) + if ($destination.Count -ne 1) { + throw "Expected exactly one /Destination argument in the mocked DISM export: $($Arguments -join ' ')" + } + + $exportRoot = $destination[0].Substring('/Destination:'.Length) + $script:driverExportRoot = $exportRoot + + # .NET file APIs rather than the cmdlets: Set-Content's -Encoding comes from the + # FileSystem provider, and a wildcard character in $exportRoot stops the provider + # resolving, so the parameter disappears. + foreach ($fixture in $fixtures) { + $fixturePath = Join-Path $exportRoot $fixture.Path + [void][System.IO.Directory]::CreateDirectory($fixturePath) + [System.IO.File]::WriteAllText( + (Join-Path $fixturePath $fixture.Name), + (@( + '[Version]' + "Class=$($fixture.Class)" + if ($fixture.Provider) { "Provider=$($fixture.Provider)" } + if ($fixture.DriverVer) { + $versionKeyword = if ($fixture.VersionKeyword) { $fixture.VersionKeyword } else { 'DriverVer' } + "$versionKeyword=$($fixture.DriverVer)" + } + ) -join "`r`n"), + [System.Text.Encoding]::ASCII) + } + } + function New-WinUtilDriverExportHarness { - param ([Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Fixtures) + param ( + [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Fixtures, + [string]$FailedDriverPath, + [switch]$FailAllAddDrivers, + [switch]$FailDiscard + ) $script:dismCalls = [System.Collections.Generic.List[string]]::new() $script:driverExportRoot = $null $script:driverExportFixtures = $Fixtures + $script:failedDriverPath = $FailedDriverPath + $script:failAllAddDrivers = $FailAllAddDrivers + $script:failDiscard = $FailDiscard + $script:exportedInfsAtAddDriver = $null Set-Item -Path function:global:dism.exe -Value { param([Parameter(ValueFromRemainingArguments)][string[]]$Arguments) @@ -56,44 +121,23 @@ Describe "Win11 Creator setup media" { 'ProductType : WinNT' } elseif ($Arguments -contains '/Mount-Image') { '[==========================100.0%==========================]' + } elseif ($Arguments -contains '/Export-Driver') { + Export-WinUtilTestDriverPackage -Arguments $Arguments -Fixtures $script:driverExportFixtures } elseif ($Arguments -contains '/Add-Driver') { # Snapshot what's still on disk right as DISM would /Recurse over it: this is the # only point excluded folders are provably gone, since the SUT wipes the whole # export root in its own cleanup once Invoke-WinUtilISOScript returns. - $script:exportRootAtAddDriver = @(Get-ChildItem -Path $script:driverExportRoot -Directory -Recurse -ErrorAction SilentlyContinue | ForEach-Object FullName) + $script:exportRootAtAddDriver = @(Get-ChildItem -LiteralPath $script:driverExportRoot -Directory -Recurse -ErrorAction SilentlyContinue | ForEach-Object FullName) + $script:exportedInfsAtAddDriver = @(Get-ChildItem -LiteralPath $script:driverExportRoot -Filter '*.inf' -File -Recurse -ErrorAction SilentlyContinue | ForEach-Object FullName) + if ($script:failAllAddDrivers -or ($script:failedDriverPath -and @($Arguments | Where-Object { $_ -like "*$($script:failedDriverPath)" }).Count -gt 0)) { + $global:LASTEXITCODE = 13 + 'Error: 13' + } + } elseif ($Arguments -contains '/Discard' -and $script:failDiscard) { + $global:LASTEXITCODE = 50 + 'Discard failed' } } - - Mock Start-Process { - param($FilePath, $ArgumentList) - - if ($FilePath -ne 'dism.exe') { - throw "Unexpected process in driver export mock: $FilePath" - } - - $destinationMatch = [regex]::Match([string]$ArgumentList, '/destination:"([^"]+)"') - if (-not $destinationMatch.Success) { - throw "Unable to find the mocked DISM export destination in: $ArgumentList" - } - - $exportRoot = $destinationMatch.Groups[1].Value - $script:driverExportRoot = $exportRoot - foreach ($fixture in $script:driverExportFixtures) { - $fixturePath = Join-Path $exportRoot $fixture.Path - New-Item -Path $fixturePath -ItemType Directory -Force | Out-Null - $infContent = "[Version]`r`nClass=$($fixture.Class)" - if ($fixture.Provider) { - $infContent += "`r`nProvider=$($fixture.Provider)" - } - if ($fixture.DriverVer) { - $versionKeyword = if ($fixture.VersionKeyword) { $fixture.VersionKeyword } else { 'DriverVer' } - $infContent += "`r`n$versionKeyword=$($fixture.DriverVer)" - } - Set-Content -Path (Join-Path $fixturePath $fixture.Name) -Value $infContent -Encoding ASCII - } - - return [pscustomobject]@{ ExitCode = 0 } - } -ParameterFilter { $FilePath -eq 'dism.exe' } } $script:modifyFunction = Get-WinUtilFunctionText -Path $script:isoWorkflowPath -FunctionName "Invoke-WinUtilISOModify" @@ -389,14 +433,18 @@ Describe "Win11 Creator setup media" { @{ Path = 'group_a\duplicate'; Name = 'audio.inf'; Class = 'Media' }, @{ Path = 'hdx_asusext_apot_g5-tse.inf_amd64_aabbccddeeff0011'; Name = 'hdx_asusext_apot_g5-tse.inf'; Class = 'Extension' }, @{ Path = 'ntprint.inf_x86_7426e1b60aa62272'; Name = 'ntprint.inf'; Class = 'Printer'; DriverVer = '1/1/2023,10.0.26100.8875' }, - @{ Path = 'ntprint.inf_x86_58e7118cdecb935e'; Name = 'ntprint.inf'; Class = 'Printer'; DriverVer = '6/1/2024,10.0.26100.9168' } + @{ Path = 'ntprint.inf_x86_58e7118cdecb935e'; Name = 'ntprint.inf'; Class = 'Printer'; DriverVer = '6/1/2024,10.0.26100.9168' }, + @{ Path = 'nested_pkg'; Name = 'main.inf'; Class = 'Net' }, + @{ Path = 'nested_pkg\x64'; Name = 'extra.inf'; Class = 'Net' }, + @{ Path = 'net_pkg_v2'; Name = 'network2.inf'; Class = 'Net' } ) try { New-Item -Path (Split-Path $installWim -Parent) -ItemType Directory -Force | Out-Null Set-Content -Path $installWim -Value 'mock-wim' . $script:isoScriptPath - Invoke-WinUtilISOScript -ISOContentsDir $contentRoot -AutoUnattendXml $template -InjectCurrentSystemDrivers $true -InstallImagePath $installWim -InstallImageIndex 6 -InstallEditionId 'Professional' -Log { + $driversInjected = [ref]$false + Invoke-WinUtilISOScript -ISOContentsDir $contentRoot -AutoUnattendXml $template -InjectCurrentSystemDrivers $true -InstallImagePath $installWim -InstallImageIndex 6 -InstallEditionId 'Professional' -DriversInjected $driversInjected -Log { param($message) $logs.Add([string]$message) } @@ -409,16 +457,22 @@ Describe "Win11 Creator setup media" { Test-Path (Join-Path $winpeDriverRoot 'net_pkg\network.inf') | Should -BeFalse @($script:dismCalls | Where-Object { $_ -match '/Mount-Image' }).Count | Should -Be 1 - @($script:dismCalls | Where-Object { $_ -match '/Add-Driver' }).Count | Should -Be 1 + @($script:dismCalls | Where-Object { $_ -match '/Add-Driver' }).Count | Should -Be $script:expectedRootPackages @($script:dismCalls | Where-Object { $_ -match '/Unmount-Image\|.*\|/Commit' }).Count | Should -Be 1 @($script:dismCalls | Where-Object { $_ -match '/Get-WimInfo' }).Count | Should -Be 2 ($script:dismCalls -join "`n") | Should -Not -Match '/Cleanup-Image|/Export-Image' + ($logs -join '|') | Should -Match "Added $script:expectedRootPackages of $script:expectedRootPackages driver packages" + + # nested_pkg holds INFs at its root and under x64. The root add already recurses + # into x64, so the subfolder must not get an /Add-Driver call of its own. + @($script:dismCalls | Where-Object { $_ -match '/Add-Driver' -and $_ -match ([regex]::Escape('nested_pkg')) }).Count | Should -Be 1 + ($script:dismCalls -join "`n") | Should -Not -Match ([regex]::Escape('nested_pkg\x64')) [xml]$answerFile = Get-Content -Path (Join-Path $contentRoot 'autounattend.xml') -Raw $nsMgr = New-Object System.Xml.XmlNamespaceManager($answerFile.NameTable) $nsMgr.AddNamespace('sg', 'https://schneegans.de/windows/unattend-generator/') $answerFile.SelectSingleNode('//sg:File[@path="C:\Windows\Setup\Scripts\WinUtil-InstallDrivers.ps1"]', $nsMgr) | Should -BeNullOrEmpty - ($logs -join '|') | Should -Match 'Exported 6 of 8 driver packages \(2 staged for WinPE, 2 excluded\)' + ($logs -join '|') | Should -Match 'Exported 9 of 11 driver packages \(2 staged for WinPE, 2 excluded\)' ($logs -join '|') | Should -Match "Excluding extension-class driver package '.*hdx_asusext_apot_g5-tse.*'" ($logs -join '|') | Should -Match "Excluding stale duplicate driver package '.*ntprint\.inf_x86_7426e1b60aa62272' \(DriverVer 1/1/2023,10\.0\.26100\.8875\) superseded by '.*ntprint\.inf_x86_58e7118cdecb935e' \(DriverVer 6/1/2024,10\.0\.26100\.9168\)" ($logs -join '|') | Should -Match 'install.wim metadata validation passed' @@ -474,6 +528,73 @@ Describe "Win11 Creator setup media" { } } + It "preserves a retained nested package when its parent package is excluded" { + $contentRoot = Join-Path ([IO.Path]::GetTempPath()) "WinUtilIsoNestedRetained_$([guid]::NewGuid())" + $installWim = Join-Path $contentRoot 'sources\install.wim' + $template = Get-Content -Path $script:autoUnattendPath -Raw + $logs = [System.Collections.Generic.List[string]]::new() + + New-WinUtilDriverExportHarness -Fixtures @( + @{ Path = 'parent_pkg'; Name = 'extension.inf'; Class = 'Extension' }, + @{ Path = 'parent_pkg\retained_child'; Name = 'network.inf'; Class = 'Net' } + ) + + try { + New-Item -Path (Split-Path $installWim -Parent) -ItemType Directory -Force | Out-Null + Set-Content -Path $installWim -Value 'mock-wim' + . $script:isoScriptPath + $driversInjected = [ref]$false + Invoke-WinUtilISOScript -ISOContentsDir $contentRoot -AutoUnattendXml $template -InjectCurrentSystemDrivers $true -InstallImagePath $installWim -InstallImageIndex 6 -InstallEditionId 'Professional' -DriversInjected $driversInjected -Log { + param($message) + $logs.Add([string]$message) + } + + $addDriverCalls = @($script:dismCalls | Where-Object { $_ -match '/Add-Driver' }) + $addDriverCalls.Count | Should -Be 1 + $addDriverCalls[0] | Should -Match ([regex]::Escape('parent_pkg\retained_child')) + $script:exportRootAtAddDriver | Should -Contain (Join-Path $script:driverExportRoot 'parent_pkg') + $script:exportRootAtAddDriver | Should -Contain (Join-Path $script:driverExportRoot 'parent_pkg\retained_child') + $script:exportedInfsAtAddDriver | Should -Not -Contain (Join-Path $script:driverExportRoot 'parent_pkg\extension.inf') + $script:exportedInfsAtAddDriver | Should -Contain (Join-Path $script:driverExportRoot 'parent_pkg\retained_child\network.inf') + ($logs -join '|') | Should -Match "Keeping excluded driver package directory '.*parent_pkg' because it contains a retained nested package" + $driversInjected.Value | Should -BeTrue + } finally { + Remove-Item Function:\dism.exe -ErrorAction SilentlyContinue + Remove-Item -Path $contentRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It "removes an excluded nested INF before recursively adding its retained ancestor" { + $contentRoot = Join-Path ([IO.Path]::GetTempPath()) "WinUtilIsoNestedExcluded_$([guid]::NewGuid())" + $installWim = Join-Path $contentRoot 'sources\install.wim' + $template = Get-Content -Path $script:autoUnattendPath -Raw + + New-WinUtilDriverExportHarness -Fixtures @( + @{ Path = 'root_pkg'; Name = 'root.inf'; Class = 'Net' }, + @{ Path = 'root_pkg\excluded_child'; Name = 'extension.inf'; Class = 'Extension' }, + @{ Path = 'root_pkg\excluded_child\retained_grandchild'; Name = 'storage.inf'; Class = 'SCSIAdapter' } + ) + + try { + New-Item -Path (Split-Path $installWim -Parent) -ItemType Directory -Force | Out-Null + Set-Content -Path $installWim -Value 'mock-wim' + . $script:isoScriptPath + Invoke-WinUtilISOScript -ISOContentsDir $contentRoot -AutoUnattendXml $template -InjectCurrentSystemDrivers $true -InstallImagePath $installWim -InstallImageIndex 6 -InstallEditionId 'Professional' + + $addDriverCalls = @($script:dismCalls | Where-Object { $_ -match '/Add-Driver' }) + $addDriverCalls.Count | Should -Be 1 + $addDriverCalls[0] | Should -Match ([regex]::Escape('/Driver:' + (Join-Path $script:driverExportRoot 'root_pkg'))) + $script:exportRootAtAddDriver | Should -Contain (Join-Path $script:driverExportRoot 'root_pkg\excluded_child') + $script:exportRootAtAddDriver | Should -Contain (Join-Path $script:driverExportRoot 'root_pkg\excluded_child\retained_grandchild') + $script:exportedInfsAtAddDriver | Should -Contain (Join-Path $script:driverExportRoot 'root_pkg\root.inf') + $script:exportedInfsAtAddDriver | Should -Not -Contain (Join-Path $script:driverExportRoot 'root_pkg\excluded_child\extension.inf') + $script:exportedInfsAtAddDriver | Should -Contain (Join-Path $script:driverExportRoot 'root_pkg\excluded_child\retained_grandchild\storage.inf') + } finally { + Remove-Item Function:\dism.exe -ErrorAction SilentlyContinue + Remove-Item -Path $contentRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + It "validates WIM metadata and reports no injection when every package is excluded" { $contentRoot = Join-Path ([IO.Path]::GetTempPath()) "WinUtilIsoAllExcluded_$([guid]::NewGuid())" $installWim = Join-Path $contentRoot 'sources\install.wim' @@ -526,7 +647,7 @@ Describe "Win11 Creator setup media" { $logs.Add([string]$message) } - @($script:dismCalls | Where-Object { $_ -match '/Add-Driver' }).Count | Should -Be 1 + @($script:dismCalls | Where-Object { $_ -match '/Add-Driver' }).Count | Should -Be 2 ($logs -join '|') | Should -Match 'Exported 2 of 2 driver packages \(0 staged for WinPE, 0 excluded\)' ($logs -join '|') | Should -Not -Match 'Excluding stale duplicate driver package' @@ -567,7 +688,7 @@ Describe "Win11 Creator setup media" { $logs.Add([string]$message) } - @($script:dismCalls | Where-Object { $_ -match '/Add-Driver' }).Count | Should -Be 1 + @($script:dismCalls | Where-Object { $_ -match '/Add-Driver' }).Count | Should -Be 3 ($logs -join '|') | Should -Match 'Exported 3 of 7 driver packages \(0 staged for WinPE, 4 excluded\)' ($logs -join '|') | Should -Match "Excluding stale duplicate driver package '.*ntprint\.inf_x86_7426e1b60aa62272' \(DriverVer 1/1/2023,10\.0\.26100\.8875\) superseded by '.*ntprint\.inf_x86_58e7118cdecb935e'" ($logs -join '|') | Should -Match "Excluding stale duplicate driver package '.*ntprint\.inf_x86_6688e7b66f8d9fb5' \(DriverVer 1/1/2024,10\.0\.26100\.8972\) superseded by '.*ntprint\.inf_x86_58e7118cdecb935e'" @@ -587,6 +708,136 @@ Describe "Win11 Creator setup media" { } } + It "discards partial changes and commits the remaining drivers when one package fails to add" { + $contentRoot = Join-Path ([IO.Path]::GetTempPath()) "WinUtilIsoDriverPartial_$([guid]::NewGuid())" + $installWim = Join-Path $contentRoot 'sources\install.wim' + $template = Get-Content -Path $script:autoUnattendPath -Raw + $logs = [System.Collections.Generic.List[string]]::new() + New-WinUtilDriverExportHarness -Fixtures $script:resilienceDriverFixtures -FailedDriverPath 'group_a\duplicate' + + try { + New-Item -Path (Split-Path $installWim -Parent) -ItemType Directory -Force | Out-Null + Set-Content -Path $installWim -Value 'mock-wim' + . $script:isoScriptPath + $driversInjected = [ref]$false + Invoke-WinUtilISOScript -ISOContentsDir $contentRoot -AutoUnattendXml $template -InjectCurrentSystemDrivers $true -InstallImagePath $installWim -InstallImageIndex 6 -InstallEditionId 'Professional' -DriversInjected $driversInjected -Log { + param($message) + $logs.Add([string]$message) + } + + @($script:dismCalls | Where-Object { $_ -match '/Mount-Image' }).Count | Should -Be 2 + @($script:dismCalls | Where-Object { $_ -match '/Unmount-Image\|.*\|/Commit' }).Count | Should -Be 1 + @($script:dismCalls | Where-Object { $_ -match '/Unmount-Image\|.*\|/Discard' }).Count | Should -Be 1 + + $lastMountCall = $script:dismCalls | Where-Object { $_ -match '/Mount-Image' } | Select-Object -Last 1 + $lastMountIndex = [Array]::LastIndexOf($script:dismCalls.ToArray(), $lastMountCall) + $finalMountCalls = @($script:dismCalls[($lastMountIndex + 1)..($script:dismCalls.Count - 1)]) + @($finalMountCalls | Where-Object { $_ -match '/Add-Driver' }).Count | Should -Be ($script:expectedRootPackages - 1) + ($finalMountCalls -join "`n") | Should -Not -Match ([regex]::Escape('group_a\duplicate')) + + ($logs -join '|') | Should -Match "Added $($script:expectedRootPackages - 1) of $script:expectedRootPackages driver packages" + ($logs -join '|') | Should -Match 'install.wim metadata validation passed' + # group_a and group_b share a leaf name, so the warning must name the failing + # package by its subpath; a leaf-only name would read 'duplicate' and be ambiguous. + ($logs -join '|') | Should -Match ([regex]::Escape("failed to add driver package 'group_a\duplicate'")) + ($logs -join '|') | Should -Match ([regex]::Escape('add-driver:group_b\duplicate completed')) + $driversInjected.Value | Should -BeTrue + } finally { + Remove-Item Function:\dism.exe -ErrorAction SilentlyContinue + Remove-Item -Path $contentRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It "discards the mount and keeps going when every driver package fails to add" { + $contentRoot = Join-Path ([IO.Path]::GetTempPath()) "WinUtilIsoDriverAllFail_$([guid]::NewGuid())" + $installWim = Join-Path $contentRoot 'sources\install.wim' + $template = Get-Content -Path $script:autoUnattendPath -Raw + $logs = [System.Collections.Generic.List[string]]::new() + New-WinUtilDriverExportHarness -Fixtures $script:resilienceDriverFixtures -FailAllAddDrivers + + try { + New-Item -Path (Split-Path $installWim -Parent) -ItemType Directory -Force | Out-Null + Set-Content -Path $installWim -Value 'mock-wim' + . $script:isoScriptPath + $driversInjected = [ref]$true + Invoke-WinUtilISOScript -ISOContentsDir $contentRoot -AutoUnattendXml $template -InjectCurrentSystemDrivers $true -InstallImagePath $installWim -InstallImageIndex 6 -InstallEditionId 'Professional' -DriversInjected $driversInjected -Log { + param($message) + $logs.Add([string]$message) + } + + @($script:dismCalls | Where-Object { $_ -match '/Add-Driver' }).Count | Should -Be $script:expectedRootPackages + @($script:dismCalls | Where-Object { $_ -match '/Unmount-Image\|.*\|/Commit' }).Count | Should -Be 0 + @($script:dismCalls | Where-Object { $_ -match '/Mount-Image' }).Count | Should -Be $script:expectedRootPackages + @($script:dismCalls | Where-Object { $_ -match '/Unmount-Image\|.*\|/Discard' }).Count | Should -Be $script:expectedRootPackages + @($script:dismCalls | Where-Object { $_ -match '/Get-WimInfo' }).Count | Should -Be 1 + + ($logs -join '|') | Should -Match "none of the $script:expectedRootPackages exported driver packages could be added" + ($logs -join '|') | Should -Not -Match "Added 0 of $script:expectedRootPackages" + # WinPE staging is independent of WIM servicing, so it must survive the failure. + @(Get-ChildItem -Path (Join-Path $contentRoot '$WinpeDriver$') -Directory).Count | Should -Be 2 + $driversInjected.Value | Should -BeFalse + } finally { + Remove-Item Function:\dism.exe -ErrorAction SilentlyContinue + Remove-Item -Path $contentRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It "finds exported drivers when the temp path contains wildcard characters" { + $contentRoot = Join-Path ([IO.Path]::GetTempPath()) "WinUtilIsoDriverGlob_$([guid]::NewGuid())" + $installWim = Join-Path $contentRoot 'sources\install.wim' + $template = Get-Content -Path $script:autoUnattendPath -Raw + $logs = [System.Collections.Generic.List[string]]::new() + New-WinUtilDriverExportHarness -Fixtures $script:resilienceDriverFixtures + + # A Windows account named like "John [Work]" puts wildcard characters in %TEMP%. + # Get-ChildItem -Path would glob them, find nothing, and abort the whole ISO run. + $originalTemp = $env:TEMP + $bracketTemp = Join-Path ([IO.Path]::GetTempPath()) "WinUtil [Glob] $([guid]::NewGuid())" + + try { + New-Item -Path (Split-Path $installWim -Parent) -ItemType Directory -Force | Out-Null + Set-Content -Path $installWim -Value 'mock-wim' + New-Item -Path $bracketTemp -ItemType Directory -Force | Out-Null + $env:TEMP = $bracketTemp + . $script:isoScriptPath + Invoke-WinUtilISOScript -ISOContentsDir $contentRoot -AutoUnattendXml $template -InjectCurrentSystemDrivers $true -InstallImagePath $installWim -InstallImageIndex 6 -InstallEditionId 'Professional' -Log { + param($message) + $logs.Add([string]$message) + } + + ($logs -join '|') | Should -Match "Added $script:expectedRootPackages of $script:expectedRootPackages driver packages" + @($script:dismCalls | Where-Object { $_ -match '/Unmount-Image\|.*\|/Commit' }).Count | Should -Be 1 + ($logs -join '|') | Should -Match ([regex]::Escape('add-driver:nested_pkg completed')) + } finally { + $env:TEMP = $originalTemp + Remove-Item Function:\dism.exe -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $bracketTemp -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $contentRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It "stops when a potentially partial mount cannot be discarded after a package fails" { + $contentRoot = Join-Path ([IO.Path]::GetTempPath()) "WinUtilIsoDriverDiscardFailure_$([guid]::NewGuid())" + $installWim = Join-Path $contentRoot 'sources\install.wim' + $template = Get-Content -Path $script:autoUnattendPath -Raw + New-WinUtilDriverExportHarness -Fixtures $script:resilienceDriverFixtures -FailAllAddDrivers -FailDiscard + + try { + New-Item -Path (Split-Path $installWim -Parent) -ItemType Directory -Force | Out-Null + Set-Content -Path $installWim -Value 'mock-wim' + . $script:isoScriptPath + + { Invoke-WinUtilISOScript -ISOContentsDir $contentRoot -AutoUnattendXml $template -InjectCurrentSystemDrivers $true -InstallImagePath $installWim -InstallImageIndex 6 -InstallEditionId 'Professional' } | + Should -Throw '*Failed to discard the potentially partial install.wim mount after driver package*' + + @($script:dismCalls | Where-Object { $_ -match '/Unmount-Image\|.*\|/Discard' }).Count | Should -Be 2 + @($script:dismCalls | Where-Object { $_ -match '/Unmount-Image\|.*\|/Commit' }).Count | Should -Be 0 + } finally { + Remove-Item Function:\dism.exe -ErrorAction SilentlyContinue + Remove-Item -Path $contentRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + It "discards a partially mounted install.wim after mount failure" { $contentRoot = Join-Path ([IO.Path]::GetTempPath()) "WinUtilIsoMountFailure_$([guid]::NewGuid())" $installWim = Join-Path $contentRoot 'sources\install.wim' @@ -610,22 +861,16 @@ Describe "Win11 Creator setup media" { } elseif ($Arguments -contains '/Get-MountedImageInfo') { $global:LASTEXITCODE = 0 "Mount Dir : $(Join-Path (Split-Path -Path $contentRoot -Parent) 'wim_mount')" + } elseif ($Arguments -contains '/Export-Driver') { + $global:LASTEXITCODE = 0 + Export-WinUtilTestDriverPackage -Arguments $Arguments -Fixtures @( + @{ Path = 'storage_pkg'; Name = 'iaStorAC.inf'; Class = 'System' } + ) } else { $global:LASTEXITCODE = 0 } } - Mock Start-Process { - param($FilePath, $ArgumentList) - - $destinationMatch = [regex]::Match([string]$ArgumentList, '/destination:"([^"]+)"') - $exportRoot = $destinationMatch.Groups[1].Value - $fixturePath = Join-Path $exportRoot 'storage_pkg' - New-Item -Path $fixturePath -ItemType Directory -Force | Out-Null - Set-Content -Path (Join-Path $fixturePath 'iaStorAC.inf') -Value "[Version]`r`nClass=System" -Encoding ASCII - return [pscustomobject]@{ ExitCode = 0 } - } -ParameterFilter { $FilePath -eq 'dism.exe' } - try { New-Item -Path (Split-Path $installWim -Parent) -ItemType Directory -Force | Out-Null Set-Content -Path $installWim -Value 'mock-wim'