diff --git a/src/windows/common/helpers/Get-OfflineWindowsDisk.ps1 b/src/windows/common/helpers/Get-OfflineWindowsDisk.ps1 new file mode 100644 index 00000000..eda7abeb --- /dev/null +++ b/src/windows/common/helpers/Get-OfflineWindowsDisk.ps1 @@ -0,0 +1,1044 @@ +<# +.SYNOPSIS + Helper functions that locate and prepare the offline Windows installation on a + broken OS disk attached to a rescue VM. + +.DESCRIPTION + 'az vm repair create' attaches the broken OS disk to a rescue VM as a data disk. + Before any offline repair can run, the disk must be brought online, every partition + that matters must be reachable through a drive letter, and the correct Windows + installation must be selected when the disk carries more than one. + + This helper does all of that. Unlike Get-Disk-Partitions.ps1 it also assigns + temporary drive letters to partitions that have none (EFI System and Recovery + partitions), which offline boot repairs need. + + Exposed functions: + Get-OfflineWindowsDisk Main entry point. Returns the resolved offline install. + Set-OfflineDisksOnline Bring attached virtual data disks online and writable. + Add-PartitionDriveLetter Assign a free drive letter to a partition via diskpart. + Get-FreeDriveLetter Return the next unused drive letter. + Remove-OfflineDriveLetter Release one drive letter this run assigned. + Clear-OfflineDriveLetter Release every drive letter this run assigned (finally). + Stop-NestedRepairVm Stop a nested Hyper-V repair VM holding the disk. + + Get-OfflineWindowsDisk sets $script:OfflineWindowsDrive, which the offline registry + hive helper (Use-OfflineRegistryHive.ps1) uses as its default Windows path. + +.NOTES + Name: Get-OfflineWindowsDisk.ps1 + Requires: common/setup/init.ps1 to be dot-sourced first (for the Log-* functions). + These functions return values, so they buffer their messages with Add-OfflineRepairLog + instead of calling Log-* directly. Call Write-OfflineRepairLog at script level to flush. + The rescue VM's own system disk is always excluded from the search. + Once a volume is chosen, Set-OfflineRepairRoot binds it, so every other helper's + Assert-OfflineTarget gate can prove it is acting on the broken disk and not the rescue VM. + +.VERSION + v1.0: Initial version. + v1.1: Fail closed when the rescue VM system disk cannot be resolved. Select attached + disks by BusType (so NVMe disks are seen) rather than by model name, and exclude + any boot/system disk and the 'Temporary Storage' resource disk. Validate the drive + letter passed to diskpart against command injection. Check the diskpart exit code + before reporting an online as successful. Poll for an assigned letter instead of a + fixed sleep, and track assigned letters so Remove-OfflineDriveLetter and + Clear-OfflineDriveLetter can release them. Add DiskNumber to the sort keys for a + deterministic selection, and bind the chosen volume as the offline repair root. + v1.2: Declare SupportsShouldProcess on the state-changing helpers (Set-OfflineDisksOnline, + Stop-NestedRepairVm, Remove-OfflineDriveLetter) and guard each mutation with + $PSCmdlet.ShouldProcess, so they honour -WhatIf. ConfirmImpact is left at the default + (Medium), below the default $ConfirmPreference (High), so non-interactive SYSTEM runs + are unchanged and never block on a prompt. Return the assigned-letter tracking list + with a unary comma so it is not unrolled to $null (empty) or a detached copy, which + otherwise made Register/Remove/Clear act on a throwaway rather than the shared list. + v1.3: Make Clear-OfflineDriveLetter safe to call from a finally block. It now releases each + letter in its own try/catch by delegating to Remove-OfflineDriveLetter (the single + guarded release path), so one letter that cannot be released no longer abandons the + rest, successfully-released letters are untracked individually instead of a blanket + Clear() that would also drop the failures, the letters still stuck are named in a + single Warning, and the function never throws. It declares SupportsShouldProcess so + -WhatIf flows into the delegated calls and its behaviour matches Remove-OfflineDriveLetter. + v1.4: Verify disk state after diskpart, skip writes to already-ready disks, and recognise + the resource disk by its language-independent warning file as well as its label. + Probe hive metadata in memory through offreg, without registry mounts. +#> + +if (-not (Get-Command Open-OfflineRegistryReader -ErrorAction SilentlyContinue)) { + try { + . (Join-Path $PSScriptRoot 'OfflineRepairCommon.ps1') + } + catch { + throw "Get-OfflineWindowsDisk.ps1 could not load its dependency OfflineRepairCommon.ps1 from '$PSScriptRoot': $($_.Exception.Message)" + } +} + +# QueryDosDevice reads the NT object namespace, which is the only place a drive letter +# that diskpart assigned to a hidden EFI System or Recovery partition can be observed. +# mountvol and Get-Partition both report the mount manager database instead, and neither +# lists those letters, so without this the helper cannot tell that a partition already +# has one and hands out a new letter on every run until the alphabet is exhausted. +if (-not ('RslOffline.NativeDosDevice' -as [type])) { + try { + Add-Type -Namespace RslOffline -Name NativeDosDevice -MemberDefinition @' +[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] +public static extern uint QueryDosDeviceW(string lpDeviceName, System.Text.StringBuilder lpTargetPath, int ucchMax); +'@ -ErrorAction Stop + } + catch { + # Falls back to the mountvol lookup, which is weaker but needs no compiler. + Add-OfflineRepairLog -Level Info -Message "QueryDosDevice is unavailable, so drive letter reuse falls back to mountvol: $($_.Exception.Message)" + } +} + +function Get-DosDeviceTarget { + <# + .SYNOPSIS + Returns the device a DOS device name points at, or an empty string. + + .PARAMETER Name + A DOS device name without the \\?\ prefix, such as 'K:' or 'Volume{guid}'. + + .OUTPUTS + A device name such as \Device\HarddiskVolume5, or '' when the name is undefined. + #> + param( + [Parameter(Mandatory = $true)][string]$Name + ) + + if (-not ('RslOffline.NativeDosDevice' -as [type])) { return '' } + + $buffer = New-Object System.Text.StringBuilder 1024 + $length = [RslOffline.NativeDosDevice]::QueryDosDeviceW($Name, $buffer, $buffer.Capacity) + if ($length -eq 0) { return '' } + + return $buffer.ToString() +} + +function Test-DriveLetterInUse { + <# + .SYNOPSIS + Returns $true when a drive letter is already taken. + + .DESCRIPTION + Get-Volume and Get-Partition do not report drive letters that were assigned to + hidden System or Recovery partitions, so the object namespace is consulted and + the root path is probed directly as well. A letter that is defined but not + reachable still counts as taken, because assigning over it would fail. + #> + param( + [Parameter(Mandatory = $true)][string]$DriveLetter + ) + + $letter = $DriveLetter.TrimEnd(':', '\') + if (-not [string]::IsNullOrWhiteSpace((Get-DosDeviceTarget -Name "${letter}:"))) { return $true } + if (Test-OfflinePath "${letter}:\") { return $true } + if (Get-PSDrive -Name $letter -PSProvider FileSystem -ErrorAction SilentlyContinue) { return $true } + if (Get-Partition -DriveLetter $letter -ErrorAction SilentlyContinue) { return $true } + return $false +} + +function Get-FreeDriveLetter { + <# + .SYNOPSIS + Returns the next unused drive letter, searching from Z: downwards. + #> + param( + [Parameter(Mandatory = $false)][string[]]$Exclude = @() + ) + + $excluded = @($Exclude | ForEach-Object { $_.TrimEnd(':', '\').ToUpperInvariant() }) + + # Z down to E. A-D are reserved for the rescue VM's own system and temporary disks. + foreach ($letter in ([char[]](90..69))) { + if ($excluded -contains "$letter") { continue } + if (-not (Test-DriveLetterInUse -DriveLetter "$letter")) { return "$letter" } + } + + throw 'No free drive letter is available on the rescue VM. Remove unused mount points with "mountvol : /d" and run the script again.' +} + +function Get-VolumeDriveLetterMap { + <# + .SYNOPSIS + Maps each volume GUID path to the drive letters currently mounted on it. + + .DESCRIPTION + Fallback used only when QueryDosDevice is unavailable. mountvol reports the + mount manager database, which does not contain letters that diskpart created + directly in the object namespace, so this is the weaker of the two sources. + + .OUTPUTS + Hashtable keyed by volume GUID path (no trailing backslash) whose values are + drive letter arrays in the form 'K:'. + #> + $map = @{} + $currentVolume = $null + + foreach ($line in @(mountvol.exe 2>$null)) { + $text = "$line".Trim() + + if ($text -match '^\\\\\?\\Volume\{[0-9a-fA-F-]+\}\\?$') { + $currentVolume = $text.TrimEnd('\') + if (-not $map.ContainsKey($currentVolume)) { $map[$currentVolume] = @() } + continue + } + + if (-not $currentVolume) { continue } + if ($text -match '^([A-Za-z]):\\?$') { $map[$currentVolume] += "$($Matches[1].ToUpperInvariant()):" } + } + + return $map +} + +function Get-DriveLetterDeviceMap { + <# + .SYNOPSIS + Maps every defined drive letter to the device it points at. + + .OUTPUTS + Hashtable keyed by drive letter in the form 'K:' whose values are device + names such as \Device\HarddiskVolume5. + #> + $map = @{} + + foreach ($letter in ([char[]](67..90))) { + $target = Get-DosDeviceTarget -Name "${letter}:" + if ([string]::IsNullOrWhiteSpace($target)) { continue } + $map["${letter}:"] = $target + } + + return $map +} + +function Get-PartitionExistingRoot { + <# + .SYNOPSIS + Returns the drive letters already usable for a partition, or an empty array. + + .DESCRIPTION + Reported access paths are confirmed before they are trusted, because a + partition can advertise a letter whose drive is no longer mounted. + + Hidden EFI System and Recovery partitions never report a drive letter at all, + so the partition's volume device is resolved instead and matched against the + device every defined drive letter points at. That recovers a letter assigned + by an earlier run, which is what stops each run from leaking two more letters + until the alphabet is exhausted. + #> + param( + [Parameter(Mandatory = $true)]$Partition, + [Parameter(Mandatory = $false)][hashtable]$LetterDeviceMap = @{}, + [Parameter(Mandatory = $false)][hashtable]$VolumeMap = @{} + ) + + $existing = @($Partition.AccessPaths | + Where-Object { $_ -and $_ -match '^[A-Za-z]:' } | + ForEach-Object { $_.TrimEnd('\').ToUpperInvariant() } | + Where-Object { Test-OfflinePath "$_\" }) + + if ($existing.Count -gt 0) { return @($existing | Select-Object -Unique) } + + $volumePaths = @($Partition.AccessPaths | Where-Object { $_ -and $_ -match '^\\\\\?\\Volume\{' }) + + # Preferred source: the object namespace, which holds letters mountvol cannot see. + foreach ($volumePath in $volumePaths) { + $device = Get-DosDeviceTarget -Name (($volumePath.TrimEnd('\')) -replace '^\\\\\?\\', '') + if ([string]::IsNullOrWhiteSpace($device)) { continue } + + # Sorted so repeated runs settle on the same letter for the same partition. + foreach ($letter in @($LetterDeviceMap.Keys | Sort-Object)) { + if ($LetterDeviceMap[$letter] -ne $device) { continue } + if (-not (Test-OfflinePath "$letter\")) { continue } + return @($letter) + } + } + + foreach ($volumePath in $volumePaths) { + $key = $volumePath.TrimEnd('\') + if (-not $VolumeMap.ContainsKey($key)) { continue } + + # All letters on one volume address the same file system, so the first + # usable one is enough and keeps later path building deterministic. + $recovered = @($VolumeMap[$key] | Where-Object { Test-OfflinePath "$_\" } | Select-Object -First 1) + if ($recovered.Count -gt 0) { return @($recovered) } + } + + return @() +} + +function Stop-NestedRepairVm { + <# + .SYNOPSIS + Stops a running nested Hyper-V VM so its VHD can be mounted offline. + + .DESCRIPTION + Only relevant when the repair VM was created with 'az vm repair create --enable-nested'. + Returns the names of the VMs that were stopped. Silently does nothing when the + Hyper-V role is not installed. + + SupportsShouldProcess is declared so -WhatIf reports each VM it would turn off. + ConfirmImpact is left at the default (Medium), below the default $ConfirmPreference + (High), so the non-interactive SYSTEM run under az vm repair proceeds without a prompt. + #> + [CmdletBinding(SupportsShouldProcess)] + [OutputType([System.Object[]])] + param() + + $stopped = @() + + if (-not (Get-Command Get-VM -ErrorAction SilentlyContinue)) { return $stopped } + + try { + $running = @(Get-VM -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Running' }) + } + catch { + Add-OfflineRepairLog -Level Info -Message "Hyper-V is present but VMs could not be enumerated: $($_.Exception.Message)" + return $stopped + } + + foreach ($vm in $running) { + if (-not $PSCmdlet.ShouldProcess($vm.Name, 'Turn off nested Hyper-V VM so its disk can be mounted offline')) { continue } + Add-OfflineRepairLog -Level Info -Message "Stopping nested Hyper-V VM '$($vm.Name)' so its disk can be mounted offline." + Stop-VM -Name $vm.Name -TurnOff -Force -ErrorAction SilentlyContinue + $stopped += $vm.Name + } + + if ($stopped.Count -gt 0) { Start-Sleep -Seconds 3 } + return $stopped +} + +function Test-TemporaryStorageDisk { + <# + .SYNOPSIS + Reports whether a disk is the Azure temporary/resource disk. + + .DESCRIPTION + The temporary/resource disk is local scratch space that is wiped on deallocation. + It sits on the same bus as the disks being repaired and carries no attribute the + bus-type filter would exclude, so without an explicit check it would be brought + online and made writable like a broken OS disk. Match either the English + 'Temporary Storage' label or the language-independent DATALOSS_WARNING_README.txt + at a volume root. Inspect existing access paths only; this check never mounts a disk. + + .OUTPUTS + $true when any volume on the disk has either resource-disk marker. + #> + param( + [Parameter(Mandatory = $true)]$Disk + ) + + try { + foreach ($partition in @(Get-Partition -DiskNumber $Disk.Number -ErrorAction SilentlyContinue)) { + $volumes = @($partition | Get-Volume -ErrorAction SilentlyContinue) + $labels = @($volumes | ForEach-Object { $_.FileSystemLabel }) + if ($labels -contains 'Temporary Storage') { return $true } + + $roots = @($partition.AccessPaths) + @($volumes | ForEach-Object { + $_.Path + if ($_.DriveLetter) { "$($_.DriveLetter):\" } + }) + foreach ($root in @($roots | Where-Object { $_ } | Select-Object -Unique)) { + $marker = Join-OfflinePath -Root $root -ChildPath 'DATALOSS_WARNING_README.txt' + if ($marker -and (Test-OfflinePath $marker)) { return $true } + } + } + } + catch { + Add-OfflineRepairLog -Level Warning -Message "Could not inspect resource-disk markers on disk $($Disk.Number): $($_.Exception.Message)" + } + + return $false +} + +function Set-OfflineDisksOnline { + <# + .SYNOPSIS + Brings every attached virtual data disk online and clears the read-only flag. + + .DESCRIPTION + The rescue VM's own boot/system disk and the Azure temporary/resource disk are + never touched. Returns disk numbers confirmed online and writable. A disk that is + already ready requires no writes; a changed disk is re-read rather than trusting + diskpart's exit code, because 'noerr' can suppress an online/attribute failure. + + SupportsShouldProcess is declared so -WhatIf reports each disk it would online. + ConfirmImpact is left at the default (Medium), below the default $ConfirmPreference + (High), so the non-interactive run proceeds without a prompt. + #> + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory = $false)][int[]]$ExcludeDiskNumber = @() + ) + + $processed = @() + # Azure SCSI disks report 'Msft Virtual Disk' but NVMe disks report 'Microsoft NVMe + # Direct Disk', so a model-name match misses NVMe entirely; the bus type sees both. The + # boot/system disk and the wipe-on-deallocate resource disk are excluded so a failed + # precondition can never bring the rescue VM's live OS disk online. + $disks = @(Get-Disk -ErrorAction SilentlyContinue | Where-Object { + $_.BusType -in @('SCSI', 'SAS', 'RAID', 'NVMe', 'File Backed Virtual') -and + $_.Number -notin $ExcludeDiskNumber -and + -not ($_.IsBoot -or $_.IsSystem) -and + -not (Test-TemporaryStorageDisk -Disk $_) + }) + + foreach ($disk in $disks) { + if (-not $disk.IsOffline -and -not $disk.IsReadOnly) { + $processed += $disk.Number + continue + } + if (-not $PSCmdlet.ShouldProcess("disk $($disk.Number)", 'Bring online and clear the read-only flag')) { continue } + + # diskpart is used rather than Set-Disk because it succeeds on disks whose + # partition table is damaged, which is common on the disks we are repairing. + $commands = @("select disk $($disk.Number)") + if ($disk.IsReadOnly) { $commands += 'attributes disk clear readonly noerr' } + if ($disk.IsOffline) { $commands += 'online disk noerr' } + $output = ($commands -join "`r`n") | diskpart.exe 2>&1 + $diskpartExit = $LASTEXITCODE + $diskState = $null + $stateError = '' + try { + $diskState = Get-Disk -Number $disk.Number -ErrorAction Stop + } + catch { + $stateError = $_.Exception.Message + } + + if ($diskState -and -not $diskState.IsOffline -and -not $diskState.IsReadOnly) { + $processed += $disk.Number + if ($diskpartExit -ne 0) { + Add-OfflineRepairLog -Level Warning -Message "Disk $($disk.Number) is confirmed online and writable, but diskpart reported exit $diskpartExit`: $(($output | Out-String).Trim())" + } + } + else { + $stateText = if ($diskState) { "IsOffline=$($diskState.IsOffline), IsReadOnly=$($diskState.IsReadOnly)" } else { "state unavailable: $stateError" } + Add-OfflineRepairLog -Level Warning -Message "Disk $($disk.Number) was not confirmed online and writable ($stateText; diskpart exit $diskpartExit): $(($output | Out-String).Trim())" + } + } + + if ($processed.Count -gt 0) { + Add-OfflineRepairLog -Level Info -Message "Attached virtual disk(s) confirmed online and writable: $($processed -join ', ')" + } + else { + Add-OfflineRepairLog -Level Warning -Message 'No attached virtual data disk was brought online on the rescue VM.' + } + + # Give the volume stack a moment to surface the new volumes. + Start-Sleep -Seconds 2 + return $processed +} + +function Add-PartitionDriveLetter { + <# + .SYNOPSIS + Assigns a free drive letter to a partition that does not have one. + + .DESCRIPTION + Set-Partition -NewDriveLetter fails on EFI System and Recovery partitions, so + diskpart is used, with Add-PartitionAccessPath as a fallback. + + Success is verified by probing the drive root rather than by re-reading + Get-Partition, because the partition object never reports a drive letter for + hidden System and Recovery partitions even after one has been assigned. + + .PARAMETER DriveLetter + Optional letter to assign, as 'D', 'D:' or 'D:\'. When omitted a free letter is + chosen. It is validated down to a single letter before use, because it is + interpolated into a diskpart script where an embedded newline would inject commands. + + .OUTPUTS + The assigned drive letter (without a colon), or $null on failure. A letter that was + successfully assigned is tracked, so Remove-OfflineDriveLetter or + Clear-OfflineDriveLetter can release it later. + #> + param( + [Parameter(Mandatory = $true)][int]$DiskNumber, + [Parameter(Mandatory = $true)][int]$PartitionNumber, + [Parameter(Mandatory = $false)][ValidatePattern('^[A-Za-z]:?\\?$')][string]$DriveLetter + ) + + if ([string]::IsNullOrWhiteSpace($DriveLetter)) { $DriveLetter = Get-FreeDriveLetter } + $DriveLetter = $DriveLetter.TrimEnd(':', '\').ToUpperInvariant() + + # ValidatePattern is skipped when the parameter is omitted, and TrimEnd only strips + # trailing characters, so this re-assertion is what actually guarantees a single letter + # reaches the here-string below. Without it an embedded newline would inject diskpart + # commands such as 'select disk 0' / 'clean' onto the wrong disk. + if ($DriveLetter -notmatch '^[A-Z]$') { + throw "Invalid drive letter '$DriveLetter'. Expected a single letter A-Z." + } + + $diskpartScript = @" +select disk $DiskNumber +select partition $PartitionNumber +assign letter=$DriveLetter +exit +"@ + $output = $diskpartScript | diskpart.exe 2>&1 + $diskpartExit = $LASTEXITCODE + + if (Wait-OfflineDriveLetterReady -DriveLetter $DriveLetter) { + Add-OfflineRepairLog -Level Info -Message "Assigned drive letter ${DriveLetter}: to disk $DiskNumber partition $PartitionNumber." + Register-OfflineAssignedDriveLetter -DriveLetter $DriveLetter -DiskNumber $DiskNumber -PartitionNumber $PartitionNumber + return $DriveLetter + } + + Add-OfflineRepairLog -Level Info -Message "diskpart did not surface ${DriveLetter}: for disk $DiskNumber partition $PartitionNumber (exit code $diskpartExit). Trying an access path. diskpart output: $(($output | Out-String).Trim())" + + # Fallback for partitions diskpart refuses to address, such as the MSR partition. + try { + Add-PartitionAccessPath -DiskNumber $DiskNumber -PartitionNumber $PartitionNumber -AccessPath "${DriveLetter}:" -ErrorAction Stop + if (Wait-OfflineDriveLetterReady -DriveLetter $DriveLetter) { + Add-OfflineRepairLog -Level Info -Message "Assigned drive letter ${DriveLetter}: to disk $DiskNumber partition $PartitionNumber (access path)." + Register-OfflineAssignedDriveLetter -DriveLetter $DriveLetter -DiskNumber $DiskNumber -PartitionNumber $PartitionNumber + return $DriveLetter + } + } + catch { + Add-OfflineRepairLog -Level Info -Message "Could not add an access path for disk $DiskNumber partition ${PartitionNumber}: $($_.Exception.Message)" + } + + # The assignment ultimately failed. diskpart may still have half-attached the letter, so + # release it rather than leak it and drop this partition out of every later run's alphabet. + Clear-PartitionDriveLetterAssignment -DiskNumber $DiskNumber -PartitionNumber $PartitionNumber -DriveLetter $DriveLetter + Add-OfflineRepairLog -Level Warning -Message "Could not assign a drive letter to disk $DiskNumber partition $PartitionNumber." + return $null +} + +function Wait-OfflineDriveLetterReady { + <# + .SYNOPSIS + Waits for a freshly assigned drive letter to become reachable. + + .DESCRIPTION + diskpart returns before the volume stack has finished surfacing the new root, and on + a slower storage stack a single fixed sleep races it: the probe runs too early, the + assignment is reported as failed, and the letter is leaked. Polling closes that race + and still returns as soon as the root responds. + + .OUTPUTS + $true once the drive root responds, $false if it never does within the timeout. + #> + param( + [Parameter(Mandatory = $true)][string]$DriveLetter, + [Parameter(Mandatory = $false)][int]$TimeoutSeconds = 10 + ) + + $letter = $DriveLetter.TrimEnd(':', '\') + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + if (Test-OfflinePath "${letter}:\") { return $true } + Start-Sleep -Milliseconds 250 + } + return [bool](Test-OfflinePath "${letter}:\") +} + +function Get-OfflineAssignedDriveLetterList { + <# + .SYNOPSIS + Returns the backing list of letters this session assigned, creating it on first use. + + .DESCRIPTION + The list lives in the shared OfflineRepairCommon state, the same hashtable that holds + the log buffer and the bound roots, so a caller's finally-block cleanup sees exactly + what the discovery pass assigned. See that file's header for why the shared state is + global rather than script-scoped. + #> + $state = Get-OfflineRepairState + if (-not $state.ContainsKey('AssignedDriveLetters') -or -not $state['AssignedDriveLetters']) { + $state['AssignedDriveLetters'] = [System.Collections.Generic.List[object]]::new() + } + # The unary comma returns the live List as a single object. Without it PowerShell unrolls + # the collection on output, so an empty list comes back as $null and a populated one as a + # detached copy, and Register/Remove/Clear would then mutate a throwaway rather than the + # instance the shared state holds. Callers must assign the result before piping it. + return , $state['AssignedDriveLetters'] +} + +function Register-OfflineAssignedDriveLetter { + <# + .SYNOPSIS + Records a drive letter this session assigned, so it can be released later. + #> + param( + [Parameter(Mandatory = $true)][string]$DriveLetter, + [Parameter(Mandatory = $true)][int]$DiskNumber, + [Parameter(Mandatory = $true)][int]$PartitionNumber + ) + + $letter = $DriveLetter.TrimEnd(':', '\').ToUpperInvariant() + $list = Get-OfflineAssignedDriveLetterList + if ($list | Where-Object { $_.Letter -eq $letter }) { return } + [void]$list.Add([PSCustomObject]@{ Letter = $letter; DiskNumber = $DiskNumber; PartitionNumber = $PartitionNumber }) +} + +function Get-OfflineAssignedDriveLetter { + <# + .SYNOPSIS + Returns the drive letters this session assigned, in the form 'K:'. + #> + # Assign first: Get-OfflineAssignedDriveLetterList returns the live List as a single + # object, so piping it straight from the call would hand ForEach-Object the whole list + # instead of its entries. Piping the assigned variable enumerates the entries. + $list = Get-OfflineAssignedDriveLetterList + return @($list | ForEach-Object { "$($_.Letter):" }) +} + +function Clear-PartitionDriveLetterAssignment { + <# + .SYNOPSIS + Releases a drive letter from a partition, by diskpart with an access-path fallback. + + .DESCRIPTION + Internal. The letter is validated to a single letter before it reaches the diskpart + script, for the same injection reason as Add-PartitionDriveLetter. Best effort: a + letter that is already gone is not treated as an error. + #> + param( + [Parameter(Mandatory = $true)][int]$DiskNumber, + [Parameter(Mandatory = $true)][int]$PartitionNumber, + [Parameter(Mandatory = $true)][string]$DriveLetter + ) + + $letter = $DriveLetter.TrimEnd(':', '\').ToUpperInvariant() + if ($letter -notmatch '^[A-Z]$') { return } + + $diskpartScript = @" +select disk $DiskNumber +select partition $PartitionNumber +remove letter=$letter noerr +exit +"@ + $null = $diskpartScript | diskpart.exe 2>&1 + + if (Test-OfflinePath "${letter}:\") { + try { Remove-PartitionAccessPath -DiskNumber $DiskNumber -PartitionNumber $PartitionNumber -AccessPath "${letter}:" -ErrorAction Stop } + catch { Add-OfflineRepairLog -Level Info -Message "Could not remove access path ${letter}: from disk $DiskNumber partition ${PartitionNumber}: $($_.Exception.Message)" } + } +} + +function Remove-OfflineDriveLetter { + <# + .SYNOPSIS + Releases one drive letter this session assigned and stops tracking it. + + .DESCRIPTION + SupportsShouldProcess is declared so -WhatIf reports the letter it would release. + ConfirmImpact is left at the default (Medium), below the default $ConfirmPreference + (High), so a caller's finally-block cleanup releases the letter without a prompt. + + .PARAMETER DriveLetter + The letter to release, as 'K', 'K:' or 'K:\'. + #> + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory = $true)][ValidatePattern('^[A-Za-z]:?\\?$')][string]$DriveLetter + ) + + $letter = $DriveLetter.TrimEnd(':', '\').ToUpperInvariant() + $list = Get-OfflineAssignedDriveLetterList + $tracked = @($list | Where-Object { $_.Letter -eq $letter }) + foreach ($entry in $tracked) { + if (-not $PSCmdlet.ShouldProcess("drive letter $($entry.Letter): (disk $($entry.DiskNumber) partition $($entry.PartitionNumber))", 'Release drive letter')) { continue } + Clear-PartitionDriveLetterAssignment -DiskNumber $entry.DiskNumber -PartitionNumber $entry.PartitionNumber -DriveLetter $entry.Letter + Add-OfflineRepairLog -Level Info -Message "Released drive letter $($entry.Letter): from disk $($entry.DiskNumber) partition $($entry.PartitionNumber)." + [void]$list.Remove($entry) + } +} + +function Clear-OfflineDriveLetter { + <# + .SYNOPSIS + Releases every drive letter this session assigned. For a caller's finally block. + + .DESCRIPTION + The discovery pass assigns temporary letters to the EFI System and Recovery + partitions, and every run would otherwise leak them until the alphabet is exhausted. + A caller runs this in a finally so the letters are handed back even when the repair + in between throws. + + Because it runs in a finally, it must never throw: a throw here would replace the + real repair exception and hide the actual failure from the operator. So every letter + is released in its own try/catch - one letter that cannot be released no longer + abandons the rest. Each letter that IS released is untracked individually (by the + delegated Remove-OfflineDriveLetter), so nothing blanket-clears the list while it + still holds failures and a later attempt does not retry a letter already handed back. + Anything still stuck is named in a single Warning rather than passed off as clean. + + Every release is delegated to Remove-OfflineDriveLetter instead of calling + Clear-PartitionDriveLetterAssignment directly, so there is exactly one guarded release + path: Remove-OfflineDriveLetter owns the ShouldProcess gate, the diskpart call and the + untracking, and this function cannot drift from it. SupportsShouldProcess is declared + here only to expose -WhatIf/-Confirm and let the preference flow into those delegated + calls; this function performs no state change of its own, which is why it does not call + ShouldProcess itself. Under -WhatIf it therefore releases nothing and reports each + letter, exactly as Remove-OfflineDriveLetter does for a single letter. + + .OUTPUTS + None. Letters that could not be released are surfaced with a Warning and left tracked + for a later attempt; nothing is written to the pipeline, so a bare call in a caller's + finally block does not pollute that caller's output. + #> + [CmdletBinding(SupportsShouldProcess)] + param() + + # Snapshot the letters first: Remove-OfflineDriveLetter mutates the shared tracking list as + # it releases each one, so iterating the live list would skip entries. + $failed = @() + foreach ($letter in @(Get-OfflineAssignedDriveLetter)) { + try { + Remove-OfflineDriveLetter -DriveLetter $letter + } + catch { + $failed += "$letter ($($_.Exception.Message))" + } + } + if ($failed.Count -gt 0) { + Add-OfflineRepairLog -Level Warning -Message "Drive-letter cleanup could not release: $($failed -join '; '). They remain tracked for a later attempt." + } +} + +function Get-OfflineWindowsInstallCandidate { + <# + .SYNOPSIS + Builds a scored candidate object for one offline Windows installation. + + .DESCRIPTION + Scoring prefers an installation that has both core hives, the boot loader + binary expected for the disk's firmware generation, and the highest build + number, and penalises an installation that is mid-setup. + #> + param( + [Parameter(Mandatory = $true)][string]$AccessPath, + [Parameter(Mandatory = $true)]$PartitionInfo, + [Parameter(Mandatory = $true)][int]$Generation + ) + + $normalizedPath = if ($AccessPath -match '\\$') { $AccessPath } else { "$AccessPath\" } + $windowsRoot = Join-OfflinePath -Root $normalizedPath -ChildPath 'Windows' + $systemHivePath = Join-OfflinePath -Root $windowsRoot -ChildPath 'System32\Config\SYSTEM' + $softwareHivePath = Join-OfflinePath -Root $windowsRoot -ChildPath 'System32\Config\SOFTWARE' + $winloadName = if ($Generation -eq 2) { 'System32\winload.efi' } else { 'System32\winload.exe' } + $expectedWinload = Join-OfflinePath -Root $windowsRoot -ChildPath $winloadName + + $candidate = [ordered]@{ + AccessPath = $normalizedPath + Drive = $normalizedPath.TrimEnd('\').ToUpperInvariant() + DiskNumber = $PartitionInfo.DiskNumber + PartitionNumber = $PartitionInfo.PartitionNumber + PartitionType = "$($PartitionInfo.Type)" + IsActive = [bool]$PartitionInfo.IsActive + WindowsRoot = $windowsRoot + SystemHivePresent = Test-OfflinePath $systemHivePath + SoftwareHivePresent = Test-OfflinePath $softwareHivePath + HasExpectedWinload = Test-OfflinePath $expectedWinload + ProductName = '' + CurrentBuildNumber = '' + GuestComputerName = '' + SetupInProgress = $false + ProbeStatus = 'NotProbed' + Score = 0 + Selected = $false + } + + if ($candidate.SystemHivePresent -and $candidate.SoftwareHivePresent) { + # Unreadable metadata lowers a candidate's score but must not hide the disk that + # needs repairing. A close failure, unlike a read failure, aborts discovery. + $probeNotes = [System.Collections.Generic.List[string]]::new() + + foreach ($hiveName in @('SOFTWARE', 'SYSTEM')) { + $hivePath = if ($hiveName -eq 'SOFTWARE') { $softwareHivePath } else { $systemHivePath } + $reader = $null + try { + $reader = Open-OfflineRegistryReader -Path $hivePath + if ($hiveName -eq 'SOFTWARE') { + $cvKey = 'Microsoft\Windows NT\CurrentVersion' + $candidate.ProductName = [string]$reader.ReadString($cvKey, 'ProductName') + $candidate.CurrentBuildNumber = [string]$reader.ReadString($cvKey, 'CurrentBuildNumber') + if (-not $candidate.ProductName -or -not $candidate.CurrentBuildNumber) { + [void]$probeNotes.Add('SOFTWARE product name or build number is absent.') + } + } + else { + $currentSet = $reader.ReadDword('Select', 'Current') + if ($null -eq $currentSet -or $currentSet -lt 1 -or $currentSet -gt 999) { + [void]$probeNotes.Add('SYSTEM\Select\Current is absent or invalid; the active control set cannot be identified.') + } + else { + $controlSetName = 'ControlSet{0:d3}' -f $currentSet + $candidate.GuestComputerName = [string]$reader.ReadString("$controlSetName\Control\ComputerName\ComputerName", 'ComputerName') + if (-not $candidate.GuestComputerName) { + [void]$probeNotes.Add("SYSTEM\$controlSetName guest computer name is absent.") + } + } + + $setupType = $reader.ReadDword('Setup', 'SetupType') + $cmdLine = $reader.ReadString('Setup', 'CmdLine') + if ($null -eq $setupType) { [void]$probeNotes.Add('SYSTEM\Setup\SetupType is absent.') } + if (($null -ne $setupType -and $setupType -ne 0) -or -not [string]::IsNullOrWhiteSpace($cmdLine)) { + $candidate.SetupInProgress = $true + } + } + } + catch { + [void]$probeNotes.Add("$hiveName read failed: $($_.Exception.Message)") + } + finally { + if ($reader) { $reader.Dispose() } + } + } + + if ($probeNotes.Count -eq 0) { + $candidate.ProbeStatus = 'OK' + } + else { + $candidate.ProbeStatus = $probeNotes -join '; ' + Add-OfflineRepairLog -Level Warning -Message "Offline hive probe of $normalizedPath has incomplete metadata, which may lower this installation's score: $($candidate.ProbeStatus)" + } + } + else { + $candidate.ProbeStatus = "Skipped: SYSTEM hive present = $($candidate.SystemHivePresent), SOFTWARE hive present = $($candidate.SoftwareHivePresent)" + } + + if (Test-OfflinePath (Join-OfflinePath -Root $normalizedPath -ChildPath '$WINDOWS.~BT')) { $candidate.SetupInProgress = $true } + + $score = 0 + if ($candidate.SystemHivePresent) { $score += 10 } + if ($candidate.SoftwareHivePresent) { $score += 10 } + if ($candidate.HasExpectedWinload) { $score += 30 } else { $score -= 25 } + if ($candidate.ProductName) { $score += 10 } + + $buildInt = 0 + if ([int]::TryParse($candidate.CurrentBuildNumber, [ref]$buildInt)) { + $score += [Math]::Min([int]($buildInt / 1000), 30) + } + if ($candidate.SetupInProgress) { $score -= 20 } + + $candidate.Score = $score + return [PSCustomObject]$candidate +} + +function Get-OfflineWindowsDisk { + <# + .SYNOPSIS + Locates the offline Windows installation on the attached broken OS disk. + + .DESCRIPTION + Stops a nested repair VM if one is running, brings the attached virtual disks + online, assigns drive letters to partitions that have none, then selects the + best Windows installation and its matching boot partition. + + Sets $script:OfflineWindowsDrive for the offline registry hive helper. + + .PARAMETER DiskNumber + Restrict the search to a specific disk number. + + .PARAMETER WindowsDrive + Skip discovery and use this drive letter as the offline Windows volume. + + .OUTPUTS + PSCustomObject with DiskNumber, PartitionStyle, Generation, WindowsDrive, + WindowsPath, PartitionNumber, BootDrive, BcdStorePath, ProductName, BuildNumber, + GuestComputerName, SetupInProgress, PartitionRoots, AssignedDriveLetters and + Candidates. AssignedDriveLetters holds the letters this run assigned; pass each to + Remove-OfflineDriveLetter, or call Clear-OfflineDriveLetter, in the caller's finally. + + .EXAMPLE + $offline = Get-OfflineWindowsDisk + Invoke-WithHive 'SYSTEM' { Get-ItemProperty "$(Get-OfflineSystemRootPath)\Services\disk" } + #> + param( + [Parameter(Mandatory = $false)][int]$DiskNumber = -1, + [Parameter(Mandatory = $false)][string]$WindowsDrive + ) + + # The rescue VM's own OS disk must be known before anything is brought online, because + # every exclusion below keys off it. A real disk number is >= 0, so leaving this at a + # sentinel would make the exclusion match nothing and expose the live OS disk. Fail + # closed rather than warn and carry on. + try { + $systemDiskNumber = (Get-Partition -DriveLetter ($env:SystemDrive.TrimEnd(':')) -ErrorAction Stop).DiskNumber + } + catch { + throw "Could not determine the rescue VM's own system disk number, so the broken disk cannot be told apart from it: $($_.Exception.Message)" + } + + $null = Stop-NestedRepairVm + $onlineDiskNumbers = @(Set-OfflineDisksOnline -ExcludeDiskNumber @($systemDiskNumber | Where-Object { $_ -ge 0 })) + + # Select by bus type, not model name: Azure NVMe disks report 'Microsoft NVMe Direct + # Disk', which the old '*Virtual Disk*' match missed. The rescue VM's own system disk, + # any boot/system disk, and the 'Temporary Storage' resource disk are all excluded so a + # broken precondition can never route the repair onto the live OS. + $disks = @(Get-Disk -ErrorAction SilentlyContinue | Where-Object { + $_.BusType -in @('SCSI', 'SAS', 'RAID', 'NVMe', 'File Backed Virtual') -and + $_.Number -ne $systemDiskNumber -and + $_.Number -in $onlineDiskNumbers -and + -not ($_.IsOffline -or $_.IsReadOnly) -and + -not ($_.IsBoot -or $_.IsSystem) -and + -not (Test-TemporaryStorageDisk -Disk $_) -and + ($DiskNumber -lt 0 -or $_.Number -eq $DiskNumber) + }) + + if ($disks.Count -eq 0) { + throw 'No attached broken OS disk was found. Create the rescue VM with "az vm repair create" first.' + } + + # Give every partition a drive letter. EFI System and Recovery partitions have none + # by default, and offline boot repairs cannot reach them without one. + # Get-Partition never reports a letter for those partitions even after assignment, + # so the letters are tracked here and used for all later path building. + $partitionRoots = @{} + $volumeMap = Get-VolumeDriveLetterMap + $letterDeviceMap = Get-DriveLetterDeviceMap + foreach ($disk in $disks) { + foreach ($part in (Get-Partition -DiskNumber $disk.Number -ErrorAction SilentlyContinue)) { + $key = "$($disk.Number)-$($part.PartitionNumber)" + + $existing = @(Get-PartitionExistingRoot -Partition $part -LetterDeviceMap $letterDeviceMap -VolumeMap $volumeMap) + if ($existing.Count -gt 0) { + $partitionRoots[$key] = @($existing) + continue + } + + # The Microsoft Reserved partition holds no file system and cannot be mounted. + if ("$($part.Type)" -eq 'Reserved') { continue } + if ($part.Size -lt 1MB) { continue } + + $letter = Add-PartitionDriveLetter -DiskNumber $disk.Number -PartitionNumber $part.PartitionNumber + if ($letter) { + $partitionRoots[$key] = @("${letter}:") + + # Keep the map current so a partition that shares this volume is not + # handed a second letter later in the same pass. + $newDevice = Get-DosDeviceTarget -Name "${letter}:" + if (-not [string]::IsNullOrWhiteSpace($newDevice)) { $letterDeviceMap["${letter}:"] = $newDevice } + } + } + } + + $candidates = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($disk in $disks) { + $generation = if ($disk.PartitionStyle -eq 'GPT') { 2 } elseif ($disk.PartitionStyle -eq 'MBR') { 1 } else { 0 } + + foreach ($part in (Get-Partition -DiskNumber $disk.Number -ErrorAction SilentlyContinue)) { + foreach ($accessPath in @($partitionRoots["$($disk.Number)-$($part.PartitionNumber)"])) { + if (-not $accessPath) { continue } + if (-not (Test-OfflinePath (Join-OfflinePath -Root $accessPath -ChildPath 'Windows\System32\ntdll.dll'))) { continue } + + $normalized = if ($accessPath -match '\\$') { $accessPath } else { "$accessPath\" } + if ($candidates | Where-Object { $_.AccessPath -eq $normalized } | Select-Object -First 1) { continue } + + [void]$candidates.Add((Get-OfflineWindowsInstallCandidate -AccessPath $normalized -PartitionInfo $part -Generation $generation)) + } + } + } + + if (-not [string]::IsNullOrWhiteSpace($WindowsDrive)) { + $wanted = $WindowsDrive.TrimEnd(':', '\').ToUpperInvariant() + ':' + $forced = @($candidates | Where-Object { $_.Drive -eq $wanted }) + if ($forced.Count -eq 0) { + throw "No offline Windows installation was found on drive $wanted." + } + $candidates = [System.Collections.Generic.List[PSCustomObject]]::new() + $forced | ForEach-Object { [void]$candidates.Add($_) } + } + + if ($candidates.Count -eq 0) { + throw 'No offline Windows installation was found on the attached disk(s).' + } + + # DiskNumber is the final key so that two installations with an equal score and build + # settle on the same disk every run instead of ordering nondeterministically. + $sorted = @($candidates | Sort-Object @{ Expression = 'Score'; Descending = $true }, + @{ Expression = { [int]($_.CurrentBuildNumber -as [int]) }; Descending = $true }, PartitionNumber, DiskNumber) + $selected = $sorted[0] + foreach ($candidate in $sorted) { $candidate.Selected = ($candidate.Drive -eq $selected.Drive) } + + if ($sorted.Count -gt 1) { + Add-OfflineRepairLog -Level Warning -Message "$($sorted.Count) Windows installations found on the attached disk(s). Selected $($selected.Drive) (score $($selected.Score))." + } + + $selectedDisk = Get-Disk -Number $selected.DiskNumber -ErrorAction SilentlyContinue + $generation = if ($selectedDisk.PartitionStyle -eq 'GPT') { 2 } elseif ($selectedDisk.PartitionStyle -eq 'MBR') { 1 } else { 0 } + + # Locate the boot partition and its BCD store on the same disk. + $bootDrive = $null + $bcdStorePath = $null + foreach ($part in (Get-Partition -DiskNumber $selected.DiskNumber -ErrorAction SilentlyContinue)) { + foreach ($accessPath in @($partitionRoots["$($selected.DiskNumber)-$($part.PartitionNumber)"])) { + if (-not $accessPath) { continue } + $efiBcd = Join-OfflinePath -Root $accessPath -ChildPath 'EFI\Microsoft\Boot\BCD' + $biosBcd = Join-OfflinePath -Root $accessPath -ChildPath 'Boot\BCD' + + if ($generation -eq 2 -and (Test-OfflinePath $efiBcd)) { + $bootDrive = $accessPath.TrimEnd('\'); $bcdStorePath = $efiBcd; break + } + if ($generation -ne 2 -and (Test-OfflinePath $biosBcd)) { + $bootDrive = $accessPath.TrimEnd('\'); $bcdStorePath = $biosBcd; break + } + } + if ($bootDrive) { break } + } + + if (-not $bootDrive) { + # The BCD file may be missing while the system partition itself is intact. + foreach ($part in (Get-Partition -DiskNumber $selected.DiskNumber -ErrorAction SilentlyContinue)) { + $isBootPartition = ("$($part.Type)" -eq 'System') -or ($generation -ne 2 -and $part.IsActive) + if (-not $isBootPartition) { continue } + $root = @($partitionRoots["$($selected.DiskNumber)-$($part.PartitionNumber)"]) | Select-Object -First 1 + if ($root) { + $bootDrive = $root.TrimEnd('\') + $bcdStorePath = if ($generation -eq 2) { Join-OfflinePath -Root $bootDrive -ChildPath 'EFI\Microsoft\Boot\BCD' } else { Join-OfflinePath -Root $bootDrive -ChildPath 'Boot\BCD' } + Add-OfflineRepairLog -Level Warning -Message "No BCD store was found at $bcdStorePath, but the boot partition is present at $bootDrive." + break + } + } + } + + if (-not $bootDrive) { + Add-OfflineRepairLog -Level Warning -Message 'No boot partition was found on the attached disk. Boot configuration repairs will not be available.' + } + + # Bind the chosen volume as the offline repair root. This is what lets every other + # helper's Assert-OfflineTarget gate prove it is writing to the broken disk and not to + # the rescue VM. Set-OfflineRepairRoot throws if this somehow resolved to the rescue + # VM's own system drive, which is the fail-closed behaviour we want. The boot/EFI + # partition is a separate volume on the same disk that BCD repairs write to, so it is + # bound too or the gate would refuse them. + $null = Set-OfflineRepairRoot -Path $selected.Drive + if ($bootDrive) { + $bootRoot = "$bootDrive".TrimEnd('\') + if ($bootRoot -match '^[A-Za-z]:$' -and $bootRoot -ne $selected.Drive) { + $null = Set-OfflineRepairRoot -Path $bootRoot + } + } + + $script:OfflineWindowsDrive = $selected.Drive + + $result = [PSCustomObject]@{ + DiskNumber = $selected.DiskNumber + PartitionStyle = "$($selectedDisk.PartitionStyle)" + Generation = $generation + WindowsDrive = $selected.Drive + WindowsPath = $selected.WindowsRoot + PartitionNumber = $selected.PartitionNumber + BootDrive = $bootDrive + BcdStorePath = $bcdStorePath + ProductName = $selected.ProductName + BuildNumber = $selected.CurrentBuildNumber + GuestComputerName = $selected.GuestComputerName + SetupInProgress = $selected.SetupInProgress + ProbeStatus = $selected.ProbeStatus + PartitionRoots = $partitionRoots + AssignedDriveLetters = Get-OfflineAssignedDriveLetter + Candidates = $sorted + } + + Add-OfflineRepairLog -Level Info -Message "Offline Windows: $($result.WindowsPath) (disk $($result.DiskNumber), Gen$($result.Generation), $($result.ProductName) build $($result.BuildNumber))" + if ($result.GuestComputerName) { Add-OfflineRepairLog -Level Info -Message "Guest computer name: $($result.GuestComputerName)" } + if ($result.BootDrive) { Add-OfflineRepairLog -Level Info -Message "Boot partition: $($result.BootDrive) (BCD: $($result.BcdStorePath))" } + + return $result +} diff --git a/src/windows/common/helpers/OfflineRepairCommon.ps1 b/src/windows/common/helpers/OfflineRepairCommon.ps1 new file mode 100644 index 00000000..5c1fa351 --- /dev/null +++ b/src/windows/common/helpers/OfflineRepairCommon.ps1 @@ -0,0 +1,879 @@ +<# +.SYNOPSIS + Shared primitives for the offline repair helpers: buffered logging, drive-safe paths + and offline binary trust checks. + +.DESCRIPTION + Buffered logging + ---------------- + The library's Logger.ps1 functions write with Write-Output, which is the same stream + a PowerShell function returns its value on. A helper that both logs and returns a + value therefore returns the log lines as well, silently corrupting the result. + + This helper solves that: helper functions call Add-OfflineRepairLog, which buffers the + message without writing anything, and the calling script calls Write-OfflineRepairLog + at statement level to flush the buffer through the standard Log-* functions. + + Rules: + - Inside a function that returns a value, use Add-OfflineRepairLog. + - Call Write-OfflineRepairLog only at script level, never from a function whose + return value is used, and always flush in the script's finally block. + + Drive-safe paths + ---------------- + Join-Path and Test-Path throw DriveNotFoundException when a path refers to a drive + letter that is not a live PowerShell drive. Offline repairs work with letters that + come and go (EFI and Recovery partitions are mounted temporarily, and a partition can + still advertise a stale access path), so Join-OfflinePath builds the string without + resolving the drive and Test-OfflinePath answers false instead of throwing. + + Offline target binding + ---------------------- + These helpers run as SYSTEM on a rescue VM that has its own healthy Windows + installation attached at C:. Every destructive operation therefore has to prove it is + acting on the broken disk and not on the rescue VM, because a precondition that fails + quietly - a drive letter that was never assigned, a disk enumeration that returned + nothing - otherwise leaves a path that still resolves, just on the wrong volume. + + Get-OfflineWindowsDisk calls Set-OfflineRepairRoot once it has chosen a volume, and + Use-OfflineRegistryHive registers each mount key it creates. Assert-OfflineTarget is + the single gate every writing function calls before it enables a privilege. It throws + when nothing is bound and when the path falls outside what is bound: it never warns + and never returns $false, because a caller that ignores a warning is exactly the + failure being prevented. + + Shared state lives in $global: rather than $script:. A dot-sourced $script: variable + binds to the scope of whoever sourced the file, so a helper sourced from inside a + function would keep its own private buffer and its own private root list, and the gate + would be asserting against a set the caller never populated. + +.NOTES + Name: OfflineRepairCommon.ps1 + Requires: common/setup/init.ps1 to be dot-sourced first (for the Log-* functions). + +.VERSION + v1.0: Initial version. + v1.1: Added the offline target binding gate. Moved shared state to $global:. Trust + reporting no longer infers a Microsoft signature from an unsigned version + resource. + v1.2: Added a read-only offreg reader for discovery and hive validation, without + mounting hives in the rescue VM's registry or replaying logs onto the source. +#> + +function Get-OfflineRepairState { + <# + .SYNOPSIS + Returns the shared helper state, creating it on first use. + + .DESCRIPTION + One hashtable in $global: holds the log buffer, the bound offline roots and the + registered hive mount keys. See the file header for why this is not $script:. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '', + Justification = 'Deliberate, and confined to this one function. These helpers are dot-sourced, and a $script: variable binds to the scope that did the dot-sourcing: a helper sourced from inside a function would get its own private copy of the root list, so Assert-OfflineTarget would check a set the caller never populated and the gate would fail open. Each az vm repair run is a fresh process, so there is nothing to leak into; Clear-OfflineRepairRoot resets it for tests.')] + [CmdletBinding()] + param() + + if (-not $global:OfflineRepairState) { + $global:OfflineRepairState = @{ + LogBuffer = [System.Collections.Generic.List[object]]::new() + Roots = [System.Collections.Generic.List[string]]::new() + HiveKeys = [System.Collections.Generic.List[string]]::new() + } + } + return $global:OfflineRepairState +} + +function Add-OfflineRepairLog { + <# + .SYNOPSIS + Buffers a log message without writing to the output stream. + #> + param( + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Message, + [Parameter(Mandatory = $false)][ValidateSet('Info', 'Warning', 'Error', 'Output')][string]$Level = 'Info' + ) + + [void](Get-OfflineRepairState).LogBuffer.Add([PSCustomObject]@{ Level = $Level; Message = $Message }) +} + +function Write-OfflineRepairLog { + <# + .SYNOPSIS + Flushes the buffered helper messages through the library Log-* functions. + + .DESCRIPTION + Call this only at script level. It writes to the output stream, so calling it + inside a function whose return value is used would corrupt that value. + + Entries are written before the buffer is cleared, and the clear happens in a + finally block. Clearing first would discard everything still unwritten if a Log-* + call threw, which is exactly what happens when init.ps1 was not sourced - the run + would lose the diagnostics that explain why it failed. For that case the Log-* + functions are resolved once and fall back to Write-Output. + #> + $state = Get-OfflineRepairState + if ($state.LogBuffer.Count -eq 0) { return } + + $haveLogger = [bool](Get-Command -Name Log-Info -ErrorAction SilentlyContinue) + + try { + foreach ($entry in $state.LogBuffer) { + if ([string]::IsNullOrEmpty($entry.Message)) { continue } + if (-not $haveLogger) { + Write-Output "[$($entry.Level)] $($entry.Message)" + continue + } + switch ($entry.Level) { + 'Warning' { Log-Warning $entry.Message } + 'Error' { Log-Error $entry.Message } + 'Output' { Log-Output $entry.Message } + default { Log-Info $entry.Message } + } + } + } + finally { + $state.LogBuffer.Clear() + } +} + +function Get-OfflineRepairLog { + <# + .SYNOPSIS + Returns the buffered messages without flushing them. + #> + return @((Get-OfflineRepairState).LogBuffer) +} + +function Clear-OfflineRepairLog { + <# + .SYNOPSIS + Discards the buffered messages. + #> + (Get-OfflineRepairState).LogBuffer.Clear() +} + +function Initialize-OfflineRegistryReader { + <# + .SYNOPSIS + Initialises read-only access to hive files through the Windows Offline Registry Library. + + .DESCRIPTION + Uses the offreg.dll in System32. Reads and log recovery happen in memory; there + is no HKLM mount, registry provider handle, save operation or reg.exe fallback. + An unavailable library is an environment failure, not evidence of hive damage. + #> + [CmdletBinding()] + param() + + if (-not ('RslOffline.RegistryHiveReader' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; + +namespace RslOffline +{ + public sealed class RegistryHiveReader : IDisposable + { + private IntPtr handle; + private const uint MaxValueBytes = 1024 * 1024; + + [DllImport("offreg.dll", CharSet = CharSet.Unicode, ExactSpelling = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + private static extern int OROpenHive(string path, out IntPtr result); + + [DllImport("offreg.dll", ExactSpelling = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + private static extern int ORCreateHive(out IntPtr result); + + [DllImport("offreg.dll", ExactSpelling = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + private static extern int ORCloseHive(IntPtr hive); + + [DllImport("offreg.dll", CharSet = CharSet.Unicode, ExactSpelling = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + private static extern int ORGetValue(IntPtr hive, string key, string name, + out uint type, byte[] data, ref uint size); + + private RegistryHiveReader(IntPtr value) { handle = value; } + + public bool IsOpen { get { return handle != IntPtr.Zero; } } + + private static void Check(int result, string operation) + { + if (result != 0) + throw new Win32Exception(result, operation + " failed (Win32 error " + + result + "): " + new Win32Exception(result).Message); + } + + public static void EnsureSupported() + { + IntPtr value; + Check(ORCreateHive(out value), "Initialising offreg.dll"); + using (RegistryHiveReader reader = new RegistryHiveReader(value)) { } + } + + public static RegistryHiveReader Open(string path) + { + IntPtr value; + Check(OROpenHive(path, out value), "Opening offline hive '" + path + "'"); + return new RegistryHiveReader(value); + } + + private byte[] ReadValue(string key, string name, out uint type) + { + if (!IsOpen) throw new ObjectDisposedException("RegistryHiveReader"); + uint size = 0; + int result = ORGetValue(handle, key, name, out type, null, ref size); + if (result == 2 || result == 3) return null; + if (result != 234) Check(result, "Reading '" + key + "\\" + name + "'"); + if (size > MaxValueBytes) + throw new InvalidDataException("Registry metadata value exceeds the 1 MiB read limit."); + + byte[] data = new byte[size]; + Check(ORGetValue(handle, key, name, out type, data, ref size), + "Reading '" + key + "\\" + name + "'"); + if (size > data.Length) + throw new InvalidDataException("Registry value grew beyond its reported size."); + if (size != data.Length) Array.Resize(ref data, (int)size); + return data; + } + + public string ReadString(string key, string name) + { + uint type; + byte[] data = ReadValue(key, name, out type); + if (data == null) return null; + if ((type != 1 && type != 2) || data.Length % 2 != 0) + throw new InvalidDataException("'" + key + "\\" + name + "' is not a registry string."); + return Encoding.Unicode.GetString(data).TrimEnd('\0'); + } + + public uint? ReadDword(string key, string name) + { + uint type; + byte[] data = ReadValue(key, name, out type); + if (data == null) return null; + if (type != 4 || data.Length != 4) + throw new InvalidDataException("'" + key + "\\" + name + "' is not a registry DWORD."); + return BitConverter.ToUInt32(data, 0); + } + + public void Dispose() + { + if (IsOpen) + { + Check(ORCloseHive(handle), "Closing offline hive"); + handle = IntPtr.Zero; + } + GC.SuppressFinalize(this); + } + + ~RegistryHiveReader() + { + if (IsOpen) ORCloseHive(handle); + } + } +} +'@ -ErrorAction Stop + } + + [RslOffline.RegistryHiveReader]::EnsureSupported() +} + +function Open-OfflineRegistryReader { + <# + .SYNOPSIS + Opens an offline hive for scalar metadata reads without mounting or modifying it. + + .DESCRIPTION + ReadString and ReadDword take hive-relative key paths and return $null only for + an absent key/value. Other read failures throw. Dirty hives may require their + matching recovery logs alongside them. Always Dispose the reader in a finally; + a failed close throws rather than reporting a successful cleanup. + #> + [CmdletBinding()] + param([Parameter(Mandatory = $true)][string]$Path) + + Initialize-OfflineRegistryReader + return [RslOffline.RegistryHiveReader]::Open($Path) +} + +#region Offline target binding + +$script:OfflineRootPattern = '^([A-Za-z]:|\\\\[^\\]+\\[^\\]+)$' + +# A repair ROOT must be a volume root, but a path built by Join-OfflinePath may be rooted +# anywhere under one - 'E:\Windows' and 'E:\Windows\System32\config' are both ordinary +# roots for a join. Reusing OfflineRootPattern for the join rejected every nested root and +# returned $null, which Test-OfflinePath then read as "file not present": a repair would +# find nothing to fix and report success. What actually has to be excluded is a root that +# is not volume-qualified at all, because that resolves against the rescue VM's current +# directory. ConvertTo-OfflineComparablePath already strips leading separators, so '\' +# collapses to empty and is rejected before this pattern is reached; this catches the +# residue, such as '\Windows' arriving as 'Windows'. +$script:OfflineQualifiedPathPattern = '^([A-Za-z]:|\\\\[^\\]+\\[^\\]+)(\\[^\\]+)*$' + +function ConvertTo-OfflineComparablePath { + <# + .SYNOPSIS + Normalises a file system path for prefix comparison, or returns $null if it is unusable. + + .DESCRIPTION + Comparison has to work on drives that are not mounted, so Resolve-Path and + GetFullPath are both unavailable. The normalisation is therefore textual: + forward slashes become backslashes, repeated separators collapse, and the + trailing separator is dropped. + + A path containing a '..' segment returns $null rather than being canonicalised. + There is no reliable way to resolve it without the drive, and no offline repair + has a legitimate reason to use one, so it is treated as unusable input. + #> + param([Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$Path) + + if ([string]::IsNullOrWhiteSpace($Path)) { return $null } + + $normalised = $Path.Trim().Replace('/', '\') + if ($normalised.IndexOfAny([char[]]@("`0", "`r", "`n")) -ge 0) { return $null } + + $isUnc = $normalised.StartsWith('\\') + $normalised = $normalised.TrimStart('\') + while ($normalised.Contains('\\')) { $normalised = $normalised.Replace('\\', '\') } + if ($isUnc) { $normalised = '\\' + $normalised } + + foreach ($segment in $normalised.Split('\')) { + if ($segment -eq '..') { return $null } + } + + $normalised = $normalised.TrimEnd('\') + if ([string]::IsNullOrWhiteSpace($normalised)) { return $null } + return $normalised +} + +function ConvertTo-OfflineComparableRegistryPath { + <# + .SYNOPSIS + Normalises the several spellings of an HKLM path to 'HKLM\Subkey', or $null. + #> + param([Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$Path) + + if ([string]::IsNullOrWhiteSpace($Path)) { return $null } + + $normalised = $Path.Trim().Replace('/', '\') + $normalised = $normalised -replace '^(Microsoft\.PowerShell\.Core\\)?Registry::', '' + $normalised = $normalised -replace '^HKEY_LOCAL_MACHINE(?=\\|$)', 'HKLM' + $normalised = $normalised -replace '^HKLM:(?=\\|$)', 'HKLM' + + while ($normalised.Contains('\\')) { $normalised = $normalised.Replace('\\', '\') } + foreach ($segment in $normalised.Split('\')) { + if ($segment -eq '..') { return $null } + } + + $normalised = $normalised.TrimEnd('\') + if ($normalised -notmatch '^HKLM(\\|$)') { return $null } + return $normalised +} + +function Test-OfflineRegistryPath { + <# + .SYNOPSIS + Reports whether a string is spelled as a registry path rather than a file path. + #> + param([Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$Path) + + if ([string]::IsNullOrWhiteSpace($Path)) { return $false } + return [bool]($Path.Trim() -match '^((Microsoft\.PowerShell\.Core\\)?Registry::)?(HKLM:|HKLM\\|HKEY_LOCAL_MACHINE)') +} + +function Test-OfflinePathUnderRoot { + <# + .SYNOPSIS + Reports whether a path is the given root or sits beneath it. + + .DESCRIPTION + Prefix comparison appends the separator before testing, so 'D:' does not match + 'DD:\x' and 'D:\Win' does not match 'D:\Windows'. + #> + param( + [Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$Path, + [Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$Root, + [Parameter(Mandatory = $false)][switch]$Registry + ) + + if ($Registry) { + $candidate = ConvertTo-OfflineComparableRegistryPath $Path + $prefix = ConvertTo-OfflineComparableRegistryPath $Root + } + else { + $candidate = ConvertTo-OfflineComparablePath $Path + $prefix = ConvertTo-OfflineComparablePath $Root + } + + if (-not $candidate -or -not $prefix) { return $false } + if ($candidate.Equals($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { return $true } + return $candidate.StartsWith($prefix + '\', [System.StringComparison]::OrdinalIgnoreCase) +} + +function Set-OfflineRepairRoot { + <# + .SYNOPSIS + Binds an offline volume, so the writing helpers can prove what they are acting on. + + .DESCRIPTION + Called by Get-OfflineWindowsDisk once it has chosen a volume. Refuses the rescue + VM's own system drive, because binding that would defeat the entire gate. + + .PARAMETER Path + Volume root, for example 'D:' or 'D:\'. UNC roots are accepted as '\\server\share'. + + .EXAMPLE + Set-OfflineRepairRoot -Path 'D:' + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'This changes in-process state only - it records which volume the helpers are allowed to touch - and never changes the system. Supporting -WhatIf would be actively harmful: skipping the bind would leave no root registered, so every subsequent Assert-OfflineTarget would throw and the run would fail for a reason unrelated to what the operator asked about.')] + param([Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$Path) + + $normalised = ConvertTo-OfflineComparablePath $Path + if (-not $normalised -or $normalised -notmatch $script:OfflineRootPattern) { + throw "'$Path' is not a usable offline root. Expected a drive root such as 'D:' or a UNC share root." + } + + $systemDrive = ConvertTo-OfflineComparablePath $env:SystemDrive + if ($systemDrive -and $normalised.Equals($systemDrive, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to bind '$Path' as an offline root: it is the rescue VM's own system drive." + } + + $state = Get-OfflineRepairState + if (-not ($state.Roots | Where-Object { $_.Equals($normalised, [System.StringComparison]::OrdinalIgnoreCase) })) { + [void]$state.Roots.Add($normalised) + Add-OfflineRepairLog -Level Info -Message "Bound offline repair root $normalised." + } + return $normalised +} + +function Get-OfflineRepairRoot { + <# + .SYNOPSIS + Returns the bound offline roots, most recently bound first. + #> + $roots = @((Get-OfflineRepairState).Roots) + if ($roots.Count -eq 0) { return @() } + [array]::Reverse($roots) + return $roots +} + +function Clear-OfflineRepairRoot { + <# + .SYNOPSIS + Releases every bound root and registered hive key. + + .DESCRIPTION + For a caller's finally block and for tests. After this, Assert-OfflineTarget + throws for every path until something is bound again. + #> + $state = Get-OfflineRepairState + $state.Roots.Clear() + $state.HiveKeys.Clear() +} + +function Register-OfflineHiveKey { + <# + .SYNOPSIS + Records a mount key as belonging to the offline image. + + .PARAMETER Key + Mount key, for example 'HKLM\BROKEN_SYSTEM'. + #> + param([Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$Key) + + $normalised = ConvertTo-OfflineComparableRegistryPath $Key + if (-not $normalised -or $normalised -eq 'HKLM') { + throw "'$Key' is not a usable offline hive mount key." + } + + $state = Get-OfflineRepairState + if (-not ($state.HiveKeys | Where-Object { $_.Equals($normalised, [System.StringComparison]::OrdinalIgnoreCase) })) { + [void]$state.HiveKeys.Add($normalised) + } + return $normalised +} + +function Unregister-OfflineHiveKey { + <# + .SYNOPSIS + Forgets a mount key once its hive has been unloaded. + #> + param([Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$Key) + + $normalised = ConvertTo-OfflineComparableRegistryPath $Key + if (-not $normalised) { return } + + $state = Get-OfflineRepairState + $existing = @($state.HiveKeys | Where-Object { $_.Equals($normalised, [System.StringComparison]::OrdinalIgnoreCase) }) + foreach ($item in $existing) { [void]$state.HiveKeys.Remove($item) } +} + +function Get-OfflineHiveKey { + <# + .SYNOPSIS + Returns the registered offline hive mount keys. + #> + return @((Get-OfflineRepairState).HiveKeys) +} + +function Assert-OfflineTarget { + <# + .SYNOPSIS + Throws unless a path belongs to the offline image. The gate every writer calls. + + .DESCRIPTION + Call this before enabling a privilege, taking ownership, writing, or deleting. + + It throws rather than returning $false, and it throws when nothing has been bound + at all. A helper that silently declines to act on an unbound target would let a + repair report success having changed nothing, and a helper that returned $false + would depend on every caller checking - which is the failure mode this exists to + remove. + + .PARAMETER Path + File system path or HKLM registry path to check. + + .PARAMETER OfflineRoot + Optional explicit root to check against instead of the bound roots. Use when a + caller wants to be explicit rather than rely on what Get-OfflineWindowsDisk bound. + + .PARAMETER Action + Short description of the operation, used in the exception message. + + .EXAMPLE + Assert-OfflineTarget -Path $file -Action 'take ownership' + #> + param( + [Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$Path, + [Parameter(Mandatory = $false)][string]$OfflineRoot, + [Parameter(Mandatory = $false)][string]$Action = 'modify' + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { + throw "Refusing to $Action an empty path." + } + + if (Test-OfflineRegistryPath $Path) { + $normalised = ConvertTo-OfflineComparableRegistryPath $Path + if (-not $normalised) { + throw "Refusing to $Action '$Path': it is not a usable HKLM path." + } + + $keys = if ($PSBoundParameters.ContainsKey('OfflineRoot') -and $OfflineRoot) { @($OfflineRoot) } else { @(Get-OfflineHiveKey) } + if ($keys.Count -eq 0) { + throw "Refusing to $Action '$Path': no offline hive is mounted, so this would act on the rescue VM's own registry." + } + foreach ($key in $keys) { + if (Test-OfflinePathUnderRoot -Path $normalised -Root $key -Registry) { return $normalised } + } + throw "Refusing to $Action '$Path': it is outside the mounted offline hive(s) $($keys -join ', ')." + } + + $normalised = ConvertTo-OfflineComparablePath $Path + if (-not $normalised) { + throw "Refusing to $Action '$Path': it is not a usable path." + } + if ($normalised -notmatch '^([A-Za-z]:|\\\\)') { + throw "Refusing to $Action '$Path': it is not rooted on a drive, so it would resolve against the rescue VM's current directory." + } + + # Belt and braces. Even a caller that somehow bound the wrong root cannot reach the + # rescue VM's own Windows directory through this gate. + $systemRoot = ConvertTo-OfflineComparablePath $env:SystemRoot + if ($systemRoot -and (Test-OfflinePathUnderRoot -Path $normalised -Root $systemRoot)) { + throw "Refusing to $Action '$Path': it is inside the rescue VM's own Windows directory." + } + + $roots = if ($PSBoundParameters.ContainsKey('OfflineRoot') -and $OfflineRoot) { @($OfflineRoot) } else { @(Get-OfflineRepairRoot) } + if ($roots.Count -eq 0) { + throw "Refusing to $Action '$Path': no offline root is bound. Run Get-OfflineWindowsDisk first, or pass -OfflineRoot." + } + foreach ($root in $roots) { + if (Test-OfflinePathUnderRoot -Path $normalised -Root $root) { return $normalised } + } + throw "Refusing to $Action '$Path': it is outside the bound offline root(s) $($roots -join ', ')." +} + +#endregion + +function Join-OfflinePath { + <# + .SYNOPSIS + Joins a root and a child path without requiring the drive to exist. + + .DESCRIPTION + Join-Path resolves the drive qualifier and throws DriveNotFoundException for a + letter that is not currently mounted. Offline repairs routinely build paths on + letters that are being mounted, are already unmounted, or are stale entries left + on a partition, so the join is done as plain string composition instead. + + The root has to be volume-qualified. A root of '\' or a bare relative path would + otherwise produce a root-relative result, which resolves against whatever drive the + rescue VM's current directory happens to be on. It does NOT have to be a volume + root: 'E:\Windows\System32\config' is a perfectly ordinary root for a join. + + .PARAMETER Root + Root of the path, with or without a trailing backslash. Must be drive- or + UNC-qualified, but may be at any depth. For example 'D:', 'D:\' or 'D:\Windows'. + + .PARAMETER ChildPath + Relative path under the root, with or without a leading backslash. + + .EXAMPLE + Join-OfflinePath -Root 'X:' -ChildPath 'Windows\System32\ntdll.dll' + #> + param( + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Root, + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$ChildPath + ) + + $trimmedRoot = ConvertTo-OfflineComparablePath $Root + if (-not $trimmedRoot -or $trimmedRoot -notmatch $script:OfflineQualifiedPathPattern) { return $null } + + if ([string]::IsNullOrWhiteSpace($ChildPath)) { return "$trimmedRoot\" } + return "$trimmedRoot\$($ChildPath.TrimStart('\'))" +} + +function Test-OfflinePath { + <# + .SYNOPSIS + Tests a path, returning false instead of throwing when the drive does not exist. + + .PARAMETER Path + Path to test. Treated literally, so square brackets and braces are safe. + + .EXAMPLE + if (Test-OfflinePath 'X:\Windows\System32\ntdll.dll') { 'found' } + #> + param( + [Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$Path + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { return $false } + try { return [bool](Test-Path -LiteralPath $Path -ErrorAction Stop) } + catch { return $false } +} + +function Test-OfflineFileSignature { + <# + .SYNOPSIS + Reports what can actually be proven about a binary on the offline disk. + + .DESCRIPTION + Authenticode alone is not enough offline. Most Windows inbox binaries are catalog + signed, and the catalog store of the broken installation is not available to the + rescue VM, so Get-AuthenticodeSignature reports NotSigned for perfectly good files. + Boot manager payloads are compressed stubs that are not parseable at all. + + The version resource is used as a fallback, but it is unsigned data that any file + can carry, so it never sets IsMicrosoft. Two separate signals are returned instead: + + IsMicrosoft cryptographically proven - a valid Authenticode signature + whose subject carries the Microsoft organisation RDN. Use + this before trusting a binary enough to act on it. + + IsLikelyMicrosoft proven, or claimed by the version resource. Use this when a + false negative is the more dangerous answer, for example when + deciding which drivers to leave alone. + + Confidence High when Authenticode gave a definitive answer, Low when + only the version resource was available, None when nothing + could be established. A caller must not treat None as either + trusted or untrusted - it means the file could not be checked. + + .PARAMETER FilePath + Full path to the file on the offline disk. + + .OUTPUTS + PSCustomObject with Path, IsSigned, IsMicrosoft, IsLikelyMicrosoft, Confidence, + Status, Subject and VersionCompany. + + .EXAMPLE + (Test-OfflineFileSignature -FilePath 'D:\Windows\System32\drivers\storvsc.sys').IsLikelyMicrosoft + + IsLikelyMicrosoft, not IsMicrosoft. storvsc.sys is an inbox driver, so it is + catalog-signed rather than Authenticode-signed, and its catalog lives on the + offline image the rescue VM cannot consult. IsMicrosoft is therefore $false for a + perfectly healthy copy. Asking for IsMicrosoft here would classify every inbox + driver as untrusted, which is the more dangerous answer in this direction. + + .EXAMPLE + $sig = Test-OfflineFileSignature -FilePath 'D:\Windows\System32\winload.efi' + if ($sig.Confidence -eq 'High' -and -not $sig.IsMicrosoft) { 'replace it' } + + The safe shape for the opposite direction. Act on a file being bad only when + Authenticode gave a definitive answer, so an unreadable catalog cannot be mistaken + for evidence of tampering. + #> + param( + [Parameter(Mandatory = $true)][string]$FilePath + ) + + $result = [PSCustomObject]@{ + Path = $FilePath + IsSigned = $false + IsMicrosoft = $false + IsLikelyMicrosoft = $false + Confidence = 'None' + Status = 'FileNotFound' + Subject = '' + VersionCompany = '' + } + + if (-not (Test-OfflinePath $FilePath)) { return $result } + + $item = Get-Item -LiteralPath $FilePath -Force -ErrorAction SilentlyContinue + if (-not $item -or $item.Length -eq 0) { + $result.Status = 'ZeroByte' + return $result + } + + try { $signature = Get-AuthenticodeSignature -LiteralPath $FilePath -ErrorAction Stop } + catch { + $result.Status = 'Error' + return $result + } + + $result.Status = [string]$signature.Status + $result.Subject = if ($signature.SignerCertificate) { $signature.SignerCertificate.Subject } else { '' } + + $versionInfo = $item.VersionInfo + if ($versionInfo -and $versionInfo.CompanyName) { $result.VersionCompany = [string]$versionInfo.CompanyName } + + if ($signature.Status -eq 'Valid') { + $result.IsSigned = $true + $result.Confidence = 'High' + # Anchored on the RDN boundary. An unanchored match is satisfied by a subject that + # merely contains the text, for example CN=O=Microsoft Corporation, O=Somebody Else. + if ($result.Subject -match '(?:^|,)\s*O=Microsoft Corporation\s*(?:,|$)') { + $result.IsMicrosoft = $true + $result.IsLikelyMicrosoft = $true + } + return $result + } + + if ($signature.Status -in @('HashMismatch', 'NotTrusted')) { + # Authenticode answered, and the answer is that the file is bad. + $result.Confidence = 'High' + return $result + } + + if ($result.VersionCompany -match 'Microsoft') { + # Consistent with a catalog signed inbox binary whose catalog the rescue VM cannot + # see. Claimed, not proven, so IsMicrosoft stays false. + $result.Status = 'CatalogSigned' + $result.IsLikelyMicrosoft = $true + $result.Confidence = 'Low' + return $result + } + + if ($signature.Status -in @('UnknownError', 'NotSupportedFileFormat')) { + # Not parseable by Authenticode and carrying no version resource, for example a + # compressed boot stub. Inconclusive: neither trusted nor untrusted. + $result.Status = 'NotVerifiable' + return $result + } + + return $result +} + +function Get-OfflineSecureBootState { + <# + .SYNOPSIS + Reads the Secure Boot state the guest last booted with, from the Measured Boot log. + + .DESCRIPTION + The obvious source, Control\SecureBoot\State\UEFISecureBootEnabled, does not work offline. + That key is volatile: Windows recreates it from the firmware at every boot and never writes + it to the SYSTEM hive file. Saving and reloading the hive on a running Server 2022 VM shows + AvailableUpdates, SBAT and Servicing surviving while State disappears, so an attached disk + never carries it. On a Generation 1 VM the key does not exist even while running. + + The firmware measures the EFI_GLOBAL_VARIABLE "SecureBoot" - a single byte, 0 or 1 - into + PCR[7], and Windows writes the whole TCG log to Windows\Logs\MeasuredBoot at every boot. + That is an ordinary file on the Windows partition, so it can simply be read. + + The record is UEFI_VARIABLE_DATA from the TCG PC Client Platform Firmware Profile: + + EFI_GUID VariableName; // +0, 16 bytes + UINT64 UnicodeNameLength; // +16, in CHAR16 units + UINT64 VariableDataLength; // +24, in bytes + CHAR16 UnicodeName[]; // +32 + INT8 VariableData[]; // the state byte + + An absent or empty log is reported as unknown rather than as "off". Azure allows Secure Boot + to be enabled with the vTPM disabled, and such a VM writes no Measured Boot log at all while + still having Secure Boot on, so absence proves nothing. + + .OUTPUTS + PSCustomObject with Known, Enabled, Source and MeasuredUtc. + #> + param([Parameter(Mandatory = $true)][string]$WindowsDrive) + + $result = [PSCustomObject]@{ Known = $false; Enabled = $false; Source = ''; MeasuredUtc = $null } + + $logDir = Join-OfflinePath -Root $WindowsDrive -ChildPath 'Windows\Logs\MeasuredBoot' + if (-not (Test-OfflinePath $logDir)) { + $result.Source = 'no Measured Boot log folder' + return $result + } + + $logs = @(Get-ChildItem -LiteralPath $logDir -Filter '*.log' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending) + if ($logs.Count -eq 0) { + $result.Source = 'the Measured Boot log folder is empty' + return $result + } + + # EFI_GLOBAL_VARIABLE {8BE4DF61-93CA-11D2-AA0D-00E098032B8C}, in the little-endian order the + # first three fields of an EFI_GUID are actually stored in. + $guid = [byte[]]@(0x61, 0xDF, 0xE4, 0x8B, 0xCA, 0x93, 0xD2, 0x11, 0xAA, 0x0D, 0x00, 0xE0, 0x98, 0x03, 0x2B, 0x8C) + + foreach ($log in ($logs | Select-Object -First 3)) { + try { $bytes = [System.IO.File]::ReadAllBytes($log.FullName) } + catch { + Add-OfflineRepairLog -Level Info -Message "Could not read the Measured Boot log $($log.Name): $($_.Exception.Message)" + continue + } + + for ($i = 0; $i -le $bytes.Length - 64; $i++) { + if ($bytes[$i] -ne $guid[0]) { continue } + + $matched = $true + for ($j = 1; $j -lt 16; $j++) { + if ($bytes[$i + $j] -ne $guid[$j]) { $matched = $false; break } + } + if (-not $matched) { continue } + + $nameLength = [BitConverter]::ToUInt64($bytes, $i + 16) + $dataLength = [BitConverter]::ToUInt64($bytes, $i + 24) + + # Guards against a random 16-byte run that happens to match the GUID. A real record + # names a variable of a few characters and carries a byte or two of data. + if ($nameLength -eq 0 -or $nameLength -gt 64) { continue } + if ($dataLength -lt 1 -or $dataLength -gt 65536) { continue } + if (($i + 32 + ($nameLength * 2) + $dataLength) -gt $bytes.Length) { continue } + + $nameBytes = New-Object byte[] ($nameLength * 2) + [Array]::Copy($bytes, $i + 32, $nameBytes, 0, $nameLength * 2) + if ([System.Text.Encoding]::Unicode.GetString($nameBytes) -ne 'SecureBoot') { continue } + + $result.Known = $true + $result.Enabled = ($bytes[$i + 32 + ($nameLength * 2)] -eq 1) + $result.Source = "Measured Boot log $($log.Name)" + $result.MeasuredUtc = $log.LastWriteTimeUtc + return $result + } + } + + $result.Source = 'the SecureBoot variable was not present in the most recent Measured Boot logs' + return $result +} diff --git a/src/windows/common/helpers/README.md b/src/windows/common/helpers/README.md index 616d2b67..c5bf15c5 100644 --- a/src/windows/common/helpers/README.md +++ b/src/windows/common/helpers/README.md @@ -8,6 +8,73 @@ Each helper script and description should be listed here. | `Get-Disk-Partitions.ps1` | Returns partitions of attached disks whose `Win32_diskdrive` model is `Microsoft Virtual Disk`, bringing them online with `diskpart`. **SCSI-attached disks only.** | | `Get-Disk-Partitions-v2.ps1` | As v1, with `$partitionlist` initialised to an array so a single result is not unrolled. **SCSI-attached disks only.** | | `Get-Disk-Partitions-v3.ps1` | `Get-Disk-Partitions-v3` selects attached disks by **BusType** (SCSI/SAS/RAID/NVMe) instead of the SCSI-only model string, so it also works when the repair VM uses the NVMe disk controller. Excludes the Azure resource disk. `Get-Windows-OsDrives-v3` narrows the result to drive letters that contain a Windows installation. | +| `OfflineRepairCommon.ps1` | Shared primitives for offline repair: buffered logging, path joining and validation, Authenticode/catalog signature inspection, a read-only `offreg.dll` hive reader, and the offline-target gate (`Set-OfflineRepairRoot` / `Assert-OfflineTarget`) that binds every other offline helper to the attached disk. | +| `Get-OfflineWindowsDisk.ps1` | Finds the offline Windows installation on the attached disk, verifies its disks are online and writable, and manages temporary drive letters for hidden EFI System and Recovery partitions. Selects by **BusType**, excludes resource disks by label or warning-file marker, and refuses the rescue VM's own boot/system disk. Reads hive metadata without registry mounts and binds the offline root for `Assert-OfflineTarget`. | +| `Use-OfflineRegistryHive.ps1` | Mounts writable hives from the attached disk, runs a scriptblock, and verifies their unload even after a partial mount failure. Its separate `Test-OfflineHiveFile` validation uses the shared in-memory reader without mounting or copying hives. | +| `Use-OfflineProtectedResource.ps1` | Takes ownership of, reads and restores files and registry keys on the attached disk that `SYSTEM` cannot otherwise open, restoring every security descriptor it changed and verifying the restore rather than counting it. | +| `Use-OfflinePrivilegedRegistry.ps1` | The privileged registry operations from `Use-OfflineProtectedResource.ps1`: enabling `SeTakeOwnershipPrivilege`/`SeRestorePrivilege` and removing or rewriting keys that deny access to `SYSTEM`. | +| `Get-OfflineBcdStore.ps1` | Locates the BCD store on the attached disk and runs `bcdedit.exe` against it directly, without a shell. Distinguishes an empty boot inventory from a failed enumeration, and refuses to operate on the rescue VM's own store. | +| `Use-OfflineFileRemoval.ps1` | Removes files from the attached disk with a backup, a verified rollback, and post-removal checks. Refuses to remove a registry hive or any of its side files, refuses to follow reparse points, and refuses any path outside the bound offline root. | +| `Use-NestedRepairVm.ps1` | Boots the offline Windows installation as a nested Hyper-V guest on the rescue VM for repairs that only the running OS can perform. Restores the offline state of every disk it took, on every exit path. | -**Which one to use:** new scripts should use **v3**. v1 and v2 are retained because existing scripts depend -on them; they return nothing on a repair VM created with the NVMe disk controller. +**Which one to use:** new scripts that need the *Windows installation* — to mount its hives, edit its +BCD, or repair files on it — should use `Get-OfflineWindowsDisk.ps1`, which also binds the offline root +for `Assert-OfflineTarget`. Use **`Get-Disk-Partitions-v3`** when you only need the attached partitions +or drive letters. v1 and v2 are retained because existing scripts depend on them; they return nothing on +a repair VM created with the NVMe disk controller. v1/v2 are not extended — port to v3 when you touch them. + +## Required caller contract + +Helpers throw on unsafe targets and failed preconditions. A `map.json` script must catch those +exceptions, log through the library logger, and return `$STATUS_ERROR` rather than exposing a raw +exception through `az vm repair run`. Always release temporary drive letters and flush helper logs +in `finally`, including when discovery fails partway through. + +```powershell +. .\src\windows\common\setup\init.ps1 + +$status = $STATUS_ERROR +try { + . .\src\windows\common\helpers\OfflineRepairCommon.ps1 + . .\src\windows\common\helpers\Get-OfflineWindowsDisk.ps1 + + $offline = Get-OfflineWindowsDisk + # Inspect and repair only the selected offline installation using guarded helpers. + # Set success only after the scenario's own verification succeeds. + $status = $STATUS_SUCCESS +} +catch { + Log-Error $_.Exception.Message +} +finally { + # A dependency may have failed to load before these functions became available. + if (Get-Command Clear-OfflineDriveLetter -ErrorAction SilentlyContinue) { + Clear-OfflineDriveLetter + } + if (Get-Command Write-OfflineRepairLog -ErrorAction SilentlyContinue) { + Write-OfflineRepairLog + } +} +return $status +``` + +Keep `Write-OfflineRepairLog` at script level: the logger writes to the output stream, so flushing +inside a value-returning helper contaminates its result. Return the final status after cleanup and +logging so it remains at the end of the output. + +## Read-only hive access + +Discovery and `Test-OfflineHiveFile` use the shared offreg reader in `OfflineRepairCommon.ps1`. +It uses the Windows Offline Registry Library (`System32\offreg.dll`), keeps recovery in memory, and +never invokes `reg.exe`, mounts an HKLM key, saves a hive, or copies credential-bearing hives to TEMP. +Keep matching recovery logs beside dirty hives. Reader handles must be disposed in `finally`; close +failures throw instead of letting a repair proceed with uncertain cleanup. + +Discovery records unreadable metadata in `ProbeStatus` and logs a warning because damaged hives are +a valid repair target. Hive validation instead throws when the reader itself is unavailable; that +environment failure must not trigger a corruption repair. `IsValid` means offreg can open the hive, +not that the guest will boot or that a separate structural check is unnecessary. + +`Invoke-WithHive` and the protected-registry helpers still expose writable HKLM paths. The in-memory +reader does not replace that contract; callers requiring those paths must keep using the guarded +mount/unmount helpers.