From 5a13b59ba04c3d92925e7dfc94975b46b1c4c00d Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Tue, 26 May 2026 13:08:34 +0300 Subject: [PATCH 01/43] Update sac-enabler.ps1 to v1.3 This script enables Special Administration Console (SAC) and Serial Console boot settings on an attached OS disk's BCD store. It includes enhanced logic for Gen2 disks and improved error handling. .VERSION v1.3: [May 2026] - Updated the script again (current) - Fixed breaking exception when the Hyper-V module is not installed on the host. - Added explicit checking via Get-Module before executing nested VM discovery. v1.2: [May 2026] - Updated the script - Included advanced Gen2 unlettered EFI fallback and dynamic drive-letter assignment. v0.1: Initial commit. This was the version 1.0 of the script --- src/windows/sac-enabler.ps1 | 319 +++++++++++++++++++++++++++++------- 1 file changed, 260 insertions(+), 59 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 31e0b7d4..d4e77bfd 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -1,82 +1,283 @@ <# .SYNOPSIS Enables Special Administration Console (SAC) and Serial Console boot settings. + .DESCRIPTION - Configures BCD to enable the boot menu, set a timeout, and turn on EMS/SAC - to allow serial console access to the VM. - Created by Tony.Mocanu@Microsoft.com + This script runs from a rescue VM to enable SAC/EMS on an attached OS disk's BCD store. + It performs the following steps: + 1. Enumerates attached partitions via Get-Disk-Partitions to locate the BCD store and OS loader. + 1a. For Gen2 disks where the EFI partition has no drive letter, uses diskpart to + temporarily assign one so the BCD store can be accessed. + 2. Identifies the default boot entry GUID from the BCD bootmgr displayorder. + 3. Logs the BCD configuration before any changes are made. + 4. Enables the boot menu with a 5-second timeout (displaybootmenu, timeout). + 5. Enables Boot EMS on the boot manager (bootems yes). + 6. Enables EMS on the default OS entry (ems ON). + 7. Configures EMS settings for serial console (EMSPORT:1, EMSBAUDRATE:115200). + 8. Logs the BCD configuration after changes for verification. + +.NOTES + Name: sac-enabler.ps1 + Author: Tony.Mocanu@Microsoft.com + + .VERSION + v1.3: [May 2026] - Updated the script again (current) + - Fixed breaking exception when the Hyper-V module is not installed on the host. + - Added explicit checking via Get-Module before executing nested VM discovery. + v1.2: [May 2026] - Updated the script + - Included advanced Gen2 unlettered EFI fallback and dynamic drive-letter assignment. + v0.1: Initial commit. This was the version 1.0 of the script. + +.SCENARIO_RECREATION + To recreate a testable scenario on a rescue VM with an attached OS disk: + 1. Create a test VM in Azure and attach its OS disk to a rescue VM. + 2. The BCD store is on the System Reserved (Gen1) or EFI (Gen2) partition, which + may not have a drive letter. Find it by scanning all volumes (run as Admin): +Get-Volume | Where-Object { $_.DriveLetter } | ForEach-Object { $d = $_.DriveLetter; @("$d`:\boot\bcd","$d`:\efi\microsoft\boot\bcd") | Where-Object { Test-Path $_ } | ForEach-Object { Write-Output "FOUND: $_" } } + If nothing is found, the partition has no drive letter. For System Reserved (Gen1): +Get-Partition | Where-Object { -not $_.DriveLetter -and $_.Size -lt 1GB } | Format-Table DiskNumber, PartitionNumber, Size, Type +Set-Partition -DiskNumber -PartitionNumber -NewDriveLetter S + For EFI partitions (Gen2), Set-Partition won't work -- use diskpart instead: + diskpart + select disk + select partition + assign letter=S + exit + Then check: Test-Path S:\boot\bcd or Test-Path S:\efi\microsoft\boot\bcd + + Example with two attached disks (from Disk Management): + Disk 2 (Gen1): System Reserved (F:) 500 MB | Windows (G:) 126 GB + -> BCD already accessible at F:\boot\bcd + Disk 3 (Gen2): 450 MB (no letter) | EFI (no letter) 99 MB | Windows (H:) 126 GB + -> EFI partitions are protected; use diskpart to assign a letter: + diskpart + select disk 3 + select partition 2 + assign letter=S + exit + -> BCD at S:\efi\microsoft\boot\bcd + + 3. Once you have the BCD path, disable SAC/EMS to simulate a broken VM: + + Gen1 example (F:\boot\bcd): +bcdedit /store F:\boot\bcd /ems "{default}" OFF +bcdedit /store F:\boot\bcd /set "{bootmgr}" bootems no +bcdedit /store F:\boot\bcd /set "{bootmgr}" displaybootmenu no + + Gen2 example (S:\efi\microsoft\boot\bcd): +bcdedit /store S:\efi\microsoft\boot\bcd /ems "{default}" OFF +bcdedit /store S:\efi\microsoft\boot\bcd /set "{bootmgr}" bootems no +bcdedit /store S:\efi\microsoft\boot\bcd /set "{bootmgr}" displaybootmenu no + + 4. Verify EMS is disabled: +bcdedit /store F:\boot\bcd /enum "{default}" +bcdedit /store F:\boot\bcd /enum "{bootmgr}" + Expected: ems = No or absent, bootems = No or absent. + 5. Run the script. It should enable ems, bootems, displaybootmenu, and emssettings. + 6. Verify all SAC settings are now enabled (see .VERIFICATION section). + +.EXAMPLE + az vm repair run -g -n --run-id win-sac-enabler --run-on-repair + +.VERIFICATION + 1. Check the log file for success: +Get-ChildItem "C:\WindowsAzure\Logs\Plugins\Microsoft.Compute.CustomScriptExtension\sac-enabler_*.log" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 | Get-Content + Expected: "BCD AFTER SAC ENABLE" section present and return code 0 ($STATUS_SUCCESS). + 2. Manually verify the BCD store (replace drive letters with the ones found in step 2): + + Gen1 (System Reserved on F:): +bcdedit /store F:\boot\bcd /enum "{default}" +bcdedit /store F:\boot\bcd /enum "{bootmgr}" + + Gen2 (EFI partition -- use diskpart to assign a letter if needed, e.g. P:): +bcdedit /store P:\efi\microsoft\boot\bcd /enum "{default}" +bcdedit /store P:\efi\microsoft\boot\bcd /enum "{bootmgr}" + + Expected: ems = Yes on the OS entry, bootems = Yes on bootmgr, + displaybootmenu = Yes, timeout = 5, EMSPORT = 1, EMSBAUDRATE = 115200. + + NOTE: For Gen2 disks, the script automatically assigns a temporary drive letter + to the EFI System Partition via diskpart if Get-Disk-Partitions did not assign one. + The temporary letter is removed after processing. #> -# 1. Initialize script and helper functions +# Initialization . .\src\windows\common\setup\init.ps1 -. .\src\windows\common\helpers\Get-Disk-Partitions.ps1 +. .\src\windows\common\helpers\Get-Disk-Partitions-v2.ps1 -# 2. Set Log Path to Public Desktop -$logFile = "C:\Users\Public\Desktop\sac-enabler-log.txt" +# Log Configuration +$logDir = "C:\WindowsAzure\Logs\Plugins\Microsoft.Compute.CustomScriptExtension" +if (-not (Test-Path $logDir)) { $null = New-Item -ItemType Directory -Path $logDir -Force } +$timestamp = Get-Date -Format "yyyyMMdd_HHmmss" +$logFile = "$logDir\sac-enabler_$timestamp.log" -# 3. Execution Logic -$partitionlist = Get-Disk-Partitions -Log-Info '#03 - Enumerate partitions to enable SAC' | Tee-Object -FilePath $logFile -Append +# Status Tracking +$script_final_status = $STATUS_ERROR -foreach ( $partitionGroup in $partitionlist | group DiskNumber ) -{ - $isBcdPath = $false - $bcdPath = '' - $isOsPath = $false +try { + # Check if the Hyper-V module is available before performing nested VM checks + if (Get-Module -ListAvailable -Name Hyper-V) { + $guestHyperVVirtualMachine = Get-VM -ErrorAction SilentlyContinue -WarningAction SilentlyContinue + if ($guestHyperVVirtualMachine) { + if ($guestHyperVVirtualMachine.State -eq 'Running') { + Log-Info "Stopping nested guest VM $($guestHyperVVirtualMachine.VMName)" | Tee-Object -FilePath $logFile -Append + try { + Stop-VM $guestHyperVVirtualMachine -ErrorAction Stop -Force + } + catch { + Log-Warning "Failed to stop nested guest VM, will continue but may have limited success" | Tee-Object -FilePath $logFile -Append + } + } + } + } else { + Log-Info "Hyper-V PowerShell module is not available on this host. Skipping nested VM validation." | Tee-Object -FilePath $logFile -Append + } + + # Step 1 - Enumerate partitions to locate the BCD store and OS loader + $partitionlist = Get-Disk-Partitions + $rescueDrive = $env:SystemDrive -replace ':', '' + Log-Info 'Enumerating partitions to enable SAC...' | Tee-Object -FilePath $logFile -Append + + foreach ( $partitionGroup in $partitionlist | group DiskNumber ) + { + $isBcdPath = $false + $bcdPath = '' + $isOsPath = $false - # Discovery Logic (Matches your BCD logic for consistency) - ForEach ($drive in $partitionGroup.Group | select -ExpandProperty DriveLetter ) - { - if ( -not $isBcdPath ) + # Scan each drive for BCD store and Windows OS loader + ForEach ($drive in $partitionGroup.Group | select -ExpandProperty DriveLetter ) { - $bcdPath = $drive + ':\boot\bcd' - $isBcdPath = Test-Path $bcdPath + # Skip the rescue VM's own OS drive + if ($drive -eq $rescueDrive) { continue } + if ( -not $isBcdPath ) { - $bcdPath = $drive + ':\efi\microsoft\boot\bcd' + $bcdPath = $drive + ':\boot\bcd' $isBcdPath = Test-Path $bcdPath - } - } - if (-not $isOsPath) + if ( -not $isBcdPath ) + { + $bcdPath = $drive + ':\efi\microsoft\boot\bcd' + $isBcdPath = Test-Path $bcdPath + } + } + if (-not $isOsPath) + { + $isOsPath = Test-Path ($drive + ':\windows\system32\winload.exe') + } + } + + # Gen2 EFI fallback: if OS found but no BCD, discover unlettered EFI partition + $tempEfiLetter = $null + $tempEfiDiskNum = $null + $tempEfiPartNum = $null + if (-not $isBcdPath -and $isOsPath) { - $isOsPath = Test-Path ($drive + ':\windows\system32\winload.exe') + $diskNum = [int]$partitionGroup.Name + $rescueDiskNum = (Get-Partition -DriveLetter $rescueDrive -ErrorAction SilentlyContinue | Select-Object -First 1).DiskNumber + if ($diskNum -ne $rescueDiskNum) + { + Log-Info "Disk ${diskNum}: OS found but no BCD - checking for unlettered EFI partition (Gen2)..." | Tee-Object -FilePath $logFile -Append + $efiGptType = '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}' + $efiParts = Get-Partition -DiskNumber $diskNum -ErrorAction SilentlyContinue | Where-Object { + $_.GptType -eq $efiGptType -and (-not $_.DriveLetter -or $_.DriveLetter -eq [char]0) + } + if ($efiParts) + { + # Find an available drive letter (Z downward to avoid conflicts) + $usedLetters = @() + Get-Volume -ErrorAction SilentlyContinue | Where-Object { $_.DriveLetter } | ForEach-Object { $usedLetters += $_.DriveLetter } + $tempLetter = $null + foreach ($l in @('Z','Y','X','W','V','U','T','S','R','Q')) { + if ($l -notin $usedLetters) { $tempLetter = $l; break } + } + if ($tempLetter) + { + foreach ($ep in $efiParts) + { + $pn = $ep.PartitionNumber + Log-Info "Assigning temp letter ${tempLetter}: to Disk $diskNum Partition $pn (EFI)..." | Tee-Object -FilePath $logFile -Append + $dpLines = @("select disk $diskNum", "select partition $pn", "assign letter=$tempLetter") + $dpLines | diskpart | Out-Null + Start-Sleep -Seconds 2 + $bcdPath = "${tempLetter}:\efi\microsoft\boot\bcd" + $isBcdPath = Test-Path $bcdPath + if ($isBcdPath) + { + Log-Info "Found Gen2 BCD store at $bcdPath" | Tee-Object -FilePath $logFile -Append + $tempEfiLetter = $tempLetter + $tempEfiDiskNum = $diskNum + $tempEfiPartNum = $pn + break + } + else + { + Log-Info "No BCD at $bcdPath, removing letter..." | Tee-Object -FilePath $logFile -Append + $dpRemove = @("select disk $diskNum", "select partition $pn", "remove letter=$tempLetter") + $dpRemove | diskpart | Out-Null + } + } + } + else + { + Log-Warning "No available drive letter for EFI partition on Disk $diskNum" | Tee-Object -FilePath $logFile -Append + } + } + } } - } - # 4. Apply SAC Changes if BCD is found - if ( $isBcdPath -and $isOsPath ) - { - # Capture the target ID (usually {default}) - $bcdout = bcdedit /store $bcdPath /enum bootmgr /v - $defaultLine = $bcdout | Select-String 'displayorder' | select -First 1 - - if ($defaultLine -match '\{([^}]+)\}') { - $defaultId = $matches[0] - - Log-Output "--- BCD BEFORE SAC ENABLE ---" | Tee-Object -FilePath $logFile -Append - $beforeBcd = bcdedit /store $bcdPath /enum $defaultId - foreach ($line in $beforeBcd) { if ($line.Trim()) { Log-Output $line | Tee-Object -FilePath $logFile -Append } } - - Log-Info "Applying SAC and EMS configurations..." | Tee-Object -FilePath $logFile -Append - - # Core Logic from Original Script - bcdedit /store $bcdPath /set "{bootmgr}" displaybootmenu yes | Out-Null - bcdedit /store $bcdPath /set "{bootmgr}" timeout 5 | Out-Null - bcdedit /store $bcdPath /set "{bootmgr}" bootems yes | Out-Null - bcdedit /store $bcdPath /ems $defaultId ON | Out-Null - $res = bcdedit /store $bcdPath /emssettings EMSPORT:1 EMSBAUDRATE:115200 - - Log-Output "Result: $res" | Tee-Object -FilePath $logFile -Append - - # --- AFTER CHANGE (Line-by-Line Logging) --- - Log-Output "--- BCD AFTER SAC ENABLE ---" | Tee-Object -FilePath $logFile -Append - $afterBcd = bcdedit /store $bcdPath /enum $defaultId - foreach ($line in $afterBcd) { if ($line.Trim()) { Log-Output $line | Tee-Object -FilePath $logFile -Append } } + # Apply SAC changes if both BCD and OS loader were found + if ( $isBcdPath -and $isOsPath ) + { + # Step 2 - Identify the default boot entry GUID + $bcdout = bcdedit /store $bcdPath /enum bootmgr /v + $defaultLine = $bcdout | Select-String 'displayorder' | select -First 1 - return $STATUS_SUCCESS + if ($defaultLine -match '\{([^}]+)\}') { + $defaultId = $matches[0] + + # Step 3 - Log BCD configuration before changes + Log-Output "--- BCD BEFORE SAC ENABLE ---" | Tee-Object -FilePath $logFile -Append + $beforeBcd = bcdedit /store $bcdPath /enum $defaultId + foreach ($line in $beforeBcd) { if ($line.Trim()) { Log-Output $line | Tee-Object -FilePath $logFile -Append } } + + # Steps 4-7 - Enable boot menu, Boot EMS, EMS on OS entry, and EMS serial settings + Log-Info "Applying SAC and EMS configurations..." | Tee-Object -FilePath $logFile -Append + bcdedit /store $bcdPath /set "{bootmgr}" displaybootmenu yes | Out-Null + bcdedit /store $bcdPath /set "{bootmgr}" timeout 5 | Out-Null + bcdedit /store $bcdPath /set "{bootmgr}" bootems yes | Out-Null + bcdedit /store $bcdPath /ems $defaultId ON | Out-Null + $res = bcdedit /store $bcdPath /emssettings EMSPORT:1 EMSBAUDRATE:115200 + + Log-Output "Result: $res" | Tee-Object -FilePath $logFile -Append + + # Step 8 - Log BCD configuration after changes for verification + Log-Output "--- BCD AFTER SAC ENABLE ---" | Tee-Object -FilePath $logFile -Append + $afterBcd = bcdedit /store $bcdPath /enum $defaultId + foreach ($line in $afterBcd) { if ($line.Trim()) { Log-Output $line | Tee-Object -FilePath $logFile -Append } } + + $script_final_status = $STATUS_SUCCESS + } + } + + # Clean up temporary EFI drive letter if one was assigned + if ($tempEfiLetter) + { + Log-Info "Removing temp letter ${tempEfiLetter}: from Disk $tempEfiDiskNum Partition $tempEfiPartNum" | Tee-Object -FilePath $logFile -Append + $dpClean = @("select disk $tempEfiDiskNum", "select partition $tempEfiPartNum", "remove letter=$tempEfiLetter") + $dpClean | diskpart | Out-Null } } + + if ($script_final_status -ne $STATUS_SUCCESS) { + Log-Error "FAILED: Script could not find a valid OS disk to enable SAC." | Tee-Object -FilePath $logFile -Append + } +} +catch { + Log-Error "An error occurred: $($_.Exception.Message)" | Tee-Object -FilePath $logFile -Append + $script_final_status = $STATUS_ERROR +} +finally { + Log-Info "Script ended at $(Get-Date)" | Tee-Object -FilePath $logFile -Append } -Log-Error "FAILED: Script could not find a valid OS disk to enable SAC." | Tee-Object -FilePath $logFile -Append -return $STATUS_ERROR +return $script_final_status From a8a9113a68fb267e355f4f0cec76933720cf8293 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:59:31 +0300 Subject: [PATCH 02/43] Enhance SAC enabler script for better OS detection Updated script to enable SAC and Serial Console boot settings on attached Windows disks, improving OS detection and error handling. --- src/windows/sac-enabler.ps1 | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index d4e77bfd..f88306dc 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -1,14 +1,16 @@ <# .SYNOPSIS - Enables Special Administration Console (SAC) and Serial Console boot settings. + Enables SAC and Serial Console boot settings on attached Windows disks, including BIOS and UEFI layouts. .DESCRIPTION This script runs from a rescue VM to enable SAC/EMS on an attached OS disk's BCD store. It performs the following steps: 1. Enumerates attached partitions via Get-Disk-Partitions to locate the BCD store and OS loader. + OS detection accepts either winload.exe or winload.efi. 1a. For Gen2 disks where the EFI partition has no drive letter, uses diskpart to temporarily assign one so the BCD store can be accessed. 2. Identifies the default boot entry GUID from the BCD bootmgr displayorder. + If the default entry cannot be determined, the script logs an explicit warning. 3. Logs the BCD configuration before any changes are made. 4. Enables the boot menu with a 5-second timeout (displaybootmenu, timeout). 5. Enables Boot EMS on the boot manager (bootems yes). @@ -113,6 +115,7 @@ $logFile = "$logDir\sac-enabler_$timestamp.log" # Status Tracking $script_final_status = $STATUS_ERROR +$failureReason = 'Script could not find a valid OS disk to enable SAC.' try { # Check if the Hyper-V module is available before performing nested VM checks @@ -138,14 +141,14 @@ try { $rescueDrive = $env:SystemDrive -replace ':', '' Log-Info 'Enumerating partitions to enable SAC...' | Tee-Object -FilePath $logFile -Append - foreach ( $partitionGroup in $partitionlist | group DiskNumber ) + foreach ( $partitionGroup in $partitionlist | Group-Object DiskNumber ) { $isBcdPath = $false $bcdPath = '' $isOsPath = $false # Scan each drive for BCD store and Windows OS loader - ForEach ($drive in $partitionGroup.Group | select -ExpandProperty DriveLetter ) + ForEach ($drive in $partitionGroup.Group | Select-Object -ExpandProperty DriveLetter ) { # Skip the rescue VM's own OS drive if ($drive -eq $rescueDrive) { continue } @@ -162,7 +165,9 @@ try { } if (-not $isOsPath) { - $isOsPath = Test-Path ($drive + ':\windows\system32\winload.exe') + $winloadExePath = $drive + ':\windows\system32\winload.exe' + $winloadEfiPath = $drive + ':\windows\system32\winload.efi' + $isOsPath = (Test-Path $winloadExePath) -or (Test-Path $winloadEfiPath) } } @@ -230,9 +235,14 @@ try { { # Step 2 - Identify the default boot entry GUID $bcdout = bcdedit /store $bcdPath /enum bootmgr /v - $defaultLine = $bcdout | Select-String 'displayorder' | select -First 1 - - if ($defaultLine -match '\{([^}]+)\}') { + $defaultLine = $bcdout | Select-String 'displayorder' | Select-Object -First 1 + + if (-not $defaultLine) + { + $failureReason = "Could not locate a displayorder entry in boot manager output for $bcdPath." + Log-Warning "Could not locate a displayorder entry in boot manager output for $bcdPath. Unable to determine the default boot entry." | Tee-Object -FilePath $logFile -Append + } + elseif ($defaultLine -match '\{([^}]+)\}') { $defaultId = $matches[0] # Step 3 - Log BCD configuration before changes @@ -257,6 +267,11 @@ try { $script_final_status = $STATUS_SUCCESS } + else + { + $failureReason = "Displayorder entry was found but no boot entry GUID could be parsed for $bcdPath." + Log-Warning "Displayorder entry was found but no boot entry GUID could be parsed for $bcdPath. Raw line: $($defaultLine.Line)" | Tee-Object -FilePath $logFile -Append + } } # Clean up temporary EFI drive letter if one was assigned @@ -269,7 +284,7 @@ try { } if ($script_final_status -ne $STATUS_SUCCESS) { - Log-Error "FAILED: Script could not find a valid OS disk to enable SAC." | Tee-Object -FilePath $logFile -Append + Log-Error "FAILED: $failureReason" | Tee-Object -FilePath $logFile -Append } } catch { From 2af8d50da09d4f39f0a894cd6a3645d9846c473a Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:58:15 +0300 Subject: [PATCH 03/43] Update sac-enabler.ps1 --- src/windows/sac-enabler.ps1 | 197 +++++++++++++++++++++++++++++------- 1 file changed, 158 insertions(+), 39 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index f88306dc..f4805dc0 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -103,19 +103,95 @@ bcdedit /store P:\efi\microsoft\boot\bcd /enum "{bootmgr}" The temporary letter is removed after processing. #> -# Initialization -. .\src\windows\common\setup\init.ps1 -. .\src\windows\common\helpers\Get-Disk-Partitions-v2.ps1 +# Initialization (path-validated) +$initPath = Join-Path $PSScriptRoot 'src\windows\common\setup\init.ps1' +$diskPartitionsPath = Join-Path $PSScriptRoot 'src\windows\common\helpers\Get-Disk-Partitions-v2.ps1' -# Log Configuration -$logDir = "C:\WindowsAzure\Logs\Plugins\Microsoft.Compute.CustomScriptExtension" -if (-not (Test-Path $logDir)) { $null = New-Item -ItemType Directory -Path $logDir -Force } +if (-not (Test-Path -LiteralPath $initPath)) { + Write-Error "Required helper not found: $initPath" + return 1 +} + +. $initPath + +if (-not (Test-Path -LiteralPath $diskPartitionsPath)) { + Log-Error "Required helper not found: $diskPartitionsPath" + return $STATUS_ERROR +} + +. $diskPartitionsPath + +# Log Configuration (desktop log standard) +$desktopPath = [Environment]::GetFolderPath('Desktop') +if ([string]::IsNullOrWhiteSpace($desktopPath)) { + $desktopPath = Join-Path $env:PUBLIC 'Desktop' +} + +$logDir = Join-Path $desktopPath 'RepairLogs' +if (-not (Test-Path -LiteralPath $logDir)) { + $null = New-Item -ItemType Directory -Path $logDir -Force +} $timestamp = Get-Date -Format "yyyyMMdd_HHmmss" -$logFile = "$logDir\sac-enabler_$timestamp.log" +$logFile = Join-Path $logDir "sac-enabler_$timestamp.log" + +if (-not (Test-Path -LiteralPath $logFile)) { + $null = New-Item -Path $logFile -ItemType File -Force +} + +function Write-DesktopLogLine { + param([string]$Message) + + if ($null -ne $Message) { + Add-Content -LiteralPath $logFile -Value ("[{0}] {1}" -f (Get-Date -Format 's'), $Message) + } +} + +$script:_origLogInfo = (Get-Command Log-Info -ErrorAction SilentlyContinue).ScriptBlock +$script:_origLogWarning = (Get-Command Log-Warning -ErrorAction SilentlyContinue).ScriptBlock +$script:_origLogError = (Get-Command Log-Error -ErrorAction SilentlyContinue).ScriptBlock +$script:_origLogOutput = (Get-Command Log-Output -ErrorAction SilentlyContinue).ScriptBlock + +if ($script:_origLogInfo) { + function Log-Info { + param([string]$Message) + & $script:_origLogInfo $Message + Write-DesktopLogLine "[INFO] $Message" + } +} + +if ($script:_origLogWarning) { + function Log-Warning { + param([string]$Message) + & $script:_origLogWarning $Message + Write-DesktopLogLine "[WARN] $Message" + } +} + +if ($script:_origLogError) { + function Log-Error { + param([string]$Message) + & $script:_origLogError $Message + Write-DesktopLogLine "[ERROR] $Message" + } +} + +if ($script:_origLogOutput) { + function Log-Output { + param([string]$Message) + & $script:_origLogOutput $Message + Write-DesktopLogLine "[OUTPUT] $Message" + } +} # Status Tracking $script_final_status = $STATUS_ERROR $failureReason = 'Script could not find a valid OS disk to enable SAC.' +$processedCount = 0 +$skippedCount = 0 +$failedCount = 0 +$changedCount = 0 + +Log-Info "Starting SAC enabler. Desktop log: $logFile" try { # Check if the Hyper-V module is available before performing nested VM checks @@ -123,29 +199,39 @@ try { $guestHyperVVirtualMachine = Get-VM -ErrorAction SilentlyContinue -WarningAction SilentlyContinue if ($guestHyperVVirtualMachine) { if ($guestHyperVVirtualMachine.State -eq 'Running') { - Log-Info "Stopping nested guest VM $($guestHyperVVirtualMachine.VMName)" | Tee-Object -FilePath $logFile -Append + Log-Info "Stopping nested guest VM $($guestHyperVVirtualMachine.VMName)" try { Stop-VM $guestHyperVVirtualMachine -ErrorAction Stop -Force } catch { - Log-Warning "Failed to stop nested guest VM, will continue but may have limited success" | Tee-Object -FilePath $logFile -Append + Log-Warning "Failed to stop nested guest VM, will continue but may have limited success" } } } } else { - Log-Info "Hyper-V PowerShell module is not available on this host. Skipping nested VM validation." | Tee-Object -FilePath $logFile -Append + Log-Info "Hyper-V PowerShell module is not available on this host. Skipping nested VM validation." } # Step 1 - Enumerate partitions to locate the BCD store and OS loader $partitionlist = Get-Disk-Partitions $rescueDrive = $env:SystemDrive -replace ':', '' - Log-Info 'Enumerating partitions to enable SAC...' | Tee-Object -FilePath $logFile -Append + Log-Info 'Enumerating partitions to enable SAC...' foreach ( $partitionGroup in $partitionlist | Group-Object DiskNumber ) { + $processedCount++ + $diskChanged = $false + $diskFailed = $false + $diskNumber = $partitionGroup.Name $isBcdPath = $false $bcdPath = '' $isOsPath = $false + $tempEfiLetter = $null + $tempEfiDiskNum = $null + $tempEfiPartNum = $null + Log-Info "Processing Disk $diskNumber" + + try { # Scan each drive for BCD store and Windows OS loader ForEach ($drive in $partitionGroup.Group | Select-Object -ExpandProperty DriveLetter ) @@ -172,16 +258,13 @@ try { } # Gen2 EFI fallback: if OS found but no BCD, discover unlettered EFI partition - $tempEfiLetter = $null - $tempEfiDiskNum = $null - $tempEfiPartNum = $null if (-not $isBcdPath -and $isOsPath) { $diskNum = [int]$partitionGroup.Name $rescueDiskNum = (Get-Partition -DriveLetter $rescueDrive -ErrorAction SilentlyContinue | Select-Object -First 1).DiskNumber if ($diskNum -ne $rescueDiskNum) { - Log-Info "Disk ${diskNum}: OS found but no BCD - checking for unlettered EFI partition (Gen2)..." | Tee-Object -FilePath $logFile -Append + Log-Info "Disk ${diskNum}: OS found but no BCD - checking for unlettered EFI partition (Gen2)..." $efiGptType = '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}' $efiParts = Get-Partition -DiskNumber $diskNum -ErrorAction SilentlyContinue | Where-Object { $_.GptType -eq $efiGptType -and (-not $_.DriveLetter -or $_.DriveLetter -eq [char]0) @@ -200,15 +283,16 @@ try { foreach ($ep in $efiParts) { $pn = $ep.PartitionNumber - Log-Info "Assigning temp letter ${tempLetter}: to Disk $diskNum Partition $pn (EFI)..." | Tee-Object -FilePath $logFile -Append + Log-Info "Assigning temp letter ${tempLetter}: to Disk $diskNum Partition $pn (EFI)..." $dpLines = @("select disk $diskNum", "select partition $pn", "assign letter=$tempLetter") - $dpLines | diskpart | Out-Null + $dpAssignOut = $dpLines | diskpart 2>&1 + foreach ($line in @($dpAssignOut)) { if ($line) { Log-Output "[diskpart][assign] $line" } } Start-Sleep -Seconds 2 $bcdPath = "${tempLetter}:\efi\microsoft\boot\bcd" $isBcdPath = Test-Path $bcdPath if ($isBcdPath) { - Log-Info "Found Gen2 BCD store at $bcdPath" | Tee-Object -FilePath $logFile -Append + Log-Info "Found Gen2 BCD store at $bcdPath" $tempEfiLetter = $tempLetter $tempEfiDiskNum = $diskNum $tempEfiPartNum = $pn @@ -216,15 +300,16 @@ try { } else { - Log-Info "No BCD at $bcdPath, removing letter..." | Tee-Object -FilePath $logFile -Append + Log-Info "No BCD at $bcdPath, removing letter..." $dpRemove = @("select disk $diskNum", "select partition $pn", "remove letter=$tempLetter") - $dpRemove | diskpart | Out-Null + $dpRemoveOut = $dpRemove | diskpart 2>&1 + foreach ($line in @($dpRemoveOut)) { if ($line) { Log-Output "[diskpart][remove] $line" } } } } } else { - Log-Warning "No available drive letter for EFI partition on Disk $diskNum" | Tee-Object -FilePath $logFile -Append + Log-Warning "No available drive letter for EFI partition on Disk $diskNum" } } } @@ -240,59 +325,93 @@ try { if (-not $defaultLine) { $failureReason = "Could not locate a displayorder entry in boot manager output for $bcdPath." - Log-Warning "Could not locate a displayorder entry in boot manager output for $bcdPath. Unable to determine the default boot entry." | Tee-Object -FilePath $logFile -Append + Log-Warning "Could not locate a displayorder entry in boot manager output for $bcdPath. Unable to determine the default boot entry." + $diskFailed = $true } elseif ($defaultLine -match '\{([^}]+)\}') { $defaultId = $matches[0] # Step 3 - Log BCD configuration before changes - Log-Output "--- BCD BEFORE SAC ENABLE ---" | Tee-Object -FilePath $logFile -Append + Log-Output "--- BCD BEFORE SAC ENABLE ---" $beforeBcd = bcdedit /store $bcdPath /enum $defaultId - foreach ($line in $beforeBcd) { if ($line.Trim()) { Log-Output $line | Tee-Object -FilePath $logFile -Append } } + foreach ($line in $beforeBcd) { if ($line.Trim()) { Log-Output $line } } # Steps 4-7 - Enable boot menu, Boot EMS, EMS on OS entry, and EMS serial settings - Log-Info "Applying SAC and EMS configurations..." | Tee-Object -FilePath $logFile -Append - bcdedit /store $bcdPath /set "{bootmgr}" displaybootmenu yes | Out-Null - bcdedit /store $bcdPath /set "{bootmgr}" timeout 5 | Out-Null - bcdedit /store $bcdPath /set "{bootmgr}" bootems yes | Out-Null - bcdedit /store $bcdPath /ems $defaultId ON | Out-Null - $res = bcdedit /store $bcdPath /emssettings EMSPORT:1 EMSBAUDRATE:115200 + Log-Info "Applying SAC and EMS configurations..." + $setBootMenuOut = bcdedit /store $bcdPath /set "{bootmgr}" displaybootmenu yes 2>&1 + foreach ($line in @($setBootMenuOut)) { if ($line) { Log-Output "[bcdedit][displaybootmenu] $line" } } + + $setTimeoutOut = bcdedit /store $bcdPath /set "{bootmgr}" timeout 5 2>&1 + foreach ($line in @($setTimeoutOut)) { if ($line) { Log-Output "[bcdedit][timeout] $line" } } + + $setBootEmsOut = bcdedit /store $bcdPath /set "{bootmgr}" bootems yes 2>&1 + foreach ($line in @($setBootEmsOut)) { if ($line) { Log-Output "[bcdedit][bootems] $line" } } - Log-Output "Result: $res" | Tee-Object -FilePath $logFile -Append + $setEmsOut = bcdedit /store $bcdPath /ems $defaultId ON 2>&1 + foreach ($line in @($setEmsOut)) { if ($line) { Log-Output "[bcdedit][ems] $line" } } + + $setEmsSettingsOut = bcdedit /store $bcdPath /emssettings EMSPORT:1 EMSBAUDRATE:115200 2>&1 + foreach ($line in @($setEmsSettingsOut)) { if ($line) { Log-Output "[bcdedit][emssettings] $line" } } # Step 8 - Log BCD configuration after changes for verification - Log-Output "--- BCD AFTER SAC ENABLE ---" | Tee-Object -FilePath $logFile -Append + Log-Output "--- BCD AFTER SAC ENABLE ---" $afterBcd = bcdedit /store $bcdPath /enum $defaultId - foreach ($line in $afterBcd) { if ($line.Trim()) { Log-Output $line | Tee-Object -FilePath $logFile -Append } } + foreach ($line in $afterBcd) { if ($line.Trim()) { Log-Output $line } } $script_final_status = $STATUS_SUCCESS + $diskChanged = $true } else { $failureReason = "Displayorder entry was found but no boot entry GUID could be parsed for $bcdPath." - Log-Warning "Displayorder entry was found but no boot entry GUID could be parsed for $bcdPath. Raw line: $($defaultLine.Line)" | Tee-Object -FilePath $logFile -Append + Log-Warning "Displayorder entry was found but no boot entry GUID could be parsed for $bcdPath. Raw line: $($defaultLine.Line)" + $diskFailed = $true } } + else { + Log-Info "Disk $diskNumber skipped: no valid BCD + OS loader combination was found." + } + } + catch { + $diskFailed = $true + $failureReason = "Disk $diskNumber failed with exception: $($_.Exception.Message)" + Log-Error $failureReason + if ($_.InvocationInfo -and $_.InvocationInfo.PositionMessage) { + Log-Error "Disk $diskNumber failure context: $($_.InvocationInfo.PositionMessage)" + } + } + finally { # Clean up temporary EFI drive letter if one was assigned if ($tempEfiLetter) { - Log-Info "Removing temp letter ${tempEfiLetter}: from Disk $tempEfiDiskNum Partition $tempEfiPartNum" | Tee-Object -FilePath $logFile -Append + Log-Info "Removing temp letter ${tempEfiLetter}: from Disk $tempEfiDiskNum Partition $tempEfiPartNum" $dpClean = @("select disk $tempEfiDiskNum", "select partition $tempEfiPartNum", "remove letter=$tempEfiLetter") - $dpClean | diskpart | Out-Null + $dpCleanOut = $dpClean | diskpart 2>&1 + foreach ($line in @($dpCleanOut)) { if ($line) { Log-Output "[diskpart][cleanup] $line" } } + } + + if ($diskChanged) { $changedCount++ } + elseif ($diskFailed) { $failedCount++ } + else { $skippedCount++ } } } if ($script_final_status -ne $STATUS_SUCCESS) { - Log-Error "FAILED: $failureReason" | Tee-Object -FilePath $logFile -Append + Log-Error "FAILED: $failureReason" } } catch { - Log-Error "An error occurred: $($_.Exception.Message)" | Tee-Object -FilePath $logFile -Append + Log-Error "An error occurred: $($_.Exception.Message)" + if ($_.InvocationInfo -and $_.InvocationInfo.PositionMessage) { + Log-Error "Failure context: $($_.InvocationInfo.PositionMessage)" + } $script_final_status = $STATUS_ERROR } finally { - Log-Info "Script ended at $(Get-Date)" | Tee-Object -FilePath $logFile -Append + Log-Info "Summary: processed=$processedCount changed=$changedCount skipped=$skippedCount failed=$failedCount" + Log-Info "Desktop log file: $logFile" + Log-Info "Script ended at $(Get-Date)" } return $script_final_status From 42751cbe89eec91e6508ad892700fd632f34ff42 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:10:29 +0300 Subject: [PATCH 04/43] Enhance SAC enabler script with improved logging Updated the script to improve logging and error handling, including better management of temporary EFI drive letters and enhanced status tracking. --- src/windows/sac-enabler.ps1 | 117 ++++++++++++++++++++---------------- 1 file changed, 66 insertions(+), 51 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index f4805dc0..e330c6db 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -73,7 +73,7 @@ bcdedit /store S:\efi\microsoft\boot\bcd /set "{bootmgr}" displaybootmenu no 4. Verify EMS is disabled: bcdedit /store F:\boot\bcd /enum "{default}" -bcdedit /store F:\boot\bcd /enum "{bootmgr}" +bcdedit /store S:\efi\microsoft\boot\bcd /enum "{default}" Expected: ems = No or absent, bootems = No or absent. 5. Run the script. It should enable ems, bootems, displaybootmenu, and emssettings. 6. Verify all SAC settings are now enabled (see .VERIFICATION section). @@ -104,85 +104,100 @@ bcdedit /store P:\efi\microsoft\boot\bcd /enum "{bootmgr}" #> # Initialization (path-validated) -$initPath = Join-Path $PSScriptRoot 'src\windows\common\setup\init.ps1' -$diskPartitionsPath = Join-Path $PSScriptRoot 'src\windows\common\helpers\Get-Disk-Partitions-v2.ps1' +$initPath = Join-Path -Path $PSScriptRoot -ChildPath 'common\setup\init.ps1' +$diskPartitionsPath = Join-Path -Path $PSScriptRoot -ChildPath 'common\helpers\Get-Disk-Partitions-v2.ps1' -if (-not (Test-Path -LiteralPath $initPath)) { - Write-Error "Required helper not found: $initPath" +if (-not (Test-Path -Path $initPath -PathType Leaf)) { + Write-Error "Missing required dependency: $initPath" return 1 } . $initPath -if (-not (Test-Path -LiteralPath $diskPartitionsPath)) { - Log-Error "Required helper not found: $diskPartitionsPath" +if (-not (Test-Path -Path $diskPartitionsPath -PathType Leaf)) { + Log-Error "Missing required dependency: $diskPartitionsPath" return $STATUS_ERROR } . $diskPartitionsPath -# Log Configuration (desktop log standard) -$desktopPath = [Environment]::GetFolderPath('Desktop') -if ([string]::IsNullOrWhiteSpace($desktopPath)) { - $desktopPath = Join-Path $env:PUBLIC 'Desktop' -} +# Script-level logging: create a plain text desktop log that mirrors Log-* output. +$scriptName = [System.IO.Path]::GetFileNameWithoutExtension($MyInvocation.MyCommand.Name) +$runTimestamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$runOutputDir = Join-Path -Path $env:PUBLIC -ChildPath ("Desktop\\{0}-run-{1}" -f $scriptName, $runTimestamp) +$logFilePath = Join-Path -Path $runOutputDir -ChildPath ("{0}-{1}.log" -f $scriptName, $runTimestamp) -$logDir = Join-Path $desktopPath 'RepairLogs' -if (-not (Test-Path -LiteralPath $logDir)) { - $null = New-Item -ItemType Directory -Path $logDir -Force +if (-not (Test-Path -Path $runOutputDir -PathType Container)) { + New-Item -Path $runOutputDir -ItemType Directory -Force | Out-Null } -$timestamp = Get-Date -Format "yyyyMMdd_HHmmss" -$logFile = Join-Path $logDir "sac-enabler_$timestamp.log" -if (-not (Test-Path -LiteralPath $logFile)) { - $null = New-Item -Path $logFile -ItemType File -Force +if (-not (Test-Path -Path $logFilePath -PathType Leaf)) { + New-Item -Path $logFilePath -ItemType File -Force | Out-Null } -function Write-DesktopLogLine { - param([string]$Message) +$script:OriginalLogOutput = (Get-Command Log-Output -CommandType Function).ScriptBlock +$script:OriginalLogInfo = (Get-Command Log-Info -CommandType Function).ScriptBlock +$script:OriginalLogWarning = (Get-Command Log-Warning -CommandType Function).ScriptBlock +$script:OriginalLogError = (Get-Command Log-Error -CommandType Function).ScriptBlock +$script:OriginalLogDebug = (Get-Command Log-Debug -CommandType Function).ScriptBlock - if ($null -ne $Message) { - Add-Content -LiteralPath $logFile -Value ("[{0}] {1}" -f (Get-Date -Format 's'), $Message) +function Write-DesktopLogLine { + param( + [Parameter(Mandatory = $true)] + [string]$Level, + + [Parameter(Mandatory = $true)] + [PSObject[]]$Message + ) + + try { + $renderedMessage = ($Message | ForEach-Object { "$_" }) -join ' ' + $line = "[{0} {1}]{2}" -f $Level, (Get-Date), $renderedMessage + Add-Content -Path $logFilePath -Value $line -Encoding UTF8 -ErrorAction Stop + } + catch { + if ($script:OriginalLogWarning) { + & $script:OriginalLogWarning -message "Failed to append to desktop log '$logFilePath': $($_.Exception.Message)" + } + else { + [Console]::Error.WriteLine("[Warning $(Get-Date)]Failed to append to desktop log '$logFilePath': $($_.Exception.Message)") + } } } -$script:_origLogInfo = (Get-Command Log-Info -ErrorAction SilentlyContinue).ScriptBlock -$script:_origLogWarning = (Get-Command Log-Warning -ErrorAction SilentlyContinue).ScriptBlock -$script:_origLogError = (Get-Command Log-Error -ErrorAction SilentlyContinue).ScriptBlock -$script:_origLogOutput = (Get-Command Log-Output -ErrorAction SilentlyContinue).ScriptBlock +function Log-Output { + Param([Parameter(Mandatory = $true)][PSObject[]]$message) + & $script:OriginalLogOutput -message $message + Write-DesktopLogLine -Level 'Output' -Message $message +} -if ($script:_origLogInfo) { - function Log-Info { - param([string]$Message) - & $script:_origLogInfo $Message - Write-DesktopLogLine "[INFO] $Message" - } +function Log-Info { + Param([Parameter(Mandatory = $true)][PSObject[]]$message) + & $script:OriginalLogInfo -message $message + Write-DesktopLogLine -Level 'Info' -Message $message } -if ($script:_origLogWarning) { - function Log-Warning { - param([string]$Message) - & $script:_origLogWarning $Message - Write-DesktopLogLine "[WARN] $Message" - } +function Log-Warning { + Param([Parameter(Mandatory = $true)][PSObject[]]$message) + & $script:OriginalLogWarning -message $message + Write-DesktopLogLine -Level 'Warning' -Message $message } -if ($script:_origLogError) { - function Log-Error { - param([string]$Message) - & $script:_origLogError $Message - Write-DesktopLogLine "[ERROR] $Message" - } +function Log-Error { + Param([Parameter(Mandatory = $true)][PSObject[]]$message) + & $script:OriginalLogError -message $message + Write-DesktopLogLine -Level 'Error' -Message $message } -if ($script:_origLogOutput) { - function Log-Output { - param([string]$Message) - & $script:_origLogOutput $Message - Write-DesktopLogLine "[OUTPUT] $Message" - } +function Log-Debug { + Param([Parameter(Mandatory = $true)][PSObject[]]$message) + & $script:OriginalLogDebug -message $message + Write-DesktopLogLine -Level 'Debug' -Message $message } +$logFile = $logFilePath +Log-Info "Desktop plain text log initialized: $logFilePath" + # Status Tracking $script_final_status = $STATUS_ERROR $failureReason = 'Script could not find a valid OS disk to enable SAC.' From 708f3fdb0c74f63e604a14a3e1cfa491148f9296 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:59:04 +0300 Subject: [PATCH 05/43] Update sac-enabler.ps1 --- src/windows/sac-enabler.ps1 | 80 +++++++++++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 12 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index e330c6db..eeb1e2f4 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -121,18 +121,30 @@ if (-not (Test-Path -Path $diskPartitionsPath -PathType Leaf)) { . $diskPartitionsPath -# Script-level logging: create a plain text desktop log that mirrors Log-* output. +# Script-level logging: mirror Log-* output to desktop and plugin log files. $scriptName = [System.IO.Path]::GetFileNameWithoutExtension($MyInvocation.MyCommand.Name) $runTimestamp = Get-Date -Format 'yyyyMMdd-HHmmss' $runOutputDir = Join-Path -Path $env:PUBLIC -ChildPath ("Desktop\\{0}-run-{1}" -f $scriptName, $runTimestamp) $logFilePath = Join-Path -Path $runOutputDir -ChildPath ("{0}-{1}.log" -f $scriptName, $runTimestamp) +$pluginLogDir = 'C:\WindowsAzure\Logs\Plugins\Microsoft.Compute.CustomScriptExtension' +$pluginLogPath = Join-Path -Path $pluginLogDir -ChildPath ("{0}_{1}.log" -f $scriptName, $runTimestamp) +$script:RunLogTargets = @() if (-not (Test-Path -Path $runOutputDir -PathType Container)) { New-Item -Path $runOutputDir -ItemType Directory -Force | Out-Null } -if (-not (Test-Path -Path $logFilePath -PathType Leaf)) { - New-Item -Path $logFilePath -ItemType File -Force | Out-Null +if (-not (Test-Path -Path $pluginLogDir -PathType Container)) { + New-Item -Path $pluginLogDir -ItemType Directory -Force | Out-Null +} + +$script:RunLogTargets += $logFilePath +$script:RunLogTargets += $pluginLogPath + +foreach ($targetLogPath in $script:RunLogTargets) { + if (-not (Test-Path -Path $targetLogPath -PathType Leaf)) { + New-Item -Path $targetLogPath -ItemType File -Force | Out-Null + } } $script:OriginalLogOutput = (Get-Command Log-Output -CommandType Function).ScriptBlock @@ -141,7 +153,7 @@ $script:OriginalLogWarning = (Get-Command Log-Warning -CommandType Function).Scr $script:OriginalLogError = (Get-Command Log-Error -CommandType Function).ScriptBlock $script:OriginalLogDebug = (Get-Command Log-Debug -CommandType Function).ScriptBlock -function Write-DesktopLogLine { +function Write-RunLogLine { param( [Parameter(Mandatory = $true)] [string]$Level, @@ -153,14 +165,16 @@ function Write-DesktopLogLine { try { $renderedMessage = ($Message | ForEach-Object { "$_" }) -join ' ' $line = "[{0} {1}]{2}" -f $Level, (Get-Date), $renderedMessage - Add-Content -Path $logFilePath -Value $line -Encoding UTF8 -ErrorAction Stop + foreach ($targetLogPath in $script:RunLogTargets) { + Add-Content -Path $targetLogPath -Value $line -Encoding UTF8 -ErrorAction Stop + } } catch { if ($script:OriginalLogWarning) { - & $script:OriginalLogWarning -message "Failed to append to desktop log '$logFilePath': $($_.Exception.Message)" + & $script:OriginalLogWarning -message "Failed to append to one or more run logs: $($_.Exception.Message)" } else { - [Console]::Error.WriteLine("[Warning $(Get-Date)]Failed to append to desktop log '$logFilePath': $($_.Exception.Message)") + [Console]::Error.WriteLine("[Warning $(Get-Date)]Failed to append to one or more run logs: $($_.Exception.Message)") } } } @@ -168,35 +182,75 @@ function Write-DesktopLogLine { function Log-Output { Param([Parameter(Mandatory = $true)][PSObject[]]$message) & $script:OriginalLogOutput -message $message - Write-DesktopLogLine -Level 'Output' -Message $message + Write-RunLogLine -Level 'Output' -Message $message } function Log-Info { Param([Parameter(Mandatory = $true)][PSObject[]]$message) & $script:OriginalLogInfo -message $message - Write-DesktopLogLine -Level 'Info' -Message $message + Write-RunLogLine -Level 'Info' -Message $message } function Log-Warning { Param([Parameter(Mandatory = $true)][PSObject[]]$message) & $script:OriginalLogWarning -message $message - Write-DesktopLogLine -Level 'Warning' -Message $message + Write-RunLogLine -Level 'Warning' -Message $message } function Log-Error { Param([Parameter(Mandatory = $true)][PSObject[]]$message) & $script:OriginalLogError -message $message - Write-DesktopLogLine -Level 'Error' -Message $message + Write-RunLogLine -Level 'Error' -Message $message } function Log-Debug { Param([Parameter(Mandatory = $true)][PSObject[]]$message) & $script:OriginalLogDebug -message $message - Write-DesktopLogLine -Level 'Debug' -Message $message + Write-RunLogLine -Level 'Debug' -Message $message +} + +function Remove-StaleEfiTempLetters { + $candidateLetters = @('Z','Y','X','W','V','U','T','S','R','Q') + $rescueDrive = ($env:SystemDrive -replace ':', '').ToUpperInvariant() + $rescueDisk = Get-Partition -DriveLetter $rescueDrive -ErrorAction SilentlyContinue | Select-Object -First 1 + $rescueDiskNum = $null + if ($rescueDisk) { + $rescueDiskNum = $rescueDisk.DiskNumber + } + + $efiGptType = '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}' + $staleParts = Get-Partition -ErrorAction SilentlyContinue | Where-Object { + $_.GptType -eq $efiGptType -and + $_.DriveLetter -and + ($_.DriveLetter.ToString().ToUpperInvariant() -in $candidateLetters) -and + ($null -eq $rescueDiskNum -or $_.DiskNumber -ne $rescueDiskNum) + } + + foreach ($part in $staleParts) { + $letter = $part.DriveLetter.ToString().ToUpperInvariant() + $probePath = "${letter}:\efi\microsoft\boot\bcd" + if (-not (Test-Path -Path $probePath)) { + continue + } + + Log-Warning "Found possible stale EFI temp letter ${letter}: on Disk $($part.DiskNumber) Partition $($part.PartitionNumber). Removing..." + $dpRemove = @( + "select disk $($part.DiskNumber)", + "select partition $($part.PartitionNumber)", + "remove letter=$letter" + ) + $dpOut = $dpRemove | diskpart 2>&1 + foreach ($line in @($dpOut)) { + if ($line) { + Log-Output "[diskpart][stale-cleanup] $line" + } + } + } } $logFile = $logFilePath Log-Info "Desktop plain text log initialized: $logFilePath" +Log-Info "Plugin log initialized: $pluginLogPath" # Status Tracking $script_final_status = $STATUS_ERROR @@ -207,6 +261,8 @@ $failedCount = 0 $changedCount = 0 Log-Info "Starting SAC enabler. Desktop log: $logFile" +Log-Info "Run logs: $($script:RunLogTargets -join ', ')" +Remove-StaleEfiTempLetters try { # Check if the Hyper-V module is available before performing nested VM checks From ae24e25d18b55c326447a2e1605fd7b413d1d9d9 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:58:21 +0300 Subject: [PATCH 06/43] Update sac-enabler.ps1 --- src/windows/sac-enabler.ps1 | 143 ++++++++++++++++++++++++------------ 1 file changed, 98 insertions(+), 45 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index eeb1e2f4..8b9b0fa1 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -248,6 +248,22 @@ function Remove-StaleEfiTempLetters { } } +function Get-AvailableTempDriveLetter { + $preferredLetters = @('Z','Y','X','W','V','U','T','S','R','Q') + $usedLetters = @() + Get-Volume -ErrorAction SilentlyContinue | Where-Object { $_.DriveLetter } | ForEach-Object { + $usedLetters += $_.DriveLetter.ToString().ToUpperInvariant() + } + + foreach ($letter in $preferredLetters) { + if ($letter -notin $usedLetters) { + return $letter + } + } + + return $null +} + $logFile = $logFilePath Log-Info "Desktop plain text log initialized: $logFilePath" Log-Info "Plugin log initialized: $pluginLogPath" @@ -297,9 +313,7 @@ try { $isBcdPath = $false $bcdPath = '' $isOsPath = $false - $tempEfiLetter = $null - $tempEfiDiskNum = $null - $tempEfiPartNum = $null + $tempMountedPartitions = @() Log-Info "Processing Disk $diskNumber" try { @@ -328,64 +342,100 @@ try { } } - # Gen2 EFI fallback: if OS found but no BCD, discover unlettered EFI partition - if (-not $isBcdPath -and $isOsPath) + # Fallback: temporarily mount unlettered partitions to find missing OS/BCD paths. + if (-not $isBcdPath -or -not $isOsPath) { $diskNum = [int]$partitionGroup.Name $rescueDiskNum = (Get-Partition -DriveLetter $rescueDrive -ErrorAction SilentlyContinue | Select-Object -First 1).DiskNumber if ($diskNum -ne $rescueDiskNum) { - Log-Info "Disk ${diskNum}: OS found but no BCD - checking for unlettered EFI partition (Gen2)..." - $efiGptType = '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}' - $efiParts = Get-Partition -DiskNumber $diskNum -ErrorAction SilentlyContinue | Where-Object { - $_.GptType -eq $efiGptType -and (-not $_.DriveLetter -or $_.DriveLetter -eq [char]0) + Log-Info "Disk ${diskNum}: probing unlettered partitions for Windows loader and BCD store..." + $msrGptType = '{e3c9e316-0b5c-4db8-817d-f92df00215ae}' + $unletteredParts = Get-Partition -DiskNumber $diskNum -ErrorAction SilentlyContinue | Where-Object { + (-not $_.DriveLetter -or $_.DriveLetter -eq [char]0) -and $_.GptType -ne $msrGptType } - if ($efiParts) + if ($unletteredParts) { - # Find an available drive letter (Z downward to avoid conflicts) - $usedLetters = @() - Get-Volume -ErrorAction SilentlyContinue | Where-Object { $_.DriveLetter } | ForEach-Object { $usedLetters += $_.DriveLetter } - $tempLetter = $null - foreach ($l in @('Z','Y','X','W','V','U','T','S','R','Q')) { - if ($l -notin $usedLetters) { $tempLetter = $l; break } - } - if ($tempLetter) + foreach ($part in $unletteredParts) { - foreach ($ep in $efiParts) + if ($isBcdPath -and $isOsPath) { break } + + $tempLetter = Get-AvailableTempDriveLetter + if (-not $tempLetter) { - $pn = $ep.PartitionNumber - Log-Info "Assigning temp letter ${tempLetter}: to Disk $diskNum Partition $pn (EFI)..." - $dpLines = @("select disk $diskNum", "select partition $pn", "assign letter=$tempLetter") - $dpAssignOut = $dpLines | diskpart 2>&1 - foreach ($line in @($dpAssignOut)) { if ($line) { Log-Output "[diskpart][assign] $line" } } - Start-Sleep -Seconds 2 - $bcdPath = "${tempLetter}:\efi\microsoft\boot\bcd" - $isBcdPath = Test-Path $bcdPath - if ($isBcdPath) + Log-Warning "No available temporary drive letter to probe Disk $diskNum" + break + } + + $pn = $part.PartitionNumber + Log-Info "Assigning temp letter ${tempLetter}: to Disk $diskNum Partition $pn for probe..." + $dpLines = @("select disk $diskNum", "select partition $pn", "assign letter=$tempLetter") + $dpAssignOut = $dpLines | diskpart 2>&1 + foreach ($line in @($dpAssignOut)) { if ($line) { Log-Output "[diskpart][assign] $line" } } + Start-Sleep -Seconds 2 + + $foundSomething = $false + + if (-not $isBcdPath) + { + $candidateBcdPath = "${tempLetter}:\boot\bcd" + if (Test-Path $candidateBcdPath) { - Log-Info "Found Gen2 BCD store at $bcdPath" - $tempEfiLetter = $tempLetter - $tempEfiDiskNum = $diskNum - $tempEfiPartNum = $pn - break + $bcdPath = $candidateBcdPath + $isBcdPath = $true + $foundSomething = $true + Log-Info "Found BCD store at $bcdPath" } else { - Log-Info "No BCD at $bcdPath, removing letter..." - $dpRemove = @("select disk $diskNum", "select partition $pn", "remove letter=$tempLetter") - $dpRemoveOut = $dpRemove | diskpart 2>&1 - foreach ($line in @($dpRemoveOut)) { if ($line) { Log-Output "[diskpart][remove] $line" } } + $candidateBcdPath = "${tempLetter}:\efi\microsoft\boot\bcd" + if (Test-Path $candidateBcdPath) + { + $bcdPath = $candidateBcdPath + $isBcdPath = $true + $foundSomething = $true + Log-Info "Found EFI BCD store at $bcdPath" + } } } - } - else - { - Log-Warning "No available drive letter for EFI partition on Disk $diskNum" + + if (-not $isOsPath) + { + $winloadExePath = "${tempLetter}:\windows\system32\winload.exe" + $winloadEfiPath = "${tempLetter}:\windows\system32\winload.efi" + if ((Test-Path $winloadExePath) -or (Test-Path $winloadEfiPath)) + { + $isOsPath = $true + $foundSomething = $true + Log-Info "Found Windows loader on ${tempLetter}:" + } + } + + if ($foundSomething) + { + $tempMountedPartitions += @{ + Letter = $tempLetter + DiskNumber = $diskNum + PartitionNumber = $pn + } + } + else + { + Log-Info "No OS/BCD artifacts on ${tempLetter}:, removing temporary letter..." + $dpRemove = @("select disk $diskNum", "select partition $pn", "remove letter=$tempLetter") + $dpRemoveOut = $dpRemove | diskpart 2>&1 + foreach ($line in @($dpRemoveOut)) { if ($line) { Log-Output "[diskpart][remove] $line" } } + } } } } } + if (-not $isBcdPath -or -not $isOsPath) + { + Log-Info "Disk $diskNumber probe result: isOsPath=$isOsPath isBcdPath=$isBcdPath" + } + # Apply SAC changes if both BCD and OS loader were found if ( $isBcdPath -and $isOsPath ) { @@ -453,11 +503,14 @@ try { } finally { - # Clean up temporary EFI drive letter if one was assigned - if ($tempEfiLetter) + # Clean up temporary drive letters that were kept for BCD/OS access. + foreach ($mount in $tempMountedPartitions) { - Log-Info "Removing temp letter ${tempEfiLetter}: from Disk $tempEfiDiskNum Partition $tempEfiPartNum" - $dpClean = @("select disk $tempEfiDiskNum", "select partition $tempEfiPartNum", "remove letter=$tempEfiLetter") + $cleanupLetter = $mount.Letter + $cleanupDisk = $mount.DiskNumber + $cleanupPart = $mount.PartitionNumber + Log-Info "Removing temp letter ${cleanupLetter}: from Disk $cleanupDisk Partition $cleanupPart" + $dpClean = @("select disk $cleanupDisk", "select partition $cleanupPart", "remove letter=$cleanupLetter") $dpCleanOut = $dpClean | diskpart 2>&1 foreach ($line in @($dpCleanOut)) { if ($line) { Log-Output "[diskpart][cleanup] $line" } } } From 3b955695c878cd8840609fbfc97a88ebdba282c6 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:29:18 +0300 Subject: [PATCH 07/43] Update sac-enabler.ps1 --- src/windows/sac-enabler.ps1 | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 8b9b0fa1..10c2f507 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -349,9 +349,25 @@ try { $rescueDiskNum = (Get-Partition -DriveLetter $rescueDrive -ErrorAction SilentlyContinue | Select-Object -First 1).DiskNumber if ($diskNum -ne $rescueDiskNum) { + $diskState = Get-Disk -Number $diskNum -ErrorAction SilentlyContinue + if ($diskState) + { + Log-Info "Disk ${diskNum} state: Number=$($diskState.Number) IsOffline=$($diskState.IsOffline) IsReadOnly=$($diskState.IsReadOnly) PartitionStyle=$($diskState.PartitionStyle) OperationalStatus=$($diskState.OperationalStatus -join ',')" + } Log-Info "Disk ${diskNum}: probing unlettered partitions for Windows loader and BCD store..." $msrGptType = '{e3c9e316-0b5c-4db8-817d-f92df00215ae}' - $unletteredParts = Get-Partition -DiskNumber $diskNum -ErrorAction SilentlyContinue | Where-Object { + $allParts = Get-Partition -DiskNumber $diskNum -ErrorAction SilentlyContinue + if ($allParts) + { + foreach ($p in $allParts) + { + $dl = if ($p.DriveLetter) { $p.DriveLetter } else { '' } + $gpt = if ($p.GptType) { $p.GptType } else { '' } + Log-Info "Disk ${diskNum} partition: Number=$($p.PartitionNumber) DriveLetter=$dl Type=$($p.Type) GptType=$gpt SizeBytes=$($p.Size)" + } + } + + $unletteredParts = $allParts | Where-Object { (-not $_.DriveLetter -or $_.DriveLetter -eq [char]0) -and $_.GptType -ne $msrGptType } if ($unletteredParts) @@ -428,6 +444,10 @@ try { } } } + else + { + Log-Info "Disk ${diskNum}: no unlettered candidate partitions were found for temporary mount probing." + } } } From 783dfd516dbf71e0acb0f9881959d2b2d66e7ee9 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:23:14 +0300 Subject: [PATCH 08/43] Update sac-enabler.ps1 --- src/windows/sac-enabler.ps1 | 48 +++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 10c2f507..8e31073f 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -264,6 +264,38 @@ function Get-AvailableTempDriveLetter { return $null } +function Test-WindowsOsVolume { + param( + [Parameter(Mandatory = $true)] + [string]$DriveLetter + ) + + $candidateChecks = @( + @{ Path = "$DriveLetter`:\windows\system32\winload.exe"; Reason = 'winload.exe' }, + @{ Path = "$DriveLetter`:\windows\system32\winload.efi"; Reason = 'winload.efi' }, + @{ Path = "$DriveLetter`:\windows\system32\config\SYSTEM"; Reason = 'SYSTEM hive' }, + @{ Path = "$DriveLetter`:\windows\explorer.exe"; Reason = 'explorer.exe' } + ) + + foreach ($check in $candidateChecks) + { + if (Test-Path -Path $check.Path) + { + return @{ + IsMatch = $true + Reason = $check.Reason + Path = $check.Path + } + } + } + + return @{ + IsMatch = $false + Reason = $null + Path = $null + } +} + $logFile = $logFilePath Log-Info "Desktop plain text log initialized: $logFilePath" Log-Info "Plugin log initialized: $pluginLogPath" @@ -336,9 +368,12 @@ try { } if (-not $isOsPath) { - $winloadExePath = $drive + ':\windows\system32\winload.exe' - $winloadEfiPath = $drive + ':\windows\system32\winload.efi' - $isOsPath = (Test-Path $winloadExePath) -or (Test-Path $winloadEfiPath) + $osProbe = Test-WindowsOsVolume -DriveLetter $drive + $isOsPath = $osProbe.IsMatch + if ($isOsPath) + { + Log-Info "Disk $diskNumber OS partition detected on ${drive}: via $($osProbe.Reason) at $($osProbe.Path)" + } } } @@ -417,13 +452,12 @@ try { if (-not $isOsPath) { - $winloadExePath = "${tempLetter}:\windows\system32\winload.exe" - $winloadEfiPath = "${tempLetter}:\windows\system32\winload.efi" - if ((Test-Path $winloadExePath) -or (Test-Path $winloadEfiPath)) + $osProbe = Test-WindowsOsVolume -DriveLetter $tempLetter + if ($osProbe.IsMatch) { $isOsPath = $true $foundSomething = $true - Log-Info "Found Windows loader on ${tempLetter}:" + Log-Info "Found Windows OS markers on ${tempLetter}: via $($osProbe.Reason) at $($osProbe.Path)" } } From 5f79052a125d8e179cc2490da603e84da11c1376 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:55:21 +0300 Subject: [PATCH 09/43] Update sac-enabler.ps1 --- src/windows/sac-enabler.ps1 | 331 ++++++++++++++---------------------- 1 file changed, 123 insertions(+), 208 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 8e31073f..2e5f4347 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -121,30 +121,36 @@ if (-not (Test-Path -Path $diskPartitionsPath -PathType Leaf)) { . $diskPartitionsPath -# Script-level logging: mirror Log-* output to desktop and plugin log files. +# Script-level logging: create a plain text desktop log that mirrors Log-* output. $scriptName = [System.IO.Path]::GetFileNameWithoutExtension($MyInvocation.MyCommand.Name) $runTimestamp = Get-Date -Format 'yyyyMMdd-HHmmss' $runOutputDir = Join-Path -Path $env:PUBLIC -ChildPath ("Desktop\\{0}-run-{1}" -f $scriptName, $runTimestamp) $logFilePath = Join-Path -Path $runOutputDir -ChildPath ("{0}-{1}.log" -f $scriptName, $runTimestamp) $pluginLogDir = 'C:\WindowsAzure\Logs\Plugins\Microsoft.Compute.CustomScriptExtension' -$pluginLogPath = Join-Path -Path $pluginLogDir -ChildPath ("{0}_{1}.log" -f $scriptName, $runTimestamp) -$script:RunLogTargets = @() +$pluginLogFilePath = Join-Path -Path $pluginLogDir -ChildPath ("{0}-{1}.log" -f $scriptName, $runTimestamp) +$scriptLogTargets = @() if (-not (Test-Path -Path $runOutputDir -PathType Container)) { New-Item -Path $runOutputDir -ItemType Directory -Force | Out-Null } -if (-not (Test-Path -Path $pluginLogDir -PathType Container)) { - New-Item -Path $pluginLogDir -ItemType Directory -Force | Out-Null +if (-not (Test-Path -Path $logFilePath -PathType Leaf)) { + New-Item -Path $logFilePath -ItemType File -Force | Out-Null } -$script:RunLogTargets += $logFilePath -$script:RunLogTargets += $pluginLogPath +$scriptLogTargets += $logFilePath -foreach ($targetLogPath in $script:RunLogTargets) { - if (-not (Test-Path -Path $targetLogPath -PathType Leaf)) { - New-Item -Path $targetLogPath -ItemType File -Force | Out-Null +try { + if (-not (Test-Path -Path $pluginLogDir -PathType Container)) { + New-Item -Path $pluginLogDir -ItemType Directory -Force | Out-Null + } + if (-not (Test-Path -Path $pluginLogFilePath -PathType Leaf)) { + New-Item -Path $pluginLogFilePath -ItemType File -Force | Out-Null } + $scriptLogTargets += $pluginLogFilePath +} +catch { + [Console]::Error.WriteLine("[Warning $(Get-Date)]Failed to initialize plugin log path '$pluginLogFilePath': $($_.Exception.Message)") } $script:OriginalLogOutput = (Get-Command Log-Output -CommandType Function).ScriptBlock @@ -153,7 +159,7 @@ $script:OriginalLogWarning = (Get-Command Log-Warning -CommandType Function).Scr $script:OriginalLogError = (Get-Command Log-Error -CommandType Function).ScriptBlock $script:OriginalLogDebug = (Get-Command Log-Debug -CommandType Function).ScriptBlock -function Write-RunLogLine { +function Write-DesktopLogLine { param( [Parameter(Mandatory = $true)] [string]$Level, @@ -165,140 +171,107 @@ function Write-RunLogLine { try { $renderedMessage = ($Message | ForEach-Object { "$_" }) -join ' ' $line = "[{0} {1}]{2}" -f $Level, (Get-Date), $renderedMessage - foreach ($targetLogPath in $script:RunLogTargets) { - Add-Content -Path $targetLogPath -Value $line -Encoding UTF8 -ErrorAction Stop + foreach ($target in $scriptLogTargets) { + Add-Content -Path $target -Value $line -Encoding UTF8 -ErrorAction Stop } } catch { if ($script:OriginalLogWarning) { - & $script:OriginalLogWarning -message "Failed to append to one or more run logs: $($_.Exception.Message)" + & $script:OriginalLogWarning -message "Failed to append to one or more log targets '$($scriptLogTargets -join ', ')': $($_.Exception.Message)" } else { - [Console]::Error.WriteLine("[Warning $(Get-Date)]Failed to append to one or more run logs: $($_.Exception.Message)") + [Console]::Error.WriteLine("[Warning $(Get-Date)]Failed to append to one or more log targets '$($scriptLogTargets -join ', ')': $($_.Exception.Message)") } } } +function Invoke-LogWithMirrors { + param( + [Parameter(Mandatory = $true)] + [ScriptBlock]$OriginalLogger, + + [Parameter(Mandatory = $true)] + [string]$Level, + + [Parameter(Mandatory = $true)] + [PSObject[]]$Message + ) + + & $OriginalLogger -message $Message + Write-DesktopLogLine -Level $Level -Message $Message +} + function Log-Output { Param([Parameter(Mandatory = $true)][PSObject[]]$message) - & $script:OriginalLogOutput -message $message - Write-RunLogLine -Level 'Output' -Message $message + Invoke-LogWithMirrors -OriginalLogger $script:OriginalLogOutput -Level 'Output' -Message $message } function Log-Info { Param([Parameter(Mandatory = $true)][PSObject[]]$message) - & $script:OriginalLogInfo -message $message - Write-RunLogLine -Level 'Info' -Message $message + Invoke-LogWithMirrors -OriginalLogger $script:OriginalLogInfo -Level 'Info' -Message $message } function Log-Warning { Param([Parameter(Mandatory = $true)][PSObject[]]$message) - & $script:OriginalLogWarning -message $message - Write-RunLogLine -Level 'Warning' -Message $message + Invoke-LogWithMirrors -OriginalLogger $script:OriginalLogWarning -Level 'Warning' -Message $message } function Log-Error { Param([Parameter(Mandatory = $true)][PSObject[]]$message) - & $script:OriginalLogError -message $message - Write-RunLogLine -Level 'Error' -Message $message + Invoke-LogWithMirrors -OriginalLogger $script:OriginalLogError -Level 'Error' -Message $message } function Log-Debug { Param([Parameter(Mandatory = $true)][PSObject[]]$message) - & $script:OriginalLogDebug -message $message - Write-RunLogLine -Level 'Debug' -Message $message + Invoke-LogWithMirrors -OriginalLogger $script:OriginalLogDebug -Level 'Debug' -Message $message } -function Remove-StaleEfiTempLetters { - $candidateLetters = @('Z','Y','X','W','V','U','T','S','R','Q') - $rescueDrive = ($env:SystemDrive -replace ':', '').ToUpperInvariant() - $rescueDisk = Get-Partition -DriveLetter $rescueDrive -ErrorAction SilentlyContinue | Select-Object -First 1 - $rescueDiskNum = $null - if ($rescueDisk) { - $rescueDiskNum = $rescueDisk.DiskNumber - } +function Invoke-StaleEfiTempLetterSweep { + param( + [bool]$Cleanup = $false + ) $efiGptType = '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}' - $staleParts = Get-Partition -ErrorAction SilentlyContinue | Where-Object { - $_.GptType -eq $efiGptType -and - $_.DriveLetter -and - ($_.DriveLetter.ToString().ToUpperInvariant() -in $candidateLetters) -and - ($null -eq $rescueDiskNum -or $_.DiskNumber -ne $rescueDiskNum) - } + $candidateLetters = @('Q','R','S','T','U','V','W','X','Y','Z') - foreach ($part in $staleParts) { - $letter = $part.DriveLetter.ToString().ToUpperInvariant() - $probePath = "${letter}:\efi\microsoft\boot\bcd" - if (-not (Test-Path -Path $probePath)) { - continue - } + foreach ($letter in $candidateLetters) { + $vol = Get-Volume -DriveLetter $letter -ErrorAction SilentlyContinue + if (-not $vol) { continue } - Log-Warning "Found possible stale EFI temp letter ${letter}: on Disk $($part.DiskNumber) Partition $($part.PartitionNumber). Removing..." - $dpRemove = @( - "select disk $($part.DiskNumber)", - "select partition $($part.PartitionNumber)", - "remove letter=$letter" - ) - $dpOut = $dpRemove | diskpart 2>&1 - foreach ($line in @($dpOut)) { - if ($line) { - Log-Output "[diskpart][stale-cleanup] $line" - } - } - } -} + $part = Get-Partition -DriveLetter $letter -ErrorAction SilentlyContinue + if (-not $part) { continue } -function Get-AvailableTempDriveLetter { - $preferredLetters = @('Z','Y','X','W','V','U','T','S','R','Q') - $usedLetters = @() - Get-Volume -ErrorAction SilentlyContinue | Where-Object { $_.DriveLetter } | ForEach-Object { - $usedLetters += $_.DriveLetter.ToString().ToUpperInvariant() - } + if ($part.GptType -ne $efiGptType) { continue } - foreach ($letter in $preferredLetters) { - if ($letter -notin $usedLetters) { - return $letter - } - } + $bcdCandidate = "${letter}:\efi\microsoft\boot\bcd" + if (-not (Test-Path -Path $bcdCandidate)) { continue } - return $null -} + Log-Warning "Detected mounted EFI partition at ${letter}: that may be from a prior interrupted run (Disk $($part.DiskNumber), Partition $($part.PartitionNumber))." -function Test-WindowsOsVolume { - param( - [Parameter(Mandatory = $true)] - [string]$DriveLetter - ) - - $candidateChecks = @( - @{ Path = "$DriveLetter`:\windows\system32\winload.exe"; Reason = 'winload.exe' }, - @{ Path = "$DriveLetter`:\windows\system32\winload.efi"; Reason = 'winload.efi' }, - @{ Path = "$DriveLetter`:\windows\system32\config\SYSTEM"; Reason = 'SYSTEM hive' }, - @{ Path = "$DriveLetter`:\windows\explorer.exe"; Reason = 'explorer.exe' } - ) - - foreach ($check in $candidateChecks) - { - if (Test-Path -Path $check.Path) - { - return @{ - IsMatch = $true - Reason = $check.Reason - Path = $check.Path + if ($Cleanup) { + try { + Log-Info "Removing stale EFI temp letter ${letter}: from Disk $($part.DiskNumber) Partition $($part.PartitionNumber)" + $dpClean = @("select disk $($part.DiskNumber)", "select partition $($part.PartitionNumber)", "remove letter=$letter") + $dpCleanOut = $dpClean | diskpart 2>&1 + foreach ($line in @($dpCleanOut)) { if ($line) { Log-Output "[diskpart][startup-cleanup] $line" } } + } + catch { + Log-Warning "Failed removing stale EFI temp letter ${letter}: $($_.Exception.Message)" } } } - - return @{ - IsMatch = $false - Reason = $null - Path = $null - } } $logFile = $logFilePath Log-Info "Desktop plain text log initialized: $logFilePath" -Log-Info "Plugin log initialized: $pluginLogPath" +if ($scriptLogTargets -contains $pluginLogFilePath) { + Log-Info "Plugin plain text log initialized: $pluginLogFilePath" +} + +# Optional startup cleanup for stale EFI temp letters from interrupted runs. +# Disabled by default to avoid removing intentionally mounted EFI volumes. +$enableStaleEfiSweepCleanup = $false +Invoke-StaleEfiTempLetterSweep -Cleanup $enableStaleEfiSweepCleanup # Status Tracking $script_final_status = $STATUS_ERROR @@ -309,8 +282,6 @@ $failedCount = 0 $changedCount = 0 Log-Info "Starting SAC enabler. Desktop log: $logFile" -Log-Info "Run logs: $($script:RunLogTargets -join ', ')" -Remove-StaleEfiTempLetters try { # Check if the Hyper-V module is available before performing nested VM checks @@ -345,7 +316,9 @@ try { $isBcdPath = $false $bcdPath = '' $isOsPath = $false - $tempMountedPartitions = @() + $tempEfiLetter = $null + $tempEfiDiskNum = $null + $tempEfiPartNum = $null Log-Info "Processing Disk $diskNumber" try { @@ -368,128 +341,70 @@ try { } if (-not $isOsPath) { - $osProbe = Test-WindowsOsVolume -DriveLetter $drive - $isOsPath = $osProbe.IsMatch - if ($isOsPath) - { - Log-Info "Disk $diskNumber OS partition detected on ${drive}: via $($osProbe.Reason) at $($osProbe.Path)" - } + $winloadExePath = $drive + ':\windows\system32\winload.exe' + $winloadEfiPath = $drive + ':\windows\system32\winload.efi' + $isOsPath = (Test-Path $winloadExePath) -or (Test-Path $winloadEfiPath) } } - # Fallback: temporarily mount unlettered partitions to find missing OS/BCD paths. - if (-not $isBcdPath -or -not $isOsPath) + # Gen2 EFI fallback: if OS found but no BCD, discover unlettered EFI partition + if (-not $isBcdPath -and $isOsPath) { $diskNum = [int]$partitionGroup.Name $rescueDiskNum = (Get-Partition -DriveLetter $rescueDrive -ErrorAction SilentlyContinue | Select-Object -First 1).DiskNumber if ($diskNum -ne $rescueDiskNum) { - $diskState = Get-Disk -Number $diskNum -ErrorAction SilentlyContinue - if ($diskState) - { - Log-Info "Disk ${diskNum} state: Number=$($diskState.Number) IsOffline=$($diskState.IsOffline) IsReadOnly=$($diskState.IsReadOnly) PartitionStyle=$($diskState.PartitionStyle) OperationalStatus=$($diskState.OperationalStatus -join ',')" + Log-Info "Disk ${diskNum}: OS found but no BCD - checking for unlettered EFI partition (Gen2)..." + $efiGptType = '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}' + $efiParts = Get-Partition -DiskNumber $diskNum -ErrorAction SilentlyContinue | Where-Object { + $_.GptType -eq $efiGptType -and (-not $_.DriveLetter -or $_.DriveLetter -eq [char]0) } - Log-Info "Disk ${diskNum}: probing unlettered partitions for Windows loader and BCD store..." - $msrGptType = '{e3c9e316-0b5c-4db8-817d-f92df00215ae}' - $allParts = Get-Partition -DiskNumber $diskNum -ErrorAction SilentlyContinue - if ($allParts) + if ($efiParts) { - foreach ($p in $allParts) - { - $dl = if ($p.DriveLetter) { $p.DriveLetter } else { '' } - $gpt = if ($p.GptType) { $p.GptType } else { '' } - Log-Info "Disk ${diskNum} partition: Number=$($p.PartitionNumber) DriveLetter=$dl Type=$($p.Type) GptType=$gpt SizeBytes=$($p.Size)" + # Find an available drive letter (Z downward to avoid conflicts) + $usedLetters = @() + Get-Volume -ErrorAction SilentlyContinue | Where-Object { $_.DriveLetter } | ForEach-Object { $usedLetters += $_.DriveLetter } + $tempLetter = $null + foreach ($l in @('Z','Y','X','W','V','U','T','S','R','Q')) { + if ($l -notin $usedLetters) { $tempLetter = $l; break } } - } - - $unletteredParts = $allParts | Where-Object { - (-not $_.DriveLetter -or $_.DriveLetter -eq [char]0) -and $_.GptType -ne $msrGptType - } - if ($unletteredParts) - { - foreach ($part in $unletteredParts) + if ($tempLetter) { - if ($isBcdPath -and $isOsPath) { break } - - $tempLetter = Get-AvailableTempDriveLetter - if (-not $tempLetter) + foreach ($ep in $efiParts) { - Log-Warning "No available temporary drive letter to probe Disk $diskNum" - break - } - - $pn = $part.PartitionNumber - Log-Info "Assigning temp letter ${tempLetter}: to Disk $diskNum Partition $pn for probe..." - $dpLines = @("select disk $diskNum", "select partition $pn", "assign letter=$tempLetter") - $dpAssignOut = $dpLines | diskpart 2>&1 - foreach ($line in @($dpAssignOut)) { if ($line) { Log-Output "[diskpart][assign] $line" } } - Start-Sleep -Seconds 2 - - $foundSomething = $false - - if (-not $isBcdPath) - { - $candidateBcdPath = "${tempLetter}:\boot\bcd" - if (Test-Path $candidateBcdPath) + $pn = $ep.PartitionNumber + Log-Info "Assigning temp letter ${tempLetter}: to Disk $diskNum Partition $pn (EFI)..." + $dpLines = @("select disk $diskNum", "select partition $pn", "assign letter=$tempLetter") + $dpAssignOut = $dpLines | diskpart 2>&1 + foreach ($line in @($dpAssignOut)) { if ($line) { Log-Output "[diskpart][assign] $line" } } + Start-Sleep -Seconds 2 + $bcdPath = "${tempLetter}:\efi\microsoft\boot\bcd" + $isBcdPath = Test-Path $bcdPath + if ($isBcdPath) { - $bcdPath = $candidateBcdPath - $isBcdPath = $true - $foundSomething = $true - Log-Info "Found BCD store at $bcdPath" + Log-Info "Found Gen2 BCD store at $bcdPath" + $tempEfiLetter = $tempLetter + $tempEfiDiskNum = $diskNum + $tempEfiPartNum = $pn + break } else { - $candidateBcdPath = "${tempLetter}:\efi\microsoft\boot\bcd" - if (Test-Path $candidateBcdPath) - { - $bcdPath = $candidateBcdPath - $isBcdPath = $true - $foundSomething = $true - Log-Info "Found EFI BCD store at $bcdPath" - } + Log-Info "No BCD at $bcdPath, removing letter..." + $dpRemove = @("select disk $diskNum", "select partition $pn", "remove letter=$tempLetter") + $dpRemoveOut = $dpRemove | diskpart 2>&1 + foreach ($line in @($dpRemoveOut)) { if ($line) { Log-Output "[diskpart][remove] $line" } } } } - - if (-not $isOsPath) - { - $osProbe = Test-WindowsOsVolume -DriveLetter $tempLetter - if ($osProbe.IsMatch) - { - $isOsPath = $true - $foundSomething = $true - Log-Info "Found Windows OS markers on ${tempLetter}: via $($osProbe.Reason) at $($osProbe.Path)" - } - } - - if ($foundSomething) - { - $tempMountedPartitions += @{ - Letter = $tempLetter - DiskNumber = $diskNum - PartitionNumber = $pn - } - } - else - { - Log-Info "No OS/BCD artifacts on ${tempLetter}:, removing temporary letter..." - $dpRemove = @("select disk $diskNum", "select partition $pn", "remove letter=$tempLetter") - $dpRemoveOut = $dpRemove | diskpart 2>&1 - foreach ($line in @($dpRemoveOut)) { if ($line) { Log-Output "[diskpart][remove] $line" } } - } } - } - else - { - Log-Info "Disk ${diskNum}: no unlettered candidate partitions were found for temporary mount probing." + else + { + Log-Warning "No available drive letter for EFI partition on Disk $diskNum" + } } } } - if (-not $isBcdPath -or -not $isOsPath) - { - Log-Info "Disk $diskNumber probe result: isOsPath=$isOsPath isBcdPath=$isBcdPath" - } - # Apply SAC changes if both BCD and OS loader were found if ( $isBcdPath -and $isOsPath ) { @@ -557,14 +472,11 @@ try { } finally { - # Clean up temporary drive letters that were kept for BCD/OS access. - foreach ($mount in $tempMountedPartitions) + # Clean up temporary EFI drive letter if one was assigned + if ($tempEfiLetter) { - $cleanupLetter = $mount.Letter - $cleanupDisk = $mount.DiskNumber - $cleanupPart = $mount.PartitionNumber - Log-Info "Removing temp letter ${cleanupLetter}: from Disk $cleanupDisk Partition $cleanupPart" - $dpClean = @("select disk $cleanupDisk", "select partition $cleanupPart", "remove letter=$cleanupLetter") + Log-Info "Removing temp letter ${tempEfiLetter}: from Disk $tempEfiDiskNum Partition $tempEfiPartNum" + $dpClean = @("select disk $tempEfiDiskNum", "select partition $tempEfiPartNum", "remove letter=$tempEfiLetter") $dpCleanOut = $dpClean | diskpart 2>&1 foreach ($line in @($dpCleanOut)) { if ($line) { Log-Output "[diskpart][cleanup] $line" } } } @@ -589,6 +501,9 @@ catch { finally { Log-Info "Summary: processed=$processedCount changed=$changedCount skipped=$skippedCount failed=$failedCount" Log-Info "Desktop log file: $logFile" + if ($scriptLogTargets -contains $pluginLogFilePath) { + Log-Info "Plugin log file: $pluginLogFilePath" + } Log-Info "Script ended at $(Get-Date)" } From a8a637e3c5bc73c6b9eb43ee615dfcc8959df573 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:58:56 +0300 Subject: [PATCH 10/43] update updated as per latest feedback --- src/windows/sac-enabler.ps1 | 65 ++++++++----------------------------- 1 file changed, 14 insertions(+), 51 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 2e5f4347..6b03bf15 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -126,9 +126,6 @@ $scriptName = [System.IO.Path]::GetFileNameWithoutExtension($MyInvocation.MyComm $runTimestamp = Get-Date -Format 'yyyyMMdd-HHmmss' $runOutputDir = Join-Path -Path $env:PUBLIC -ChildPath ("Desktop\\{0}-run-{1}" -f $scriptName, $runTimestamp) $logFilePath = Join-Path -Path $runOutputDir -ChildPath ("{0}-{1}.log" -f $scriptName, $runTimestamp) -$pluginLogDir = 'C:\WindowsAzure\Logs\Plugins\Microsoft.Compute.CustomScriptExtension' -$pluginLogFilePath = Join-Path -Path $pluginLogDir -ChildPath ("{0}-{1}.log" -f $scriptName, $runTimestamp) -$scriptLogTargets = @() if (-not (Test-Path -Path $runOutputDir -PathType Container)) { New-Item -Path $runOutputDir -ItemType Directory -Force | Out-Null @@ -138,21 +135,6 @@ if (-not (Test-Path -Path $logFilePath -PathType Leaf)) { New-Item -Path $logFilePath -ItemType File -Force | Out-Null } -$scriptLogTargets += $logFilePath - -try { - if (-not (Test-Path -Path $pluginLogDir -PathType Container)) { - New-Item -Path $pluginLogDir -ItemType Directory -Force | Out-Null - } - if (-not (Test-Path -Path $pluginLogFilePath -PathType Leaf)) { - New-Item -Path $pluginLogFilePath -ItemType File -Force | Out-Null - } - $scriptLogTargets += $pluginLogFilePath -} -catch { - [Console]::Error.WriteLine("[Warning $(Get-Date)]Failed to initialize plugin log path '$pluginLogFilePath': $($_.Exception.Message)") -} - $script:OriginalLogOutput = (Get-Command Log-Output -CommandType Function).ScriptBlock $script:OriginalLogInfo = (Get-Command Log-Info -CommandType Function).ScriptBlock $script:OriginalLogWarning = (Get-Command Log-Warning -CommandType Function).ScriptBlock @@ -171,59 +153,46 @@ function Write-DesktopLogLine { try { $renderedMessage = ($Message | ForEach-Object { "$_" }) -join ' ' $line = "[{0} {1}]{2}" -f $Level, (Get-Date), $renderedMessage - foreach ($target in $scriptLogTargets) { - Add-Content -Path $target -Value $line -Encoding UTF8 -ErrorAction Stop - } + Add-Content -Path $logFilePath -Value $line -Encoding UTF8 -ErrorAction Stop } catch { if ($script:OriginalLogWarning) { - & $script:OriginalLogWarning -message "Failed to append to one or more log targets '$($scriptLogTargets -join ', ')': $($_.Exception.Message)" + & $script:OriginalLogWarning -message "Failed to append to desktop log '$logFilePath': $($_.Exception.Message)" } else { - [Console]::Error.WriteLine("[Warning $(Get-Date)]Failed to append to one or more log targets '$($scriptLogTargets -join ', ')': $($_.Exception.Message)") + [Console]::Error.WriteLine("[Warning $(Get-Date)]Failed to append to desktop log '$logFilePath': $($_.Exception.Message)") } } } -function Invoke-LogWithMirrors { - param( - [Parameter(Mandatory = $true)] - [ScriptBlock]$OriginalLogger, - - [Parameter(Mandatory = $true)] - [string]$Level, - - [Parameter(Mandatory = $true)] - [PSObject[]]$Message - ) - - & $OriginalLogger -message $Message - Write-DesktopLogLine -Level $Level -Message $Message -} - function Log-Output { Param([Parameter(Mandatory = $true)][PSObject[]]$message) - Invoke-LogWithMirrors -OriginalLogger $script:OriginalLogOutput -Level 'Output' -Message $message + & $script:OriginalLogOutput -message $message + Write-DesktopLogLine -Level 'Output' -Message $message } function Log-Info { Param([Parameter(Mandatory = $true)][PSObject[]]$message) - Invoke-LogWithMirrors -OriginalLogger $script:OriginalLogInfo -Level 'Info' -Message $message + & $script:OriginalLogInfo -message $message + Write-DesktopLogLine -Level 'Info' -Message $message } function Log-Warning { Param([Parameter(Mandatory = $true)][PSObject[]]$message) - Invoke-LogWithMirrors -OriginalLogger $script:OriginalLogWarning -Level 'Warning' -Message $message + & $script:OriginalLogWarning -message $message + Write-DesktopLogLine -Level 'Warning' -Message $message } function Log-Error { Param([Parameter(Mandatory = $true)][PSObject[]]$message) - Invoke-LogWithMirrors -OriginalLogger $script:OriginalLogError -Level 'Error' -Message $message + & $script:OriginalLogError -message $message + Write-DesktopLogLine -Level 'Error' -Message $message } function Log-Debug { Param([Parameter(Mandatory = $true)][PSObject[]]$message) - Invoke-LogWithMirrors -OriginalLogger $script:OriginalLogDebug -Level 'Debug' -Message $message + & $script:OriginalLogDebug -message $message + Write-DesktopLogLine -Level 'Debug' -Message $message } function Invoke-StaleEfiTempLetterSweep { @@ -264,12 +233,9 @@ function Invoke-StaleEfiTempLetterSweep { $logFile = $logFilePath Log-Info "Desktop plain text log initialized: $logFilePath" -if ($scriptLogTargets -contains $pluginLogFilePath) { - Log-Info "Plugin plain text log initialized: $pluginLogFilePath" -} # Optional startup cleanup for stale EFI temp letters from interrupted runs. -# Disabled by default to avoid removing intentionally mounted EFI volumes. +# Disabled by default to preserve existing behavior. $enableStaleEfiSweepCleanup = $false Invoke-StaleEfiTempLetterSweep -Cleanup $enableStaleEfiSweepCleanup @@ -501,9 +467,6 @@ catch { finally { Log-Info "Summary: processed=$processedCount changed=$changedCount skipped=$skippedCount failed=$failedCount" Log-Info "Desktop log file: $logFile" - if ($scriptLogTargets -contains $pluginLogFilePath) { - Log-Info "Plugin log file: $pluginLogFilePath" - } Log-Info "Script ended at $(Get-Date)" } From c6a8ec44f3966232a94465c0ae9a13246e2699fc Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:07:04 +0300 Subject: [PATCH 11/43] Update --- src/windows/sac-enabler.ps1 | 40 ++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 6b03bf15..837f571a 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -271,14 +271,37 @@ try { # Step 1 - Enumerate partitions to locate the BCD store and OS loader $partitionlist = Get-Disk-Partitions $rescueDrive = $env:SystemDrive -replace ':', '' + $partitionGroups = @($partitionlist | Group-Object DiskNumber) + $rescueDiskNum = (Get-Partition -DriveLetter $rescueDrive -ErrorAction SilentlyContinue | Select-Object -First 1).DiskNumber + + $hasNonRescueDisk = $false + foreach ($group in $partitionGroups) { + if ($null -ne $rescueDiskNum -and ([int]$group.Name -ne [int]$rescueDiskNum)) { + $hasNonRescueDisk = $true + break + } + } + + $processRescueDisk = -not $hasNonRescueDisk + + if ($processRescueDisk) { + Log-Warning "No attached non-rescue disk detected. Falling back to in-place mode on current VM disk." + } + Log-Info 'Enumerating partitions to enable SAC...' - foreach ( $partitionGroup in $partitionlist | Group-Object DiskNumber ) + foreach ( $partitionGroup in $partitionGroups ) { + $diskNumber = [int]$partitionGroup.Name + + if (($null -ne $rescueDiskNum) -and (-not $processRescueDisk) -and ($diskNumber -eq [int]$rescueDiskNum)) { + Log-Info "Skipping rescue host disk $diskNumber because attached disk(s) were detected." + continue + } + $processedCount++ $diskChanged = $false $diskFailed = $false - $diskNumber = $partitionGroup.Name $isBcdPath = $false $bcdPath = '' $isOsPath = $false @@ -293,7 +316,7 @@ try { ForEach ($drive in $partitionGroup.Group | Select-Object -ExpandProperty DriveLetter ) { # Skip the rescue VM's own OS drive - if ($drive -eq $rescueDrive) { continue } + if (($drive -eq $rescueDrive) -and (-not $processRescueDisk)) { continue } if ( -not $isBcdPath ) { @@ -317,8 +340,7 @@ try { if (-not $isBcdPath -and $isOsPath) { $diskNum = [int]$partitionGroup.Name - $rescueDiskNum = (Get-Partition -DriveLetter $rescueDrive -ErrorAction SilentlyContinue | Select-Object -First 1).DiskNumber - if ($diskNum -ne $rescueDiskNum) + if (($null -eq $rescueDiskNum) -or ($diskNum -ne [int]$rescueDiskNum) -or $processRescueDisk) { Log-Info "Disk ${diskNum}: OS found but no BCD - checking for unlettered EFI partition (Gen2)..." $efiGptType = '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}' @@ -454,6 +476,14 @@ try { } if ($script_final_status -ne $STATUS_SUCCESS) { + if (($failedCount -eq 0) -and ($changedCount -eq 0)) { + if ($processRescueDisk) { + $failureReason = 'In-place mode did not find a valid BCD + OS loader combination on the current VM disk.' + } + else { + $failureReason = 'No attached OS disk was detected with a valid BCD + OS loader combination.' + } + } Log-Error "FAILED: $failureReason" } } From 68f0c00c7fa8d22909c0ca7373dfc09986123ab4 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:21:26 +0300 Subject: [PATCH 12/43] Update --- src/windows/sac-enabler.ps1 | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 837f571a..4dad2b79 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -274,19 +274,36 @@ try { $partitionGroups = @($partitionlist | Group-Object DiskNumber) $rescueDiskNum = (Get-Partition -DriveLetter $rescueDrive -ErrorAction SilentlyContinue | Select-Object -First 1).DiskNumber - $hasNonRescueDisk = $false + $hasAttachedOsCandidate = $false foreach ($group in $partitionGroups) { - if ($null -ne $rescueDiskNum -and ([int]$group.Name -ne [int]$rescueDiskNum)) { - $hasNonRescueDisk = $true + if ($null -ne $rescueDiskNum -and ([int]$group.Name -eq [int]$rescueDiskNum)) { + continue + } + + foreach ($drive in $group.Group | Select-Object -ExpandProperty DriveLetter) { + if ([string]::IsNullOrWhiteSpace("$drive")) { continue } + + $winloadExePath = $drive + ':\windows\system32\winload.exe' + $winloadEfiPath = $drive + ':\windows\system32\winload.efi' + if ((Test-Path $winloadExePath) -or (Test-Path $winloadEfiPath)) { + $hasAttachedOsCandidate = $true + break + } + } + + if ($hasAttachedOsCandidate) { break } } - $processRescueDisk = -not $hasNonRescueDisk + $processRescueDisk = -not $hasAttachedOsCandidate if ($processRescueDisk) { Log-Warning "No attached non-rescue disk detected. Falling back to in-place mode on current VM disk." } + else { + Log-Info "Detected attached OS candidate disk(s). Running in rescue mode." + } Log-Info 'Enumerating partitions to enable SAC...' From 9c9395509548f81e8fb8dd2d3860a83e30cac337 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:55:43 +0300 Subject: [PATCH 13/43] Update --- src/windows/sac-enabler.ps1 | 396 +++++++++++++++++++++++------------- 1 file changed, 259 insertions(+), 137 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 4dad2b79..ae6671bf 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -19,16 +19,31 @@ 8. Logs the BCD configuration after changes for verification. .NOTES - Name: sac-enabler.ps1 - Author: Tony.Mocanu@Microsoft.com + Name: sac-enabler.ps1 + Author: Tony.Mocanu@Microsoft.com + Requirement: Azure rescue VM with attached Windows OS disk OR standard VM with local modifications + DeployMode: az vm repair run (with --run-on-repair) .VERSION - v1.3: [May 2026] - Updated the script again (current) - - Fixed breaking exception when the Hyper-V module is not installed on the host. - - Added explicit checking via Get-Module before executing nested VM discovery. - v1.2: [May 2026] - Updated the script - - Included advanced Gen2 unlettered EFI fallback and dynamic drive-letter assignment. - v0.1: Initial commit. This was the version 1.0 of the script. + v1.3: [July 2026] - Added execution context detection and dual-logging (current) + - Detects rescue VM mode vs standard mode for context-aware error messages. + - Dual-logs to desktop and plugin directory for az vm repair auto-collection. + - **NEW SAFETY: Pre-flight checks, BCD backup, and post-change verification. + - **NEW SAFETY: Validates GUID format before making BCD edits. + - **NEW SAFETY: Verifies EMS was actually enabled after bcdedit commands. + - Added .ROLLBACK_RECOVERY section with disaster recovery instructions. + - Annotated Log-* wrapper pattern with consolidation note. + v1.2: [May 2026] - Fixed breaking exception when the Hyper-V module is not installed on the host. + - Added explicit checking via Get-Module before executing nested VM discovery. + v1.1: [May 2026] - Included advanced Gen2 unlettered EFI fallback and dynamic drive-letter assignment. + v0.1: [Initial] - Initial commit. Version 1.0 of the script. + + .MODE_DETECTION + This script can run in two modes: + - RESCUE_VM: Running from a rescue VM with the target OS disk attached as a secondary disk. + Use: az vm repair run -g -n --run-id win-sac-enabler --run-on-repair + - STANDARD_VM: Running directly on the target VM (not recommended; use rescue mode for safety). + Useful for testing or direct remediation if rescue VM access is unavailable. .SCENARIO_RECREATION To recreate a testable scenario on a rescue VM with an attached OS disk: @@ -101,6 +116,31 @@ bcdedit /store P:\efi\microsoft\boot\bcd /enum "{bootmgr}" NOTE: For Gen2 disks, the script automatically assigns a temporary drive letter to the EFI System Partition via diskpart if Get-Disk-Partitions did not assign one. The temporary letter is removed after processing. + +.ROLLBACK_RECOVERY + IF THE VM FAILS TO BOOT AFTER az vm repair restore: + + 1. Boot the VM from the Windows installation media or attach to a rescue VM. + 2. Locate the BCD backup file created by the script: + - From rescue VM: Look in the mounted disk for *.backup-* files + - Example path: F:\boot\bcd.backup-20260724-153022 (or S:\efi\microsoft\boot\bcd.backup-...) + 3. Restore the BCD from backup: + Gen1 (System Reserved): + bcdedit /store F:\boot\bcd /import F:\boot\bcd.backup-20260724-153022 + + Gen2 (EFI partition with assigned letter): + bcdedit /store S:\efi\microsoft\boot\bcd /import S:\efi\microsoft\boot\bcd.backup-20260724-153022 + + 4. Verify the BCD was restored: + bcdedit /store F:\boot\bcd /enum "{default}" | findstr /I "ems" + Expected: ems = No (or absent) + + 5. Boot the VM. It should start normally without SAC/EMS enabled. + + ALTERNATIVE (if BCD restore doesn't work): + - Use sfc /scannow from Windows Recovery Environment to repair system files + - Use bcdboot.exe to rebuild the BCD store from scratch + - See internal troubleshooting guide: azure-vm-dump-issues.md #> # Initialization (path-validated) @@ -121,20 +161,94 @@ if (-not (Test-Path -Path $diskPartitionsPath -PathType Leaf)) { . $diskPartitionsPath -# Script-level logging: create a plain text desktop log that mirrors Log-* output. +# =========================================== +# Execution Context Detection +# =========================================== +function Test-RescueVmMode { + <# + .SYNOPSIS + Detects if the script is running in rescue VM mode (attached OS disk) or standard mode (local VM). + .DESCRIPTION + Rescue VM mode: The target OS disk is attached as a secondary disk to a rescue VM. + Get-Disk will show multiple disks, rescue disk is at index 0. + Standard mode: The script runs on the actual target VM. + Only one disk (or all disks are the OS disk). + #> + try { + $disks = @(Get-Disk -ErrorAction Stop | Where-Object { $_.OperationalStatus -eq 'Online' }) + + if ($disks.Count -lt 2) { + return $false # Standard mode: only one disk (or one online disk) + } + + # Check if multiple OS drives are detected (unlikely on rescue, likely on standard) + $osDriveLetters = @(Get-Volume -ErrorAction Stop | Where-Object { $_.DriveLetter } | Select-Object -ExpandProperty DriveLetter | Where-Object { $_ -eq $env:SystemDrive[0] }) + + # Rescue mode: typically has multiple disks but only one local OS drive (the rescue VM's own) + # Standard mode: has the target disk with its own OS boot structures + return ($disks.Count -ge 2) + } + catch { + # If we can't determine, assume we might be in rescue mode (safer assumption) + return $true + } +} + +$isRescueVmMode = Test-RescueVmMode +$executionContext = if ($isRescueVmMode) { 'RESCUE_VM' } else { 'STANDARD_VM' } + +# =========================================== +# Logging Setup (Dual-Write: Desktop + Plugin Directory) +# =========================================== $scriptName = [System.IO.Path]::GetFileNameWithoutExtension($MyInvocation.MyCommand.Name) $runTimestamp = Get-Date -Format 'yyyyMMdd-HHmmss' -$runOutputDir = Join-Path -Path $env:PUBLIC -ChildPath ("Desktop\\{0}-run-{1}" -f $scriptName, $runTimestamp) -$logFilePath = Join-Path -Path $runOutputDir -ChildPath ("{0}-{1}.log" -f $scriptName, $runTimestamp) -if (-not (Test-Path -Path $runOutputDir -PathType Container)) { - New-Item -Path $runOutputDir -ItemType Directory -Force | Out-Null +# Desktop log (for local inspection) +$desktopLogDir = Join-Path -Path $env:PUBLIC -ChildPath ("Desktop\\{0}-run-{1}" -f $scriptName, $runTimestamp) +$desktopLogFile = Join-Path -Path $desktopLogDir -ChildPath ("{0}-{1}.log" -f $scriptName, $runTimestamp) + +# Plugin directory log (for az vm repair auto-collection) +$pluginLogDir = 'C:\WindowsAzure\Logs\Plugins\Microsoft.Compute.CustomScriptExtension\' +$pluginLogFile = Join-Path -Path $pluginLogDir -ChildPath ("{0}_{1}.log" -f $scriptName, $runTimestamp) + +# Ensure directories exist +@($desktopLogDir, $pluginLogDir) | ForEach-Object { + if (-not (Test-Path -Path $_ -PathType Container)) { + try { + New-Item -Path $_ -ItemType Directory -Force -ErrorAction Stop | Out-Null + } + catch { + # Plugin dir may not be creatable; continue with desktop log only + if ($_ -eq $pluginLogDir) { + [Console]::Error.WriteLine("[Warning] Could not create plugin log directory: $_. Will use desktop log only.") + } else { + throw + } + } + } } -if (-not (Test-Path -Path $logFilePath -PathType Leaf)) { - New-Item -Path $logFilePath -ItemType File -Force | Out-Null +# Initialize log files +@($desktopLogFile, $pluginLogFile) | Where-Object { -not (Test-Path -Path $_ -PathType Leaf) } | ForEach-Object { + try { + New-Item -Path $_ -ItemType File -Force -ErrorAction Stop | Out-Null + } + catch { + # Log creation failure is not critical; logging will append if file doesn't exist + } } +# For backward compatibility with existing Log-* function references +$logFilePath = $desktopLogFile + + +# =========================================== +# Log Wrapper Functions (Consolidation Note) +# =========================================== +# NOTE: This Log-* wrapper pattern (duplicated across sac-enabler.ps1 and other scripts) +# should be consolidated into a shared helper module (e.g., common\helpers\Logging-Helper.ps1) +# to avoid duplication. All scripts should source a single centralized logging provider. + $script:OriginalLogOutput = (Get-Command Log-Output -CommandType Function).ScriptBlock $script:OriginalLogInfo = (Get-Command Log-Info -CommandType Function).ScriptBlock $script:OriginalLogWarning = (Get-Command Log-Warning -CommandType Function).ScriptBlock @@ -153,14 +267,19 @@ function Write-DesktopLogLine { try { $renderedMessage = ($Message | ForEach-Object { "$_" }) -join ' ' $line = "[{0} {1}]{2}" -f $Level, (Get-Date), $renderedMessage - Add-Content -Path $logFilePath -Value $line -Encoding UTF8 -ErrorAction Stop + + # Write to both log files (desktop and plugin directory) + Add-Content -Path $desktopLogFile -Value $line -Encoding UTF8 -ErrorAction Stop + if (Test-Path -Path $pluginLogDir -PathType Container) { + Add-Content -Path $pluginLogFile -Value $line -Encoding UTF8 -ErrorAction Stop + } } catch { if ($script:OriginalLogWarning) { - & $script:OriginalLogWarning -message "Failed to append to desktop log '$logFilePath': $($_.Exception.Message)" + & $script:OriginalLogWarning -message "Failed to append to log files: $($_.Exception.Message)" } else { - [Console]::Error.WriteLine("[Warning $(Get-Date)]Failed to append to desktop log '$logFilePath': $($_.Exception.Message)") + [Console]::Error.WriteLine("[Warning $(Get-Date)]Failed to append to log files: $($_.Exception.Message)") } } } @@ -195,61 +314,45 @@ function Log-Debug { Write-DesktopLogLine -Level 'Debug' -Message $message } -function Invoke-StaleEfiTempLetterSweep { - param( - [bool]$Cleanup = $false - ) - - $efiGptType = '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}' - $candidateLetters = @('Q','R','S','T','U','V','W','X','Y','Z') - - foreach ($letter in $candidateLetters) { - $vol = Get-Volume -DriveLetter $letter -ErrorAction SilentlyContinue - if (-not $vol) { continue } - - $part = Get-Partition -DriveLetter $letter -ErrorAction SilentlyContinue - if (-not $part) { continue } - - if ($part.GptType -ne $efiGptType) { continue } - - $bcdCandidate = "${letter}:\efi\microsoft\boot\bcd" - if (-not (Test-Path -Path $bcdCandidate)) { continue } - - Log-Warning "Detected mounted EFI partition at ${letter}: that may be from a prior interrupted run (Disk $($part.DiskNumber), Partition $($part.PartitionNumber))." - - if ($Cleanup) { - try { - Log-Info "Removing stale EFI temp letter ${letter}: from Disk $($part.DiskNumber) Partition $($part.PartitionNumber)" - $dpClean = @("select disk $($part.DiskNumber)", "select partition $($part.PartitionNumber)", "remove letter=$letter") - $dpCleanOut = $dpClean | diskpart 2>&1 - foreach ($line in @($dpCleanOut)) { if ($line) { Log-Output "[diskpart][startup-cleanup] $line" } } - } - catch { - Log-Warning "Failed removing stale EFI temp letter ${letter}: $($_.Exception.Message)" - } - } - } -} - $logFile = $logFilePath -Log-Info "Desktop plain text log initialized: $logFilePath" - -# Optional startup cleanup for stale EFI temp letters from interrupted runs. -# Disabled by default to preserve existing behavior. -$enableStaleEfiSweepCleanup = $false -Invoke-StaleEfiTempLetterSweep -Cleanup $enableStaleEfiSweepCleanup +Log-Info "Dual logging initialized - Desktop: $desktopLogFile | Plugin: $pluginLogFile" +Log-Info "Execution mode: $executionContext" # Status Tracking $script_final_status = $STATUS_ERROR -$failureReason = 'Script could not find a valid OS disk to enable SAC.' +$contextAwareFailureReason = @{ + RESCUE_VM = 'Script could not find a valid attached OS disk to enable SAC. Verify the disk is properly attached to the rescue VM.' + STANDARD_VM = 'Script could not find a valid OS disk. Running on the local VM instead of rescue mode may limit disk detection.' +} +$failureReason = $contextAwareFailureReason[$executionContext] $processedCount = 0 $skippedCount = 0 $failedCount = 0 $changedCount = 0 -Log-Info "Starting SAC enabler. Desktop log: $logFile" +Log-Info "Starting SAC enabler in $executionContext mode. Logs: $logFile" try { + # Optional: Clean up orphaned temp drive letters from previous failed runs + # This helps prevent lingering mount points from blocking EFI partition access + $orphanedLetters = @() + try { + Get-Volume -ErrorAction SilentlyContinue | Where-Object { $_.DriveLetter -and -not $_.DriveType -eq 'Unknown' } | ForEach-Object { + $letter = $_.DriveLetter + $volumePath = "${letter}:\" + if (-not (Test-Path -Path $volumePath)) { + $orphanedLetters += $letter + } + } + if ($orphanedLetters.Count -gt 0) { + Log-Info "Found potentially orphaned drive letters (may be harmless): $($orphanedLetters -join ', '). Continuing..." + } + } + catch { + # Orphan detection is optional; don't block on failure + Log-Debug "Orphan detection encountered an error (non-critical): $($_.Exception.Message)" + } + # Check if the Hyper-V module is available before performing nested VM checks if (Get-Module -ListAvailable -Name Hyper-V) { $guestHyperVVirtualMachine = Get-VM -ErrorAction SilentlyContinue -WarningAction SilentlyContinue @@ -271,61 +374,49 @@ try { # Step 1 - Enumerate partitions to locate the BCD store and OS loader $partitionlist = Get-Disk-Partitions $rescueDrive = $env:SystemDrive -replace ':', '' - $partitionGroups = @($partitionlist | Group-Object DiskNumber) + Log-Info 'Enumerating partitions to enable SAC...' + + # SAFETY CHECK: Ensure we're not operating on the rescue VM's own disk $rescueDiskNum = (Get-Partition -DriveLetter $rescueDrive -ErrorAction SilentlyContinue | Select-Object -First 1).DiskNumber - - $hasAttachedOsCandidate = $false - foreach ($group in $partitionGroups) { - if ($null -ne $rescueDiskNum -and ([int]$group.Name -eq [int]$rescueDiskNum)) { - continue - } - - foreach ($drive in $group.Group | Select-Object -ExpandProperty DriveLetter) { - if ([string]::IsNullOrWhiteSpace("$drive")) { continue } - - $winloadExePath = $drive + ':\windows\system32\winload.exe' - $winloadEfiPath = $drive + ':\windows\system32\winload.efi' - if ((Test-Path $winloadExePath) -or (Test-Path $winloadEfiPath)) { - $hasAttachedOsCandidate = $true - break - } - } - - if ($hasAttachedOsCandidate) { - break - } - } - - $processRescueDisk = -not $hasAttachedOsCandidate - - if ($processRescueDisk) { - Log-Warning "No attached non-rescue disk detected. Falling back to in-place mode on current VM disk." - } - else { - Log-Info "Detected attached OS candidate disk(s). Running in rescue mode." + Log-Info "Rescue VM OS disk identified as Disk $rescueDiskNum" + + $targetDisksFound = @($partitionlist | Group-Object DiskNumber | Where-Object { [int]$_.Name -ne $rescueDiskNum }) + if ($targetDisksFound.Count -eq 0 -and $executionContext -eq 'RESCUE_VM') { + Log-Error "CRITICAL SAFETY CHECK FAILED: No secondary disks found in rescue VM mode." + Log-Error "This script requires an attached OS disk (different from the rescue VM's own disk)." + $script_final_status = $STATUS_ERROR + $failureReason = "No secondary disks found. Rescue VM must have an attached target OS disk." } - - Log-Info 'Enumerating partitions to enable SAC...' - - foreach ( $partitionGroup in $partitionGroups ) + else { - $diskNumber = [int]$partitionGroup.Name - - if (($null -ne $rescueDiskNum) -and (-not $processRescueDisk) -and ($diskNumber -eq [int]$rescueDiskNum)) { - Log-Info "Skipping rescue host disk $diskNumber because attached disk(s) were detected." - continue + if ($executionContext -eq 'STANDARD_VM') { + Log-Warning "Running in STANDARD_VM mode. Changes will be applied to the local VM's boot configuration." + Log-Warning "SAFETY RISK: If changes corrupt BCD, the VM may not boot after restart." + Log-Warning "Ensure you have recovery/rollback capability before proceeding." } + foreach ( $partitionGroup in $partitionlist | Group-Object DiskNumber ) + { $processedCount++ $diskChanged = $false $diskFailed = $false + $diskNumber = $partitionGroup.Name $isBcdPath = $false $bcdPath = '' $isOsPath = $false $tempEfiLetter = $null $tempEfiDiskNum = $null $tempEfiPartNum = $null + $bcdBackup = $null + Log-Info "Processing Disk $diskNumber" + + # SAFETY: Skip if this is the rescue VM's own disk in rescue mode + if ($executionContext -eq 'RESCUE_VM' -and [int]$diskNumber -eq $rescueDiskNum) { + Log-Warning "Disk $diskNumber is the rescue VM's own disk. Skipping for safety." + $skippedCount++ + continue + } try { @@ -333,7 +424,7 @@ try { ForEach ($drive in $partitionGroup.Group | Select-Object -ExpandProperty DriveLetter ) { # Skip the rescue VM's own OS drive - if (($drive -eq $rescueDrive) -and (-not $processRescueDisk)) { continue } + if ($drive -eq $rescueDrive) { continue } if ( -not $isBcdPath ) { @@ -357,7 +448,8 @@ try { if (-not $isBcdPath -and $isOsPath) { $diskNum = [int]$partitionGroup.Name - if (($null -eq $rescueDiskNum) -or ($diskNum -ne [int]$rescueDiskNum) -or $processRescueDisk) + $rescueDiskNum = (Get-Partition -DriveLetter $rescueDrive -ErrorAction SilentlyContinue | Select-Object -First 1).DiskNumber + if ($diskNum -ne $rescueDiskNum) { Log-Info "Disk ${diskNum}: OS found but no BCD - checking for unlettered EFI partition (Gen2)..." $efiGptType = '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}' @@ -425,36 +517,64 @@ try { } elseif ($defaultLine -match '\{([^}]+)\}') { $defaultId = $matches[0] + + # VALIDATION: Confirm we have a valid GUID + if ($defaultId -notmatch '^\{[0-9a-f\-]{36}\}$') { + Log-Error "Invalid boot entry GUID format: $defaultId. This may indicate a corrupted BCD store." + $diskFailed = $true + } + else + { + # VALIDATION: Backup BCD store before any modifications + $bcdBackup = $bcdPath + '.backup-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + try { + Copy-Item -Path $bcdPath -Destination $bcdBackup -Force -ErrorAction Stop + Log-Info "BCD backup created at: $bcdBackup" + } + catch { + Log-Warning "Could not create BCD backup: $($_.Exception.Message). Proceeding with caution." + } - # Step 3 - Log BCD configuration before changes - Log-Output "--- BCD BEFORE SAC ENABLE ---" - $beforeBcd = bcdedit /store $bcdPath /enum $defaultId - foreach ($line in $beforeBcd) { if ($line.Trim()) { Log-Output $line } } + # Step 3 - Log BCD configuration before changes + Log-Output "--- BCD BEFORE SAC ENABLE ---" + $beforeBcd = bcdedit /store $bcdPath /enum $defaultId + foreach ($line in $beforeBcd) { if ($line.Trim()) { Log-Output $line } } - # Steps 4-7 - Enable boot menu, Boot EMS, EMS on OS entry, and EMS serial settings - Log-Info "Applying SAC and EMS configurations..." - $setBootMenuOut = bcdedit /store $bcdPath /set "{bootmgr}" displaybootmenu yes 2>&1 - foreach ($line in @($setBootMenuOut)) { if ($line) { Log-Output "[bcdedit][displaybootmenu] $line" } } + # Steps 4-7 - Enable boot menu, Boot EMS, EMS on OS entry, and EMS serial settings + Log-Info "Applying SAC and EMS configurations to BCD: $bcdPath" + $setBootMenuOut = bcdedit /store $bcdPath /set "{bootmgr}" displaybootmenu yes 2>&1 + foreach ($line in @($setBootMenuOut)) { if ($line) { Log-Output "[bcdedit][displaybootmenu] $line" } } - $setTimeoutOut = bcdedit /store $bcdPath /set "{bootmgr}" timeout 5 2>&1 - foreach ($line in @($setTimeoutOut)) { if ($line) { Log-Output "[bcdedit][timeout] $line" } } + $setTimeoutOut = bcdedit /store $bcdPath /set "{bootmgr}" timeout 5 2>&1 + foreach ($line in @($setTimeoutOut)) { if ($line) { Log-Output "[bcdedit][timeout] $line" } } - $setBootEmsOut = bcdedit /store $bcdPath /set "{bootmgr}" bootems yes 2>&1 - foreach ($line in @($setBootEmsOut)) { if ($line) { Log-Output "[bcdedit][bootems] $line" } } + $setBootEmsOut = bcdedit /store $bcdPath /set "{bootmgr}" bootems yes 2>&1 + foreach ($line in @($setBootEmsOut)) { if ($line) { Log-Output "[bcdedit][bootems] $line" } } - $setEmsOut = bcdedit /store $bcdPath /ems $defaultId ON 2>&1 - foreach ($line in @($setEmsOut)) { if ($line) { Log-Output "[bcdedit][ems] $line" } } + $setEmsOut = bcdedit /store $bcdPath /ems $defaultId ON 2>&1 + foreach ($line in @($setEmsOut)) { if ($line) { Log-Output "[bcdedit][ems] $line" } } - $setEmsSettingsOut = bcdedit /store $bcdPath /emssettings EMSPORT:1 EMSBAUDRATE:115200 2>&1 - foreach ($line in @($setEmsSettingsOut)) { if ($line) { Log-Output "[bcdedit][emssettings] $line" } } + $setEmsSettingsOut = bcdedit /store $bcdPath /emssettings EMSPORT:1 EMSBAUDRATE:115200 2>&1 + foreach ($line in @($setEmsSettingsOut)) { if ($line) { Log-Output "[bcdedit][emssettings] $line" } } - # Step 8 - Log BCD configuration after changes for verification - Log-Output "--- BCD AFTER SAC ENABLE ---" - $afterBcd = bcdedit /store $bcdPath /enum $defaultId - foreach ($line in $afterBcd) { if ($line.Trim()) { Log-Output $line } } - - $script_final_status = $STATUS_SUCCESS - $diskChanged = $true + # VALIDATION: Verify BCD changes were applied successfully + Log-Info "Verifying BCD changes..." + $verifyBcd = bcdedit /store $bcdPath /enum $defaultId + $emsEnabled = $verifyBcd | Select-String 'ems' | Select-String 'Yes' + if (-not $emsEnabled) { + Log-Error "CRITICAL: EMS verification failed! BCD may be corrupted. Restore from backup: $bcdBackup" + $diskFailed = $true + } + else { + # Step 8 - Log BCD configuration after changes for verification + Log-Output "--- BCD AFTER SAC ENABLE ---" + $afterBcd = bcdedit /store $bcdPath /enum $defaultId + foreach ($line in $afterBcd) { if ($line.Trim()) { Log-Output $line } } + + $script_final_status = $STATUS_SUCCESS + $diskChanged = $true + } + } } else { @@ -466,7 +586,7 @@ try { else { Log-Info "Disk $diskNumber skipped: no valid BCD + OS loader combination was found." } - } + catch { $diskFailed = $true $failureReason = "Disk $diskNumber failed with exception: $($_.Exception.Message)" @@ -491,17 +611,15 @@ try { else { $skippedCount++ } } } + } # Close else block from target disk check if ($script_final_status -ne $STATUS_SUCCESS) { - if (($failedCount -eq 0) -and ($changedCount -eq 0)) { - if ($processRescueDisk) { - $failureReason = 'In-place mode did not find a valid BCD + OS loader combination on the current VM disk.' - } - else { - $failureReason = 'No attached OS disk was detected with a valid BCD + OS loader combination.' - } + if ($executionContext -eq 'STANDARD_VM') { + Log-Error "[$executionContext] FAILED: $failureReason" + Log-Error "[$executionContext] Note: This script is optimized for RESCUE_VM mode. Consider running from a rescue VM for better disk detection." + } else { + Log-Error "[$executionContext] FAILED: $failureReason" } - Log-Error "FAILED: $failureReason" } } catch { @@ -513,7 +631,11 @@ catch { } finally { Log-Info "Summary: processed=$processedCount changed=$changedCount skipped=$skippedCount failed=$failedCount" - Log-Info "Desktop log file: $logFile" + Log-Info "Execution mode: $executionContext" + Log-Info "Desktop log: $desktopLogFile" + if (Test-Path -Path $pluginLogFile -PathType Leaf) { + Log-Info "Plugin log (auto-collected): $pluginLogFile" + } Log-Info "Script ended at $(Get-Date)" } From 40f5d09f686b54752a85497a1640f0658d81ef3d Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:27:46 +0300 Subject: [PATCH 14/43] Update --- src/windows/sac-enabler.ps1 | 1 - 1 file changed, 1 deletion(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index ae6671bf..97513bb9 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -610,7 +610,6 @@ try { elseif ($diskFailed) { $failedCount++ } else { $skippedCount++ } } - } } # Close else block from target disk check if ($script_final_status -ne $STATUS_SUCCESS) { From 479300ccb263c09f74d80d36e60d3ca21834881c Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:36:18 +0300 Subject: [PATCH 15/43] Update --- src/windows/sac-enabler.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 97513bb9..3ce287b3 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -609,7 +609,8 @@ try { if ($diskChanged) { $changedCount++ } elseif ($diskFailed) { $failedCount++ } else { $skippedCount++ } - } + } # Close finally + } # Close foreach } # Close else block from target disk check if ($script_final_status -ne $STATUS_SUCCESS) { From df37daa65dd0cf791b14b85f431f1be87e738816 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:39:31 +0300 Subject: [PATCH 16/43] Update sac-enabler.ps1 --- src/windows/sac-enabler.ps1 | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 3ce287b3..56dcbf9c 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -586,16 +586,14 @@ try { else { Log-Info "Disk $diskNumber skipped: no valid BCD + OS loader combination was found." } - - catch { + } catch { $diskFailed = $true $failureReason = "Disk $diskNumber failed with exception: $($_.Exception.Message)" Log-Error $failureReason if ($_.InvocationInfo -and $_.InvocationInfo.PositionMessage) { Log-Error "Disk $diskNumber failure context: $($_.InvocationInfo.PositionMessage)" } - } - finally { + } finally { # Clean up temporary EFI drive letter if one was assigned if ($tempEfiLetter) @@ -609,9 +607,9 @@ try { if ($diskChanged) { $changedCount++ } elseif ($diskFailed) { $failedCount++ } else { $skippedCount++ } - } # Close finally - } # Close foreach - } # Close else block from target disk check + } + } + } if ($script_final_status -ne $STATUS_SUCCESS) { if ($executionContext -eq 'STANDARD_VM') { From d964f12fabca12d5559b183dee8c7757c9f09459 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:51:43 +0300 Subject: [PATCH 17/43] Update --- src/windows/sac-enabler.ps1 | 175 +++++++++++++++++------------------- 1 file changed, 84 insertions(+), 91 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 56dcbf9c..509a7805 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -3,7 +3,7 @@ Enables SAC and Serial Console boot settings on attached Windows disks, including BIOS and UEFI layouts. .DESCRIPTION - This script runs from a rescue VM to enable SAC/EMS on an attached OS disk's BCD store. + This script runs only from a repair VM to enable SAC/EMS on an attached OS disk's BCD store. It performs the following steps: 1. Enumerates attached partitions via Get-Disk-Partitions to locate the BCD store and OS loader. OS detection accepts either winload.exe or winload.efi. @@ -21,12 +21,18 @@ .NOTES Name: sac-enabler.ps1 Author: Tony.Mocanu@Microsoft.com - Requirement: Azure rescue VM with attached Windows OS disk OR standard VM with local modifications + Requirement: Azure repair VM with an attached Windows OS disk DeployMode: az vm repair run (with --run-on-repair) .VERSION - v1.3: [July 2026] - Added execution context detection and dual-logging (current) - - Detects rescue VM mode vs standard mode for context-aware error messages. + v1.4: [July 2026] - Restricted execution to repair VM mode (current). + - Uses Get-Disk-Partitions to enumerate Azure virtual disks. + - Detects repair vs. standard context from a Windows loader on a secondary disk. + - Refuses BCD changes when a repair VM context is not detected. + - Fails closed if the repair VM OS disk cannot be identified. + - Filters out the repair VM OS disk before processing attached disks. + v1.3: [July 2026] - Added execution context detection and dual-logging. + - Detected rescue VM mode vs standard mode for context-aware error messages. - Dual-logs to desktop and plugin directory for az vm repair auto-collection. - **NEW SAFETY: Pre-flight checks, BCD backup, and post-change verification. - **NEW SAFETY: Validates GUID format before making BCD edits. @@ -38,16 +44,15 @@ v1.1: [May 2026] - Included advanced Gen2 unlettered EFI fallback and dynamic drive-letter assignment. v0.1: [Initial] - Initial commit. Version 1.0 of the script. - .MODE_DETECTION - This script can run in two modes: - - RESCUE_VM: Running from a rescue VM with the target OS disk attached as a secondary disk. - Use: az vm repair run -g -n --run-id win-sac-enabler --run-on-repair - - STANDARD_VM: Running directly on the target VM (not recommended; use rescue mode for safety). - Useful for testing or direct remediation if rescue VM access is unavailable. + .EXECUTION_CONTEXT + This script is classified as repair-VM-only. It detects whether a Windows OS loader exists + on a secondary disk and refuses to modify BCD when it detects standard VM execution. + Use: az vm repair run -g -n --run-id win-sac-on --run-on-repair + The repair VM OS disk is identified from $env:SystemDrive and excluded from all BCD operations. .SCENARIO_RECREATION - To recreate a testable scenario on a rescue VM with an attached OS disk: - 1. Create a test VM in Azure and attach its OS disk to a rescue VM. + To recreate a testable scenario on a repair VM with an attached OS disk: + 1. Create a test VM in Azure and attach its OS disk to a repair VM. 2. The BCD store is on the System Reserved (Gen1) or EFI (Gen2) partition, which may not have a drive letter. Find it by scanning all volumes (run as Admin): Get-Volume | Where-Object { $_.DriveLetter } | ForEach-Object { $d = $_.DriveLetter; @("$d`:\boot\bcd","$d`:\efi\microsoft\boot\bcd") | Where-Object { Test-Path $_ } | ForEach-Object { Write-Output "FOUND: $_" } } @@ -94,7 +99,7 @@ bcdedit /store S:\efi\microsoft\boot\bcd /enum "{default}" 6. Verify all SAC settings are now enabled (see .VERIFICATION section). .EXAMPLE - az vm repair run -g -n --run-id win-sac-enabler --run-on-repair + az vm repair run -g -n --run-id win-sac-on --run-on-repair .VERIFICATION 1. Check the log file for success: @@ -120,9 +125,9 @@ bcdedit /store P:\efi\microsoft\boot\bcd /enum "{bootmgr}" .ROLLBACK_RECOVERY IF THE VM FAILS TO BOOT AFTER az vm repair restore: - 1. Boot the VM from the Windows installation media or attach to a rescue VM. + 1. Boot the VM from the Windows installation media or attach to a repair VM. 2. Locate the BCD backup file created by the script: - - From rescue VM: Look in the mounted disk for *.backup-* files + - From repair VM: Look in the mounted disk for *.backup-* files - Example path: F:\boot\bcd.backup-20260724-153022 (or S:\efi\microsoft\boot\bcd.backup-...) 3. Restore the BCD from backup: Gen1 (System Reserved): @@ -161,41 +166,37 @@ if (-not (Test-Path -Path $diskPartitionsPath -PathType Leaf)) { . $diskPartitionsPath -# =========================================== -# Execution Context Detection -# =========================================== -function Test-RescueVmMode { - <# - .SYNOPSIS - Detects if the script is running in rescue VM mode (attached OS disk) or standard mode (local VM). - .DESCRIPTION - Rescue VM mode: The target OS disk is attached as a secondary disk to a rescue VM. - Get-Disk will show multiple disks, rescue disk is at index 0. - Standard mode: The script runs on the actual target VM. - Only one disk (or all disks are the OS disk). - #> - try { - $disks = @(Get-Disk -ErrorAction Stop | Where-Object { $_.OperationalStatus -eq 'Online' }) - - if ($disks.Count -lt 2) { - return $false # Standard mode: only one disk (or one online disk) +if (-not (Get-Command -Name Get-Disk-Partitions -CommandType Function -ErrorAction SilentlyContinue)) { + Log-Error "Dependency did not define the required Get-Disk-Partitions function: $diskPartitionsPath" + return $STATUS_ERROR +} + +function Get-SacExecutionContext { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]]$TargetDiskGroups, + + [Parameter(Mandatory = $true)] + [string]$RepairDrive + ) + + foreach ($partitionGroup in $TargetDiskGroups) { + foreach ($drive in @($partitionGroup.Group | Select-Object -ExpandProperty DriveLetter)) { + if (-not $drive -or $drive -eq $RepairDrive) { + continue + } + + $winloadExePath = $drive + ':\windows\system32\winload.exe' + $winloadEfiPath = $drive + ':\windows\system32\winload.efi' + if ((Test-Path -Path $winloadExePath) -or (Test-Path -Path $winloadEfiPath)) { + return 'REPAIR_VM' + } } - - # Check if multiple OS drives are detected (unlikely on rescue, likely on standard) - $osDriveLetters = @(Get-Volume -ErrorAction Stop | Where-Object { $_.DriveLetter } | Select-Object -ExpandProperty DriveLetter | Where-Object { $_ -eq $env:SystemDrive[0] }) - - # Rescue mode: typically has multiple disks but only one local OS drive (the rescue VM's own) - # Standard mode: has the target disk with its own OS boot structures - return ($disks.Count -ge 2) } - catch { - # If we can't determine, assume we might be in rescue mode (safer assumption) - return $true - } -} -$isRescueVmMode = Test-RescueVmMode -$executionContext = if ($isRescueVmMode) { 'RESCUE_VM' } else { 'STANDARD_VM' } + return 'STANDARD_VM' +} # =========================================== # Logging Setup (Dual-Write: Desktop + Plugin Directory) @@ -316,21 +317,18 @@ function Log-Debug { $logFile = $logFilePath Log-Info "Dual logging initialized - Desktop: $desktopLogFile | Plugin: $pluginLogFile" -Log-Info "Execution mode: $executionContext" +Log-Info "Execution mode: REPAIR_VM" # Status Tracking $script_final_status = $STATUS_ERROR -$contextAwareFailureReason = @{ - RESCUE_VM = 'Script could not find a valid attached OS disk to enable SAC. Verify the disk is properly attached to the rescue VM.' - STANDARD_VM = 'Script could not find a valid OS disk. Running on the local VM instead of rescue mode may limit disk detection.' -} -$failureReason = $contextAwareFailureReason[$executionContext] +$failureReason = 'Script could not find a valid attached OS disk to enable SAC. Verify the disk is attached to the repair VM.' +$detectedExecutionContext = 'UNDETERMINED' $processedCount = 0 $skippedCount = 0 $failedCount = 0 $changedCount = 0 -Log-Info "Starting SAC enabler in $executionContext mode. Logs: $logFile" +Log-Info "Starting SAC enabler in REPAIR_VM mode. Logs: $logFile" try { # Optional: Clean up orphaned temp drive letters from previous failed runs @@ -372,30 +370,38 @@ try { } # Step 1 - Enumerate partitions to locate the BCD store and OS loader - $partitionlist = Get-Disk-Partitions - $rescueDrive = $env:SystemDrive -replace ':', '' + $partitionlist = @(Get-Disk-Partitions) + if ($partitionlist.Count -eq 0) { + throw 'Get-Disk-Partitions returned no partitions from Azure virtual disks.' + } + + $discoveredDiskNumbers = @($partitionlist | Select-Object -ExpandProperty DiskNumber -Unique) + Log-Info "Get-Disk-Partitions discovered disk numbers: $($discoveredDiskNumbers -join ', ')" + $repairDrive = $env:SystemDrive -replace ':', '' Log-Info 'Enumerating partitions to enable SAC...' - # SAFETY CHECK: Ensure we're not operating on the rescue VM's own disk - $rescueDiskNum = (Get-Partition -DriveLetter $rescueDrive -ErrorAction SilentlyContinue | Select-Object -First 1).DiskNumber - Log-Info "Rescue VM OS disk identified as Disk $rescueDiskNum" - - $targetDisksFound = @($partitionlist | Group-Object DiskNumber | Where-Object { [int]$_.Name -ne $rescueDiskNum }) - if ($targetDisksFound.Count -eq 0 -and $executionContext -eq 'RESCUE_VM') { - Log-Error "CRITICAL SAFETY CHECK FAILED: No secondary disks found in rescue VM mode." - Log-Error "This script requires an attached OS disk (different from the rescue VM's own disk)." + # SAFETY CHECK: Ensure we're not operating on the repair VM's own disk + $repairOsPartition = Get-Partition -DriveLetter $repairDrive -ErrorAction Stop | Select-Object -First 1 + if ($null -eq $repairOsPartition -or $null -eq $repairOsPartition.DiskNumber) { + throw "CRITICAL SAFETY CHECK FAILED: Could not identify the repair VM OS disk from $($env:SystemDrive)." + } + + $repairDiskNumber = [int]$repairOsPartition.DiskNumber + Log-Info "Repair VM OS disk identified as Disk $repairDiskNumber" + + $targetDiskGroups = @($partitionlist | Group-Object DiskNumber | Where-Object { [int]$_.Name -ne $repairDiskNumber }) + $detectedExecutionContext = Get-SacExecutionContext -TargetDiskGroups $targetDiskGroups -RepairDrive $repairDrive + Log-Info "Detected execution context: $detectedExecutionContext" + + if ($detectedExecutionContext -ne 'REPAIR_VM') { + Log-Error "[STANDARD_VM] REPAIR-ONLY SCRIPT: No attached Windows OS disk was detected on a secondary disk." + Log-Error "[STANDARD_VM] Run this script with az vm repair run and --run-on-repair. No BCD changes were attempted." $script_final_status = $STATUS_ERROR - $failureReason = "No secondary disks found. Rescue VM must have an attached target OS disk." + $failureReason = 'Standard VM context detected, or the repair VM has no accessible attached Windows OS disk.' } else { - if ($executionContext -eq 'STANDARD_VM') { - Log-Warning "Running in STANDARD_VM mode. Changes will be applied to the local VM's boot configuration." - Log-Warning "SAFETY RISK: If changes corrupt BCD, the VM may not boot after restart." - Log-Warning "Ensure you have recovery/rollback capability before proceeding." - } - - foreach ( $partitionGroup in $partitionlist | Group-Object DiskNumber ) + foreach ( $partitionGroup in $targetDiskGroups ) { $processedCount++ $diskChanged = $false @@ -411,20 +417,13 @@ try { Log-Info "Processing Disk $diskNumber" - # SAFETY: Skip if this is the rescue VM's own disk in rescue mode - if ($executionContext -eq 'RESCUE_VM' -and [int]$diskNumber -eq $rescueDiskNum) { - Log-Warning "Disk $diskNumber is the rescue VM's own disk. Skipping for safety." - $skippedCount++ - continue - } - try { # Scan each drive for BCD store and Windows OS loader ForEach ($drive in $partitionGroup.Group | Select-Object -ExpandProperty DriveLetter ) { - # Skip the rescue VM's own OS drive - if ($drive -eq $rescueDrive) { continue } + # The repair disk was filtered out above; retain this drive-level safety check. + if ($drive -eq $repairDrive) { continue } if ( -not $isBcdPath ) { @@ -448,8 +447,7 @@ try { if (-not $isBcdPath -and $isOsPath) { $diskNum = [int]$partitionGroup.Name - $rescueDiskNum = (Get-Partition -DriveLetter $rescueDrive -ErrorAction SilentlyContinue | Select-Object -First 1).DiskNumber - if ($diskNum -ne $rescueDiskNum) + if ($diskNum -ne $repairDiskNumber) { Log-Info "Disk ${diskNum}: OS found but no BCD - checking for unlettered EFI partition (Gen2)..." $efiGptType = '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}' @@ -612,16 +610,11 @@ try { } if ($script_final_status -ne $STATUS_SUCCESS) { - if ($executionContext -eq 'STANDARD_VM') { - Log-Error "[$executionContext] FAILED: $failureReason" - Log-Error "[$executionContext] Note: This script is optimized for RESCUE_VM mode. Consider running from a rescue VM for better disk detection." - } else { - Log-Error "[$executionContext] FAILED: $failureReason" - } + Log-Error "[$detectedExecutionContext] FAILED: $failureReason" } } catch { - Log-Error "An error occurred: $($_.Exception.Message)" + Log-Error "[$detectedExecutionContext] An error occurred: $($_.Exception.Message)" if ($_.InvocationInfo -and $_.InvocationInfo.PositionMessage) { Log-Error "Failure context: $($_.InvocationInfo.PositionMessage)" } @@ -629,7 +622,7 @@ catch { } finally { Log-Info "Summary: processed=$processedCount changed=$changedCount skipped=$skippedCount failed=$failedCount" - Log-Info "Execution mode: $executionContext" + Log-Info "Detected execution context: $detectedExecutionContext" Log-Info "Desktop log: $desktopLogFile" if (Test-Path -Path $pluginLogFile -PathType Leaf) { Log-Info "Plugin log (auto-collected): $pluginLogFile" From aeeb45ff0b4f5457264d1cfc3e6454c32040c49c Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:00:44 +0300 Subject: [PATCH 18/43] Update sac-enabler.ps1 --- src/windows/sac-enabler.ps1 | 118 ++++++++++++++++++++++++++---------- 1 file changed, 86 insertions(+), 32 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 509a7805..6d717a30 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -27,7 +27,8 @@ .VERSION v1.4: [July 2026] - Restricted execution to repair VM mode (current). - Uses Get-Disk-Partitions to enumerate Azure virtual disks. - - Detects repair vs. standard context from a Windows loader on a secondary disk. + - Detects repair vs. standard context from secondary disks returned by the helper. + - Mounts unlettered Gen2 Windows and EFI partitions temporarily. - Refuses BCD changes when a repair VM context is not detected. - Fails closed if the repair VM OS disk cannot be identified. - Filters out the repair VM OS disk before processing attached disks. @@ -45,8 +46,8 @@ v0.1: [Initial] - Initial commit. Version 1.0 of the script. .EXECUTION_CONTEXT - This script is classified as repair-VM-only. It detects whether a Windows OS loader exists - on a secondary disk and refuses to modify BCD when it detects standard VM execution. + This script is classified as repair-VM-only. It detects repair context when the helper returns + at least one disk other than the repair VM OS disk and refuses BCD changes when none exists. Use: az vm repair run -g -n --run-id win-sac-on --run-on-repair The repair VM OS disk is identified from $env:SystemDrive and excluded from all BCD operations. @@ -175,27 +176,25 @@ function Get-SacExecutionContext { param( [Parameter(Mandatory = $true)] [AllowEmptyCollection()] - [object[]]$TargetDiskGroups, - - [Parameter(Mandatory = $true)] - [string]$RepairDrive + [object[]]$TargetDiskGroups ) - foreach ($partitionGroup in $TargetDiskGroups) { - foreach ($drive in @($partitionGroup.Group | Select-Object -ExpandProperty DriveLetter)) { - if (-not $drive -or $drive -eq $RepairDrive) { - continue - } + if ($TargetDiskGroups.Count -gt 0) { + return 'REPAIR_VM' + } - $winloadExePath = $drive + ':\windows\system32\winload.exe' - $winloadEfiPath = $drive + ':\windows\system32\winload.efi' - if ((Test-Path -Path $winloadExePath) -or (Test-Path -Path $winloadEfiPath)) { - return 'REPAIR_VM' - } + return 'STANDARD_VM' +} + +function Get-AvailableTempDriveLetter { + $usedLetters = @(Get-Volume -ErrorAction SilentlyContinue | Where-Object { $_.DriveLetter } | Select-Object -ExpandProperty DriveLetter) + foreach ($letter in @('Z','Y','X','W','V','U','T','S','R','Q')) { + if ($letter -notin $usedLetters -and -not (Test-Path -Path "${letter}:\")) { + return $letter } } - return 'STANDARD_VM' + return $null } # =========================================== @@ -317,7 +316,7 @@ function Log-Debug { $logFile = $logFilePath Log-Info "Dual logging initialized - Desktop: $desktopLogFile | Plugin: $pluginLogFile" -Log-Info "Execution mode: REPAIR_VM" +Log-Info "Script classification: REPAIR_VM_ONLY" # Status Tracking $script_final_status = $STATUS_ERROR @@ -328,7 +327,7 @@ $skippedCount = 0 $failedCount = 0 $changedCount = 0 -Log-Info "Starting SAC enabler in REPAIR_VM mode. Logs: $logFile" +Log-Info "Starting repair-only SAC enabler. Logs: $logFile" try { # Optional: Clean up orphaned temp drive letters from previous failed runs @@ -390,11 +389,11 @@ try { Log-Info "Repair VM OS disk identified as Disk $repairDiskNumber" $targetDiskGroups = @($partitionlist | Group-Object DiskNumber | Where-Object { [int]$_.Name -ne $repairDiskNumber }) - $detectedExecutionContext = Get-SacExecutionContext -TargetDiskGroups $targetDiskGroups -RepairDrive $repairDrive + $detectedExecutionContext = Get-SacExecutionContext -TargetDiskGroups $targetDiskGroups Log-Info "Detected execution context: $detectedExecutionContext" if ($detectedExecutionContext -ne 'REPAIR_VM') { - Log-Error "[STANDARD_VM] REPAIR-ONLY SCRIPT: No attached Windows OS disk was detected on a secondary disk." + Log-Error "[STANDARD_VM] REPAIR-ONLY SCRIPT: The helper returned no secondary disk." Log-Error "[STANDARD_VM] Run this script with az vm repair run and --run-on-repair. No BCD changes were attempted." $script_final_status = $STATUS_ERROR $failureReason = 'Standard VM context detected, or the repair VM has no accessible attached Windows OS disk.' @@ -413,6 +412,9 @@ try { $tempEfiLetter = $null $tempEfiDiskNum = $null $tempEfiPartNum = $null + $tempOsLetter = $null + $tempOsDiskNum = $null + $tempOsPartNum = $null $bcdBackup = $null Log-Info "Processing Disk $diskNumber" @@ -443,6 +445,53 @@ try { } } + # Gen2 OS fallback: temporarily mount an unlettered GPT Basic Data partition. + if (-not $isOsPath) + { + $diskNum = [int]$partitionGroup.Name + $basicDataGptType = '{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}' + $unletteredOsCandidates = @(Get-Partition -DiskNumber $diskNum -ErrorAction SilentlyContinue | Where-Object { + $_.GptType -eq $basicDataGptType -and + (-not $_.DriveLetter -or $_.DriveLetter -eq [char]0) -and + $_.Size -gt 10GB + }) + + foreach ($osCandidate in $unletteredOsCandidates) + { + $candidateLetter = Get-AvailableTempDriveLetter + if (-not $candidateLetter) { + Log-Warning "No available drive letter for an unlettered Windows partition on Disk $diskNum" + break + } + + $candidatePartNum = $osCandidate.PartitionNumber + Log-Info "Assigning temp letter ${candidateLetter}: to Disk $diskNum Partition $candidatePartNum (Windows candidate)..." + $dpOsAssign = @("select disk $diskNum", "select partition $candidatePartNum", "assign letter=$candidateLetter") + $dpOsAssignOut = $dpOsAssign | diskpart 2>&1 + foreach ($line in @($dpOsAssignOut)) { if ($line) { Log-Output "[diskpart][os-assign] $line" } } + $tempOsLetter = $candidateLetter + $tempOsDiskNum = $diskNum + $tempOsPartNum = $candidatePartNum + Start-Sleep -Seconds 2 + + $winloadExePath = "${candidateLetter}:\windows\system32\winload.exe" + $winloadEfiPath = "${candidateLetter}:\windows\system32\winload.efi" + $isOsPath = (Test-Path -Path $winloadExePath) -or (Test-Path -Path $winloadEfiPath) + if ($isOsPath) { + Log-Info "Found Windows OS partition at ${candidateLetter}: on Disk $diskNum" + break + } + + Log-Info "No Windows loader found at ${candidateLetter}:, removing letter..." + $dpOsRemove = @("select disk $diskNum", "select partition $candidatePartNum", "remove letter=$candidateLetter") + $dpOsRemoveOut = $dpOsRemove | diskpart 2>&1 + foreach ($line in @($dpOsRemoveOut)) { if ($line) { Log-Output "[diskpart][os-remove] $line" } } + $tempOsLetter = $null + $tempOsDiskNum = $null + $tempOsPartNum = $null + } + } + # Gen2 EFI fallback: if OS found but no BCD, discover unlettered EFI partition if (-not $isBcdPath -and $isOsPath) { @@ -456,13 +505,7 @@ try { } if ($efiParts) { - # Find an available drive letter (Z downward to avoid conflicts) - $usedLetters = @() - Get-Volume -ErrorAction SilentlyContinue | Where-Object { $_.DriveLetter } | ForEach-Object { $usedLetters += $_.DriveLetter } - $tempLetter = $null - foreach ($l in @('Z','Y','X','W','V','U','T','S','R','Q')) { - if ($l -notin $usedLetters) { $tempLetter = $l; break } - } + $tempLetter = Get-AvailableTempDriveLetter if ($tempLetter) { foreach ($ep in $efiParts) @@ -472,15 +515,15 @@ try { $dpLines = @("select disk $diskNum", "select partition $pn", "assign letter=$tempLetter") $dpAssignOut = $dpLines | diskpart 2>&1 foreach ($line in @($dpAssignOut)) { if ($line) { Log-Output "[diskpart][assign] $line" } } + $tempEfiLetter = $tempLetter + $tempEfiDiskNum = $diskNum + $tempEfiPartNum = $pn Start-Sleep -Seconds 2 $bcdPath = "${tempLetter}:\efi\microsoft\boot\bcd" $isBcdPath = Test-Path $bcdPath if ($isBcdPath) { Log-Info "Found Gen2 BCD store at $bcdPath" - $tempEfiLetter = $tempLetter - $tempEfiDiskNum = $diskNum - $tempEfiPartNum = $pn break } else @@ -489,6 +532,9 @@ try { $dpRemove = @("select disk $diskNum", "select partition $pn", "remove letter=$tempLetter") $dpRemoveOut = $dpRemove | diskpart 2>&1 foreach ($line in @($dpRemoveOut)) { if ($line) { Log-Output "[diskpart][remove] $line" } } + $tempEfiLetter = $null + $tempEfiDiskNum = $null + $tempEfiPartNum = $null } } } @@ -602,6 +648,14 @@ try { foreach ($line in @($dpCleanOut)) { if ($line) { Log-Output "[diskpart][cleanup] $line" } } } + if ($tempOsLetter) + { + Log-Info "Removing temp letter ${tempOsLetter}: from Disk $tempOsDiskNum Partition $tempOsPartNum" + $dpOsClean = @("select disk $tempOsDiskNum", "select partition $tempOsPartNum", "remove letter=$tempOsLetter") + $dpOsCleanOut = $dpOsClean | diskpart 2>&1 + foreach ($line in @($dpOsCleanOut)) { if ($line) { Log-Output "[diskpart][os-cleanup] $line" } } + } + if ($diskChanged) { $changedCount++ } elseif ($diskFailed) { $failedCount++ } else { $skippedCount++ } From ca4278f753f76960cdfb0c64d31025ca47ff9c58 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:21:10 +0300 Subject: [PATCH 19/43] Update sac-enabler.ps1 --- src/windows/sac-enabler.ps1 | 51 ++++++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 6d717a30..7ea30430 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -29,6 +29,7 @@ - Uses Get-Disk-Partitions to enumerate Azure virtual disks. - Detects repair vs. standard context from secondary disks returned by the helper. - Mounts unlettered Gen2 Windows and EFI partitions temporarily. + - Probes unlettered partitions directly instead of assuming GPT metadata or size. - Refuses BCD changes when a repair VM context is not detected. - Fails closed if the repair VM OS disk cannot be identified. - Filters out the repair VM OS disk before processing attached disks. @@ -197,6 +198,20 @@ function Get-AvailableTempDriveLetter { return $null } +function Test-SacGptType { + param( + [AllowNull()] + [object]$ActualType, + + [Parameter(Mandatory = $true)] + [string]$ExpectedType + ) + + $actual = ([string]$ActualType).Trim().Trim('{', '}') + $expected = $ExpectedType.Trim().Trim('{', '}') + return $actual -eq $expected +} + # =========================================== # Logging Setup (Dual-Write: Desktop + Plugin Directory) # =========================================== @@ -212,15 +227,15 @@ $pluginLogDir = 'C:\WindowsAzure\Logs\Plugins\Microsoft.Compute.CustomScriptExte $pluginLogFile = Join-Path -Path $pluginLogDir -ChildPath ("{0}_{1}.log" -f $scriptName, $runTimestamp) # Ensure directories exist -@($desktopLogDir, $pluginLogDir) | ForEach-Object { - if (-not (Test-Path -Path $_ -PathType Container)) { +foreach ($logDirectory in @($desktopLogDir, $pluginLogDir)) { + if (-not (Test-Path -Path $logDirectory -PathType Container)) { try { - New-Item -Path $_ -ItemType Directory -Force -ErrorAction Stop | Out-Null + New-Item -Path $logDirectory -ItemType Directory -Force -ErrorAction Stop | Out-Null } catch { # Plugin dir may not be creatable; continue with desktop log only - if ($_ -eq $pluginLogDir) { - [Console]::Error.WriteLine("[Warning] Could not create plugin log directory: $_. Will use desktop log only.") + if ($logDirectory -eq $pluginLogDir) { + [Console]::Error.WriteLine("[Warning] Could not create plugin log directory '$logDirectory': $($_.Exception.Message). Will use desktop log only.") } else { throw } @@ -422,7 +437,7 @@ try { try { # Scan each drive for BCD store and Windows OS loader - ForEach ($drive in $partitionGroup.Group | Select-Object -ExpandProperty DriveLetter ) + ForEach ($drive in $partitionGroup.Group | Select-Object -ExpandProperty DriveLetter | Where-Object { $_ }) { # The repair disk was filtered out above; retain this drive-level safety check. if ($drive -eq $repairDrive) { continue } @@ -445,16 +460,21 @@ try { } } - # Gen2 OS fallback: temporarily mount an unlettered GPT Basic Data partition. + # Gen2 fallback: probe unlettered partitions directly for a Windows loader. if (-not $isOsPath) { $diskNum = [int]$partitionGroup.Name - $basicDataGptType = '{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}' - $unletteredOsCandidates = @(Get-Partition -DiskNumber $diskNum -ErrorAction SilentlyContinue | Where-Object { - $_.GptType -eq $basicDataGptType -and - (-not $_.DriveLetter -or $_.DriveLetter -eq [char]0) -and - $_.Size -gt 10GB - }) + $diskPartitions = @(Get-Partition -DiskNumber $diskNum -ErrorAction SilentlyContinue) + foreach ($partition in $diskPartitions) { + $driveDescription = if ($partition.DriveLetter) { "$($partition.DriveLetter):" } else { '' } + $sizeMb = [math]::Round($partition.Size / 1MB) + Log-Info "Disk $diskNum partition $($partition.PartitionNumber): drive=$driveDescription sizeMB=$sizeMb type=$($partition.Type) gptType=$($partition.GptType)" + } + + $unletteredOsCandidates = @($diskPartitions | Where-Object { + -not $_.DriveLetter -or $_.DriveLetter -eq [char]0 + } | Sort-Object Size -Descending) + Log-Info "Disk ${diskNum}: probing $($unletteredOsCandidates.Count) unlettered partition(s) for a Windows loader." foreach ($osCandidate in $unletteredOsCandidates) { @@ -499,9 +519,10 @@ try { if ($diskNum -ne $repairDiskNumber) { Log-Info "Disk ${diskNum}: OS found but no BCD - checking for unlettered EFI partition (Gen2)..." - $efiGptType = '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}' + $efiGptType = 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b' $efiParts = Get-Partition -DiskNumber $diskNum -ErrorAction SilentlyContinue | Where-Object { - $_.GptType -eq $efiGptType -and (-not $_.DriveLetter -or $_.DriveLetter -eq [char]0) + (Test-SacGptType -ActualType $_.GptType -ExpectedType $efiGptType) -and + (-not $_.DriveLetter -or $_.DriveLetter -eq [char]0) } if ($efiParts) { From d615e7d63210354e297940960ee838e92681ecb7 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:48:31 +0300 Subject: [PATCH 20/43] Updat --- src/windows/sac-enabler.ps1 | 160 ++++++++++++++++++++++++++++++------ 1 file changed, 135 insertions(+), 25 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 7ea30430..62706e5d 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -30,6 +30,8 @@ - Detects repair vs. standard context from secondary disks returned by the helper. - Mounts unlettered Gen2 Windows and EFI partitions temporarily. - Probes unlettered partitions directly instead of assuming GPT metadata or size. + - Journals temporary mounts and removes only identity-matched stale mounts. + - Keeps dual logs on the repair host; does not write to the attached OS disk. - Refuses BCD changes when a repair VM context is not detected. - Fails closed if the repair VM OS disk cannot be identified. - Filters out the repair VM OS disk before processing attached disks. @@ -40,7 +42,7 @@ - **NEW SAFETY: Validates GUID format before making BCD edits. - **NEW SAFETY: Verifies EMS was actually enabled after bcdedit commands. - Added .ROLLBACK_RECOVERY section with disaster recovery instructions. - - Annotated Log-* wrapper pattern with consolidation note. + - Identified the Log-* wrapper pattern for shared-helper consolidation. v1.2: [May 2026] - Fixed breaking exception when the Hyper-V module is not installed on the host. - Added explicit checking via Get-Module before executing nested VM discovery. v1.1: [May 2026] - Included advanced Gen2 unlettered EFI fallback and dynamic drive-letter assignment. @@ -122,7 +124,8 @@ bcdedit /store P:\efi\microsoft\boot\bcd /enum "{bootmgr}" NOTE: For Gen2 disks, the script automatically assigns a temporary drive letter to the EFI System Partition via diskpart if Get-Disk-Partitions did not assign one. - The temporary letter is removed after processing. + The temporary letter is removed after processing. Before each assignment, ownership + is journaled under ProgramData so an exact stale mount can be removed on the next run. .ROLLBACK_RECOVERY IF THE VM FAILS TO BOOT AFTER az vm repair restore: @@ -212,6 +215,121 @@ function Test-SacGptType { return $actual -eq $expected } +$script:SacTempMountStateDirectory = Join-Path -Path $env:ProgramData -ChildPath 'AzureVmRepair\SacEnabler\TempMounts' + +function Get-SacDiskIdentity { + param( + [Parameter(Mandatory = $true)] + [int]$DiskNumber + ) + + $disk = Get-Disk -Number $DiskNumber -ErrorAction SilentlyContinue + if (-not $disk) { return $null } + + $identityParts = @($disk.UniqueId, $disk.SerialNumber, $disk.Location) | Where-Object { + -not [string]::IsNullOrWhiteSpace("$_") + } + if ($identityParts.Count -eq 0) { return $null } + + return ($identityParts -join '|') +} + +function Register-SacTemporaryMount { + param( + [Parameter(Mandatory = $true)][int]$DiskNumber, + [Parameter(Mandatory = $true)][int]$PartitionNumber, + [Parameter(Mandatory = $true)][char]$DriveLetter, + [Parameter(Mandatory = $true)][ValidateSet('Windows', 'EFI')][string]$Kind + ) + + $diskIdentity = Get-SacDiskIdentity -DiskNumber $DiskNumber + if (-not $diskIdentity) { + Log-Warning "Disk ${DiskNumber}: temporary $Kind mount ownership cannot be persisted because the disk has no stable identity. Runtime finally cleanup will still be attempted." + return $null + } + + New-Item -Path $script:SacTempMountStateDirectory -ItemType Directory -Force -ErrorAction Stop | Out-Null + $statePath = Join-Path -Path $script:SacTempMountStateDirectory -ChildPath (([guid]::NewGuid().ToString('N')) + '.json') + $state = [ordered]@{ + DiskIdentity = $diskIdentity + DiskNumber = $DiskNumber + PartitionNumber = $PartitionNumber + DriveLetter = "$DriveLetter" + Kind = $Kind + CreatedUtc = [DateTime]::UtcNow.ToString('o') + } + $state | ConvertTo-Json | Set-Content -Path $statePath -Encoding UTF8 -ErrorAction Stop + return $statePath +} + +function Complete-SacTemporaryMountCleanup { + param( + [AllowNull()][string]$StatePath, + [Parameter(Mandatory = $true)][int]$DiskNumber, + [Parameter(Mandatory = $true)][int]$PartitionNumber, + [Parameter(Mandatory = $true)][char]$DriveLetter + ) + + if (-not $StatePath) { return } + + $partition = Get-Partition -DiskNumber $DiskNumber -PartitionNumber $PartitionNumber -ErrorAction SilentlyContinue + if (-not $partition -or "$($partition.DriveLetter)" -ne "$DriveLetter") { + Remove-Item -Path $StatePath -Force -ErrorAction SilentlyContinue + } + else { + Log-Warning "Temporary letter ${DriveLetter}: remains on Disk $DiskNumber Partition $PartitionNumber. Ownership state retained for the next run." + } +} + +function Clear-SacStaleTemporaryMounts { + param( + [Parameter(Mandatory = $true)] + [int[]]$TargetDiskNumbers + ) + + if (-not (Test-Path -Path $script:SacTempMountStateDirectory -PathType Container)) { return } + + $targetIdentities = @{} + foreach ($targetDiskNumber in $TargetDiskNumbers) { + $identity = Get-SacDiskIdentity -DiskNumber $targetDiskNumber + if ($identity) { $targetIdentities[$identity] = $targetDiskNumber } + } + + foreach ($stateFile in @(Get-ChildItem -Path $script:SacTempMountStateDirectory -Filter '*.json' -File -ErrorAction SilentlyContinue)) { + try { + $state = Get-Content -Path $stateFile.FullName -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + $requiredValues = @($state.DiskIdentity, $state.PartitionNumber, $state.DriveLetter, $state.Kind) + if (@($requiredValues | Where-Object { [string]::IsNullOrWhiteSpace("$_") }).Count -gt 0) { + throw 'The ownership record is incomplete.' + } + + if (-not $targetIdentities.ContainsKey("$($state.DiskIdentity)")) { + Log-Warning "Discarding stale temporary-mount state '$($stateFile.Name)': its disk is not an attached repair target. No drive letter was removed." + Remove-Item -Path $stateFile.FullName -Force -ErrorAction SilentlyContinue + continue + } + + $currentDiskNumber = [int]$targetIdentities["$($state.DiskIdentity)"] + $partitionNumber = [int]$state.PartitionNumber + $driveLetter = [char]("$($state.DriveLetter)") + $partition = Get-Partition -DiskNumber $currentDiskNumber -PartitionNumber $partitionNumber -ErrorAction SilentlyContinue + if (-not $partition -or "$($partition.DriveLetter)" -ne "$driveLetter") { + Log-Info "Removing resolved temporary-mount state '$($stateFile.Name)'; the recorded letter is no longer assigned." + Remove-Item -Path $stateFile.FullName -Force -ErrorAction SilentlyContinue + continue + } + + Log-Warning "Recovering stale $($state.Kind) letter ${driveLetter}: from Disk $currentDiskNumber Partition $partitionNumber." + $removeOutput = @("select disk $currentDiskNumber", "select partition $partitionNumber", "remove letter=$driveLetter") | diskpart 2>&1 + foreach ($line in @($removeOutput)) { if ($line) { Log-Output "[diskpart][stale-cleanup] $line" } } + Complete-SacTemporaryMountCleanup -StatePath $stateFile.FullName -DiskNumber $currentDiskNumber -PartitionNumber $partitionNumber -DriveLetter $driveLetter + } + catch { + Log-Warning "Could not process temporary-mount state '$($stateFile.FullName)': $($_.Exception.Message). No drive letter was removed from this record." + } + } +} + # =========================================== # Logging Setup (Dual-Write: Desktop + Plugin Directory) # =========================================== @@ -260,9 +378,8 @@ $logFilePath = $desktopLogFile # =========================================== # Log Wrapper Functions (Consolidation Note) # =========================================== -# NOTE: This Log-* wrapper pattern (duplicated across sac-enabler.ps1 and other scripts) -# should be consolidated into a shared helper module (e.g., common\helpers\Logging-Helper.ps1) -# to avoid duplication. All scripts should source a single centralized logging provider. +# These wrappers remain local so the repair script stays self-contained while dual-writing +# to repair-host locations. Logs are not written to the attached customer OS disk. $script:OriginalLogOutput = (Get-Command Log-Output -CommandType Function).ScriptBlock $script:OriginalLogInfo = (Get-Command Log-Info -CommandType Function).ScriptBlock @@ -345,26 +462,6 @@ $changedCount = 0 Log-Info "Starting repair-only SAC enabler. Logs: $logFile" try { - # Optional: Clean up orphaned temp drive letters from previous failed runs - # This helps prevent lingering mount points from blocking EFI partition access - $orphanedLetters = @() - try { - Get-Volume -ErrorAction SilentlyContinue | Where-Object { $_.DriveLetter -and -not $_.DriveType -eq 'Unknown' } | ForEach-Object { - $letter = $_.DriveLetter - $volumePath = "${letter}:\" - if (-not (Test-Path -Path $volumePath)) { - $orphanedLetters += $letter - } - } - if ($orphanedLetters.Count -gt 0) { - Log-Info "Found potentially orphaned drive letters (may be harmless): $($orphanedLetters -join ', '). Continuing..." - } - } - catch { - # Orphan detection is optional; don't block on failure - Log-Debug "Orphan detection encountered an error (non-critical): $($_.Exception.Message)" - } - # Check if the Hyper-V module is available before performing nested VM checks if (Get-Module -ListAvailable -Name Hyper-V) { $guestHyperVVirtualMachine = Get-VM -ErrorAction SilentlyContinue -WarningAction SilentlyContinue @@ -415,6 +512,9 @@ try { } else { + $targetDiskNumbers = @($targetDiskGroups | ForEach-Object { [int]$_.Name }) + Clear-SacStaleTemporaryMounts -TargetDiskNumbers $targetDiskNumbers + foreach ( $partitionGroup in $targetDiskGroups ) { $processedCount++ @@ -430,6 +530,8 @@ try { $tempOsLetter = $null $tempOsDiskNum = $null $tempOsPartNum = $null + $tempOsStatePath = $null + $tempEfiStatePath = $null $bcdBackup = $null Log-Info "Processing Disk $diskNumber" @@ -486,6 +588,7 @@ try { $candidatePartNum = $osCandidate.PartitionNumber Log-Info "Assigning temp letter ${candidateLetter}: to Disk $diskNum Partition $candidatePartNum (Windows candidate)..." + $tempOsStatePath = Register-SacTemporaryMount -DiskNumber $diskNum -PartitionNumber $candidatePartNum -DriveLetter $candidateLetter -Kind Windows $dpOsAssign = @("select disk $diskNum", "select partition $candidatePartNum", "assign letter=$candidateLetter") $dpOsAssignOut = $dpOsAssign | diskpart 2>&1 foreach ($line in @($dpOsAssignOut)) { if ($line) { Log-Output "[diskpart][os-assign] $line" } } @@ -506,9 +609,11 @@ try { $dpOsRemove = @("select disk $diskNum", "select partition $candidatePartNum", "remove letter=$candidateLetter") $dpOsRemoveOut = $dpOsRemove | diskpart 2>&1 foreach ($line in @($dpOsRemoveOut)) { if ($line) { Log-Output "[diskpart][os-remove] $line" } } + Complete-SacTemporaryMountCleanup -StatePath $tempOsStatePath -DiskNumber $diskNum -PartitionNumber $candidatePartNum -DriveLetter $candidateLetter $tempOsLetter = $null $tempOsDiskNum = $null $tempOsPartNum = $null + $tempOsStatePath = $null } } @@ -533,6 +638,7 @@ try { { $pn = $ep.PartitionNumber Log-Info "Assigning temp letter ${tempLetter}: to Disk $diskNum Partition $pn (EFI)..." + $tempEfiStatePath = Register-SacTemporaryMount -DiskNumber $diskNum -PartitionNumber $pn -DriveLetter $tempLetter -Kind EFI $dpLines = @("select disk $diskNum", "select partition $pn", "assign letter=$tempLetter") $dpAssignOut = $dpLines | diskpart 2>&1 foreach ($line in @($dpAssignOut)) { if ($line) { Log-Output "[diskpart][assign] $line" } } @@ -553,9 +659,11 @@ try { $dpRemove = @("select disk $diskNum", "select partition $pn", "remove letter=$tempLetter") $dpRemoveOut = $dpRemove | diskpart 2>&1 foreach ($line in @($dpRemoveOut)) { if ($line) { Log-Output "[diskpart][remove] $line" } } + Complete-SacTemporaryMountCleanup -StatePath $tempEfiStatePath -DiskNumber $diskNum -PartitionNumber $pn -DriveLetter $tempLetter $tempEfiLetter = $null $tempEfiDiskNum = $null $tempEfiPartNum = $null + $tempEfiStatePath = $null } } } @@ -667,6 +775,7 @@ try { $dpClean = @("select disk $tempEfiDiskNum", "select partition $tempEfiPartNum", "remove letter=$tempEfiLetter") $dpCleanOut = $dpClean | diskpart 2>&1 foreach ($line in @($dpCleanOut)) { if ($line) { Log-Output "[diskpart][cleanup] $line" } } + Complete-SacTemporaryMountCleanup -StatePath $tempEfiStatePath -DiskNumber $tempEfiDiskNum -PartitionNumber $tempEfiPartNum -DriveLetter $tempEfiLetter } if ($tempOsLetter) @@ -675,6 +784,7 @@ try { $dpOsClean = @("select disk $tempOsDiskNum", "select partition $tempOsPartNum", "remove letter=$tempOsLetter") $dpOsCleanOut = $dpOsClean | diskpart 2>&1 foreach ($line in @($dpOsCleanOut)) { if ($line) { Log-Output "[diskpart][os-cleanup] $line" } } + Complete-SacTemporaryMountCleanup -StatePath $tempOsStatePath -DiskNumber $tempOsDiskNum -PartitionNumber $tempOsPartNum -DriveLetter $tempOsLetter } if ($diskChanged) { $changedCount++ } From a18ddf5f44f319f18c2e31ab9820bf2f9f9e1e22 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:21:44 +0300 Subject: [PATCH 21/43] Update sac-enabler.ps1 --- src/windows/sac-enabler.ps1 | 1159 +++++++++++++---------------------- 1 file changed, 441 insertions(+), 718 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 62706e5d..513ad82c 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -1,817 +1,540 @@ <# .SYNOPSIS - Enables SAC and Serial Console boot settings on attached Windows disks, including BIOS and UEFI layouts. + Configures Azure VM memory dumps with intelligent placement strategies to work around temporary storage issues - no reboot required. .DESCRIPTION - This script runs only from a repair VM to enable SAC/EMS on an attached OS disk's BCD store. - It performs the following steps: - 1. Enumerates attached partitions via Get-Disk-Partitions to locate the BCD store and OS loader. - OS detection accepts either winload.exe or winload.efi. - 1a. For Gen2 disks where the EFI partition has no drive letter, uses diskpart to - temporarily assign one so the BCD store can be accessed. - 2. Identifies the default boot entry GUID from the BCD bootmgr displayorder. - If the default entry cannot be determined, the script logs an explicit warning. - 3. Logs the BCD configuration before any changes are made. - 4. Enables the boot menu with a 5-second timeout (displaybootmenu, timeout). - 5. Enables Boot EMS on the boot manager (bootems yes). - 6. Enables EMS on the default OS entry (ems ON). - 7. Configures EMS settings for serial console (EMSPORT:1, EMSBAUDRATE:115200). - 8. Logs the BCD configuration after changes for verification. - -.NOTES - Name: sac-enabler.ps1 - Author: Tony.Mocanu@Microsoft.com - Requirement: Azure repair VM with an attached Windows OS disk - DeployMode: az vm repair run (with --run-on-repair) - - .VERSION - v1.4: [July 2026] - Restricted execution to repair VM mode (current). - - Uses Get-Disk-Partitions to enumerate Azure virtual disks. - - Detects repair vs. standard context from secondary disks returned by the helper. - - Mounts unlettered Gen2 Windows and EFI partitions temporarily. - - Probes unlettered partitions directly instead of assuming GPT metadata or size. - - Journals temporary mounts and removes only identity-matched stale mounts. - - Keeps dual logs on the repair host; does not write to the attached OS disk. - - Refuses BCD changes when a repair VM context is not detected. - - Fails closed if the repair VM OS disk cannot be identified. - - Filters out the repair VM OS disk before processing attached disks. - v1.3: [July 2026] - Added execution context detection and dual-logging. - - Detected rescue VM mode vs standard mode for context-aware error messages. - - Dual-logs to desktop and plugin directory for az vm repair auto-collection. - - **NEW SAFETY: Pre-flight checks, BCD backup, and post-change verification. - - **NEW SAFETY: Validates GUID format before making BCD edits. - - **NEW SAFETY: Verifies EMS was actually enabled after bcdedit commands. - - Added .ROLLBACK_RECOVERY section with disaster recovery instructions. - - Identified the Log-* wrapper pattern for shared-helper consolidation. - v1.2: [May 2026] - Fixed breaking exception when the Hyper-V module is not installed on the host. - - Added explicit checking via Get-Module before executing nested VM discovery. - v1.1: [May 2026] - Included advanced Gen2 unlettered EFI fallback and dynamic drive-letter assignment. - v0.1: [Initial] - Initial commit. Version 1.0 of the script. + This script runs on the live VM (not a rescue VM) to configure crash dump settings + WITHOUT REQUIRING A REBOOT. Includes smart placement strategies to work around + Azure VM temporary storage limitations. - .EXECUTION_CONTEXT - This script is classified as repair-VM-only. It detects repair context when the helper returns - at least one disk other than the repair VM OS disk and refuses BCD changes when none exists. - Use: az vm repair run -g -n --run-id win-sac-on --run-on-repair - The repair VM OS disk is identified from $env:SystemDrive and excluded from all BCD operations. - -.SCENARIO_RECREATION - To recreate a testable scenario on a repair VM with an attached OS disk: - 1. Create a test VM in Azure and attach its OS disk to a repair VM. - 2. The BCD store is on the System Reserved (Gen1) or EFI (Gen2) partition, which - may not have a drive letter. Find it by scanning all volumes (run as Admin): -Get-Volume | Where-Object { $_.DriveLetter } | ForEach-Object { $d = $_.DriveLetter; @("$d`:\boot\bcd","$d`:\efi\microsoft\boot\bcd") | Where-Object { Test-Path $_ } | ForEach-Object { Write-Output "FOUND: $_" } } - If nothing is found, the partition has no drive letter. For System Reserved (Gen1): -Get-Partition | Where-Object { -not $_.DriveLetter -and $_.Size -lt 1GB } | Format-Table DiskNumber, PartitionNumber, Size, Type -Set-Partition -DiskNumber -PartitionNumber -NewDriveLetter S - For EFI partitions (Gen2), Set-Partition won't work -- use diskpart instead: - diskpart - select disk - select partition - assign letter=S - exit - Then check: Test-Path S:\boot\bcd or Test-Path S:\efi\microsoft\boot\bcd - - Example with two attached disks (from Disk Management): - Disk 2 (Gen1): System Reserved (F:) 500 MB | Windows (G:) 126 GB - -> BCD already accessible at F:\boot\bcd - Disk 3 (Gen2): 450 MB (no letter) | EFI (no letter) 99 MB | Windows (H:) 126 GB - -> EFI partitions are protected; use diskpart to assign a letter: - diskpart - select disk 3 - select partition 2 - assign letter=S - exit - -> BCD at S:\efi\microsoft\boot\bcd - - 3. Once you have the BCD path, disable SAC/EMS to simulate a broken VM: - - Gen1 example (F:\boot\bcd): -bcdedit /store F:\boot\bcd /ems "{default}" OFF -bcdedit /store F:\boot\bcd /set "{bootmgr}" bootems no -bcdedit /store F:\boot\bcd /set "{bootmgr}" displaybootmenu no - - Gen2 example (S:\efi\microsoft\boot\bcd): -bcdedit /store S:\efi\microsoft\boot\bcd /ems "{default}" OFF -bcdedit /store S:\efi\microsoft\boot\bcd /set "{bootmgr}" bootems no -bcdedit /store S:\efi\microsoft\boot\bcd /set "{bootmgr}" displaybootmenu no - - 4. Verify EMS is disabled: -bcdedit /store F:\boot\bcd /enum "{default}" -bcdedit /store S:\efi\microsoft\boot\bcd /enum "{default}" - Expected: ems = No or absent, bootems = No or absent. - 5. Run the script. It should enable ems, bootems, displaybootmenu, and emssettings. - 6. Verify all SAC settings are now enabled (see .VERIFICATION section). + It performs the following steps: + 1. Audits current crash control settings using both Registry and CIM (for pagefile accuracy) + 2. Enables NMICrashDump (DWORD 1) to allow NMI triggering from the Azure Portal + 3. Optionally configures automatic reboot after crash (use -ConfigureAutomaticReboot to enable) + 4. INTELLIGENTLY configures dump file placement to work around temporary drive issues + 5. Uses dedicated dump files when necessary to ensure reliability on Azure VMs + 6. Uses kdbgctrl.exe to apply the selected dump type to the live kernel immediately + 7. If -OneDump is specified, restores original CrashDumpEnabled after kernel update + 8. Validates C: drive free space (minimum 20%) before pagefile relocation to prevent VM crashes + 9. NO REBOOT REQUIRED - All changes take effect immediately + +.PARAMETER OneDump + Switch to restore the original CrashDumpEnabled value after the kernel has been updated. + Useful for single-event debugging. + +.PARAMETER DumpType + The type of dump to configure. Valid values: active, automatic, full, kernel, mini. + +.PARAMETER DumpFile + The target path for the final .dmp file. Defaults to %SystemRoot%\MEMORY.DMP. + +.PARAMETER DedicatedDumpFile + The path to a dedicated dump file (e.g., D:\dd.sys) to preserve space on the OS drive. + Use "delete" to remove an existing dedicated dump file configuration. + +.PARAMETER MovePagefile + Switch to relocate pagefile from temporary D: drive to persistent storage (C: or F: drive). + WARNING: This change requires restoration after troubleshooting. The script will log + detailed restoration instructions including the original pagefile location. + +.PARAMETER ConfigureAutomaticReboot + Switch to configure automatic reboot after system crash (BootStatusPolicy=1). + By default, automatic reboot is NOT configured. Enable this parameter to opt-in. + Useful for production systems, but may not be desired on Citrix VMs or other + specialized environments. + +.PARAMETER EnableDebugDefaults + Applies local test defaults only when set to true and only for values not provided + by runtime parameters. .EXAMPLE - az vm repair run -g -n --run-id win-sac-on --run-on-repair + .\win-dumpconfigurator.ps1 -DumpType kernel -DumpFile "%SystemRoot%\MEMORY.DMP" -ConfigureAutomaticReboot true + Configures kernel dump collection and enables automatic reboot after a crash. -.VERIFICATION - 1. Check the log file for success: -Get-ChildItem "C:\WindowsAzure\Logs\Plugins\Microsoft.Compute.CustomScriptExtension\sac-enabler_*.log" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 | Get-Content - Expected: "BCD AFTER SAC ENABLE" section present and return code 0 ($STATUS_SUCCESS). - 2. Manually verify the BCD store (replace drive letters with the ones found in step 2): - - Gen1 (System Reserved on F:): -bcdedit /store F:\boot\bcd /enum "{default}" -bcdedit /store F:\boot\bcd /enum "{bootmgr}" +.EXAMPLE + .\win-dumpconfigurator.ps1 -DumpType full -DedicatedDumpFile "delete" -OneDump true + Applies full dump for a single capture cycle and removes an existing dedicated dump file setting. + +.VERSION + Name: win-dumpconfigurator.ps1 + Version: 1.3 (Critical fixes, safety enhancements, and PowerShell 7 compatibility) + Author: Michael.Smith@microsoft.com for v1.0, Tony.Mocanu@Microsoft.com for the rest. + +.VERSION + v1.3: [July 2026] - CRITICAL FIXES & SAFETY ENHANCEMENTS (current) + - FIXED: Changed [switch] parameters to [string] for CLI compatibility + - FIXED: Added early parameter validation to catch invalid parameters before execution + - FIXED: Migrated all Get-WmiObject to Get-CimInstance (PowerShell 7 compatibility) + - FIXED: Added guard for empty $DumpFile path + - FIXED: Corrected typo 'Procceding' → 'Proceeding' + - FIXED: Added missing Step 9 in output (numbering now 1-11) + - IMPROVED: Better error messaging for invalid parameters via CLI + - IMPROVED: Added comprehensive warnings about pagefile relocation destructiveness + - IMPROVED: Added C: drive free space validation before relocation (20% minimum required) + - IMPROVED: Added local test defaults documentation for local testing without CLI parameters + v1.2: [May 2026] - Updated script + - Added Michael.Smith@microsoft.com as co-author (v1.0 creator) + - Changed log file location to $env:PUBLIC\Desktop for uniformity with other scripts + - Made automatic reboot configuration optional via -ConfigureAutomaticReboot parameter + - Filtered non-actionable kdbgctrl noise from user-facing output. + - Added explicit before/after human-readable dump configuration logging. + - Added strict post-apply verification and status failure on validation mismatch. + v1.1: [May 2026] - Updated script + - Added intelligent dump placement for Azure temporary storage scenarios. + - Added optional pagefile relocation from D: to C: for dump reliability. + - Added CIM-based live pagefile auditing and no-reboot workflow. + v1.0: Initial commit. First working version of the script. +#> - Gen2 (EFI partition -- use diskpart to assign a letter if needed, e.g. P:): -bcdedit /store P:\efi\microsoft\boot\bcd /enum "{default}" -bcdedit /store P:\efi\microsoft\boot\bcd /enum "{bootmgr}" +Param( + [Parameter(Mandatory = $false)] + [string]$DumpType = '', - Expected: ems = Yes on the OS entry, bootems = Yes on bootmgr, - displaybootmenu = Yes, timeout = 5, EMSPORT = 1, EMSBAUDRATE = 115200. + [Parameter(Mandatory = $false)] + [string]$DumpFile = '', - NOTE: For Gen2 disks, the script automatically assigns a temporary drive letter - to the EFI System Partition via diskpart if Get-Disk-Partitions did not assign one. - The temporary letter is removed after processing. Before each assignment, ownership - is journaled under ProgramData so an exact stale mount can be removed on the next run. + [Parameter(Mandatory = $false)] + [string]$DedicatedDumpFile = '', -.ROLLBACK_RECOVERY - IF THE VM FAILS TO BOOT AFTER az vm repair restore: - - 1. Boot the VM from the Windows installation media or attach to a repair VM. - 2. Locate the BCD backup file created by the script: - - From repair VM: Look in the mounted disk for *.backup-* files - - Example path: F:\boot\bcd.backup-20260724-153022 (or S:\efi\microsoft\boot\bcd.backup-...) - 3. Restore the BCD from backup: - Gen1 (System Reserved): - bcdedit /store F:\boot\bcd /import F:\boot\bcd.backup-20260724-153022 - - Gen2 (EFI partition with assigned letter): - bcdedit /store S:\efi\microsoft\boot\bcd /import S:\efi\microsoft\boot\bcd.backup-20260724-153022 - - 4. Verify the BCD was restored: - bcdedit /store F:\boot\bcd /enum "{default}" | findstr /I "ems" - Expected: ems = No (or absent) - - 5. Boot the VM. It should start normally without SAC/EMS enabled. - - ALTERNATIVE (if BCD restore doesn't work): - - Use sfc /scannow from Windows Recovery Environment to repair system files - - Use bcdboot.exe to rebuild the BCD store from scratch - - See internal troubleshooting guide: azure-vm-dump-issues.md -#> + [Parameter(Mandatory = $false)] + [string]$OneDump = '', -# Initialization (path-validated) -$initPath = Join-Path -Path $PSScriptRoot -ChildPath 'common\setup\init.ps1' -$diskPartitionsPath = Join-Path -Path $PSScriptRoot -ChildPath 'common\helpers\Get-Disk-Partitions-v2.ps1' + [Parameter(Mandatory = $false)] + [string]$MovePagefile = '', -if (-not (Test-Path -Path $initPath -PathType Leaf)) { - Write-Error "Missing required dependency: $initPath" - return 1 -} + [Parameter(Mandatory = $false)] + [string]$ConfigureAutomaticReboot = '', -. $initPath + [Parameter(Mandatory = $false)] + [string]$EnableDebugDefaults = '' +) -if (-not (Test-Path -Path $diskPartitionsPath -PathType Leaf)) { - Log-Error "Missing required dependency: $diskPartitionsPath" - return $STATUS_ERROR +# Initialization +$initScriptPath = Join-Path -Path $PSScriptRoot -ChildPath 'common\setup\init.ps1' +if (-not (Test-Path -Path $initScriptPath -PathType Leaf)) { + Write-Error "Missing required dependency: $initScriptPath" + return 1 } -. $diskPartitionsPath - -if (-not (Get-Command -Name Get-Disk-Partitions -CommandType Function -ErrorAction SilentlyContinue)) { - Log-Error "Dependency did not define the required Get-Disk-Partitions function: $diskPartitionsPath" - return $STATUS_ERROR +. $initScriptPath + +# LOCAL TEST DEFAULTS: Uncomment the variables below to test locally without --parameters +# You can either: +# 1. Uncomment individual variables and run the script +# 2. Uncomment ONLY $EnableDebugDefaults='true' to activate all defaults +# Example: +# $DumpType = 'full' +# $OneDump = 'false' +# $MovePagefile = 'false' +# $EnableDebugDefaults = 'true +# Then run: .\win-dumpconfigurator.ps1 + +# Normalize incoming parameter names (vm-repair commonly passes lowercase names). +if (-not $DumpType -and $dumptype) { $DumpType = $dumptype } +if (-not $DumpFile -and $dumpfile) { $DumpFile = $dumpfile } +if (-not $DedicatedDumpFile -and $dedicateddumpfile) { $DedicatedDumpFile = $dedicateddumpfile } +if (-not $OneDump -and $onedump) { $OneDump = $onedump } +if (-not $MovePagefile -and $movepagefile) { $MovePagefile = $movepagefile } +if (-not $ConfigureAutomaticReboot -and $configureautomaticreboot) { $ConfigureAutomaticReboot = $configureautomaticreboot } +if (-not $EnableDebugDefaults -and $enabledebugdefaults) { $EnableDebugDefaults = $enabledebugdefaults } + +# Normalize boolean-like string parameters for consistent downstream checks. +$OneDump = "$OneDump".Trim().ToLowerInvariant() +$MovePagefile = "$MovePagefile".Trim().ToLowerInvariant() +$ConfigureAutomaticReboot = "$ConfigureAutomaticReboot".Trim().ToLowerInvariant() +$EnableDebugDefaults = "$EnableDebugDefaults".Trim().ToLowerInvariant() + +# Optional local-only defaults for troubleshooting without --parameters. +$debugDefaultsEnabled = $EnableDebugDefaults -eq $true -or $EnableDebugDefaults -eq 'true' +if ($debugDefaultsEnabled) { + if (-not $DumpType) { $DumpType = 'full' } + if (-not $DumpFile) { $DumpFile = '%SystemRoot%\Memory.dmp' } + if (-not $DedicatedDumpFile) { $DedicatedDumpFile = 'Z:\dd.sys' } + if (-not $OneDump) { $OneDump = 'false' } + if (-not $MovePagefile) { $MovePagefile = 'true' } + Log-Info "EnableDebugDefaults is active. Applying local fallback defaults for missing parameters." } -function Get-SacExecutionContext { - param( - [Parameter(Mandatory = $true)] - [AllowEmptyCollection()] - [object[]]$TargetDiskGroups - ) - - if ($TargetDiskGroups.Count -gt 0) { - return 'REPAIR_VM' +# === PARAMETER VALIDATION (EARLY FAIL) === +# Validate all parameters BEFORE any operations +$validDumpTypes = @('active', 'automatic', 'full', 'kernel', 'mini') + +# 1. Validate DumpType if provided +$userProvidedDumpType = -not [string]::IsNullOrWhiteSpace("$DumpType") +if (-not $userProvidedDumpType) { + $DumpType = 'full' +} else { + if ($DumpType -notin $validDumpTypes) { + throw "Invalid DumpType '$DumpType'. Valid values: $($validDumpTypes -join ', '). Check your --parameters syntax." } - - return 'STANDARD_VM' } -function Get-AvailableTempDriveLetter { - $usedLetters = @(Get-Volume -ErrorAction SilentlyContinue | Where-Object { $_.DriveLetter } | Select-Object -ExpandProperty DriveLetter) - foreach ($letter in @('Z','Y','X','W','V','U','T','S','R','Q')) { - if ($letter -notin $usedLetters -and -not (Test-Path -Path "${letter}:\")) { - return $letter - } - } - - return $null +# 2. Validate OneDump is boolean-compatible +if ($OneDump -notin @('true', 'false', $true, $false, '')) { + throw "Invalid OneDump '$OneDump'. Must be 'true' or 'false'." } -function Test-SacGptType { - param( - [AllowNull()] - [object]$ActualType, - - [Parameter(Mandatory = $true)] - [string]$ExpectedType - ) - - $actual = ([string]$ActualType).Trim().Trim('{', '}') - $expected = $ExpectedType.Trim().Trim('{', '}') - return $actual -eq $expected +# 3. Validate MovePagefile is boolean-compatible +if ($MovePagefile -notin @('true', 'false', $true, $false, '')) { + throw "Invalid MovePagefile '$MovePagefile'. Must be 'true' or 'false'." } -$script:SacTempMountStateDirectory = Join-Path -Path $env:ProgramData -ChildPath 'AzureVmRepair\SacEnabler\TempMounts' - -function Get-SacDiskIdentity { - param( - [Parameter(Mandatory = $true)] - [int]$DiskNumber - ) - - $disk = Get-Disk -Number $DiskNumber -ErrorAction SilentlyContinue - if (-not $disk) { return $null } - - $identityParts = @($disk.UniqueId, $disk.SerialNumber, $disk.Location) | Where-Object { - -not [string]::IsNullOrWhiteSpace("$_") - } - if ($identityParts.Count -eq 0) { return $null } - - return ($identityParts -join '|') +# 4. Validate ConfigureAutomaticReboot is boolean-compatible +if ($ConfigureAutomaticReboot -notin @('true', 'false', $true, $false, '')) { + throw "Invalid ConfigureAutomaticReboot '$ConfigureAutomaticReboot'. Must be 'true' or 'false'." } -function Register-SacTemporaryMount { - param( - [Parameter(Mandatory = $true)][int]$DiskNumber, - [Parameter(Mandatory = $true)][int]$PartitionNumber, - [Parameter(Mandatory = $true)][char]$DriveLetter, - [Parameter(Mandatory = $true)][ValidateSet('Windows', 'EFI')][string]$Kind - ) - - $diskIdentity = Get-SacDiskIdentity -DiskNumber $DiskNumber - if (-not $diskIdentity) { - Log-Warning "Disk ${DiskNumber}: temporary $Kind mount ownership cannot be persisted because the disk has no stable identity. Runtime finally cleanup will still be attempted." - return $null - } - - New-Item -Path $script:SacTempMountStateDirectory -ItemType Directory -Force -ErrorAction Stop | Out-Null - $statePath = Join-Path -Path $script:SacTempMountStateDirectory -ChildPath (([guid]::NewGuid().ToString('N')) + '.json') - $state = [ordered]@{ - DiskIdentity = $diskIdentity - DiskNumber = $DiskNumber - PartitionNumber = $PartitionNumber - DriveLetter = "$DriveLetter" - Kind = $Kind - CreatedUtc = [DateTime]::UtcNow.ToString('o') - } - $state | ConvertTo-Json | Set-Content -Path $statePath -Encoding UTF8 -ErrorAction Stop - return $statePath -} +$script_final_status = $STATUS_ERROR -function Complete-SacTemporaryMountCleanup { - param( - [AllowNull()][string]$StatePath, - [Parameter(Mandatory = $true)][int]$DiskNumber, - [Parameter(Mandatory = $true)][int]$PartitionNumber, - [Parameter(Mandatory = $true)][char]$DriveLetter - ) +function Get-DumpTypeLabel { + param($Value) - if (-not $StatePath) { return } + if ($null -eq $Value) { return "NOT FOUND" } - $partition = Get-Partition -DiskNumber $DiskNumber -PartitionNumber $PartitionNumber -ErrorAction SilentlyContinue - if (-not $partition -or "$($partition.DriveLetter)" -ne "$DriveLetter") { - Remove-Item -Path $StatePath -Force -ErrorAction SilentlyContinue - } - else { - Log-Warning "Temporary letter ${DriveLetter}: remains on Disk $DiskNumber Partition $PartitionNumber. Ownership state retained for the next run." + $intValue = [int]$Value + switch ($intValue) { + 0 { return "Disabled/None (0)" } + 1 { return "Complete/Full (1)" } + 2 { return "Kernel (2)" } + 3 { return "Small/Minidump (3)" } + 7 { return "Automatic (7)" } + default { return "Unknown ($intValue)" } } } -function Clear-SacStaleTemporaryMounts { - param( - [Parameter(Mandatory = $true)] - [int[]]$TargetDiskNumbers - ) - - if (-not (Test-Path -Path $script:SacTempMountStateDirectory -PathType Container)) { return } - - $targetIdentities = @{} - foreach ($targetDiskNumber in $TargetDiskNumbers) { - $identity = Get-SacDiskIdentity -DiskNumber $targetDiskNumber - if ($identity) { $targetIdentities[$identity] = $targetDiskNumber } - } +function Get-KdbgctrlOutputSummary { + param($OutputLines) - foreach ($stateFile in @(Get-ChildItem -Path $script:SacTempMountStateDirectory -Filter '*.json' -File -ErrorAction SilentlyContinue)) { - try { - $state = Get-Content -Path $stateFile.FullName -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop - $requiredValues = @($state.DiskIdentity, $state.PartitionNumber, $state.DriveLetter, $state.Kind) - if (@($requiredValues | Where-Object { [string]::IsNullOrWhiteSpace("$_") }).Count -gt 0) { - throw 'The ownership record is incomplete.' - } + $noisePatterns = @( + "Dump type from system registry is Invalid", + "lastError after QueryDosDevice call is 3" + ) - if (-not $targetIdentities.ContainsKey("$($state.DiskIdentity)")) { - Log-Warning "Discarding stale temporary-mount state '$($stateFile.Name)': its disk is not an attached repair target. No drive letter was removed." - Remove-Item -Path $stateFile.FullName -Force -ErrorAction SilentlyContinue - continue - } + $allLines = @($OutputLines | ForEach-Object { "$($_)".Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + $filtered = @() + $suppressed = @() - $currentDiskNumber = [int]$targetIdentities["$($state.DiskIdentity)"] - $partitionNumber = [int]$state.PartitionNumber - $driveLetter = [char]("$($state.DriveLetter)") - $partition = Get-Partition -DiskNumber $currentDiskNumber -PartitionNumber $partitionNumber -ErrorAction SilentlyContinue - if (-not $partition -or "$($partition.DriveLetter)" -ne "$driveLetter") { - Log-Info "Removing resolved temporary-mount state '$($stateFile.Name)'; the recorded letter is no longer assigned." - Remove-Item -Path $stateFile.FullName -Force -ErrorAction SilentlyContinue - continue + foreach ($line in $allLines) { + $isNoise = $false + foreach ($pattern in $noisePatterns) { + if ($line -like "*$pattern*") { + $isNoise = $true + break } - - Log-Warning "Recovering stale $($state.Kind) letter ${driveLetter}: from Disk $currentDiskNumber Partition $partitionNumber." - $removeOutput = @("select disk $currentDiskNumber", "select partition $partitionNumber", "remove letter=$driveLetter") | diskpart 2>&1 - foreach ($line in @($removeOutput)) { if ($line) { Log-Output "[diskpart][stale-cleanup] $line" } } - Complete-SacTemporaryMountCleanup -StatePath $stateFile.FullName -DiskNumber $currentDiskNumber -PartitionNumber $partitionNumber -DriveLetter $driveLetter } - catch { - Log-Warning "Could not process temporary-mount state '$($stateFile.FullName)': $($_.Exception.Message). No drive letter was removed from this record." + + if ($isNoise) { + $suppressed += $line + } else { + $filtered += $line } } -} -# =========================================== -# Logging Setup (Dual-Write: Desktop + Plugin Directory) -# =========================================== -$scriptName = [System.IO.Path]::GetFileNameWithoutExtension($MyInvocation.MyCommand.Name) -$runTimestamp = Get-Date -Format 'yyyyMMdd-HHmmss' + return @{ + All = $allLines + Filtered = $filtered + Suppressed = $suppressed + } +} -# Desktop log (for local inspection) -$desktopLogDir = Join-Path -Path $env:PUBLIC -ChildPath ("Desktop\\{0}-run-{1}" -f $scriptName, $runTimestamp) -$desktopLogFile = Join-Path -Path $desktopLogDir -ChildPath ("{0}-{1}.log" -f $scriptName, $runTimestamp) +function Get-AuditSnapshot { + param($Title) + + $Path = "HKLM:\SYSTEM\CurrentControlSet\Control\CrashControl" + $MMPath = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management" + $RelPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Reliability" + + # Read core dump settings + $NMI = (Get-ItemProperty -Path $Path -ErrorAction SilentlyContinue).NMICrashDump + $BSP = (Get-ItemProperty -Path $RelPath -ErrorAction SilentlyContinue).BootStatusPolicy + + # PAGEFILE DETECTION: Query CIM for the active configuration. + $ConfiguredPageFiles = Get-CimInstance -ClassName Win32_PageFileSetting -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty Name + + Log-Output ">>> $Title <<<" + $crashDumpEnabled = (Get-ItemProperty -Path $Path).CrashDumpEnabled -# Plugin directory log (for az vm repair auto-collection) -$pluginLogDir = 'C:\WindowsAzure\Logs\Plugins\Microsoft.Compute.CustomScriptExtension\' -$pluginLogFile = Join-Path -Path $pluginLogDir -ChildPath ("{0}_{1}.log" -f $scriptName, $runTimestamp) + $currentDumpFile = (Get-ItemProperty -Path $Path -ErrorAction SilentlyContinue).DumpFile + $currentDedicatedDumpFile = (Get-ItemProperty -Path $Path -ErrorAction SilentlyContinue).DedicatedDumpFile -# Ensure directories exist -foreach ($logDirectory in @($desktopLogDir, $pluginLogDir)) { - if (-not (Test-Path -Path $logDirectory -PathType Container)) { - try { - New-Item -Path $logDirectory -ItemType Directory -Force -ErrorAction Stop | Out-Null - } - catch { - # Plugin dir may not be creatable; continue with desktop log only - if ($logDirectory -eq $pluginLogDir) { - [Console]::Error.WriteLine("[Warning] Could not create plugin log directory '$logDirectory': $($_.Exception.Message). Will use desktop log only.") - } else { - throw - } - } + Log-Output "DumpFile : $(if([string]::IsNullOrWhiteSpace("$currentDumpFile")){"NOT FOUND"}else{$currentDumpFile})" + Log-Output "DedicatedDumpFile : $(if([string]::IsNullOrWhiteSpace("$currentDedicatedDumpFile")){"NOT FOUND"}else{$currentDedicatedDumpFile})" + Log-Output "CrashDumpEnabled : $(Get-DumpTypeLabel -Value $crashDumpEnabled)" + Log-Output "NMICrashDump : $(if($null -eq $NMI){"NOT FOUND"}else{$NMI})" + Log-Output "BootStatusPolicy : $(if($null -eq $BSP){"NOT FOUND"}else{$BSP})" + + if ($ConfiguredPageFiles) { + Log-Output "ConfiguredPageFiles (LIVE): $($ConfiguredPageFiles -join ', ')" + } else { + # Fallback to registry if WMI returns nothing (unusual) + $PFile = (Get-ItemProperty -Path $MMPath -ErrorAction SilentlyContinue).ExistingPageFiles + Log-Output "ExistingPageFiles : $(if($null -eq $PFile){"NOT FOUND"}else{$PFile})" } } -# Initialize log files -@($desktopLogFile, $pluginLogFile) | Where-Object { -not (Test-Path -Path $_ -PathType Leaf) } | ForEach-Object { - try { - New-Item -Path $_ -ItemType File -Force -ErrorAction Stop | Out-Null +try { + # Step 1 - Audit BEFORE + Get-AuditSnapshot "AUDITING SETTINGS (BEFORE)" + + $CrashCtrlPath = "HKLM:\SYSTEM\CurrentControlSet\Control\CrashControl" + $crashControlBackupPath = Join-Path -Path $env:PUBLIC -ChildPath ("Desktop\\CrashControl-backup-{0}.reg" -f (Get-Date -Format 'yyyyMMdd-HHmmss')) + $backupResult = & reg.exe export "HKLM\SYSTEM\CurrentControlSet\Control\CrashControl" $crashControlBackupPath /y 2>&1 + if ($LASTEXITCODE -eq 0) { + Log-Info "Created registry backup: $crashControlBackupPath" } - catch { - # Log creation failure is not critical; logging will append if file doesn't exist + else { + Log-Warning "Could not create registry backup. Output: $($backupResult -join ' | ')" } -} -# For backward compatibility with existing Log-* function references -$logFilePath = $desktopLogFile + $initialValue = (Get-ItemProperty -Path $CrashCtrlPath).CrashDumpEnabled + $dumpTypeMap = @{ 'full' = 1; 'kernel' = 2; 'mini' = 3; 'automatic' = 7; 'active' = 1 } + $requestedDumpValue = $dumpTypeMap[$DumpType] + $verificationFailed = $false + Log-Output "Current dump configuration: $(Get-DumpTypeLabel -Value $initialValue)" + Log-Output "Requested dump type: $DumpType ($(Get-DumpTypeLabel -Value $requestedDumpValue))" + Log-Output "Requested DumpFile: $(if([string]::IsNullOrWhiteSpace("$DumpFile")){"NOT SPECIFIED"}else{$DumpFile})" + Log-Output "Requested DedicatedDumpFile: $(if([string]::IsNullOrWhiteSpace("$DedicatedDumpFile")){"NOT SPECIFIED"}else{$DedicatedDumpFile})" -# =========================================== -# Log Wrapper Functions (Consolidation Note) -# =========================================== -# These wrappers remain local so the repair script stays self-contained while dual-writing -# to repair-host locations. Logs are not written to the attached customer OS disk. + # Step 2 - Enable NMI + Set-ItemProperty -Path $CrashCtrlPath -Name NMICrashDump -Value 1 -Type DWord -$script:OriginalLogOutput = (Get-Command Log-Output -CommandType Function).ScriptBlock -$script:OriginalLogInfo = (Get-Command Log-Info -CommandType Function).ScriptBlock -$script:OriginalLogWarning = (Get-Command Log-Warning -CommandType Function).ScriptBlock -$script:OriginalLogError = (Get-Command Log-Error -CommandType Function).ScriptBlock -$script:OriginalLogDebug = (Get-Command Log-Debug -CommandType Function).ScriptBlock - -function Write-DesktopLogLine { - param( - [Parameter(Mandatory = $true)] - [string]$Level, - - [Parameter(Mandatory = $true)] - [PSObject[]]$Message - ) + # Step 3 - Configure automatic reboot (optional) + if ($ConfigureAutomaticReboot -eq $true -or $ConfigureAutomaticReboot -eq 'true') { + Set-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Reliability" -Name BootStatusPolicy -Value 1 -Type DWord + Log-Info "Automatic reboot on crash configured (BootStatusPolicy=1)." + } + else { + Log-Info "Automatic reboot on crash NOT configured. Use -ConfigureAutomaticReboot to enable." + } - try { - $renderedMessage = ($Message | ForEach-Object { "$_" }) -join ' ' - $line = "[{0} {1}]{2}" -f $Level, (Get-Date), $renderedMessage - - # Write to both log files (desktop and plugin directory) - Add-Content -Path $desktopLogFile -Value $line -Encoding UTF8 -ErrorAction Stop - if (Test-Path -Path $pluginLogDir -PathType Container) { - Add-Content -Path $pluginLogFile -Value $line -Encoding UTF8 -ErrorAction Stop + # Step 4 - Pagefile Detection for Smart Placement + $MMPath = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management" + $currentPFiles = Get-CimInstance -ClassName Win32_PageFileSetting | Select-Object -ExpandProperty Name + $pagefileOnTempDrive = $false + $originalPagefileLocations = $currentPFiles + $pagefileWasMoved = $false + $pagefileScanProcessed = 0 + $pagefileScanTempMatches = 0 + + foreach ($pf in $currentPFiles) { + $pagefileScanProcessed++ + Log-Debug "Detected pagefile setting: $pf" + if ($pf -like "D:*" -or $pf -like "*D:\*") { + $pagefileOnTempDrive = $true + $pagefileScanTempMatches++ + Log-Warning "Pagefile detected on D: drive: $pf" + break } } - catch { - if ($script:OriginalLogWarning) { - & $script:OriginalLogWarning -message "Failed to append to log files: $($_.Exception.Message)" - } - else { - [Console]::Error.WriteLine("[Warning $(Get-Date)]Failed to append to log files: $($_.Exception.Message)") + Log-Info "Pagefile scan summary: processed=$pagefileScanProcessed tempDriveMatches=$pagefileScanTempMatches" + + # INTELLIGENT DUMP PLACEMENT + # Respect explicit user-provided values exactly as passed. + if ($DumpFile) { + Set-ItemProperty -Path $CrashCtrlPath -Name DumpFile -Value $DumpFile + Log-Info "Applied user-provided DumpFile: $DumpFile" + } + else { + if ($pagefileOnTempDrive) { + Set-ItemProperty -Path $CrashCtrlPath -Name DumpFile -Value "%SystemRoot%\MEMORY.DMP" + # Only apply fallback DedicatedDumpFile when user did not provide a value. + if (-not $DedicatedDumpFile) { + Set-ItemProperty -Path $CrashCtrlPath -Name DedicatedDumpFile -Value "C:\dd.sys" + } + } else { + Set-ItemProperty -Path $CrashCtrlPath -Name DumpFile -Value "%SystemRoot%\MEMORY.DMP" } } -} - -function Log-Output { - Param([Parameter(Mandatory = $true)][PSObject[]]$message) - & $script:OriginalLogOutput -message $message - Write-DesktopLogLine -Level 'Output' -Message $message -} - -function Log-Info { - Param([Parameter(Mandatory = $true)][PSObject[]]$message) - & $script:OriginalLogInfo -message $message - Write-DesktopLogLine -Level 'Info' -Message $message -} -function Log-Warning { - Param([Parameter(Mandatory = $true)][PSObject[]]$message) - & $script:OriginalLogWarning -message $message - Write-DesktopLogLine -Level 'Warning' -Message $message -} - -function Log-Error { - Param([Parameter(Mandatory = $true)][PSObject[]]$message) - & $script:OriginalLogError -message $message - Write-DesktopLogLine -Level 'Error' -Message $message -} - -function Log-Debug { - Param([Parameter(Mandatory = $true)][PSObject[]]$message) - & $script:OriginalLogDebug -message $message - Write-DesktopLogLine -Level 'Debug' -Message $message -} - -$logFile = $logFilePath -Log-Info "Dual logging initialized - Desktop: $desktopLogFile | Plugin: $pluginLogFile" -Log-Info "Script classification: REPAIR_VM_ONLY" + # Step 5 - OPTIONAL PAGEFILE RELOCATION + if (($MovePagefile -eq $true -or $MovePagefile -eq 'true') -and $pagefileOnTempDrive) { + Log-Warning "PAGEFILE RELOCATION REQUESTED" + Log-Warning "⚠️ IMPORTANT: Pagefile relocation from D: to C: is DESTRUCTIVE and NOT EASILY REVERSIBLE:" + Log-Warning " 1. If C: drive runs out of space, the VM may crash" + Log-Warning " 2. To restore pagefile to D: after troubleshooting, manual intervention or script re-run is required" + Log-Warning " 3. Ensure C: drive has sufficient free space (recommend minimum 50% free) before proceeding" + Log-Warning " 4. For production VMs, consider scheduling this change during maintenance window" + + try { + # FIX: Explicitly target C: if logic loop fails, bypass the CIM free space comparison bug + $cDrive = Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DeviceID='C:'" + + if ($null -ne $cDrive) { + $targetPagefile = "C:\pagefile.sys" + + # Validate C: drive has sufficient free space + $cDriveFreeSpaceGB = [math]::Round($cDrive.FreeSpace / 1GB, 2) + $cDriveTotalSpaceGB = [math]::Round($cDrive.Size / 1GB, 2) + $cDriveFreePercent = [math]::Round(($cDrive.FreeSpace / $cDrive.Size) * 100, 0) + + Log-Info "C: Drive space status: $cDriveFreeSpaceGB GB free of $cDriveTotalSpaceGB GB ($cDriveFreePercent% free)" + + if ($cDriveFreePercent -lt 20) { + Log-Error "C: Drive free space is below 20% ($cDriveFreePercent%). Relocation aborted to prevent VM crash." + throw "Insufficient C: drive free space. Minimum 20% recommended, current: $cDriveFreePercent%" + } + + Log-Info "C: Drive detected via CIM. Proceeding with relocation..." + + $pageFileSettings = Get-CimInstance -ClassName Win32_PageFileSetting + $pagefileDeleteProcessed = 0 + $pagefileDeleteDeleted = 0 + $pagefileDeleteFailed = 0 + $pagefileDeleteSkipped = 0 + + foreach ($pf in $pageFileSettings) { + $pagefileDeleteProcessed++ + if ($pf.Name -like "D:*" -or $pf.Name -like "*D:\*") { + try { + Log-Info "Deleting current pagefile instance: $($pf.Name)" + $pf | Remove-CimInstance -ErrorAction Stop + $pagefileDeleteDeleted++ + } + catch { + $pagefileDeleteFailed++ + Log-Warning "Failed to delete pagefile instance '$($pf.Name)': $($_.Exception.Message)" + } + } + else { + $pagefileDeleteSkipped++ + } + } + Log-Info "Pagefile delete summary: processed=$pagefileDeleteProcessed deleted=$pagefileDeleteDeleted skipped=$pagefileDeleteSkipped failed=$pagefileDeleteFailed" -# Status Tracking -$script_final_status = $STATUS_ERROR -$failureReason = 'Script could not find a valid attached OS disk to enable SAC. Verify the disk is attached to the repair VM.' -$detectedExecutionContext = 'UNDETERMINED' -$processedCount = 0 -$skippedCount = 0 -$failedCount = 0 -$changedCount = 0 + if ($pagefileDeleteFailed -gt 0) { + throw "One or more D: pagefile entries could not be removed. See logs for details." + } -Log-Info "Starting repair-only SAC enabler. Logs: $logFile" + $newPageFile = New-CimInstance -ClassName Win32_PageFileSetting -Property @{ + Name = $targetPagefile + InitialSize = 0 + MaximumSize = 0 + } -ErrorAction Stop -try { - # Check if the Hyper-V module is available before performing nested VM checks - if (Get-Module -ListAvailable -Name Hyper-V) { - $guestHyperVVirtualMachine = Get-VM -ErrorAction SilentlyContinue -WarningAction SilentlyContinue - if ($guestHyperVVirtualMachine) { - if ($guestHyperVVirtualMachine.State -eq 'Running') { - Log-Info "Stopping nested guest VM $($guestHyperVVirtualMachine.VMName)" - try { - Stop-VM $guestHyperVVirtualMachine -ErrorAction Stop -Force - } - catch { - Log-Warning "Failed to stop nested guest VM, will continue but may have limited success" + if ($null -ne $newPageFile) { + $pagefileWasMoved = $true + Log-Info "Successfully updated WMI configuration to: $targetPagefile" } + } else { + throw "C: drive could not be verified via CIM. Relocation aborted." } } - } else { - Log-Info "Hyper-V PowerShell module is not available on this host. Skipping nested VM validation." + catch { + Log-Error "Failed to relocate pagefile: $($_.Exception.Message)" + } } - # Step 1 - Enumerate partitions to locate the BCD store and OS loader - $partitionlist = @(Get-Disk-Partitions) - if ($partitionlist.Count -eq 0) { - throw 'Get-Disk-Partitions returned no partitions from Azure virtual disks.' + # Step 6 - DedicatedDumpFile + if ($DedicatedDumpFile -eq "delete") { + Remove-ItemProperty -Path $CrashCtrlPath -Name DedicatedDumpFile -ErrorAction SilentlyContinue + Log-Info "Applied user request: DedicatedDumpFile deleted." } - - $discoveredDiskNumbers = @($partitionlist | Select-Object -ExpandProperty DiskNumber -Unique) - Log-Info "Get-Disk-Partitions discovered disk numbers: $($discoveredDiskNumbers -join ', ')" - $repairDrive = $env:SystemDrive -replace ':', '' - Log-Info 'Enumerating partitions to enable SAC...' - - # SAFETY CHECK: Ensure we're not operating on the repair VM's own disk - $repairOsPartition = Get-Partition -DriveLetter $repairDrive -ErrorAction Stop | Select-Object -First 1 - if ($null -eq $repairOsPartition -or $null -eq $repairOsPartition.DiskNumber) { - throw "CRITICAL SAFETY CHECK FAILED: Could not identify the repair VM OS disk from $($env:SystemDrive)." + elseif ($DedicatedDumpFile) { + Set-ItemProperty -Path $CrashCtrlPath -Name DedicatedDumpFile -Value $DedicatedDumpFile + Log-Info "Applied user-provided DedicatedDumpFile: $DedicatedDumpFile" } - $repairDiskNumber = [int]$repairOsPartition.DiskNumber - Log-Info "Repair VM OS disk identified as Disk $repairDiskNumber" + # Step 7 - Guard for empty DumpFile (ensure valid path before kdbgctrl) + if ([string]::IsNullOrEmpty($DumpFile)) { + Log-Warning "DumpFile is empty. Using Windows default: %SystemRoot%\\MEMORY.DMP" + $DumpFile = "%SystemRoot%\\MEMORY.DMP" + Set-ItemProperty -Path $CrashCtrlPath -Name DumpFile -Value $DumpFile + } - $targetDiskGroups = @($partitionlist | Group-Object DiskNumber | Where-Object { [int]$_.Name -ne $repairDiskNumber }) - $detectedExecutionContext = Get-SacExecutionContext -TargetDiskGroups $targetDiskGroups - Log-Info "Detected execution context: $detectedExecutionContext" + # Step 8 - Apply to LIVE KERNEL + Log-Info "Applying dump type '$DumpType' via kdbgctrl..." + Set-ItemProperty -Path $CrashCtrlPath -Name CrashDumpEnabled -Value 0 + + $toolPath = Join-Path -Path $PSScriptRoot -ChildPath 'common\tools\kdbgctrl.exe' + if (-not (Test-Path -Path $toolPath -PathType Leaf)) { + throw "Missing required dependency: $toolPath" + } + $kdbgResult = & $toolPath -sd $DumpType 2>&1 + $kdbgExitCode = $LASTEXITCODE + $parsedKdbg = Get-KdbgctrlOutputSummary -OutputLines $kdbgResult - if ($detectedExecutionContext -ne 'REPAIR_VM') { - Log-Error "[STANDARD_VM] REPAIR-ONLY SCRIPT: The helper returned no secondary disk." - Log-Error "[STANDARD_VM] Run this script with az vm repair run and --run-on-repair. No BCD changes were attempted." - $script_final_status = $STATUS_ERROR - $failureReason = 'Standard VM context detected, or the repair VM has no accessible attached Windows OS disk.' + if ($parsedKdbg.Suppressed.Count -gt 0) { + Log-Debug "Suppressed non-actionable kdbgctrl messages: $($parsedKdbg.Suppressed -join ' | ')" } - else - { - $targetDiskNumbers = @($targetDiskGroups | ForEach-Object { [int]$_.Name }) - Clear-SacStaleTemporaryMounts -TargetDiskNumbers $targetDiskNumbers - - foreach ( $partitionGroup in $targetDiskGroups ) - { - $processedCount++ - $diskChanged = $false - $diskFailed = $false - $diskNumber = $partitionGroup.Name - $isBcdPath = $false - $bcdPath = '' - $isOsPath = $false - $tempEfiLetter = $null - $tempEfiDiskNum = $null - $tempEfiPartNum = $null - $tempOsLetter = $null - $tempOsDiskNum = $null - $tempOsPartNum = $null - $tempOsStatePath = $null - $tempEfiStatePath = $null - $bcdBackup = $null - - Log-Info "Processing Disk $diskNumber" - - try { - # Scan each drive for BCD store and Windows OS loader - ForEach ($drive in $partitionGroup.Group | Select-Object -ExpandProperty DriveLetter | Where-Object { $_ }) - { - # The repair disk was filtered out above; retain this drive-level safety check. - if ($drive -eq $repairDrive) { continue } - - if ( -not $isBcdPath ) - { - $bcdPath = $drive + ':\boot\bcd' - $isBcdPath = Test-Path $bcdPath - if ( -not $isBcdPath ) - { - $bcdPath = $drive + ':\efi\microsoft\boot\bcd' - $isBcdPath = Test-Path $bcdPath - } - } - if (-not $isOsPath) - { - $winloadExePath = $drive + ':\windows\system32\winload.exe' - $winloadEfiPath = $drive + ':\windows\system32\winload.efi' - $isOsPath = (Test-Path $winloadExePath) -or (Test-Path $winloadEfiPath) + if ($kdbgExitCode -ne 0) { + $verificationFailed = $true + Log-Error "kdbgctrl failed with exit code $kdbgExitCode. Output: $($parsedKdbg.Filtered -join ' | ')" + } + else { + $successMatched = $false + foreach ($line in $parsedKdbg.Filtered) { + if ($line -match '(?i)success|successfully updated dump settings') { + $successMatched = $true + break } } - # Gen2 fallback: probe unlettered partitions directly for a Windows loader. - if (-not $isOsPath) - { - $diskNum = [int]$partitionGroup.Name - $diskPartitions = @(Get-Partition -DiskNumber $diskNum -ErrorAction SilentlyContinue) - foreach ($partition in $diskPartitions) { - $driveDescription = if ($partition.DriveLetter) { "$($partition.DriveLetter):" } else { '' } - $sizeMb = [math]::Round($partition.Size / 1MB) - Log-Info "Disk $diskNum partition $($partition.PartitionNumber): drive=$driveDescription sizeMB=$sizeMb type=$($partition.Type) gptType=$($partition.GptType)" - } - - $unletteredOsCandidates = @($diskPartitions | Where-Object { - -not $_.DriveLetter -or $_.DriveLetter -eq [char]0 - } | Sort-Object Size -Descending) - Log-Info "Disk ${diskNum}: probing $($unletteredOsCandidates.Count) unlettered partition(s) for a Windows loader." - - foreach ($osCandidate in $unletteredOsCandidates) - { - $candidateLetter = Get-AvailableTempDriveLetter - if (-not $candidateLetter) { - Log-Warning "No available drive letter for an unlettered Windows partition on Disk $diskNum" - break - } - - $candidatePartNum = $osCandidate.PartitionNumber - Log-Info "Assigning temp letter ${candidateLetter}: to Disk $diskNum Partition $candidatePartNum (Windows candidate)..." - $tempOsStatePath = Register-SacTemporaryMount -DiskNumber $diskNum -PartitionNumber $candidatePartNum -DriveLetter $candidateLetter -Kind Windows - $dpOsAssign = @("select disk $diskNum", "select partition $candidatePartNum", "assign letter=$candidateLetter") - $dpOsAssignOut = $dpOsAssign | diskpart 2>&1 - foreach ($line in @($dpOsAssignOut)) { if ($line) { Log-Output "[diskpart][os-assign] $line" } } - $tempOsLetter = $candidateLetter - $tempOsDiskNum = $diskNum - $tempOsPartNum = $candidatePartNum - Start-Sleep -Seconds 2 - - $winloadExePath = "${candidateLetter}:\windows\system32\winload.exe" - $winloadEfiPath = "${candidateLetter}:\windows\system32\winload.efi" - $isOsPath = (Test-Path -Path $winloadExePath) -or (Test-Path -Path $winloadEfiPath) - if ($isOsPath) { - Log-Info "Found Windows OS partition at ${candidateLetter}: on Disk $diskNum" - break - } - - Log-Info "No Windows loader found at ${candidateLetter}:, removing letter..." - $dpOsRemove = @("select disk $diskNum", "select partition $candidatePartNum", "remove letter=$candidateLetter") - $dpOsRemoveOut = $dpOsRemove | diskpart 2>&1 - foreach ($line in @($dpOsRemoveOut)) { if ($line) { Log-Output "[diskpart][os-remove] $line" } } - Complete-SacTemporaryMountCleanup -StatePath $tempOsStatePath -DiskNumber $diskNum -PartitionNumber $candidatePartNum -DriveLetter $candidateLetter - $tempOsLetter = $null - $tempOsDiskNum = $null - $tempOsPartNum = $null - $tempOsStatePath = $null - } + if ($successMatched) { + Log-Output "Successfully updated dump settings to '$DumpType' via kdbgctrl." } - - # Gen2 EFI fallback: if OS found but no BCD, discover unlettered EFI partition - if (-not $isBcdPath -and $isOsPath) - { - $diskNum = [int]$partitionGroup.Name - if ($diskNum -ne $repairDiskNumber) - { - Log-Info "Disk ${diskNum}: OS found but no BCD - checking for unlettered EFI partition (Gen2)..." - $efiGptType = 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b' - $efiParts = Get-Partition -DiskNumber $diskNum -ErrorAction SilentlyContinue | Where-Object { - (Test-SacGptType -ActualType $_.GptType -ExpectedType $efiGptType) -and - (-not $_.DriveLetter -or $_.DriveLetter -eq [char]0) - } - if ($efiParts) - { - $tempLetter = Get-AvailableTempDriveLetter - if ($tempLetter) - { - foreach ($ep in $efiParts) - { - $pn = $ep.PartitionNumber - Log-Info "Assigning temp letter ${tempLetter}: to Disk $diskNum Partition $pn (EFI)..." - $tempEfiStatePath = Register-SacTemporaryMount -DiskNumber $diskNum -PartitionNumber $pn -DriveLetter $tempLetter -Kind EFI - $dpLines = @("select disk $diskNum", "select partition $pn", "assign letter=$tempLetter") - $dpAssignOut = $dpLines | diskpart 2>&1 - foreach ($line in @($dpAssignOut)) { if ($line) { Log-Output "[diskpart][assign] $line" } } - $tempEfiLetter = $tempLetter - $tempEfiDiskNum = $diskNum - $tempEfiPartNum = $pn - Start-Sleep -Seconds 2 - $bcdPath = "${tempLetter}:\efi\microsoft\boot\bcd" - $isBcdPath = Test-Path $bcdPath - if ($isBcdPath) - { - Log-Info "Found Gen2 BCD store at $bcdPath" - break - } - else - { - Log-Info "No BCD at $bcdPath, removing letter..." - $dpRemove = @("select disk $diskNum", "select partition $pn", "remove letter=$tempLetter") - $dpRemoveOut = $dpRemove | diskpart 2>&1 - foreach ($line in @($dpRemoveOut)) { if ($line) { Log-Output "[diskpart][remove] $line" } } - Complete-SacTemporaryMountCleanup -StatePath $tempEfiStatePath -DiskNumber $diskNum -PartitionNumber $pn -DriveLetter $tempLetter - $tempEfiLetter = $null - $tempEfiDiskNum = $null - $tempEfiPartNum = $null - $tempEfiStatePath = $null - } - } - } - else - { - Log-Warning "No available drive letter for EFI partition on Disk $diskNum" - } - } - } + elseif ($parsedKdbg.Filtered.Count -gt 0) { + Log-Warning "kdbgctrl completed with unexpected output: $($parsedKdbg.Filtered -join ' | ')" } + } - # Apply SAC changes if both BCD and OS loader were found - if ( $isBcdPath -and $isOsPath ) - { - # Step 2 - Identify the default boot entry GUID - $bcdout = bcdedit /store $bcdPath /enum bootmgr /v - $defaultLine = $bcdout | Select-String 'displayorder' | Select-Object -First 1 - - if (-not $defaultLine) - { - $failureReason = "Could not locate a displayorder entry in boot manager output for $bcdPath." - Log-Warning "Could not locate a displayorder entry in boot manager output for $bcdPath. Unable to determine the default boot entry." - $diskFailed = $true - } - elseif ($defaultLine -match '\{([^}]+)\}') { - $defaultId = $matches[0] - - # VALIDATION: Confirm we have a valid GUID - if ($defaultId -notmatch '^\{[0-9a-f\-]{36}\}$') { - Log-Error "Invalid boot entry GUID format: $defaultId. This may indicate a corrupted BCD store." - $diskFailed = $true - } - else - { - # VALIDATION: Backup BCD store before any modifications - $bcdBackup = $bcdPath + '.backup-' + (Get-Date -Format 'yyyyMMdd-HHmmss') - try { - Copy-Item -Path $bcdPath -Destination $bcdBackup -Force -ErrorAction Stop - Log-Info "BCD backup created at: $bcdBackup" - } - catch { - Log-Warning "Could not create BCD backup: $($_.Exception.Message). Proceeding with caution." - } - - # Step 3 - Log BCD configuration before changes - Log-Output "--- BCD BEFORE SAC ENABLE ---" - $beforeBcd = bcdedit /store $bcdPath /enum $defaultId - foreach ($line in $beforeBcd) { if ($line.Trim()) { Log-Output $line } } - - # Steps 4-7 - Enable boot menu, Boot EMS, EMS on OS entry, and EMS serial settings - Log-Info "Applying SAC and EMS configurations to BCD: $bcdPath" - $setBootMenuOut = bcdedit /store $bcdPath /set "{bootmgr}" displaybootmenu yes 2>&1 - foreach ($line in @($setBootMenuOut)) { if ($line) { Log-Output "[bcdedit][displaybootmenu] $line" } } - - $setTimeoutOut = bcdedit /store $bcdPath /set "{bootmgr}" timeout 5 2>&1 - foreach ($line in @($setTimeoutOut)) { if ($line) { Log-Output "[bcdedit][timeout] $line" } } + # Registry Fallback for kdbgctrl + if ((Get-ItemProperty -Path $CrashCtrlPath).CrashDumpEnabled -eq 0) { + Set-ItemProperty -Path $CrashCtrlPath -Name CrashDumpEnabled -Value $dumpTypeMap[$DumpType] -Type DWord + } - $setBootEmsOut = bcdedit /store $bcdPath /set "{bootmgr}" bootems yes 2>&1 - foreach ($line in @($setBootEmsOut)) { if ($line) { Log-Output "[bcdedit][bootems] $line" } } + # Step 9 - OneDump + if ($OneDump -eq $true -or $OneDump -eq 'true') { + Set-ItemProperty -Path $CrashCtrlPath -Name CrashDumpEnabled -Value $initialValue + } - $setEmsOut = bcdedit /store $bcdPath /ems $defaultId ON 2>&1 - foreach ($line in @($setEmsOut)) { if ($line) { Log-Output "[bcdedit][ems] $line" } } + # Step 10 - Verification Summary + Log-Info "Dump configuration task completed." - $setEmsSettingsOut = bcdedit /store $bcdPath /emssettings EMSPORT:1 EMSBAUDRATE:115200 2>&1 - foreach ($line in @($setEmsSettingsOut)) { if ($line) { Log-Output "[bcdedit][emssettings] $line" } } + # Step 11 - Final Audit AFTER + Get-AuditSnapshot "VERIFYING UPDATED SETTINGS (AFTER)" - # VALIDATION: Verify BCD changes were applied successfully - Log-Info "Verifying BCD changes..." - $verifyBcd = bcdedit /store $bcdPath /enum $defaultId - $emsEnabled = $verifyBcd | Select-String 'ems' | Select-String 'Yes' - if (-not $emsEnabled) { - Log-Error "CRITICAL: EMS verification failed! BCD may be corrupted. Restore from backup: $bcdBackup" - $diskFailed = $true - } - else { - # Step 8 - Log BCD configuration after changes for verification - Log-Output "--- BCD AFTER SAC ENABLE ---" - $afterBcd = bcdedit /store $bcdPath /enum $defaultId - foreach ($line in $afterBcd) { if ($line.Trim()) { Log-Output $line } } - - $script_final_status = $STATUS_SUCCESS - $diskChanged = $true - } - } - } - else - { - $failureReason = "Displayorder entry was found but no boot entry GUID could be parsed for $bcdPath." - Log-Warning "Displayorder entry was found but no boot entry GUID could be parsed for $bcdPath. Raw line: $($defaultLine.Line)" - $diskFailed = $true - } - } - else { - Log-Info "Disk $diskNumber skipped: no valid BCD + OS loader combination was found." - } - } catch { - $diskFailed = $true - $failureReason = "Disk $diskNumber failed with exception: $($_.Exception.Message)" - Log-Error $failureReason - if ($_.InvocationInfo -and $_.InvocationInfo.PositionMessage) { - Log-Error "Disk $diskNumber failure context: $($_.InvocationInfo.PositionMessage)" - } - } finally { - - # Clean up temporary EFI drive letter if one was assigned - if ($tempEfiLetter) - { - Log-Info "Removing temp letter ${tempEfiLetter}: from Disk $tempEfiDiskNum Partition $tempEfiPartNum" - $dpClean = @("select disk $tempEfiDiskNum", "select partition $tempEfiPartNum", "remove letter=$tempEfiLetter") - $dpCleanOut = $dpClean | diskpart 2>&1 - foreach ($line in @($dpCleanOut)) { if ($line) { Log-Output "[diskpart][cleanup] $line" } } - Complete-SacTemporaryMountCleanup -StatePath $tempEfiStatePath -DiskNumber $tempEfiDiskNum -PartitionNumber $tempEfiPartNum -DriveLetter $tempEfiLetter - } - - if ($tempOsLetter) - { - Log-Info "Removing temp letter ${tempOsLetter}: from Disk $tempOsDiskNum Partition $tempOsPartNum" - $dpOsClean = @("select disk $tempOsDiskNum", "select partition $tempOsPartNum", "remove letter=$tempOsLetter") - $dpOsCleanOut = $dpOsClean | diskpart 2>&1 - foreach ($line in @($dpOsCleanOut)) { if ($line) { Log-Output "[diskpart][os-cleanup] $line" } } - Complete-SacTemporaryMountCleanup -StatePath $tempOsStatePath -DiskNumber $tempOsDiskNum -PartitionNumber $tempOsPartNum -DriveLetter $tempOsLetter - } - - if ($diskChanged) { $changedCount++ } - elseif ($diskFailed) { $failedCount++ } - else { $skippedCount++ } - } + $currentDumpValue = (Get-ItemProperty -Path $CrashCtrlPath).CrashDumpEnabled + if ($OneDump -eq $true -or $OneDump -eq 'true') { + Log-Output "OneDump requested. CrashDumpEnabled restored to $(Get-DumpTypeLabel -Value $currentDumpValue)." + } + elseif ($currentDumpValue -ne $requestedDumpValue) { + $verificationFailed = $true + Log-Error "Dump configuration verification failed. Expected $(Get-DumpTypeLabel -Value $requestedDumpValue), found $(Get-DumpTypeLabel -Value $currentDumpValue)." } + else { + Log-Output "Verified dump configuration: $(Get-DumpTypeLabel -Value $currentDumpValue)." } - if ($script_final_status -ne $STATUS_SUCCESS) { - Log-Error "[$detectedExecutionContext] FAILED: $failureReason" + $effectiveCrashControl = Get-ItemProperty -Path $CrashCtrlPath -ErrorAction SilentlyContinue + Log-Output "Effective DumpFile: $($effectiveCrashControl.DumpFile)" + Log-Output "Effective DedicatedDumpFile: $($effectiveCrashControl.DedicatedDumpFile)" + + if ($pagefileWasMoved) { + Log-Output "PAGEFILE RELOCATION COMPLETED: Pagefile moved from temporary D: drive." + Log-Warning "RESTORATION REQUIRED: Restore to $($originalPagefileLocations -join ', ') after debugging." + } + + if ($verificationFailed) { + Log-Error "Configuration completed with one or more validation errors." + $script_final_status = $STATUS_ERROR + } + else { + Log-Output "SUCCESS: Configuration applied immediately - NO REBOOT REQUIRED" + Log-Info "Desktop log file: $logFilePath" + $script_final_status = $STATUS_SUCCESS } } catch { - Log-Error "[$detectedExecutionContext] An error occurred: $($_.Exception.Message)" - if ($_.InvocationInfo -and $_.InvocationInfo.PositionMessage) { - Log-Error "Failure context: $($_.InvocationInfo.PositionMessage)" + Log-Error "Failure: $($_.Exception.Message)" + if ($crashControlBackupPath -and (Test-Path -Path $crashControlBackupPath -PathType Leaf)) { + Log-Warning "Rollback available. To restore previous CrashControl values, run: reg import `"$crashControlBackupPath`"" } $script_final_status = $STATUS_ERROR } finally { - Log-Info "Summary: processed=$processedCount changed=$changedCount skipped=$skippedCount failed=$failedCount" - Log-Info "Detected execution context: $detectedExecutionContext" - Log-Info "Desktop log: $desktopLogFile" - if (Test-Path -Path $pluginLogFile -PathType Leaf) { - Log-Info "Plugin log (auto-collected): $pluginLogFile" - } Log-Info "Script ended at $(Get-Date)" } From dd26ae84a1240866a329f313e6a51e5cc254a8e3 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:53:47 +0300 Subject: [PATCH 22/43] Update sac-enabler.ps1 --- src/windows/sac-enabler.ps1 | 1059 ++++++++++++++++++++--------------- 1 file changed, 613 insertions(+), 446 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 513ad82c..7ea30430 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -1,540 +1,707 @@ <# .SYNOPSIS - Configures Azure VM memory dumps with intelligent placement strategies to work around temporary storage issues - no reboot required. + Enables SAC and Serial Console boot settings on attached Windows disks, including BIOS and UEFI layouts. .DESCRIPTION - This script runs on the live VM (not a rescue VM) to configure crash dump settings - WITHOUT REQUIRING A REBOOT. Includes smart placement strategies to work around - Azure VM temporary storage limitations. - + This script runs only from a repair VM to enable SAC/EMS on an attached OS disk's BCD store. It performs the following steps: - 1. Audits current crash control settings using both Registry and CIM (for pagefile accuracy) - 2. Enables NMICrashDump (DWORD 1) to allow NMI triggering from the Azure Portal - 3. Optionally configures automatic reboot after crash (use -ConfigureAutomaticReboot to enable) - 4. INTELLIGENTLY configures dump file placement to work around temporary drive issues - 5. Uses dedicated dump files when necessary to ensure reliability on Azure VMs - 6. Uses kdbgctrl.exe to apply the selected dump type to the live kernel immediately - 7. If -OneDump is specified, restores original CrashDumpEnabled after kernel update - 8. Validates C: drive free space (minimum 20%) before pagefile relocation to prevent VM crashes - 9. NO REBOOT REQUIRED - All changes take effect immediately - -.PARAMETER OneDump - Switch to restore the original CrashDumpEnabled value after the kernel has been updated. - Useful for single-event debugging. - -.PARAMETER DumpType - The type of dump to configure. Valid values: active, automatic, full, kernel, mini. - -.PARAMETER DumpFile - The target path for the final .dmp file. Defaults to %SystemRoot%\MEMORY.DMP. - -.PARAMETER DedicatedDumpFile - The path to a dedicated dump file (e.g., D:\dd.sys) to preserve space on the OS drive. - Use "delete" to remove an existing dedicated dump file configuration. - -.PARAMETER MovePagefile - Switch to relocate pagefile from temporary D: drive to persistent storage (C: or F: drive). - WARNING: This change requires restoration after troubleshooting. The script will log - detailed restoration instructions including the original pagefile location. - -.PARAMETER ConfigureAutomaticReboot - Switch to configure automatic reboot after system crash (BootStatusPolicy=1). - By default, automatic reboot is NOT configured. Enable this parameter to opt-in. - Useful for production systems, but may not be desired on Citrix VMs or other - specialized environments. - -.PARAMETER EnableDebugDefaults - Applies local test defaults only when set to true and only for values not provided - by runtime parameters. + 1. Enumerates attached partitions via Get-Disk-Partitions to locate the BCD store and OS loader. + OS detection accepts either winload.exe or winload.efi. + 1a. For Gen2 disks where the EFI partition has no drive letter, uses diskpart to + temporarily assign one so the BCD store can be accessed. + 2. Identifies the default boot entry GUID from the BCD bootmgr displayorder. + If the default entry cannot be determined, the script logs an explicit warning. + 3. Logs the BCD configuration before any changes are made. + 4. Enables the boot menu with a 5-second timeout (displaybootmenu, timeout). + 5. Enables Boot EMS on the boot manager (bootems yes). + 6. Enables EMS on the default OS entry (ems ON). + 7. Configures EMS settings for serial console (EMSPORT:1, EMSBAUDRATE:115200). + 8. Logs the BCD configuration after changes for verification. + +.NOTES + Name: sac-enabler.ps1 + Author: Tony.Mocanu@Microsoft.com + Requirement: Azure repair VM with an attached Windows OS disk + DeployMode: az vm repair run (with --run-on-repair) + + .VERSION + v1.4: [July 2026] - Restricted execution to repair VM mode (current). + - Uses Get-Disk-Partitions to enumerate Azure virtual disks. + - Detects repair vs. standard context from secondary disks returned by the helper. + - Mounts unlettered Gen2 Windows and EFI partitions temporarily. + - Probes unlettered partitions directly instead of assuming GPT metadata or size. + - Refuses BCD changes when a repair VM context is not detected. + - Fails closed if the repair VM OS disk cannot be identified. + - Filters out the repair VM OS disk before processing attached disks. + v1.3: [July 2026] - Added execution context detection and dual-logging. + - Detected rescue VM mode vs standard mode for context-aware error messages. + - Dual-logs to desktop and plugin directory for az vm repair auto-collection. + - **NEW SAFETY: Pre-flight checks, BCD backup, and post-change verification. + - **NEW SAFETY: Validates GUID format before making BCD edits. + - **NEW SAFETY: Verifies EMS was actually enabled after bcdedit commands. + - Added .ROLLBACK_RECOVERY section with disaster recovery instructions. + - Annotated Log-* wrapper pattern with consolidation note. + v1.2: [May 2026] - Fixed breaking exception when the Hyper-V module is not installed on the host. + - Added explicit checking via Get-Module before executing nested VM discovery. + v1.1: [May 2026] - Included advanced Gen2 unlettered EFI fallback and dynamic drive-letter assignment. + v0.1: [Initial] - Initial commit. Version 1.0 of the script. + + .EXECUTION_CONTEXT + This script is classified as repair-VM-only. It detects repair context when the helper returns + at least one disk other than the repair VM OS disk and refuses BCD changes when none exists. + Use: az vm repair run -g -n --run-id win-sac-on --run-on-repair + The repair VM OS disk is identified from $env:SystemDrive and excluded from all BCD operations. + +.SCENARIO_RECREATION + To recreate a testable scenario on a repair VM with an attached OS disk: + 1. Create a test VM in Azure and attach its OS disk to a repair VM. + 2. The BCD store is on the System Reserved (Gen1) or EFI (Gen2) partition, which + may not have a drive letter. Find it by scanning all volumes (run as Admin): +Get-Volume | Where-Object { $_.DriveLetter } | ForEach-Object { $d = $_.DriveLetter; @("$d`:\boot\bcd","$d`:\efi\microsoft\boot\bcd") | Where-Object { Test-Path $_ } | ForEach-Object { Write-Output "FOUND: $_" } } + If nothing is found, the partition has no drive letter. For System Reserved (Gen1): +Get-Partition | Where-Object { -not $_.DriveLetter -and $_.Size -lt 1GB } | Format-Table DiskNumber, PartitionNumber, Size, Type +Set-Partition -DiskNumber -PartitionNumber -NewDriveLetter S + For EFI partitions (Gen2), Set-Partition won't work -- use diskpart instead: + diskpart + select disk + select partition + assign letter=S + exit + Then check: Test-Path S:\boot\bcd or Test-Path S:\efi\microsoft\boot\bcd + + Example with two attached disks (from Disk Management): + Disk 2 (Gen1): System Reserved (F:) 500 MB | Windows (G:) 126 GB + -> BCD already accessible at F:\boot\bcd + Disk 3 (Gen2): 450 MB (no letter) | EFI (no letter) 99 MB | Windows (H:) 126 GB + -> EFI partitions are protected; use diskpart to assign a letter: + diskpart + select disk 3 + select partition 2 + assign letter=S + exit + -> BCD at S:\efi\microsoft\boot\bcd + + 3. Once you have the BCD path, disable SAC/EMS to simulate a broken VM: + + Gen1 example (F:\boot\bcd): +bcdedit /store F:\boot\bcd /ems "{default}" OFF +bcdedit /store F:\boot\bcd /set "{bootmgr}" bootems no +bcdedit /store F:\boot\bcd /set "{bootmgr}" displaybootmenu no + + Gen2 example (S:\efi\microsoft\boot\bcd): +bcdedit /store S:\efi\microsoft\boot\bcd /ems "{default}" OFF +bcdedit /store S:\efi\microsoft\boot\bcd /set "{bootmgr}" bootems no +bcdedit /store S:\efi\microsoft\boot\bcd /set "{bootmgr}" displaybootmenu no + + 4. Verify EMS is disabled: +bcdedit /store F:\boot\bcd /enum "{default}" +bcdedit /store S:\efi\microsoft\boot\bcd /enum "{default}" + Expected: ems = No or absent, bootems = No or absent. + 5. Run the script. It should enable ems, bootems, displaybootmenu, and emssettings. + 6. Verify all SAC settings are now enabled (see .VERIFICATION section). .EXAMPLE - .\win-dumpconfigurator.ps1 -DumpType kernel -DumpFile "%SystemRoot%\MEMORY.DMP" -ConfigureAutomaticReboot true - Configures kernel dump collection and enables automatic reboot after a crash. + az vm repair run -g -n --run-id win-sac-on --run-on-repair -.EXAMPLE - .\win-dumpconfigurator.ps1 -DumpType full -DedicatedDumpFile "delete" -OneDump true - Applies full dump for a single capture cycle and removes an existing dedicated dump file setting. - -.VERSION - Name: win-dumpconfigurator.ps1 - Version: 1.3 (Critical fixes, safety enhancements, and PowerShell 7 compatibility) - Author: Michael.Smith@microsoft.com for v1.0, Tony.Mocanu@Microsoft.com for the rest. - -.VERSION - v1.3: [July 2026] - CRITICAL FIXES & SAFETY ENHANCEMENTS (current) - - FIXED: Changed [switch] parameters to [string] for CLI compatibility - - FIXED: Added early parameter validation to catch invalid parameters before execution - - FIXED: Migrated all Get-WmiObject to Get-CimInstance (PowerShell 7 compatibility) - - FIXED: Added guard for empty $DumpFile path - - FIXED: Corrected typo 'Procceding' → 'Proceeding' - - FIXED: Added missing Step 9 in output (numbering now 1-11) - - IMPROVED: Better error messaging for invalid parameters via CLI - - IMPROVED: Added comprehensive warnings about pagefile relocation destructiveness - - IMPROVED: Added C: drive free space validation before relocation (20% minimum required) - - IMPROVED: Added local test defaults documentation for local testing without CLI parameters - v1.2: [May 2026] - Updated script - - Added Michael.Smith@microsoft.com as co-author (v1.0 creator) - - Changed log file location to $env:PUBLIC\Desktop for uniformity with other scripts - - Made automatic reboot configuration optional via -ConfigureAutomaticReboot parameter - - Filtered non-actionable kdbgctrl noise from user-facing output. - - Added explicit before/after human-readable dump configuration logging. - - Added strict post-apply verification and status failure on validation mismatch. - v1.1: [May 2026] - Updated script - - Added intelligent dump placement for Azure temporary storage scenarios. - - Added optional pagefile relocation from D: to C: for dump reliability. - - Added CIM-based live pagefile auditing and no-reboot workflow. - v1.0: Initial commit. First working version of the script. -#> +.VERIFICATION + 1. Check the log file for success: +Get-ChildItem "C:\WindowsAzure\Logs\Plugins\Microsoft.Compute.CustomScriptExtension\sac-enabler_*.log" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 | Get-Content + Expected: "BCD AFTER SAC ENABLE" section present and return code 0 ($STATUS_SUCCESS). + 2. Manually verify the BCD store (replace drive letters with the ones found in step 2): -Param( - [Parameter(Mandatory = $false)] - [string]$DumpType = '', + Gen1 (System Reserved on F:): +bcdedit /store F:\boot\bcd /enum "{default}" +bcdedit /store F:\boot\bcd /enum "{bootmgr}" - [Parameter(Mandatory = $false)] - [string]$DumpFile = '', + Gen2 (EFI partition -- use diskpart to assign a letter if needed, e.g. P:): +bcdedit /store P:\efi\microsoft\boot\bcd /enum "{default}" +bcdedit /store P:\efi\microsoft\boot\bcd /enum "{bootmgr}" - [Parameter(Mandatory = $false)] - [string]$DedicatedDumpFile = '', + Expected: ems = Yes on the OS entry, bootems = Yes on bootmgr, + displaybootmenu = Yes, timeout = 5, EMSPORT = 1, EMSBAUDRATE = 115200. - [Parameter(Mandatory = $false)] - [string]$OneDump = '', + NOTE: For Gen2 disks, the script automatically assigns a temporary drive letter + to the EFI System Partition via diskpart if Get-Disk-Partitions did not assign one. + The temporary letter is removed after processing. - [Parameter(Mandatory = $false)] - [string]$MovePagefile = '', - - [Parameter(Mandatory = $false)] - [string]$ConfigureAutomaticReboot = '', +.ROLLBACK_RECOVERY + IF THE VM FAILS TO BOOT AFTER az vm repair restore: + + 1. Boot the VM from the Windows installation media or attach to a repair VM. + 2. Locate the BCD backup file created by the script: + - From repair VM: Look in the mounted disk for *.backup-* files + - Example path: F:\boot\bcd.backup-20260724-153022 (or S:\efi\microsoft\boot\bcd.backup-...) + 3. Restore the BCD from backup: + Gen1 (System Reserved): + bcdedit /store F:\boot\bcd /import F:\boot\bcd.backup-20260724-153022 + + Gen2 (EFI partition with assigned letter): + bcdedit /store S:\efi\microsoft\boot\bcd /import S:\efi\microsoft\boot\bcd.backup-20260724-153022 + + 4. Verify the BCD was restored: + bcdedit /store F:\boot\bcd /enum "{default}" | findstr /I "ems" + Expected: ems = No (or absent) + + 5. Boot the VM. It should start normally without SAC/EMS enabled. + + ALTERNATIVE (if BCD restore doesn't work): + - Use sfc /scannow from Windows Recovery Environment to repair system files + - Use bcdboot.exe to rebuild the BCD store from scratch + - See internal troubleshooting guide: azure-vm-dump-issues.md +#> - [Parameter(Mandatory = $false)] - [string]$EnableDebugDefaults = '' -) +# Initialization (path-validated) +$initPath = Join-Path -Path $PSScriptRoot -ChildPath 'common\setup\init.ps1' +$diskPartitionsPath = Join-Path -Path $PSScriptRoot -ChildPath 'common\helpers\Get-Disk-Partitions-v2.ps1' -# Initialization -$initScriptPath = Join-Path -Path $PSScriptRoot -ChildPath 'common\setup\init.ps1' -if (-not (Test-Path -Path $initScriptPath -PathType Leaf)) { - Write-Error "Missing required dependency: $initScriptPath" +if (-not (Test-Path -Path $initPath -PathType Leaf)) { + Write-Error "Missing required dependency: $initPath" return 1 } -. $initScriptPath - -# LOCAL TEST DEFAULTS: Uncomment the variables below to test locally without --parameters -# You can either: -# 1. Uncomment individual variables and run the script -# 2. Uncomment ONLY $EnableDebugDefaults='true' to activate all defaults -# Example: -# $DumpType = 'full' -# $OneDump = 'false' -# $MovePagefile = 'false' -# $EnableDebugDefaults = 'true -# Then run: .\win-dumpconfigurator.ps1 - -# Normalize incoming parameter names (vm-repair commonly passes lowercase names). -if (-not $DumpType -and $dumptype) { $DumpType = $dumptype } -if (-not $DumpFile -and $dumpfile) { $DumpFile = $dumpfile } -if (-not $DedicatedDumpFile -and $dedicateddumpfile) { $DedicatedDumpFile = $dedicateddumpfile } -if (-not $OneDump -and $onedump) { $OneDump = $onedump } -if (-not $MovePagefile -and $movepagefile) { $MovePagefile = $movepagefile } -if (-not $ConfigureAutomaticReboot -and $configureautomaticreboot) { $ConfigureAutomaticReboot = $configureautomaticreboot } -if (-not $EnableDebugDefaults -and $enabledebugdefaults) { $EnableDebugDefaults = $enabledebugdefaults } - -# Normalize boolean-like string parameters for consistent downstream checks. -$OneDump = "$OneDump".Trim().ToLowerInvariant() -$MovePagefile = "$MovePagefile".Trim().ToLowerInvariant() -$ConfigureAutomaticReboot = "$ConfigureAutomaticReboot".Trim().ToLowerInvariant() -$EnableDebugDefaults = "$EnableDebugDefaults".Trim().ToLowerInvariant() - -# Optional local-only defaults for troubleshooting without --parameters. -$debugDefaultsEnabled = $EnableDebugDefaults -eq $true -or $EnableDebugDefaults -eq 'true' -if ($debugDefaultsEnabled) { - if (-not $DumpType) { $DumpType = 'full' } - if (-not $DumpFile) { $DumpFile = '%SystemRoot%\Memory.dmp' } - if (-not $DedicatedDumpFile) { $DedicatedDumpFile = 'Z:\dd.sys' } - if (-not $OneDump) { $OneDump = 'false' } - if (-not $MovePagefile) { $MovePagefile = 'true' } - Log-Info "EnableDebugDefaults is active. Applying local fallback defaults for missing parameters." +. $initPath + +if (-not (Test-Path -Path $diskPartitionsPath -PathType Leaf)) { + Log-Error "Missing required dependency: $diskPartitionsPath" + return $STATUS_ERROR } -# === PARAMETER VALIDATION (EARLY FAIL) === -# Validate all parameters BEFORE any operations -$validDumpTypes = @('active', 'automatic', 'full', 'kernel', 'mini') - -# 1. Validate DumpType if provided -$userProvidedDumpType = -not [string]::IsNullOrWhiteSpace("$DumpType") -if (-not $userProvidedDumpType) { - $DumpType = 'full' -} else { - if ($DumpType -notin $validDumpTypes) { - throw "Invalid DumpType '$DumpType'. Valid values: $($validDumpTypes -join ', '). Check your --parameters syntax." - } +. $diskPartitionsPath + +if (-not (Get-Command -Name Get-Disk-Partitions -CommandType Function -ErrorAction SilentlyContinue)) { + Log-Error "Dependency did not define the required Get-Disk-Partitions function: $diskPartitionsPath" + return $STATUS_ERROR } -# 2. Validate OneDump is boolean-compatible -if ($OneDump -notin @('true', 'false', $true, $false, '')) { - throw "Invalid OneDump '$OneDump'. Must be 'true' or 'false'." +function Get-SacExecutionContext { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]]$TargetDiskGroups + ) + + if ($TargetDiskGroups.Count -gt 0) { + return 'REPAIR_VM' + } + + return 'STANDARD_VM' } -# 3. Validate MovePagefile is boolean-compatible -if ($MovePagefile -notin @('true', 'false', $true, $false, '')) { - throw "Invalid MovePagefile '$MovePagefile'. Must be 'true' or 'false'." +function Get-AvailableTempDriveLetter { + $usedLetters = @(Get-Volume -ErrorAction SilentlyContinue | Where-Object { $_.DriveLetter } | Select-Object -ExpandProperty DriveLetter) + foreach ($letter in @('Z','Y','X','W','V','U','T','S','R','Q')) { + if ($letter -notin $usedLetters -and -not (Test-Path -Path "${letter}:\")) { + return $letter + } + } + + return $null } -# 4. Validate ConfigureAutomaticReboot is boolean-compatible -if ($ConfigureAutomaticReboot -notin @('true', 'false', $true, $false, '')) { - throw "Invalid ConfigureAutomaticReboot '$ConfigureAutomaticReboot'. Must be 'true' or 'false'." +function Test-SacGptType { + param( + [AllowNull()] + [object]$ActualType, + + [Parameter(Mandatory = $true)] + [string]$ExpectedType + ) + + $actual = ([string]$ActualType).Trim().Trim('{', '}') + $expected = $ExpectedType.Trim().Trim('{', '}') + return $actual -eq $expected } -$script_final_status = $STATUS_ERROR +# =========================================== +# Logging Setup (Dual-Write: Desktop + Plugin Directory) +# =========================================== +$scriptName = [System.IO.Path]::GetFileNameWithoutExtension($MyInvocation.MyCommand.Name) +$runTimestamp = Get-Date -Format 'yyyyMMdd-HHmmss' -function Get-DumpTypeLabel { - param($Value) +# Desktop log (for local inspection) +$desktopLogDir = Join-Path -Path $env:PUBLIC -ChildPath ("Desktop\\{0}-run-{1}" -f $scriptName, $runTimestamp) +$desktopLogFile = Join-Path -Path $desktopLogDir -ChildPath ("{0}-{1}.log" -f $scriptName, $runTimestamp) - if ($null -eq $Value) { return "NOT FOUND" } +# Plugin directory log (for az vm repair auto-collection) +$pluginLogDir = 'C:\WindowsAzure\Logs\Plugins\Microsoft.Compute.CustomScriptExtension\' +$pluginLogFile = Join-Path -Path $pluginLogDir -ChildPath ("{0}_{1}.log" -f $scriptName, $runTimestamp) - $intValue = [int]$Value - switch ($intValue) { - 0 { return "Disabled/None (0)" } - 1 { return "Complete/Full (1)" } - 2 { return "Kernel (2)" } - 3 { return "Small/Minidump (3)" } - 7 { return "Automatic (7)" } - default { return "Unknown ($intValue)" } +# Ensure directories exist +foreach ($logDirectory in @($desktopLogDir, $pluginLogDir)) { + if (-not (Test-Path -Path $logDirectory -PathType Container)) { + try { + New-Item -Path $logDirectory -ItemType Directory -Force -ErrorAction Stop | Out-Null + } + catch { + # Plugin dir may not be creatable; continue with desktop log only + if ($logDirectory -eq $pluginLogDir) { + [Console]::Error.WriteLine("[Warning] Could not create plugin log directory '$logDirectory': $($_.Exception.Message). Will use desktop log only.") + } else { + throw + } + } } } -function Get-KdbgctrlOutputSummary { - param($OutputLines) +# Initialize log files +@($desktopLogFile, $pluginLogFile) | Where-Object { -not (Test-Path -Path $_ -PathType Leaf) } | ForEach-Object { + try { + New-Item -Path $_ -ItemType File -Force -ErrorAction Stop | Out-Null + } + catch { + # Log creation failure is not critical; logging will append if file doesn't exist + } +} - $noisePatterns = @( - "Dump type from system registry is Invalid", - "lastError after QueryDosDevice call is 3" - ) +# For backward compatibility with existing Log-* function references +$logFilePath = $desktopLogFile - $allLines = @($OutputLines | ForEach-Object { "$($_)".Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) - $filtered = @() - $suppressed = @() - foreach ($line in $allLines) { - $isNoise = $false - foreach ($pattern in $noisePatterns) { - if ($line -like "*$pattern*") { - $isNoise = $true - break - } - } +# =========================================== +# Log Wrapper Functions (Consolidation Note) +# =========================================== +# NOTE: This Log-* wrapper pattern (duplicated across sac-enabler.ps1 and other scripts) +# should be consolidated into a shared helper module (e.g., common\helpers\Logging-Helper.ps1) +# to avoid duplication. All scripts should source a single centralized logging provider. + +$script:OriginalLogOutput = (Get-Command Log-Output -CommandType Function).ScriptBlock +$script:OriginalLogInfo = (Get-Command Log-Info -CommandType Function).ScriptBlock +$script:OriginalLogWarning = (Get-Command Log-Warning -CommandType Function).ScriptBlock +$script:OriginalLogError = (Get-Command Log-Error -CommandType Function).ScriptBlock +$script:OriginalLogDebug = (Get-Command Log-Debug -CommandType Function).ScriptBlock + +function Write-DesktopLogLine { + param( + [Parameter(Mandatory = $true)] + [string]$Level, + + [Parameter(Mandatory = $true)] + [PSObject[]]$Message + ) - if ($isNoise) { - $suppressed += $line - } else { - $filtered += $line + try { + $renderedMessage = ($Message | ForEach-Object { "$_" }) -join ' ' + $line = "[{0} {1}]{2}" -f $Level, (Get-Date), $renderedMessage + + # Write to both log files (desktop and plugin directory) + Add-Content -Path $desktopLogFile -Value $line -Encoding UTF8 -ErrorAction Stop + if (Test-Path -Path $pluginLogDir -PathType Container) { + Add-Content -Path $pluginLogFile -Value $line -Encoding UTF8 -ErrorAction Stop } } - - return @{ - All = $allLines - Filtered = $filtered - Suppressed = $suppressed + catch { + if ($script:OriginalLogWarning) { + & $script:OriginalLogWarning -message "Failed to append to log files: $($_.Exception.Message)" + } + else { + [Console]::Error.WriteLine("[Warning $(Get-Date)]Failed to append to log files: $($_.Exception.Message)") + } } } -function Get-AuditSnapshot { - param($Title) - - $Path = "HKLM:\SYSTEM\CurrentControlSet\Control\CrashControl" - $MMPath = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management" - $RelPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Reliability" - - # Read core dump settings - $NMI = (Get-ItemProperty -Path $Path -ErrorAction SilentlyContinue).NMICrashDump - $BSP = (Get-ItemProperty -Path $RelPath -ErrorAction SilentlyContinue).BootStatusPolicy - - # PAGEFILE DETECTION: Query CIM for the active configuration. - $ConfiguredPageFiles = Get-CimInstance -ClassName Win32_PageFileSetting -ErrorAction SilentlyContinue | - Select-Object -ExpandProperty Name - - Log-Output ">>> $Title <<<" - $crashDumpEnabled = (Get-ItemProperty -Path $Path).CrashDumpEnabled +function Log-Output { + Param([Parameter(Mandatory = $true)][PSObject[]]$message) + & $script:OriginalLogOutput -message $message + Write-DesktopLogLine -Level 'Output' -Message $message +} - $currentDumpFile = (Get-ItemProperty -Path $Path -ErrorAction SilentlyContinue).DumpFile - $currentDedicatedDumpFile = (Get-ItemProperty -Path $Path -ErrorAction SilentlyContinue).DedicatedDumpFile +function Log-Info { + Param([Parameter(Mandatory = $true)][PSObject[]]$message) + & $script:OriginalLogInfo -message $message + Write-DesktopLogLine -Level 'Info' -Message $message +} - Log-Output "DumpFile : $(if([string]::IsNullOrWhiteSpace("$currentDumpFile")){"NOT FOUND"}else{$currentDumpFile})" - Log-Output "DedicatedDumpFile : $(if([string]::IsNullOrWhiteSpace("$currentDedicatedDumpFile")){"NOT FOUND"}else{$currentDedicatedDumpFile})" - Log-Output "CrashDumpEnabled : $(Get-DumpTypeLabel -Value $crashDumpEnabled)" - Log-Output "NMICrashDump : $(if($null -eq $NMI){"NOT FOUND"}else{$NMI})" - Log-Output "BootStatusPolicy : $(if($null -eq $BSP){"NOT FOUND"}else{$BSP})" - - if ($ConfiguredPageFiles) { - Log-Output "ConfiguredPageFiles (LIVE): $($ConfiguredPageFiles -join ', ')" - } else { - # Fallback to registry if WMI returns nothing (unusual) - $PFile = (Get-ItemProperty -Path $MMPath -ErrorAction SilentlyContinue).ExistingPageFiles - Log-Output "ExistingPageFiles : $(if($null -eq $PFile){"NOT FOUND"}else{$PFile})" - } +function Log-Warning { + Param([Parameter(Mandatory = $true)][PSObject[]]$message) + & $script:OriginalLogWarning -message $message + Write-DesktopLogLine -Level 'Warning' -Message $message } -try { - # Step 1 - Audit BEFORE - Get-AuditSnapshot "AUDITING SETTINGS (BEFORE)" - - $CrashCtrlPath = "HKLM:\SYSTEM\CurrentControlSet\Control\CrashControl" - $crashControlBackupPath = Join-Path -Path $env:PUBLIC -ChildPath ("Desktop\\CrashControl-backup-{0}.reg" -f (Get-Date -Format 'yyyyMMdd-HHmmss')) - $backupResult = & reg.exe export "HKLM\SYSTEM\CurrentControlSet\Control\CrashControl" $crashControlBackupPath /y 2>&1 - if ($LASTEXITCODE -eq 0) { - Log-Info "Created registry backup: $crashControlBackupPath" - } - else { - Log-Warning "Could not create registry backup. Output: $($backupResult -join ' | ')" - } +function Log-Error { + Param([Parameter(Mandatory = $true)][PSObject[]]$message) + & $script:OriginalLogError -message $message + Write-DesktopLogLine -Level 'Error' -Message $message +} - $initialValue = (Get-ItemProperty -Path $CrashCtrlPath).CrashDumpEnabled - $dumpTypeMap = @{ 'full' = 1; 'kernel' = 2; 'mini' = 3; 'automatic' = 7; 'active' = 1 } - $requestedDumpValue = $dumpTypeMap[$DumpType] - $verificationFailed = $false +function Log-Debug { + Param([Parameter(Mandatory = $true)][PSObject[]]$message) + & $script:OriginalLogDebug -message $message + Write-DesktopLogLine -Level 'Debug' -Message $message +} - Log-Output "Current dump configuration: $(Get-DumpTypeLabel -Value $initialValue)" - Log-Output "Requested dump type: $DumpType ($(Get-DumpTypeLabel -Value $requestedDumpValue))" - Log-Output "Requested DumpFile: $(if([string]::IsNullOrWhiteSpace("$DumpFile")){"NOT SPECIFIED"}else{$DumpFile})" - Log-Output "Requested DedicatedDumpFile: $(if([string]::IsNullOrWhiteSpace("$DedicatedDumpFile")){"NOT SPECIFIED"}else{$DedicatedDumpFile})" +$logFile = $logFilePath +Log-Info "Dual logging initialized - Desktop: $desktopLogFile | Plugin: $pluginLogFile" +Log-Info "Script classification: REPAIR_VM_ONLY" - # Step 2 - Enable NMI - Set-ItemProperty -Path $CrashCtrlPath -Name NMICrashDump -Value 1 -Type DWord +# Status Tracking +$script_final_status = $STATUS_ERROR +$failureReason = 'Script could not find a valid attached OS disk to enable SAC. Verify the disk is attached to the repair VM.' +$detectedExecutionContext = 'UNDETERMINED' +$processedCount = 0 +$skippedCount = 0 +$failedCount = 0 +$changedCount = 0 - # Step 3 - Configure automatic reboot (optional) - if ($ConfigureAutomaticReboot -eq $true -or $ConfigureAutomaticReboot -eq 'true') { - Set-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Reliability" -Name BootStatusPolicy -Value 1 -Type DWord - Log-Info "Automatic reboot on crash configured (BootStatusPolicy=1)." - } - else { - Log-Info "Automatic reboot on crash NOT configured. Use -ConfigureAutomaticReboot to enable." - } +Log-Info "Starting repair-only SAC enabler. Logs: $logFile" - # Step 4 - Pagefile Detection for Smart Placement - $MMPath = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management" - $currentPFiles = Get-CimInstance -ClassName Win32_PageFileSetting | Select-Object -ExpandProperty Name - $pagefileOnTempDrive = $false - $originalPagefileLocations = $currentPFiles - $pagefileWasMoved = $false - $pagefileScanProcessed = 0 - $pagefileScanTempMatches = 0 - - foreach ($pf in $currentPFiles) { - $pagefileScanProcessed++ - Log-Debug "Detected pagefile setting: $pf" - if ($pf -like "D:*" -or $pf -like "*D:\*") { - $pagefileOnTempDrive = $true - $pagefileScanTempMatches++ - Log-Warning "Pagefile detected on D: drive: $pf" - break +try { + # Optional: Clean up orphaned temp drive letters from previous failed runs + # This helps prevent lingering mount points from blocking EFI partition access + $orphanedLetters = @() + try { + Get-Volume -ErrorAction SilentlyContinue | Where-Object { $_.DriveLetter -and -not $_.DriveType -eq 'Unknown' } | ForEach-Object { + $letter = $_.DriveLetter + $volumePath = "${letter}:\" + if (-not (Test-Path -Path $volumePath)) { + $orphanedLetters += $letter + } + } + if ($orphanedLetters.Count -gt 0) { + Log-Info "Found potentially orphaned drive letters (may be harmless): $($orphanedLetters -join ', '). Continuing..." } } - Log-Info "Pagefile scan summary: processed=$pagefileScanProcessed tempDriveMatches=$pagefileScanTempMatches" - - # INTELLIGENT DUMP PLACEMENT - # Respect explicit user-provided values exactly as passed. - if ($DumpFile) { - Set-ItemProperty -Path $CrashCtrlPath -Name DumpFile -Value $DumpFile - Log-Info "Applied user-provided DumpFile: $DumpFile" + catch { + # Orphan detection is optional; don't block on failure + Log-Debug "Orphan detection encountered an error (non-critical): $($_.Exception.Message)" } - else { - if ($pagefileOnTempDrive) { - Set-ItemProperty -Path $CrashCtrlPath -Name DumpFile -Value "%SystemRoot%\MEMORY.DMP" - # Only apply fallback DedicatedDumpFile when user did not provide a value. - if (-not $DedicatedDumpFile) { - Set-ItemProperty -Path $CrashCtrlPath -Name DedicatedDumpFile -Value "C:\dd.sys" + + # Check if the Hyper-V module is available before performing nested VM checks + if (Get-Module -ListAvailable -Name Hyper-V) { + $guestHyperVVirtualMachine = Get-VM -ErrorAction SilentlyContinue -WarningAction SilentlyContinue + if ($guestHyperVVirtualMachine) { + if ($guestHyperVVirtualMachine.State -eq 'Running') { + Log-Info "Stopping nested guest VM $($guestHyperVVirtualMachine.VMName)" + try { + Stop-VM $guestHyperVVirtualMachine -ErrorAction Stop -Force + } + catch { + Log-Warning "Failed to stop nested guest VM, will continue but may have limited success" + } } - } else { - Set-ItemProperty -Path $CrashCtrlPath -Name DumpFile -Value "%SystemRoot%\MEMORY.DMP" } + } else { + Log-Info "Hyper-V PowerShell module is not available on this host. Skipping nested VM validation." } - # Step 5 - OPTIONAL PAGEFILE RELOCATION - if (($MovePagefile -eq $true -or $MovePagefile -eq 'true') -and $pagefileOnTempDrive) { - Log-Warning "PAGEFILE RELOCATION REQUESTED" - Log-Warning "⚠️ IMPORTANT: Pagefile relocation from D: to C: is DESTRUCTIVE and NOT EASILY REVERSIBLE:" - Log-Warning " 1. If C: drive runs out of space, the VM may crash" - Log-Warning " 2. To restore pagefile to D: after troubleshooting, manual intervention or script re-run is required" - Log-Warning " 3. Ensure C: drive has sufficient free space (recommend minimum 50% free) before proceeding" - Log-Warning " 4. For production VMs, consider scheduling this change during maintenance window" + # Step 1 - Enumerate partitions to locate the BCD store and OS loader + $partitionlist = @(Get-Disk-Partitions) + if ($partitionlist.Count -eq 0) { + throw 'Get-Disk-Partitions returned no partitions from Azure virtual disks.' + } + + $discoveredDiskNumbers = @($partitionlist | Select-Object -ExpandProperty DiskNumber -Unique) + Log-Info "Get-Disk-Partitions discovered disk numbers: $($discoveredDiskNumbers -join ', ')" + $repairDrive = $env:SystemDrive -replace ':', '' + Log-Info 'Enumerating partitions to enable SAC...' + + # SAFETY CHECK: Ensure we're not operating on the repair VM's own disk + $repairOsPartition = Get-Partition -DriveLetter $repairDrive -ErrorAction Stop | Select-Object -First 1 + if ($null -eq $repairOsPartition -or $null -eq $repairOsPartition.DiskNumber) { + throw "CRITICAL SAFETY CHECK FAILED: Could not identify the repair VM OS disk from $($env:SystemDrive)." + } + + $repairDiskNumber = [int]$repairOsPartition.DiskNumber + Log-Info "Repair VM OS disk identified as Disk $repairDiskNumber" + + $targetDiskGroups = @($partitionlist | Group-Object DiskNumber | Where-Object { [int]$_.Name -ne $repairDiskNumber }) + $detectedExecutionContext = Get-SacExecutionContext -TargetDiskGroups $targetDiskGroups + Log-Info "Detected execution context: $detectedExecutionContext" + + if ($detectedExecutionContext -ne 'REPAIR_VM') { + Log-Error "[STANDARD_VM] REPAIR-ONLY SCRIPT: The helper returned no secondary disk." + Log-Error "[STANDARD_VM] Run this script with az vm repair run and --run-on-repair. No BCD changes were attempted." + $script_final_status = $STATUS_ERROR + $failureReason = 'Standard VM context detected, or the repair VM has no accessible attached Windows OS disk.' + } + else + { + foreach ( $partitionGroup in $targetDiskGroups ) + { + $processedCount++ + $diskChanged = $false + $diskFailed = $false + $diskNumber = $partitionGroup.Name + $isBcdPath = $false + $bcdPath = '' + $isOsPath = $false + $tempEfiLetter = $null + $tempEfiDiskNum = $null + $tempEfiPartNum = $null + $tempOsLetter = $null + $tempOsDiskNum = $null + $tempOsPartNum = $null + $bcdBackup = $null + + Log-Info "Processing Disk $diskNumber" try { - # FIX: Explicitly target C: if logic loop fails, bypass the CIM free space comparison bug - $cDrive = Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DeviceID='C:'" - - if ($null -ne $cDrive) { - $targetPagefile = "C:\pagefile.sys" - - # Validate C: drive has sufficient free space - $cDriveFreeSpaceGB = [math]::Round($cDrive.FreeSpace / 1GB, 2) - $cDriveTotalSpaceGB = [math]::Round($cDrive.Size / 1GB, 2) - $cDriveFreePercent = [math]::Round(($cDrive.FreeSpace / $cDrive.Size) * 100, 0) - - Log-Info "C: Drive space status: $cDriveFreeSpaceGB GB free of $cDriveTotalSpaceGB GB ($cDriveFreePercent% free)" - - if ($cDriveFreePercent -lt 20) { - Log-Error "C: Drive free space is below 20% ($cDriveFreePercent%). Relocation aborted to prevent VM crash." - throw "Insufficient C: drive free space. Minimum 20% recommended, current: $cDriveFreePercent%" + + # Scan each drive for BCD store and Windows OS loader + ForEach ($drive in $partitionGroup.Group | Select-Object -ExpandProperty DriveLetter | Where-Object { $_ }) + { + # The repair disk was filtered out above; retain this drive-level safety check. + if ($drive -eq $repairDrive) { continue } + + if ( -not $isBcdPath ) + { + $bcdPath = $drive + ':\boot\bcd' + $isBcdPath = Test-Path $bcdPath + if ( -not $isBcdPath ) + { + $bcdPath = $drive + ':\efi\microsoft\boot\bcd' + $isBcdPath = Test-Path $bcdPath + } + } + if (-not $isOsPath) + { + $winloadExePath = $drive + ':\windows\system32\winload.exe' + $winloadEfiPath = $drive + ':\windows\system32\winload.efi' + $isOsPath = (Test-Path $winloadExePath) -or (Test-Path $winloadEfiPath) + } + } + + # Gen2 fallback: probe unlettered partitions directly for a Windows loader. + if (-not $isOsPath) + { + $diskNum = [int]$partitionGroup.Name + $diskPartitions = @(Get-Partition -DiskNumber $diskNum -ErrorAction SilentlyContinue) + foreach ($partition in $diskPartitions) { + $driveDescription = if ($partition.DriveLetter) { "$($partition.DriveLetter):" } else { '' } + $sizeMb = [math]::Round($partition.Size / 1MB) + Log-Info "Disk $diskNum partition $($partition.PartitionNumber): drive=$driveDescription sizeMB=$sizeMb type=$($partition.Type) gptType=$($partition.GptType)" + } + + $unletteredOsCandidates = @($diskPartitions | Where-Object { + -not $_.DriveLetter -or $_.DriveLetter -eq [char]0 + } | Sort-Object Size -Descending) + Log-Info "Disk ${diskNum}: probing $($unletteredOsCandidates.Count) unlettered partition(s) for a Windows loader." + + foreach ($osCandidate in $unletteredOsCandidates) + { + $candidateLetter = Get-AvailableTempDriveLetter + if (-not $candidateLetter) { + Log-Warning "No available drive letter for an unlettered Windows partition on Disk $diskNum" + break } - - Log-Info "C: Drive detected via CIM. Proceeding with relocation..." - - $pageFileSettings = Get-CimInstance -ClassName Win32_PageFileSetting - $pagefileDeleteProcessed = 0 - $pagefileDeleteDeleted = 0 - $pagefileDeleteFailed = 0 - $pagefileDeleteSkipped = 0 - - foreach ($pf in $pageFileSettings) { - $pagefileDeleteProcessed++ - if ($pf.Name -like "D:*" -or $pf.Name -like "*D:\*") { - try { - Log-Info "Deleting current pagefile instance: $($pf.Name)" - $pf | Remove-CimInstance -ErrorAction Stop - $pagefileDeleteDeleted++ - } - catch { - $pagefileDeleteFailed++ - Log-Warning "Failed to delete pagefile instance '$($pf.Name)': $($_.Exception.Message)" + + $candidatePartNum = $osCandidate.PartitionNumber + Log-Info "Assigning temp letter ${candidateLetter}: to Disk $diskNum Partition $candidatePartNum (Windows candidate)..." + $dpOsAssign = @("select disk $diskNum", "select partition $candidatePartNum", "assign letter=$candidateLetter") + $dpOsAssignOut = $dpOsAssign | diskpart 2>&1 + foreach ($line in @($dpOsAssignOut)) { if ($line) { Log-Output "[diskpart][os-assign] $line" } } + $tempOsLetter = $candidateLetter + $tempOsDiskNum = $diskNum + $tempOsPartNum = $candidatePartNum + Start-Sleep -Seconds 2 + + $winloadExePath = "${candidateLetter}:\windows\system32\winload.exe" + $winloadEfiPath = "${candidateLetter}:\windows\system32\winload.efi" + $isOsPath = (Test-Path -Path $winloadExePath) -or (Test-Path -Path $winloadEfiPath) + if ($isOsPath) { + Log-Info "Found Windows OS partition at ${candidateLetter}: on Disk $diskNum" + break + } + + Log-Info "No Windows loader found at ${candidateLetter}:, removing letter..." + $dpOsRemove = @("select disk $diskNum", "select partition $candidatePartNum", "remove letter=$candidateLetter") + $dpOsRemoveOut = $dpOsRemove | diskpart 2>&1 + foreach ($line in @($dpOsRemoveOut)) { if ($line) { Log-Output "[diskpart][os-remove] $line" } } + $tempOsLetter = $null + $tempOsDiskNum = $null + $tempOsPartNum = $null + } + } + + # Gen2 EFI fallback: if OS found but no BCD, discover unlettered EFI partition + if (-not $isBcdPath -and $isOsPath) + { + $diskNum = [int]$partitionGroup.Name + if ($diskNum -ne $repairDiskNumber) + { + Log-Info "Disk ${diskNum}: OS found but no BCD - checking for unlettered EFI partition (Gen2)..." + $efiGptType = 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b' + $efiParts = Get-Partition -DiskNumber $diskNum -ErrorAction SilentlyContinue | Where-Object { + (Test-SacGptType -ActualType $_.GptType -ExpectedType $efiGptType) -and + (-not $_.DriveLetter -or $_.DriveLetter -eq [char]0) + } + if ($efiParts) + { + $tempLetter = Get-AvailableTempDriveLetter + if ($tempLetter) + { + foreach ($ep in $efiParts) + { + $pn = $ep.PartitionNumber + Log-Info "Assigning temp letter ${tempLetter}: to Disk $diskNum Partition $pn (EFI)..." + $dpLines = @("select disk $diskNum", "select partition $pn", "assign letter=$tempLetter") + $dpAssignOut = $dpLines | diskpart 2>&1 + foreach ($line in @($dpAssignOut)) { if ($line) { Log-Output "[diskpart][assign] $line" } } + $tempEfiLetter = $tempLetter + $tempEfiDiskNum = $diskNum + $tempEfiPartNum = $pn + Start-Sleep -Seconds 2 + $bcdPath = "${tempLetter}:\efi\microsoft\boot\bcd" + $isBcdPath = Test-Path $bcdPath + if ($isBcdPath) + { + Log-Info "Found Gen2 BCD store at $bcdPath" + break + } + else + { + Log-Info "No BCD at $bcdPath, removing letter..." + $dpRemove = @("select disk $diskNum", "select partition $pn", "remove letter=$tempLetter") + $dpRemoveOut = $dpRemove | diskpart 2>&1 + foreach ($line in @($dpRemoveOut)) { if ($line) { Log-Output "[diskpart][remove] $line" } } + $tempEfiLetter = $null + $tempEfiDiskNum = $null + $tempEfiPartNum = $null + } } } - else { - $pagefileDeleteSkipped++ + else + { + Log-Warning "No available drive letter for EFI partition on Disk $diskNum" } } - Log-Info "Pagefile delete summary: processed=$pagefileDeleteProcessed deleted=$pagefileDeleteDeleted skipped=$pagefileDeleteSkipped failed=$pagefileDeleteFailed" + } + } - if ($pagefileDeleteFailed -gt 0) { - throw "One or more D: pagefile entries could not be removed. See logs for details." + # Apply SAC changes if both BCD and OS loader were found + if ( $isBcdPath -and $isOsPath ) + { + # Step 2 - Identify the default boot entry GUID + $bcdout = bcdedit /store $bcdPath /enum bootmgr /v + $defaultLine = $bcdout | Select-String 'displayorder' | Select-Object -First 1 + + if (-not $defaultLine) + { + $failureReason = "Could not locate a displayorder entry in boot manager output for $bcdPath." + Log-Warning "Could not locate a displayorder entry in boot manager output for $bcdPath. Unable to determine the default boot entry." + $diskFailed = $true + } + elseif ($defaultLine -match '\{([^}]+)\}') { + $defaultId = $matches[0] + + # VALIDATION: Confirm we have a valid GUID + if ($defaultId -notmatch '^\{[0-9a-f\-]{36}\}$') { + Log-Error "Invalid boot entry GUID format: $defaultId. This may indicate a corrupted BCD store." + $diskFailed = $true } + else + { + # VALIDATION: Backup BCD store before any modifications + $bcdBackup = $bcdPath + '.backup-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + try { + Copy-Item -Path $bcdPath -Destination $bcdBackup -Force -ErrorAction Stop + Log-Info "BCD backup created at: $bcdBackup" + } + catch { + Log-Warning "Could not create BCD backup: $($_.Exception.Message). Proceeding with caution." + } - $newPageFile = New-CimInstance -ClassName Win32_PageFileSetting -Property @{ - Name = $targetPagefile - InitialSize = 0 - MaximumSize = 0 - } -ErrorAction Stop + # Step 3 - Log BCD configuration before changes + Log-Output "--- BCD BEFORE SAC ENABLE ---" + $beforeBcd = bcdedit /store $bcdPath /enum $defaultId + foreach ($line in $beforeBcd) { if ($line.Trim()) { Log-Output $line } } - if ($null -ne $newPageFile) { - $pagefileWasMoved = $true - Log-Info "Successfully updated WMI configuration to: $targetPagefile" - } - } else { - throw "C: drive could not be verified via CIM. Relocation aborted." - } - } - catch { - Log-Error "Failed to relocate pagefile: $($_.Exception.Message)" - } - } + # Steps 4-7 - Enable boot menu, Boot EMS, EMS on OS entry, and EMS serial settings + Log-Info "Applying SAC and EMS configurations to BCD: $bcdPath" + $setBootMenuOut = bcdedit /store $bcdPath /set "{bootmgr}" displaybootmenu yes 2>&1 + foreach ($line in @($setBootMenuOut)) { if ($line) { Log-Output "[bcdedit][displaybootmenu] $line" } } - # Step 6 - DedicatedDumpFile - if ($DedicatedDumpFile -eq "delete") { - Remove-ItemProperty -Path $CrashCtrlPath -Name DedicatedDumpFile -ErrorAction SilentlyContinue - Log-Info "Applied user request: DedicatedDumpFile deleted." - } - elseif ($DedicatedDumpFile) { - Set-ItemProperty -Path $CrashCtrlPath -Name DedicatedDumpFile -Value $DedicatedDumpFile - Log-Info "Applied user-provided DedicatedDumpFile: $DedicatedDumpFile" - } + $setTimeoutOut = bcdedit /store $bcdPath /set "{bootmgr}" timeout 5 2>&1 + foreach ($line in @($setTimeoutOut)) { if ($line) { Log-Output "[bcdedit][timeout] $line" } } - # Step 7 - Guard for empty DumpFile (ensure valid path before kdbgctrl) - if ([string]::IsNullOrEmpty($DumpFile)) { - Log-Warning "DumpFile is empty. Using Windows default: %SystemRoot%\\MEMORY.DMP" - $DumpFile = "%SystemRoot%\\MEMORY.DMP" - Set-ItemProperty -Path $CrashCtrlPath -Name DumpFile -Value $DumpFile - } + $setBootEmsOut = bcdedit /store $bcdPath /set "{bootmgr}" bootems yes 2>&1 + foreach ($line in @($setBootEmsOut)) { if ($line) { Log-Output "[bcdedit][bootems] $line" } } - # Step 8 - Apply to LIVE KERNEL - Log-Info "Applying dump type '$DumpType' via kdbgctrl..." - Set-ItemProperty -Path $CrashCtrlPath -Name CrashDumpEnabled -Value 0 - - $toolPath = Join-Path -Path $PSScriptRoot -ChildPath 'common\tools\kdbgctrl.exe' - if (-not (Test-Path -Path $toolPath -PathType Leaf)) { - throw "Missing required dependency: $toolPath" - } - $kdbgResult = & $toolPath -sd $DumpType 2>&1 - $kdbgExitCode = $LASTEXITCODE - $parsedKdbg = Get-KdbgctrlOutputSummary -OutputLines $kdbgResult + $setEmsOut = bcdedit /store $bcdPath /ems $defaultId ON 2>&1 + foreach ($line in @($setEmsOut)) { if ($line) { Log-Output "[bcdedit][ems] $line" } } - if ($parsedKdbg.Suppressed.Count -gt 0) { - Log-Debug "Suppressed non-actionable kdbgctrl messages: $($parsedKdbg.Suppressed -join ' | ')" - } + $setEmsSettingsOut = bcdedit /store $bcdPath /emssettings EMSPORT:1 EMSBAUDRATE:115200 2>&1 + foreach ($line in @($setEmsSettingsOut)) { if ($line) { Log-Output "[bcdedit][emssettings] $line" } } - if ($kdbgExitCode -ne 0) { - $verificationFailed = $true - Log-Error "kdbgctrl failed with exit code $kdbgExitCode. Output: $($parsedKdbg.Filtered -join ' | ')" - } - else { - $successMatched = $false - foreach ($line in $parsedKdbg.Filtered) { - if ($line -match '(?i)success|successfully updated dump settings') { - $successMatched = $true - break + # VALIDATION: Verify BCD changes were applied successfully + Log-Info "Verifying BCD changes..." + $verifyBcd = bcdedit /store $bcdPath /enum $defaultId + $emsEnabled = $verifyBcd | Select-String 'ems' | Select-String 'Yes' + if (-not $emsEnabled) { + Log-Error "CRITICAL: EMS verification failed! BCD may be corrupted. Restore from backup: $bcdBackup" + $diskFailed = $true + } + else { + # Step 8 - Log BCD configuration after changes for verification + Log-Output "--- BCD AFTER SAC ENABLE ---" + $afterBcd = bcdedit /store $bcdPath /enum $defaultId + foreach ($line in $afterBcd) { if ($line.Trim()) { Log-Output $line } } + + $script_final_status = $STATUS_SUCCESS + $diskChanged = $true + } + } + } + else + { + $failureReason = "Displayorder entry was found but no boot entry GUID could be parsed for $bcdPath." + Log-Warning "Displayorder entry was found but no boot entry GUID could be parsed for $bcdPath. Raw line: $($defaultLine.Line)" + $diskFailed = $true } } - - if ($successMatched) { - Log-Output "Successfully updated dump settings to '$DumpType' via kdbgctrl." + else { + Log-Info "Disk $diskNumber skipped: no valid BCD + OS loader combination was found." } - elseif ($parsedKdbg.Filtered.Count -gt 0) { - Log-Warning "kdbgctrl completed with unexpected output: $($parsedKdbg.Filtered -join ' | ')" + } catch { + $diskFailed = $true + $failureReason = "Disk $diskNumber failed with exception: $($_.Exception.Message)" + Log-Error $failureReason + if ($_.InvocationInfo -and $_.InvocationInfo.PositionMessage) { + Log-Error "Disk $diskNumber failure context: $($_.InvocationInfo.PositionMessage)" + } + } finally { + + # Clean up temporary EFI drive letter if one was assigned + if ($tempEfiLetter) + { + Log-Info "Removing temp letter ${tempEfiLetter}: from Disk $tempEfiDiskNum Partition $tempEfiPartNum" + $dpClean = @("select disk $tempEfiDiskNum", "select partition $tempEfiPartNum", "remove letter=$tempEfiLetter") + $dpCleanOut = $dpClean | diskpart 2>&1 + foreach ($line in @($dpCleanOut)) { if ($line) { Log-Output "[diskpart][cleanup] $line" } } } - } - - # Registry Fallback for kdbgctrl - if ((Get-ItemProperty -Path $CrashCtrlPath).CrashDumpEnabled -eq 0) { - Set-ItemProperty -Path $CrashCtrlPath -Name CrashDumpEnabled -Value $dumpTypeMap[$DumpType] -Type DWord - } - # Step 9 - OneDump - if ($OneDump -eq $true -or $OneDump -eq 'true') { - Set-ItemProperty -Path $CrashCtrlPath -Name CrashDumpEnabled -Value $initialValue - } - - # Step 10 - Verification Summary - Log-Info "Dump configuration task completed." - - # Step 11 - Final Audit AFTER - Get-AuditSnapshot "VERIFYING UPDATED SETTINGS (AFTER)" + if ($tempOsLetter) + { + Log-Info "Removing temp letter ${tempOsLetter}: from Disk $tempOsDiskNum Partition $tempOsPartNum" + $dpOsClean = @("select disk $tempOsDiskNum", "select partition $tempOsPartNum", "remove letter=$tempOsLetter") + $dpOsCleanOut = $dpOsClean | diskpart 2>&1 + foreach ($line in @($dpOsCleanOut)) { if ($line) { Log-Output "[diskpart][os-cleanup] $line" } } + } - $currentDumpValue = (Get-ItemProperty -Path $CrashCtrlPath).CrashDumpEnabled - if ($OneDump -eq $true -or $OneDump -eq 'true') { - Log-Output "OneDump requested. CrashDumpEnabled restored to $(Get-DumpTypeLabel -Value $currentDumpValue)." - } - elseif ($currentDumpValue -ne $requestedDumpValue) { - $verificationFailed = $true - Log-Error "Dump configuration verification failed. Expected $(Get-DumpTypeLabel -Value $requestedDumpValue), found $(Get-DumpTypeLabel -Value $currentDumpValue)." + if ($diskChanged) { $changedCount++ } + elseif ($diskFailed) { $failedCount++ } + else { $skippedCount++ } + } } - else { - Log-Output "Verified dump configuration: $(Get-DumpTypeLabel -Value $currentDumpValue)." } - $effectiveCrashControl = Get-ItemProperty -Path $CrashCtrlPath -ErrorAction SilentlyContinue - Log-Output "Effective DumpFile: $($effectiveCrashControl.DumpFile)" - Log-Output "Effective DedicatedDumpFile: $($effectiveCrashControl.DedicatedDumpFile)" - - if ($pagefileWasMoved) { - Log-Output "PAGEFILE RELOCATION COMPLETED: Pagefile moved from temporary D: drive." - Log-Warning "RESTORATION REQUIRED: Restore to $($originalPagefileLocations -join ', ') after debugging." - } - - if ($verificationFailed) { - Log-Error "Configuration completed with one or more validation errors." - $script_final_status = $STATUS_ERROR - } - else { - Log-Output "SUCCESS: Configuration applied immediately - NO REBOOT REQUIRED" - Log-Info "Desktop log file: $logFilePath" - $script_final_status = $STATUS_SUCCESS + if ($script_final_status -ne $STATUS_SUCCESS) { + Log-Error "[$detectedExecutionContext] FAILED: $failureReason" } } catch { - Log-Error "Failure: $($_.Exception.Message)" - if ($crashControlBackupPath -and (Test-Path -Path $crashControlBackupPath -PathType Leaf)) { - Log-Warning "Rollback available. To restore previous CrashControl values, run: reg import `"$crashControlBackupPath`"" + Log-Error "[$detectedExecutionContext] An error occurred: $($_.Exception.Message)" + if ($_.InvocationInfo -and $_.InvocationInfo.PositionMessage) { + Log-Error "Failure context: $($_.InvocationInfo.PositionMessage)" } $script_final_status = $STATUS_ERROR } finally { + Log-Info "Summary: processed=$processedCount changed=$changedCount skipped=$skippedCount failed=$failedCount" + Log-Info "Detected execution context: $detectedExecutionContext" + Log-Info "Desktop log: $desktopLogFile" + if (Test-Path -Path $pluginLogFile -PathType Leaf) { + Log-Info "Plugin log (auto-collected): $pluginLogFile" + } Log-Info "Script ended at $(Get-Date)" } From 7365ca31e3cc065af9e7d52a463ec06044fe28c1 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:06:45 +0300 Subject: [PATCH 23/43] Update --- src/windows/sac-enabler.ps1 | 223 ++++++++++++++++++++++++++++++------ 1 file changed, 187 insertions(+), 36 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 7ea30430..72d49066 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -25,7 +25,7 @@ DeployMode: az vm repair run (with --run-on-repair) .VERSION - v1.4: [July 2026] - Restricted execution to repair VM mode (current). + v1.3: [July 2026] - Restricted execution to repair VM mode (current). - Uses Get-Disk-Partitions to enumerate Azure virtual disks. - Detects repair vs. standard context from secondary disks returned by the helper. - Mounts unlettered Gen2 Windows and EFI partitions temporarily. @@ -33,7 +33,7 @@ - Refuses BCD changes when a repair VM context is not detected. - Fails closed if the repair VM OS disk cannot be identified. - Filters out the repair VM OS disk before processing attached disks. - v1.3: [July 2026] - Added execution context detection and dual-logging. + Update [July 2026] - Added execution context detection and dual-logging. - Detected rescue VM mode vs standard mode for context-aware error messages. - Dual-logs to desktop and plugin directory for az vm repair auto-collection. - **NEW SAFETY: Pre-flight checks, BCD backup, and post-change verification. @@ -44,7 +44,7 @@ v1.2: [May 2026] - Fixed breaking exception when the Hyper-V module is not installed on the host. - Added explicit checking via Get-Module before executing nested VM discovery. v1.1: [May 2026] - Included advanced Gen2 unlettered EFI fallback and dynamic drive-letter assignment. - v0.1: [Initial] - Initial commit. Version 1.0 of the script. + v1.0: [Initial] - Initial commit. Version 1.0 of the script. .EXECUTION_CONTEXT This script is classified as repair-VM-only. It detects repair context when the helper returns @@ -329,6 +329,73 @@ function Log-Debug { Write-DesktopLogLine -Level 'Debug' -Message $message } +# Structured telemetry is written through the existing dual-write logging path. +$script:RepairScriptVersion = '1.4' +$script:ExecutionStarted = Get-Date +$script:OperationCount = 0 +$script:LastCommand = $null + +function Write-SacTelemetry { + param( + [Parameter(Mandatory = $true)] + [ValidateSet('Start', 'Operation', 'Success', 'Error')] + [string]$Event, + + [Parameter(Mandatory = $true)] + [string]$Message, + + [hashtable]$Properties = @{} + ) + + $payload = [ordered]@{ + Event = $Event + Message = $Message + TimestampUtc = (Get-Date).ToUniversalTime().ToString('o') + RepairScriptVersion = $script:RepairScriptVersion + Properties = $Properties + } + + $json = $payload | ConvertTo-Json -Compress -Depth 8 + if ($Event -eq 'Error') { + Log-Error "[Telemetry] $json" + } + else { + Log-Info "[Telemetry] $json" + } +} + +function Invoke-SacBcdEdit { + param( + [Parameter(Mandatory = $true)] + [string[]]$Arguments, + + [Parameter(Mandatory = $true)] + [string]$Operation + ) + + $script:OperationCount++ + $script:LastCommand = 'bcdedit.exe ' + ($Arguments -join ' ') + $output = & bcdedit.exe @Arguments 2>&1 + $exitCode = $LASTEXITCODE + + Write-SacTelemetry -Event Operation -Message 'Applied bcdedit command' -Properties @{ + Operation = $Operation + Command = $script:LastCommand + ExitCode = $exitCode + Success = ($exitCode -eq 0) + } + + foreach ($line in @($output)) { + if ($line) { Log-Output "[bcdedit][$Operation] $line" } + } + + [pscustomobject]@{ + Output = @($output) + ExitCode = $exitCode + Success = ($exitCode -eq 0) + } +} + $logFile = $logFilePath Log-Info "Dual logging initialized - Desktop: $desktopLogFile | Plugin: $pluginLogFile" Log-Info "Script classification: REPAIR_VM_ONLY" @@ -344,6 +411,15 @@ $changedCount = 0 Log-Info "Starting repair-only SAC enabler. Logs: $logFile" +$hostOs = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction SilentlyContinue +Write-SacTelemetry -Event Start -Message 'Starting SAC/EMS enablement' -Properties @{ + OSVersion = if ($hostOs) { $hostOs.Version } else { [Environment]::OSVersion.Version.ToString() } + OSCaption = if ($hostOs) { $hostOs.Caption } else { 'Unknown' } + ExecutionMode = 'REPAIR_VM_ONLY' + DesktopLog = $desktopLogFile + PluginLog = $pluginLogFile +} + try { # Optional: Clean up orphaned temp drive letters from previous failed runs # This helps prevent lingering mount points from blocking EFI partition access @@ -571,8 +647,16 @@ try { if ( $isBcdPath -and $isOsPath ) { # Step 2 - Identify the default boot entry GUID - $bcdout = bcdedit /store $bcdPath /enum bootmgr /v - $defaultLine = $bcdout | Select-String 'displayorder' | Select-Object -First 1 + $bootMgrQuery = Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/enum', 'bootmgr', '/v') -Operation 'query-bootmgr' + if (-not $bootMgrQuery.Success) { + $failureReason = "Could not enumerate boot manager from $bcdPath. Exit code: $($bootMgrQuery.ExitCode)." + Log-Warning $failureReason + $diskFailed = $true + continue + } + + $bcdout = $bootMgrQuery.Output + $defaultLine = $bcdout | Select-String -Pattern '^\s*displayorder\s+' | Select-Object -First 1 if (-not $defaultLine) { @@ -602,41 +686,75 @@ try { # Step 3 - Log BCD configuration before changes Log-Output "--- BCD BEFORE SAC ENABLE ---" - $beforeBcd = bcdedit /store $bcdPath /enum $defaultId - foreach ($line in $beforeBcd) { if ($line.Trim()) { Log-Output $line } } + $beforeQuery = Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/enum', $defaultId, '/v') -Operation 'before-state' + if (-not $beforeQuery.Success) { + throw "Unable to capture the BCD before-state for $defaultId. Exit code: $($beforeQuery.ExitCode)." + } + $beforeBcd = $beforeQuery.Output + foreach ($line in $beforeBcd) { if (-not [string]::IsNullOrWhiteSpace([string]$line)) { Log-Output $line } } # Steps 4-7 - Enable boot menu, Boot EMS, EMS on OS entry, and EMS serial settings Log-Info "Applying SAC and EMS configurations to BCD: $bcdPath" - $setBootMenuOut = bcdedit /store $bcdPath /set "{bootmgr}" displaybootmenu yes 2>&1 - foreach ($line in @($setBootMenuOut)) { if ($line) { Log-Output "[bcdedit][displaybootmenu] $line" } } - - $setTimeoutOut = bcdedit /store $bcdPath /set "{bootmgr}" timeout 5 2>&1 - foreach ($line in @($setTimeoutOut)) { if ($line) { Log-Output "[bcdedit][timeout] $line" } } - - $setBootEmsOut = bcdedit /store $bcdPath /set "{bootmgr}" bootems yes 2>&1 - foreach ($line in @($setBootEmsOut)) { if ($line) { Log-Output "[bcdedit][bootems] $line" } } - - $setEmsOut = bcdedit /store $bcdPath /ems $defaultId ON 2>&1 - foreach ($line in @($setEmsOut)) { if ($line) { Log-Output "[bcdedit][ems] $line" } } + $bcdOperations = @( + Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/set', '{bootmgr}', 'displaybootmenu', 'yes') -Operation 'displaybootmenu' + Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/set', '{bootmgr}', 'timeout', '5') -Operation 'timeout' + Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/set', '{bootmgr}', 'bootems', 'yes') -Operation 'bootems' + Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/ems', $defaultId, 'ON') -Operation 'ems' + Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/emssettings', 'EMSPORT:1', 'EMSBAUDRATE:115200') -Operation 'emssettings' + ) + + $failedBcdOperations = @($bcdOperations | Where-Object { -not $_.Success }) + if ($failedBcdOperations.Count -gt 0) { + throw "$($failedBcdOperations.Count) bcdedit operation(s) failed. BCD backup: $bcdBackup" + } - $setEmsSettingsOut = bcdedit /store $bcdPath /emssettings EMSPORT:1 EMSBAUDRATE:115200 2>&1 - foreach ($line in @($setEmsSettingsOut)) { if ($line) { Log-Output "[bcdedit][emssettings] $line" } } + # Verify every setting requested by the repair. + Log-Info 'Verifying BCD changes...' + $verifyLoaderQuery = Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/enum', $defaultId) -Operation 'verify-loader' + $verifyBootMgrQuery = Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/enum', '{bootmgr}') -Operation 'verify-bootmgr' + $verifyEmsSettingsQuery = Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/enum', '{emssettings}') -Operation 'verify-emssettings' + + $verifyLoaderText = $verifyLoaderQuery.Output -join "`n" + $verifyBootMgrText = $verifyBootMgrQuery.Output -join "`n" + $verifyEmsSettingsText = $verifyEmsSettingsQuery.Output -join "`n" + + $emsEnabled = $verifyLoaderText -match '(?im)^\s*ems\s+Yes\s*$' + $bootEmsEnabled = $verifyBootMgrText -match '(?im)^\s*bootems\s+Yes\s*$' + $bootMenuEnabled = $verifyBootMgrText -match '(?im)^\s*displaybootmenu\s+Yes\s*$' + $timeoutConfigured = $verifyBootMgrText -match '(?im)^\s*timeout\s+5\s*$' + $portConfigured = $verifyEmsSettingsText -match '(?im)^\s*port\s+1\s*$' + $baudConfigured = $verifyEmsSettingsText -match '(?im)^\s*baudrate\s+115200\s*$' + + $verificationPassed = $verifyLoaderQuery.Success -and + $verifyBootMgrQuery.Success -and + $verifyEmsSettingsQuery.Success -and + $emsEnabled -and $bootEmsEnabled -and $bootMenuEnabled -and + $timeoutConfigured -and $portConfigured -and $baudConfigured + + Write-SacTelemetry -Event Operation -Message 'Post-change BCD verification completed' -Properties @{ + DiskNumber = $diskNumber + BcdPath = $bcdPath + LoaderGuid = $defaultId + EmsEnabled = $emsEnabled + BootEmsEnabled = $bootEmsEnabled + BootMenuEnabled = $bootMenuEnabled + TimeoutConfigured = $timeoutConfigured + PortConfigured = $portConfigured + BaudConfigured = $baudConfigured + VerificationPassed = $verificationPassed + } - # VALIDATION: Verify BCD changes were applied successfully - Log-Info "Verifying BCD changes..." - $verifyBcd = bcdedit /store $bcdPath /enum $defaultId - $emsEnabled = $verifyBcd | Select-String 'ems' | Select-String 'Yes' - if (-not $emsEnabled) { - Log-Error "CRITICAL: EMS verification failed! BCD may be corrupted. Restore from backup: $bcdBackup" + if (-not $verificationPassed) { + Log-Error "CRITICAL: SAC/EMS verification failed. Restore from backup if needed: $bcdBackup" + Log-Error "Verification state: ems=$emsEnabled bootems=$bootEmsEnabled displaybootmenu=$bootMenuEnabled timeout=$timeoutConfigured port=$portConfigured baud=$baudConfigured" + $failureReason = "Post-change BCD verification failed for Disk $diskNumber." $diskFailed = $true } else { - # Step 8 - Log BCD configuration after changes for verification - Log-Output "--- BCD AFTER SAC ENABLE ---" - $afterBcd = bcdedit /store $bcdPath /enum $defaultId - foreach ($line in $afterBcd) { if ($line.Trim()) { Log-Output $line } } - - $script_final_status = $STATUS_SUCCESS + Log-Output '--- BCD AFTER SAC ENABLE ---' + foreach ($line in $verifyLoaderQuery.Output) { if (-not [string]::IsNullOrWhiteSpace([string]$line)) { Log-Output $line } } + foreach ($line in $verifyBootMgrQuery.Output) { if (-not [string]::IsNullOrWhiteSpace([string]$line)) { Log-Output $line } } + foreach ($line in $verifyEmsSettingsQuery.Output) { if (-not [string]::IsNullOrWhiteSpace([string]$line)) { Log-Output $line } } $diskChanged = $true } } @@ -664,7 +782,7 @@ try { if ($tempEfiLetter) { Log-Info "Removing temp letter ${tempEfiLetter}: from Disk $tempEfiDiskNum Partition $tempEfiPartNum" - $dpClean = @("select disk $tempEfiDiskNum", "select partition $tempEfiPartNum", "remove letter=$tempEfiLetter") + $dpClean = @("select disk $tempEfiDiskNum", "select partition $tempEfiPartNum", "remove letter=$tempEfiLetter noerr") $dpCleanOut = $dpClean | diskpart 2>&1 foreach ($line in @($dpCleanOut)) { if ($line) { Log-Output "[diskpart][cleanup] $line" } } } @@ -672,18 +790,25 @@ try { if ($tempOsLetter) { Log-Info "Removing temp letter ${tempOsLetter}: from Disk $tempOsDiskNum Partition $tempOsPartNum" - $dpOsClean = @("select disk $tempOsDiskNum", "select partition $tempOsPartNum", "remove letter=$tempOsLetter") + $dpOsClean = @("select disk $tempOsDiskNum", "select partition $tempOsPartNum", "remove letter=$tempOsLetter noerr") $dpOsCleanOut = $dpOsClean | diskpart 2>&1 foreach ($line in @($dpOsCleanOut)) { if ($line) { Log-Output "[diskpart][os-cleanup] $line" } } } - if ($diskChanged) { $changedCount++ } - elseif ($diskFailed) { $failedCount++ } + if ($diskFailed) { $failedCount++ } + elseif ($diskChanged) { $changedCount++ } else { $skippedCount++ } } } } + if ($failedCount -gt 0 -or $changedCount -eq 0) { + $script_final_status = $STATUS_ERROR + } + else { + $script_final_status = $STATUS_SUCCESS + } + if ($script_final_status -ne $STATUS_SUCCESS) { Log-Error "[$detectedExecutionContext] FAILED: $failureReason" } @@ -696,6 +821,32 @@ catch { $script_final_status = $STATUS_ERROR } finally { + $durationSeconds = [math]::Round(((Get-Date) - $script:ExecutionStarted).TotalSeconds, 3) + if ($script_final_status -eq $STATUS_SUCCESS) { + Write-SacTelemetry -Event Success -Message 'SAC/EMS repair completed successfully' -Properties @{ + OperationsPerformed = $script:OperationCount + DurationSeconds = $durationSeconds + DisksProcessed = $processedCount + DisksChanged = $changedCount + DisksSkipped = $skippedCount + DisksFailed = $failedCount + BootEmsEnabled = $true + EmsEnabled = $true + } + } + else { + Write-SacTelemetry -Event Error -Message 'SAC/EMS repair completed with errors' -Properties @{ + OperationsPerformed = $script:OperationCount + DurationSeconds = $durationSeconds + DisksProcessed = $processedCount + DisksChanged = $changedCount + DisksSkipped = $skippedCount + DisksFailed = $failedCount + FailureReason = $failureReason + LastCommand = $script:LastCommand + } + } + Log-Info "Summary: processed=$processedCount changed=$changedCount skipped=$skippedCount failed=$failedCount" Log-Info "Detected execution context: $detectedExecutionContext" Log-Info "Desktop log: $desktopLogFile" From 50d9b9960b1e040cbe4900d51bb390f0098e8d4b Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:15:52 +0300 Subject: [PATCH 24/43] Update --- src/windows/sac-enabler.ps1 | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 72d49066..cf78c501 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -25,7 +25,7 @@ DeployMode: az vm repair run (with --run-on-repair) .VERSION - v1.3: [July 2026] - Restricted execution to repair VM mode (current). + v1.4: [July 2026] - Restricted execution to repair VM mode (current). - Uses Get-Disk-Partitions to enumerate Azure virtual disks. - Detects repair vs. standard context from secondary disks returned by the helper. - Mounts unlettered Gen2 Windows and EFI partitions temporarily. @@ -33,7 +33,7 @@ - Refuses BCD changes when a repair VM context is not detected. - Fails closed if the repair VM OS disk cannot be identified. - Filters out the repair VM OS disk before processing attached disks. - Update [July 2026] - Added execution context detection and dual-logging. + v1.3: [July 2026] - Added execution context detection and dual-logging. - Detected rescue VM mode vs standard mode for context-aware error messages. - Dual-logs to desktop and plugin directory for az vm repair auto-collection. - **NEW SAFETY: Pre-flight checks, BCD backup, and post-change verification. @@ -44,7 +44,7 @@ v1.2: [May 2026] - Fixed breaking exception when the Hyper-V module is not installed on the host. - Added explicit checking via Get-Module before executing nested VM discovery. v1.1: [May 2026] - Included advanced Gen2 unlettered EFI fallback and dynamic drive-letter assignment. - v1.0: [Initial] - Initial commit. Version 1.0 of the script. + v0.1: [Initial] - Initial commit. Version 1.0 of the script. .EXECUTION_CONTEXT This script is classified as repair-VM-only. It detects repair context when the helper returns @@ -357,10 +357,10 @@ function Write-SacTelemetry { $json = $payload | ConvertTo-Json -Compress -Depth 8 if ($Event -eq 'Error') { - Log-Error "[Telemetry] $json" + Log-Error "[Telemetry] $json" | Out-Null } else { - Log-Info "[Telemetry] $json" + Log-Info "[Telemetry] $json" | Out-Null } } @@ -383,13 +383,13 @@ function Invoke-SacBcdEdit { Command = $script:LastCommand ExitCode = $exitCode Success = ($exitCode -eq 0) - } + } | Out-Null foreach ($line in @($output)) { - if ($line) { Log-Output "[bcdedit][$Operation] $line" } + if ($line) { Log-Output "[bcdedit][$Operation] $line" | Out-Null } } - [pscustomobject]@{ + return [pscustomobject]@{ Output = @($output) ExitCode = $exitCode Success = ($exitCode -eq 0) @@ -703,8 +703,16 @@ try { Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/emssettings', 'EMSPORT:1', 'EMSBAUDRATE:115200') -Operation 'emssettings' ) - $failedBcdOperations = @($bcdOperations | Where-Object { -not $_.Success }) + $failedBcdOperations = @($bcdOperations | Where-Object { $_ -isnot [pscustomobject] -or -not $_.Success }) if ($failedBcdOperations.Count -gt 0) { + foreach ($failedOperation in $failedBcdOperations) { + if ($failedOperation -is [pscustomobject]) { + Log-Error "bcdedit operation failed with exit code $($failedOperation.ExitCode)." + } + else { + Log-Error "Unexpected pipeline output was returned by Invoke-SacBcdEdit: $failedOperation" + } + } throw "$($failedBcdOperations.Count) bcdedit operation(s) failed. BCD backup: $bcdBackup" } From fa3b2cdf3ab598034a87c0cd8531018179884bae Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:19:57 +0300 Subject: [PATCH 25/43] Update --- src/windows/sac-enabler.ps1 | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index cf78c501..6d5db26c 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -25,7 +25,7 @@ DeployMode: az vm repair run (with --run-on-repair) .VERSION - v1.4: [July 2026] - Restricted execution to repair VM mode (current). + v1.3: [July 2026] - Restricted execution to repair VM mode (current). - Uses Get-Disk-Partitions to enumerate Azure virtual disks. - Detects repair vs. standard context from secondary disks returned by the helper. - Mounts unlettered Gen2 Windows and EFI partitions temporarily. @@ -33,7 +33,7 @@ - Refuses BCD changes when a repair VM context is not detected. - Fails closed if the repair VM OS disk cannot be identified. - Filters out the repair VM OS disk before processing attached disks. - v1.3: [July 2026] - Added execution context detection and dual-logging. + Update [July 2026] - Added execution context detection and dual-logging. - Detected rescue VM mode vs standard mode for context-aware error messages. - Dual-logs to desktop and plugin directory for az vm repair auto-collection. - **NEW SAFETY: Pre-flight checks, BCD backup, and post-change verification. @@ -357,10 +357,10 @@ function Write-SacTelemetry { $json = $payload | ConvertTo-Json -Compress -Depth 8 if ($Event -eq 'Error') { - Log-Error "[Telemetry] $json" | Out-Null + Log-Error "[Telemetry] $json" } else { - Log-Info "[Telemetry] $json" | Out-Null + Log-Info "[Telemetry] $json" } } @@ -383,13 +383,13 @@ function Invoke-SacBcdEdit { Command = $script:LastCommand ExitCode = $exitCode Success = ($exitCode -eq 0) - } | Out-Null + } foreach ($line in @($output)) { - if ($line) { Log-Output "[bcdedit][$Operation] $line" | Out-Null } + if ($line) { Log-Output "[bcdedit][$Operation] $line" } } - return [pscustomobject]@{ + [pscustomobject]@{ Output = @($output) ExitCode = $exitCode Success = ($exitCode -eq 0) @@ -703,16 +703,8 @@ try { Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/emssettings', 'EMSPORT:1', 'EMSBAUDRATE:115200') -Operation 'emssettings' ) - $failedBcdOperations = @($bcdOperations | Where-Object { $_ -isnot [pscustomobject] -or -not $_.Success }) + $failedBcdOperations = @($bcdOperations | Where-Object { -not $_.Success }) if ($failedBcdOperations.Count -gt 0) { - foreach ($failedOperation in $failedBcdOperations) { - if ($failedOperation -is [pscustomobject]) { - Log-Error "bcdedit operation failed with exit code $($failedOperation.ExitCode)." - } - else { - Log-Error "Unexpected pipeline output was returned by Invoke-SacBcdEdit: $failedOperation" - } - } throw "$($failedBcdOperations.Count) bcdedit operation(s) failed. BCD backup: $bcdBackup" } From 6b3d9cbd1849524dca63478061edaaff429ce1bb Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:25:30 +0300 Subject: [PATCH 26/43] Update --- src/windows/sac-enabler.ps1 | 40 +++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 6d5db26c..edaae321 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -33,7 +33,7 @@ - Refuses BCD changes when a repair VM context is not detected. - Fails closed if the repair VM OS disk cannot be identified. - Filters out the repair VM OS disk before processing attached disks. - Update [July 2026] - Added execution context detection and dual-logging. + Update: [July 2026] - Added execution context detection and dual-logging. - Detected rescue VM mode vs standard mode for context-aware error messages. - Dual-logs to desktop and plugin directory for az vm repair auto-collection. - **NEW SAFETY: Pre-flight checks, BCD backup, and post-change verification. @@ -357,10 +357,10 @@ function Write-SacTelemetry { $json = $payload | ConvertTo-Json -Compress -Depth 8 if ($Event -eq 'Error') { - Log-Error "[Telemetry] $json" + Log-Error "[Telemetry] $json" | Out-Null } else { - Log-Info "[Telemetry] $json" + Log-Info "[Telemetry] $json" | Out-Null } } @@ -383,13 +383,13 @@ function Invoke-SacBcdEdit { Command = $script:LastCommand ExitCode = $exitCode Success = ($exitCode -eq 0) - } + } | Out-Null foreach ($line in @($output)) { - if ($line) { Log-Output "[bcdedit][$Operation] $line" } + if ($line) { Log-Output "[bcdedit][$Operation] $line" | Out-Null } } - [pscustomobject]@{ + return [pscustomobject]@{ Output = @($output) ExitCode = $exitCode Success = ($exitCode -eq 0) @@ -410,6 +410,7 @@ $failedCount = 0 $changedCount = 0 Log-Info "Starting repair-only SAC enabler. Logs: $logFile" +Log-Info "Build marker: v1.4-pipeline-fix3" $hostOs = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction SilentlyContinue Write-SacTelemetry -Event Start -Message 'Starting SAC/EMS enablement' -Properties @{ @@ -695,17 +696,21 @@ try { # Steps 4-7 - Enable boot menu, Boot EMS, EMS on OS entry, and EMS serial settings Log-Info "Applying SAC and EMS configurations to BCD: $bcdPath" - $bcdOperations = @( - Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/set', '{bootmgr}', 'displaybootmenu', 'yes') -Operation 'displaybootmenu' - Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/set', '{bootmgr}', 'timeout', '5') -Operation 'timeout' - Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/set', '{bootmgr}', 'bootems', 'yes') -Operation 'bootems' - Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/ems', $defaultId, 'ON') -Operation 'ems' - Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/emssettings', 'EMSPORT:1', 'EMSBAUDRATE:115200') -Operation 'emssettings' + # Execute each command independently. Do not collect command results in a + # shared pipeline because repository Log-* helpers write to the success stream. + $operationDefinitions = @( + @{ Name = 'displaybootmenu'; Arguments = @('/store', $bcdPath, '/set', '{bootmgr}', 'displaybootmenu', 'yes') } + @{ Name = 'timeout'; Arguments = @('/store', $bcdPath, '/set', '{bootmgr}', 'timeout', '5') } + @{ Name = 'bootems'; Arguments = @('/store', $bcdPath, '/set', '{bootmgr}', 'bootems', 'yes') } + @{ Name = 'ems'; Arguments = @('/store', $bcdPath, '/ems', $defaultId, 'ON') } + @{ Name = 'emssettings'; Arguments = @('/store', $bcdPath, '/emssettings', 'EMSPORT:1', 'EMSBAUDRATE:115200') } ) - $failedBcdOperations = @($bcdOperations | Where-Object { -not $_.Success }) - if ($failedBcdOperations.Count -gt 0) { - throw "$($failedBcdOperations.Count) bcdedit operation(s) failed. BCD backup: $bcdBackup" + foreach ($operationDefinition in $operationDefinitions) { + $operationResult = Invoke-SacBcdEdit -Arguments $operationDefinition.Arguments -Operation $operationDefinition.Name + if (-not $operationResult.Success) { + throw "bcdedit operation '$($operationDefinition.Name)' failed with exit code $($operationResult.ExitCode). BCD backup: $bcdBackup" + } } # Verify every setting requested by the repair. @@ -722,8 +727,9 @@ try { $bootEmsEnabled = $verifyBootMgrText -match '(?im)^\s*bootems\s+Yes\s*$' $bootMenuEnabled = $verifyBootMgrText -match '(?im)^\s*displaybootmenu\s+Yes\s*$' $timeoutConfigured = $verifyBootMgrText -match '(?im)^\s*timeout\s+5\s*$' - $portConfigured = $verifyEmsSettingsText -match '(?im)^\s*port\s+1\s*$' - $baudConfigured = $verifyEmsSettingsText -match '(?im)^\s*baudrate\s+115200\s*$' + # BCDEdit may label these fields as EMSPORT/EMSBAUDRATE or port/baudrate. + $portConfigured = $verifyEmsSettingsText -match '(?im)^\s*(?:emsport|port)\s+1\s*$' + $baudConfigured = $verifyEmsSettingsText -match '(?im)^\s*(?:emsbaudrate|baudrate)\s+115200\s*$' $verificationPassed = $verifyLoaderQuery.Success -and $verifyBootMgrQuery.Success -and From 771ac876267463b1764b8599a91deb4b77c8fdcd Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:38:39 +0300 Subject: [PATCH 27/43] Update sac-enabler.ps1 --- src/windows/sac-enabler.ps1 | 239 +++++++++++++++++++++--------------- 1 file changed, 137 insertions(+), 102 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index edaae321..3fde7a73 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -410,7 +410,7 @@ $failedCount = 0 $changedCount = 0 Log-Info "Starting repair-only SAC enabler. Logs: $logFile" -Log-Info "Build marker: v1.4-pipeline-fix3" +Log-Info "Build marker: v1.4-efi-bcd-selection-fix" $hostOs = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction SilentlyContinue Write-SacTelemetry -Event Start -Message 'Starting SAC/EMS enablement' -Properties @{ @@ -513,136 +513,171 @@ try { try { - # Scan each drive for BCD store and Windows OS loader - ForEach ($drive in $partitionGroup.Group | Select-Object -ExpandProperty DriveLetter | Where-Object { $_ }) - { - # The repair disk was filtered out above; retain this drive-level safety check. + # Discover the OS loader and BCD store on this target disk. + # Get-Disk-Partitions-v2.ps1 remains the authoritative disk enumerator. + $diskNum = [int]$partitionGroup.Name + $diskPartitions = @(Get-Partition -DiskNumber $diskNum -ErrorAction Stop) + $efiGptType = 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b' + $efiPartitions = @($diskPartitions | Where-Object { + Test-SacGptType -ActualType $_.GptType -ExpectedType $efiGptType + }) + $isGen2Disk = $efiPartitions.Count -gt 0 + + Log-Info "Disk ${diskNum}: generation detection result = $(if ($isGen2Disk) { 'Gen2/UEFI' } else { 'Gen1/BIOS' })" + + # Locate the Windows partition. Check both loader formats as requested. + foreach ($drive in $partitionGroup.Group | Select-Object -ExpandProperty DriveLetter | Where-Object { $_ }) { if ($drive -eq $repairDrive) { continue } - if ( -not $isBcdPath ) - { - $bcdPath = $drive + ':\boot\bcd' - $isBcdPath = Test-Path $bcdPath - if ( -not $isBcdPath ) - { - $bcdPath = $drive + ':\efi\microsoft\boot\bcd' - $isBcdPath = Test-Path $bcdPath - } - } - if (-not $isOsPath) - { - $winloadExePath = $drive + ':\windows\system32\winload.exe' - $winloadEfiPath = $drive + ':\windows\system32\winload.efi' - $isOsPath = (Test-Path $winloadExePath) -or (Test-Path $winloadEfiPath) + $winloadExePath = "${drive}:\windows\system32\winload.exe" + $winloadEfiPath = "${drive}:\windows\system32\winload.efi" + if ((Test-Path -LiteralPath $winloadExePath -PathType Leaf) -or + (Test-Path -LiteralPath $winloadEfiPath -PathType Leaf)) { + $isOsPath = $true + Log-Info "Disk ${diskNum}: Windows loader found on ${drive}:" + break } } - # Gen2 fallback: probe unlettered partitions directly for a Windows loader. - if (-not $isOsPath) - { - $diskNum = [int]$partitionGroup.Name - $diskPartitions = @(Get-Partition -DiskNumber $diskNum -ErrorAction SilentlyContinue) - foreach ($partition in $diskPartitions) { - $driveDescription = if ($partition.DriveLetter) { "$($partition.DriveLetter):" } else { '' } - $sizeMb = [math]::Round($partition.Size / 1MB) - Log-Info "Disk $diskNum partition $($partition.PartitionNumber): drive=$driveDescription sizeMB=$sizeMb type=$($partition.Type) gptType=$($partition.GptType)" - } - + # If the Windows partition is unlettered, mount candidates temporarily and probe them. + if (-not $isOsPath) { $unletteredOsCandidates = @($diskPartitions | Where-Object { - -not $_.DriveLetter -or $_.DriveLetter -eq [char]0 + (-not $_.DriveLetter -or $_.DriveLetter -eq [char]0) -and + -not (Test-SacGptType -ActualType $_.GptType -ExpectedType $efiGptType) } | Sort-Object Size -Descending) - Log-Info "Disk ${diskNum}: probing $($unletteredOsCandidates.Count) unlettered partition(s) for a Windows loader." - foreach ($osCandidate in $unletteredOsCandidates) - { + foreach ($osCandidate in $unletteredOsCandidates) { $candidateLetter = Get-AvailableTempDriveLetter if (-not $candidateLetter) { - Log-Warning "No available drive letter for an unlettered Windows partition on Disk $diskNum" + Log-Warning "Disk ${diskNum}: no temporary drive letter is available for Windows-partition probing." break } $candidatePartNum = $osCandidate.PartitionNumber - Log-Info "Assigning temp letter ${candidateLetter}: to Disk $diskNum Partition $candidatePartNum (Windows candidate)..." - $dpOsAssign = @("select disk $diskNum", "select partition $candidatePartNum", "assign letter=$candidateLetter") + $dpOsAssign = @( + "select disk $diskNum" + "select partition $candidatePartNum" + "assign letter=$candidateLetter" + ) $dpOsAssignOut = $dpOsAssign | diskpart 2>&1 - foreach ($line in @($dpOsAssignOut)) { if ($line) { Log-Output "[diskpart][os-assign] $line" } } - $tempOsLetter = $candidateLetter - $tempOsDiskNum = $diskNum - $tempOsPartNum = $candidatePartNum + foreach ($line in @($dpOsAssignOut)) { + if ($line) { Log-Output "[diskpart][os-assign] $line" | Out-Null } + } Start-Sleep -Seconds 2 - $winloadExePath = "${candidateLetter}:\windows\system32\winload.exe" - $winloadEfiPath = "${candidateLetter}:\windows\system32\winload.efi" - $isOsPath = (Test-Path -Path $winloadExePath) -or (Test-Path -Path $winloadEfiPath) - if ($isOsPath) { - Log-Info "Found Windows OS partition at ${candidateLetter}: on Disk $diskNum" - break + if (Test-Path -LiteralPath "${candidateLetter}:\" -PathType Container) { + $winloadExePath = "${candidateLetter}:\windows\system32\winload.exe" + $winloadEfiPath = "${candidateLetter}:\windows\system32\winload.efi" + if ((Test-Path -LiteralPath $winloadExePath -PathType Leaf) -or + (Test-Path -LiteralPath $winloadEfiPath -PathType Leaf)) { + $tempOsLetter = $candidateLetter + $tempOsDiskNum = $diskNum + $tempOsPartNum = $candidatePartNum + $isOsPath = $true + Log-Info "Disk ${diskNum}: Windows loader found on temporary drive ${candidateLetter}:" + break + } } - Log-Info "No Windows loader found at ${candidateLetter}:, removing letter..." - $dpOsRemove = @("select disk $diskNum", "select partition $candidatePartNum", "remove letter=$candidateLetter") + $dpOsRemove = @( + "select disk $diskNum" + "select partition $candidatePartNum" + "remove letter=$candidateLetter noerr" + ) $dpOsRemoveOut = $dpOsRemove | diskpart 2>&1 - foreach ($line in @($dpOsRemoveOut)) { if ($line) { Log-Output "[diskpart][os-remove] $line" } } - $tempOsLetter = $null - $tempOsDiskNum = $null - $tempOsPartNum = $null + foreach ($line in @($dpOsRemoveOut)) { + if ($line) { Log-Output "[diskpart][os-remove] $line" | Out-Null } + } } } - # Gen2 EFI fallback: if OS found but no BCD, discover unlettered EFI partition - if (-not $isBcdPath -and $isOsPath) - { - $diskNum = [int]$partitionGroup.Name - if ($diskNum -ne $repairDiskNumber) - { - Log-Info "Disk ${diskNum}: OS found but no BCD - checking for unlettered EFI partition (Gen2)..." - $efiGptType = 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b' - $efiParts = Get-Partition -DiskNumber $diskNum -ErrorAction SilentlyContinue | Where-Object { - (Test-SacGptType -ActualType $_.GptType -ExpectedType $efiGptType) -and - (-not $_.DriveLetter -or $_.DriveLetter -eq [char]0) + # BCD selection is generation-aware and remains restricted to this target disk. + if ($isGen2Disk) { + # For Gen2, the authoritative store must be on the EFI System Partition. + # Do not fall back to :\boot\bcd. + foreach ($efiPartition in $efiPartitions) { + $efiLetter = $null + $letterAssignedByScript = $false + + if ($efiPartition.DriveLetter -and $efiPartition.DriveLetter -ne [char]0) { + $efiLetter = [string]$efiPartition.DriveLetter } - if ($efiParts) - { - $tempLetter = Get-AvailableTempDriveLetter - if ($tempLetter) - { - foreach ($ep in $efiParts) - { - $pn = $ep.PartitionNumber - Log-Info "Assigning temp letter ${tempLetter}: to Disk $diskNum Partition $pn (EFI)..." - $dpLines = @("select disk $diskNum", "select partition $pn", "assign letter=$tempLetter") - $dpAssignOut = $dpLines | diskpart 2>&1 - foreach ($line in @($dpAssignOut)) { if ($line) { Log-Output "[diskpart][assign] $line" } } - $tempEfiLetter = $tempLetter - $tempEfiDiskNum = $diskNum - $tempEfiPartNum = $pn - Start-Sleep -Seconds 2 - $bcdPath = "${tempLetter}:\efi\microsoft\boot\bcd" - $isBcdPath = Test-Path $bcdPath - if ($isBcdPath) - { - Log-Info "Found Gen2 BCD store at $bcdPath" - break - } - else - { - Log-Info "No BCD at $bcdPath, removing letter..." - $dpRemove = @("select disk $diskNum", "select partition $pn", "remove letter=$tempLetter") - $dpRemoveOut = $dpRemove | diskpart 2>&1 - foreach ($line in @($dpRemoveOut)) { if ($line) { Log-Output "[diskpart][remove] $line" } } - $tempEfiLetter = $null - $tempEfiDiskNum = $null - $tempEfiPartNum = $null - } - } + else { + $efiLetter = Get-AvailableTempDriveLetter + if (-not $efiLetter) { + Log-Warning "Disk ${diskNum}: no temporary drive letter is available for EFI Partition $($efiPartition.PartitionNumber)." + continue } - else - { - Log-Warning "No available drive letter for EFI partition on Disk $diskNum" + + $dpEfiAssign = @( + "select disk $diskNum" + "select partition $($efiPartition.PartitionNumber)" + "assign letter=$efiLetter" + ) + $dpEfiAssignOut = $dpEfiAssign | diskpart 2>&1 + foreach ($line in @($dpEfiAssignOut)) { + if ($line) { Log-Output "[diskpart][efi-assign] $line" | Out-Null } + } + Start-Sleep -Seconds 2 + $letterAssignedByScript = Test-Path -LiteralPath "${efiLetter}:\" -PathType Container + } + + if (-not $efiLetter -or -not (Test-Path -LiteralPath "${efiLetter}:\" -PathType Container)) { + Log-Warning "Disk ${diskNum}: EFI Partition $($efiPartition.PartitionNumber) could not be mounted." + continue + } + + $candidateBcdPath = "${efiLetter}:\EFI\Microsoft\Boot\BCD" + Log-Info "Disk ${diskNum}: probing EFI BCD candidate $candidateBcdPath" + + if (Test-Path -LiteralPath $candidateBcdPath -PathType Leaf) { + $bcdPath = $candidateBcdPath + $isBcdPath = $true + Log-Info "Disk ${diskNum}: selected Gen2 EFI BCD store: $bcdPath" + + if ($letterAssignedByScript) { + $tempEfiLetter = $efiLetter + $tempEfiDiskNum = $diskNum + $tempEfiPartNum = $efiPartition.PartitionNumber + } + break + } + + Log-Warning "Disk ${diskNum}: no BCD store was found at $candidateBcdPath." + if ($letterAssignedByScript) { + $dpEfiRemove = @( + "select disk $diskNum" + "select partition $($efiPartition.PartitionNumber)" + "remove letter=$efiLetter noerr" + ) + $dpEfiRemoveOut = $dpEfiRemove | diskpart 2>&1 + foreach ($line in @($dpEfiRemoveOut)) { + if ($line) { Log-Output "[diskpart][efi-remove] $line" | Out-Null } } } } } + else { + # Gen1: locate \boot\bcd only on mounted partitions belonging to this disk. + foreach ($drive in $partitionGroup.Group | Select-Object -ExpandProperty DriveLetter | Where-Object { $_ }) { + if ($drive -eq $repairDrive) { continue } + $candidateBcdPath = "${drive}:\boot\bcd" + if (Test-Path -LiteralPath $candidateBcdPath -PathType Leaf) { + $bcdPath = $candidateBcdPath + $isBcdPath = $true + Log-Info "Disk ${diskNum}: selected Gen1 BCD store: $bcdPath" + break + } + } + } + + if ($isBcdPath) { + Log-Info "Disk ${diskNum}: final BCD path selected for modification: $bcdPath" + } + else { + Log-Warning "Disk ${diskNum}: no generation-appropriate BCD store was found." + } + # Apply SAC changes if both BCD and OS loader were found if ( $isBcdPath -and $isOsPath ) From 582aee3fa37e5968732a2bf89c8c01babda94b63 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:13:50 +0300 Subject: [PATCH 28/43] Update --- src/windows/sac-enabler.ps1 | 123 ++++++++++++++++++++++-------------- 1 file changed, 75 insertions(+), 48 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 3fde7a73..154d9368 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -410,7 +410,7 @@ $failedCount = 0 $changedCount = 0 Log-Info "Starting repair-only SAC enabler. Logs: $logFile" -Log-Info "Build marker: v1.4-efi-bcd-selection-fix" +Log-Info "Build marker: v1.4-os-and-bcd-discovery-fix" $hostOs = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction SilentlyContinue Write-SacTelemetry -Event Start -Message 'Starting SAC/EMS enablement' -Properties @{ @@ -513,47 +513,68 @@ try { try { - # Discover the OS loader and BCD store on this target disk. - # Get-Disk-Partitions-v2.ps1 remains the authoritative disk enumerator. + # Discover the Windows partition and select the BCD store from the + # same target disk. Get-Disk-Partitions-v2.ps1 remains unchanged. $diskNum = [int]$partitionGroup.Name $diskPartitions = @(Get-Partition -DiskNumber $diskNum -ErrorAction Stop) $efiGptType = 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b' - $efiPartitions = @($diskPartitions | Where-Object { + $efiParts = @($diskPartitions | Where-Object { Test-SacGptType -ActualType $_.GptType -ExpectedType $efiGptType }) - $isGen2Disk = $efiPartitions.Count -gt 0 + $isGen2Disk = $efiParts.Count -gt 0 Log-Info "Disk ${diskNum}: generation detection result = $(if ($isGen2Disk) { 'Gen2/UEFI' } else { 'Gen1/BIOS' })" - # Locate the Windows partition. Check both loader formats as requested. - foreach ($drive in $partitionGroup.Group | Select-Object -ExpandProperty DriveLetter | Where-Object { $_ }) { + foreach ($partition in $diskPartitions) { + $displayLetter = if ($partition.DriveLetter -and $partition.DriveLetter -ne [char]0) { + "$($partition.DriveLetter):" + } + else { + '' + } + Log-Info "Disk $diskNum Partition $($partition.PartitionNumber): drive=$displayLetter size=$($partition.Size) type=$($partition.Type) gptType=$($partition.GptType)" + } + + # Locate Windows on every currently mounted partition of this disk. + # Once detected, $isOsPath is never reset to false. + foreach ($partition in @($diskPartitions | Where-Object { + $_.DriveLetter -and $_.DriveLetter -ne [char]0 + })) { + $drive = [string]$partition.DriveLetter if ($drive -eq $repairDrive) { continue } - $winloadExePath = "${drive}:\windows\system32\winload.exe" - $winloadEfiPath = "${drive}:\windows\system32\winload.efi" - if ((Test-Path -LiteralPath $winloadExePath -PathType Leaf) -or - (Test-Path -LiteralPath $winloadEfiPath -PathType Leaf)) { + $winloadExePath = "${drive}:\Windows\System32\winload.exe" + $winloadEfiPath = "${drive}:\Windows\System32\winload.efi" + $hasWinloadExe = Test-Path -LiteralPath $winloadExePath -PathType Leaf + $hasWinloadEfi = Test-Path -LiteralPath $winloadEfiPath -PathType Leaf + + Log-Info "Disk ${diskNum}: checking ${drive}: for Windows loader; winload.exe=$hasWinloadExe winload.efi=$hasWinloadEfi" + + if ($hasWinloadExe -or $hasWinloadEfi) { $isOsPath = $true - Log-Info "Disk ${diskNum}: Windows loader found on ${drive}:" + Log-Info "Disk ${diskNum}: Windows loader confirmed on ${drive}:" break } } - # If the Windows partition is unlettered, mount candidates temporarily and probe them. + # Probe unlettered non-EFI partitions only if Windows was not found above. if (-not $isOsPath) { $unletteredOsCandidates = @($diskPartitions | Where-Object { (-not $_.DriveLetter -or $_.DriveLetter -eq [char]0) -and -not (Test-SacGptType -ActualType $_.GptType -ExpectedType $efiGptType) } | Sort-Object Size -Descending) + Log-Info "Disk ${diskNum}: probing $($unletteredOsCandidates.Count) unlettered non-EFI partition(s) for Windows." + foreach ($osCandidate in $unletteredOsCandidates) { $candidateLetter = Get-AvailableTempDriveLetter if (-not $candidateLetter) { - Log-Warning "Disk ${diskNum}: no temporary drive letter is available for Windows-partition probing." + Log-Warning "Disk ${diskNum}: no temporary drive letter is available for OS probing." break } $candidatePartNum = $osCandidate.PartitionNumber + Log-Info "Disk ${diskNum}: assigning ${candidateLetter}: to Partition $candidatePartNum for OS probing." $dpOsAssign = @( "select disk $diskNum" "select partition $candidatePartNum" @@ -565,18 +586,21 @@ try { } Start-Sleep -Seconds 2 - if (Test-Path -LiteralPath "${candidateLetter}:\" -PathType Container) { - $winloadExePath = "${candidateLetter}:\windows\system32\winload.exe" - $winloadEfiPath = "${candidateLetter}:\windows\system32\winload.efi" - if ((Test-Path -LiteralPath $winloadExePath -PathType Leaf) -or - (Test-Path -LiteralPath $winloadEfiPath -PathType Leaf)) { - $tempOsLetter = $candidateLetter - $tempOsDiskNum = $diskNum - $tempOsPartNum = $candidatePartNum - $isOsPath = $true - Log-Info "Disk ${diskNum}: Windows loader found on temporary drive ${candidateLetter}:" - break - } + $mounted = Test-Path -LiteralPath "${candidateLetter}:\" -PathType Container + $winloadExePath = "${candidateLetter}:\Windows\System32\winload.exe" + $winloadEfiPath = "${candidateLetter}:\Windows\System32\winload.efi" + $hasWinloadExe = $mounted -and (Test-Path -LiteralPath $winloadExePath -PathType Leaf) + $hasWinloadEfi = $mounted -and (Test-Path -LiteralPath $winloadEfiPath -PathType Leaf) + + Log-Info "Disk ${diskNum}: checked temporary ${candidateLetter}:; mounted=$mounted winload.exe=$hasWinloadExe winload.efi=$hasWinloadEfi" + + if ($hasWinloadExe -or $hasWinloadEfi) { + $tempOsLetter = $candidateLetter + $tempOsDiskNum = $diskNum + $tempOsPartNum = $candidatePartNum + $isOsPath = $true + Log-Info "Disk ${diskNum}: Windows loader confirmed on temporary ${candidateLetter}:" + break } $dpOsRemove = @( @@ -591,27 +615,26 @@ try { } } - # BCD selection is generation-aware and remains restricted to this target disk. if ($isGen2Disk) { - # For Gen2, the authoritative store must be on the EFI System Partition. - # Do not fall back to :\boot\bcd. - foreach ($efiPartition in $efiPartitions) { + # Gen2 must use the BCD on the EFI System Partition on this disk. + foreach ($efiPart in $efiParts) { $efiLetter = $null $letterAssignedByScript = $false - if ($efiPartition.DriveLetter -and $efiPartition.DriveLetter -ne [char]0) { - $efiLetter = [string]$efiPartition.DriveLetter + if ($efiPart.DriveLetter -and $efiPart.DriveLetter -ne [char]0) { + $efiLetter = [string]$efiPart.DriveLetter } else { $efiLetter = Get-AvailableTempDriveLetter if (-not $efiLetter) { - Log-Warning "Disk ${diskNum}: no temporary drive letter is available for EFI Partition $($efiPartition.PartitionNumber)." + Log-Warning "Disk ${diskNum}: no temporary drive letter is available for EFI Partition $($efiPart.PartitionNumber)." continue } + Log-Info "Disk ${diskNum}: assigning ${efiLetter}: to EFI Partition $($efiPart.PartitionNumber)." $dpEfiAssign = @( "select disk $diskNum" - "select partition $($efiPartition.PartitionNumber)" + "select partition $($efiPart.PartitionNumber)" "assign letter=$efiLetter" ) $dpEfiAssignOut = $dpEfiAssign | diskpart 2>&1 @@ -623,7 +646,7 @@ try { } if (-not $efiLetter -or -not (Test-Path -LiteralPath "${efiLetter}:\" -PathType Container)) { - Log-Warning "Disk ${diskNum}: EFI Partition $($efiPartition.PartitionNumber) could not be mounted." + Log-Warning "Disk ${diskNum}: EFI Partition $($efiPart.PartitionNumber) could not be mounted." continue } @@ -634,20 +657,19 @@ try { $bcdPath = $candidateBcdPath $isBcdPath = $true Log-Info "Disk ${diskNum}: selected Gen2 EFI BCD store: $bcdPath" - if ($letterAssignedByScript) { $tempEfiLetter = $efiLetter $tempEfiDiskNum = $diskNum - $tempEfiPartNum = $efiPartition.PartitionNumber + $tempEfiPartNum = $efiPart.PartitionNumber } break } - Log-Warning "Disk ${diskNum}: no BCD store was found at $candidateBcdPath." + Log-Warning "Disk ${diskNum}: no BCD store found at $candidateBcdPath." if ($letterAssignedByScript) { $dpEfiRemove = @( "select disk $diskNum" - "select partition $($efiPartition.PartitionNumber)" + "select partition $($efiPart.PartitionNumber)" "remove letter=$efiLetter noerr" ) $dpEfiRemoveOut = $dpEfiRemove | diskpart 2>&1 @@ -658,11 +680,16 @@ try { } } else { - # Gen1: locate \boot\bcd only on mounted partitions belonging to this disk. - foreach ($drive in $partitionGroup.Group | Select-Object -ExpandProperty DriveLetter | Where-Object { $_ }) { + # Gen1 uses the BCD from a mounted System Reserved partition. + foreach ($partition in @($diskPartitions | Where-Object { + $_.DriveLetter -and $_.DriveLetter -ne [char]0 + })) { + $drive = [string]$partition.DriveLetter if ($drive -eq $repairDrive) { continue } - $candidateBcdPath = "${drive}:\boot\bcd" - if (Test-Path -LiteralPath $candidateBcdPath -PathType Leaf) { + $candidateBcdPath = "${drive}:\Boot\BCD" + $candidateExists = Test-Path -LiteralPath $candidateBcdPath -PathType Leaf + Log-Info "Disk ${diskNum}: checking Gen1 BCD candidate $candidateBcdPath; exists=$candidateExists" + if ($candidateExists) { $bcdPath = $candidateBcdPath $isBcdPath = $true Log-Info "Disk ${diskNum}: selected Gen1 BCD store: $bcdPath" @@ -671,13 +698,13 @@ try { } } - if ($isBcdPath) { - Log-Info "Disk ${diskNum}: final BCD path selected for modification: $bcdPath" - } - else { + Log-Info "Disk ${diskNum}: final discovery state isBcdPath=$isBcdPath isOsPath=$isOsPath bcdPath=$bcdPath" + if (-not $isBcdPath) { Log-Warning "Disk ${diskNum}: no generation-appropriate BCD store was found." } - + if (-not $isOsPath) { + Log-Warning "Disk ${diskNum}: no Windows loader was found." + } # Apply SAC changes if both BCD and OS loader were found if ( $isBcdPath -and $isOsPath ) From 2cc5f473eb9dcdc947af566d99741a625e36cbcf Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:43:13 +0300 Subject: [PATCH 29/43] Update --- src/windows/sac-enabler.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 154d9368..4ac88470 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -731,7 +731,7 @@ try { $defaultId = $matches[0] # VALIDATION: Confirm we have a valid GUID - if ($defaultId -notmatch '^\{[0-9a-f\-]{36}\}$') { + if ($defaultId -notmatch '^(?i)\{[0-9a-f\-]{36}\}$') { Log-Error "Invalid boot entry GUID format: $defaultId. This may indicate a corrupted BCD store." $diskFailed = $true } From 603275bc223be6efdcfcc75eee6986c7186373e1 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:41:45 +0300 Subject: [PATCH 30/43] Update --- src/windows/sac-enabler.ps1 | 99 ++++++++++++++++++++++++++++++++----- 1 file changed, 87 insertions(+), 12 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 4ac88470..6c587170 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -3,7 +3,7 @@ Enables SAC and Serial Console boot settings on attached Windows disks, including BIOS and UEFI layouts. .DESCRIPTION - This script runs only from a repair VM to enable SAC/EMS on an attached OS disk's BCD store. + This script must be run from a repair VM only to enable SAC/EMS on an attached OS disk's BCD store. It performs the following steps: 1. Enumerates attached partitions via Get-Disk-Partitions to locate the BCD store and OS loader. OS detection accepts either winload.exe or winload.efi. @@ -13,7 +13,7 @@ If the default entry cannot be determined, the script logs an explicit warning. 3. Logs the BCD configuration before any changes are made. 4. Enables the boot menu with a 5-second timeout (displaybootmenu, timeout). - 5. Enables Boot EMS on the boot manager (bootems yes). + 5. Enables Boot EMS on Boot Manager (bootems yes). 6. Enables EMS on the default OS entry (ems ON). 7. Configures EMS settings for serial console (EMSPORT:1, EMSBAUDRATE:115200). 8. Logs the BCD configuration after changes for verification. @@ -21,11 +21,16 @@ .NOTES Name: sac-enabler.ps1 Author: Tony.Mocanu@Microsoft.com + Last Modified: 2026-08-05 + Version: 1.4 Requirement: Azure repair VM with an attached Windows OS disk DeployMode: az vm repair run (with --run-on-repair) .VERSION - v1.3: [July 2026] - Restricted execution to repair VM mode (current). + v1.3: [August 2026] - Added VMRepairMint telemetry, structured before-state capture, and Gen1/Gen2 discovery telemetry. + - Added explicit winload.exe and winload.efi detection. + - Added explicit displayorder and boot entry GUID failure diagnostics. + Update [July 2026] - Restricted execution to repair VM mode. - Uses Get-Disk-Partitions to enumerate Azure virtual disks. - Detects repair vs. standard context from secondary disks returned by the helper. - Mounts unlettered Gen2 Windows and EFI partitions temporarily. @@ -34,8 +39,8 @@ - Fails closed if the repair VM OS disk cannot be identified. - Filters out the repair VM OS disk before processing attached disks. Update: [July 2026] - Added execution context detection and dual-logging. - - Detected rescue VM mode vs standard mode for context-aware error messages. - - Dual-logs to desktop and plugin directory for az vm repair auto-collection. + - Detected rescue VM mode versus standard mode for context-aware error messages. + - Logs to both the desktop and the plugin directory for automatic collection by az vm repair. - **NEW SAFETY: Pre-flight checks, BCD backup, and post-change verification. - **NEW SAFETY: Validates GUID format before making BCD edits. - **NEW SAFETY: Verifies EMS was actually enabled after bcdedit commands. @@ -125,7 +130,7 @@ bcdedit /store P:\efi\microsoft\boot\bcd /enum "{bootmgr}" The temporary letter is removed after processing. .ROLLBACK_RECOVERY - IF THE VM FAILS TO BOOT AFTER az vm repair restore: + IF THE VM FAILS TO BOOT AFTER RUNNING az vm repair restore: 1. Boot the VM from the Windows installation media or attach to a repair VM. 2. Locate the BCD backup file created by the script: @@ -144,10 +149,13 @@ bcdedit /store P:\efi\microsoft\boot\bcd /enum "{bootmgr}" 5. Boot the VM. It should start normally without SAC/EMS enabled. - ALTERNATIVE (if BCD restore doesn't work): + ALTERNATIVE (if BCD restore does not work): - Use sfc /scannow from Windows Recovery Environment to repair system files - Use bcdboot.exe to rebuild the BCD store from scratch - See internal troubleshooting guide: azure-vm-dump-issues.md + +.LINK + https://github.com/Azure/repair-script-library #> # Initialization (path-validated) @@ -222,7 +230,7 @@ $runTimestamp = Get-Date -Format 'yyyyMMdd-HHmmss' $desktopLogDir = Join-Path -Path $env:PUBLIC -ChildPath ("Desktop\\{0}-run-{1}" -f $scriptName, $runTimestamp) $desktopLogFile = Join-Path -Path $desktopLogDir -ChildPath ("{0}-{1}.log" -f $scriptName, $runTimestamp) -# Plugin directory log (for az vm repair auto-collection) +# Plugin directory log (for automatic collection by az vm repair) $pluginLogDir = 'C:\WindowsAzure\Logs\Plugins\Microsoft.Compute.CustomScriptExtension\' $pluginLogFile = Join-Path -Path $pluginLogDir -ChildPath ("{0}_{1}.log" -f $scriptName, $runTimestamp) @@ -397,7 +405,7 @@ function Invoke-SacBcdEdit { } $logFile = $logFilePath -Log-Info "Dual logging initialized - Desktop: $desktopLogFile | Plugin: $pluginLogFile" +Log-Info "Dual logging initialized: Desktop: $desktopLogFile | Plugin: $pluginLogFile" Log-Info "Script classification: REPAIR_VM_ONLY" # Status Tracking @@ -412,10 +420,21 @@ $changedCount = 0 Log-Info "Starting repair-only SAC enabler. Logs: $logFile" Log-Info "Build marker: v1.4-os-and-bcd-discovery-fix" +# VMRepairMint telemetry marker +Log-Info "[script_start] Script=sac-enabler Version=$($script:RepairScriptVersion)" + +Write-SacTelemetry -Event Start -Message 'script_start' -Properties @{ + ScriptName = 'sac-enabler.ps1' + ScriptVersion = $script:RepairScriptVersion + BuildMarker = 'v1.4-os-and-bcd-discovery-fix' + StartTimeUtc = (Get-Date).ToUniversalTime().ToString('o') +} + $hostOs = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction SilentlyContinue Write-SacTelemetry -Event Start -Message 'Starting SAC/EMS enablement' -Properties @{ OSVersion = if ($hostOs) { $hostOs.Version } else { [Environment]::OSVersion.Version.ToString() } OSCaption = if ($hostOs) { $hostOs.Caption } else { 'Unknown' } + VMGeneration = 'PendingTargetDiskDiscovery' ExecutionMode = 'REPAIR_VM_ONLY' DesktopLog = $desktopLogFile PluginLog = $pluginLogFile @@ -439,7 +458,7 @@ try { } catch { # Orphan detection is optional; don't block on failure - Log-Debug "Orphan detection encountered an error (non-critical): $($_.Exception.Message)" + Log-Debug "Orphan detection encountered a noncritical error: $($_.Exception.Message)" } # Check if the Hyper-V module is available before performing nested VM checks @@ -452,7 +471,7 @@ try { Stop-VM $guestHyperVVirtualMachine -ErrorAction Stop -Force } catch { - Log-Warning "Failed to stop nested guest VM, will continue but may have limited success" + Log-Warning "Failed to stop the nested guest VM; continuing, but the repair may have limited success." } } } @@ -524,6 +543,11 @@ try { $isGen2Disk = $efiParts.Count -gt 0 Log-Info "Disk ${diskNum}: generation detection result = $(if ($isGen2Disk) { 'Gen2/UEFI' } else { 'Gen1/BIOS' })" + Write-SacTelemetry -Event Operation -Message 'Target disk generation detected' -Properties @{ + DiskNumber = $diskNum + VMGeneration = if ($isGen2Disk) { 'V2' } else { 'V1' } + EfiPartitionCount = $efiParts.Count + } foreach ($partition in $diskPartitions) { $displayLetter = if ($partition.DriveLetter -and $partition.DriveLetter -ne [char]0) { @@ -741,10 +765,22 @@ try { $bcdBackup = $bcdPath + '.backup-' + (Get-Date -Format 'yyyyMMdd-HHmmss') try { Copy-Item -Path $bcdPath -Destination $bcdBackup -Force -ErrorAction Stop + Log-Info "BCD backup created at: $bcdBackup" + + Write-SacTelemetry -Event Operation -Message 'BCD backup created' -Properties @{ + DiskNumber = $diskNumber + BcdPath = $bcdPath + BackupPath = $bcdBackup + } } catch { Log-Warning "Could not create BCD backup: $($_.Exception.Message). Proceeding with caution." + Write-SacTelemetry -Event Error -Message 'BCD backup creation failed' -Properties @{ + DiskNumber = $diskNumber + BcdPath = $bcdPath + Error = $_.Exception.Message + } } # Step 3 - Log BCD configuration before changes @@ -754,6 +790,22 @@ try { throw "Unable to capture the BCD before-state for $defaultId. Exit code: $($beforeQuery.ExitCode)." } $beforeBcd = $beforeQuery.Output + $beforeLoaderText = $beforeBcd -join "`n" + $beforeBootMgrText = $bootMgrQuery.Output -join "`n" + $beforeEmsEnabled = $beforeLoaderText -match '(?im)^\s*ems\s+Yes\s*$' + $beforeBootEmsEnabled = $beforeBootMgrText -match '(?im)^\s*bootems\s+Yes\s*$' + $beforeBootMenuEnabled = $beforeBootMgrText -match '(?im)^\s*displaybootmenu\s+Yes\s*$' + + Write-SacTelemetry -Event Operation -Message 'Before-state captured' -Properties @{ + DiskNumber = $diskNumber + VMGeneration = if ($isGen2Disk) { 'V2' } else { 'V1' } + BcdPath = $bcdPath + LoaderGuid = $defaultId + EMS = if ($beforeEmsEnabled) { 'Yes' } else { 'NoOrAbsent' } + BootEms = if ($beforeBootEmsEnabled) { 'Yes' } else { 'NoOrAbsent' } + DisplayBootMenu = if ($beforeBootMenuEnabled) { 'Yes' } else { 'NoOrAbsent' } + } + foreach ($line in $beforeBcd) { if (-not [string]::IsNullOrWhiteSpace([string]$line)) { Log-Output $line } } # Steps 4-7 - Enable boot menu, Boot EMS, EMS on OS entry, and EMS serial settings @@ -813,6 +865,17 @@ try { } if (-not $verificationPassed) { + Write-SacTelemetry -Event Error -Message 'BCD verification failed' -Properties @{ + DiskNumber = $diskNumber + BcdPath = $bcdPath + BackupPath = $bcdBackup + EmsEnabled = $emsEnabled + BootEmsEnabled = $bootEmsEnabled + BootMenuEnabled = $bootMenuEnabled + TimeoutConfigured = $timeoutConfigured + PortConfigured = $portConfigured + BaudConfigured = $baudConfigured + } Log-Error "CRITICAL: SAC/EMS verification failed. Restore from backup if needed: $bcdBackup" Log-Error "Verification state: ems=$emsEnabled bootems=$bootEmsEnabled displaybootmenu=$bootMenuEnabled timeout=$timeoutConfigured port=$portConfigured baud=$baudConfigured" $failureReason = "Post-change BCD verification failed for Disk $diskNumber." @@ -835,7 +898,7 @@ try { } } else { - Log-Info "Disk $diskNumber skipped: no valid BCD + OS loader combination was found." + Log-Info "Disk $diskNumber skipped: no valid combination of BCD store and OS loader was found." } } catch { $diskFailed = $true @@ -915,6 +978,18 @@ finally { } } +# VMRepairMint telemetry marker +Log-Info "[final_status] Status=$script_final_status" + +Write-SacTelemetry -Event Operation -Message 'final_status' -Properties @{ + FinalStatus = $script_final_status + DurationSeconds = $durationSeconds + DisksProcessed = $processedCount + DisksChanged = $changedCount + DisksSkipped = $skippedCount + DisksFailed = $failedCount +} + Log-Info "Summary: processed=$processedCount changed=$changedCount skipped=$skippedCount failed=$failedCount" Log-Info "Detected execution context: $detectedExecutionContext" Log-Info "Desktop log: $desktopLogFile" From 985bace95e4f5fdd0df98c7eda52470c253a2d02 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:23:49 +0300 Subject: [PATCH 31/43] Update --- src/windows/sac-enabler.ps1 | 163 ++++++++++++++++++++++++++++++++---- 1 file changed, 147 insertions(+), 16 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 6c587170..df8f519f 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -9,8 +9,8 @@ OS detection accepts either winload.exe or winload.efi. 1a. For Gen2 disks where the EFI partition has no drive letter, uses diskpart to temporarily assign one so the BCD store can be accessed. - 2. Identifies the default boot entry GUID from the BCD bootmgr displayorder. - If the default entry cannot be determined, the script logs an explicit warning. + 2. Identifies and validates the Windows Boot Loader entry referenced by the BCD bootmgr default element. + The script fails closed if the entry does not map to the discovered Windows partition. 3. Logs the BCD configuration before any changes are made. 4. Enables the boot menu with a 5-second timeout (displaybootmenu, timeout). 5. Enables Boot EMS on Boot Manager (bootems yes). @@ -22,12 +22,15 @@ Name: sac-enabler.ps1 Author: Tony.Mocanu@Microsoft.com Last Modified: 2026-08-05 - Version: 1.4 + Version: 1.5 Requirement: Azure repair VM with an attached Windows OS disk DeployMode: az vm repair run (with --run-on-repair) .VERSION - v1.3: [August 2026] - Added VMRepairMint telemetry, structured before-state capture, and Gen1/Gen2 discovery telemetry. + v1.5: [August 2026] - Validates loader mapping before BCD writes and verifies mapping invariance afterward. + - Requires and verifies a BCD backup before applying SAC settings. + - Restores the backup if path, device, osdevice, or systemroot changes unexpectedly. + v1.4: [August 2026] - Added VMRepairMint telemetry, structured before-state capture, and Gen1/Gen2 discovery telemetry. - Added explicit winload.exe and winload.efi detection. - Added explicit displayorder and boot entry GUID failure diagnostics. Update [July 2026] - Restricted execution to repair VM mode. @@ -338,7 +341,7 @@ function Log-Debug { } # Structured telemetry is written through the existing dual-write logging path. -$script:RepairScriptVersion = '1.4' +$script:RepairScriptVersion = '1.5' $script:ExecutionStarted = Get-Date $script:OperationCount = 0 $script:LastCommand = $null @@ -418,7 +421,7 @@ $failedCount = 0 $changedCount = 0 Log-Info "Starting repair-only SAC enabler. Logs: $logFile" -Log-Info "Build marker: v1.4-os-and-bcd-discovery-fix" +Log-Info "Build marker: v1.5-loader-mapping-safety" # VMRepairMint telemetry marker Log-Info "[script_start] Script=sac-enabler Version=$($script:RepairScriptVersion)" @@ -426,7 +429,7 @@ Log-Info "[script_start] Script=sac-enabler Version=$($script:RepairScriptVersio Write-SacTelemetry -Event Start -Message 'script_start' -Properties @{ ScriptName = 'sac-enabler.ps1' ScriptVersion = $script:RepairScriptVersion - BuildMarker = 'v1.4-os-and-bcd-discovery-fix' + BuildMarker = 'v1.5-loader-mapping-safety' StartTimeUtc = (Get-Date).ToUniversalTime().ToString('o') } @@ -520,6 +523,8 @@ try { $isBcdPath = $false $bcdPath = '' $isOsPath = $false + $windowsDrive = $null + $detectedLoaderPath = $null $tempEfiLetter = $null $tempEfiDiskNum = $null $tempEfiPartNum = $null @@ -576,7 +581,9 @@ try { if ($hasWinloadExe -or $hasWinloadEfi) { $isOsPath = $true - Log-Info "Disk ${diskNum}: Windows loader confirmed on ${drive}:" + $windowsDrive = $drive + $detectedLoaderPath = if ($hasWinloadEfi) { '\Windows\System32\winload.efi' } else { '\Windows\System32\winload.exe' } + Log-Info "Disk ${diskNum}: Windows loader confirmed on ${windowsDrive}: path=$detectedLoaderPath" break } } @@ -623,7 +630,9 @@ try { $tempOsDiskNum = $diskNum $tempOsPartNum = $candidatePartNum $isOsPath = $true - Log-Info "Disk ${diskNum}: Windows loader confirmed on temporary ${candidateLetter}:" + $windowsDrive = $candidateLetter + $detectedLoaderPath = if ($hasWinloadEfi) { '\Windows\System32\winload.efi' } else { '\Windows\System32\winload.exe' } + Log-Info "Disk ${diskNum}: Windows loader confirmed on temporary ${windowsDrive}: path=$detectedLoaderPath" break } @@ -743,12 +752,12 @@ try { } $bcdout = $bootMgrQuery.Output - $defaultLine = $bcdout | Select-String -Pattern '^\s*displayorder\s+' | Select-Object -First 1 + $defaultLine = $bcdout | Select-String -Pattern '^\s*default\s+' | Select-Object -First 1 if (-not $defaultLine) { - $failureReason = "Could not locate a displayorder entry in boot manager output for $bcdPath." - Log-Warning "Could not locate a displayorder entry in boot manager output for $bcdPath. Unable to determine the default boot entry." + $failureReason = "Could not locate the default Windows Boot Loader entry in boot manager output for $bcdPath." + Log-Warning "$failureReason The script will not fall back to the first displayorder entry." $diskFailed = $true } elseif ($defaultLine -match '\{([^}]+)\}') { @@ -761,10 +770,84 @@ try { } else { + # Validate the selected Windows Boot Loader before any BCD write. + $selectedLoaderQuery = Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/enum', $defaultId, '/v') -Operation 'validate-selected-loader' + if (-not $selectedLoaderQuery.Success) { + throw "Unable to enumerate selected loader $defaultId from $bcdPath. No BCD changes were made." + } + + $selectedLoaderText = $selectedLoaderQuery.Output -join "`n" + $selectedPathMatch = [regex]::Match($selectedLoaderText, '(?im)^\s*path\s+(.+?)\s*$') + $selectedDeviceMatch = [regex]::Match($selectedLoaderText, '(?im)^\s*device\s+(.+?)\s*$') + $selectedOsDeviceMatch = [regex]::Match($selectedLoaderText, '(?im)^\s*osdevice\s+(.+?)\s*$') + $selectedSystemRootMatch = [regex]::Match($selectedLoaderText, '(?im)^\s*systemroot\s+(.+?)\s*$') + + if (-not $selectedPathMatch.Success) { + throw "Selected loader $defaultId has no path element. No BCD changes were made." + } + if (-not $selectedDeviceMatch.Success) { + throw "Selected loader $defaultId has no device element. No BCD changes were made." + } + if (-not $selectedOsDeviceMatch.Success) { + throw "Selected loader $defaultId has no osdevice element. No BCD changes were made." + } + if (-not $selectedSystemRootMatch.Success) { + throw "Selected loader $defaultId has no systemroot element. No BCD changes were made." + } + + $originalLoaderPath = $selectedPathMatch.Groups[1].Value.Trim() + $originalDevice = $selectedDeviceMatch.Groups[1].Value.Trim() + $originalOsDevice = $selectedOsDeviceMatch.Groups[1].Value.Trim() + $originalSystemRoot = $selectedSystemRootMatch.Groups[1].Value.Trim() + + if ($originalLoaderPath -notmatch '(?i)^\\Windows\\System32\\winload\.(exe|efi)$') { + throw "Selected entry $defaultId does not reference winload.exe or winload.efi. Path: $originalLoaderPath. No BCD changes were made." + } + if ($originalDevice -match '(?i)^unknown$') { + throw "Selected loader $defaultId has device=unknown. Separate BCD repair is required; no BCD changes were made." + } + if ($originalOsDevice -match '(?i)^unknown$') { + throw "Selected loader $defaultId has osdevice=unknown. Separate BCD repair is required; no BCD changes were made." + } + + $expectedPartitionMapping = "partition=${windowsDrive}:" + if ($originalDevice -match '(?i)^partition=([a-z]):$' -and $originalDevice -ine $expectedPartitionMapping) { + throw "Selected loader $defaultId maps device to '$originalDevice', not the discovered Windows partition '$expectedPartitionMapping'. No BCD changes were made." + } + if ($originalOsDevice -match '(?i)^partition=([a-z]):$' -and $originalOsDevice -ine $expectedPartitionMapping) { + throw "Selected loader $defaultId maps osdevice to '$originalOsDevice', not the discovered Windows partition '$expectedPartitionMapping'. No BCD changes were made." + } + + $resolvedLoaderFile = Join-Path -Path "${windowsDrive}:\" -ChildPath $originalLoaderPath.TrimStart('\') + if (-not (Test-Path -LiteralPath $resolvedLoaderFile -PathType Leaf)) { + throw "Selected BCD entry references '$originalLoaderPath', but '$resolvedLoaderFile' does not exist. No BCD changes were made." + } + + Write-SacTelemetry -Event Operation -Message 'Selected loader mapping validated' -Properties @{ + DiskNumber = $diskNumber + BcdPath = $bcdPath + LoaderGuid = $defaultId + WindowsDrive = $windowsDrive + DetectedLoaderPath = $detectedLoaderPath + BcdLoaderPath = $originalLoaderPath + Device = $originalDevice + OsDevice = $originalOsDevice + SystemRoot = $originalSystemRoot + } + # VALIDATION: Backup BCD store before any modifications $bcdBackup = $bcdPath + '.backup-' + (Get-Date -Format 'yyyyMMdd-HHmmss') try { - Copy-Item -Path $bcdPath -Destination $bcdBackup -Force -ErrorAction Stop + Copy-Item -LiteralPath $bcdPath -Destination $bcdBackup -Force -ErrorAction Stop + + if (-not (Test-Path -LiteralPath $bcdBackup -PathType Leaf)) { + throw "Expected backup file was not created: $bcdBackup" + } + $bcdLength = (Get-Item -LiteralPath $bcdPath -ErrorAction Stop).Length + $backupLength = (Get-Item -LiteralPath $bcdBackup -ErrorAction Stop).Length + if ($backupLength -ne $bcdLength) { + throw "Backup size $backupLength does not match BCD store size $bcdLength." + } Log-Info "BCD backup created at: $bcdBackup" @@ -775,12 +858,13 @@ try { } } catch { - Log-Warning "Could not create BCD backup: $($_.Exception.Message). Proceeding with caution." Write-SacTelemetry -Event Error -Message 'BCD backup creation failed' -Properties @{ DiskNumber = $diskNumber BcdPath = $bcdPath + BackupPath = $bcdBackup Error = $_.Exception.Message } + throw "Could not create and verify BCD backup for '$bcdPath'. No BCD changes were made. Error: $($_.Exception.Message)" } # Step 3 - Log BCD configuration before changes @@ -827,6 +911,53 @@ try { } } + # SAC changes must not alter the selected loader's boot mapping. + $mappingVerificationQuery = Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/enum', $defaultId, '/v') -Operation 'verify-loader-mapping' + if (-not $mappingVerificationQuery.Success) { + throw "Unable to verify loader mapping after SAC changes. BCD backup: $bcdBackup" + } + + $mappingVerificationText = $mappingVerificationQuery.Output -join "`n" + $afterPathMatch = [regex]::Match($mappingVerificationText, '(?im)^\s*path\s+(.+?)\s*$') + $afterDeviceMatch = [regex]::Match($mappingVerificationText, '(?im)^\s*device\s+(.+?)\s*$') + $afterOsDeviceMatch = [regex]::Match($mappingVerificationText, '(?im)^\s*osdevice\s+(.+?)\s*$') + $afterSystemRootMatch = [regex]::Match($mappingVerificationText, '(?im)^\s*systemroot\s+(.+?)\s*$') + $mappingUnchanged = $afterPathMatch.Success -and + $afterDeviceMatch.Success -and + $afterOsDeviceMatch.Success -and + $afterSystemRootMatch.Success -and + ($afterPathMatch.Groups[1].Value.Trim() -ieq $originalLoaderPath) -and + ($afterDeviceMatch.Groups[1].Value.Trim() -ieq $originalDevice) -and + ($afterOsDeviceMatch.Groups[1].Value.Trim() -ieq $originalOsDevice) -and + ($afterSystemRootMatch.Groups[1].Value.Trim() -ieq $originalSystemRoot) + + if (-not $mappingUnchanged) { + $afterPath = if ($afterPathMatch.Success) { $afterPathMatch.Groups[1].Value.Trim() } else { '' } + $afterDevice = if ($afterDeviceMatch.Success) { $afterDeviceMatch.Groups[1].Value.Trim() } else { '' } + $afterOsDevice = if ($afterOsDeviceMatch.Success) { $afterOsDeviceMatch.Groups[1].Value.Trim() } else { '' } + $afterSystemRoot = if ($afterSystemRootMatch.Success) { $afterSystemRootMatch.Groups[1].Value.Trim() } else { '' } + Log-Error 'BCD loader mapping changed unexpectedly.' + Log-Error "Before: path=$originalLoaderPath device=$originalDevice osdevice=$originalOsDevice systemroot=$originalSystemRoot" + Log-Error "After: path=$afterPath device=$afterDevice osdevice=$afterOsDevice systemroot=$afterSystemRoot" + + Copy-Item -LiteralPath $bcdBackup -Destination $bcdPath -Force -ErrorAction Stop + Write-SacTelemetry -Event Error -Message 'BCD loader mapping changed and backup restored' -Properties @{ + DiskNumber = $diskNumber + BcdPath = $bcdPath + BackupPath = $bcdBackup + LoaderGuid = $defaultId + BeforePath = $originalLoaderPath + AfterPath = $afterPath + BeforeDevice = $originalDevice + AfterDevice = $afterDevice + BeforeOsDevice = $originalOsDevice + AfterOsDevice = $afterOsDevice + BeforeSystemRoot = $originalSystemRoot + AfterSystemRoot = $afterSystemRoot + } + throw 'BCD loader mapping changed unexpectedly. The original BCD backup was restored.' + } + # Verify every setting requested by the repair. Log-Info 'Verifying BCD changes...' $verifyLoaderQuery = Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/enum', $defaultId) -Operation 'verify-loader' @@ -892,8 +1023,8 @@ try { } else { - $failureReason = "Displayorder entry was found but no boot entry GUID could be parsed for $bcdPath." - Log-Warning "Displayorder entry was found but no boot entry GUID could be parsed for $bcdPath. Raw line: $($defaultLine.Line)" + $failureReason = "The default Windows Boot Loader entry was found, but no GUID could be parsed for $bcdPath." + Log-Warning "$failureReason Raw line: $($defaultLine.Line)" $diskFailed = $true } } From 9faefe7acccc8608a38ca8017e5092dcbbd15481 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:43:29 +0300 Subject: [PATCH 32/43] Update --- src/windows/sac-enabler.ps1 | 43 ++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index df8f519f..8d7632fb 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -10,7 +10,7 @@ 1a. For Gen2 disks where the EFI partition has no drive letter, uses diskpart to temporarily assign one so the BCD store can be accessed. 2. Identifies and validates the Windows Boot Loader entry referenced by the BCD bootmgr default element. - The script fails closed if the entry does not map to the discovered Windows partition. + The script fails closed if the entry's loader path does not resolve on the discovered Windows partition. 3. Logs the BCD configuration before any changes are made. 4. Enables the boot menu with a 5-second timeout (displaybootmenu, timeout). 5. Enables Boot EMS on Boot Manager (bootems yes). @@ -22,11 +22,13 @@ Name: sac-enabler.ps1 Author: Tony.Mocanu@Microsoft.com Last Modified: 2026-08-05 - Version: 1.5 + Version: 1.5.1 Requirement: Azure repair VM with an attached Windows OS disk DeployMode: az vm repair run (with --run-on-repair) .VERSION + v1.5.1: [August 2026] - Avoids comparing guest BCD drive letters with repair-VM mount letters. + - Uses VM generation when selecting the detected loader path for logging. v1.5: [August 2026] - Validates loader mapping before BCD writes and verifies mapping invariance afterward. - Requires and verifies a BCD backup before applying SAC settings. - Restores the backup if path, device, osdevice, or systemroot changes unexpectedly. @@ -341,7 +343,7 @@ function Log-Debug { } # Structured telemetry is written through the existing dual-write logging path. -$script:RepairScriptVersion = '1.5' +$script:RepairScriptVersion = '1.5.1' $script:ExecutionStarted = Get-Date $script:OperationCount = 0 $script:LastCommand = $null @@ -421,7 +423,7 @@ $failedCount = 0 $changedCount = 0 Log-Info "Starting repair-only SAC enabler. Logs: $logFile" -Log-Info "Build marker: v1.5-loader-mapping-safety" +Log-Info "Build marker: v1.5.1-offline-drive-mapping-fix" # VMRepairMint telemetry marker Log-Info "[script_start] Script=sac-enabler Version=$($script:RepairScriptVersion)" @@ -429,7 +431,7 @@ Log-Info "[script_start] Script=sac-enabler Version=$($script:RepairScriptVersio Write-SacTelemetry -Event Start -Message 'script_start' -Properties @{ ScriptName = 'sac-enabler.ps1' ScriptVersion = $script:RepairScriptVersion - BuildMarker = 'v1.5-loader-mapping-safety' + BuildMarker = 'v1.5.1-offline-drive-mapping-fix' StartTimeUtc = (Get-Date).ToUniversalTime().ToString('o') } @@ -582,7 +584,15 @@ try { if ($hasWinloadExe -or $hasWinloadEfi) { $isOsPath = $true $windowsDrive = $drive - $detectedLoaderPath = if ($hasWinloadEfi) { '\Windows\System32\winload.efi' } else { '\Windows\System32\winload.exe' } + $detectedLoaderPath = if ($isGen2Disk -and $hasWinloadEfi) { + '\Windows\System32\winload.efi' + } + elseif ($hasWinloadExe) { + '\Windows\System32\winload.exe' + } + else { + '\Windows\System32\winload.efi' + } Log-Info "Disk ${diskNum}: Windows loader confirmed on ${windowsDrive}: path=$detectedLoaderPath" break } @@ -631,7 +641,15 @@ try { $tempOsPartNum = $candidatePartNum $isOsPath = $true $windowsDrive = $candidateLetter - $detectedLoaderPath = if ($hasWinloadEfi) { '\Windows\System32\winload.efi' } else { '\Windows\System32\winload.exe' } + $detectedLoaderPath = if ($isGen2Disk -and $hasWinloadEfi) { + '\Windows\System32\winload.efi' + } + elseif ($hasWinloadExe) { + '\Windows\System32\winload.exe' + } + else { + '\Windows\System32\winload.efi' + } Log-Info "Disk ${diskNum}: Windows loader confirmed on temporary ${windowsDrive}: path=$detectedLoaderPath" break } @@ -810,14 +828,9 @@ try { throw "Selected loader $defaultId has osdevice=unknown. Separate BCD repair is required; no BCD changes were made." } - $expectedPartitionMapping = "partition=${windowsDrive}:" - if ($originalDevice -match '(?i)^partition=([a-z]):$' -and $originalDevice -ine $expectedPartitionMapping) { - throw "Selected loader $defaultId maps device to '$originalDevice', not the discovered Windows partition '$expectedPartitionMapping'. No BCD changes were made." - } - if ($originalOsDevice -match '(?i)^partition=([a-z]):$' -and $originalOsDevice -ine $expectedPartitionMapping) { - throw "Selected loader $defaultId maps osdevice to '$originalOsDevice', not the discovered Windows partition '$expectedPartitionMapping'. No BCD changes were made." - } - + # Offline BCD output uses the guest's drive-letter namespace (commonly C:), + # while the repair VM mounts that partition under a temporary letter. + # Validate identity by resolving the BCD loader path on the discovered partition. $resolvedLoaderFile = Join-Path -Path "${windowsDrive}:\" -ChildPath $originalLoaderPath.TrimStart('\') if (-not (Test-Path -LiteralPath $resolvedLoaderFile -PathType Leaf)) { throw "Selected BCD entry references '$originalLoaderPath', but '$resolvedLoaderFile' does not exist. No BCD changes were made." From 94e955eabe63d7a9c8c0b50d7101e4de1af2db38 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:08:29 +0300 Subject: [PATCH 33/43] Update --- src/windows/sac-enabler.ps1 | 71 ++++++++++++++++++++----------------- 1 file changed, 39 insertions(+), 32 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 8d7632fb..e18ffea9 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -12,21 +12,23 @@ 2. Identifies and validates the Windows Boot Loader entry referenced by the BCD bootmgr default element. The script fails closed if the entry's loader path does not resolve on the discovered Windows partition. 3. Logs the BCD configuration before any changes are made. - 4. Enables the boot menu with a 5-second timeout (displaybootmenu, timeout). - 5. Enables Boot EMS on Boot Manager (bootems yes). - 6. Enables EMS on the default OS entry (ems ON). - 7. Configures EMS settings for serial console (EMSPORT:1, EMSBAUDRATE:115200). - 8. Logs the BCD configuration after changes for verification. + 4. Enables EMS on the default OS entry (ems ON). + 5. Configures EMS settings for serial console (EMSPORT:1, EMSBAUDRATE:115200). + 6. Logs the BCD configuration after changes for verification. .NOTES Name: sac-enabler.ps1 Author: Tony.Mocanu@Microsoft.com Last Modified: 2026-08-05 - Version: 1.5.1 + Version: 1.5.3 Requirement: Azure repair VM with an attached Windows OS disk DeployMode: az vm repair run (with --run-on-repair) .VERSION + v1.5.3: [August 2026] - Uses only the documented offline EMS and EMS settings commands. + - Does not enable the optional Windows boot menu or Boot Manager EMS. + v1.5.2: [August 2026] - Restores the verified BCD backup after any post-write failure. + - Preserves device and osdevice mappings without normalization. v1.5.1: [August 2026] - Avoids comparing guest BCD drive letters with repair-VM mount letters. - Uses VM generation when selecting the detected loader path for logging. v1.5: [August 2026] - Validates loader mapping before BCD writes and verifies mapping invariance afterward. @@ -107,7 +109,7 @@ bcdedit /store S:\efi\microsoft\boot\bcd /set "{bootmgr}" displaybootmenu no bcdedit /store F:\boot\bcd /enum "{default}" bcdedit /store S:\efi\microsoft\boot\bcd /enum "{default}" Expected: ems = No or absent, bootems = No or absent. - 5. Run the script. It should enable ems, bootems, displaybootmenu, and emssettings. + 5. Run the script. It should enable ems and configure emssettings. 6. Verify all SAC settings are now enabled (see .VERIFICATION section). .EXAMPLE @@ -127,8 +129,7 @@ bcdedit /store F:\boot\bcd /enum "{bootmgr}" bcdedit /store P:\efi\microsoft\boot\bcd /enum "{default}" bcdedit /store P:\efi\microsoft\boot\bcd /enum "{bootmgr}" - Expected: ems = Yes on the OS entry, bootems = Yes on bootmgr, - displaybootmenu = Yes, timeout = 5, EMSPORT = 1, EMSBAUDRATE = 115200. + Expected: ems = Yes on the OS entry, EMSPORT = 1, and EMSBAUDRATE = 115200. NOTE: For Gen2 disks, the script automatically assigns a temporary drive letter to the EFI System Partition via diskpart if Get-Disk-Partitions did not assign one. @@ -343,7 +344,7 @@ function Log-Debug { } # Structured telemetry is written through the existing dual-write logging path. -$script:RepairScriptVersion = '1.5.1' +$script:RepairScriptVersion = '1.5.3' $script:ExecutionStarted = Get-Date $script:OperationCount = 0 $script:LastCommand = $null @@ -423,7 +424,7 @@ $failedCount = 0 $changedCount = 0 Log-Info "Starting repair-only SAC enabler. Logs: $logFile" -Log-Info "Build marker: v1.5.1-offline-drive-mapping-fix" +Log-Info "Build marker: v1.5.3-minimal-offline-ems" # VMRepairMint telemetry marker Log-Info "[script_start] Script=sac-enabler Version=$($script:RepairScriptVersion)" @@ -431,7 +432,7 @@ Log-Info "[script_start] Script=sac-enabler Version=$($script:RepairScriptVersio Write-SacTelemetry -Event Start -Message 'script_start' -Properties @{ ScriptName = 'sac-enabler.ps1' ScriptVersion = $script:RepairScriptVersion - BuildMarker = 'v1.5.1-offline-drive-mapping-fix' + BuildMarker = 'v1.5.3-minimal-offline-ems' StartTimeUtc = (Get-Date).ToUniversalTime().ToString('o') } @@ -534,6 +535,8 @@ try { $tempOsDiskNum = $null $tempOsPartNum = $null $bcdBackup = $null + $bcdWriteStarted = $false + $bcdBackupRestored = $false Log-Info "Processing Disk $diskNumber" @@ -905,19 +908,17 @@ try { foreach ($line in $beforeBcd) { if (-not [string]::IsNullOrWhiteSpace([string]$line)) { Log-Output $line } } - # Steps 4-7 - Enable boot menu, Boot EMS, EMS on OS entry, and EMS serial settings + # Enable only the settings required by Microsoft's offline SAC procedure. Log-Info "Applying SAC and EMS configurations to BCD: $bcdPath" # Execute each command independently. Do not collect command results in a # shared pipeline because repository Log-* helpers write to the success stream. $operationDefinitions = @( - @{ Name = 'displaybootmenu'; Arguments = @('/store', $bcdPath, '/set', '{bootmgr}', 'displaybootmenu', 'yes') } - @{ Name = 'timeout'; Arguments = @('/store', $bcdPath, '/set', '{bootmgr}', 'timeout', '5') } - @{ Name = 'bootems'; Arguments = @('/store', $bcdPath, '/set', '{bootmgr}', 'bootems', 'yes') } @{ Name = 'ems'; Arguments = @('/store', $bcdPath, '/ems', $defaultId, 'ON') } @{ Name = 'emssettings'; Arguments = @('/store', $bcdPath, '/emssettings', 'EMSPORT:1', 'EMSBAUDRATE:115200') } ) foreach ($operationDefinition in $operationDefinitions) { + $bcdWriteStarted = $true $operationResult = Invoke-SacBcdEdit -Arguments $operationDefinition.Arguments -Operation $operationDefinition.Name if (-not $operationResult.Success) { throw "bcdedit operation '$($operationDefinition.Name)' failed with exit code $($operationResult.ExitCode). BCD backup: $bcdBackup" @@ -954,6 +955,7 @@ try { Log-Error "After: path=$afterPath device=$afterDevice osdevice=$afterOsDevice systemroot=$afterSystemRoot" Copy-Item -LiteralPath $bcdBackup -Destination $bcdPath -Force -ErrorAction Stop + $bcdBackupRestored = $true Write-SacTelemetry -Event Error -Message 'BCD loader mapping changed and backup restored' -Properties @{ DiskNumber = $diskNumber BcdPath = $bcdPath @@ -982,27 +984,19 @@ try { $verifyEmsSettingsText = $verifyEmsSettingsQuery.Output -join "`n" $emsEnabled = $verifyLoaderText -match '(?im)^\s*ems\s+Yes\s*$' - $bootEmsEnabled = $verifyBootMgrText -match '(?im)^\s*bootems\s+Yes\s*$' - $bootMenuEnabled = $verifyBootMgrText -match '(?im)^\s*displaybootmenu\s+Yes\s*$' - $timeoutConfigured = $verifyBootMgrText -match '(?im)^\s*timeout\s+5\s*$' # BCDEdit may label these fields as EMSPORT/EMSBAUDRATE or port/baudrate. $portConfigured = $verifyEmsSettingsText -match '(?im)^\s*(?:emsport|port)\s+1\s*$' $baudConfigured = $verifyEmsSettingsText -match '(?im)^\s*(?:emsbaudrate|baudrate)\s+115200\s*$' $verificationPassed = $verifyLoaderQuery.Success -and - $verifyBootMgrQuery.Success -and $verifyEmsSettingsQuery.Success -and - $emsEnabled -and $bootEmsEnabled -and $bootMenuEnabled -and - $timeoutConfigured -and $portConfigured -and $baudConfigured + $emsEnabled -and $portConfigured -and $baudConfigured Write-SacTelemetry -Event Operation -Message 'Post-change BCD verification completed' -Properties @{ DiskNumber = $diskNumber BcdPath = $bcdPath LoaderGuid = $defaultId EmsEnabled = $emsEnabled - BootEmsEnabled = $bootEmsEnabled - BootMenuEnabled = $bootMenuEnabled - TimeoutConfigured = $timeoutConfigured PortConfigured = $portConfigured BaudConfigured = $baudConfigured VerificationPassed = $verificationPassed @@ -1014,16 +1008,12 @@ try { BcdPath = $bcdPath BackupPath = $bcdBackup EmsEnabled = $emsEnabled - BootEmsEnabled = $bootEmsEnabled - BootMenuEnabled = $bootMenuEnabled - TimeoutConfigured = $timeoutConfigured PortConfigured = $portConfigured BaudConfigured = $baudConfigured } Log-Error "CRITICAL: SAC/EMS verification failed. Restore from backup if needed: $bcdBackup" - Log-Error "Verification state: ems=$emsEnabled bootems=$bootEmsEnabled displaybootmenu=$bootMenuEnabled timeout=$timeoutConfigured port=$portConfigured baud=$baudConfigured" - $failureReason = "Post-change BCD verification failed for Disk $diskNumber." - $diskFailed = $true + Log-Error "Verification state: ems=$emsEnabled port=$portConfigured baud=$baudConfigured" + throw "Post-change BCD verification failed for Disk $diskNumber." } else { Log-Output '--- BCD AFTER SAC ENABLE ---' @@ -1051,6 +1041,22 @@ try { if ($_.InvocationInfo -and $_.InvocationInfo.PositionMessage) { Log-Error "Disk $diskNumber failure context: $($_.InvocationInfo.PositionMessage)" } + if ($bcdWriteStarted -and -not $bcdBackupRestored -and $bcdBackup -and (Test-Path -LiteralPath $bcdBackup -PathType Leaf)) { + try { + Copy-Item -LiteralPath $bcdBackup -Destination $bcdPath -Force -ErrorAction Stop + $bcdBackupRestored = $true + Log-Warning "Restored BCD backup after failed operation: $bcdBackup" + Write-SacTelemetry -Event Error -Message 'BCD backup restored after failed operation' -Properties @{ + DiskNumber = $diskNumber + BcdPath = $bcdPath + BackupPath = $bcdBackup + FailureReason = $failureReason + } + } + catch { + Log-Error "CRITICAL: Failed to restore BCD backup '$bcdBackup': $($_.Exception.Message)" + } + } } finally { # Clean up temporary EFI drive letter if one was assigned @@ -1105,8 +1111,9 @@ finally { DisksChanged = $changedCount DisksSkipped = $skippedCount DisksFailed = $failedCount - BootEmsEnabled = $true EmsEnabled = $true + EmsPort = 1 + EmsBaudRate = 115200 } } else { From da52b98d75b9d3ace51c5b75fb43949800133934 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:51:56 +0300 Subject: [PATCH 34/43] Update --- src/windows/sac-enabler.ps1 | 68 ++++++++++++++++++++++++++++--------- 1 file changed, 52 insertions(+), 16 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index e18ffea9..0f0341ea 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -20,11 +20,13 @@ Name: sac-enabler.ps1 Author: Tony.Mocanu@Microsoft.com Last Modified: 2026-08-05 - Version: 1.5.3 + Version: 1.5.4 Requirement: Azure repair VM with an attached Windows OS disk DeployMode: az vm repair run (with --run-on-repair) .VERSION + v1.5.4: [August 2026] - Skips EMS writes for settings that are already correct. + - Logs the full verbose BCD store before and after changes. v1.5.3: [August 2026] - Uses only the documented offline EMS and EMS settings commands. - Does not enable the optional Windows boot menu or Boot Manager EMS. v1.5.2: [August 2026] - Restores the verified BCD backup after any post-write failure. @@ -97,18 +99,14 @@ Set-Partition -DiskNumber -PartitionNumber -NewDriveLetter S Gen1 example (F:\boot\bcd): bcdedit /store F:\boot\bcd /ems "{default}" OFF -bcdedit /store F:\boot\bcd /set "{bootmgr}" bootems no -bcdedit /store F:\boot\bcd /set "{bootmgr}" displaybootmenu no Gen2 example (S:\efi\microsoft\boot\bcd): bcdedit /store S:\efi\microsoft\boot\bcd /ems "{default}" OFF -bcdedit /store S:\efi\microsoft\boot\bcd /set "{bootmgr}" bootems no -bcdedit /store S:\efi\microsoft\boot\bcd /set "{bootmgr}" displaybootmenu no 4. Verify EMS is disabled: bcdedit /store F:\boot\bcd /enum "{default}" bcdedit /store S:\efi\microsoft\boot\bcd /enum "{default}" - Expected: ems = No or absent, bootems = No or absent. + Expected: ems = No or absent. 5. Run the script. It should enable ems and configure emssettings. 6. Verify all SAC settings are now enabled (see .VERIFICATION section). @@ -344,7 +342,7 @@ function Log-Debug { } # Structured telemetry is written through the existing dual-write logging path. -$script:RepairScriptVersion = '1.5.3' +$script:RepairScriptVersion = '1.5.4' $script:ExecutionStarted = Get-Date $script:OperationCount = 0 $script:LastCommand = $null @@ -424,7 +422,7 @@ $failedCount = 0 $changedCount = 0 Log-Info "Starting repair-only SAC enabler. Logs: $logFile" -Log-Info "Build marker: v1.5.3-minimal-offline-ems" +Log-Info "Build marker: v1.5.4-idempotent-offline-ems" # VMRepairMint telemetry marker Log-Info "[script_start] Script=sac-enabler Version=$($script:RepairScriptVersion)" @@ -432,7 +430,7 @@ Log-Info "[script_start] Script=sac-enabler Version=$($script:RepairScriptVersio Write-SacTelemetry -Event Start -Message 'script_start' -Properties @{ ScriptName = 'sac-enabler.ps1' ScriptVersion = $script:RepairScriptVersion - BuildMarker = 'v1.5.3-minimal-offline-ems' + BuildMarker = 'v1.5.4-idempotent-offline-ems' StartTimeUtc = (Get-Date).ToUniversalTime().ToString('o') } @@ -889,12 +887,22 @@ try { if (-not $beforeQuery.Success) { throw "Unable to capture the BCD before-state for $defaultId. Exit code: $($beforeQuery.ExitCode)." } + $beforeEmsSettingsQuery = Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/enum', '{emssettings}', '/v') -Operation 'before-emssettings' + $beforeFullQuery = Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/enum', 'all', '/v') -Operation 'before-full-store' + if (-not $beforeFullQuery.Success) { + throw "Unable to capture the full BCD before-state. Exit code: $($beforeFullQuery.ExitCode)." + } $beforeBcd = $beforeQuery.Output $beforeLoaderText = $beforeBcd -join "`n" $beforeBootMgrText = $bootMgrQuery.Output -join "`n" + $beforeEmsSettingsText = $beforeEmsSettingsQuery.Output -join "`n" $beforeEmsEnabled = $beforeLoaderText -match '(?im)^\s*ems\s+Yes\s*$' $beforeBootEmsEnabled = $beforeBootMgrText -match '(?im)^\s*bootems\s+Yes\s*$' $beforeBootMenuEnabled = $beforeBootMgrText -match '(?im)^\s*displaybootmenu\s+Yes\s*$' + $beforePortConfigured = $beforeEmsSettingsQuery.Success -and + $beforeEmsSettingsText -match '(?im)^\s*(?:emsport|port)\s+1\s*$' + $beforeBaudConfigured = $beforeEmsSettingsQuery.Success -and + $beforeEmsSettingsText -match '(?im)^\s*(?:emsbaudrate|baudrate)\s+115200\s*$' Write-SacTelemetry -Event Operation -Message 'Before-state captured' -Properties @{ DiskNumber = $diskNumber @@ -904,18 +912,32 @@ try { EMS = if ($beforeEmsEnabled) { 'Yes' } else { 'NoOrAbsent' } BootEms = if ($beforeBootEmsEnabled) { 'Yes' } else { 'NoOrAbsent' } DisplayBootMenu = if ($beforeBootMenuEnabled) { 'Yes' } else { 'NoOrAbsent' } + PortConfigured = $beforePortConfigured + BaudConfigured = $beforeBaudConfigured } foreach ($line in $beforeBcd) { if (-not [string]::IsNullOrWhiteSpace([string]$line)) { Log-Output $line } } + Log-Output '--- BCD FULL STORE BEFORE SAC ENABLE ---' + foreach ($line in $beforeFullQuery.Output) { if (-not [string]::IsNullOrWhiteSpace([string]$line)) { Log-Output $line } } # Enable only the settings required by Microsoft's offline SAC procedure. Log-Info "Applying SAC and EMS configurations to BCD: $bcdPath" # Execute each command independently. Do not collect command results in a # shared pipeline because repository Log-* helpers write to the success stream. - $operationDefinitions = @( - @{ Name = 'ems'; Arguments = @('/store', $bcdPath, '/ems', $defaultId, 'ON') } - @{ Name = 'emssettings'; Arguments = @('/store', $bcdPath, '/emssettings', 'EMSPORT:1', 'EMSBAUDRATE:115200') } - ) + $operationDefinitions = @() + if (-not $beforeEmsEnabled) { + $operationDefinitions += @{ Name = 'ems'; Arguments = @('/store', $bcdPath, '/ems', $defaultId, 'ON') } + } + else { + Log-Info "EMS is already enabled on $defaultId; skipping the EMS write." + } + + if (-not ($beforePortConfigured -and $beforeBaudConfigured)) { + $operationDefinitions += @{ Name = 'emssettings'; Arguments = @('/store', $bcdPath, '/emssettings', 'EMSPORT:1', 'EMSBAUDRATE:115200') } + } + else { + Log-Info 'EMS port and baud rate are already configured; skipping the EMS settings write.' + } foreach ($operationDefinition in $operationDefinitions) { $bcdWriteStarted = $true @@ -975,9 +997,10 @@ try { # Verify every setting requested by the repair. Log-Info 'Verifying BCD changes...' - $verifyLoaderQuery = Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/enum', $defaultId) -Operation 'verify-loader' - $verifyBootMgrQuery = Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/enum', '{bootmgr}') -Operation 'verify-bootmgr' - $verifyEmsSettingsQuery = Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/enum', '{emssettings}') -Operation 'verify-emssettings' + $verifyLoaderQuery = Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/enum', $defaultId, '/v') -Operation 'verify-loader' + $verifyBootMgrQuery = Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/enum', '{bootmgr}', '/v') -Operation 'verify-bootmgr' + $verifyEmsSettingsQuery = Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/enum', '{emssettings}', '/v') -Operation 'verify-emssettings' + $afterFullQuery = Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/enum', 'all', '/v') -Operation 'after-full-store' $verifyLoaderText = $verifyLoaderQuery.Output -join "`n" $verifyBootMgrText = $verifyBootMgrQuery.Output -join "`n" @@ -990,6 +1013,7 @@ try { $verificationPassed = $verifyLoaderQuery.Success -and $verifyEmsSettingsQuery.Success -and + $afterFullQuery.Success -and $emsEnabled -and $portConfigured -and $baudConfigured Write-SacTelemetry -Event Operation -Message 'Post-change BCD verification completed' -Properties @{ @@ -1020,6 +1044,18 @@ try { foreach ($line in $verifyLoaderQuery.Output) { if (-not [string]::IsNullOrWhiteSpace([string]$line)) { Log-Output $line } } foreach ($line in $verifyBootMgrQuery.Output) { if (-not [string]::IsNullOrWhiteSpace([string]$line)) { Log-Output $line } } foreach ($line in $verifyEmsSettingsQuery.Output) { if (-not [string]::IsNullOrWhiteSpace([string]$line)) { Log-Output $line } } + Log-Output '--- BCD FULL STORE AFTER SAC ENABLE ---' + foreach ($line in $afterFullQuery.Output) { if (-not [string]::IsNullOrWhiteSpace([string]$line)) { Log-Output $line } } + Log-Output '--- BCD VERBOSE DELTA ---' + $verboseDelta = @(Compare-Object -ReferenceObject @($beforeFullQuery.Output) -DifferenceObject @($afterFullQuery.Output)) + if ($verboseDelta.Count -eq 0) { + Log-Output '' + } + else { + foreach ($difference in $verboseDelta) { + Log-Output "[$($difference.SideIndicator)] $($difference.InputObject)" + } + } $diskChanged = $true } } From d646237ba6dc7afec9aed299024cb1959a4f9da2 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:47:52 +0300 Subject: [PATCH 35/43] Update --- src/windows/sac-enabler.ps1 | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 0f0341ea..48cf26dc 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -20,11 +20,13 @@ Name: sac-enabler.ps1 Author: Tony.Mocanu@Microsoft.com Last Modified: 2026-08-05 - Version: 1.5.4 + Version: 1.5.5 Requirement: Azure repair VM with an attached Windows OS disk DeployMode: az vm repair run (with --run-on-repair) .VERSION + v1.5.5: [August 2026] - Refuses repair when an attached disk is offline due to an identity collision. + - Prevents onlining a colliding source disk and invalidating BCD device references. v1.5.4: [August 2026] - Skips EMS writes for settings that are already correct. - Logs the full verbose BCD store before and after changes. v1.5.3: [August 2026] - Uses only the documented offline EMS and EMS settings commands. @@ -342,7 +344,7 @@ function Log-Debug { } # Structured telemetry is written through the existing dual-write logging path. -$script:RepairScriptVersion = '1.5.4' +$script:RepairScriptVersion = '1.5.5' $script:ExecutionStarted = Get-Date $script:OperationCount = 0 $script:LastCommand = $null @@ -422,7 +424,7 @@ $failedCount = 0 $changedCount = 0 Log-Info "Starting repair-only SAC enabler. Logs: $logFile" -Log-Info "Build marker: v1.5.4-idempotent-offline-ems" +Log-Info "Build marker: v1.5.5-collision-preflight" # VMRepairMint telemetry marker Log-Info "[script_start] Script=sac-enabler Version=$($script:RepairScriptVersion)" @@ -430,7 +432,7 @@ Log-Info "[script_start] Script=sac-enabler Version=$($script:RepairScriptVersio Write-SacTelemetry -Event Start -Message 'script_start' -Properties @{ ScriptName = 'sac-enabler.ps1' ScriptVersion = $script:RepairScriptVersion - BuildMarker = 'v1.5.4-idempotent-offline-ems' + BuildMarker = 'v1.5.5-collision-preflight' StartTimeUtc = (Get-Date).ToUniversalTime().ToString('o') } @@ -483,6 +485,28 @@ try { Log-Info "Hyper-V PowerShell module is not available on this host. Skipping nested VM validation." } + # A disk identity collision is unsafe for offline BCD repair. Bringing the + # source disk online may require changing its MBR signature or GPT identity, + # which invalidates the BCD device descriptors used when the VM boots. + $collisionDisks = @(Get-Disk -ErrorAction Stop | Where-Object { + $_.IsOffline -and ([string]$_.OfflineReason -eq 'Collision') + }) + if ($collisionDisks.Count -gt 0) { + foreach ($collisionDisk in $collisionDisks) { + Write-SacTelemetry -Event Error -Message 'Attached disk identity collision detected' -Properties @{ + DiskNumber = $collisionDisk.Number + FriendlyName = $collisionDisk.FriendlyName + SerialNumber = $collisionDisk.SerialNumber + PartitionStyle = [string]$collisionDisk.PartitionStyle + OfflineReason = [string]$collisionDisk.OfflineReason + UniqueId = $collisionDisk.UniqueId + } + Log-Error "Disk $($collisionDisk.Number) is offline because of an identity collision. No disk or BCD changes were attempted." + } + + throw 'Unsafe repair context: an attached disk is offline with OfflineReason=Collision. Use a repair VM whose OS disk does not collide with the source disk. Do not change the source disk identity merely to bring it online.' + } + # Step 1 - Enumerate partitions to locate the BCD store and OS loader $partitionlist = @(Get-Disk-Partitions) if ($partitionlist.Count -eq 0) { From 40c5803c9e165a4d6c125e5688d33bfff27503e1 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:22:29 +0300 Subject: [PATCH 36/43] Update --- src/windows/sac-enabler.ps1 | 178 ++++++++++++++++++++++++++++++++---- 1 file changed, 158 insertions(+), 20 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 48cf26dc..3e1f65da 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -20,11 +20,13 @@ Name: sac-enabler.ps1 Author: Tony.Mocanu@Microsoft.com Last Modified: 2026-08-05 - Version: 1.5.5 + Version: 1.5.6 Requirement: Azure repair VM with an attached Windows OS disk DeployMode: az vm repair run (with --run-on-repair) .VERSION + v1.5.6: [August 2026] - Temporarily assigns a unique identity to collision-offlined attached disks. + - Restores and verifies the exact original disk identity before reporting success. v1.5.5: [August 2026] - Refuses repair when an attached disk is offline due to an identity collision. - Prevents onlining a colliding source disk and invalidating BCD device references. v1.5.4: [August 2026] - Skips EMS writes for settings that are already correct. @@ -226,6 +228,89 @@ function Test-SacGptType { return $actual -eq $expected } +function Get-SacDiskIdentity { + param( + [Parameter(Mandatory = $true)] + [int]$DiskNumber + ) + + $disk = Get-Disk -Number $DiskNumber -ErrorAction Stop + if ([string]$disk.PartitionStyle -eq 'MBR') { + $signature = [uint32]$disk.Signature + return [pscustomobject]@{ + PartitionStyle = 'MBR' + Value = $signature.ToString('X8') + DiskPartValue = $signature.ToString('X8') + } + } + + if ([string]$disk.PartitionStyle -eq 'GPT') { + $diskGuid = ([guid]$disk.Guid).ToString('D') + return [pscustomobject]@{ + PartitionStyle = 'GPT' + Value = $diskGuid + DiskPartValue = $diskGuid + } + } + + throw "Disk $DiskNumber has unsupported partition style '$($disk.PartitionStyle)'." +} + +function Invoke-SacDiskPart { + param( + [Parameter(Mandatory = $true)] + [string[]]$Commands, + + [Parameter(Mandatory = $true)] + [string]$Operation + ) + + $output = $Commands | diskpart 2>&1 + foreach ($line in @($output)) { + if ($line) { Log-Output "[diskpart][$Operation] $line" | Out-Null } + } +} + +function Set-SacTemporaryDiskIdentity { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Record + ) + + Invoke-SacDiskPart -Operation 'collision-prepare' -Commands @( + "select disk $($Record.DiskNumber)" + "uniqueid disk id=$($Record.TemporaryDiskPartValue)" + 'online disk' + ) + Update-HostStorageCache -ErrorAction SilentlyContinue + + $currentIdentity = Get-SacDiskIdentity -DiskNumber $Record.DiskNumber + $currentDisk = Get-Disk -Number $Record.DiskNumber -ErrorAction Stop + if ($currentDisk.IsOffline -or $currentIdentity.Value -ine $Record.TemporaryValue) { + throw "Disk $($Record.DiskNumber) could not be brought online with its verified temporary identity." + } +} + +function Restore-SacOriginalDiskIdentity { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Record + ) + + Invoke-SacDiskPart -Operation 'collision-restore' -Commands @( + "select disk $($Record.DiskNumber)" + 'offline disk' + "uniqueid disk id=$($Record.OriginalDiskPartValue)" + ) + Update-HostStorageCache -ErrorAction SilentlyContinue + + $restoredIdentity = Get-SacDiskIdentity -DiskNumber $Record.DiskNumber + $restoredDisk = Get-Disk -Number $Record.DiskNumber -ErrorAction Stop + if (-not $restoredDisk.IsOffline -or $restoredIdentity.Value -ine $Record.OriginalValue) { + throw "Disk $($Record.DiskNumber) did not return to its original offline identity." + } +} + # =========================================== # Logging Setup (Dual-Write: Desktop + Plugin Directory) # =========================================== @@ -344,7 +429,7 @@ function Log-Debug { } # Structured telemetry is written through the existing dual-write logging path. -$script:RepairScriptVersion = '1.5.5' +$script:RepairScriptVersion = '1.5.6' $script:ExecutionStarted = Get-Date $script:OperationCount = 0 $script:LastCommand = $null @@ -422,9 +507,10 @@ $processedCount = 0 $skippedCount = 0 $failedCount = 0 $changedCount = 0 +$collisionDiskRecords = @() Log-Info "Starting repair-only SAC enabler. Logs: $logFile" -Log-Info "Build marker: v1.5.5-collision-preflight" +Log-Info "Build marker: v1.5.6-transactional-collision-identity" # VMRepairMint telemetry marker Log-Info "[script_start] Script=sac-enabler Version=$($script:RepairScriptVersion)" @@ -432,7 +518,7 @@ Log-Info "[script_start] Script=sac-enabler Version=$($script:RepairScriptVersio Write-SacTelemetry -Event Start -Message 'script_start' -Properties @{ ScriptName = 'sac-enabler.ps1' ScriptVersion = $script:RepairScriptVersion - BuildMarker = 'v1.5.5-collision-preflight' + BuildMarker = 'v1.5.6-transactional-collision-identity' StartTimeUtc = (Get-Date).ToUniversalTime().ToString('o') } @@ -485,26 +571,50 @@ try { Log-Info "Hyper-V PowerShell module is not available on this host. Skipping nested VM validation." } - # A disk identity collision is unsafe for offline BCD repair. Bringing the - # source disk online may require changing its MBR signature or GPT identity, - # which invalidates the BCD device descriptors used when the VM boots. + # Get-Disk-Partitions onlines every Microsoft Virtual Disk. Prepare any + # colliding attached disk with a temporary identity first, then restore the + # exact original identity in the outer finally block before repair restore. + $azureVirtualDiskNumbers = @(Get-CimInstance -ClassName Win32_DiskDrive -ErrorAction Stop | + Where-Object { $_.Model -like 'Microsoft Virtual Disk*' } | + ForEach-Object { [int]$_.Index }) $collisionDisks = @(Get-Disk -ErrorAction Stop | Where-Object { - $_.IsOffline -and ([string]$_.OfflineReason -eq 'Collision') + $_.Number -in $azureVirtualDiskNumbers -and + $_.IsOffline -and + ([string]$_.OfflineReason -eq 'Collision') }) - if ($collisionDisks.Count -gt 0) { - foreach ($collisionDisk in $collisionDisks) { - Write-SacTelemetry -Event Error -Message 'Attached disk identity collision detected' -Properties @{ - DiskNumber = $collisionDisk.Number - FriendlyName = $collisionDisk.FriendlyName - SerialNumber = $collisionDisk.SerialNumber - PartitionStyle = [string]$collisionDisk.PartitionStyle - OfflineReason = [string]$collisionDisk.OfflineReason - UniqueId = $collisionDisk.UniqueId - } - Log-Error "Disk $($collisionDisk.Number) is offline because of an identity collision. No disk or BCD changes were attempted." + foreach ($collisionDisk in $collisionDisks) { + $originalIdentity = Get-SacDiskIdentity -DiskNumber $collisionDisk.Number + if ($originalIdentity.PartitionStyle -eq 'MBR') { + do { + $temporaryValue = ([Convert]::ToUInt32(([guid]::NewGuid().ToString('N').Substring(0, 8)), 16)).ToString('X8') + } while ($temporaryValue -eq '00000000' -or $temporaryValue -ieq $originalIdentity.Value) + $temporaryDiskPartValue = $temporaryValue + } + else { + $temporaryValue = ([guid]::NewGuid()).ToString('D') + $temporaryDiskPartValue = $temporaryValue } - throw 'Unsafe repair context: an attached disk is offline with OfflineReason=Collision. Use a repair VM whose OS disk does not collide with the source disk. Do not change the source disk identity merely to bring it online.' + $record = [pscustomobject]@{ + DiskNumber = [int]$collisionDisk.Number + PartitionStyle = $originalIdentity.PartitionStyle + OriginalValue = $originalIdentity.Value + OriginalDiskPartValue = $originalIdentity.DiskPartValue + TemporaryValue = $temporaryValue + TemporaryDiskPartValue = $temporaryDiskPartValue + } + $collisionDiskRecords += $record + + Write-SacTelemetry -Event Operation -Message 'Preparing collision-offlined attached disk' -Properties @{ + DiskNumber = $record.DiskNumber + PartitionStyle = $record.PartitionStyle + OfflineReason = [string]$collisionDisk.OfflineReason + OriginalIdentity = $record.OriginalValue + TemporaryIdentity = $record.TemporaryValue + } + Log-Warning "Disk $($record.DiskNumber) is offline due to an identity collision. Applying a temporary $($record.PartitionStyle) identity for this repair run." + Set-SacTemporaryDiskIdentity -Record $record + Log-Info "Disk $($record.DiskNumber) is online with a verified temporary identity." } # Step 1 - Enumerate partitions to locate the BCD store and OS loader @@ -1162,6 +1272,34 @@ catch { $script_final_status = $STATUS_ERROR } finally { + $identityRestorationFailed = $false + foreach ($record in @($collisionDiskRecords | Sort-Object DiskNumber -Descending)) { + try { + Restore-SacOriginalDiskIdentity -Record $record + Log-Info "Disk $($record.DiskNumber) is offline with its verified original $($record.PartitionStyle) identity restored." + Write-SacTelemetry -Event Operation -Message 'Original attached disk identity restored' -Properties @{ + DiskNumber = $record.DiskNumber + PartitionStyle = $record.PartitionStyle + OriginalIdentity = $record.OriginalValue + DiskOffline = $true + } + } + catch { + $identityRestorationFailed = $true + $failureReason = "CRITICAL: Failed to restore the original identity of Disk $($record.DiskNumber): $($_.Exception.Message)" + Log-Error $failureReason + Write-SacTelemetry -Event Error -Message 'Original attached disk identity restoration failed' -Properties @{ + DiskNumber = $record.DiskNumber + PartitionStyle = $record.PartitionStyle + OriginalIdentity = $record.OriginalValue + Error = $_.Exception.Message + } + } + } + if ($identityRestorationFailed) { + $script_final_status = $STATUS_ERROR + } + $durationSeconds = [math]::Round(((Get-Date) - $script:ExecutionStarted).TotalSeconds, 3) if ($script_final_status -eq $STATUS_SUCCESS) { Write-SacTelemetry -Event Success -Message 'SAC/EMS repair completed successfully' -Properties @{ From adcaa9560184a773474997feeea9affaac012727 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:36:46 +0300 Subject: [PATCH 37/43] Update --- src/windows/sac-enabler.ps1 | 153 +++++++++++++++++++++++++++++++----- 1 file changed, 132 insertions(+), 21 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 3e1f65da..7c4e67c2 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -20,11 +20,13 @@ Name: sac-enabler.ps1 Author: Tony.Mocanu@Microsoft.com Last Modified: 2026-08-05 - Version: 1.5.6 + Version: 1.5.7 Requirement: Azure repair VM with an attached Windows OS disk DeployMode: az vm repair run (with --run-on-repair) .VERSION + v1.5.7: [August 2026] - Uses typed BCD WMI element setters instead of BCDEdit for writes. + - Prevents unrelated GPT device descriptors from being reserialized under a temporary identity. v1.5.6: [August 2026] - Temporarily assigns a unique identity to collision-offlined attached disks. - Restores and verifies the exact original disk identity before reporting success. v1.5.5: [August 2026] - Refuses repair when an attached disk is offline due to an identity collision. @@ -429,7 +431,7 @@ function Log-Debug { } # Structured telemetry is written through the existing dual-write logging path. -$script:RepairScriptVersion = '1.5.6' +$script:RepairScriptVersion = '1.5.7' $script:ExecutionStarted = Get-Date $script:OperationCount = 0 $script:LastCommand = $null @@ -495,6 +497,113 @@ function Invoke-SacBcdEdit { } } +function Invoke-SacBcdWmiMethod { + param( + [Parameter(Mandatory = $true)] + [System.Management.ManagementObject]$InputObject, + + [Parameter(Mandatory = $true)] + [string]$MethodName, + + [Parameter(Mandatory = $true)] + [hashtable]$Parameters, + + [Parameter(Mandatory = $true)] + [string]$Operation + ) + + $script:OperationCount++ + $script:LastCommand = "BcdObject.$MethodName ($Operation)" + $inputParameters = $InputObject.GetMethodParameters($MethodName) + foreach ($name in $Parameters.Keys) { + $inputParameters[$name] = $Parameters[$name] + } + + $result = $InputObject.InvokeMethod($MethodName, $inputParameters, $null) + $success = $result -and [bool]$result.ReturnValue + Write-SacTelemetry -Event Operation -Message 'Applied typed BCD WMI element update' -Properties @{ + Operation = $Operation + Method = $MethodName + ElementType = ('0x{0:X8}' -f [uint32]$Parameters.Type) + Success = $success + } | Out-Null + + if (-not $success) { + throw "BCD WMI operation '$Operation' failed." + } +} + +function Set-SacBcdEmsElements { + param( + [Parameter(Mandatory = $true)] + [string]$BcdPath, + + [Parameter(Mandatory = $true)] + [string]$LoaderId, + + [bool]$SetLoaderEms, + [bool]$SetPort, + [bool]$SetBaudRate + ) + + $storeClass = [wmiclass]'\\.\root\WMI:BcdStore' + $openStoreResult = $storeClass.OpenStore($BcdPath) + if (-not $openStoreResult.ReturnValue -or -not $openStoreResult.Store) { + throw "The BCD WMI provider could not open offline store '$BcdPath'." + } + + $store = [System.Management.ManagementObject]$openStoreResult.Store + try { + if ($SetLoaderEms) { + $openLoaderResult = $store.OpenObject($LoaderId) + if (-not $openLoaderResult.ReturnValue -or -not $openLoaderResult.Object) { + throw "The BCD WMI provider could not open loader '$LoaderId'." + } + + $loader = [System.Management.ManagementObject]$openLoaderResult.Object + try { + Invoke-SacBcdWmiMethod -InputObject $loader -MethodName 'SetBooleanElement' -Operation 'ems' -Parameters @{ + Boolean = $true + Type = [uint32]0x260000B0 + } + } + finally { + $loader.Dispose() + } + } + + if ($SetPort -or $SetBaudRate) { + $emsSettingsId = '{0ce4991b-e6b3-4b16-b23c-5e0d9250e5d9}' + $openEmsSettingsResult = $store.OpenObject($emsSettingsId) + if (-not $openEmsSettingsResult.ReturnValue -or -not $openEmsSettingsResult.Object) { + throw "The BCD WMI provider could not open EMS settings object '$emsSettingsId'." + } + + $emsSettings = [System.Management.ManagementObject]$openEmsSettingsResult.Object + try { + if ($SetPort) { + Invoke-SacBcdWmiMethod -InputObject $emsSettings -MethodName 'SetIntegerElement' -Operation 'ems-port' -Parameters @{ + Integer = [uint64]1 + Type = [uint32]0x15000022 + } + } + if ($SetBaudRate) { + Invoke-SacBcdWmiMethod -InputObject $emsSettings -MethodName 'SetIntegerElement' -Operation 'ems-baud-rate' -Parameters @{ + Integer = [uint64]115200 + Type = [uint32]0x15000023 + } + } + } + finally { + $emsSettings.Dispose() + } + } + } + finally { + $store.Dispose() + } +} + $logFile = $logFilePath Log-Info "Dual logging initialized: Desktop: $desktopLogFile | Plugin: $pluginLogFile" Log-Info "Script classification: REPAIR_VM_ONLY" @@ -510,7 +619,7 @@ $changedCount = 0 $collisionDiskRecords = @() Log-Info "Starting repair-only SAC enabler. Logs: $logFile" -Log-Info "Build marker: v1.5.6-transactional-collision-identity" +Log-Info "Build marker: v1.5.7-typed-bcd-wmi-writes" # VMRepairMint telemetry marker Log-Info "[script_start] Script=sac-enabler Version=$($script:RepairScriptVersion)" @@ -518,7 +627,7 @@ Log-Info "[script_start] Script=sac-enabler Version=$($script:RepairScriptVersio Write-SacTelemetry -Event Start -Message 'script_start' -Properties @{ ScriptName = 'sac-enabler.ps1' ScriptVersion = $script:RepairScriptVersion - BuildMarker = 'v1.5.6-transactional-collision-identity' + BuildMarker = 'v1.5.7-typed-bcd-wmi-writes' StartTimeUtc = (Get-Date).ToUniversalTime().ToString('o') } @@ -533,6 +642,15 @@ Write-SacTelemetry -Event Start -Message 'Starting SAC/EMS enablement' -Properti } try { + $bcdStoreClass = Get-WmiObject -Namespace root\wmi -List BcdStore -ErrorAction Stop + $bcdObjectClass = Get-WmiObject -Namespace root\wmi -List BcdObject -ErrorAction Stop + $requiredBcdMethods = @('OpenStore', 'OpenObject', 'SetBooleanElement', 'SetIntegerElement') + $availableBcdMethods = @($bcdStoreClass.Methods.Name) + @($bcdObjectClass.Methods.Name) + $missingBcdMethods = @($requiredBcdMethods | Where-Object { $_ -notin $availableBcdMethods }) + if ($missingBcdMethods.Count -gt 0) { + throw "The BCD WMI provider is missing required methods: $($missingBcdMethods -join ', ')." + } + # Optional: Clean up orphaned temp drive letters from previous failed runs # This helps prevent lingering mount points from blocking EFI partition access $orphanedLetters = @() @@ -1055,30 +1173,23 @@ try { foreach ($line in $beforeFullQuery.Output) { if (-not [string]::IsNullOrWhiteSpace([string]$line)) { Log-Output $line } } # Enable only the settings required by Microsoft's offline SAC procedure. + # Typed WMI setters update the requested elements without asking BCDEdit to + # rewrite the store while the attached GPT disk has a temporary identity. Log-Info "Applying SAC and EMS configurations to BCD: $bcdPath" - # Execute each command independently. Do not collect command results in a - # shared pipeline because repository Log-* helpers write to the success stream. - $operationDefinitions = @() - if (-not $beforeEmsEnabled) { - $operationDefinitions += @{ Name = 'ems'; Arguments = @('/store', $bcdPath, '/ems', $defaultId, 'ON') } - } - else { + $setLoaderEms = -not $beforeEmsEnabled + $setPort = -not $beforePortConfigured + $setBaudRate = -not $beforeBaudConfigured + if (-not $setLoaderEms) { Log-Info "EMS is already enabled on $defaultId; skipping the EMS write." } - - if (-not ($beforePortConfigured -and $beforeBaudConfigured)) { - $operationDefinitions += @{ Name = 'emssettings'; Arguments = @('/store', $bcdPath, '/emssettings', 'EMSPORT:1', 'EMSBAUDRATE:115200') } - } - else { + if (-not $setPort -and -not $setBaudRate) { Log-Info 'EMS port and baud rate are already configured; skipping the EMS settings write.' } - foreach ($operationDefinition in $operationDefinitions) { + if ($setLoaderEms -or $setPort -or $setBaudRate) { $bcdWriteStarted = $true - $operationResult = Invoke-SacBcdEdit -Arguments $operationDefinition.Arguments -Operation $operationDefinition.Name - if (-not $operationResult.Success) { - throw "bcdedit operation '$($operationDefinition.Name)' failed with exit code $($operationResult.ExitCode). BCD backup: $bcdBackup" - } + Set-SacBcdEmsElements -BcdPath $bcdPath -LoaderId $defaultId ` + -SetLoaderEms $setLoaderEms -SetPort $setPort -SetBaudRate $setBaudRate } # SAC changes must not alter the selected loader's boot mapping. From b201f3a21e86336d5aad53806478e2b0580fbb50 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:33:58 +0300 Subject: [PATCH 38/43] Update --- src/windows/sac-enabler.ps1 | 46 +++++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 7c4e67c2..e51319e6 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -20,11 +20,13 @@ Name: sac-enabler.ps1 Author: Tony.Mocanu@Microsoft.com Last Modified: 2026-08-05 - Version: 1.5.7 + Version: 1.5.8 Requirement: Azure repair VM with an attached Windows OS disk DeployMode: az vm repair run (with --run-on-repair) .VERSION + v1.5.8: [August 2026] - Rebinds embedded BCD WMI results through their documented key properties. + - Avoids invalid ManagementBaseObject-to-ManagementObject casts. v1.5.7: [August 2026] - Uses typed BCD WMI element setters instead of BCDEdit for writes. - Prevents unrelated GPT device descriptors from being reserialized under a temporary identity. v1.5.6: [August 2026] - Temporarily assigns a unique identity to collision-offlined attached disks. @@ -431,7 +433,7 @@ function Log-Debug { } # Structured telemetry is written through the existing dual-write logging path. -$script:RepairScriptVersion = '1.5.7' +$script:RepairScriptVersion = '1.5.8' $script:ExecutionStarted = Get-Date $script:OperationCount = 0 $script:LastCommand = $null @@ -533,6 +535,28 @@ function Invoke-SacBcdWmiMethod { } } +function New-SacBcdManagementObject { + param( + [Parameter(Mandatory = $true)] + [ValidateSet('BcdStore', 'BcdObject')] + [string]$ClassName, + + [Parameter(Mandatory = $true)] + [hashtable]$Keys + ) + + $keyAssignments = @($Keys.GetEnumerator() | Sort-Object Key | ForEach-Object { + $escapedValue = ([string]$_.Value).Replace('\', '\\').Replace('"', '\"') + '{0}="{1}"' -f $_.Key, $escapedValue + }) + $relativePath = '{0}.{1}' -f $ClassName, ($keyAssignments -join ',') + $scope = [System.Management.ManagementScope]::new('\\.\root\WMI') + $path = [System.Management.ManagementPath]::new($relativePath) + $managementObject = [System.Management.ManagementObject]::new($scope, $path, $null) + $managementObject.Get() + return $managementObject +} + function Set-SacBcdEmsElements { param( [Parameter(Mandatory = $true)] @@ -552,7 +576,9 @@ function Set-SacBcdEmsElements { throw "The BCD WMI provider could not open offline store '$BcdPath'." } - $store = [System.Management.ManagementObject]$openStoreResult.Store + $store = New-SacBcdManagementObject -ClassName BcdStore -Keys @{ + FilePath = [string]$openStoreResult.Store.FilePath + } try { if ($SetLoaderEms) { $openLoaderResult = $store.OpenObject($LoaderId) @@ -560,7 +586,10 @@ function Set-SacBcdEmsElements { throw "The BCD WMI provider could not open loader '$LoaderId'." } - $loader = [System.Management.ManagementObject]$openLoaderResult.Object + $loader = New-SacBcdManagementObject -ClassName BcdObject -Keys @{ + Id = [string]$openLoaderResult.Object.Id + StoreFilePath = [string]$openLoaderResult.Object.StoreFilePath + } try { Invoke-SacBcdWmiMethod -InputObject $loader -MethodName 'SetBooleanElement' -Operation 'ems' -Parameters @{ Boolean = $true @@ -579,7 +608,10 @@ function Set-SacBcdEmsElements { throw "The BCD WMI provider could not open EMS settings object '$emsSettingsId'." } - $emsSettings = [System.Management.ManagementObject]$openEmsSettingsResult.Object + $emsSettings = New-SacBcdManagementObject -ClassName BcdObject -Keys @{ + Id = [string]$openEmsSettingsResult.Object.Id + StoreFilePath = [string]$openEmsSettingsResult.Object.StoreFilePath + } try { if ($SetPort) { Invoke-SacBcdWmiMethod -InputObject $emsSettings -MethodName 'SetIntegerElement' -Operation 'ems-port' -Parameters @{ @@ -619,7 +651,7 @@ $changedCount = 0 $collisionDiskRecords = @() Log-Info "Starting repair-only SAC enabler. Logs: $logFile" -Log-Info "Build marker: v1.5.7-typed-bcd-wmi-writes" +Log-Info "Build marker: v1.5.8-bcd-wmi-key-rebind" # VMRepairMint telemetry marker Log-Info "[script_start] Script=sac-enabler Version=$($script:RepairScriptVersion)" @@ -627,7 +659,7 @@ Log-Info "[script_start] Script=sac-enabler Version=$($script:RepairScriptVersio Write-SacTelemetry -Event Start -Message 'script_start' -Properties @{ ScriptName = 'sac-enabler.ps1' ScriptVersion = $script:RepairScriptVersion - BuildMarker = 'v1.5.7-typed-bcd-wmi-writes' + BuildMarker = 'v1.5.8-bcd-wmi-key-rebind' StartTimeUtc = (Get-Date).ToUniversalTime().ToString('o') } From 7aa2458fa451b5fe8f3176c3ccaad5d07692db08 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:06:20 +0300 Subject: [PATCH 39/43] Update --- src/windows/sac-enabler.ps1 | 42 ++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index e51319e6..4440d0da 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -19,12 +19,14 @@ .NOTES Name: sac-enabler.ps1 Author: Tony.Mocanu@Microsoft.com - Last Modified: 2026-08-05 - Version: 1.5.8 + Last Modified: 2026-08-06 + Version: 1.5.9 Requirement: Azure repair VM with an attached Windows OS disk DeployMode: az vm repair run (with --run-on-repair) .VERSION + v1.5.9: [August 2026] - Refuses to rewrite a collision-offlined GPT disk identity. + - Requires a non-colliding repair OS disk or matching-generation nested repair for Gen2. v1.5.8: [August 2026] - Rebinds embedded BCD WMI results through their documented key properties. - Avoids invalid ManagementBaseObject-to-ManagementObject casts. v1.5.7: [August 2026] - Uses typed BCD WMI element setters instead of BCDEdit for writes. @@ -433,7 +435,7 @@ function Log-Debug { } # Structured telemetry is written through the existing dual-write logging path. -$script:RepairScriptVersion = '1.5.8' +$script:RepairScriptVersion = '1.5.9' $script:ExecutionStarted = Get-Date $script:OperationCount = 0 $script:LastCommand = $null @@ -651,7 +653,7 @@ $changedCount = 0 $collisionDiskRecords = @() Log-Info "Starting repair-only SAC enabler. Logs: $logFile" -Log-Info "Build marker: v1.5.8-bcd-wmi-key-rebind" +Log-Info "Build marker: v1.5.9-gpt-collision-fail-closed" # VMRepairMint telemetry marker Log-Info "[script_start] Script=sac-enabler Version=$($script:RepairScriptVersion)" @@ -659,7 +661,7 @@ Log-Info "[script_start] Script=sac-enabler Version=$($script:RepairScriptVersio Write-SacTelemetry -Event Start -Message 'script_start' -Properties @{ ScriptName = 'sac-enabler.ps1' ScriptVersion = $script:RepairScriptVersion - BuildMarker = 'v1.5.8-bcd-wmi-key-rebind' + BuildMarker = 'v1.5.9-gpt-collision-fail-closed' StartTimeUtc = (Get-Date).ToUniversalTime().ToString('o') } @@ -721,9 +723,9 @@ try { Log-Info "Hyper-V PowerShell module is not available on this host. Skipping nested VM validation." } - # Get-Disk-Partitions onlines every Microsoft Virtual Disk. Prepare any - # colliding attached disk with a temporary identity first, then restore the - # exact original identity in the outer finally block before repair restore. + # Get-Disk-Partitions onlines every Microsoft Virtual Disk. A temporary MBR + # signature is retained for the boot-tested Gen1 path. Never rewrite a GPT + # disk GUID: a later restoration does not make that host online cycle boot-safe. $azureVirtualDiskNumbers = @(Get-CimInstance -ClassName Win32_DiskDrive -ErrorAction Stop | Where-Object { $_.Model -like 'Microsoft Virtual Disk*' } | ForEach-Object { [int]$_.Index }) @@ -734,17 +736,23 @@ try { }) foreach ($collisionDisk in $collisionDisks) { $originalIdentity = Get-SacDiskIdentity -DiskNumber $collisionDisk.Number - if ($originalIdentity.PartitionStyle -eq 'MBR') { - do { - $temporaryValue = ([Convert]::ToUInt32(([guid]::NewGuid().ToString('N').Substring(0, 8)), 16)).ToString('X8') - } while ($temporaryValue -eq '00000000' -or $temporaryValue -ieq $originalIdentity.Value) - $temporaryDiskPartValue = $temporaryValue - } - else { - $temporaryValue = ([guid]::NewGuid()).ToString('D') - $temporaryDiskPartValue = $temporaryValue + if ($originalIdentity.PartitionStyle -eq 'GPT') { + $failureReason = "Disk $($collisionDisk.Number) is a collision-offlined GPT disk. Refusing to change its GPT disk GUID because the temporary-identity online cycle is not boot-safe for Gen2. Recreate the repair VM with an OS image whose disk identity does not collide, or keep this disk offline on the repair host and modify it from a matching-generation nested VM." + Write-SacTelemetry -Event Error -Message 'Unsafe GPT identity collision detected' -Properties @{ + DiskNumber = [int]$collisionDisk.Number + PartitionStyle = $originalIdentity.PartitionStyle + OfflineReason = [string]$collisionDisk.OfflineReason + OriginalIdentity = $originalIdentity.Value + IdentityChanged = $false + } + throw $failureReason } + do { + $temporaryValue = ([Convert]::ToUInt32(([guid]::NewGuid().ToString('N').Substring(0, 8)), 16)).ToString('X8') + } while ($temporaryValue -eq '00000000' -or $temporaryValue -ieq $originalIdentity.Value) + $temporaryDiskPartValue = $temporaryValue + $record = [pscustomobject]@{ DiskNumber = [int]$collisionDisk.Number PartitionStyle = $originalIdentity.PartitionStyle From b2a68ca9cd2f771a3387f051826979a5130d7fb9 Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:15:57 +0300 Subject: [PATCH 40/43] Update From 5d19195b9160e53e6a06ecdca03f48b0f318a74e Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:51:33 +0300 Subject: [PATCH 41/43] Update --- src/windows/sac-enabler.ps1 | 174 +++++++++++++++++++++++++++++++----- 1 file changed, 153 insertions(+), 21 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 4440d0da..bf37cc64 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -20,11 +20,14 @@ Name: sac-enabler.ps1 Author: Tony.Mocanu@Microsoft.com Last Modified: 2026-08-06 - Version: 1.5.9 + Version: 1.6.0 Requirement: Azure repair VM with an attached Windows OS disk DeployMode: az vm repair run (with --run-on-repair) .VERSION + v1.6.0: [August 2026] - Preserves the Gen2 source disk GPT GUID for the entire repair. + - Resolves a GPT collision by temporarily changing only the disposable repair VM OS disk GUID. + - Offlines the source disk before restoring and verifying the repair OS disk GUID. v1.5.9: [August 2026] - Refuses to rewrite a collision-offlined GPT disk identity. - Requires a non-colliding repair OS disk or matching-generation nested repair for Gen2. v1.5.8: [August 2026] - Rebinds embedded BCD WMI results through their documented key properties. @@ -283,6 +286,10 @@ function Set-SacTemporaryDiskIdentity { [pscustomobject]$Record ) + if ($Record.PartitionStyle -ne 'MBR') { + throw 'Temporary source disk identities are permitted only for the boot-tested Gen1 MBR path.' + } + Invoke-SacDiskPart -Operation 'collision-prepare' -Commands @( "select disk $($Record.DiskNumber)" "uniqueid disk id=$($Record.TemporaryDiskPartValue)" @@ -303,6 +310,10 @@ function Restore-SacOriginalDiskIdentity { [pscustomobject]$Record ) + if ($Record.PartitionStyle -ne 'MBR') { + throw 'Source disk identity restoration is permitted only for the Gen1 MBR path.' + } + Invoke-SacDiskPart -Operation 'collision-restore' -Commands @( "select disk $($Record.DiskNumber)" 'offline disk' @@ -317,6 +328,44 @@ function Restore-SacOriginalDiskIdentity { } } +function Set-SacTemporaryRepairDiskIdentity { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Record + ) + + Invoke-SacDiskPart -Operation 'repair-host-collision-prepare' -Commands @( + "select disk $($Record.DiskNumber)" + "uniqueid disk id=$($Record.TemporaryDiskPartValue)" + ) + Update-HostStorageCache -ErrorAction SilentlyContinue + + $currentIdentity = Get-SacDiskIdentity -DiskNumber $Record.DiskNumber + $currentDisk = Get-Disk -Number $Record.DiskNumber -ErrorAction Stop + if ($currentDisk.IsOffline -or $currentIdentity.Value -ine $Record.TemporaryValue) { + throw 'The repair VM OS disk did not retain a verified temporary GPT identity.' + } +} + +function Restore-SacRepairDiskIdentity { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Record + ) + + Invoke-SacDiskPart -Operation 'repair-host-collision-restore' -Commands @( + "select disk $($Record.DiskNumber)" + "uniqueid disk id=$($Record.OriginalDiskPartValue)" + ) + Update-HostStorageCache -ErrorAction SilentlyContinue + + $restoredIdentity = Get-SacDiskIdentity -DiskNumber $Record.DiskNumber + $restoredDisk = Get-Disk -Number $Record.DiskNumber -ErrorAction Stop + if ($restoredDisk.IsOffline -or $restoredIdentity.Value -ine $Record.OriginalValue) { + throw 'The repair VM OS disk did not return to its original GPT identity.' + } +} + # =========================================== # Logging Setup (Dual-Write: Desktop + Plugin Directory) # =========================================== @@ -435,7 +484,7 @@ function Log-Debug { } # Structured telemetry is written through the existing dual-write logging path. -$script:RepairScriptVersion = '1.5.9' +$script:RepairScriptVersion = '1.6.0' $script:ExecutionStarted = Get-Date $script:OperationCount = 0 $script:LastCommand = $null @@ -651,9 +700,11 @@ $skippedCount = 0 $failedCount = 0 $changedCount = 0 $collisionDiskRecords = @() +$gptCollisionDiskNumbers = @() +$repairDiskIdentityRecord = $null Log-Info "Starting repair-only SAC enabler. Logs: $logFile" -Log-Info "Build marker: v1.5.9-gpt-collision-fail-closed" +Log-Info "Build marker: v1.6.0-preserve-source-gpt-identity" # VMRepairMint telemetry marker Log-Info "[script_start] Script=sac-enabler Version=$($script:RepairScriptVersion)" @@ -661,7 +712,7 @@ Log-Info "[script_start] Script=sac-enabler Version=$($script:RepairScriptVersio Write-SacTelemetry -Event Start -Message 'script_start' -Properties @{ ScriptName = 'sac-enabler.ps1' ScriptVersion = $script:RepairScriptVersion - BuildMarker = 'v1.5.9-gpt-collision-fail-closed' + BuildMarker = 'v1.6.0-preserve-source-gpt-identity' StartTimeUtc = (Get-Date).ToUniversalTime().ToString('o') } @@ -723,9 +774,19 @@ try { Log-Info "Hyper-V PowerShell module is not available on this host. Skipping nested VM validation." } - # Get-Disk-Partitions onlines every Microsoft Virtual Disk. A temporary MBR - # signature is retained for the boot-tested Gen1 path. Never rewrite a GPT - # disk GUID: a later restoration does not make that host online cycle boot-safe. + $repairDrive = $env:SystemDrive -replace ':', '' + $repairOsPartition = Get-Partition -DriveLetter $repairDrive -ErrorAction Stop | Select-Object -First 1 + if ($null -eq $repairOsPartition -or $null -eq $repairOsPartition.DiskNumber) { + throw "CRITICAL SAFETY CHECK FAILED: Could not identify the repair VM OS disk from $($env:SystemDrive)." + } + $repairDiskNumber = [int]$repairOsPartition.DiskNumber + $repairDiskIdentity = Get-SacDiskIdentity -DiskNumber $repairDiskNumber + Log-Info "Repair VM OS disk identified as Disk $repairDiskNumber ($($repairDiskIdentity.PartitionStyle))." + + # Get-Disk-Partitions onlines every Microsoft Virtual Disk. For Gen1, retain + # the boot-tested temporary source MBR signature. For a GPT collision, change + # only the disposable repair VM OS disk identity; the Gen2 source GUID remains + # unchanged before, during, and after BCD access. $azureVirtualDiskNumbers = @(Get-CimInstance -ClassName Win32_DiskDrive -ErrorAction Stop | Where-Object { $_.Model -like 'Microsoft Virtual Disk*' } | ForEach-Object { [int]$_.Index }) @@ -737,15 +798,52 @@ try { foreach ($collisionDisk in $collisionDisks) { $originalIdentity = Get-SacDiskIdentity -DiskNumber $collisionDisk.Number if ($originalIdentity.PartitionStyle -eq 'GPT') { - $failureReason = "Disk $($collisionDisk.Number) is a collision-offlined GPT disk. Refusing to change its GPT disk GUID because the temporary-identity online cycle is not boot-safe for Gen2. Recreate the repair VM with an OS image whose disk identity does not collide, or keep this disk offline on the repair host and modify it from a matching-generation nested VM." - Write-SacTelemetry -Event Error -Message 'Unsafe GPT identity collision detected' -Properties @{ + if ($repairDiskIdentity.PartitionStyle -ne 'GPT' -or $repairDiskIdentity.Value -ine $originalIdentity.Value) { + throw "Disk $($collisionDisk.Number) reports a GPT identity collision, but its GUID does not match the repair VM OS disk. Refusing an unverified identity change." + } + + if (-not $repairDiskIdentityRecord) { + $temporaryRepairGuid = ([guid]::NewGuid()).ToString('D') + $repairDiskIdentityRecord = [pscustomobject]@{ + DiskNumber = $repairDiskNumber + PartitionStyle = 'GPT' + OriginalValue = $repairDiskIdentity.Value + OriginalDiskPartValue = $repairDiskIdentity.DiskPartValue + TemporaryValue = $temporaryRepairGuid + TemporaryDiskPartValue = $temporaryRepairGuid + } + + Log-Warning 'Temporarily changing only the repair VM OS disk GPT identity to release the attached Gen2 disk collision. The source disk GUID will not be changed.' + Set-SacTemporaryRepairDiskIdentity -Record $repairDiskIdentityRecord + } + + $gptCollisionDiskNumbers += [int]$collisionDisk.Number + Invoke-SacDiskPart -Operation 'source-gpt-online' -Commands @( + "select disk $($collisionDisk.Number)" + 'online disk' + ) + Update-HostStorageCache -ErrorAction SilentlyContinue + $releasedDisk = Get-Disk -Number $collisionDisk.Number -ErrorAction Stop + if ($releasedDisk.IsOffline -or [string]$releasedDisk.OfflineReason -eq 'Collision') { + throw "Disk $($collisionDisk.Number) could not be onlined after the repair VM OS disk identity changed. No source identity or BCD change was attempted." + } + + $sourceIdentityAfterRelease = Get-SacDiskIdentity -DiskNumber $collisionDisk.Number + if ($sourceIdentityAfterRelease.Value -ine $originalIdentity.Value) { + throw "Source Disk $($collisionDisk.Number) identity changed unexpectedly while releasing the collision." + } + + Write-SacTelemetry -Event Operation -Message 'GPT collision released without changing source identity' -Properties @{ DiskNumber = [int]$collisionDisk.Number PartitionStyle = $originalIdentity.PartitionStyle OfflineReason = [string]$collisionDisk.OfflineReason - OriginalIdentity = $originalIdentity.Value - IdentityChanged = $false + SourceIdentity = $originalIdentity.Value + SourceIdentityChanged = $false + RepairDiskNumber = $repairDiskNumber + RepairDiskTemporaryIdentity = $repairDiskIdentityRecord.TemporaryValue } - throw $failureReason + Log-Info "Disk $($collisionDisk.Number) collision released; source GPT identity remains $($originalIdentity.Value)." + continue } do { @@ -783,16 +881,9 @@ try { $discoveredDiskNumbers = @($partitionlist | Select-Object -ExpandProperty DiskNumber -Unique) Log-Info "Get-Disk-Partitions discovered disk numbers: $($discoveredDiskNumbers -join ', ')" - $repairDrive = $env:SystemDrive -replace ':', '' Log-Info 'Enumerating partitions to enable SAC...' # SAFETY CHECK: Ensure we're not operating on the repair VM's own disk - $repairOsPartition = Get-Partition -DriveLetter $repairDrive -ErrorAction Stop | Select-Object -First 1 - if ($null -eq $repairOsPartition -or $null -eq $repairOsPartition.DiskNumber) { - throw "CRITICAL SAFETY CHECK FAILED: Could not identify the repair VM OS disk from $($env:SystemDrive)." - } - - $repairDiskNumber = [int]$repairOsPartition.DiskNumber Log-Info "Repair VM OS disk identified as Disk $repairDiskNumber" $targetDiskGroups = @($partitionlist | Group-Object DiskNumber | Where-Object { [int]$_.Name -ne $repairDiskNumber }) @@ -1213,8 +1304,8 @@ try { foreach ($line in $beforeFullQuery.Output) { if (-not [string]::IsNullOrWhiteSpace([string]$line)) { Log-Output $line } } # Enable only the settings required by Microsoft's offline SAC procedure. - # Typed WMI setters update the requested elements without asking BCDEdit to - # rewrite the store while the attached GPT disk has a temporary identity. + # Typed WMI setters update only the requested elements. For Gen2, the source + # disk retains its original GPT identity throughout this operation. Log-Info "Applying SAC and EMS configurations to BCD: $bcdPath" $setLoaderEms = -not $beforeEmsEnabled $setPort = -not $beforePortConfigured @@ -1424,6 +1515,47 @@ catch { } finally { $identityRestorationFailed = $false + + if ($repairDiskIdentityRecord) { + foreach ($diskNumber in @($gptCollisionDiskNumbers | Sort-Object -Unique)) { + try { + Invoke-SacDiskPart -Operation 'source-before-repair-host-restore' -Commands @( + "select disk $diskNumber" + 'offline disk' + ) + Update-HostStorageCache -ErrorAction SilentlyContinue + $sourceDisk = Get-Disk -Number $diskNumber -ErrorAction Stop + $sourceIdentity = Get-SacDiskIdentity -DiskNumber $diskNumber + if (-not $sourceDisk.IsOffline -or $sourceIdentity.Value -ine $repairDiskIdentityRecord.OriginalValue) { + throw "Source Disk $diskNumber was not offline with its unchanged original GPT identity." + } + Log-Info "Source Disk $diskNumber is offline with its original GPT identity verified before repair host restoration." + } + catch { + $identityRestorationFailed = $true + $failureReason = "CRITICAL: Could not safely offline and verify source Disk ${diskNumber}: $($_.Exception.Message)" + Log-Error $failureReason + } + } + + if (-not $identityRestorationFailed) { + try { + Restore-SacRepairDiskIdentity -Record $repairDiskIdentityRecord + Log-Info 'Repair VM OS disk GPT identity restored and verified.' + Write-SacTelemetry -Event Operation -Message 'Repair VM OS disk identity restored' -Properties @{ + DiskNumber = $repairDiskIdentityRecord.DiskNumber + OriginalIdentity = $repairDiskIdentityRecord.OriginalValue + SourceIdentityChanged = $false + } + } + catch { + $identityRestorationFailed = $true + $failureReason = "CRITICAL: Failed to restore the repair VM OS disk identity: $($_.Exception.Message)" + Log-Error $failureReason + } + } + } + foreach ($record in @($collisionDiskRecords | Sort-Object DiskNumber -Descending)) { try { Restore-SacOriginalDiskIdentity -Record $record From ca5d6bfee88e385ea3c54e71a3d5e1e4be5c82ca Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:57:16 +0300 Subject: [PATCH 42/43] Update --- src/windows/sac-enabler.ps1 | 70 +++++++++++++++++++++++++++++++------ 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index bf37cc64..6ae0143e 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -20,11 +20,14 @@ Name: sac-enabler.ps1 Author: Tony.Mocanu@Microsoft.com Last Modified: 2026-08-06 - Version: 1.6.0 + Version: 1.6.1 Requirement: Azure repair VM with an attached Windows OS disk DeployMode: az vm repair run (with --run-on-repair) .VERSION + v1.6.1: [August 2026] - Repairs loader device/osdevice when they render as unknown. + - Writes the validated Windows partition while the source retains its original GPT identity. + - Re-queries and verifies the repaired loader mapping before enabling EMS. v1.6.0: [August 2026] - Preserves the Gen2 source disk GPT GUID for the entire repair. - Resolves a GPT collision by temporarily changing only the disposable repair VM OS disk GUID. - Offlines the source disk before restoring and verifying the repair OS disk GUID. @@ -484,7 +487,7 @@ function Log-Debug { } # Structured telemetry is written through the existing dual-write logging path. -$script:RepairScriptVersion = '1.6.0' +$script:RepairScriptVersion = '1.6.1' $script:ExecutionStarted = Get-Date $script:OperationCount = 0 $script:LastCommand = $null @@ -704,7 +707,7 @@ $gptCollisionDiskNumbers = @() $repairDiskIdentityRecord = $null Log-Info "Starting repair-only SAC enabler. Logs: $logFile" -Log-Info "Build marker: v1.6.0-preserve-source-gpt-identity" +Log-Info "Build marker: v1.6.1-repair-unknown-loader-device" # VMRepairMint telemetry marker Log-Info "[script_start] Script=sac-enabler Version=$($script:RepairScriptVersion)" @@ -712,7 +715,7 @@ Log-Info "[script_start] Script=sac-enabler Version=$($script:RepairScriptVersio Write-SacTelemetry -Event Start -Message 'script_start' -Properties @{ ScriptName = 'sac-enabler.ps1' ScriptVersion = $script:RepairScriptVersion - BuildMarker = 'v1.6.0-preserve-source-gpt-identity' + BuildMarker = 'v1.6.1-repair-unknown-loader-device' StartTimeUtc = (Get-Date).ToUniversalTime().ToString('o') } @@ -1205,12 +1208,8 @@ try { if ($originalLoaderPath -notmatch '(?i)^\\Windows\\System32\\winload\.(exe|efi)$') { throw "Selected entry $defaultId does not reference winload.exe or winload.efi. Path: $originalLoaderPath. No BCD changes were made." } - if ($originalDevice -match '(?i)^unknown$') { - throw "Selected loader $defaultId has device=unknown. Separate BCD repair is required; no BCD changes were made." - } - if ($originalOsDevice -match '(?i)^unknown$') { - throw "Selected loader $defaultId has osdevice=unknown. Separate BCD repair is required; no BCD changes were made." - } + $repairLoaderDevice = $originalDevice -match '(?i)^unknown$' + $repairLoaderOsDevice = $originalOsDevice -match '(?i)^unknown$' # Offline BCD output uses the guest's drive-letter namespace (commonly C:), # while the repair VM mounts that partition under a temporary letter. @@ -1303,6 +1302,57 @@ try { Log-Output '--- BCD FULL STORE BEFORE SAC ENABLE ---' foreach ($line in $beforeFullQuery.Output) { if (-not [string]::IsNullOrWhiteSpace([string]$line)) { Log-Output $line } } + if ($repairLoaderDevice -or $repairLoaderOsDevice) { + $validatedWindowsPartition = "partition=${windowsDrive}:" + Log-Warning "Loader mapping contains an unknown descriptor. Repairing it to the validated Windows partition $validatedWindowsPartition while the source disk retains its original identity." + $bcdWriteStarted = $true + + if ($repairLoaderDevice) { + $setDeviceResult = Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/set', $defaultId, 'device', $validatedWindowsPartition) -Operation 'repair-loader-device' + if (-not $setDeviceResult.Success) { + throw "Could not repair device for loader $defaultId. Exit code: $($setDeviceResult.ExitCode)." + } + } + if ($repairLoaderOsDevice) { + $setOsDeviceResult = Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/set', $defaultId, 'osdevice', $validatedWindowsPartition) -Operation 'repair-loader-osdevice' + if (-not $setOsDeviceResult.Success) { + throw "Could not repair osdevice for loader $defaultId. Exit code: $($setOsDeviceResult.ExitCode)." + } + } + + $repairedLoaderQuery = Invoke-SacBcdEdit -Arguments @('/store', $bcdPath, '/enum', $defaultId, '/v') -Operation 'verify-repaired-loader-mapping' + if (-not $repairedLoaderQuery.Success) { + throw "Could not verify repaired loader mapping for $defaultId." + } + + $repairedLoaderText = $repairedLoaderQuery.Output -join "`n" + $repairedPathMatch = [regex]::Match($repairedLoaderText, '(?im)^\s*path\s+(.+?)\s*$') + $repairedDeviceMatch = [regex]::Match($repairedLoaderText, '(?im)^\s*device\s+(.+?)\s*$') + $repairedOsDeviceMatch = [regex]::Match($repairedLoaderText, '(?im)^\s*osdevice\s+(.+?)\s*$') + $repairedSystemRootMatch = [regex]::Match($repairedLoaderText, '(?im)^\s*systemroot\s+(.+?)\s*$') + if (-not $repairedPathMatch.Success -or + -not $repairedDeviceMatch.Success -or + -not $repairedOsDeviceMatch.Success -or + -not $repairedSystemRootMatch.Success -or + $repairedDeviceMatch.Groups[1].Value.Trim() -match '(?i)^unknown$' -or + $repairedOsDeviceMatch.Groups[1].Value.Trim() -match '(?i)^unknown$' -or + $repairedPathMatch.Groups[1].Value.Trim() -ine $originalLoaderPath -or + $repairedSystemRootMatch.Groups[1].Value.Trim() -ine $originalSystemRoot) { + throw "Loader mapping repair verification failed for $defaultId." + } + + $originalDevice = $repairedDeviceMatch.Groups[1].Value.Trim() + $originalOsDevice = $repairedOsDeviceMatch.Groups[1].Value.Trim() + Write-SacTelemetry -Event Operation -Message 'Unknown loader mapping repaired' -Properties @{ + DiskNumber = $diskNumber + BcdPath = $bcdPath + LoaderGuid = $defaultId + Device = $originalDevice + OsDevice = $originalOsDevice + SourceIdentityChanged = $false + } + } + # Enable only the settings required by Microsoft's offline SAC procedure. # Typed WMI setters update only the requested elements. For Gen2, the source # disk retains its original GPT identity throughout this operation. From e245bf48ba63ffbf0052f1fca34749c4a1ca18ad Mon Sep 17 00:00:00 2001 From: Tony Mocanu <64985430+anmocanu@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:44:23 +0300 Subject: [PATCH 43/43] Update --- src/windows/sac-enabler.ps1 | 64 ++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/src/windows/sac-enabler.ps1 b/src/windows/sac-enabler.ps1 index 6ae0143e..b45c9908 100644 --- a/src/windows/sac-enabler.ps1 +++ b/src/windows/sac-enabler.ps1 @@ -20,42 +20,42 @@ Name: sac-enabler.ps1 Author: Tony.Mocanu@Microsoft.com Last Modified: 2026-08-06 - Version: 1.6.1 + Version: 1.3 Requirement: Azure repair VM with an attached Windows OS disk DeployMode: az vm repair run (with --run-on-repair) .VERSION - v1.6.1: [August 2026] - Repairs loader device/osdevice when they render as unknown. - - Writes the validated Windows partition while the source retains its original GPT identity. - - Re-queries and verifies the repaired loader mapping before enabling EMS. - v1.6.0: [August 2026] - Preserves the Gen2 source disk GPT GUID for the entire repair. - - Resolves a GPT collision by temporarily changing only the disposable repair VM OS disk GUID. - - Offlines the source disk before restoring and verifying the repair OS disk GUID. - v1.5.9: [August 2026] - Refuses to rewrite a collision-offlined GPT disk identity. - - Requires a non-colliding repair OS disk or matching-generation nested repair for Gen2. - v1.5.8: [August 2026] - Rebinds embedded BCD WMI results through their documented key properties. - - Avoids invalid ManagementBaseObject-to-ManagementObject casts. - v1.5.7: [August 2026] - Uses typed BCD WMI element setters instead of BCDEdit for writes. - - Prevents unrelated GPT device descriptors from being reserialized under a temporary identity. - v1.5.6: [August 2026] - Temporarily assigns a unique identity to collision-offlined attached disks. - - Restores and verifies the exact original disk identity before reporting success. - v1.5.5: [August 2026] - Refuses repair when an attached disk is offline due to an identity collision. - - Prevents onlining a colliding source disk and invalidating BCD device references. - v1.5.4: [August 2026] - Skips EMS writes for settings that are already correct. - - Logs the full verbose BCD store before and after changes. - v1.5.3: [August 2026] - Uses only the documented offline EMS and EMS settings commands. - - Does not enable the optional Windows boot menu or Boot Manager EMS. - v1.5.2: [August 2026] - Restores the verified BCD backup after any post-write failure. - - Preserves device and osdevice mappings without normalization. - v1.5.1: [August 2026] - Avoids comparing guest BCD drive letters with repair-VM mount letters. - - Uses VM generation when selecting the detected loader path for logging. - v1.5: [August 2026] - Validates loader mapping before BCD writes and verifies mapping invariance afterward. + v1.3: [August 2026] - Repairs loader device/osdevice when they render as unknown. + - Writes the validated Windows partition while the source retains its original GPT identity. + - Re-queries and verifies the repaired loader mapping before enabling EMS. + - Preserves the Gen2 source disk GPT GUID for the entire repair. + - Resolves a GPT collision by temporarily changing only the disposable repair VM OS disk GUID. + - Offlines the source disk before restoring and verifying the repair OS disk GUID. + - Refuses to rewrite a collision-offlined GPT disk identity. + - Requires a non-colliding repair OS disk or matching-generation nested repair for Gen2. + - Rebinds embedded BCD WMI results through their documented key properties. + - Avoids invalid ManagementBaseObject-to-ManagementObject casts. + - Uses typed BCD WMI element setters instead of BCDEdit for writes. + - Prevents unrelated GPT device descriptors from being reserialized under a temporary identity. + - Temporarily assigns a unique identity to collision-offlined attached disks. + - Restores and verifies the exact original disk identity before reporting success. + - Refuses repair when an attached disk is offline due to an identity collision. + - Prevents onlining a colliding source disk and invalidating BCD device references. + - Skips EMS writes for settings that are already correct. + - Logs the full verbose BCD store before and after changes. + - Uses only the documented offline EMS and EMS settings commands. + - Does not enable the optional Windows boot menu or Boot Manager EMS. + - Restores the verified BCD backup after any post-write failure. + - Preserves device and osdevice mappings without normalization. + - Avoids comparing guest BCD drive letters with repair-VM mount letters. + - Uses VM generation when selecting the detected loader path for logging. + - Validates loader mapping before BCD writes and verifies mapping invariance afterward. - Requires and verifies a BCD backup before applying SAC settings. - Restores the backup if path, device, osdevice, or systemroot changes unexpectedly. - v1.4: [August 2026] - Added VMRepairMint telemetry, structured before-state capture, and Gen1/Gen2 discovery telemetry. + - Added VMRepairMint telemetry, structured before-state capture, and Gen1/Gen2 discovery telemetry. - Added explicit winload.exe and winload.efi detection. - Added explicit displayorder and boot entry GUID failure diagnostics. - Update [July 2026] - Restricted execution to repair VM mode. + - Restricted execution to repair VM mode. - Uses Get-Disk-Partitions to enumerate Azure virtual disks. - Detects repair vs. standard context from secondary disks returned by the helper. - Mounts unlettered Gen2 Windows and EFI partitions temporarily. @@ -63,7 +63,7 @@ - Refuses BCD changes when a repair VM context is not detected. - Fails closed if the repair VM OS disk cannot be identified. - Filters out the repair VM OS disk before processing attached disks. - Update: [July 2026] - Added execution context detection and dual-logging. + - Added execution context detection and dual-logging. - Detected rescue VM mode versus standard mode for context-aware error messages. - Logs to both the desktop and the plugin directory for automatic collection by az vm repair. - **NEW SAFETY: Pre-flight checks, BCD backup, and post-change verification. @@ -487,7 +487,7 @@ function Log-Debug { } # Structured telemetry is written through the existing dual-write logging path. -$script:RepairScriptVersion = '1.6.1' +$script:RepairScriptVersion = '1.3' $script:ExecutionStarted = Get-Date $script:OperationCount = 0 $script:LastCommand = $null @@ -707,7 +707,7 @@ $gptCollisionDiskNumbers = @() $repairDiskIdentityRecord = $null Log-Info "Starting repair-only SAC enabler. Logs: $logFile" -Log-Info "Build marker: v1.6.1-repair-unknown-loader-device" +Log-Info "Build marker: v1.3-repair-unknown-loader-device" # VMRepairMint telemetry marker Log-Info "[script_start] Script=sac-enabler Version=$($script:RepairScriptVersion)" @@ -715,7 +715,7 @@ Log-Info "[script_start] Script=sac-enabler Version=$($script:RepairScriptVersio Write-SacTelemetry -Event Start -Message 'script_start' -Properties @{ ScriptName = 'sac-enabler.ps1' ScriptVersion = $script:RepairScriptVersion - BuildMarker = 'v1.6.1-repair-unknown-loader-device' + BuildMarker = 'v1.3-repair-unknown-loader-device' StartTimeUtc = (Get-Date).ToUniversalTime().ToString('o') }