From 7861911bb4d606acdab7ca0e1772d979ef5101a8 Mon Sep 17 00:00:00 2001 From: Jamie Chapman <104535858+bilbospocketses@users.noreply.github.com> Date: Sun, 31 May 2026 01:01:00 -0400 Subject: [PATCH 1/2] fix(build): reg.exe-only offline-hive access fixes Dismount-WindowsImage -Save lock (v1.0.30) v1.0.29 broke Standard ISO creation: Dismount-WindowsImage -Save failed with "The process cannot access the file because it is being used by another process." Root cause: Invoke-RegistryPatternZeroAction read the loaded offline NTUSER hive via the .NET PowerShell registry provider (Test-Path/Get-Item HKLM:\z*), which caches an in-process RegistryKey handle that survives `reg unload` and locks the mount at dismount-save. Latent since v1.0.3; intermittent via GC non-determinism. v1.0.29's -Save retry cannot clear a same-process handle. Fix: offline-hive access is reg.exe-only (Get-Tiny11RegValueNames / Test-Tiny11HiveLoaded / Invoke-Tiny11RegExe); Invoke-RegistryPatternZeroAction rewritten; Clear-Tiny11StaleHives no longer probes via Test-Path. Matches upstream tiny11builder + Microsoft offline-servicing docs. v1.0.29 integrity gate + retry kept as defense-in-depth. local-deps: reg.exe/dism.exe/robocopy.exe in the build process now resolve from %SystemRoot%\System32 by absolute path, not %PATH%. Adds a static drift guard that fails if HK*:\z provider access reappears in src. Validated end-to-end: clean ISO built + Windows 11 installed + post-boot task working. Pester 521/0 (non-elevated; +2 admin-gated Synthetic run in CI). --- CHANGELOG.md | 24 ++++++ README.md | 2 +- launcher/app.manifest | 2 +- launcher/tiny11options.Launcher.csproj | 2 +- src/Tiny11.Actions.ProvisionedAppx.psm1 | 9 ++- src/Tiny11.Actions.Registry.psm1 | 31 ++++---- src/Tiny11.Core.psm1 | 26 +++++-- src/Tiny11.Hives.psm1 | 68 +++++++++++++++- src/Tiny11.Worker.psm1 | 7 +- tests/Tiny11.Actions.Registry.Tests.ps1 | 55 +++++++++++++ tests/Tiny11.Core.Tests.ps1 | 2 +- tests/Tiny11.Hives.Tests.ps1 | 78 ++++++++++++++++++- ...y11.OfflineHive.NoProvider.Drift.Tests.ps1 | 44 +++++++++++ 13 files changed, 312 insertions(+), 38 deletions(-) create mode 100644 tests/Tiny11.OfflineHive.NoProvider.Drift.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index aba38df..53bec91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.30] - 2026-05-30 + +**Fixes the `Dismount-WindowsImage -Save` lock that broke ISO creation — at its real root cause.** v1.0.29 (now retracted) misdiagnosed the WIM-commit failure as transient host interference and bolted on a retry; the lock is actually an **in-process .NET registry-provider handle** held by the build's own PowerShell process, which a retry can never clear. Offline-hive value enumeration now uses `reg.exe` exclusively (never the `HKLM:\z*` provider), so no hive handle is held at dismount time — the reg.exe-only pattern of upstream `tiny11builder` and Microsoft's offline-servicing docs. Pester 521/0 (plus the admin-gated Synthetic harness). + +### Fixed + +- **The Standard build no longer fails at `Dismount-WindowsImage -Save` with "The process cannot access the file because it is being used by another process."** Root cause: `Invoke-RegistryPatternZeroAction` read the loaded offline `NTUSER` hive through the .NET PowerShell registry **provider** (`Test-Path` / `Get-Item` on `HKLM:\z…`). The provider caches an in-process `RegistryKey` handle to the hive's backing file *inside the mount*; the kernel keeps a hive loaded while any key handle is open, and `reg unload` has no force flag — so the handle survived into the dismount and locked `install.wim`. v1.0.29's retry could not help: the handle is held by the same long-lived process for the whole retry window, and GC (the only thing that would release it) is non-deterministic and never fires during the backoff. The latent bug has existed since **v1.0.3** — the provider read predates the v1.0.28 idiom tweak and was byte-identical in the v1.0.27 "revert to v1.0.6" — surfacing intermittently with GC timing and deterministically once a build actually reached the commit step. This corrects the v1.0.26–v1.0.29 "a transient WIM dismount/save file-lock is the leading suspect" framing: it was never environmental. +- **Stale-hive recovery (`Clear-Tiny11StaleHives`) no longer opens a provider handle either.** It detected stranded `HKLM\z*` hives with `Test-Path HKLM:\z…`, which — on the exact stranded-hive path it exists to recover — opened a fresh in-process handle. It now probes via `reg.exe` (`Test-Tiny11HiveLoaded`). + +### Changed + +- **All offline-hive registry access in the build process is now `reg.exe`-only.** New `Tiny11.Hives` helpers: `Get-Tiny11RegValueNames` (value-name enumeration via `reg query`, replacing `Get-Item … | Select-Object -ExpandProperty Property`), `Test-Tiny11HiveLoaded` (load check via `reg query`), and `Invoke-Tiny11RegExe` (a `%SystemRoot%\System32\reg.exe` absolute-path wrapper — child process, handle closed on exit). `Invoke-RegistryPatternZeroAction` is rewritten onto `Get-Tiny11RegValueNames`. No change to a successful build's output image — the same value names are matched and the same `reg add … /d 0` writes are issued. +- **v1.0.29's WIM-integrity gate and `-Save` retry are retained as defense-in-depth** (harmless, and they still guard a genuinely bad save or rare external interference), but they are no longer the mechanism — the reg.exe fix removes the in-process cause at the source. +- **Offline-servicing binaries now resolve from `%SystemRoot%\System32` by absolute path, not via `%PATH%` (Local-Dependencies-Only).** `reg.exe` (Hives `Invoke-RegCommand` + Core's inline tweak writes), `dism.exe` (Worker cleanup/export, Core appx enumeration/removal, `Actions.ProvisionedAppx`, and the `Start-CoreProcess` wrapper that backs dism/takeown/icacls), and `robocopy.exe` (Worker ISO copy) are all OS-intrinsic servicing tools that can't be vendored, so they're pinned to the System32 path — blocking a PATH-hijacked stand-in. New `Tiny11.Hives` resolvers `Get-Tiny11RegExePath` / `Get-Tiny11DismExePath`. (The post-boot cleanup script's `reg.exe` runs on the *target* machine — a separate context — and is unchanged.) + +### Added + +- **Regression guard `tests/Tiny11.OfflineHive.NoProvider.Drift.Tests.ps1`** — a static scan that fails if any `src` module reintroduces an `HK*:\z` offline-hive provider-drive reference. Plus reg.exe-only unit coverage for `Get-Tiny11RegValueNames`, `Test-Tiny11HiveLoaded`, and the rewritten `Invoke-RegistryPatternZeroAction`. (The v1.0.29 synthetic-WIM harness never loaded a registry hive, so it structurally could not have caught this class.) + +### Notes + +- **Process fix:** v1.0.29 shipped without a real end-to-end ISO build — only the content-agnostic synthetic-WIM test ran in CI — which is exactly why a real-build regression slipped through. v1.0.30 is gated on an actual `install.wim`-loading build (load hives → pattern-zero → unload → dismount-save) before release. +- v1.0.29 was **retracted**: its GitHub release and Velopack feed assets were deleted, so v1.0.28 remains the offered latest until v1.0.30 ships. The signed `v1.0.29` tag is retained — the repo's tag-protection ruleset blocks tag deletion. + ## [1.0.29] - 2026-05-31 **Hardens the WIM commit path so a corrupt image can never ship silently, and adds real-DISM end-to-end test coverage for it.** Under transient host interference (Defender real-time scan, Windows Search indexer, Controlled Folder Access, a lingering handle), `Dismount-WindowsImage -Save` could partially commit `install.wim` — producing a valid-looking ISO that then failed Windows Setup at the file-copy step (the v1.0.26 "Windows installation has failed" symptom). The build now retries a failed save and verifies the saved/exported `install.wim` and `boot.wim`, aborting with a clear error rather than shipping a broken image. A new admin-gated synthetic-WIM harness validates the gate + retry against a real `New-WindowsImage` WIM (and runs for real in CI). No change to a successful build's output image. Pester 507/0, xUnit 140/0. diff --git a/README.md b/README.md index f7a0ba6..2af5ad5 100644 --- a/README.md +++ b/README.md @@ -359,7 +359,7 @@ One blocked: pwsh -NoProfile -File tests/Run-Tests.ps1 ``` -495 Pester tests (catalog parsing + schema validation, selection model + reconcile/lock logic, registry hive helpers, four action handlers including the post-boot online emitter shapes, action dispatcher, ISO mounting + edition enumeration, autounattend templating + drift detection, worker / Core dispatch, bridge protocol, WebView2 SDK detection, post-boot generator + helpers golden + Format-PSNamedParams + task XML + SetupComplete + Install + the v1.0.2 audit-bundle regression guards across A3 / A4 / A5 / A6 / A7 / A11 + boot.wim pipelineSucceeded wrap + Core Hives -Global + the v1.0.3 BCP-47 language regex coverage across every Windows 11 Language Pack tag including Serbian Latin + the v1.0.3 `registry-pattern-zero` action-type coverage from catalog completeness phase 2 + v1.0.7 takeown/icacls stderr noise-suppression guards + v1.0.8 audit-cleanup guards: registry pattern-zero scope + catalog hive/required-field validation + filesystem noise-filter anchored regex + orchestrator type-aware Build-RelaunchArgs / NonInteractive self-elevation refuse + v1.0.10 retargeting of the UI source-text Pester suite onto the v1.0.9 Step 1 two-column / Step 3 segmented-cards surface: canMoveForward forward-nav predicate gates on outputFilled + outputClean + Output ISO Step 1 field markup with `.req-asterisk` label + `aria-required` input + reserved `.error-slot`, replacing 9 deleted assertions against the pre-v1.0.9 Step 3 output-required-warning + outputMissing predicate + Build ISO tooltip + 4 anchor rewrites on Cleanup.Tests.ps1 for buildDisabled wiring moved into renderIdleCtaCard / build-error path running through renderErrorCard + 1 PostBootCleanup anchor rewrite onto stable input id literals; dead `.output-required-warning` CSS rules + their 3 retained CSS-rule tests pruned in the same release + v1.0.13 RefocusUpdateCheck coverage for the focus-based update re-check: UPDATE_CHECK_MIN_INTERVAL_MS 5-min throttle constant + lastUpdateCheckMs state init + boot-time stamp-before-dispatch order + window.focus listener registration + skip-if-applying / throttle-window / stamp-before-dispatch guard clauses + dispatch-shape match against the boot handshake) and 140 xUnit launcher tests (BuildHandlers / CleanupHandlers / EmbeddedResources drift / payload contracts + v1.0.3 ArchitectureGate rejection coverage for arm64 / arm / x86 hosts + v1.0.3 A13 HeadlessArgs parser + BuildLogPathResolver coverage + v1.0.7 AppVersion formatter + v1.0.8 ArgQuoting shared helper + bridge requestType echo + Process state cache-before-Dispose + v1.0.9 AutoScratchPath generator + PathValidationHandlers covering scratch + output path validation with writability probe + v1.0.11 AutoScratchPath relocation guards: path is under `\tiny11_outputs` + leaf uses `tiny11-<8charhex>` suffix pattern + helper is side-effect-free relative to disk). +521 Pester tests (catalog parsing + schema validation, selection model + reconcile/lock logic, registry hive helpers, four action handlers including the post-boot online emitter shapes, action dispatcher, ISO mounting + edition enumeration, autounattend templating + drift detection, worker / Core dispatch, bridge protocol, WebView2 SDK detection, post-boot generator + helpers golden + Format-PSNamedParams + task XML + SetupComplete + Install + the v1.0.2 audit-bundle regression guards across A3 / A4 / A5 / A6 / A7 / A11 + boot.wim pipelineSucceeded wrap + Core Hives -Global + the v1.0.3 BCP-47 language regex coverage across every Windows 11 Language Pack tag including Serbian Latin + the v1.0.3 `registry-pattern-zero` action-type coverage from catalog completeness phase 2 + v1.0.7 takeown/icacls stderr noise-suppression guards + v1.0.8 audit-cleanup guards: registry pattern-zero scope + catalog hive/required-field validation + filesystem noise-filter anchored regex + orchestrator type-aware Build-RelaunchArgs / NonInteractive self-elevation refuse + v1.0.10 retargeting of the UI source-text Pester suite onto the v1.0.9 Step 1 two-column / Step 3 segmented-cards surface: canMoveForward forward-nav predicate gates on outputFilled + outputClean + Output ISO Step 1 field markup with `.req-asterisk` label + `aria-required` input + reserved `.error-slot`, replacing 9 deleted assertions against the pre-v1.0.9 Step 3 output-required-warning + outputMissing predicate + Build ISO tooltip + 4 anchor rewrites on Cleanup.Tests.ps1 for buildDisabled wiring moved into renderIdleCtaCard / build-error path running through renderErrorCard + 1 PostBootCleanup anchor rewrite onto stable input id literals; dead `.output-required-warning` CSS rules + their 3 retained CSS-rule tests pruned in the same release + v1.0.13 RefocusUpdateCheck coverage for the focus-based update re-check: UPDATE_CHECK_MIN_INTERVAL_MS 5-min throttle constant + lastUpdateCheckMs state init + boot-time stamp-before-dispatch order + window.focus listener registration + skip-if-applying / throttle-window / stamp-before-dispatch guard clauses + dispatch-shape match against the boot handshake) and 140 xUnit launcher tests (BuildHandlers / CleanupHandlers / EmbeddedResources drift / payload contracts + v1.0.3 ArchitectureGate rejection coverage for arm64 / arm / x86 hosts + v1.0.3 A13 HeadlessArgs parser + BuildLogPathResolver coverage + v1.0.7 AppVersion formatter + v1.0.8 ArgQuoting shared helper + bridge requestType echo + Process state cache-before-Dispose + v1.0.9 AutoScratchPath generator + PathValidationHandlers covering scratch + output path validation with writability probe + v1.0.11 AutoScratchPath relocation guards: path is under `\tiny11_outputs` + leaf uses `tiny11-<8charhex>` suffix pattern + helper is side-effect-free relative to disk). Note on the v1.0.1 "409 / 0" headline: the 2026-05-14 empirical audit reconciled this against `Invoke-Pester` runs in a worktree at each landing commit and revealed v1.0.1 actually shipped at **408 passed / 1 failed** (the prior figure reported `TotalCount` rather than `PassedCount`). The persistent failure was a CRLF-vs-LF byte-equal mismatch in the helpers golden fixture and healed in the v1.0.2 cycle by the A6 W2 line-ending-normalize fix. The full audit-verified chain is embedded in `CHANGELOG.md` `[1.0.1] > Test counts > Audit-verified Pester test count chain`. diff --git a/launcher/app.manifest b/launcher/app.manifest index f9270ae..3f28234 100644 --- a/launcher/app.manifest +++ b/launcher/app.manifest @@ -1,6 +1,6 @@ - + diff --git a/launcher/tiny11options.Launcher.csproj b/launcher/tiny11options.Launcher.csproj index c63a5c8..9331756 100644 --- a/launcher/tiny11options.Launcher.csproj +++ b/launcher/tiny11options.Launcher.csproj @@ -9,7 +9,7 @@ AppVersion.Current() -> window.__appVersion (see MainWindow.xaml.cs injection block). Bumping automatically updates the UI display — no manual sync step. --> - 1.0.29 + 1.0.30 WinExe net10.0-windows true diff --git a/src/Tiny11.Actions.ProvisionedAppx.psm1 b/src/Tiny11.Actions.ProvisionedAppx.psm1 index 85f6951..14b1260 100644 --- a/src/Tiny11.Actions.ProvisionedAppx.psm1 +++ b/src/Tiny11.Actions.ProvisionedAppx.psm1 @@ -2,6 +2,11 @@ Set-StrictMode -Version Latest $script:packageCache = @{} +# Absolute path to the OS (inbox) dism.exe -- resolved here, not via PATH, per the +# Local-Dependencies-Only rule (dism.exe is an OS-intrinsic servicing tool that can't be +# vendored; pinning the System32 path also blocks a PATH-hijacked dism.exe). +$script:Tiny11DismExe = Join-Path $env:SystemRoot 'System32\dism.exe' + function Clear-Tiny11AppxPackageCache { [CmdletBinding()] param([string]$ScratchDir) if ($ScratchDir) { $script:packageCache.Remove($ScratchDir) | Out-Null } @@ -15,7 +20,7 @@ function Get-ProvisionedAppxPackagesFromImage { return $script:packageCache[$ScratchDir] } $list = [System.Collections.Generic.List[string]]::new() - & 'dism.exe' '/English' "/image:$ScratchDir" '/Get-ProvisionedAppxPackages' | + & $script:Tiny11DismExe '/English' "/image:$ScratchDir" '/Get-ProvisionedAppxPackages' | ForEach-Object { if ($_ -match '^PackageName\s*:\s*(.+)$') { $list.Add($matches[1].Trim()) } } @@ -26,7 +31,7 @@ function Get-ProvisionedAppxPackagesFromImage { function Invoke-DismRemoveAppx { [CmdletBinding()] param([Parameter(Mandatory)][string]$ScratchDir, [Parameter(Mandatory)][string]$PackageName) - & 'dism.exe' '/English' "/image:$ScratchDir" '/Remove-ProvisionedAppxPackage' "/PackageName:$PackageName" | Out-Null + & $script:Tiny11DismExe '/English' "/image:$ScratchDir" '/Remove-ProvisionedAppxPackage' "/PackageName:$PackageName" | Out-Null if ($LASTEXITCODE -ne 0) { throw "dism /Remove-ProvisionedAppxPackage failed for $PackageName (exit $LASTEXITCODE)" } if ($script:packageCache.ContainsKey($ScratchDir)) { $null = $script:packageCache[$ScratchDir].Remove($PackageName) diff --git a/src/Tiny11.Actions.Registry.psm1 b/src/Tiny11.Actions.Registry.psm1 index a036702..cc6aae4 100644 --- a/src/Tiny11.Actions.Registry.psm1 +++ b/src/Tiny11.Actions.Registry.psm1 @@ -129,24 +129,23 @@ function Invoke-RegistryPatternZeroAction { } $mountKey = Get-Tiny11HiveMountKey -Hive $Action.hive # native reg.exe form: HKLM\zNTUSER - $psPath = "HKLM:\z$($Action.hive)\$($Action.key)" # PSDrive form: HKLM:\zNTUSER\ - - if (-not (Test-Path -LiteralPath $psPath)) { - # Source ISO doesn't have this key populated -- legitimate no-op for - # source ISOs that ship without the CDM key (rare but valid). - return - } - - # v1.0.8 audit BLOCKER ps-modules B1: use Get-Item -LiteralPath | Property - # to read the actual registry value names array from the provider, without - # PSObject metadata pollution (PSPath, PSChildName, PSDrive, PSProvider, - # PSParentPath) that Get-ItemProperty | Get-Member would also surface. - $allNames = @(Get-Item -LiteralPath $psPath -ErrorAction SilentlyContinue | - Select-Object -ExpandProperty Property) - $names = @($allNames | Where-Object { $_ -like $Action.namePattern }) + $fullKey = "$mountKey\$($Action.key)" # HKLM\zNTUSER\ + + # Enumerate matching value names via reg.exe (Get-Tiny11RegValueNames), NEVER the .NET + # registry provider. Through v1.0.29 this used Test-Path + Get-Item on the + # "HKLM:\z\..." provider drive; the provider caches an in-process RegistryKey + # handle to the loaded offline NTUSER hive that survives `reg unload` and locks the + # mount at Dismount-WindowsImage -Save ("being used by another process" -- the build + # break this fixes). reg.exe runs in a child process whose handle dies on exit, so + # nothing in the long-lived build process ever holds the hive open -- the reg.exe-only + # pattern of upstream tiny11builder and Microsoft's offline-servicing docs. An absent + # key yields no names (reg query exit 1): the legitimate no-op for source ISOs that + # ship without the CDM key (B1: reg query also avoids the PSObject metadata pollution + # that the old Get-ItemProperty | Get-Member form had to filter out). + $names = Get-Tiny11RegValueNames -Key $fullKey -NamePattern $Action.namePattern foreach ($name in $names) { - Invoke-RegCommand 'add' "$mountKey\$($Action.key)" '/v' $name '/t' $Action.valueType '/d' '0' '/f' | Out-Null + Invoke-RegCommand 'add' $fullKey '/v' $name '/t' $Action.valueType '/d' '0' '/f' | Out-Null } } diff --git a/src/Tiny11.Core.psm1 b/src/Tiny11.Core.psm1 index 385ab5f..8af494e 100644 --- a/src/Tiny11.Core.psm1 +++ b/src/Tiny11.Core.psm1 @@ -13,6 +13,13 @@ Set-StrictMode -Version Latest Import-Module (Join-Path $PSScriptRoot 'Tiny11.PostBoot.psm1') -Force -Global -DisableNameChecking +# OS-intrinsic servicing tools resolved by absolute System32 path, not PATH (Local- +# Dependencies-Only). reg.exe/dism.exe can't be vendored -- they must be the host's to +# service the offline image; absolute paths also block PATH-hijacked stand-ins. The +# Start-CoreProcess wrapper (dism/takeown/icacls) resolves its FileName the same way. +$script:Tiny11RegExe = Join-Path $env:SystemRoot 'System32\reg.exe' +$script:Tiny11DismExe = Join-Path $env:SystemRoot 'System32\dism.exe' + # ---------- Build log infrastructure ---------- # Persistent on-disk log of every Core build, written to $ScratchDir\tiny11-core-build.log. # Survives the post-failure cleanup commands (those only nuke `mount/` and `source/` subdirs of the scratch root). @@ -480,7 +487,10 @@ function Start-CoreProcess { $output = $null try { try { - $output = & $FileName @Arguments 2>&1 + # Resolve a bare OS tool name (dism.exe/takeown.exe/icacls.exe) to its absolute + # System32 path (Local-Dependencies-Only); a path-bearing FileName is used as-is. + $resolved = if ($FileName -match '[\\/]') { $FileName } else { Join-Path $env:SystemRoot "System32\$FileName" } + $output = & $resolved @Arguments 2>&1 $exit = $LASTEXITCODE } catch { @@ -1286,7 +1296,7 @@ function Invoke-Tiny11CoreBuildPipeline { # Phase 4: appx-removal (upstream lines 111-128) & $ProgressCallback @{ phase='appx-removal'; step="Removing provisioned apps"; percent=15 } $appxPrefixes = Get-Tiny11CoreAppxPrefixes - $allAppxOutput = (& 'dism.exe' '/English' "/image:$mountDir" '/Get-ProvisionedAppxPackages') -join "`n" + $allAppxOutput = (& $script:Tiny11DismExe '/English' "/image:$mountDir" '/Get-ProvisionedAppxPackages') -join "`n" $allAppxPackages = @() foreach ($line in ($allAppxOutput -split "`n")) { # D4.1 — \s*:\s* tolerates non-canonical DISM whitespace; intentional widening from upstream's literal " : ". @@ -1295,7 +1305,7 @@ function Invoke-Tiny11CoreBuildPipeline { foreach ($pkg in $allAppxPackages) { foreach ($prefix in $appxPrefixes) { if ($pkg -like "$prefix*") { - & 'dism.exe' '/English' "/image:$mountDir" '/Remove-ProvisionedAppxPackage' "/PackageName:$pkg" | Out-Null + & $script:Tiny11DismExe '/English' "/image:$mountDir" '/Remove-ProvisionedAppxPackage' "/PackageName:$pkg" | Out-Null # D4.3 — surface non-zero so silent removal failures are visible; build continues per upstream tolerance. if ($LASTEXITCODE -ne 0) { Write-Warning "dism /Remove-ProvisionedAppxPackage $pkg failed (exit $LASTEXITCODE) — non-fatal, continuing" @@ -1388,12 +1398,12 @@ function Invoke-Tiny11CoreBuildPipeline { # Tiny11.Actions.Registry.psm1's Invoke-RegistryAction; # required here too because Core has its own inline path. $regValue = ([string]$t.Value) -replace '"', '\"' - & 'reg.exe' 'add' $fullKey '/v' $t.Name '/t' $t.Type '/d' $regValue '/f' | Out-Null + & $script:Tiny11RegExe 'add' $fullKey '/v' $t.Name '/t' $t.Type '/d' $regValue '/f' | Out-Null } elseif ($t.Op -eq 'delete') { if ($t.PSObject.Properties['Name'] -and $t.Name) { - & 'reg.exe' 'delete' $fullKey '/v' $t.Name '/f' | Out-Null + & $script:Tiny11RegExe 'delete' $fullKey '/v' $t.Name '/f' | Out-Null } else { - & 'reg.exe' 'delete' $fullKey '/f' | Out-Null + & $script:Tiny11RegExe 'delete' $fullKey '/f' | Out-Null } } } @@ -1537,11 +1547,11 @@ function Invoke-Tiny11CoreBuildPipeline { if ($t.Op -eq 'add') { # Pre-escape " for reg.exe (see Tiny11.Actions.Registry.psm1). $regValue = ([string]$t.Value) -replace '"', '\"' - & 'reg.exe' 'add' $fullKey '/v' $t.Name '/t' $t.Type '/d' $regValue '/f' | Out-Null + & $script:Tiny11RegExe 'add' $fullKey '/v' $t.Name '/t' $t.Type '/d' $regValue '/f' | Out-Null } } # Plus the setup-image-only CmdLine override (upstream tiny11Coremaker.ps1 line 514) - & 'reg.exe' 'add' 'HKLM\zSYSTEM\Setup' '/v' 'CmdLine' '/t' 'REG_SZ' '/d' 'X:\sources\setup.exe' '/f' | Out-Null + & $script:Tiny11RegExe 'add' 'HKLM\zSYSTEM\Setup' '/v' 'CmdLine' '/t' 'REG_SZ' '/d' 'X:\sources\setup.exe' '/f' | Out-Null } finally { foreach ($hive in @('SYSTEM', 'SOFTWARE', 'NTUSER', 'DEFAULT', 'COMPONENTS')) { diff --git a/src/Tiny11.Hives.psm1 b/src/Tiny11.Hives.psm1 index ef84fe1..df87482 100644 --- a/src/Tiny11.Hives.psm1 +++ b/src/Tiny11.Hives.psm1 @@ -22,12 +22,72 @@ function Get-Tiny11HiveMountKey { "HKLM\z$Hive" } +function Get-Tiny11RegExePath { + # Absolute path to the OS reg.exe rather than PATH resolution. reg.exe is an + # OS-intrinsic servicing tool (it can't be vendored into the app -- it must be the + # host's, to operate on the loaded offline hive), so the local-dependency rule is + # satisfied by pinning the absolute OS path; this also blocks a PATH-hijacked reg.exe. + # ($env:SystemRoot is the OS root, not a tool-locator env var like %ADB%/$FFMPEG.) + Join-Path $env:SystemRoot 'System32\reg.exe' +} + +function Get-Tiny11DismExePath { + # Absolute path to the OS (inbox) dism.exe -- same local-deps rationale as + # Get-Tiny11RegExePath. The build has always used the inbox dism (System32), not the + # ADK copy; pinning the absolute path keeps that and blocks a PATH-hijacked dism.exe. + Join-Path $env:SystemRoot 'System32\dism.exe' +} + function Invoke-RegCommand { param([Parameter(ValueFromRemainingArguments)][string[]]$RegArgs) - $captured = (& reg.exe @RegArgs) 2>&1 + $captured = (& (Get-Tiny11RegExePath) @RegArgs) 2>&1 if ($LASTEXITCODE -ne 0) { throw "reg.exe failed (exit $LASTEXITCODE): $($RegArgs -join ' ')`n$captured" } } +function Invoke-Tiny11RegExe { + [CmdletBinding()] + param([Parameter(ValueFromRemainingArguments)][string[]]$RegArgs) + # Run the OS reg.exe (absolute path, child process) and return its exit code + stdout. + # A child process closes its handles on exit, so -- unlike the .NET registry provider -- + # it never leaves an in-process hive handle that would lock Dismount-WindowsImage -Save. + # Returning a structured result keeps callers off the ambient $LASTEXITCODE. + $out = & (Get-Tiny11RegExePath) @RegArgs 2>$null + [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = @($out) } +} + +function Test-Tiny11HiveLoaded { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$Hive) + # Is HKLM\z currently loaded? Checked via reg.exe (a child process; no in-process + # handle), NOT `Test-Path` on the provider drive, which opens a .NET RegistryKey handle + # that survives `reg unload` and locks the mount at Dismount-WindowsImage -Save. + # `reg query` against a loaded hive root returns exit 0; an unloaded key returns non-zero. + (Invoke-Tiny11RegExe 'query' (Get-Tiny11HiveMountKey -Hive $Hive)).ExitCode -eq 0 +} + +function Get-Tiny11RegValueNames { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Key, # native reg.exe form, e.g. HKLM\zNTUSER\ + [Parameter()][string]$NamePattern = '*' + ) + # Enumerate value names under $Key via reg.exe -- NEVER the .NET registry provider. + # Get-Item / Get-ItemProperty / Test-Path on an offline-hive provider path cache an + # in-process RegistryKey handle that keeps the hive's backing file (inside the mount) + # open after `reg unload`, so Dismount-WindowsImage -Save fails "being used by another + # process". reg.exe is a child process (handle closed on exit -- deterministic): the + # reg.exe-only pattern of upstream tiny11builder and Microsoft's offline-servicing docs. + # `reg query` exits non-zero when the key is absent -> return no names (legitimate no-op). + $res = Invoke-Tiny11RegExe 'query' $Key + if ($res.ExitCode -ne 0) { return @() } + $names = foreach ($line in $res.Output) { + # reg query value rows: 4-space indent, value NAME, 4-space gap, REG_, gap, data. + # Anchor on the 4-space + REG_ column (value names may themselves contain spaces). + if ($line -match '^\s{4}(.+?)\s{4}REG_[A-Z_]+\b') { $Matches[1] } + } + @($names | Where-Object { $_ -like $NamePattern }) +} + function Mount-Tiny11Hive { [CmdletBinding()] param([Parameter(Mandatory)][string]$Hive, [Parameter(Mandatory)][string]$ScratchDir) @@ -60,12 +120,14 @@ function Clear-Tiny11StaleHives { # HKLM\z makes the next build's `reg load` fail with "Access is denied", bricking # every subsequent build until manual cleanup. Best-effort + never throws: unload any # z-key currently present, ignoring per-hive failures (e.g. a hive genuinely in use). + # Detection is via reg.exe (Test-Tiny11HiveLoaded), never the provider -- a stranded + # hive is exactly the case where a Test-Path probe would open a fresh in-process handle. $ErrorActionPreference = 'Continue' foreach ($h in $HiveMap.Keys) { - if (Test-Path -LiteralPath "HKLM:\z$h") { + if (Test-Tiny11HiveLoaded -Hive $h) { try { Dismount-Tiny11Hive -Hive $h } catch { Write-Warning "Stale-hive recovery: could not unload HKLM\z${h}: $_" } } } } -Export-ModuleMember -Function Resolve-Tiny11HivePath, Get-Tiny11HiveMountKey, Invoke-RegCommand, Mount-Tiny11Hive, Dismount-Tiny11Hive, Mount-Tiny11AllHives, Dismount-Tiny11AllHives, Clear-Tiny11StaleHives +Export-ModuleMember -Function Resolve-Tiny11HivePath, Get-Tiny11HiveMountKey, Get-Tiny11RegExePath, Get-Tiny11DismExePath, Invoke-RegCommand, Invoke-Tiny11RegExe, Test-Tiny11HiveLoaded, Get-Tiny11RegValueNames, Mount-Tiny11Hive, Dismount-Tiny11Hive, Mount-Tiny11AllHives, Dismount-Tiny11AllHives, Clear-Tiny11StaleHives diff --git a/src/Tiny11.Worker.psm1 b/src/Tiny11.Worker.psm1 index b77cb3c..771e057 100644 --- a/src/Tiny11.Worker.psm1 +++ b/src/Tiny11.Worker.psm1 @@ -51,7 +51,8 @@ function Invoke-Tiny11BuildPipeline { $scratchImg = Join-Path $ScratchDir 'scratchdir' New-Item -ItemType Directory -Force -Path "$tinyDir\sources" | Out-Null New-Item -ItemType Directory -Force -Path $scratchImg | Out-Null - & 'robocopy.exe' $sourceRoot.TrimEnd('\') $tinyDir '/MIR' '/MT:8' '/NFL' '/NDL' '/NJH' '/NJS' '/NP' '/NS' '/NC' | Out-Null + # robocopy.exe resolved from System32 (absolute), not PATH -- Local-Dependencies-Only. + & (Join-Path $env:SystemRoot 'System32\robocopy.exe') $sourceRoot.TrimEnd('\') $tinyDir '/MIR' '/MT:8' '/NFL' '/NDL' '/NJH' '/NJS' '/NP' '/NS' '/NC' | Out-Null if ($LASTEXITCODE -ge 8) { throw "robocopy failed (exit $LASTEXITCODE) copying $sourceRoot to $tinyDir" } CheckCancel @@ -103,7 +104,7 @@ function Invoke-Tiny11BuildPipeline { & $progress @{ phase='cleanup-image-skip'; step='Skipping /Cleanup-Image (FastBuild)'; percent=75 } } else { & $progress @{ phase='cleanup-image'; step='dism /Cleanup-Image /StartComponentCleanup /ResetBase'; percent=75 } - & 'dism.exe' "/Image:$scratchImg" '/Cleanup-Image' '/StartComponentCleanup' '/ResetBase' | Out-Null + & (Get-Tiny11DismExePath) "/Image:$scratchImg" '/Cleanup-Image' '/StartComponentCleanup' '/ResetBase' | Out-Null } & $progress @{ phase='inject-postboot-cleanup'; step='Installing post-boot cleanup task'; percent=78 } @@ -143,7 +144,7 @@ function Invoke-Tiny11BuildPipeline { & $progress @{ phase='export-skip'; step='Skipping /Export-Image recovery compression (FastBuild)'; percent=85 } } else { & $progress @{ phase='export'; step='Exporting install.wim with recovery compression'; percent=85 } - & 'dism.exe' '/Export-Image' "/SourceImageFile:$tinyDir\sources\install.wim" "/SourceIndex:$ImageIndex" "/DestinationImageFile:$tinyDir\sources\install2.wim" '/Compress:recovery' '/CheckIntegrity' | Out-Null + & (Get-Tiny11DismExePath) '/Export-Image' "/SourceImageFile:$tinyDir\sources\install.wim" "/SourceIndex:$ImageIndex" "/DestinationImageFile:$tinyDir\sources\install2.wim" '/Compress:recovery' '/CheckIntegrity' | Out-Null if ($LASTEXITCODE -ne 0) { throw "Build aborted -- dism /Export-Image failed (exit $LASTEXITCODE) for install.wim; the image was NOT shipped. Likely WIM corruption or transient host interference. Re-run the build." } Remove-Item -Path "$tinyDir\sources\install.wim" -Force | Out-Null Rename-Item -Path "$tinyDir\sources\install2.wim" -NewName 'install.wim' | Out-Null diff --git a/tests/Tiny11.Actions.Registry.Tests.ps1 b/tests/Tiny11.Actions.Registry.Tests.ps1 index 87177d1..ddf170c 100644 --- a/tests/Tiny11.Actions.Registry.Tests.ps1 +++ b/tests/Tiny11.Actions.Registry.Tests.ps1 @@ -66,3 +66,58 @@ Describe "Invoke-RegistryAction" { } } } + +Describe "Invoke-RegistryPatternZeroAction (offline, reg.exe-only -- no .NET registry provider)" { + # v1.0.30 dismount-lock fix. Through v1.0.29 this read value names via + # Get-Item "HKLM:\z\..." (the .NET provider), which cached an in-process hive + # handle that survived `reg unload` and locked Dismount-WindowsImage -Save with + # "being used by another process". It now enumerates via reg.exe (Get-Tiny11RegValueNames) + # and writes matches via reg.exe (Invoke-RegCommand). These tests pin that contract. + BeforeEach { Mock -CommandName 'Invoke-RegCommand' -ModuleName 'Tiny11.Actions.Registry' -MockWith { 0 } } + + It "writes 0 to each matching value name via 'reg add' under the z-mounted key" { + Mock -CommandName 'Get-Tiny11RegValueNames' -ModuleName 'Tiny11.Actions.Registry' -MockWith { + @('SubscribedContent-338388Enabled', 'SubscribedContent-338389Enabled') + } + $action = @{ type='registry-pattern-zero'; hive='NTUSER'; key='Software\Microsoft\Windows\CurrentVersion\ContentDeliveryManager'; namePattern='SubscribedContent-*Enabled'; valueType='REG_DWORD' } + Invoke-RegistryPatternZeroAction -Action $action -ScratchDir 'C:\s' + Should -Invoke -CommandName 'Invoke-RegCommand' -ModuleName 'Tiny11.Actions.Registry' -Times 2 -Exactly + Should -Invoke -CommandName 'Invoke-RegCommand' -ModuleName 'Tiny11.Actions.Registry' -ParameterFilter { + $RegArgs[0] -eq 'add' -and + $RegArgs[1] -eq 'HKLM\zNTUSER\Software\Microsoft\Windows\CurrentVersion\ContentDeliveryManager' -and + $RegArgs -contains '/v' -and + $RegArgs -contains '/t' -and $RegArgs -contains 'REG_DWORD' -and + $RegArgs -contains '/d' -and $RegArgs -contains '0' -and $RegArgs -contains '/f' + } + } + It "is a no-op when no value names match (absent key or no matches)" { + Mock -CommandName 'Get-Tiny11RegValueNames' -ModuleName 'Tiny11.Actions.Registry' -MockWith { @() } + $action = @{ type='registry-pattern-zero'; hive='NTUSER'; key='K'; namePattern='SubscribedContent-*Enabled'; valueType='REG_DWORD' } + Invoke-RegistryPatternZeroAction -Action $action -ScratchDir 'C:\s' + Should -Invoke -CommandName 'Invoke-RegCommand' -ModuleName 'Tiny11.Actions.Registry' -Times 0 -Exactly + } + It "passes the native z-key and the namePattern through to the reg.exe enumerator" { + Mock -CommandName 'Get-Tiny11RegValueNames' -ModuleName 'Tiny11.Actions.Registry' -MockWith { @() } + $action = @{ type='registry-pattern-zero'; hive='NTUSER'; key='Software\X'; namePattern='SubscribedContent-*Enabled'; valueType='REG_DWORD' } + Invoke-RegistryPatternZeroAction -Action $action -ScratchDir 'C:\s' + Should -Invoke -CommandName 'Get-Tiny11RegValueNames' -ModuleName 'Tiny11.Actions.Registry' -ParameterFilter { + $Key -eq 'HKLM\zNTUSER\Software\X' -and $NamePattern -eq 'SubscribedContent-*Enabled' + } + } + It "enumerates via reg.exe (Get-Tiny11RegValueNames), never the .NET provider" { + Mock -CommandName 'Get-Tiny11RegValueNames' -ModuleName 'Tiny11.Actions.Registry' -MockWith { @() } + Mock -CommandName 'Get-Item' -ModuleName 'Tiny11.Actions.Registry' -MockWith { throw 'provider must not be used on offline hives' } + Mock -CommandName 'Test-Path' -ModuleName 'Tiny11.Actions.Registry' -MockWith { throw 'provider must not be used on offline hives' } + $action = @{ type='registry-pattern-zero'; hive='NTUSER'; key='K'; namePattern='X*'; valueType='REG_DWORD' } + { Invoke-RegistryPatternZeroAction -Action $action -ScratchDir 'C:\s' } | Should -Not -Throw + Should -Invoke -CommandName 'Get-Tiny11RegValueNames' -ModuleName 'Tiny11.Actions.Registry' -Times 1 + } + It "still validates: throws on a non-NTUSER hive" { + $action = @{ type='registry-pattern-zero'; hive='SOFTWARE'; key='K'; namePattern='X*'; valueType='REG_DWORD' } + { Invoke-RegistryPatternZeroAction -Action $action -ScratchDir 'C:\s' } | Should -Throw '*only supports NTUSER*' + } + It "still validates: throws on a non-DWORD/QWORD value type" { + $action = @{ type='registry-pattern-zero'; hive='NTUSER'; key='K'; namePattern='X*'; valueType='REG_SZ' } + { Invoke-RegistryPatternZeroAction -Action $action -ScratchDir 'C:\s' } | Should -Throw '*REG_DWORD / REG_QWORD*' + } +} diff --git a/tests/Tiny11.Core.Tests.ps1 b/tests/Tiny11.Core.Tests.ps1 index 9eabc57..b22d28d 100644 --- a/tests/Tiny11.Core.Tests.ps1 +++ b/tests/Tiny11.Core.Tests.ps1 @@ -193,7 +193,7 @@ Describe 'Get-Tiny11CoreRegistryTweaks' { $coreSource = Get-Content (Join-Path $PSScriptRoot '..' 'src' 'Tiny11.Core.psm1') -Raw # No reg.exe 'add' call should pass $t.Value directly without escape; # all such call sites must use $regValue (the escaped variant). - $coreSource | Should -Not -Match "reg\.exe' 'add'[^|]*'/d' \`$t\.Value '/f'" + $coreSource | Should -Not -Match "'add'[^|]*'/d' \`$t\.Value '/f'" # ConfigureStartPins (with embedded ") must be in the tweaks table. ($script:tweaks | Where-Object Name -eq 'ConfigureStartPins').Value | Should -Match '"pinnedList"' } diff --git a/tests/Tiny11.Hives.Tests.ps1 b/tests/Tiny11.Hives.Tests.ps1 index 3cfe4c9..4c56a1e 100644 --- a/tests/Tiny11.Hives.Tests.ps1 +++ b/tests/Tiny11.Hives.Tests.ps1 @@ -36,21 +36,95 @@ Describe "Mount-/Dismount-Tiny11Hive" { } } +Describe "Get-Tiny11RegValueNames (reg.exe enumeration, no .NET registry provider)" { + # v1.0.30 dismount-lock fix: pattern-zero (and any offline-hive value enumeration) + # must read value names via reg.exe, never Get-Item/Get-ItemProperty/Test-Path on the + # HKLM:\z* provider drive -- the provider caches an in-process hive handle that locks + # Dismount-WindowsImage -Save. These tests pin the reg.exe-only contract + the parser. + It "returns only the value names matching the pattern, parsed from reg query output" { + Mock -CommandName 'Invoke-Tiny11RegExe' -ModuleName 'Tiny11.Hives' -MockWith { + [pscustomobject]@{ ExitCode = 0; Output = @( + '' + 'HKEY_LOCAL_MACHINE\zNTUSER\Software\Microsoft\Windows\CurrentVersion\ContentDeliveryManager' + ' SubscribedContent-338388Enabled REG_DWORD 0x1' + ' SubscribedContent-338389Enabled REG_DWORD 0x1' + ' RotatingLockScreenEnabled REG_DWORD 0x1' + ' ContentDeliveryAllowed REG_DWORD 0x1' + '' + ) } + } + $names = Get-Tiny11RegValueNames -Key 'HKLM\zNTUSER\K' -NamePattern 'SubscribedContent-*Enabled' + $names | Should -HaveCount 2 + $names | Should -Contain 'SubscribedContent-338388Enabled' + $names | Should -Contain 'SubscribedContent-338389Enabled' + $names | Should -Not -Contain 'RotatingLockScreenEnabled' + } + It "returns empty when the key is absent (reg query non-zero exit) -- the no-op case" { + Mock -CommandName 'Invoke-Tiny11RegExe' -ModuleName 'Tiny11.Hives' -MockWith { [pscustomobject]@{ ExitCode = 1; Output = @() } } + Get-Tiny11RegValueNames -Key 'HKLM\zNTUSER\Missing' -NamePattern '*' | Should -HaveCount 0 + } + It "parses value names that contain spaces (anchors on the 4-space + REG_ column)" { + Mock -CommandName 'Invoke-Tiny11RegExe' -ModuleName 'Tiny11.Hives' -MockWith { + [pscustomobject]@{ ExitCode = 0; Output = @(' My Value Name REG_SZ hello world') } + } + Get-Tiny11RegValueNames -Key 'HKLM\zNTUSER\K' -NamePattern '*' | Should -Be 'My Value Name' + } + It "ignores the key-path header line and blank lines" { + Mock -CommandName 'Invoke-Tiny11RegExe' -ModuleName 'Tiny11.Hives' -MockWith { + [pscustomobject]@{ ExitCode = 0; Output = @('HKEY_LOCAL_MACHINE\zNTUSER\K', '', ' OnlyValue REG_DWORD 0x0') } + } + Get-Tiny11RegValueNames -Key 'HKLM\zNTUSER\K' -NamePattern '*' | Should -Be 'OnlyValue' + } + It "enumerates via reg.exe (Invoke-Tiny11RegExe) and never via the provider" { + Mock -CommandName 'Invoke-Tiny11RegExe' -ModuleName 'Tiny11.Hives' -MockWith { [pscustomobject]@{ ExitCode = 0; Output = @() } } + Mock -CommandName 'Get-Item' -ModuleName 'Tiny11.Hives' -MockWith { throw 'provider must not be used on offline hives' } + Mock -CommandName 'Get-ItemProperty' -ModuleName 'Tiny11.Hives' -MockWith { throw 'provider must not be used on offline hives' } + { Get-Tiny11RegValueNames -Key 'HKLM\zNTUSER\K' } | Should -Not -Throw + Should -Invoke -CommandName 'Invoke-Tiny11RegExe' -ModuleName 'Tiny11.Hives' -Times 1 + } +} + +Describe "Test-Tiny11HiveLoaded (reg.exe, no provider)" { + It "true when reg query exits 0 (hive loaded)" { + Mock -CommandName 'Invoke-Tiny11RegExe' -ModuleName 'Tiny11.Hives' -MockWith { [pscustomobject]@{ ExitCode = 0; Output = @() } } + Test-Tiny11HiveLoaded -Hive 'SOFTWARE' | Should -BeTrue + } + It "false when reg query exits non-zero (hive not loaded)" { + Mock -CommandName 'Invoke-Tiny11RegExe' -ModuleName 'Tiny11.Hives' -MockWith { [pscustomobject]@{ ExitCode = 1; Output = @() } } + Test-Tiny11HiveLoaded -Hive 'SOFTWARE' | Should -BeFalse + } + It "queries the z-prefixed mount key via reg.exe" { + Mock -CommandName 'Invoke-Tiny11RegExe' -ModuleName 'Tiny11.Hives' -MockWith { [pscustomobject]@{ ExitCode = 0; Output = @() } } + Test-Tiny11HiveLoaded -Hive 'NTUSER' | Out-Null + Should -Invoke -CommandName 'Invoke-Tiny11RegExe' -ModuleName 'Tiny11.Hives' -ParameterFilter { + $RegArgs -contains 'query' -and $RegArgs -contains 'HKLM\zNTUSER' + } + } +} + Describe "Clear-Tiny11StaleHives" { # v1.0.26: a build that fails with hives still loaded strands HKLM\z* and bricks every # subsequent build at `reg load` ("Access is denied"). Build-start recovery unloads any # stranded z-hive, best-effort, and must never throw (a hive genuinely in use is warned, not fatal). + # v1.0.30: detection is via Test-Tiny11HiveLoaded (reg.exe), not Test-Path (provider). It "unloads only the z-hives currently present, and never throws on a failed unload" { # Simulate zSOFTWARE + zSYSTEM stranded-loaded from a prior failed build; others absent. - Mock -CommandName 'Test-Path' -ModuleName 'Tiny11.Hives' -MockWith { $LiteralPath -in 'HKLM:\zSOFTWARE','HKLM:\zSYSTEM' } + Mock -CommandName 'Test-Tiny11HiveLoaded' -ModuleName 'Tiny11.Hives' -MockWith { $Hive -in 'SOFTWARE','SYSTEM' } Mock -CommandName 'Invoke-RegCommand' -ModuleName 'Tiny11.Hives' -MockWith { if (($RegArgs -join ' ') -like '*zSYSTEM*') { throw 'hive in use' }; 0 } { Clear-Tiny11StaleHives } | Should -Not -Throw Should -Invoke -CommandName 'Invoke-RegCommand' -ModuleName 'Tiny11.Hives' -Times 2 -Exactly -ParameterFilter { $RegArgs -contains 'unload' } } It "is a no-op when no z-hives are loaded" { - Mock -CommandName 'Test-Path' -ModuleName 'Tiny11.Hives' -MockWith { $false } + Mock -CommandName 'Test-Tiny11HiveLoaded' -ModuleName 'Tiny11.Hives' -MockWith { $false } Mock -CommandName 'Invoke-RegCommand' -ModuleName 'Tiny11.Hives' -MockWith { 0 } Clear-Tiny11StaleHives Should -Invoke -CommandName 'Invoke-RegCommand' -ModuleName 'Tiny11.Hives' -Times 0 -Exactly } + It "detects stale hives via reg.exe (Test-Tiny11HiveLoaded), never the provider" { + Mock -CommandName 'Test-Tiny11HiveLoaded' -ModuleName 'Tiny11.Hives' -MockWith { $false } + Mock -CommandName 'Test-Path' -ModuleName 'Tiny11.Hives' -MockWith { throw 'provider must not be used to detect stale offline hives' } + Mock -CommandName 'Invoke-RegCommand' -ModuleName 'Tiny11.Hives' -MockWith { 0 } + { Clear-Tiny11StaleHives } | Should -Not -Throw + Should -Invoke -CommandName 'Test-Tiny11HiveLoaded' -ModuleName 'Tiny11.Hives' -Times 5 -Exactly + } } diff --git a/tests/Tiny11.OfflineHive.NoProvider.Drift.Tests.ps1 b/tests/Tiny11.OfflineHive.NoProvider.Drift.Tests.ps1 new file mode 100644 index 0000000..58d0003 --- /dev/null +++ b/tests/Tiny11.OfflineHive.NoProvider.Drift.Tests.ps1 @@ -0,0 +1,44 @@ +# Regression guard for the v1.0.30 dismount-lock fix. +# +# The offline build process must NEVER touch a loaded offline hive (HKLM\z) through +# the .NET PowerShell registry provider -- i.e. the "HKLM:\z*" (or HKU:\z* / HKCU:\z*) +# PSDrive paths used with Get-Item / Get-ItemProperty / Set-ItemProperty / New-Item / +# Test-Path. The provider caches an in-process RegistryKey handle that survives `reg unload` +# and keeps the hive's backing file (inside the mount) open, so Dismount-WindowsImage -Save +# fails with "The process cannot access the file because it is being used by another process" +# (the v1.0.26..v1.0.29 build break). All offline-hive access must go through reg.exe +# (Get-Tiny11RegValueNames / Test-Tiny11HiveLoaded / Invoke-RegCommand), whose child process +# closes its handle on exit -- the reg.exe-only pattern of upstream tiny11builder and +# Microsoft's offline-servicing docs. +# +# This static scan fails if a provider-drive reference to a z-prefixed offline hive +# ("HK{LM,U,CU}:\z") reappears in any src module. It is intentionally narrow: the z-prefix +# is what marks a *mounted offline* hive (live-registry paths like HKLM:\SOFTWARE or +# HKU:\ used in the emitted post-boot scripts are fine and are NOT matched). Matches +# that fall after a '#' on the line are treated as explanatory comments and ignored. + +Set-StrictMode -Version Latest + +Describe 'Offline-hive servicing uses reg.exe only (no .NET registry provider)' { + BeforeAll { + $script:srcDir = Join-Path (Split-Path $PSScriptRoot -Parent) 'src' + } + + It 'no src module references an offline-hive provider drive (HK*:\z)' { + $pattern = 'HK(LM|U|CU):\\z' + $offenders = foreach ($file in (Get-ChildItem -LiteralPath $script:srcDir -Filter '*.psm1')) { + foreach ($mi in (Select-String -LiteralPath $file.FullName -Pattern $pattern -AllMatches)) { + foreach ($m in $mi.Matches) { + # Skip matches that appear after a '#' on the line (explanatory comments). + if ($mi.Line.Substring(0, $m.Index) -notmatch '#') { + '{0}:{1}: {2}' -f $file.Name, $mi.LineNumber, $mi.Line.Trim() + } + } + } + } + $offenders | Should -BeNullOrEmpty -Because ( + "offline hives must be serviced via reg.exe, not the .NET registry provider " + + "(an HK*:\z* provider handle locks Dismount-WindowsImage -Save). Offenders:`n" + + ($offenders -join "`n")) + } +} From c9ce118e152524f8db3765f390c3db71f8c03725 Mon Sep 17 00:00:00 2001 From: Jamie Chapman <104535858+bilbospocketses@users.noreply.github.com> Date: Sun, 31 May 2026 01:07:51 -0400 Subject: [PATCH 2/2] docs(contributing): fix Invoke-RegCommand module path + record reg.exe-only offline-hive convention --- CONTRIBUTING.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c16d05..bf49de8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,7 +69,8 @@ tiny11options/ - **PowerShell:** verb-noun cmdlet naming; `Set-StrictMode -Version Latest` at module top; `$ErrorActionPreference = 'Stop'`; no aliases in scripts (`Get-ChildItem`, not `gci`) - **C# 13 / .NET 10:** nullable reference types enabled; file-scoped namespaces; primary constructors welcome; `dotnet format` clean before commit -- **No PowerShell string interpolation into native commands** — use parameter binding / argument arrays; `Invoke-RegCommand` (in `src/Tiny11.Registry.psm1`) wraps `reg.exe` safely +- **No PowerShell string interpolation into native commands** — use parameter binding / argument arrays; `Invoke-RegCommand` (in `src/Tiny11.Hives.psm1`) wraps `reg.exe` safely +- **Offline registry hives: `reg.exe` only — never the .NET registry provider.** Read/write loaded offline hives via `reg.exe` (`Invoke-RegCommand` / `Get-Tiny11RegValueNames` / `Test-Tiny11HiveLoaded`), never `Get-Item`/`Set-ItemProperty`/`Test-Path` on `HKLM:\z*`. The provider caches an in-process hive handle that survives `reg unload` and locks `Dismount-WindowsImage -Save` ("being used by another process"). Enforced by `tests/Tiny11.OfflineHive.NoProvider.Drift.Tests.ps1`. Build-process `reg.exe`/`dism.exe`/`robocopy.exe` resolve from the absolute `%SystemRoot%\System32` path (`Get-Tiny11RegExePath` / `Get-Tiny11DismExePath`), not `%PATH%`. - **Logging:** use the launcher's bridge-routed logger; do not `Write-Host` in PS modules (breaks the UI bridge); use `Write-BridgeLog -Level Info/Warning/Error` - **No AI-generated attribution lines** in commit messages