#Requires -Version 5.1 <# .SYNOPSIS Safely (re)creates the Recovery Partition (WinRE) at the end of the system disk and, on request, expands C: into the remaining space. .DESCRIPTION Multi-phase, highly defensive script: PHASE 1 - DISCOVERY (read-only) PHASE 2 - VALIDATION (read-only) PHASE 3 - USER CONFIRMATION PHASE 4 - DESTRUCTIVE OPERATION (remove old Recovery, if present) PHASE 5 - REBUILD (create new Recovery FIRST at the TRUE end of the disk using an explicit -Offset, then optionally expand C: into whatever remains, THEN enable WinRE) PHASE 6 - VALIDATION (final audit) PHASE 7 - FINAL REPORT v2.1.0 CHANGES (bugfix release): - New-RecoveryPartition now computes an EXPLICIT end-of-disk -Offset (disk.Size - buffer - RecoverySizeMB) instead of letting New-Partition auto-place it. Auto-placement (no -Offset) puts a new partition at the START of the first available free extent - NOT the end. Since the free extent runs from the end of C: to the end of the disk, this glued Recovery directly onto C: and pushed ALL remaining free space past Recovery, to the true end of the disk - the opposite of the intended design, and it also meant the gap-to-expand-C-into was always computed as ~0 MB, so the script never asked to expand C:. - The interactive "expand C: into remaining space?" question is now asked BEFORE reagentc /enable, not after. Recovery is created at the true end of the disk (so there can be a gap between C: and Recovery at that point); if adjacency between C: and Recovery really is required for reagentc to accept the partition (per this script's own prior testing notes), closing that gap has to happen before WinRE is enabled, not after. - The old "blocking partition directly after C:" hard-abort guard (which assumed Recovery must always sit immediately after C:) was relaxed to an informational check, since Recovery is no longer required to be placed immediately after C: at creation time. An existing Recovery Partition is OPTIONAL on input. If none is found, the script treats this as a fresh-build scenario (e.g. after a manual diskpart shrink) instead of failing. The script NEVER assumes Disk 0, Partition 4, or any fixed size. Every disk/partition reference is re-detected dynamically and re-validated immediately before any destructive action. .PARAMETER AuditOnly Performs full discovery + validation, prints the plan, but makes NO changes to the system. .PARAMETER RecoverySizeMB Target size (MB) of the new Recovery Partition. Default 2000. NOTE: 1024 MB is NOT reliably enough on modern Windows 11 - reagentc /enable silently falls back to storing WinRE inside C: itself (instead of using the Recovery partition) if the partition is too small for the current Winre.wim plus its own required working-space buffer, even though the partition otherwise looks perfectly valid. The script also checks the actual current Winre.wim size at runtime and warns if this value is too small for it. .PARAMETER DiskEndBufferMB Small safety buffer (MB) left truly unallocated at the very end of the disk, to avoid colliding with the GPT backup header / alignment rounding. Default 1. .PARAMETER LogPath Directory for the log file. Default C:\Temp. .PARAMETER SkipBanner Suppresses the ASCII banner. .EXAMPLE .\WinRE-Resize2.ps1 -AuditOnly .EXAMPLE .\WinRE-Resize2.ps1 -RecoverySizeMB 1024 .NOTES Author : F.Š. | Windows Recovery Partition Tool Version: 2.1.0 #> [CmdletBinding()] param( [switch]$AuditOnly, [int]$RecoverySizeMB = 2000, [int]$DiskEndBufferMB = 1, [string]$LogPath = "C:\Temp", [switch]$SkipBanner ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # ============================================================================ # GLOBAL STATE # ============================================================================ $Script:ToolVersion = "2.1.0" $Script:LogFile = $null $Script:TranscriptFile = $null $Script:CurrentStep = 0 $Script:TotalSteps = 14 $Script:RecoveryGptType = "de94bba4-06d1-4d40-a16a-bfd50179d6ac" $Script:RecoveryMbrType = 27 $Script:RecoveryGptAttrs = "0x8000000000000001" $Script:BitLockerSuspended = $false # true while WE hold BitLocker protection suspended # ============================================================================ # FUNCTION: Write-Log # ============================================================================ function Write-Log { param( [Parameter(Mandatory)][string]$Message, [ValidateSet('INFO','WARNING','ERROR','SUCCESS','DEBUG')][string]$Level = 'INFO' ) $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $hostName = $env:COMPUTERNAME $line = "[$timestamp] [$Level] [$hostName] $Message" if ($Script:LogFile) { try { Add-Content -Path $Script:LogFile -Value $line -ErrorAction Stop } catch { Write-Host "LOG WRITE FAILED: $_" -ForegroundColor Red } } switch ($Level) { 'WARNING' { Write-Host $line -ForegroundColor Yellow } 'ERROR' { Write-Host $line -ForegroundColor Red } 'SUCCESS' { Write-Host $line -ForegroundColor Green } 'DEBUG' { Write-Host $line -ForegroundColor DarkGray } default { Write-Host $line } } } # ============================================================================ # FUNCTION: Write-Banner # ============================================================================ function Write-Banner { if ($SkipBanner) { return } $banner = @" ███████╗███████╗ ██╔════╝██╔════╝ █████╗ ███████╗ ██╔══╝ ╚════██║ ██║ ███████║ ╚═╝ ╚══════╝ F.Š. | Windows Recovery Partition Tool "@ Write-Host $banner -ForegroundColor Cyan Write-Host "Version : $Script:ToolVersion" Write-Host "Computer : $env:COMPUTERNAME" Write-Host "User : $env:USERDOMAIN\$env:USERNAME" Write-Host "Started : $(Get-Date -Format 'dd.MM.yyyy HH:mm:ss')" Write-Host "PS Ver : $($PSVersionTable.PSVersion.ToString())" Write-Host "OS : $((Get-CimInstance Win32_OperatingSystem).Caption)" if ($AuditOnly) { Write-Host "MODE : AUDIT ONLY - no changes will be made" -ForegroundColor Yellow } Write-Host ("=" * 60) Write-Host "" } # ============================================================================ # FUNCTION: Write-Step # ============================================================================ function Write-Step { param( [Parameter(Mandatory)][string]$Description, [ValidateSet('START','OK','FAIL','SKIP')][string]$Status = 'START' ) if ($Status -eq 'START') { $Script:CurrentStep++ $prefix = "[{0}/{1}]" -f $Script:CurrentStep, $Script:TotalSteps Write-Host ("{0} {1}" -f $prefix, $Description.PadRight(45, '.')) -NoNewline Write-Log -Message "STEP START: $Description" -Level 'INFO' } else { $color = switch ($Status) { 'OK' {'Green'} 'FAIL' {'Red'} 'SKIP' {'Yellow'} } Write-Host " [$Status]" -ForegroundColor $color Write-Log -Message "STEP ${Status}: $Description" -Level $(if ($Status -eq 'FAIL') {'ERROR'} else {'INFO'}) } } # ============================================================================ # FUNCTION: Stop-Safely # ============================================================================ function Stop-Safely { param( [string]$Reason = "User requested termination", [int]$ExitCode = 1 ) if ($Script:BitLockerSuspended) { Write-Log -Message "Aborting with BitLocker still suspended - resuming protection before exit." -Level 'WARNING' Resume-BitLockerProtection -MountPoint 'C:' $Script:BitLockerSuspended = $false } Write-Log -Message "SCRIPT TERMINATED: $Reason" -Level 'ERROR' Write-Host "" Write-Host ("=" * 60) -ForegroundColor Red Write-Host "SCRIPT STOPPED" -ForegroundColor Red Write-Host ("=" * 60) -ForegroundColor Red Write-Host "Reason : $Reason" -ForegroundColor Red if ($Script:LogFile) { Write-Host "Log : $Script:LogFile" -ForegroundColor Red } Write-Host ("=" * 60) -ForegroundColor Red try { Stop-Transcript -ErrorAction SilentlyContinue | Out-Null } catch {} exit $ExitCode } # ============================================================================ # FUNCTION: Test-IsAdministrator # ============================================================================ function Test-IsAdministrator { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = [Security.Principal.WindowsPrincipal]::new($identity) return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } # ============================================================================ # FUNCTION: Confirm-YesNo (generic Y/N/Q prompt helper) # ============================================================================ function Confirm-YesNo { param( [Parameter(Mandatory)][string]$Prompt ) while ($true) { Write-Host "" Write-Host $Prompt Write-Host "[Y] Yes [N] No [Q] Quit" -ForegroundColor Cyan $answer = Read-Host "Your choice" switch ($answer.Trim().ToUpper()) { 'Y' { return $true } 'N' { return $false } 'Q' { Stop-Safely -Reason "User aborted at confirmation prompt" } default { Write-Host "Invalid input, please type Y, N or Q." -ForegroundColor Yellow } } } } # ============================================================================ # FUNCTION: Invoke-NativeCommand # Runs an external .exe and captures stdout+stderr WITHOUT letting # $ErrorActionPreference = 'Stop' turn stderr lines into terminating # errors. # ============================================================================ function Invoke-NativeCommand { param( [Parameter(Mandatory)][string]$FilePath, [string[]]$ArgumentList = @() ) $previousEAP = $ErrorActionPreference $ErrorActionPreference = 'Continue' $global:LASTEXITCODE = 0 try { if ($ArgumentList.Count -gt 0) { $rawOutput = & $FilePath @ArgumentList 2>&1 } else { $rawOutput = & $FilePath 2>&1 } } finally { $ErrorActionPreference = $previousEAP } $lines = @($rawOutput | ForEach-Object { $_.ToString() }) return [PSCustomObject]@{ Output = $lines ExitCode = $LASTEXITCODE } } # ============================================================================ # FUNCTION: Get-SystemDisk # ============================================================================ function Get-SystemDisk { Write-Log -Message "Detecting system disk hosting C:\" -Level 'INFO' $cPartition = Get-Partition -DriveLetter C -ErrorAction Stop $disk = Get-Disk -Number $cPartition.DiskNumber -ErrorAction Stop Write-Log -Message "System disk detected: Disk $($disk.Number) ($($disk.FriendlyName))" -Level 'SUCCESS' Write-Host "" Write-Host "SYSTEM DISK" -ForegroundColor Cyan Write-Host "Disk Number : $($disk.Number)" Write-Host "Model : $($disk.FriendlyName)" Write-Host "Serial Number : $($disk.SerialNumber)" Write-Host "Bus Type : $($disk.BusType)" Write-Host "Partition Style : $($disk.PartitionStyle)" Write-Host "Size : $([math]::Round($disk.Size/1GB,2)) GB" Write-Host "Operational Status : $($disk.OperationalStatus)" Write-Host "Health Status : $($disk.HealthStatus)" return [PSCustomObject]@{ Disk = $disk CPartition = $cPartition } } # ============================================================================ # FUNCTION: Get-DiskLayout # ============================================================================ function Get-DiskLayout { param([Parameter(Mandatory)][int]$DiskNumber) $partitions = Get-Partition -DiskNumber $DiskNumber | Sort-Object Offset Write-Host "" Write-Host "CURRENT PARTITION LAYOUT (Disk $DiskNumber)" -ForegroundColor Cyan Write-Host ("{0,-6} {1,-12} {2,-10} {3,-16} {4,-8} {5}" -f "Part#","Type","SizeMB","Offset","Drive","GptType") foreach ($p in $partitions) { $gptType = $null try { $gptType = $p.GptType } catch { $gptType = "n/a" } Write-Host ("{0,-6} {1,-12} {2,-10} {3,-16} {4,-8} {5}" -f ` $p.PartitionNumber, $p.Type, [math]::Round($p.Size/1MB,0), $p.Offset, ` ($(if ($p.DriveLetter) { $p.DriveLetter } else { '-' })), $gptType) } return $partitions } # ============================================================================ # FUNCTION: Get-TrailingFreeSpaceMB # ============================================================================ function Get-TrailingFreeSpaceMB { param([Parameter(Mandatory)][int]$DiskNumber) $disk = Get-Disk -Number $DiskNumber $freeBytes = $disk.Size - $disk.AllocatedSize if ($freeBytes -lt 0) { $freeBytes = 0 } return [math]::Floor($freeBytes / 1MB) } # ============================================================================ # FUNCTION: Get-WinREStatus # ============================================================================ function Get-WinREStatus { Write-Log -Message "Querying reagentc /info" -Level 'INFO' $result = Invoke-NativeCommand -FilePath 'reagentc.exe' -ArgumentList @('/info') $raw = $result.Output Write-Log -Message "reagentc /info output (exit $($result.ExitCode)): `n$($raw -join "`n")" -Level 'DEBUG' $statusLine = $raw | Where-Object { $_ -match 'Windows RE status' } $locationLine = $raw | Where-Object { $_ -match 'Windows RE location' } $status = if ($statusLine -match ':\s*(.+)$') { $Matches[1].Trim() } else { 'Unknown' } $location = if ($locationLine -match 'harddisk(\d+)\\partition(\d+)') { [PSCustomObject]@{ DiskNumber = [int]$Matches[1]; PartitionNumber = [int]$Matches[2] } } else { $null } return [PSCustomObject]@{ Status = $status Location = $location RawOutput = $raw } } # ============================================================================ # FUNCTION: Get-RecoveryPartition # ============================================================================ function Get-RecoveryPartition { param( [Parameter(Mandatory)]$Disk, [Parameter(Mandatory)]$Partitions, [Parameter(Mandatory)]$CPartition, [Parameter(Mandatory)]$WinREStatus ) Write-Log -Message "Detecting Recovery Partition candidates" -Level 'INFO' $candidates = @() foreach ($p in $Partitions) { if ($p.PartitionNumber -eq $CPartition.PartitionNumber) { continue } $isCandidate = $false $typeMatches = $false $reasons = @() if ($Disk.PartitionStyle -eq 'GPT') { try { if ($p.GptType -and ($p.GptType.Trim('{}').ToLower() -eq $Script:RecoveryGptType)) { $typeMatches = $true } } catch {} } elseif ($Disk.PartitionStyle -eq 'MBR') { try { if ($p.MbrType -eq $Script:RecoveryMbrType) { $typeMatches = $true } } catch {} } if ($typeMatches) { $isCandidate = $true $reasons += if ($Disk.PartitionStyle -eq 'GPT') { "GptType matches Recovery GUID" } else { "MbrType = 27" } if ($p.DriveLetter) { $reasons += "WARNING: has drive letter $($p.DriveLetter): - unusual for a genuine Recovery partition, verify manually" } else { $reasons += "No drive letter" } } if ($p.Offset -gt $CPartition.Offset) { $reasons += "Located after C:" } if ($WinREStatus.Location -and $WinREStatus.Location.PartitionNumber -eq $p.PartitionNumber) { $isCandidate = $true $reasons += "Matches reagentc /info WinRE location" } if (-not $isCandidate -and -not $p.DriveLetter -and $p.Offset -ge ($CPartition.Offset + $CPartition.Size) -and ($p.Offset - ($CPartition.Offset + $CPartition.Size)) -lt 1MB -and $p.Size -le 4096MB) { $isCandidate = $true $reasons += "UNRECOGNIZED TYPE but immediately after C: with no gap and no drive letter - likely an orphaned Recovery-ish partition" } if ($isCandidate) { $candidates += [PSCustomObject]@{ Partition = $p Reasons = $reasons } } } if ($candidates.Count -eq 0) { Write-Log -Message "No existing Recovery Partition detected on this disk." -Level 'INFO' return $null } if ($candidates.Count -gt 1) { Write-Log -Message "Multiple Recovery Partition candidates found ($($candidates.Count)) - cannot auto-select" -Level 'ERROR' Write-Host "" Write-Host "AMBIGUOUS: multiple possible Recovery partitions detected:" -ForegroundColor Red foreach ($c in $candidates) { Write-Host " - Partition $($c.Partition.PartitionNumber): $($c.Reasons -join '; ')" } return $null } $winner = $candidates[0] Write-Log -Message "Recovery Partition identified: Partition $($winner.Partition.PartitionNumber) [$($winner.Reasons -join '; ')]" -Level 'SUCCESS' return $winner.Partition } # ============================================================================ # FUNCTION: Test-DiskLayout # ============================================================================ function Test-DiskLayout { param( [Parameter(Mandatory)]$Partitions, [Parameter(Mandatory)]$CPartition, [Parameter(Mandatory)]$RecoveryPartition ) $sorted = $Partitions | Sort-Object Offset $cIndex = [array]::IndexOf($sorted.PartitionNumber, $CPartition.PartitionNumber) $rIndex = [array]::IndexOf($sorted.PartitionNumber, $RecoveryPartition.PartitionNumber) if ($rIndex -ne ($cIndex + 1)) { Write-Log -Message "Recovery partition is NOT directly behind C: (other partitions in between)" -Level 'ERROR' return $false } Write-Log -Message "Disk layout validated: Recovery partition is immediately behind C:" -Level 'SUCCESS' return $true } # ============================================================================ # FUNCTION: Confirm-RecoveryPartition # ============================================================================ function Confirm-RecoveryPartition { param( [Parameter(Mandatory)][int]$DiskNumber, [Parameter(Mandatory)]$RecoveryPartition, [Parameter(Mandatory)]$CPartition ) $gptType = $null try { $gptType = $RecoveryPartition.GptType } catch { $gptType = "n/a" } Write-Host "" Write-Host ("=" * 60) -ForegroundColor Cyan Write-Host "RECOVERY PARTITION DETECTION" -ForegroundColor Cyan Write-Host ("=" * 60) -ForegroundColor Cyan Write-Host "The script believes the following partition is WinRE:" Write-Host "" Write-Host "Disk Number : $DiskNumber" Write-Host "Partition Number : $($RecoveryPartition.PartitionNumber)" Write-Host "Size : $([math]::Round($RecoveryPartition.Size/1MB,0)) MB" Write-Host "Offset : $($RecoveryPartition.Offset)" Write-Host "Type : $($RecoveryPartition.Type)" Write-Host "GPT Type : $gptType" Write-Host "Drive Letter : $(if ($RecoveryPartition.DriveLetter) { $RecoveryPartition.DriveLetter } else { 'None' })" Write-Host "Position : $(if ($RecoveryPartition.Offset -gt $CPartition.Offset) { 'After C:' } else { 'Before C:' })" return Confirm-YesNo -Prompt "Is this the CORRECT Recovery Partition (to be deleted and recreated)?" } # ============================================================================ # FUNCTION: Confirm-Snapshot # ============================================================================ function Confirm-Snapshot { Write-Host "" Write-Host ("=" * 60) -ForegroundColor Yellow Write-Host "IMPORTANT - VM SNAPSHOT / CHECKPOINT" -ForegroundColor Yellow Write-Host ("=" * 60) -ForegroundColor Yellow Write-Host @" Before continuing, verify that this VM does NOT have an active snapshot (VMware) or checkpoint (Hyper-V). This script cannot guarantee that VMware snapshots or Hyper-V checkpoints are correctly detected from inside Windows. Recommended: - Verify VMware snapshot / Hyper-V checkpoint state manually. - Verify current backup exists and is valid. "@ Write-Host ("=" * 60) -ForegroundColor Yellow return Confirm-YesNo -Prompt "Have you verified that the VM has no active snapshot/checkpoint (VMware or Hyper-V)?" } # ============================================================================ # FUNCTION: Confirm-Backup # ============================================================================ function Confirm-Backup { return Confirm-YesNo -Prompt "Have you verified that a recent successful backup exists?" } # ============================================================================ # FUNCTION: Get-BitLockerInfo # ============================================================================ function Get-BitLockerInfo { param([string]$MountPoint = 'C:') try { return Get-BitLockerVolume -MountPoint $MountPoint -ErrorAction Stop } catch { Write-Log -Message "Could not query BitLocker status for ${MountPoint}: $_" -Level 'DEBUG' return $null } } # ============================================================================ # FUNCTION: Suspend-BitLockerProtection # ============================================================================ function Suspend-BitLockerProtection { param([string]$MountPoint = 'C:') $info = Get-BitLockerInfo -MountPoint $MountPoint if (-not $info -or $info.VolumeStatus -eq 'FullyDecrypted') { return $false } if ($info.ProtectionStatus -ne 'On') { Write-Log -Message "BitLocker on $MountPoint is encrypted but protection is already Off - nothing to suspend." -Level 'INFO' return $false } Write-Log -Message "Suspending BitLocker protection on $MountPoint for 1 restart (required before reagentc/diskpart touch the boot config)." -Level 'WARNING' try { Suspend-BitLocker -MountPoint $MountPoint -RebootCount 1 -ErrorAction Stop | Out-Null } catch { Write-Log -Message "Failed to suspend BitLocker on ${MountPoint}: $_" -Level 'ERROR' return $false } Write-Log -Message "BitLocker protection suspended on $MountPoint." -Level 'SUCCESS' return $true } # ============================================================================ # FUNCTION: Resume-BitLockerProtection # ============================================================================ function Resume-BitLockerProtection { param([string]$MountPoint = 'C:') Write-Log -Message "Resuming BitLocker protection on $MountPoint." -Level 'INFO' try { Resume-BitLocker -MountPoint $MountPoint -ErrorAction Stop | Out-Null Write-Log -Message "BitLocker protection resumed on $MountPoint." -Level 'SUCCESS' } catch { Write-Log -Message "FAILED to resume BitLocker protection on ${MountPoint}: $_. Resume it manually: Resume-BitLocker -MountPoint $MountPoint (or 'manage-bde -protectors -enable $MountPoint')." -Level 'ERROR' } } # ============================================================================ # FUNCTION: Wait-BitLockerDecryption # ============================================================================ function Wait-BitLockerDecryption { param([string]$MountPoint = 'C:') $bitlocker = $null while ($true) { try { $bitlocker = Get-BitLockerVolume -MountPoint $MountPoint -ErrorAction Stop } catch { Write-Log -Message "Could not query BitLocker status for ${MountPoint} while waiting for decryption: $_" -Level 'ERROR' break } Clear-Host Write-Host "BitLocker decryption progress" -ForegroundColor Cyan Write-Host "--------------------------------" Write-Host "Volume : $($bitlocker.MountPoint)" Write-Host "Status : $($bitlocker.VolumeStatus)" Write-Host "Progress : $($bitlocker.EncryptionPercentage)%" Write-Host "Protection : $($bitlocker.ProtectionStatus)" Write-Host "" Write-Host "Last check : $(Get-Date)" if ($bitlocker.VolumeStatus -ne 'DecryptionInProgress') { Write-Host "" Write-Host "Decryption is no longer in progress." -ForegroundColor Green break } Start-Sleep -Seconds 5 } return $bitlocker } # ============================================================================ # FUNCTION: Invoke-BitLockerDecryptWorkaround # ============================================================================ function Invoke-BitLockerDecryptWorkaround { param([string]$MountPoint = 'C:') $before = Get-BitLockerInfo -MountPoint $MountPoint if (-not $before -or $before.VolumeStatus -eq 'FullyDecrypted') { return [PSCustomObject]@{ Success = $true; ProtectorTypes = @() } } $protectorTypes = @($before.KeyProtector | ForEach-Object { $_.KeyProtectorType }) Write-Host "" Write-Host ("=" * 60) -ForegroundColor Red Write-Host "BITLOCKER FULL DECRYPT WORKAROUND" -ForegroundColor Red Write-Host ("=" * 60) -ForegroundColor Red Write-Host @" reagentc.exe refuses to install WinRE while $MountPoint carries ANY BitLocker encryption - confirmed via ReAgent.log: it falls back to targeting the OS partition itself and then rejects that purely because of BitLocker, even with protection suspended/off. The only known workaround is to FULLY decrypt $MountPoint, enable WinRE, then re-encrypt it again. The disk will be UNENCRYPTED for the entire decrypt + re-encrypt duration. Protectors currently on ${MountPoint}: $($protectorTypes -join ', ') This script can only restore a plain TPM and/or recovery-password protector afterwards - NOT any PIN/startup-key/AD-Entra escrow setup you may have. You are responsible for reconfiguring that and re-escrowing any new recovery key per your organization policy. "@ Write-Host ("=" * 60) -ForegroundColor Red if (-not (Confirm-YesNo -Prompt "Fully decrypt $MountPoint now so WinRE can be enabled, then re-encrypt it afterwards?")) { return [PSCustomObject]@{ Success = $false; ProtectorTypes = $protectorTypes } } Write-Log -Message "Disabling BitLocker (full decrypt) on $MountPoint. Protectors before: $($protectorTypes -join ', ')" -Level 'WARNING' try { Disable-BitLocker -MountPoint $MountPoint -ErrorAction Stop | Out-Null } catch { Write-Log -Message "Disable-BitLocker failed: $_" -Level 'ERROR' return [PSCustomObject]@{ Success = $false; ProtectorTypes = $protectorTypes } } $final = Wait-BitLockerDecryption -MountPoint $MountPoint if (-not $final -or $final.VolumeStatus -ne 'FullyDecrypted') { $endStatus = if ($final) { $final.VolumeStatus } else { 'Unknown' } Write-Log -Message "$MountPoint did not reach FullyDecrypted (ended at $endStatus) - aborting workaround." -Level 'ERROR' return [PSCustomObject]@{ Success = $false; ProtectorTypes = $protectorTypes } } Write-Log -Message "$MountPoint is now fully decrypted." -Level 'SUCCESS' return [PSCustomObject]@{ Success = $true; ProtectorTypes = $protectorTypes } } # ============================================================================ # FUNCTION: Invoke-BitLockerReencrypt # ============================================================================ function Invoke-BitLockerReencrypt { param( [string]$MountPoint = 'C:', [string[]]$OriginalProtectorTypes = @() ) Write-Host "" Write-Host ("=" * 60) -ForegroundColor Yellow Write-Host "RE-ENCRYPTING $MountPoint" -ForegroundColor Yellow Write-Host ("=" * 60) -ForegroundColor Yellow try { if ($OriginalProtectorTypes -contains 'Tpm') { Write-Log -Message "Re-enabling BitLocker on $MountPoint with a TPM protector (matches what was there before)." -Level 'INFO' Enable-BitLocker -MountPoint $MountPoint -TpmProtector -SkipHardwareTest -ErrorAction Stop | Out-Null } else { Write-Log -Message "Re-enabling BitLocker on $MountPoint with a recovery-password protector (no TPM protector was present before)." -Level 'INFO' Enable-BitLocker -MountPoint $MountPoint -RecoveryPasswordProtector -SkipHardwareTest -ErrorAction Stop | Out-Null } $info = Get-BitLockerInfo -MountPoint $MountPoint if (-not ($info.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' })) { Add-BitLockerKeyProtector -MountPoint $MountPoint -RecoveryPasswordProtector -ErrorAction Stop | Out-Null $info = Get-BitLockerInfo -MountPoint $MountPoint } } catch { Write-Log -Message "Re-enabling BitLocker on $MountPoint failed: $_. You MUST re-encrypt $MountPoint manually." -Level 'ERROR' return $false } $rp = $info.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' } | Select-Object -First 1 if ($rp) { Write-Host "" Write-Host "!!! NEW BITLOCKER RECOVERY PASSWORD - RECORD THIS NOW !!!" -ForegroundColor Red Write-Host "Key protector ID : $($rp.KeyProtectorId)" -ForegroundColor Red Write-Host "Recovery password : $($rp.RecoveryPassword)" -ForegroundColor Red Write-Log -Message "New BitLocker recovery password on ${MountPoint}: ID $($rp.KeyProtectorId) = $($rp.RecoveryPassword)" -Level 'WARNING' } Write-Host "" Write-Host "Encryption has (re)started in the background on $MountPoint." -ForegroundColor Yellow if ($OriginalProtectorTypes -and ($OriginalProtectorTypes | Where-Object { $_ -ne 'Tpm' -and $_ -ne 'RecoveryPassword' })) { Write-Host "Original protectors also included: $($OriginalProtectorTypes -join ', ') - reconfigure any PIN/startup-key/AD-Entra escrow manually." -ForegroundColor Yellow } Write-Log -Message "Re-encryption of $MountPoint started (runs in the background)." -Level 'SUCCESS' return $true } # ============================================================================ # FUNCTION: Show-PreflightChecklist # ============================================================================ function Show-PreflightChecklist { param( [Parameter(Mandatory)][int]$DiskNumber, [Parameter(Mandatory)][string]$WinREStatusText, [Parameter(Mandatory)][bool]$HasExistingRecovery, [Parameter(Mandatory)][int]$AvailableMB, [Parameter(Mandatory)][int]$RecoverySizeMB, [Parameter()]$BitLockerInfo ) Write-Host "" Write-Host ("=" * 60) -ForegroundColor Cyan Write-Host "PRE-FLIGHT CHECKLIST (already verified automatically)" -ForegroundColor Cyan Write-Host ("=" * 60) -ForegroundColor Cyan Write-Host "[OK] Running as Administrator, required cmdlets/tools present" Write-Host "[OK] System disk detected: Disk $DiskNumber" Write-Host "[OK] WinRE status queried: $WinREStatusText" if ($HasExistingRecovery) { Write-Host "[OK] Existing Recovery partition detected (identity confirmed next)" } else { Write-Host "[--] No existing Recovery partition found - fresh build" } Write-Host "[OK] Free space check: $AvailableMB MB available / $RecoverySizeMB MB required" if ($BitLockerInfo) { if ($BitLockerInfo.ProtectionStatus -eq 'On') { Write-Host "[!!] BitLocker on C: is $($BitLockerInfo.VolumeStatus), protection ON - will be suspended automatically for the destructive steps and resumed afterwards" -ForegroundColor Yellow } else { Write-Host "[OK] BitLocker on C: is $($BitLockerInfo.VolumeStatus), protection $($BitLockerInfo.ProtectionStatus) - no suspend needed" } } else { Write-Host "[--] BitLocker not present/not queryable on C:" } Write-Host ("=" * 60) -ForegroundColor Cyan Write-Host "" Write-Host "Still needs YOUR manual confirmation:" -ForegroundColor Yellow if ($HasExistingRecovery) { Write-Host " - Identity of the detected Recovery partition" } Write-Host " - VM snapshot (VMware) / checkpoint (Hyper-V) state" Write-Host " - Existence of a recent, successful backup" Write-Host "" } # ============================================================================ # FUNCTION: Get-ReAgentLogTail # ============================================================================ function Get-ReAgentLogTail { param([int]$Lines = 40) $logPath = Join-Path -Path $env:WINDIR -ChildPath 'Logs\ReAgent\ReAgent.log' if (-not (Test-Path -Path $logPath)) { return $null } try { return Get-Content -Path $logPath -Tail $Lines -ErrorAction Stop } catch { Write-Log -Message "Could not read ${logPath}: $_" -Level 'DEBUG' return $null } } # ============================================================================ # FUNCTION: Get-WinREWimSizeMB # ============================================================================ function Get-WinREWimSizeMB { $candidatePaths = @( (Join-Path -Path $env:WINDIR -ChildPath 'System32\Recovery\Winre.wim') ) foreach ($path in $candidatePaths) { if (Test-Path -Path $path) { try { $bytes = (Get-Item -Path $path -Force -ErrorAction Stop).Length return [math]::Ceiling($bytes / 1MB) } catch { Write-Log -Message "Could not read size of ${path}: $_" -Level 'DEBUG' } } } return $null } # ============================================================================ # FUNCTION: Get-RecommendedRecoverySizeMB # ============================================================================ function Get-RecommendedRecoverySizeMB { param([int]$FallbackMB = 2000) $wimSizeMB = Get-WinREWimSizeMB if (-not $wimSizeMB) { return $FallbackMB } $recommended = $wimSizeMB + 400 + 100 return [math]::Max($recommended, $FallbackMB) } # ============================================================================ # FUNCTION: Disable-WinRE # ============================================================================ function Disable-WinRE { Write-Log -Message "Disabling WinRE via reagentc /disable" -Level 'INFO' $result = Invoke-NativeCommand -FilePath 'reagentc.exe' -ArgumentList @('/disable') Write-Log -Message "reagentc /disable output (exit $($result.ExitCode)): $($result.Output -join ' | ')" -Level 'DEBUG' Start-Sleep -Seconds 2 $status = Get-WinREStatus if ($status.Status -notmatch 'Disabled') { Write-Log -Message "WinRE could not be disabled. Current status: $($status.Status)" -Level 'ERROR' return $false } Write-Log -Message "WinRE successfully disabled" -Level 'SUCCESS' return $true } # ============================================================================ # FUNCTION: Enable-WinRE # ============================================================================ function Enable-WinRE { Write-Log -Message "Enabling WinRE via reagentc /enable" -Level 'INFO' $result = Invoke-NativeCommand -FilePath 'reagentc.exe' -ArgumentList @('/enable') Write-Log -Message "reagentc /enable output (exit $($result.ExitCode)): $($result.Output -join ' | ')" -Level 'DEBUG' Start-Sleep -Seconds 2 $status = Get-WinREStatus if ($status.Status -notmatch 'Enabled') { Write-Log -Message "WinRE could not be enabled. Current status: $($status.Status)" -Level 'ERROR' Write-Log -Message "Full reagentc output: `n$($status.RawOutput -join "`n")" -Level 'ERROR' $reagentLog = Get-ReAgentLogTail -Lines 40 if ($reagentLog) { Write-Log -Message "Tail of $env:WINDIR\Logs\ReAgent\ReAgent.log (often shows the REAL cause behind a misleading console message):`n$($reagentLog -join "`n")" -Level 'ERROR' } if (($result.Output -join ' ') -match 'BitLocker') { Write-Host "" Write-Host ("=" * 60) -ForegroundColor Red Write-Host "REAGENTC REFUSED - CITES BITLOCKER" -ForegroundColor Red Write-Host ("=" * 60) -ForegroundColor Red Write-Host @" reagentc.exe refused to enable WinRE citing BitLocker Drive Encryption on this volume - even with BitLocker protection suspended/off. This is a documented reagentc limitation on some builds: it can block whenever the volume carries ANY BitLocker encryption at all, regardless of protector/suspend state, so suspending protectors (what this script already tried) is not always enough. Known workaround - NOT automated here on purpose, because it means fully decrypting and later re-encrypting C:, and only you know which protectors / recovery-key escrow policy (TPM, PIN, AD/Entra/MBAM) need restoring afterwards: 1. manage-bde -off C: (full decrypt - can take a while) 2. reagentc /enable 3. Re-enable BitLocker with your normal protector setup. IMPORTANT: this exact console message is also known to be misleading - the REAL cause can instead be an undersized Recovery partition for winre.wim, or the new partition's ID/ attributes not yet recognized by reagentc, OR (v2.1) a gap between C: and the Recovery partition if adjacency turns out to matter. Check the ReAgent.log excerpt above (also in the log file) before assuming it really is BitLocker. "@ Write-Host ("=" * 60) -ForegroundColor Red } return $false } Write-Log -Message "WinRE successfully enabled" -Level 'SUCCESS' return $true } # ============================================================================ # FUNCTION: Invoke-DiskpartScript # ============================================================================ function Invoke-DiskpartScript { param([Parameter(Mandatory)][string[]]$Commands) $tempScript = Join-Path -Path $env:TEMP -ChildPath "diskpart_$([guid]::NewGuid().ToString('N')).txt" $Commands | Set-Content -Path $tempScript -Encoding ASCII Write-Log -Message "Running diskpart script: `n$($Commands -join "`n")" -Level 'DEBUG' $result = Invoke-NativeCommand -FilePath 'diskpart.exe' -ArgumentList @('/s', $tempScript) Write-Log -Message "diskpart output (exit $($result.ExitCode)): `n$($result.Output -join "`n")" -Level 'DEBUG' Remove-Item -Path $tempScript -Force -ErrorAction SilentlyContinue return $result.Output } # ============================================================================ # FUNCTION: Remove-RecoveryPartition # ============================================================================ function Remove-RecoveryPartition { param( [Parameter(Mandatory)][int]$DiskNumber, [Parameter(Mandatory)][int]$PartitionNumber ) Write-Log -Message "Removing Recovery Partition: Disk $DiskNumber / Partition $PartitionNumber" -Level 'WARNING' $commands = @( "select disk $DiskNumber" "select partition $PartitionNumber" "delete partition override" "exit" ) Invoke-DiskpartScript -Commands $commands | Out-Null Start-Sleep -Seconds 2 $stillExists = Get-Partition -DiskNumber $DiskNumber -ErrorAction SilentlyContinue | Where-Object { $_.PartitionNumber -eq $PartitionNumber } if ($stillExists) { Write-Log -Message "Recovery Partition removal FAILED - partition still present" -Level 'ERROR' return $false } Write-Log -Message "Recovery Partition removed successfully" -Level 'SUCCESS' return $true } # ============================================================================ # FUNCTION: New-RecoveryPartition # v2.1 FIX: creates the Recovery partition at the TRUE END of the disk # using an EXPLICIT -Offset computed from disk.Size, instead of letting # New-Partition auto-place it. Auto-placement (no -Offset) puts a new # partition at the START of the first available free extent - which, # right after removing the old Recovery, is the space starting # immediately after C: - NOT the end of the disk. That silently glued # Recovery onto C: and pushed all remaining free space past it, to the # true end of the disk, which is the opposite of this script's design # intent (Recovery at the true end, C: expandable into the gap). # # No longer checks/enforces adjacency to C: at creation time - Recovery # is intentionally allowed to have a gap in front of it here. Adjacency # (if it matters to reagentc) is established afterwards, optionally, by # expanding C: into that gap - see the "Expand C:?" step in MAIN, which # now runs BEFORE Enable-WinRE. # ============================================================================ function New-RecoveryPartition { param( [Parameter(Mandatory)][int]$DiskNumber, [Parameter(Mandatory)][int]$RecoverySizeMB, [Parameter(Mandatory)][string]$PartitionStyle, [int]$EndBufferMB = 1 ) $sizeBytes = [uint64]$RecoverySizeMB * 1MB $bufferBytes = [uint64]$EndBufferMB * 1MB $disk = Get-Disk -Number $DiskNumber $rawOffset = $disk.Size - $bufferBytes - $sizeBytes if ($rawOffset -le 0) { Write-Log -Message "Computed Recovery offset ($rawOffset) is not positive - disk is too small for a $RecoverySizeMB MB Recovery partition plus $EndBufferMB MB buffer." -Level 'ERROR' return $null } $alignedOffset = [uint64]([math]::Floor($rawOffset / 1MB) * 1MB) Write-Log -Message "Creating new Recovery Partition ($RecoverySizeMB MB) on Disk $DiskNumber at explicit end-of-disk offset $alignedOffset (disk size $($disk.Size) bytes, buffer $EndBufferMB MB)" -Level 'INFO' try { $newPartition = New-Partition -DiskNumber $DiskNumber -Size $sizeBytes -Offset $alignedOffset -AssignDriveLetter:$false -ErrorAction Stop } catch { Write-Log -Message "New-Partition at offset $alignedOffset failed: $_" -Level 'ERROR' Write-Log -Message "This usually means the trailing free space no longer matches what the free-space check saw - re-run the script from Step 1 so it re-reads the current disk layout instead of continuing from a stale state." -Level 'ERROR' return $null } # Sanity check: it must actually have landed at (or very near, after # alignment rounding) the true end of the disk. $diskEndSlack = 2MB $partitionEndOffset = $newPartition.Offset + $newPartition.Size if (($disk.Size - $partitionEndOffset) -gt ($bufferBytes + $diskEndSlack)) { Write-Log -Message "New Recovery partition ends at $partitionEndOffset, but disk size is $($disk.Size) bytes - it does not appear to be at the true end of the disk. Removing it and aborting rather than leaving a stray partition behind." -Level 'ERROR' try { Remove-Partition -DiskNumber $DiskNumber -PartitionNumber $newPartition.PartitionNumber -Confirm:$false -ErrorAction Stop Write-Log -Message "Removed the misplaced partition (was Partition $($newPartition.PartitionNumber))." -Level 'INFO' } catch { Write-Log -Message "Could not remove the misplaced partition automatically: $_. Remove Partition $($newPartition.PartitionNumber) on Disk $DiskNumber manually before re-running." -Level 'ERROR' } return $null } try { Format-Volume -Partition $newPartition -FileSystem NTFS -NewFileSystemLabel "Recovery" -Confirm:$false -Force -ErrorAction Stop | Out-Null } catch { Write-Log -Message "Format-Volume failed: $_" -Level 'ERROR' return $null } Write-Log -Message "New Recovery Partition created: Partition $($newPartition.PartitionNumber) at offset $($newPartition.Offset)" -Level 'SUCCESS' $allParts = Get-Partition -DiskNumber $DiskNumber | Sort-Object Offset if ($allParts[-1].PartitionNumber -ne $newPartition.PartitionNumber) { Write-Log -Message "WARNING: newly created Recovery partition is NOT the last partition on the disk. Layout may be unexpected." -Level 'WARNING' } $commands = @("select disk $DiskNumber", "select partition $($newPartition.PartitionNumber)") if ($PartitionStyle -eq 'GPT') { $commands += "set id=$Script:RecoveryGptType" $commands += "gpt attributes=$Script:RecoveryGptAttrs" } else { $commands += "set id=$Script:RecoveryMbrType" } $commands += "exit" Invoke-DiskpartScript -Commands $commands | Out-Null Start-Sleep -Seconds 2 $verifyPartition = Get-Partition -DiskNumber $DiskNumber -PartitionNumber $newPartition.PartitionNumber Write-Log -Message "Recovery Partition type configured (Style: $PartitionStyle)" -Level 'SUCCESS' return $verifyPartition } # ============================================================================ # FUNCTION: Resize-SystemPartition # Expands C: into whatever contiguous free space remains (the gap # between C: and the already-created, true-end-of-disk Recovery # partition). Get-PartitionSupportedSize naturally stops right before # Recovery, so no manual reserve math is needed. # ============================================================================ function Resize-SystemPartition { param([Parameter(Mandatory)][int]$DiskNumber) $before = Get-Partition -DiskNumber $DiskNumber | Where-Object { $_.DriveLetter -eq 'C' } $beforeSizeGB = [math]::Round($before.Size / 1GB, 2) $supported = Get-PartitionSupportedSize -DiskNumber $DiskNumber -PartitionNumber $before.PartitionNumber $targetSize = $supported.SizeMax if ($targetSize -le $before.Size) { Write-Log -Message "C: is already at its maximum possible size - nothing to expand" -Level 'WARNING' return $true } Write-Log -Message "Resizing C: from $([math]::Round($before.Size/1GB,2)) GB to $([math]::Round($targetSize/1GB,2)) GB" -Level 'INFO' try { Resize-Partition -DiskNumber $DiskNumber -PartitionNumber $before.PartitionNumber -Size $targetSize -ErrorAction Stop } catch { Write-Log -Message "Resize-Partition failed: $_" -Level 'ERROR' return $false } Start-Sleep -Seconds 2 $after = Get-Partition -DriveLetter C $afterSizeGB = [math]::Round($after.Size / 1GB, 2) Write-Host "" Write-Host "C: before: $beforeSizeGB GB" Write-Host "C: after : $afterSizeGB GB" Write-Host "Increase : +$([math]::Round($afterSizeGB - $beforeSizeGB, 2)) GB" if ($after.Size -le $before.Size) { Write-Log -Message "C: partition size did not increase as expected" -Level 'ERROR' return $false } Write-Log -Message "C: successfully expanded to $afterSizeGB GB" -Level 'SUCCESS' return $true } # ============================================================================ # FUNCTION: Test-FinalState # ============================================================================ function Test-FinalState { param( [Parameter(Mandatory)][int]$DiskNumber, [Parameter(Mandatory)]$RecoveryPartition ) $results = [ordered]@{} $disk = Get-Disk -Number $DiskNumber $results['Disk online/healthy'] = ($disk.OperationalStatus -eq 'Online' -and $disk.HealthStatus -eq 'Healthy') $c = Get-Partition -DriveLetter C $cVolume = Get-Volume -DriveLetter C $results['C: exists and NTFS'] = ($cVolume.FileSystem -eq 'NTFS') $recovery = Get-Partition -DiskNumber $DiskNumber -PartitionNumber $RecoveryPartition.PartitionNumber $results['Recovery exists'] = [bool]$recovery $results['Recovery has no drive letter'] = (-not $recovery.DriveLetter) $allPartitions = Get-Partition -DiskNumber $DiskNumber | Sort-Object Offset $results['Recovery is last partition'] = ($allPartitions[-1].PartitionNumber -eq $recovery.PartitionNumber) $winre = Get-WinREStatus $results['WinRE enabled'] = ($winre.Status -match 'Enabled') $results['WinRE location matches new Recovery'] = ( $winre.Location -and $winre.Location.DiskNumber -eq $DiskNumber -and $winre.Location.PartitionNumber -eq $recovery.PartitionNumber ) return $results } # ============================================================================ # FUNCTION: Show-FinalSummary # ============================================================================ function Show-FinalSummary { param( [Parameter(Mandatory)][hashtable]$Results, [Parameter(Mandatory)][double]$CSizeBeforeGB, [Parameter(Mandatory)][double]$CSizeAfterGB, [Parameter(Mandatory)][int]$RecoverySizeMB, [Parameter(Mandatory)][bool]$CExpanded, [Parameter(Mandatory)][int]$RemainingFreeMB ) Write-Host "" Write-Host ("=" * 60) -ForegroundColor Cyan Write-Host "FINAL VALIDATION" -ForegroundColor Cyan Write-Host ("=" * 60) -ForegroundColor Cyan $allOk = $true foreach ($key in $Results.Keys) { $status = if ($Results[$key]) { "[OK]" } else { $allOk = $false; "[FAIL]" } $color = if ($Results[$key]) { 'Green' } else { 'Red' } Write-Host ("{0} {1}" -f $status, $key) -ForegroundColor $color } Write-Host "" Write-Host ("=" * 60) -ForegroundColor Cyan Write-Host "RESULT" -ForegroundColor Cyan Write-Host ("=" * 60) -ForegroundColor Cyan Write-Host "C: BEFORE : $CSizeBeforeGB GB" Write-Host "C: AFTER : $CSizeAfterGB GB" if ($CExpanded) { Write-Host "GAIN : +$([math]::Round($CSizeAfterGB - $CSizeBeforeGB, 2)) GB" } else { Write-Host "GAIN : C: was NOT expanded (skipped on request)" Write-Host "REMAINING : ~$RemainingFreeMB MB free between C: and Recovery (unallocated)" } Write-Host "" Write-Host "Recovery Partition:" Write-Host "Size : $RecoverySizeMB MB" Write-Host "Position : Last partition" Write-Host "" if ($allOk) { Write-Host "STATUS: SUCCESS" -ForegroundColor Green Write-Log -Message "FINAL STATUS: SUCCESS" -Level 'SUCCESS' } else { Write-Host "STATUS: FAILED" -ForegroundColor Red Write-Log -Message "FINAL STATUS: FAILED" -Level 'ERROR' } Write-Host "" Write-Host ("=" * 60) -ForegroundColor Yellow Write-Host "POST-ACTION VM CHECK" -ForegroundColor Yellow Write-Host ("=" * 60) -ForegroundColor Yellow Write-Host @" If you created a VMware snapshot or Hyper-V checkpoint before this operation, do NOT keep it longer than necessary. Verify: - Windows boots normally - C: has the expected size - WinRE is Enabled - Recovery partition is present - Backup is healthy Only after validation should the temporary snapshot be removed according to your VMware/backup procedure. "@ Write-Host ("=" * 60) -ForegroundColor Yellow } # ============================================================================ # MAIN EXECUTION # ============================================================================ try { if (-not (Test-Path -Path $LogPath)) { New-Item -Path $LogPath -ItemType Directory -Force | Out-Null } $timestampForFile = Get-Date -Format 'yyyyMMdd-HHmmss' $Script:LogFile = Join-Path -Path $LogPath -ChildPath "WinRE-Resize-$timestampForFile-$env:COMPUTERNAME.log" $Script:TranscriptFile = Join-Path -Path $LogPath -ChildPath "WinRE-Resize-$timestampForFile-$env:COMPUTERNAME.transcript.log" try { Start-Transcript -Path $Script:TranscriptFile -Force | Out-Null } catch { Write-Host "WARNING: could not start transcript ($_)" -ForegroundColor Yellow } Write-Banner Write-Log -Message "=== WinRE-Resize2.ps1 v$Script:ToolVersion started ===" -Level 'INFO' Write-Log -Message "Parameters: AuditOnly=$AuditOnly RecoverySizeMB=$RecoverySizeMB DiskEndBufferMB=$DiskEndBufferMB LogPath=$LogPath" -Level 'INFO' # ------------------------------------------------------------------ # PHASE 1 - DISCOVERY # ------------------------------------------------------------------ Write-Step -Description "Checking administrator privileges" -Status 'START' if (-not (Test-IsAdministrator)) { Write-Step -Description "Checking administrator privileges" -Status 'FAIL' Stop-Safely -Reason "Script must be run as Administrator. Please re-launch PowerShell elevated." } Write-Step -Description "Checking administrator privileges" -Status 'OK' foreach ($cmd in @('Get-Disk','Get-Partition','Get-Volume','Resize-Partition','New-Partition','Format-Volume')) { if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) { Stop-Safely -Reason "Required cmdlet '$cmd' is not available on this system." } } foreach ($exe in @('reagentc.exe','diskpart.exe')) { if (-not (Get-Command $exe -ErrorAction SilentlyContinue)) { Stop-Safely -Reason "Required executable '$exe' was not found in PATH." } } Write-Step -Description "Detecting system disk" -Status 'START' $sysDiskInfo = Get-SystemDisk $diskNumber = $sysDiskInfo.Disk.Number $cPartition = $sysDiskInfo.CPartition $cSizeBeforeGB = [math]::Round($cPartition.Size / 1GB, 2) Write-Step -Description "Detecting system disk" -Status 'OK' $partitions = Get-DiskLayout -DiskNumber $diskNumber Write-Step -Description "Checking WinRE" -Status 'START' $winREStatus = Get-WinREStatus Write-Host "" Write-Host "WinRE Status : $($winREStatus.Status)" if ($winREStatus.Location) { Write-Host "WinRE Location : Disk $($winREStatus.Location.DiskNumber) / Partition $($winREStatus.Location.PartitionNumber)" } Write-Step -Description "Checking WinRE" -Status 'OK' Write-Step -Description "Detecting Recovery partition" -Status 'START' $recoveryPartition = Get-RecoveryPartition -Disk $sysDiskInfo.Disk -Partitions $partitions -CPartition $cPartition -WinREStatus $winREStatus $hasExistingRecovery = [bool]$recoveryPartition if ($hasExistingRecovery) { Write-Step -Description "Detecting Recovery partition" -Status 'OK' } else { Write-Step -Description "Detecting Recovery partition" -Status 'SKIP' Write-Host "No existing Recovery partition found - a new one will be created from scratch." -ForegroundColor Yellow } Write-Step -Description "Checking BitLocker status" -Status 'START' $bitlockerInfo = Get-BitLockerInfo -MountPoint 'C:' if ($bitlockerInfo) { Write-Host "" Write-Host "BitLocker on C: : $($bitlockerInfo.VolumeStatus) / Protection $($bitlockerInfo.ProtectionStatus)" if ($bitlockerInfo.ProtectionStatus -eq 'On') { Write-Host "Protection will be suspended automatically around the WinRE/partition steps and resumed afterwards." -ForegroundColor Yellow } Write-Step -Description "Checking BitLocker status" -Status 'OK' } else { Write-Step -Description "Checking BitLocker status" -Status 'SKIP' Write-Log -Message "BitLocker not present/not queryable on C: - nothing to suspend." -Level 'INFO' } Write-Step -Description "Checking Recovery partition sizing" -Status 'START' $winreWimSizeMB = Get-WinREWimSizeMB $recommendedRecoveryMB = Get-RecommendedRecoverySizeMB if ($winreWimSizeMB) { Write-Host "" Write-Host "Current Winre.wim size : ~$winreWimSizeMB MB" Write-Host "Recommended minimum : $recommendedRecoveryMB MB (includes reagentc's working-space buffer + headroom)" } if ($RecoverySizeMB -lt $recommendedRecoveryMB) { Write-Step -Description "Checking Recovery partition sizing" -Status 'FAIL' Write-Host "" Write-Host ("=" * 60) -ForegroundColor Yellow Write-Host "RECOVERY PARTITION SIZE WARNING" -ForegroundColor Yellow Write-Host ("=" * 60) -ForegroundColor Yellow Write-Host @" reagentc /enable does NOT error out when the Recovery partition is too small - it silently falls back to storing WinRE inside C: itself instead, even though the partition otherwise looks perfectly valid (correct type, no drive letter, adjacent to C:). This has been observed directly on this system. Requested Recovery partition size : $RecoverySizeMB MB Recommended minimum for this system: $recommendedRecoveryMB MB "@ Write-Host ("=" * 60) -ForegroundColor Yellow if (-not (Confirm-YesNo -Prompt "Continue anyway with a $RecoverySizeMB MB Recovery partition (real risk of the same silent fallback)?")) { Stop-Safely -Reason "Aborted by user - re-run with -RecoverySizeMB $recommendedRecoveryMB (or more)." } } else { Write-Step -Description "Checking Recovery partition sizing" -Status 'OK' } # ------------------------------------------------------------------ # PHASE 2 - VALIDATION # ------------------------------------------------------------------ Write-Step -Description "Checking free space" -Status 'START' if ($hasExistingRecovery) { if (-not (Test-DiskLayout -Partitions $partitions -CPartition $cPartition -RecoveryPartition $recoveryPartition)) { Write-Step -Description "Checking free space" -Status 'FAIL' Write-Log -Message "WARNING: Recovery partition is not directly behind C:. Automatic procedure cannot safely continue." -Level 'ERROR' Stop-Safely -Reason "Recovery partition is not directly behind C:. Aborting for safety." } } $trailingFreeMB = Get-TrailingFreeSpaceMB -DiskNumber $diskNumber $existingRecoveryMB = if ($hasExistingRecovery) { [math]::Round($recoveryPartition.Size / 1MB, 0) } else { 0 } $availableMB = $trailingFreeMB + $existingRecoveryMB Write-Host "" Write-Host "FREE SPACE CHECK" -ForegroundColor Cyan Write-Host "Existing Recovery size : $existingRecoveryMB MB" Write-Host "Trailing unallocated space: $trailingFreeMB MB" Write-Host "Total available : $availableMB MB" Write-Host "Required for new Recovery : $RecoverySizeMB MB" if ($availableMB -lt $RecoverySizeMB) { Write-Step -Description "Checking free space" -Status 'FAIL' Stop-Safely -Reason "Insufficient space: only $availableMB MB available (existing Recovery + trailing free space), need at least $RecoverySizeMB MB. Free up space manually (e.g. shrink C: further) and re-run." } Write-Step -Description "Checking free space" -Status 'OK' Write-Host "" Write-Host ("=" * 60) -ForegroundColor Cyan Write-Host "CURRENT SYSTEM STATE" -ForegroundColor Cyan Write-Host ("=" * 60) -ForegroundColor Cyan Write-Host "Computer : $env:COMPUTERNAME" Write-Host "System Disk : $diskNumber" Write-Host "Partition Style : $($sysDiskInfo.Disk.PartitionStyle)" Write-Host "C: Size : $cSizeBeforeGB GB" Write-Host "WinRE Status : $($winREStatus.Status)" if ($hasExistingRecovery) { Write-Host "Recovery Part# : $($recoveryPartition.PartitionNumber)" Write-Host "Recovery Size : $existingRecoveryMB MB" } else { Write-Host "Recovery Part# : (none - will be created fresh)" } if ($AuditOnly) { Write-Host "" Write-Host "AUDIT ONLY" -ForegroundColor Yellow Write-Host "" Write-Host "Planned actions (NOT executed):" $n = 1 if ($hasExistingRecovery) { Write-Host " $n. Disable WinRE"; $n++ Write-Host " $n. Delete old Recovery Partition $($recoveryPartition.PartitionNumber) on Disk $diskNumber"; $n++ } Write-Host " $n. Create new Recovery Partition ($RecoverySizeMB MB) at the true end of the disk"; $n++ Write-Host " $n. Ask whether to expand C: into the remaining free space (interactive, optional)"; $n++ Write-Host " $n. Configure Recovery Partition type ($($sysDiskInfo.Disk.PartitionStyle)) and re-enable WinRE" Write-Host "" Write-Host "No changes have been made." Write-Log -Message "Audit-only run completed. No changes made." -Level 'SUCCESS' Stop-Transcript -ErrorAction SilentlyContinue | Out-Null exit 0 } # ------------------------------------------------------------------ # PHASE 3 - USER CONFIRMATION # ------------------------------------------------------------------ Show-PreflightChecklist -DiskNumber $diskNumber -WinREStatusText $winREStatus.Status ` -HasExistingRecovery $hasExistingRecovery -AvailableMB $availableMB -RecoverySizeMB $RecoverySizeMB ` -BitLockerInfo $bitlockerInfo Write-Step -Description "User confirmation" -Status 'START' if ($hasExistingRecovery) { if (-not (Confirm-RecoveryPartition -DiskNumber $diskNumber -RecoveryPartition $recoveryPartition -CPartition $cPartition)) { Write-Step -Description "User confirmation" -Status 'FAIL' Stop-Safely -Reason "User did not confirm the detected Recovery partition." } } if (-not (Confirm-Snapshot)) { Write-Step -Description "User confirmation" -Status 'FAIL' Stop-Safely -Reason "User did not confirm VM snapshot/checkpoint verification." } if (-not (Confirm-Backup)) { Write-Step -Description "User confirmation" -Status 'FAIL' Stop-Safely -Reason "User did not confirm existence of a recent successful backup." } Write-Step -Description "User confirmation" -Status 'OK' # ------------------------------------------------------------------ # PHASE 4 - DESTRUCTIVE OPERATION (only if an old Recovery exists) # ------------------------------------------------------------------ if ($bitlockerInfo -and $bitlockerInfo.ProtectionStatus -eq 'On') { Write-Step -Description "Suspending BitLocker" -Status 'START' if (Suspend-BitLockerProtection -MountPoint 'C:') { $Script:BitLockerSuspended = $true Write-Step -Description "Suspending BitLocker" -Status 'OK' } else { Write-Step -Description "Suspending BitLocker" -Status 'FAIL' Stop-Safely -Reason "Could not suspend BitLocker protection on C:. Aborting before touching WinRE/partitions to avoid a BitLocker recovery prompt on next boot." } } Write-Step -Description "Disabling WinRE" -Status 'START' if ($winREStatus.Status -match 'Enabled') { if (-not (Disable-WinRE)) { Write-Step -Description "Disabling WinRE" -Status 'FAIL' Stop-Safely -Reason "WinRE could not be disabled. Cannot safely proceed." } Write-Step -Description "Disabling WinRE" -Status 'OK' } else { Write-Step -Description "Disabling WinRE" -Status 'SKIP' Write-Log -Message "WinRE already disabled - nothing to do." -Level 'INFO' } Write-Step -Description "Removing old Recovery partition" -Status 'START' if ($hasExistingRecovery) { $finalCheckPartitions = Get-Partition -DiskNumber $diskNumber $finalRecovery = $finalCheckPartitions | Where-Object { $_.PartitionNumber -eq $recoveryPartition.PartitionNumber } $finalC = $finalCheckPartitions | Where-Object { $_.DriveLetter -eq 'C' } if (-not $finalRecovery -or -not $finalC) { Write-Step -Description "Removing old Recovery partition" -Status 'FAIL' Stop-Safely -Reason "Partition table changed unexpectedly before destructive step. Aborting." } Write-Host "" Write-Host ("=" * 60) -ForegroundColor Red Write-Host "FINAL DESTRUCTIVE CHECK" -ForegroundColor Red Write-Host ("=" * 60) -ForegroundColor Red Write-Host "System Disk : Disk $diskNumber ($($sysDiskInfo.Disk.FriendlyName))" Write-Host "C: : Disk $diskNumber / Partition $($finalC.PartitionNumber)" Write-Host "Recovery to DELETE: Disk $diskNumber / Partition $($finalRecovery.PartitionNumber)" Write-Host "Size : $([math]::Round($finalRecovery.Size/1MB,0)) MB" Write-Host "" Write-Host "!!! DESTRUCTIVE OPERATION !!!" -ForegroundColor Red Write-Host "This operation is irreversible without backup. The script will NOT delete C:." Write-Host "" $typed = Read-Host "Type exactly DELETE to continue" if ($typed -cne 'DELETE') { Write-Step -Description "Removing old Recovery partition" -Status 'FAIL' Stop-Safely -Reason "User did not type DELETE exactly. Destructive operation aborted." } if (-not (Remove-RecoveryPartition -DiskNumber $diskNumber -PartitionNumber $finalRecovery.PartitionNumber)) { Write-Step -Description "Removing old Recovery partition" -Status 'FAIL' Stop-Safely -Reason "Recovery partition removal failed or could not be verified." } Write-Step -Description "Removing old Recovery partition" -Status 'OK' } else { Write-Step -Description "Removing old Recovery partition" -Status 'SKIP' } # ------------------------------------------------------------------ # PHASE 5 - REBUILD (Recovery at true end FIRST, then optional C: # expansion, THEN enable WinRE) # ------------------------------------------------------------------ $trailingFreeMB = Get-TrailingFreeSpaceMB -DiskNumber $diskNumber if ($trailingFreeMB -lt ($RecoverySizeMB + $DiskEndBufferMB)) { Stop-Safely -Reason "After removing the old Recovery partition, only $trailingFreeMB MB is free at the end of the disk - not enough for a $RecoverySizeMB MB Recovery partition plus the $DiskEndBufferMB MB safety buffer." } # Informational only (v2.1): with Recovery now placed at the true end # of the disk via explicit offset, a partition sitting directly after # C: no longer blocks Recovery creation - it just means there will be # no gap for C: to expand into later. We warn instead of aborting. $cPartitionNow = Get-Partition -DriveLetter C $cEndOffsetNow = $cPartitionNow.Offset + $cPartitionNow.Size $alignmentSlackNow = 1MB $adjacentPartition = Get-Partition -DiskNumber $diskNumber | Where-Object { $_.PartitionNumber -ne $cPartitionNow.PartitionNumber -and $_.Offset -ge $cEndOffsetNow -and ($_.Offset - $cEndOffsetNow) -lt $alignmentSlackNow } if ($adjacentPartition) { Write-Log -Message "NOTE: Partition $($adjacentPartition.PartitionNumber) ($([math]::Round($adjacentPartition.Size/1MB,0)) MB) already sits directly after C:. This will limit or eliminate the space available to later expand C: into." -Level 'WARNING' } Write-Step -Description "Creating Recovery partition" -Status 'START' $newRecovery = New-RecoveryPartition -DiskNumber $diskNumber -RecoverySizeMB $RecoverySizeMB -PartitionStyle $sysDiskInfo.Disk.PartitionStyle -EndBufferMB $DiskEndBufferMB if (-not $newRecovery) { Write-Step -Description "Creating Recovery partition" -Status 'FAIL' Stop-Safely -Reason "Failed to create the new Recovery partition. WinRE remains disabled - manual intervention required." } Write-Step -Description "Creating Recovery partition" -Status 'OK' # --- Interactive: expand C: into whatever remains, BEFORE enabling WinRE --- $cNow = Get-Partition -DriveLetter C $recoveryNow = Get-Partition -DiskNumber $diskNumber -PartitionNumber $newRecovery.PartitionNumber $gapBytes = $recoveryNow.Offset - ($cNow.Offset + $cNow.Size) $gapMB = [math]::Max(0, [math]::Round($gapBytes / 1MB, 0)) Write-Host "" Write-Host ("=" * 60) -ForegroundColor Cyan Write-Host "EXPAND C: ?" -ForegroundColor Cyan Write-Host ("=" * 60) -ForegroundColor Cyan Write-Host "Free space currently sitting between C: and the new Recovery partition: ~$gapMB MB" $cExpanded = $false if ($gapMB -lt 1) { Write-Host "No meaningful free space to expand into - skipping." -ForegroundColor Yellow } elseif (Confirm-YesNo -Prompt "Do you want to expand C: to fill this space now?") { Write-Step -Description "Expanding C:" -Status 'START' if (Resize-SystemPartition -DiskNumber $diskNumber) { $cExpanded = $true Write-Step -Description "Expanding C:" -Status 'OK' } else { Write-Step -Description "Expanding C:" -Status 'FAIL' Write-Log -Message "C: expansion failed - Recovery partition is unaffected. You can retry expanding C: manually via Disk Management. Proceeding to enable WinRE with the gap still in place." -Level 'WARNING' } } else { Write-Log -Message "User chose not to expand C: now. Remaining free space: ~$gapMB MB. If reagentc requires Recovery to be strictly adjacent to C:, leaving this gap in place could cause WinRE enable to fail or silently fall back to storing WinRE on C: - watch the next step's result." -Level 'WARNING' } # Step: Configure / enable WinRE (runs AFTER the expand-C: decision, # so that if the user chose to expand, C: and Recovery are adjacent # by the time reagentc runs). Write-Step -Description "Configuring WinRE" -Status 'START' $winREEnabled = Enable-WinRE $bitlockerDecryptedByUs = $false $bitlockerProtectorTypesBeforeDecrypt = @() if (-not $winREEnabled) { $currentBitlockerInfo = Get-BitLockerInfo -MountPoint 'C:' if ($currentBitlockerInfo -and $currentBitlockerInfo.VolumeStatus -ne 'FullyDecrypted') { Write-Log -Message "reagentc /enable failed while BitLocker encryption is still present on C: - offering the full-decrypt workaround." -Level 'WARNING' $workaround = Invoke-BitLockerDecryptWorkaround -MountPoint 'C:' if ($workaround.Success) { $bitlockerDecryptedByUs = $true $bitlockerProtectorTypesBeforeDecrypt = $workaround.ProtectorTypes $winREEnabled = Enable-WinRE } } } if (-not $winREEnabled) { Write-Step -Description "Configuring WinRE" -Status 'FAIL' if ($bitlockerDecryptedByUs) { Write-Log -Message "WinRE still could not be enabled even after fully decrypting C: - re-encrypting before aborting." -Level 'ERROR' Invoke-BitLockerReencrypt -MountPoint 'C:' -OriginalProtectorTypes $bitlockerProtectorTypesBeforeDecrypt | Out-Null } Stop-Safely -Reason "Failed to enable WinRE after creating the new Recovery partition. Manual intervention required." } Write-Step -Description "Configuring WinRE" -Status 'OK' if ($bitlockerDecryptedByUs) { Invoke-BitLockerReencrypt -MountPoint 'C:' -OriginalProtectorTypes $bitlockerProtectorTypesBeforeDecrypt | Out-Null } if ($Script:BitLockerSuspended) { Write-Step -Description "Resuming BitLocker" -Status 'START' Resume-BitLockerProtection -MountPoint 'C:' $Script:BitLockerSuspended = $false Write-Step -Description "Resuming BitLocker" -Status 'OK' } # ------------------------------------------------------------------ # PHASE 6 - FINAL VALIDATION # ------------------------------------------------------------------ Write-Step -Description "Final validation" -Status 'START' $results = Test-FinalState -DiskNumber $diskNumber -RecoveryPartition $newRecovery $cSizeAfterGB = [math]::Round((Get-Partition -DriveLetter C).Size / 1GB, 2) $cFinal = Get-Partition -DriveLetter C $recoveryFinal = Get-Partition -DiskNumber $diskNumber -PartitionNumber $newRecovery.PartitionNumber $remainingGapMB = [math]::Max(0, [math]::Round(($recoveryFinal.Offset - ($cFinal.Offset + $cFinal.Size)) / 1MB, 0)) $stepStatus = if ($results.Values -notcontains $false) { 'OK' } else { 'FAIL' } Write-Step -Description "Final validation" -Status $stepStatus if ($results['WinRE enabled'] -and -not $results['WinRE location matches new Recovery']) { Write-Host "" Write-Host ("=" * 60) -ForegroundColor Red Write-Host "WINRE IS ENABLED BUT NOT USING THE RECOVERY PARTITION" -ForegroundColor Red Write-Host ("=" * 60) -ForegroundColor Red Write-Host @" reagentc reported success, but WinRE is actually stored inside C: itself rather than on the dedicated Recovery partition. This can happen when reagentc's own partition-size check silently rejects the Recovery partition and falls back to C: without raising an error, OR (v2.1) if adjacency to C: turns out to be required and a gap was left between C: and Recovery. Current Winre.wim size : $(if ($winreWimSizeMB) { "~$winreWimSizeMB MB" } else { "unknown" }) Recovery partition size: $RecoverySizeMB MB Recommended minimum : $recommendedRecoveryMB MB Gap between C: and Recovery at final check: ~$remainingGapMB MB If the partition size looks adequate above, check the ReAgent.log excerpt below (also written to the script log) for the real reason. "@ $reagentLogNow = Get-ReAgentLogTail -Lines 40 if ($reagentLogNow) { Write-Log -Message "Tail of $env:WINDIR\Logs\ReAgent\ReAgent.log at final validation (WinRE enabled but not using the Recovery partition):`n$($reagentLogNow -join "`n")" -Level 'ERROR' Write-Host ($reagentLogNow -join "`n") } Write-Host ("=" * 60) -ForegroundColor Red Write-Host "If a gap remains, re-run and choose to expand C: this time so C: and Recovery are adjacent before WinRE is enabled." -ForegroundColor Yellow } # ------------------------------------------------------------------ # PHASE 7 - FINAL REPORT # ------------------------------------------------------------------ Show-FinalSummary -Results $results -CSizeBeforeGB $cSizeBeforeGB -CSizeAfterGB $cSizeAfterGB -RecoverySizeMB $RecoverySizeMB -CExpanded $cExpanded -RemainingFreeMB $remainingGapMB Write-Log -Message "=== WinRE-Resize2.ps1 finished ===" -Level 'INFO' Write-Host "" Write-Host "Log file: $Script:LogFile" try { Stop-Transcript -ErrorAction SilentlyContinue | Out-Null } catch {} if ($results.Values -contains $false) { exit 1 } else { exit 0 } } catch { if ($Script:BitLockerSuspended) { Write-Log -Message "Unhandled exception while BitLocker was suspended - attempting to resume protection." -Level 'WARNING' Resume-BitLockerProtection -MountPoint 'C:' $Script:BitLockerSuspended = $false } Write-Log -Message "UNHANDLED EXCEPTION: $_" -Level 'ERROR' Write-Log -Message "Stack trace: $($_.ScriptStackTrace)" -Level 'ERROR' Write-Host "" Write-Host "UNSAFE / AMBIGUOUS STATE" -ForegroundColor Red Write-Host "An unexpected error occurred. No further changes will be made." -ForegroundColor Red Write-Host "Error: $_" -ForegroundColor Red if ($Script:LogFile) { Write-Host "Log: $Script:LogFile" -ForegroundColor Red } try { Stop-Transcript -ErrorAction SilentlyContinue | Out-Null } catch {} exit 1 }