Add the offline repair foundation helpers (1 of 3) - #143
Conversation
278255f to
ac25b02
Compare
ac25b02 to
d384fae
Compare
OverviewMarcus I suggest to split this in at least 3 pr in that way we reduce the iteration of the review
Files
Static validation performed locally
🔴 CriticalRescue VM can be targeted instead of the offline disk
Command injection / unsanitized input
Deletion safety weaker than the PR claims
Registry hive handling does not deliver its headline guarantee
Ownership/ACL restore can be lost
Nested Hyper-V disk hand-off
🟡 ImportantPortability — affects every fileAll seven helpers bootstrap with a CWD-relative dot-source: . .\src\windows\common\helpers\OfflineRepairCommon.ps1
Fix: Affected lines: Failure indistinguishable from success
Other important findings
🔵 Suggestions
Operational Risk Assessment
Overall Risk: 🔴 High VerdictRequest changes. The engineering quality here is genuinely above the repo norm — the buffered-logging design correctly solves the real problem that The problem is that the failure paths do not uphold the guarantees the headers advertise, and these scripts run as SYSTEM against a disk that is already unbootable:
Recommended before merge
Process noteThe batching rationale in the PR description is sound — landing helpers first keeps the 20 follow-on scenario PRs independently reviewable. However, 4893 lines of security-sensitive, SYSTEM-privileged code in a single review is a lot to hold. Consider splitting into two or three PRs along dependency lines ( Overlap with in-flight work
|
d384fae to
9850982
Compare
Reply to the #143 review — point-by-point matrixPoint-by-point reply. Every finding in the review is answered below. Thank you for this review. It was specific, it was correct, and it was clearly a lot of work — every You identified the thing that ties the whole set together better than I had: nothing bound these What changed structurallyThe PR is now three PRs, which addresses your point about surface area. 7 files and +4893 lines
They are independent and can merge in any order, because no I also split If you would prefer a different split, say so before you start reading — restructuring the One deliberate deviation, flagged up frontYou asked for the offline root to be a required parameter, hard-rejecting anything outside it. I Requiring it as a parameter changes ~40 function signatures across 60+ call sites, because the Instead the root is bound once and asserted everywhere:
It throws. It never warns and never returns Public entry points also take an optional If you would still rather have the mandatory parameter, I will do it — it is mechanical, just wide.
|
| Your finding | Status | What I did |
|---|---|---|
38-98 — $script: state binds to the sourcing scope |
Fixed | Moved to a shared global via Get-OfflineRepairState. You offered $global: or New-Module; I took $global: because this codebase dot-sources helpers from inside functions, and each az vm repair run is a fresh process so there is nothing to leak into. Clear-OfflineRepairRoot exists for tests |
| 70-76 — buffer cleared before entries are written | Fixed | Flushes first, clears in finally, and falls back to Write-Output when init.ps1 was not sourced. Previously a missing Log-Info discarded the whole buffer unwritten |
128-131 — Join-OfflinePath -Root '\' passes the guard |
Fixed, then fixed again | See below — my first fix for this caused the worst regression in the whole change |
206 — unanchored O=Microsoft Corporation |
Fixed | Anchored on the RDN boundary: (?:^|,)\s*O=Microsoft Corporation\s*(?:,|$). The subject that motivated it — CN=O=Microsoft Corporation, O=Evil Corp — no longer matches |
210-223 — IsMicrosoft = $true from the unsigned version resource and on UnknownError |
Fixed, and split three ways | See below — the literal fix would have caused a regression |
| CWD-relative dot-source | Fixed | $PSScriptRoot, wrapped in try/catch { throw } |
| new | — | Set-OfflineRepairRoot, Get-OfflineRepairRoot, Clear-OfflineRepairRoot, Register-OfflineHiveKey, Unregister-OfflineHiveKey, Get-OfflineHiveKey, Assert-OfflineTarget |
Join-OfflinePath — the fix for your finding was worse than the finding
This is the one I would most like you to check, because I got it wrong and only caught it on a real
rescue VM.
Your finding was correct: -Root '\' passed the emptiness check and produced a root-relative path.
My first fix required the root to match the volume-root pattern
^([A-Za-z]:|\\\\[^\\]+\\[^\\]+)$. That does reject '\' — and it also rejects 'E:\Windows'.
Join-OfflinePath is called 77 times, and 33 of those pass a nested root: everything built under
$windowsPath, $ConfigPath, $windowsRoot, $ScratchDir and $regBack. All of them silently
returned $null. Test-OfflinePath $null is $false, so every one of those paths reported file not
present — a repair would look at a broken system, find nothing to fix, and report success. That is
strictly worse than the bug I was fixing, and it is exactly the silent-degradation class your review
was written about.
Two things are worth saying about how it was found, because neither was code review:
- The unit tests did not catch it. They asserted the guard rejected
'\', which it did. Nothing
asserted the guard still accepted an ordinary path. A test that only proves the door is shut does
not prove you can still walk through it. - The rescue-VM run passed 13 of 14 and still exposed it. The offline hive probe reported
SYSTEM hive present = False, SOFTWARE hive present = Falsefor a volume the very next assertion
proved heldE:\Windows. Two outputs that cannot both be true. I only had that string to look at
because of theProbeStatusfield added in the previous round, which itself came from noticing an
empty product name in an otherwise green 11/14 run.
The corrected guard uses a separate pattern,
^([A-Za-z]:|\\\\[^\\]+\\[^\\]+)(\\[^\\]+)*$ — volume-qualified at any depth. A repair root, which
genuinely must be a volume root, still uses the original pattern. It is also worth recording that
ConvertTo-OfflineComparablePath strips leading separators, so '\' collapses to empty and is
refused before any pattern is consulted; the pattern's real job is to catch the residue, such as
'\Windows' arriving as 'Windows'.
There are now 11 cases on this function — 9 behavioural, covering both directions — and the three
nested-root ones fail against the tree that carried the regression.
Having shipped one over-tight guard, I audited the rest rather than assuming it was isolated. Every
ValidatePattern left in the helpers constrains a drive letter, which genuinely is a volume root.
The one remaining allowlist is the ValidateSet of hive names, so I resolved all 55 -Hive
arguments across the tree by AST — including the array and variable forms, such as
$servicingHive, which is @('SOFTWARE','COMPONENTS') — and every one falls inside the set. No
second instance.
Test-OfflineFileSignature — fixed, but not literally
Setting IsMicrosoft = $false on both fallbacks is correct, and I did it. But applied on its own it
breaks three callers, so I want to explain what else had to move.
Offline, most inbox binaries are catalog signed, and the catalogs that would verify them are on the
offline image — not registered on the rescue VM. So Get-AuthenticodeSignature cannot prove a
perfectly healthy winlogon.exe. Three scenarios were using IsMicrosoft to decide whether it was
safe to act, and a strict flag would have made them refuse to repair healthy VMs — and made
win-fix-bcd rebuild the boot store on every one of them.
So the signal is now three fields:
| Field | Meaning |
|---|---|
IsMicrosoft |
Cryptographically proven: valid Authenticode and an anchored Microsoft RDN |
IsLikelyMicrosoft |
Proven, or claimed by the version resource — and never true when Authenticode actively said the file is bad |
Confidence |
High when Authenticode answered definitively, Low when only the version resource was available, None when nothing could be established |
IsSigned is also now true only for a valid Authenticode signature; the version-resource path used
to claim it. 'CatalogSigned' is preserved as a status string because a caller matches on it.
Measured on real binaries:
| File | Status | IsMicrosoft |
IsLikelyMicrosoft |
Confidence |
|---|---|---|---|---|
bootmgfw.efi (healthy) |
Valid |
✔ | ✔ | High |
kernel32.dll (healthy) |
Valid |
✔ | ✔ | High |
kernel32.dll with one byte flipped |
HashMismatch |
✘ | ✘ | High |
| valid signature, non-Microsoft signer | Valid |
✘ | ✘ | High |
| unsigned junk, no version resource | NotVerifiable |
✘ | ✘ | None |
Note the third row: a tampered Microsoft binary still carries CompanyName = Microsoft Corporation,
and IsLikelyMicrosoft correctly refuses it, because a definitive Authenticode answer overrides the
version-resource claim. That is what makes the advisory field safe to use.
The callers were updated accordingly: the paths that decide whether to act use
IsLikelyMicrosoft, and the path that decides whether a boot manager is damaged requires
Confidence -eq 'High' -and -not $signature.IsMicrosoft.
Get-OfflineBcdStore.ps1
| Your finding | Status | What I did |
|---|---|---|
392-395 — cmd.exe /c with an interpolated command string |
Fixed | Invoke-BcdEdit now takes [string[]]$Arguments and calls & bcdedit.exe /store $StorePath @Arguments. No shell |
51-53 — $BootDrive unvalidated |
Fixed | [ValidatePattern('^[A-Za-z]:\\?$')], plus a new Assert-OfflineBcdStorePath that rejects an unrooted path, rejects the rescue VM's own system drive outright, and defers to Assert-OfflineTarget once a root is bound |
197, 232, 338 — bcdedit exit code never inspected |
Fixed | New Invoke-BcdEnum returns Success/ExitCode/Text; Get-BcdInventory gained Exists, EnumSucceeded and EnumExitCode, so "the store could not be opened" is no longer indistinguishable from "the store is empty" — which mattered, because an empty inventory is what makes win-fix-bcd decide to rebuild |
| 367 — backup copy unverified | Fixed | -ErrorAction Stop, then the copy is confirmed to exist and to match the source length, before anything is logged as backed up |
You were right that this was the most serious one. The identifiers reaching Invoke-BcdEdit are
parsed out of the broken VM's own store, so they are attacker-controlled. Measured both ways with a
payload of {default} & echo PWNED-INJECTION-RAN:
OLD (cmd.exe /c, interpolated): the injected command executed
NEW (direct invocation, array): ARG=</set>
ARG=<{default} & echo PWNED-INJECTION-RAN>
ARG=<recoveryenabled>
ARG=<No>
-> 0 executions, payload delivered as one literal argument
win-fix-bcd.ps1 was updated for the new signature (3 call sites).
Use-OfflineRegistryHive.ps1
| Your finding | Status | Detail |
|---|---|---|
Mount loop sits outside the try (184-195) |
Fixed | Mounting moved inside the try; each acquired hive tracked and unwound in reverse by the finally, so a second-hive failure no longer strands the first |
Scriptblock output can keep a RegistryKey alive (196-210) |
Fixed | Output is walked once and only live registry objects are replaced with disconnected snapshots exposing the same value members; everything else is returned by reference |
Depth entry removed before dismount; $null = discards the failure (203-207) |
Fixed | Entry removed only after a confirmed unload; failures collected and Invoke-WithHive throws naming the hives still loaded and the reg unload command to clear them |
Non-zero reg query treated as "not loaded → success" (119-122) |
Fixed | Key state is now Present / Absent / Unknown; only the genuine not-found message means Absent, so access-denied is never read as a clean unload |
| No verification the hive is gone after unload (134-149) | Fixed | Unload retries and then re-queries; returns $true only on a confirmed absence |
%TEMP% hive copy is unprotected credential material (341-371) |
Fixed | Per-run directory created empty and locked to SYSTEM + Administrators with inheritance disabled before any bytes are written; surviving files reported as an error |
Fixed BROKEN<HIVE> mount key (75, 116) |
Fixed differently | See below |
Mount key — kept, with the risk closed a different way
You asked for a per-run unique key. I measured the blast radius first: 35 references across 13
scenario scripts hardcode HKLM:\BROKEN<HIVE> inside their scriptblocks (for example
'HKLM:\BROKENSYSTEM\Setup'). A random key would have been 35 edits and a large regression surface
for no gain inside a single SYSTEM-owned run.
The real risk you identified — inheriting a stale or foreign mount — is instead closed fail-closed:
an existing key is reused only after Test-OfflineFileInUse proves it is backed by this disk's hive
file, and otherwise the helper throws and names the reg unload command. The unique-key idiom is
used where nothing depends on the name: the scratch validation copy mounts as RSLVALIDATE<guid8>.
Evidence the output-capture change is safe. All 53 Invoke-WithHive call sites across 15
scenario files were enumerated by AST and classified — not sampled. 40 consume the return value:
37 return a detached [PSCustomObject], 2 return Get-ItemProperty output, and 1 returns an array of
strings. Zero return a live RegistryKey, so no scenario script needed an edit. Verified against
the real shapes with a read-only registry read: a Get-ItemProperty bag keeps its values with the
provider link severed; arrays and PSCustomObjects are not rebuilt at all.
Use-OfflineFileRemoval.ps1
| Your finding | Status | Detail |
|---|---|---|
| "Two-layer" guard is extension-only on both layers (94-99, 510) | Fixed | Base-name veto added: a file is refused when GetFileNameWithoutExtension matches any HiveName, ordinal-ignore-case, before the extension logic runs |
| Nothing binds removal to the offline disk (156, 262-266) | Fixed | Optional -OfflineRoot; Assert-OfflineTarget on the set root and on every resolved file path; reparse points are no longer followed out of the image |
Rollback enumerates with -ErrorAction SilentlyContinue (400) |
Fixed | -ErrorAction Stop; success now requires Failed -eq 0 -and Restored -eq Expected; a missing or unreadable backup folder is an explicit failure |
Rollback uses plain Copy-Item (404) |
Fixed | Restores route through Copy-OfflineProtectedFile |
| Failed rollback invisible to the caller (646, 664) | Fixed | RollbackAttempted / RollbackSucceeded / RollbackDetail added; a failed rollback returns a distinct fatal outcome |
| Backup folder reused across runs (582-583) | Fixed | Per-run folder Label_PID_timestamp, hard-fail if non-empty |
| Post-check 4 is tautological (486-492) | Fixed | Re-tests the filesystem directly instead of deriving the verdict from the variable it audits |
| Post-check 6 reports PASS when no hive was loaded (510-522) | Fixed | Reports INCONCLUSIVE as a distinct status, logged at Warning and carried into the verdict |
| Rollback restores bytes but not security (331-336, 407-409) | Fixed | Owner and DACL recorded in binary form and replayed; SDDL was rejected because machine-relative aliases would re-resolve on the rescue VM |
| Extensionless exclusion is undocumented but load-bearing (95) | Fixed | Documented in .DESCRIPTION and covered by tests — it is what keeps a bare SYSTEM safe when a caller passes no HiveName |
Evidence the veto does not turn the scenarios into no-ops. This was the real risk: both callers
pass MatchExtension = @('.blf','.regtrans-ms'), and real Windows TxR files carry a GUID and a
.TM/.TxR infix, so their base name never equals a hive name. Proven with a 12-case table:
REFUSE SYSTEM / SYSTEM.LOG1 / SYSTEM.blf / SYSTEM.regtrans-ms / SYSTEM.SAV
REFUSE system.BLF (casing) SYSTEM. (trailing dot) BCD-Template.blf (hyphen)
remove SYSTEM{guid}.TM.blf <- REAL TxR file, must stay removable
remove SOFTWARE{guid}.TMContainer.regtrans-ms
remove {guid}.TxR.blf <- REAL config\TxR file
remove unrelated.blf
12 of 12 passed
Use-NestedRepairVm.ps1
| Your finding | Status | Detail |
|---|---|---|
Connect-NestedRepairVmDisk passed $DiskNumber, not $offlined (418) |
Fixed | Passes the disks actually offlined |
No try/finally; four early returns leave disks offline (374-431) |
Fixed | Wrapped in try/finally. Restoration is scoped to the disks this call offlined: disks already offline at entry are not ours to hand back, and a guest that started owns its disks |
Already-running short-circuits to Started = $true (366-372) |
Fixed | The attach is verified before success is reported |
Failed Set-VMFirmware only warns (308-310) |
Fixed | Now a failure and a return; a Gen2 guest would otherwise PXE-boot into the 600 s timeout |
Catch does not return; later branches overwrite Reason (612-618) |
Fixed | Returns from the catch |
| Insufficient memory unhandled (425-431) | Fixed | Startup memory compared to free physical memory, retried reduced with dynamic memory |
| Wait loop misses terminal states (514) | Fixed | Exits on Off, Paused, Saved and PausedCritical |
vmms not required for Supported = $true (93) |
Fixed | Service must be running |
VM resolved by -Name, which is a wildcard (360, 622) |
Fixed | Resolved by -Id |
| Header omits two functions (26-35) | Fixed | Both added, .VERSION bumped |
Two defects were found beyond your list while fixing these, and are fixed too: the rescue VM's own
IsBoot/IsSystem disk could be taken offline, and an empty offline set would start a diskless
guest that reported Started = $true and then waited out its full heartbeat timeout — the same
"success without the work" shape as your 366-372 finding. Both ends now refuse.
Get-OfflineWindowsDisk.ps1
This is the file the whole contribution turned on. Every other helper trusted a drive letter that
this one produced, so its failure modes were the ones that reached the rescue VM's own C:.
| Your finding | Status |
|---|---|
Catch only warned, so $systemDiskNumber stayed -1, the -ne filter never excluded anything, and the rescue VM's own disk became a repair candidate |
Fixed. It now throws. This is the single most important change in the PR, and it deliberately converts some previously "successful" runs into loud failures |
FriendlyName -like '*Virtual Disk*' is SCSI-only, so NVMe disks were invisible |
Fixed, using the BusType selection from your #144. Credited below |
| Azure temporary/resource disk not excluded | Fixed — excluded by FileSystemLabel, plus IsBoot/IsSystem predicates on both candidate filters, so exclusion no longer depends on one lookup succeeding |
Unvalidated $DriveLetter interpolated into a diskpart script |
Fixed. ValidatePattern at the binding boundary, re-asserted after trimming. There is a test that feeds it a newline-injection payload |
diskpart exit code ignored; success logged unconditionally |
Fixed — exit code checked before anything is logged as done |
| Fixed 500 ms sleep after assigning a letter | Fixed — polls, and releases the letter if it never appears |
| Letters leaked on every run | Fixed — assignments are tracked and a cleanup function releases them from a caller's finally |
$Error.Clear() |
Removed |
| Candidate ordering not deterministic | Fixed — DiskNumber added to the sort keys |
It also now calls Set-OfflineRepairRoot for both the Windows root and the boot root as soon as a
volume is chosen, which is what arms Assert-OfflineTarget for every other helper. That is the
mechanism described in the deviation section.
Use-OfflineProtectedResource.ps1 and Use-OfflinePrivilegedRegistry.ps1
You asked for this file to be split. It is: the #region Privileged registry access block, seven
functions all named *OfflinePrivileged*, is now Use-OfflinePrivilegedRegistry.ps1. Only two
scenarios reach into that region, so each gained a single dot-source line. Nothing was lost or
duplicated in the move — the function count went from 31 to 26 + 7, and the two extra are new
functions described below, not copies.
| Your finding | Status | Detail |
|---|---|---|
| Privileges enabled with no check on the target (182-196, 743-752, 805, 1073, 1167) | Fixed | Assert-OfflineTarget before any privilege is enabled |
Grant-OfflineRegistryKeyAccess loses every capture if one key throws mid-subtree (235-328) |
Fixed | Captures append to a caller-owned list as each key is taken, so a later failure cannot discard the earlier ones |
Access-denied reported as Removed = $true (520-523) |
Fixed | -ErrorAction Stop; access-denied now falls through to the ownership path instead of claiming success |
| Restores counted, not verified (359-368, 884-893) | Fixed | Test-OfflineDescriptorMatch compares what was written back with what was captured |
| A key that cannot be reopened is silently skipped (362-363) | Fixed | That is now a failure |
| DACL round-tripped as SDDL (846-893) | Fixed for registry, deliberately not for files — see below | |
Grant catch does not restore, unlike 651 (534-537) |
Fixed | Mirrors 651 |
| Caller scriptblock output contaminates the result (640, 665) | Fixed | [void](& $Action) |
| File is too large (1210-1773) | Fixed | Split as above |
SDDL — fixed for registry keys, deliberately kept for file paths
You are right that SDDL is lossy: it re-resolves machine-relative aliases (LA, DA, DU, DC)
against whichever machine parses it, and the P/AI control flags do not survive. For registry keys
nothing outside the helper ever sees the descriptor, so those now capture and replay in binary
form and the problem is gone.
For file paths the SDDL string is an external contract, not an implementation detail. Scenario
scripts already consume it: win-fix-rdp-certificate.ps1 parses it in about ten places
(RawSecurityDescriptor::new($Sddl) at 256, Get-OfflineSddlOwner at 604-605,
Restore-OfflinePathSecurity -Sddl at 342 and 355), and win-fix-catalog-store.ps1 passes it
through at 826→856. Changing that return type would have been a silent breaking change across
already-validated repairs. So the SDDL string stays, and an optional -BinaryDescriptor was added
alongside it for callers that want the lossless form. I would rather migrate those call sites in the
scenario PRs, where each one can be re-validated, than change the contract underneath them here.
One finding beyond your list
Restore-OfflinePathSecurity enabled SeTakeOwnershipPrivilege on a caller-supplied path with
nothing checking where that path pointed — the same shape as the findings above, and the last
ungated one. It is gated now. The gate sits after the empty-descriptor and missing-path guards on
purpose: two of its four call sites restore from a catch or a finally
(win-fix-catalog-store.ps1:856, win-fix-rdp-certificate.ps1:617), and a gate that threw there
would replace the operator's real error with a complaint about paths. All four sites restore a path
that a gated Grant-OfflinePathAccess had already accepted, so in practice the gate cannot fire —
but the ordering means it could not mask anything even if it did.
The three remaining functions that enable the ownership privilege have no scenario callers and are
reached only through gated entry points. Backup-OfflineFile is deliberately left ungated: it
writes to a scratch folder that is intentionally outside the offline root, where the gate would be
actively wrong. There is a test asserting this invariant structurally, so a future function that
takes ownership without gating will fail the suite rather than be caught by review.
PSScriptAnalyzer
You recorded this as "NOT RUN — module unavailable". It is available (1.24.0), so it was run and the
findings on the helpers are resolved. All eight helpers are new files in this contribution, so none of
these findings is inherited — they are ours.
| Helper | Lines | Findings, all severities |
|---|---|---|
OfflineRepairCommon.ps1 |
715 | 0 |
Get-OfflineBcdStore.ps1 |
579 | 0 |
Get-OfflineWindowsDisk.ps1 |
992 | 0 |
Use-OfflineRegistryHive.ps1 |
771 | 0 |
Use-OfflineProtectedResource.ps1 |
1584 | 0 |
Use-OfflinePrivilegedRegistry.ps1 (new, from the split) |
670 | 0 |
Use-OfflineFileRemoval.ps1 |
1013 | 0 |
Use-NestedRepairVm.ps1 |
902 | 0 |
That is zero at every severity, not merely at Warning and Error — Information included. The
three other files in common\helpers\ (Get-Disk-Partitions.ps1, Get-Disk-Partitions-v2.ps1,
Logger.ps1) still report findings; those are pre-existing and not part of this contribution, so
they were left alone.
Three functions were renamed for PSUseSingularNouns (10 references updated). One of those renames
is worth calling out because the obvious name was wrong: Get-OfflineControlSetNames became
Get-OfflineReferencedControlSetName, not Get-OfflineControlSetName, because that name is
already taken by a different function returning the active control set. Collapsing them would have
left the later definition silently winning, and win-fix-boot-partition.ps1 — which wants every
referenced set — would have quietly received the active one instead.
Two findings are suppressed with a justification rather than changed, because the rule's remedy would
be actively wrong:
PSAvoidGlobalVarsonGet-OfflineRepairState. Explained in the deviation section above: a
$script:variable binds to the scope that dot-sourced the file, so the gate would fail open.PSUseShouldProcessonSet-OfflineRepairRoot. It changes in-process state only. A-WhatIf
that skipped the bind would leave no root registered, so every laterAssert-OfflineTargetwould
throw for a reason unrelated to what the operator asked about.
Test evidence
A defect-level test suite was written, one case class per bug you reported, each asserting the
defect is gone rather than merely exercising the function. Current state: 108 of 108 pass.
| Defect class | Cases |
|---|---|
| offline-root gate | 10 |
| signature trust | 5 |
| bcdedit injection | 4 |
| hive base-name veto | 11 |
| nested-VM disk hand-off | 4 |
| disk selection | 4 |
| qualified path join | 11 |
| hive probe visibility | 6 |
| drive-letter tracking | 7 |
| drive-letter cleanup | 7 |
-WhatIf safety |
15 |
| ownership gate | 2 |
| dependency loading | 22 |
The later classes cover defects that your review did not raise but that fixing it surfaced, and
they are worth naming because they are the same kind of bug you were objecting to:
- hive probe visibility. Found by the lab run, not by reading the code — and it is the finding I
would most want you to look at, because it is your review's central complaint reproducing itself
one layer down.Get-OfflineWindowsInstallCandidatemounts the offlineSOFTWAREhive to read
ProductNameand the build number, which are worth 10 and up to 30 points of the score that
decides which installation gets repaired when a disk carries more than one. It discarded the
result ofreg.exe loadwith$null =, tested only$LASTEXITCODE, and had noelsebranch — so
a hive it could not read was indistinguishable from one it read fine. The run passed every
assertion while silently scoring the candidate on partial data. It now records aProbeStatuson
every path, warns, and returns it to the caller. It deliberately still does not throw: an
unreadableSOFTWAREhive is a fault this library exists to repair, so refusing to proceed would
be the wrong failure mode. The unload path had the same flaw and matters more — a probe hive that
will not unload holds the offline hive file locked for the rest of the run. - drive-letter tracking. The list of letters the run assigned was returned with
return $list.
PowerShell unrolls a returned collection, so an empty list came back as$nulland a one-element
list came back as a fixed-sizeObject[]. Registering the first temporary drive letter during
discovery would therefore have thrown. Fixed with a unary comma; the tests exercise the real
round-trip rather than matching on source text. - drive-letter cleanup.
Clear-OfflineDriveLetterexists to be called from a caller's
finally, but one failing release aborted the loop — leaking every remaining letter at exactly
the moment it mattered, after a repair had already failed. It now releases each letter
independently, untracks only what it actually released, names what is still stuck, and never
throws. The test makes the middle of three releases fail, so it proves cleanup continues past
a failure rather than merely surviving one at the end. -WhatIfsafety. Five functions take host disks offline, release drive letters or stop a
guest. None declaredSupportsShouldProcess, so-WhatIfperformed them for real. All five now
guard their first mutation and return their normal result shape withStarted/Stoppedleft
$false, so a preview cannot be mistaken for a completed operation.ConfirmImpactis
deliberately left at the default on every one of them — these run non-interactively as SYSTEM
underaz vm repair run, andHighwould block forever on a prompt nobody can answer. There is
an explicit test asserting that, so it cannot regress.- ownership gate. A structural invariant rather than a fixed list: any helper function that
enables the take-ownership privilege and is reachable from a scenario script must call
Assert-OfflineTarget. Reachability is resolved from the AST, because the helper name also
appears inside a comment inwin-fix-pending-servicing.ps1and a text search treats that as a
call site. - dependency loading. Covered below.
Two things about the suite are worth stating plainly, because they are what makes it evidence rather
than decoration.
It was scored against the code you reviewed. Running the same suite against dd148b3 — the tree
as it stood when you wrote the review — gives 25 passed, 83 failed, against 108 passed, 0
failed now:
| Defect class | Reviewed code | Now |
|---|---|---|
| offline-root gate | 7 / 10 | 10 / 10 |
| signature trust | 0 / 5 | 5 / 5 |
| bcdedit injection | 2 / 4 | 4 / 4 |
| hive base-name veto | 0 / 11 | 11 / 11 |
| nested-VM disk hand-off | 0 / 4 | 4 / 4 |
| disk selection | 0 / 4 | 4 / 4 |
| qualified path join | 5 / 11 | 11 / 11 |
| hive probe visibility | 0 / 6 | 6 / 6 |
| drive-letter tracking | 0 / 7 | 7 / 7 |
| drive-letter cleanup | 2 / 7 | 7 / 7 |
-WhatIf safety |
5 / 15 | 15 / 15 |
| ownership gate | 0 / 2 | 2 / 2 |
| dependency loading | 4 / 22 | 22 / 22 |
I am not claiming all 108 discriminate. The 25 that pass against the old code fall into three groups,
and it is worth being exact about which:
- Vacuous under baseline. Where the function did not exist at all, an assertion of the form
"this must throw" or "this must not report success" is satisfied for the wrong reason — a missing
command throws too. The sevenoffline-root gatepasses and the twodrive-letter cleanuppasses
are this. In the fixed tree they are meaningful; in the baseline they only show the function was
absent, which the other cases in the same class record properly as failures. - Guarding a future regression rather than a past defect. The five
-WhatIfpasses assert that
ConfirmImpactis notHigh. The old code had noSupportsShouldProcessat all, so that was
trivially true. Their job is to stop someone "hardening" these functions later into a prompt that
a SYSTEM run can never answer, so they are supposed to pass in both trees. - Testing a regression the review predates. Five of the eleven
qualified path joincases pass
against your tree, and they should: three of them assert that an ordinary nested root still joins,
which was true before I broke it. That class is not evidence about the code you reviewed — it is
evidence about the code I wrote afterwards. Its real mutation check is the tree that carried the
regression, where exactly those three fail.
Assertions that could only ever be satisfied by matching source text were mutation-checked. The
ownership-gate invariant is the one that matters: deleting the Assert-OfflineTarget call from an
in-memory copy of Restore-OfflinePathSecurity flips the case to FAIL. A test that cannot fail is
not being counted as a pass.
Dependency loading
Your finding was that every helper dot-sourced with a current-directory-relative path. Fixing it
surfaced something worse: three helpers had no dependency loading at all and silently relied on
the caller having sourced the core first. That mattered most in the file that takes ownership —
discovering Assert-OfflineTarget is absent part-way through a privileged operation is far worse
than refusing to load.
All eight now resolve siblings against $PSScriptRoot, skip anything already defined so a scenario
that sources them in order does not load twice, and re-check the sentinels afterwards so a
dependency that loaded but did not define what was needed is still an error at load time. Verified
by loading each helper in its own fresh process with the working directory set to C:\, which has
no src\windows tree: 8 of 8 load.
The scenario scripts keep their existing repository-root-relative form. They belong to separate pull
requests, and rewriting twenty of them inside a helpers-only change would mix concerns.
The disk-selection fix, measured on a real machine
The change I am least comfortable asking you to take on trust is Get-OfflineWindowsDisk throwing
where it used to warn, because it turns runs that previously "succeeded" into loud failures. So I
scored the old and new selection predicates against real Get-Disk output on an Azure-hosted Windows
machine, read-only — no disk was brought online, no letter assigned, no diskpart run:
Disks visible:
#0 BusType=SAS IsBoot=True IsSystem=True TempStorage=False 2048GB
[PASS] System disk resolves (throw does not fire) SystemDrive C: is on disk 0
[PASS] Own system disk excluded from candidates candidates = []; system disk = 0
[PASS] No boot/system disk is a candidate boot/system disks = [0]
[PASS] Pre-fix filter would not have excluded the OS disk
with sentinel -1 the -ne filter excludes
nothing; old model match admitted [0]
[PASS] Probe changed no disk state (read-only) 0 differences
The fourth line is the finding you raised, reproduced rather than asserted: that machine's disk 0 is
its own boot and system disk, and the pre-fix predicate — FriendlyName -like '*Virtual Disk*'
with $systemDiskNumber still at its -1 sentinel — admitted it as a repair candidate. The
first line is the answer to the obvious objection to the new throw: the precondition it guards
resolves normally, so it does not fire on a healthy machine.
That machine reports BusType = SAS, incidentally, which is a second argument for selecting on bus
type rather than a model string.
The gate, proven on a real rescue VM
The read-only probe above cannot answer the question your review actually asks, which is whether the
gate holds when the helpers are running as SYSTEM on a rescue VM with a broken disk attached. So the
foundation branch was run end to end on one: az vm repair create against a lab VM, then the checks
executed through az vm run-command as SYSTEM, exactly the way az vm repair run invokes a script.
[PASS] Helpers load from an unrelated working directory :: cwd=C:\Windows
[PASS] Get-OfflineWindowsDisk succeeded (no fail-closed throw) :: Windows Server 2022 Datacenter build 20348 on disk 2 Gen2
[PASS] Selected volume is NOT the rescue VM system drive :: selected=E: rescue=C:
[PASS] Selected disk is NOT the rescue VM system disk :: selected disk=2
[PASS] Offline root was bound by disk selection :: root=Y:,E:
[PASS] Gate ACCEPTS a path on the attached disk
[PASS] Gate REJECTS a path on the rescue VM :: tried C:\Windows\System32\config\SYSTEM
[PASS] Assigned drive letters are tracked for cleanup :: tracked=[Z:,Y:]
[PASS] Selected volume really holds a Windows installation :: E:\Windows
[PASS] Offline hive probe reports its own status :: ProbeStatus=OK
[PASS] Offline hive probe read product name and build :: ProductName='Windows Server 2022 Datacenter' Build='20348' Guest='win2022b'
[PASS] Probe left no hive mounted :: leaked=[]
[PASS] Clear-OfflineDriveLetter released every letter :: before=[Z:,Y:] stillTracked=[]
[PASS] Rescue VM system drive still present :: C:
LAB RESULT: 14/14 passed
The seventh line is the one worth having: running as SYSTEM, with every privilege available to it,
Assert-OfflineTarget refused C:\Windows\System32\config\SYSTEM — the rescue VM's own live
registry, and the exact target the five findings you grouped together would have reached.
It took three runs, and the two failures were worth more than the pass. The first was 11/11
green while quietly reporting an empty product name and build; that led to the hive-probe swallow.
The second was 13/14 and reported both hives absent on a volume that demonstrably had E:\Windows;
that contradiction is what exposed the Join-OfflinePath regression described earlier. Neither was
visible from reading the code, and neither would have been caught by a suite that only tests what it
already suspects.
What I have not validated
I would rather list this than let you assume a clean bill of health.
- The scenario scripts are not in these PRs. Three call sites change behaviour and are validated
only by unit test and call-site audit so far:Invoke-BcdEdit's-Command→-Arguments
(win-fix-bcd.ps1, 3 sites), the new dot-source ofUse-OfflinePrivilegedRegistry.ps1
(win-fix-firewall-service.ps1,win-fix-user-rights.ps1), andInvoke-WithHive's output
capture (17 scenarios; all 53 call sites audited by AST, none returns a liveRegistryKey). Those
scripts come in their own PRs and I will validate each end-to-end there. - The binary-form ACL round-trip has not been re-run against a broken image. Switching registry
descriptors from SDDL toGetSecurityDescriptorBinaryFormis a behaviour change on repairs that
were previously validated —win-fix-catalog-storeandwin-fix-secure-bootare the two that
exercise it. The unit tests cover the round-trip; a real break/repair/verify pass does not exist
yet. Restore-OfflineRegistrySecurity's deleted-versus-stuck-key branch is untested live. It needs
a key that cannot be reopened after a privileged removal, and I will not enable those privileges on
a workstation to manufacture one.- The nested Hyper-V path is unit-tested, not lab-tested.
Use-NestedRepairVmis in Add the offline removal, BCD and nested-VM helpers (3 of 3) #147 and
needs a rescue VM with nested virtualisation to exercise for real.
If you would rather any of these be evidenced before you read the corresponding PR, tell me which and
I will do that one first.
On #144
Your NVMe finding was correct and I have adopted your BusType selection, matching
Get-Disk-Partitions-v3. All three branches here are cut from main after #144 merged, so
they add files alongside Get-Disk-Partitions-v3.ps1 and leave your README entry for it intact.
Your live-validation notes on #144 also caught two defects that were present in my code in a
different form — the drive-letter timing and the helper returning its own log lines as data — so that
PR was useful to me twice over.
Get-OfflineWindowsDisk and Get-Disk-Partitions-v3 overlap but are not duplicates: yours returns
every partition of every attached disk, mine identifies the Windows installation to repair and
binds it as the offline root. I have said so in the README rather than leaving a reader to guess
which to reach for. If you would rather I layer mine on top of Get-Disk-Partitions-v3 instead of
running its own Get-Disk pass, that is a small change and I am happy to make it — the selection
predicate is already the same one.
9850982 to
cd5787a
Compare
OfflineRepairCommon.ps1 provides the primitives every offline repair script shares: buffered logging that keeps the verdict last, path joining and validation, signature inspection that distinguishes a proven Microsoft signature from a version resource merely claiming one, and the offline-target gate. The gate is the reason these are one pull request. Offline repair scripts run as SYSTEM on a rescue VM whose own healthy Windows is at C:, so a precondition that degrades silently turns a privileged delete into one aimed at the rescue VM itself. Get-OfflineWindowsDisk binds the offline volume once it has resolved it, and Assert-OfflineTarget throws for any path outside it - and throws when nothing is bound at all, so failing to bind cannot fail open. Get-OfflineWindowsDisk.ps1 finds the Windows installation to repair. It selects disks by BusType rather than a SCSI-only model string, for the same reason Get-Disk-Partitions-v3 does; excludes the Azure resource disk; refuses to return the rescue VM own boot or system disk; and throws rather than warns when it cannot identify that disk, because a warning left the rescue disk as a repair candidate. It also tracks the temporary drive letters it assigns to hidden EFI System and Recovery partitions so a caller can release them. Both files report zero PSScriptAnalyzer findings at every severity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 518d6d32-f227-4d1a-ae36-1e0be233b09b
cd5787a to
911de95
Compare
Follow-up: the four gaps I listed as unvalidated are now closed — and two things I said were wrongMy previous reply ended with a "What I have not validated" section. Leaving those open would Correction 1 — "can be merged in any order" was false when I wrote itAll three descriptions said the pull requests are independent and mergeable in any order. I asserted That reasoning was wrong, and I only found out because I measured it instead of re-reading it. All Simulated with
0 of 6 clean. Whichever went in first was fine; the second always stopped. In every case the Fixed by giving PR A sole ownership of the README. It now carries the complete twelve-row table I also ran the same test against the previously published branches to confirm it can actually fail: If you would rather each PR carried only its own rows and you resolved the trivial README conflict Correction 2 — I overstated what SDDL losesI justified the binary-form ACL round-trip by saying an SDDL string can drop the protected (P) Measuring it, the P/AI half did not reproduce. I am not able to stand behind it, so it is What did reproduce is the alias half, and it is a stronger argument for the same design:
One further note on measuring this, in case it saves you time later: SDDL string equality is not a The four gaps, closed
On the merged-tree check, two caveats so the number is not read as more than it is. 25 calls are Four more defects, found while closing those gaps1. 2 and 3. Two post-checks in Fixing that exposed a knock-on: the success message hard-coded the explanation for check 6 ("no hive 4. An empty Two other empty catches in that file I deliberately left: both are cleanup in a Evidence as it now stands
Every test is mutation-checked against the reviewed tree, so a passing number means the defect is All three PRs are MERGEABLE with zero deletions, so none of them can revert your merged #144. |
…o bcdedit Addresses the first tranche of review feedback on Azure#143. OfflineRepairCommon.ps1 Adds the offline target gate the review asked for. Set-OfflineRepairRoot binds the volume that was selected for repair and Register-OfflineHiveKey records each mounted hive; Assert-OfflineTarget then refuses any path that is not under one of them. It throws rather than warning, and it throws when nothing is bound at all, so a precondition that degrades quietly can no longer redirect a privileged take-own-and-delete at the rescue VM's own C:. State moved from $script: to a shared global. A dot-sourced $script: variable binds to the scope of whoever sourced the file, so a helper sourced from inside a function kept its own private root list and the gate would have asserted against a set the caller never populated. Test-OfflineFileSignature no longer claims IsMicrosoft on the strength of an unsigned version resource, and its Microsoft subject match is anchored on the RDN boundary so that CN=O=Microsoft Corporation, O=Somebody Else no longer passes. The signal is split three ways instead: IsMicrosoft is cryptographically proven, IsLikelyMicrosoft also accepts the version resource claim, and Confidence reports whether Authenticode answered at all. Write-OfflineRepairLog flushes before it clears rather than after, and falls back to Write-Output when init.ps1 was not sourced, so buffered messages are no longer discarded unwritten. Join-OfflinePath validates its root, so -Root '\' no longer produces a rescue-VM-relative path. Get-OfflineBcdStore.ps1 Invoke-BcdEdit no longer builds a command line and hands it to cmd.exe. Every identifier it is given was parsed out of the broken VM's own store, so a crafted store could run arbitrary commands on the rescue VM as SYSTEM. It now takes an argument array and invokes bcdedit directly; a payload such as '{default} & echo PWNED' arrives as one literal argument. Measured both ways: the old shape executed the injected command, the new shape passed it through intact. Store paths are validated, a store on the rescue VM's own system drive is refused, bcdedit exit codes are inspected on the read paths so a store that cannot be opened is no longer indistinguishable from an empty one, and Backup-BcdStore verifies the copy before reporting success. Scenario callers win-fix-bcd, win-fix-logon-subsystem and win-fix-inaccessible-boot-device are updated for the split trust signal. Catalog signed inbox binaries cannot be proven from the rescue VM, because the catalogs that would verify them live on the offline image, so the paths that decide whether to act use IsLikelyMicrosoft while the path that decides whether a boot manager is damaged requires a definitive Authenticode answer. Without this the strict IsMicrosoft would have made win-fix-bcd rebuild the store on healthy VMs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…pers Addresses the review on Azure#143 for the nested-VM and file-removal helpers. Both shared a failure mode the reviewer called out repeatedly: reporting success for work that was never done. Use-NestedRepairVm.ps1 - Connect-NestedRepairVmDisk received $DiskNumber, the raw request, instead of the disks actually offlined, so a guest could be started with a disk it could never claim. - The offlining sequence had four early returns and no try/finally, leaving the rescue VM's disks offline whenever a start failed. Restoration is now scoped to the disks this call took offline: disks already offline at entry are not this function's to hand back, and a guest that started owns its disks. - Disks the rescue VM boots from (IsBoot/IsSystem) are never taken offline. - An empty offline set no longer starts a diskless guest that reports Started = $true and then waits out its full heartbeat timeout. Both Start-NestedRepairVm and Connect-NestedRepairVmDisk now refuse and say which disks were skipped and why. - A failed Set-VMFirmware is a failure, not a warning; a Gen2 guest would otherwise PXE-boot into the timeout. - The boot wait exits on every terminal state, including PausedCritical, which means the host is out of memory and the guest will never reach a heartbeat. - Guests are resolved by Id; Get-VM -Name treats its argument as a wildcard. Use-OfflineFileRemoval.ps1 - The "two-layer" guard checked extensions on both layers, so protection depended entirely on the caller naming every risky extension. A hive base-name veto now refuses SYSTEM.LOG1, SYSTEM.blf and SYSTEM.SAV intrinsically. Verified against the real Windows TxR filenames, which carry a GUID and a .TM/.TxR infix and must stay removable. - Removal is bound to the offline image with Assert-OfflineTarget, both for the set root and for every resolved file, and reparse points are no longer followed out of it. - Rollback enumerated with -ErrorAction SilentlyContinue, so a missing backup folder reported Restored=0, Failed=0 and looked like success. It now fails loudly, restores through Copy-OfflineProtectedFile so de-protected files can be put back, replays the recorded owner and DACL in binary form, and surfaces a distinct fatal outcome when it cannot. - Backup folders are per-run, so a second run cannot overwrite the first run's only copy of the removed files. - Post-check 4 no longer derives its verdict from the variable it is meant to audit, and post-check 6 reports INCONCLUSIVE rather than PASS when no hive could be loaded to check. Verified: parse clean; PSScriptAnalyzer -Severity Warning,Error reports no new findings (Use-OfflineFileRemoval is now clean, Use-NestedRepairVm retains its two pre-existing PSUseShouldProcess warnings); the base-name veto passes a 12-case table including the real TxR filenames; all 53 Invoke-WithHive call sites audited and unaffected. No Hyper-V, disk or registry operation was run against a live machine. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 518d6d32-f227-4d1a-ae36-1e0be233b09b
Addresses the review on Azure#143 for the registry hive helper. The theme is the same as the rest of the set: the helper reported success for work it had not done, and left the offline hive mounted on the rescue VM when it did. - The mount loop sat outside the try, so a failure mounting the second hive stranded the first one and left the depth counter claiming it was still held. Mounting now happens inside the try, each acquired hive is tracked, and the finally unwinds exactly those in reverse order. - A single live Microsoft.Win32.RegistryKey returned by a caller's scriptblock keeps a handle open and makes reg unload fail. Every Get-Item, Get-ItemProperty and Get-ChildItem result is bound to the registry provider through its PSDrive, so this was reachable from ordinary scenario code. The block's output is now walked once and only live registry objects are replaced with disconnected snapshots exposing the same value members; everything else is returned by reference. Verified against the three shapes the 53 call sites actually return: a Get-ItemProperty property bag keeps its values with the provider link severed, and arrays and PSCustomObjects are not rebuilt at all. - The depth entry was removed before the dismount, and $null = discarded whether the dismount had worked, so in-memory state said the hive was gone while it was still mounted. The entry is now removed only after a confirmed unload, failures are collected, and Invoke-WithHive throws naming the hives that are still loaded and the reg unload command to clear them. - A non-zero reg query was treated as "not loaded, therefore success", so an access-denied answer was read as a clean unload. Key state is now Present, Absent or Unknown, and only the genuine not-found message means Absent. - Unloading is retried and then re-queried, so it returns true only on a confirmed absence rather than on the exit code alone. - Test-OfflineHiveFile copied hive bytes into %TEMP% before securing the directory. SAM and SECURITY are in scope for this helper, so that is credential material sitting on the rescue VM. The per-run directory is now created empty and locked to SYSTEM and Administrators with inheritance disabled before any bytes are written, the scratch mount is unloaded through the verified path, and surviving files are reported as an error. - The fixed BROKEN<HIVE> mount key is deliberately kept: 35 references across 13 scenario scripts hardcode it, so a per-run random key would have been 35 edits and a large regression surface for no gain inside a single SYSTEM-owned run. The stale-mount risk is instead closed fail-closed - an existing key is reused only after it is proven to be backed by this disk's hive file, and refused otherwise. Verified: parse clean; PSScriptAnalyzer -Severity Warning,Error reports the same single pre-existing PSUseSingularNouns finding as HEAD and nothing new; all 53 Invoke-WithHive call sites audited, none returns a live RegistryKey, so no scenario script needs an edit. No hive was mounted and no registry key was written on this machine; the snapshot behaviour was proven with a read-only HKLM read. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 518d6d32-f227-4d1a-ae36-1e0be233b09b
…region Addresses the PR Azure#143 review findings on Use-OfflineProtectedResource.ps1. Assert-OfflineTarget is now called before any privilege is enabled, so a helper can no longer take ownership of a path or key on the rescue VM itself when a precondition has silently degraded. Restore-OfflinePathSecurity was the last gap: it enabled SeTakeOwnershipPrivilege on a caller-supplied path with nothing checking where that path was. The gate is placed after the empty-descriptor and missing-path guards, so the two call sites that restore from a catch or a finally still surface the original error instead of being masked by a gate throw. Restores are now verified rather than counted. Test-OfflineDescriptorMatch and Get-OfflineRawOwner compare the descriptor that was written back with the one that was captured, and descriptors round-trip in binary form for registry keys: SDDL parsed on another machine does not preserve machine-relative aliases such as LA, DA, DU and DC, nor the P and AI control flags. SDDL is kept as the external contract for file paths because scenario scripts parse it directly. The privileged registry region is extracted to Use-OfflinePrivilegedRegistry.ps1 (7 functions), which the review asked for on size grounds. Only two scenarios reach into that region, so each gains one dot-source line. Both files report zero PSScriptAnalyzer findings at every severity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 518d6d32-f227-4d1a-ae36-1e0be233b09b
…lete Addresses the PR Azure#143 review findings on Get-OfflineWindowsDisk.ps1. Disk selection no longer treats an unresolvable rescue system disk as a warning. Previously the catch only warned, $systemDiskNumber stayed -1, the -ne filter therefore excluded nothing, and the rescue VM own disk became a repair candidate. It now throws, and both candidate filters additionally exclude anything marked IsBoot or IsSystem. Disks are selected by BusType rather than a FriendlyName match on *Virtual Disk*, which only ever matched SCSI-attached disks and missed NVMe entirely. The Azure temporary/resource disk is excluded by volume label. Selection is sorted by DiskNumber so it is deterministic. The drive letter passed to diskpart is validated by pattern and re-asserted after trimming, diskpart exit codes are checked instead of assuming success, and assignment polls for the volume rather than sleeping a fixed 500 ms. Assigned letters are tracked and returned so a caller can release them. Get-OfflineAssignedDriveLetterList returns the list with a unary comma: PowerShell unrolls a returned collection, so an empty list came back as $null and a one-element list as a fixed-size Object[], and .Add() threw on the FIRST letter assigned during discovery rather than only at cleanup. Clear-OfflineDriveLetter is written for a caller finally block. It releases each letter in its own try/catch, so one stuck letter no longer abandons the rest; it untracks only what it actually released, keeps the remainder for a later attempt, names them in a single warning, and never throws - a throw there would replace the real repair exception. Zero PSScriptAnalyzer findings at every severity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 518d6d32-f227-4d1a-ae36-1e0be233b09b
Addresses the PR Azure#143 review finding that every helper dot-sourced its dependencies with a path relative to the current directory. That form only resolves when the caller happens to be sitting at the repository root, so a helper dot-sourced from anywhere else failed in a way that looked like a missing function rather than a missing file. Three helpers - Use-OfflineProtectedResource, Use-OfflineFileRemoval and Use-NestedRepairVm - had no dependency loading at all and silently relied on the caller having sourced the core first. That mattered most in the file that takes ownership: discovering Assert-OfflineTarget is absent part-way through a privileged operation is far worse than refusing to load. Each now resolves its siblings against its own folder, skips anything already defined so a scenario that sources them in order does not load twice, and re-checks the sentinels afterwards so a dependency that loaded but did not define what is needed is still an error here rather than a failure mid-repair. Verified by loading each of the eight helpers in its own fresh process with the working directory set to C:\, which has no src\windows tree: 8/8 load. All eight report zero PSScriptAnalyzer findings at every severity. The scenario scripts keep their existing repository-root-relative form. They belong to separate pull requests, and rewriting twenty of them here would mix concerns with a helpers-only change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 518d6d32-f227-4d1a-ae36-1e0be233b09b
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1b2a597
into
Azure:main
What this adds
The two foundation helpers that every offline repair scenario in this series depends on.
OfflineRepairCommon.ps1Get-OfflineWindowsDisk.ps1No
map.jsonentries are added. Helpers are not run-ids, so nothing new is invocable yet.This is #143, narrowed
This pull request is the original #143 reduced to its foundation. The review on it asked for a
smaller reviewable unit, and the helpers split cleanly along their real dependency lines into three
independent pull requests:
rsl-pr1OfflineRepairCommon.ps1,Get-OfflineWindowsDisk.ps1rsl-helpers-registryUse-OfflineRegistryHive.ps1,Use-OfflineProtectedResource.ps1,Use-OfflinePrivilegedRegistry.ps1rsl-helpers-repairUse-OfflineFileRemoval.ps1,Get-OfflineBcdStore.ps1,Use-NestedRepairVm.ps1They can be merged in any order. B and C dot-source this one, but because no
map.jsonentrypoints at any helper, a helper that dot-sources a not-yet-merged helper is not reachable by
az vm repair runand so cannot break a shipped run-id. The twenty scenario pull requests thatfollow do require all three, and they come afterwards.
The problem the review found, and what actually fixes it
The review's most serious findings all had one cause: nothing bound these helpers to the offline
disk. They run as SYSTEM on a rescue VM whose own healthy Windows is at
C:, and every helpertrusted an ambient drive letter. A precondition that degraded quietly — a disk lookup that failed and
only warned, for instance — could redirect a privileged take-ownership-and-delete at the rescue VM
itself.
The fix is in this pull request, because this is where the drive letter is produced:
Get-OfflineWindowsDisk.ps1nowthrows instead of warning when it cannot establish which diskbelongs to the rescue VM, and excludes that disk by
IsBoot/IsSystemon both candidatefilters, so exclusion no longer depends on a single lookup succeeding.
Set-OfflineRepairRoot, binding the offline root for the run.Assert-OfflineTargetinOfflineRepairCommon.ps1is the gate every destructive helper callsbefore enabling a privilege. It throws — it never warns and never returns
$false— and itthrows when nothing is bound, so a helper used without discovery fails closed rather than
defaulting to the rescue VM.
This is a deliberate deviation from the literal review comment, which asked for the offline root as a
required parameter on each function. Taken literally that changes roughly forty signatures across
sixty-plus call sites. Binding once and asserting everywhere gives the same guarantee — no privileged
write outside the bound root — without that churn, and an optional
-OfflineRootparameter on thepublic entry points still lets a caller be explicit. The reply on #143 argues this in full.
Other findings fixed here: NVMe disks are now visible (
BusTypeselection, adopted from #144), theAzure temporary disk is excluded,
$DriveLetteris validated before it reaches a diskpart script,actual disk state is checked before anything is logged as ready, temporary drive letters are
tracked and released from a caller's
finally, and candidate ordering is deterministic.September 10 review follow-up
Set-OfflineDisksOnlinere-readsGet-Diskafter diskpartand counts a disk only when it is online and writable. An already-ready disk receives no writes.
Failed preparation also excludes the disk from installation discovery. On the live fixture,
diskpart returned 0 while a read-only-backed VHD stayed read-only; the helper correctly
refused to count it.
offreg.dllreader inOfflineRepairCommon.ps1. Recovery stays in memory, so there is noreg.exe load/unload, HKLM probe key or weaker duplicate unload implementation. Add the offline registry and protected-resource helpers (2 of 3) #146 uses thissame reader for
Test-OfflineHiveFile. Read failures remain visible inProbeStatus; a failedclose throws. The existing writable HKLM-based repair APIs remain unchanged.
Temporary Storagelabel orDATALOSS_WARNING_README.txtat a volume root excludes the disk. Existing drive letters,partition access paths and volume GUID paths are supported without mounting a disk.
try/catch/finallyskeleton that logs errors, releases assigned letters, flushes buffered messages and returns
$STATUS_ERRORor$STATUS_SUCCESSlast. It distinguishes installation discovery from thepartition-only v3 helper using the reviewer's suggested wording.
Relationship to
Get-Disk-Partitions-v3This branch includes upstream #144 and its #145 follow-up. It adds files alongside
Get-Disk-Partitions-v3.ps1without reverting its fixes or README entry.The two overlap but are not duplicates.
Get-Disk-Partitions-v3returns every partition of everyattached disk;
Get-OfflineWindowsDiskidentifies the Windows installation to repair, scores itwhen there is more than one, locates its boot partition and BCD store, and binds it as the offline
root. Both select disks by
BusType; installation discovery additionally requires verifiedwrite readiness and binds the offline target. The README explains which helper to use.
Conventions followed
healthy image produces no changes.
az vm run-commandkeeps only the final 4096characters of the output stream.
Testing
latter on the lab VM. They cover misleading diskpart exits, exclusions, zero writes on healthy
disks, resource markers, metadata degradation, checked cleanup, native scalar reads and the
exact README skeleton on success and discovery/dependency failures.
exercised offline/read-only fault → preparation → discovery → hidden-partition letter cleanup.
Discovery ran before loading Add the offline registry and protected-resource helpers (2 of 3) #146. Ten hive types opened, including four dirty hives with their
logs; SHA-256 hashes, timestamps and file inventories stayed unchanged, and no probe/validation
HKLM mounts appeared. A read-only attachment reproduced diskpart's masked failure. The VHD and
VSS snapshot were removed, and the original disks' states were preserved.
az vm repair createattached-OS-disk validation is recorded in theSeptember 8 reply.
Today's disposable-VHD helper run is not presented as
--previewvalidation of all twentyscenarios. Those run-ids ship and receive end-to-end validation separately.