From 2e0b572863cc5e9367492687697c6fc7b762fc24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexey=20ALERT=20Rubash=D1=91ff?= Date: Mon, 24 Aug 2026 21:48:12 +0300 Subject: [PATCH] feat: say before anything is created that BitLocker is mandatory here A single setting decides whether a Dev Drive is usable at all: FDVDenyWriteAccess under HKLM\SYSTEM\CurrentControlSet\Policies\Microsoft \FVE. While it is 1, Windows mounts every unencrypted fixed data drive read-only, so declining BitLocker leaves a drive nothing can be written to. Until now that was invisible until the write check stopped the run, with the partition already made. It is read before the BitLocker question and whatever the answer, unlike the four other machine facts, which are read only once BitLocker has been chosen. The question then carries it, the plan repeats it as a consequence of skipping, and the write check, if it still comes to that, names the setting instead of listing suspects. The other half of the report: on such a machine Windows opens its own prompt to encrypt the new drive while this run is already encrypting it. Answering it produces "BitLocker encryption already enabled". The run now says to leave it alone, and says so on an unreadable setting too, since a failed read is not evidence the setting is off. Closes #65 Closes #3 Co-Authored-By: Claude Opus 5 --- README.md | 2 + dev_drive.Tests.ps1 | 253 ++++++++++++++++++++++++++++++++++++++++---- dev_drive.ps1 | 108 +++++++++++++++++-- 3 files changed, 334 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 2fc2f86..94f0d86 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,8 @@ E Projects ReFS Fixed Healthy OK - Automatic unlocking is set only when the operating system drive is BitLocker-protected - The volume is then asked whether automatic unlocking really is on, rather than the call being taken at its word - Where automatic unlocking cannot be set up, the encryption is finished all the same: the run gives the reason in the words Windows used, and says the drive will need unlocking by hand after every restart + - **On a machine that denies write access to unencrypted fixed drives, BitLocker is not optional**, and the run says so before the question rather than after the drive exists. The setting is `FDVDenyWriteAccess` under `HKLM\SYSTEM\CurrentControlSet\Policies\Microsoft\FVE` - the path where it takes effect, whatever policy channel delivered it. Skip BitLocker there and the plan says the drive will mount read-only and the run will stop at the write check + - On such a machine Windows may put up its own prompt to encrypt the new drive while the script is already encrypting it. The run warns to leave that prompt alone: answering it only produces "BitLocker encryption already enabled" - What the machine can actually do is checked before the plan is shown, and listed in it - A BitLocker failure offers a choice: retry, carry on without it, or stop - except a refusal by group policy, which is not offered a retry that would meet the same refusal diff --git a/dev_drive.Tests.ps1 b/dev_drive.Tests.ps1 index 4cb9f75..58bf250 100644 --- a/dev_drive.Tests.ps1 +++ b/dev_drive.Tests.ps1 @@ -339,6 +339,39 @@ Describe 'The script itself' { ([regex]::Matches($content, '\$MaxLength = \d')).Count | Should -Be 0 } + It 'reads the write-access setting before it asks about BitLocker, and whatever the answer' { + # The four other machine facts are read only when BitLocker is chosen. This one decides + # whether declining leaves an unusable drive, so it has to be read either way, and first. + $content = Get-Content -Path $script:ScriptPath -Raw + $readAt = $content.IndexOf('$WritePolicy = Get-FixedDriveWritePolicy') + $askAt = $content.IndexOf('$enableBitLocker = Request-BitLockerChoice') + $gateAt = $content.IndexOf('if ($enableBitLocker) {') + $readAt | Should -BeGreaterThan 0 + $askAt | Should -BeGreaterThan $readAt + $gateAt | Should -BeGreaterThan $readAt + # Read unconditionally: a run that declines BitLocker needs this fact most of all. + ([regex]::Matches($content, '(?m)^\$WritePolicy = Get-FixedDriveWritePolicy')).Count | Should -Be 1 + # And handed to the question, or the person answers without knowing it. + $content | Should -Match '-Notes \(Resolve-WriteAccessPolicyAdvice -Policy \$WritePolicy\)' + } + + It 'keeps the policy registry path in one place rather than beside every use of it' { + $content = Get-Content -Path $script:ScriptPath -Raw + ([regex]::Matches($content, '(?m)^\$FixedDriveWritePolicyPath\s*=')).Count | Should -Be 1 + ([regex]::Matches($content, 'CurrentControlSet\\Policies\\Microsoft\\FVE')).Count | Should -Be 1 + ([regex]::Matches($content, '\$PolicyPath = \$script:FixedDriveWritePolicyPath')).Count | Should -Be 2 + } + + It 'states the setting in the plan when BitLocker is being skipped' { + # Nothing exists yet at that point, so the run can still be declined over it. + $content = Get-Content -Path $script:ScriptPath -Raw + $skipAt = $content.IndexOf('* Skip BitLocker encryption') + $adviceAt = $content.IndexOf('Resolve-WriteAccessPolicyAdvice -Policy $WritePolicy', $skipAt) + $confirmAt = $content.IndexOf('Are you ready to proceed') + $adviceAt | Should -BeGreaterThan $skipAt + $confirmAt | Should -BeGreaterThan $adviceAt + } + It 'never tells the user to just try again' { Select-String -Path $script:ScriptPath -Pattern 'try again' | Should -BeNullOrEmpty } @@ -1006,6 +1039,138 @@ Describe 'Get-Win32ErrorText' { } } +Describe 'Request-BitLockerChoice' { + BeforeAll { + Mock Write-Host { } + } + + It 'returns for answer ' -TestCases @( + @{ Answer = '1'; Expected = $true } + @{ Answer = '2'; Expected = $false } + ) { + Mock Read-Host { $Answer } + Request-BitLockerChoice | Should -Be $Expected + } + + It 'keeps asking until the answer is one of the two' { + $script:answers = @('yes', '3', '2') + $script:index = 0 + Mock Read-Host { $script:answers[$script:index++] } + Request-BitLockerChoice | Should -BeFalse + $script:index | Should -Be 3 + } + + It 'prints what it was given about this machine before the menu' { + # A machine where encryption is mandatory has to say so before the answer, not after. + Mock Read-Host { '1' } + Request-BitLockerChoice -Notes @('This machine denies write access to unprotected fixed drives.') | Out-Null + Should -Invoke Write-Host -ParameterFilter { $Object -match 'denies write access' } + } + + It 'prints nothing extra when there is nothing to say' { + Mock Read-Host { '1' } + Request-BitLockerChoice | Out-Null + Should -Not -Invoke Write-Host -ParameterFilter { $Object -match 'denies write access' } + } +} + +Describe 'Get-FixedDriveWritePolicy' { + BeforeAll { + $script:TestKeyRoot = 'HKCU:\Software\DevDriveTests' + $script:FakeKey = "$script:TestKeyRoot\FVE" + } + + AfterAll { + # The whole branch, not just the leaf, so the suite leaves the user's hive as it found it. + if (Test-Path -Path $script:TestKeyRoot) { Remove-Item -Path $script:TestKeyRoot -Recurse -Force } + } + + BeforeEach { + if (Test-Path -Path $script:FakeKey) { Remove-Item -Path $script:FakeKey -Recurse -Force } + } + + It 'reads the setting as on when the value is 1' { + New-Item -Path $script:FakeKey -Force | Out-Null + New-ItemProperty -Path $script:FakeKey -Name 'FDVDenyWriteAccess' -Value 1 -PropertyType DWord -Force | Out-Null + Get-FixedDriveWritePolicy -Path $script:FakeKey | Should -Be 'Deny' + } + + It 'reads the setting as off when the value is 0' { + New-Item -Path $script:FakeKey -Force | Out-Null + New-ItemProperty -Path $script:FakeKey -Name 'FDVDenyWriteAccess' -Value 0 -PropertyType DWord -Force | Out-Null + Get-FixedDriveWritePolicy -Path $script:FakeKey | Should -Be 'Allow' + } + + It 'treats a key without the value as the setting not being set' { + # The ordinary case on an unmanaged machine, and it must not read as "could not be read". + New-Item -Path $script:FakeKey -Force | Out-Null + Get-FixedDriveWritePolicy -Path $script:FakeKey | Should -Be 'Allow' + } + + It 'treats a missing key the same way' { + Get-FixedDriveWritePolicy -Path "$script:TestKeyRoot\NoSuchKey" | Should -Be 'Allow' + } + + It 'answers Unknown for a value of a type it cannot read, rather than reading it as off' { + # A string where a number belongs is an answer this cannot understand, not an absent setting. + New-Item -Path $script:FakeKey -Force | Out-Null + New-ItemProperty -Path $script:FakeKey -Name 'FDVDenyWriteAccess' -Value 'yes' -PropertyType String -Force | Out-Null + Get-FixedDriveWritePolicy -Path $script:FakeKey | Should -Be 'Unknown' + } + + It 'reads a number that is neither 0 nor 1 as the setting not denying writes' { + New-Item -Path $script:FakeKey -Force | Out-Null + New-ItemProperty -Path $script:FakeKey -Name 'FDVDenyWriteAccess' -Value 2 -PropertyType DWord -Force | Out-Null + Get-FixedDriveWritePolicy -Path $script:FakeKey | Should -Be 'Allow' + } + + It 'answers Unknown when the read itself fails' { + Mock Get-ItemProperty { throw 'Requested registry access is not allowed.' } + New-Item -Path $script:FakeKey -Force | Out-Null + Get-FixedDriveWritePolicy -Path $script:FakeKey | Should -Be 'Unknown' + } + + It 'answers Unknown when even the existence check fails, not "not set"' { + # A key that is there but unreadable must never be reported as one that is not there. + Mock Test-Path { throw 'Requested registry access is not allowed.' } + Get-FixedDriveWritePolicy -Path $script:FakeKey | Should -Be 'Unknown' + } +} + +Describe 'Resolve-WriteAccessPolicyAdvice' { + It 'says nothing at all on a machine that allows the writes' { + @(Resolve-WriteAccessPolicyAdvice -Policy 'Allow' -PolicyPath 'HKLM:\X').Count | Should -Be 0 + @(Resolve-WriteAccessPolicyAdvice -Policy 'Allow' -PolicyPath 'HKLM:\X' -Skipping).Count | Should -Be 0 + } + + It 'states the consequence as a fact once the answer to skip is known' { + $lines = (Resolve-WriteAccessPolicyAdvice -Policy 'Deny' -PolicyPath 'HKLM:\X' -Skipping) -join "`n" + $lines | Should -Match 'FDVDenyWriteAccess is 1' + $lines | Should -Match 'this Dev Drive will mount read-only' + $lines | Should -Match 'stop at the write check' + } + + It 'states it as a condition while the answer is still open' { + # Printed before the menu, where nobody has decided to skip anything yet. + $lines = (Resolve-WriteAccessPolicyAdvice -Policy 'Deny' -PolicyPath 'HKLM:\X') -join "`n" + $lines | Should -Match 'would mount read-only' + $lines | Should -Not -Match 'stop at the write check' + $lines | Should -Not -Match 'will mount read-only' + } + + It 'raises an unreadable setting either way, and says where to look' { + foreach ($lines in (Resolve-WriteAccessPolicyAdvice -Policy 'Unknown' -PolicyPath 'HKLM:\Somewhere') -join "`n", + (Resolve-WriteAccessPolicyAdvice -Policy 'Unknown' -PolicyPath 'HKLM:\Somewhere' -Skipping) -join "`n") { + $lines | Should -Match 'could not be read' + $lines | Should -Match "Get-ItemProperty 'HKLM:\\Somewhere' -Name FDVDenyWriteAccess" + } + } + + It 'refuses a policy that is not one of the three' { + { Resolve-WriteAccessPolicyAdvice -Policy 'Maybe' -PolicyPath 'HKLM:\X' } | Should -Throw + } +} + Describe 'Resolve-BitLockerSetupPlan' { It 'asks for a password only in virtual hard disk mode' -TestCases @( @{ Vhdx = $true; Expected = $true } @@ -1014,6 +1179,26 @@ Describe 'Resolve-BitLockerSetupPlan' { (Resolve-BitLockerSetupPlan -VhdxMode:$Vhdx).UsePasswordProtector | Should -Be $Expected } + It 'warns about the read-only spell and Windows'' own prompt where the setting is on' { + # The reporter answered that prompt, got "BitLocker encryption already enabled", and started over. + $notes = (Resolve-BitLockerSetupPlan -WritePolicy 'Deny').Notes -join "`n" + $notes | Should -Match 'read-only' + $notes | Should -Match 'Windows may put up its own prompt' + $notes | Should -Match 'Leave it alone' + } + + It 'says neither where the setting is off' { + $notes = (Resolve-BitLockerSetupPlan).Notes -join "`n" + $notes | Should -Not -Match 'read-only' + $notes | Should -Not -Match 'own prompt' + } + + It 'puts that warning first, before the protector notes' { + # It is about what happens during the run; the rest is about what the drive ends up with. + $notes = @((Resolve-BitLockerSetupPlan -WritePolicy 'Deny').Notes) + $notes[0] | Should -Match 'read-only' + } + It 'adds the domain account protector only on a domain-joined machine' -TestCases @( @{ Joined = $true; Expected = $true } @{ Joined = $false; Expected = $false } @@ -2679,64 +2864,94 @@ Describe 'Request-DedupSchedule' { Describe 'Resolve-WriteProtectionAdvice' { It 'names the drive and says nothing can be written to it' { - (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear') -join "`n" | + (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear' -PolicyPath 'HKLM:\FVE') -join "`n" | Should -Match 'Drive X:.*nothing can be written' } - It 'blames the BitLocker write-access setting only when the drive is unencrypted' { - $lines = (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear') -join "`n" - $lines | Should -Match 'deny write access to fixed drives' - $lines | Should -Match 'PolicyManager' - $lines | Should -Match 'Microsoft.FVE' + It 'names the setting as the likely cause once the run has actually read it' { + # The run reads FDVDenyWriteAccess before the plan now, so this no longer lists suspects. + $lines = (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear' -WritePolicy 'Deny' -PolicyPath 'HKLM:\FVE') -join "`n" + $lines | Should -Match 'almost certainly the cause' + $lines | Should -Not -Match 'may be set to deny' } - It 'keeps that blame conditional, since the setting is never read' { - $lines = (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear') -join "`n" + It 'tells someone how to encrypt the volume that exists, not to create it again' { + # The Dev Drive is already there by this point, so a rerun would repeat the creation. + $lines = (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear' -WritePolicy 'Deny' -PolicyPath 'HKLM:\FVE') -join "`n" + $lines | Should -Match 'Enable-BitLocker -MountPoint X:' + $lines | Should -Not -Match 'Run the script again' + } + + It 'rules the setting out no harder than it read it' { + # One key was read. That is enough to stop blaming the setting, not enough to acquit it. + $lines = (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear' -WritePolicy 'Allow' -PolicyPath 'HKLM:\FVE') -join "`n" + $lines | Should -Match 'does not report that setting as on' + $lines | Should -Match 'does not look like the cause' + $lines | Should -Not -Match 'almost certainly' + } + + It 'keeps the blame conditional only where the setting could not be read' { + $lines = (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear' -WritePolicy 'Unknown' -PolicyPath 'HKLM:\FVE') -join "`n" $lines | Should -Match 'may be set to deny' $lines | Should -Match 'If that setting is on' - $lines | Should -Not -Match 'is what makes it writable' + $lines | Should -Not -Match 'almost certainly' } - It 'rules that setting out when the drive is encrypted' { - $lines = (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Encrypted') -join "`n" - $lines | Should -Match 'does not explain this' + It 'sends an unread setting to the path it was given, and never to PolicyManager' { + # PolicyManager was empty on the machine that reported this; the effective path had the value. + $lines = (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear' -WritePolicy 'Unknown' -PolicyPath 'HKLM:\Somewhere') -join "`n" + $lines | Should -Match "Get-ItemProperty 'HKLM:\\Somewhere' -Name FDVDenyWriteAccess" $lines | Should -Not -Match 'PolicyManager' } + It 'assumes the setting was not read when none is passed' { + (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear' -PolicyPath 'HKLM:\FVE') -join "`n" | + Should -Match 'may be set to deny' + } + + It 'rules that setting out when the drive is encrypted, whatever the setting says' { + foreach ($policy in 'Deny', 'Allow', 'Unknown') { + $lines = (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Encrypted' -WritePolicy $policy -PolicyPath 'HKLM:\FVE') -join "`n" + $lines | Should -Match 'does not explain this' + $lines | Should -Not -Match 'That is the cause' + } + } + It 'narrows nothing down when the encryption state could not be read' { - $lines = (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Unknown') -join "`n" + $lines = (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Unknown' -PolicyPath 'HKLM:\FVE') -join "`n" $lines | Should -Match 'could not be read' $lines | Should -Not -Match 'deny write access' } It 'assumes nothing when no state is passed' { - (Resolve-WriteProtectionAdvice -MountPoint 'X:') -join "`n" | Should -Match 'could not be read' + (Resolve-WriteProtectionAdvice -MountPoint 'X:' -PolicyPath 'HKLM:\FVE') -join "`n" | Should -Match 'could not be read' } It 'quotes what Windows said when there is a message, and stays silent when there is not' { - (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear' -Reason 'Media is write-protected') -join "`n" | + (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear' -Reason 'Media is write-protected' -PolicyPath 'HKLM:\FVE') -join "`n" | Should -Match 'Windows said: Media is write-protected' - (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear') -join "`n" | + (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear' -PolicyPath 'HKLM:\FVE') -join "`n" | Should -Not -Match 'Windows said' } It 'offers the partition read-only check whatever BitLocker is doing' { foreach ($state in @('Clear', 'Encrypted', 'Unknown')) { - (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState $state) -join "`n" | + (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState $state -PolicyPath 'HKLM:\FVE') -join "`n" | Should -Match 'Get-Partition -DriveLetter X \|' } } It 'says the drive stays and nothing more can be set up, whatever the state' { foreach ($state in @('Clear', 'Encrypted', 'Unknown')) { - $lines = (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState $state) -join "`n" + $lines = (Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState $state -PolicyPath 'HKLM:\FVE') -join "`n" $lines | Should -Match 'Drive X: stays as it is' $lines | Should -Match 'Nothing more can be set up' } } It 'returns plain lines rather than an object to unwrap' { - Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear' | Should -BeOfType [string] + Resolve-WriteProtectionAdvice -MountPoint 'X:' -VolumeState 'Clear' -PolicyPath 'HKLM:\FVE' | + Should -BeOfType [string] } } diff --git a/dev_drive.ps1 b/dev_drive.ps1 index 1a88a45..858a4c2 100644 --- a/dev_drive.ps1 +++ b/dev_drive.ps1 @@ -293,7 +293,10 @@ function Add-VirtualDiskAttachment { } function Request-BitLockerChoice { - param([switch]$VhdxMode) + param( + [switch]$VhdxMode, + [string[]]$Notes + ) Write-Host "`nDo you want to enable BitLocker encryption for the Dev Drive?" -ForegroundColor Cyan Write-Host "BitLocker provides security but may impact performance." -ForegroundColor White @@ -301,6 +304,10 @@ function Request-BitLockerChoice { Write-Host "If the volume hosting the .vhdx file is itself encrypted, its contents are already" -ForegroundColor Yellow Write-Host "covered, and Microsoft does not recommend encrypting the virtual disk as well." -ForegroundColor Yellow } + # Printed before the menu, so a machine that makes encryption mandatory says so before the answer. + foreach ($note in $Notes) { + Write-Host $note -ForegroundColor Yellow + } Write-Host "1. Yes, enable BitLocker encryption" -ForegroundColor White Write-Host "2. No, skip BitLocker encryption" -ForegroundColor White Write-Host "" @@ -401,17 +408,76 @@ function Get-BitLockerAutoUnlockState { } } +function Get-FixedDriveWritePolicy { + <# Reads FDVDenyWriteAccess where it takes effect, whatever channel delivered it. Absent means + not set; Unknown means the read did not succeed, or answered something unreadable. #> + param([string]$Path = $script:FixedDriveWritePolicyPath) + + try { + if (-not (Test-Path -Path $Path -ErrorAction Stop)) { return 'Allow' } + $key = Get-ItemProperty -Path $Path -ErrorAction Stop + if ($null -eq $key.PSObject.Properties['FDVDenyWriteAccess']) { return 'Allow' } + + # A value of another type is an answer this cannot read, not an answer of "not set". + $value = $key.FDVDenyWriteAccess + if ($value -isnot [int] -and $value -isnot [long]) { return 'Unknown' } + if ($value -eq 1) { return 'Deny' } + return 'Allow' + } + catch { + return 'Unknown' + } +} + +function Resolve-WriteAccessPolicyAdvice { + <# What the setting means for this run: without BitLocker such a machine mounts the Dev Drive + read-only. -Skipping is for the plan, where the answer is already known. #> + param( + [Parameter(Mandatory)][ValidateSet('Deny', 'Allow', 'Unknown')][string]$Policy, + [string]$PolicyPath = $script:FixedDriveWritePolicyPath, + [switch]$Skipping + ) + + if ($Policy -eq 'Allow') { + return @() + } + + if ($Policy -eq 'Unknown') { + $lines = @("This machine's setting on unencrypted fixed drives could not be read, so it is not known whether the Dev Drive will accept writes without BitLocker.") + $lines += "Read it with: Get-ItemProperty '$PolicyPath' -Name FDVDenyWriteAccess -ErrorAction SilentlyContinue" + return $lines + } + + $lines = @("This machine denies write access to fixed drives that BitLocker does not protect (FDVDenyWriteAccess is 1).") + if ($Skipping) { + $lines += "So this Dev Drive will mount read-only, and nothing can be written to it." + $lines += "The run will stop at the write check once the drive exists. Enable BitLocker, or change that setting first." + } else { + $lines += "So a Dev Drive without BitLocker would mount read-only here, with nothing able to be written to it." + } + return $lines +} + function Resolve-BitLockerSetupPlan { <# Decides which protectors this machine can carry, and the lines explaining why. #> param( [switch]$DomainJoined, [switch]$EntraJoined, [switch]$VhdxMode, - [switch]$OsDriveProtected + [switch]$OsDriveProtected, + [ValidateSet('Deny', 'Allow', 'Unknown')][string]$WritePolicy = 'Allow' ) $notes = @() + # Warned on Unknown too: a read that failed is not evidence that the setting is off. + if ($WritePolicy -eq 'Deny') { + $notes += "Until this finishes the drive is read-only, because this machine denies writes to fixed drives BitLocker does not protect." + } + if ($WritePolicy -ne 'Allow') { + $notes += "Windows may put up its own prompt to encrypt the drive while this runs. Leave it alone - this run is already encrypting, and answering it only produces an error." + } + if ($VhdxMode) { $notes += "The Dev Drive lives in a virtual hard disk, so a BitLocker password will be asked for: it unlocks the volume after the file is mounted." } else { @@ -1896,7 +1962,9 @@ function Resolve-WriteProtectionAdvice { param( [Parameter(Mandatory)][string]$MountPoint, [ValidateSet('Encrypted', 'Clear', 'Unknown')][string]$VolumeState = 'Unknown', - [AllowNull()][AllowEmptyString()][string]$Reason + [AllowNull()][AllowEmptyString()][string]$Reason, + [ValidateSet('Deny', 'Allow', 'Unknown')][string]$WritePolicy = 'Unknown', + [string]$PolicyPath = $script:FixedDriveWritePolicyPath ) # Offered in every branch: a partition carrying the read-only flag refuses writes whatever @@ -1909,10 +1977,17 @@ function Resolve-WriteProtectionAdvice { } if ($VolumeState -eq 'Clear') { - $lines += "The drive is not encrypted, and this machine may be set to deny write access to fixed drives that BitLocker does not protect." - $lines += "If that setting is on, Windows mounts every unencrypted fixed data drive read-only, and finishing BitLocker on $MountPoint would make it writable." - $lines += "Read it on a machine managed from the cloud with: Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\PolicyManager\current\device\BitLocker' -ErrorAction SilentlyContinue" - $lines += "Read it on a machine managed by group policy with: Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\FVE' -ErrorAction SilentlyContinue" + # The setting was read before the plan, so this names a likely cause instead of listing suspects. + if ($WritePolicy -eq 'Deny') { + $lines += "This machine denies write access to fixed drives that BitLocker does not protect, and $MountPoint is not encrypted. That is almost certainly the cause." + $lines += "Encrypt this volume to make it writable, without creating it again: Enable-BitLocker -MountPoint $MountPoint -RecoveryPasswordProtector -UsedSpaceOnly" + } elseif ($WritePolicy -eq 'Allow') { + $lines += "The drive is not encrypted, but this machine does not report that setting as on, so it does not look like the cause." + } else { + $lines += "The drive is not encrypted, and this machine may be set to deny write access to fixed drives that BitLocker does not protect." + $lines += "If that setting is on, Windows mounts every unencrypted fixed data drive read-only, and encrypting $MountPoint would make it writable." + $lines += "Read it with: Get-ItemProperty '$PolicyPath' -Name FDVDenyWriteAccess -ErrorAction SilentlyContinue" + } $lines += $partitionCheck } elseif ($VolumeState -eq 'Encrypted') { $lines += "The drive is encrypted, so the setting that mounts unencrypted drives read-only does not explain this." @@ -1947,6 +2022,10 @@ if ($windows_build -ge $windows_build_min) { # Microsoft's documented minimum size for a Dev Drive volume (https://learn.microsoft.com/en-us/windows/dev-drive/) $DevDriveMinSizeGB = 50 +# Where "deny write access to fixed drives not protected by BitLocker" takes effect. PolicyManager +# was empty on the machine that reported this, so the effective path is the one to read. +$FixedDriveWritePolicyPath = 'HKLM:\SYSTEM\CurrentControlSet\Policies\Microsoft\FVE' + # The name offered on the Enter key. $DevDriveDefaultLabel = "DevDrive" @@ -2147,8 +2226,12 @@ if ($mode -eq "FreeSpace") { # Asked here rather than in each mode: the name is the same question whatever created the volume. $DevDriveLabel = Request-DevDriveLabel -Default $DevDriveDefaultLabel -MaxLength $DevDriveLabelMaxLength +# Read before the question, not after: on a machine with this setting the answer is not a preference. +$WritePolicy = Get-FixedDriveWritePolicy + # Ask about BitLocker encryption -$enableBitLocker = Request-BitLockerChoice -VhdxMode:($mode -eq "Vhdx") +$enableBitLocker = Request-BitLockerChoice -VhdxMode:($mode -eq "Vhdx") ` + -Notes (Resolve-WriteAccessPolicyAdvice -Policy $WritePolicy) $SkipBitLocker = -not $enableBitLocker # Settled before the plan is shown; a fact that cannot be read counts as the safe answer. @@ -2182,7 +2265,7 @@ if ($enableBitLocker) { } $bitLockerPlan = Resolve-BitLockerSetupPlan -DomainJoined:$isDomainJoined -EntraJoined:$isEntraJoined ` - -VhdxMode:($mode -eq "Vhdx") -OsDriveProtected:$osDriveProtected + -VhdxMode:($mode -eq "Vhdx") -OsDriveProtected:$osDriveProtected -WritePolicy $WritePolicy } # Ask about deduplication @@ -2247,6 +2330,10 @@ if (-not $SkipBitLocker) { } } else { Write-Host "* Skip BitLocker encryption" -ForegroundColor White + # Said here, before anything exists, rather than left for the write check to discover afterwards. + foreach ($line in (Resolve-WriteAccessPolicyAdvice -Policy $WritePolicy -Skipping)) { + Write-Host " - $line" -ForegroundColor Yellow + } } if (-not $SkipDeduplication) { @@ -2600,7 +2687,8 @@ try { } else { $encryptionState = (Get-BitLockerProtectionState -MountPoint $devLetterColon).Label Write-Host "" - foreach ($line in (Resolve-WriteProtectionAdvice -MountPoint $devLetterColon -VolumeState $encryptionState -Reason $writeState.Reason)) { + foreach ($line in (Resolve-WriteProtectionAdvice -MountPoint $devLetterColon -VolumeState $encryptionState ` + -Reason $writeState.Reason -WritePolicy $WritePolicy)) { Write-Host $line -ForegroundColor Red } # Thrown rather than exited: the closing advice about a shrunk drive and a left-behind .vhdx lives in the catch.