diff --git a/Tests/Access.Tests.ps1 b/Tests/Access.Tests.ps1 index 6972150..c351038 100644 --- a/Tests/Access.Tests.ps1 +++ b/Tests/Access.Tests.ps1 @@ -155,6 +155,20 @@ Describe 'Get-NTFSEffectiveAccess' { "because the computer 'ntfssecurity-test.invalid' can't be reached for a remote access check. " + 'For more accurate results, calculate effective access rights on that computer.') } + + # An empty name names no computer, so it names this one no more than any other name that can't be reached. + It 'Should return the result of this computer and warn for an empty -ServerName' { + $expected = Get-NTFSEffectiveAccess -Path $effectiveFile -WarningAction SilentlyContinue -ErrorAction Stop + + $result = @(Get-NTFSEffectiveAccess -Path $effectiveFile -ServerName '' -WarningVariable accessWarnings -WarningAction SilentlyContinue -ErrorVariable accessErrors -ErrorAction SilentlyContinue) + + $accessErrors | Should -BeNullOrEmpty + $result | Should -HaveCount 1 + $result[0].AccessRights | Should -Be $expected.AccessRights + $accessWarnings.Message | Should -Contain ("The effective rights can only be computed based on group membership on this computer, " + + "because the computer '' can't be reached for a remote access check. " + + 'For more accurate results, calculate effective access rights on that computer.') + } } # Not every computer offers the remote interface of the authorization manager; the cmdlet then calculates the result @@ -604,6 +618,24 @@ Describe 'Remove-NTFSAccess' { $removeErrors | Should -BeNullOrEmpty Get-GuestsRule -Path $folder | Should -BeNullOrEmpty } + + # The entry of another account with exactly the rights to remove is not an exact match for the entry of the + # account, so the rights that the entry of the account keeps still have their Synchronize. + It 'Should take only the requested generic right from the entry of the account when another account has an exact entry' { + $users = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-545' + $folder = New-GenericRightFolder -Entry '(A;OICIIO;0x10100000;;;BU)(A;OICIIO;0x90100000;;;BG)' + + Remove-NTFSAccess -Path $folder -Account 'S-1-5-32-546' -AccessRights GenericAll -InheritanceFlags ContainerInherit, ObjectInherit -PropagationFlags InheritOnly -ErrorVariable removeErrors -ErrorAction SilentlyContinue + + $removeErrors | Should -BeNullOrEmpty + $guestsRule = @(Get-GuestsRule -Path $folder) + $guestsRule | Should -HaveCount 1 + [int] $guestsRule[0].FileSystemRights | Should -Be 0x80100000 + $usersRule = @((Get-Acl -LiteralPath $folder).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | + Where-Object -Property IdentityReference -EQ -Value $users) + $usersRule | Should -HaveCount 1 + [int] $usersRule[0].FileSystemRights | Should -Be 0x10100000 + } } Context 'With -RemoveSpecific' { BeforeEach { diff --git a/Tests/DriveRoot.Tests.ps1 b/Tests/DriveRoot.Tests.ps1 index ba5d199..5506794 100644 --- a/Tests/DriveRoot.Tests.ps1 +++ b/Tests/DriveRoot.Tests.ps1 @@ -1,12 +1,18 @@ <# - Tests the cmdlets of the module built in NTFSSecurity\bin\Release on the root folder of the system drive. The tests - only read, so they need no sandbox. + Tests the cmdlets of the module built in NTFSSecurity\bin\Release on the root folder of the system drive, which they + only read, and on the root of a drive that maps a folder of a sandbox, which they change. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.' )] param () +BeforeDiscovery { + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force + # The restricted token of the basic-user runner cannot define a drive letter. + $canMapDrive = Test-DriveMappingAvailable +} + BeforeAll { $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\NTFSSecurity\bin\Release\NTFSSecurity.psd1' Import-Module -Name $modulePath -Force -ErrorAction Stop @@ -51,3 +57,63 @@ Describe 'The root folder of a drive' { @($entries | ForEach-Object -Process { $_.Account.Sid } | Sort-Object) | Should -Be $expected } } + +# A test must not change the permissions of a volume. A drive letter that subst maps to a folder of a sandbox is the root +# of a drive for Windows and for the module, so the code that changes the root folder of a drive changes that folder. +Describe 'Changing the root folder of a drive' -Skip:(-not $canMapDrive) { + BeforeAll { + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force + $sandbox = New-TestSandbox -Name 'DriveRootChange' + $mapped = New-TestSandboxItem -Sandbox $sandbox -Name 'Mapped' -Directory + $driveRoot = New-TestDriveMapping -Sandbox $sandbox -Path $mapped + if (-not $driveRoot) { + throw 'No drive letter could be mapped to the sandbox folder.' + } + + function Get-MappedEntry { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseSingularNouns', '', Justification = 'The helper returns the explicit entries of the folder.' + )] + param ([string] $Account) + + @((Get-Acl -LiteralPath $mapped).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq $Account }) + } + } + + AfterAll { + if ($driveRoot) { + Remove-TestDriveMapping -Root $driveRoot + } + Remove-TestSandbox -Sandbox $sandbox + } + + It 'Should read the access entries of the folder that the drive maps' { + $expected = @((Get-Acl -LiteralPath $mapped).GetAccessRules($true, $true, $sidType) | + ForEach-Object -Process { $_.IdentityReference.Value } | Sort-Object) + + $entries = @(Get-NTFSAccess -Path $driveRoot) + + @($entries | ForEach-Object -Process { $_.Account.Sid } | Sort-Object) | Should -Be $expected + } + + It 'Should add and remove an access entry of the folder that the drive maps' { + Add-NTFSAccess -Path $driveRoot -Account 'S-1-1-0' -AccessRights ReadData -ErrorAction Stop + + Get-MappedEntry -Account 'S-1-1-0' | Should -HaveCount 1 + + Remove-NTFSAccess -Path $driveRoot -Account 'S-1-1-0' -AccessRights ReadData -ErrorAction Stop + + Get-MappedEntry -Account 'S-1-1-0' | Should -BeNullOrEmpty + } + + It 'Should block and restore the access inheritance of the folder that the drive maps' { + Disable-NTFSAccessInheritance -Path $driveRoot -ErrorAction Stop + + (Get-Acl -LiteralPath $mapped).AreAccessRulesProtected | Should -BeTrue + + Enable-NTFSAccessInheritance -Path $driveRoot -ErrorAction Stop + + (Get-Acl -LiteralPath $mapped).AreAccessRulesProtected | Should -BeFalse + } +} diff --git a/Tests/ItemCmdlets.Tests.ps1 b/Tests/ItemCmdlets.Tests.ps1 index e0b65dd..8458488 100644 --- a/Tests/ItemCmdlets.Tests.ps1 +++ b/Tests/ItemCmdlets.Tests.ps1 @@ -140,6 +140,26 @@ Describe 'Get-ChildItem2' { ($relative | Sort-Object) -join ',' | Should -Be (($Expected | Sort-Object) -join ',') } + # The pattern must match the name of the item. When Windows lists a folder with a pattern, it also compares the + # short name (8.3) of an item, so *.htm finds Page2.html as well, as Get-ChildItem does where the volume creates + # short names. The cmdlet compares the name again. + It 'Should return only the items whose name matches -Filter ' -ForEach @( + @{ Filter = '*.htm'; Expected = @('Page.htm') } + @{ Filter = 'Page?.html'; Expected = @('Page2.html') } + @{ Filter = 'PAGE*'; Expected = @('Page.htm', 'Page2.html') } + ) { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'FilterNames' -Directory + foreach ($name in 'Page.htm', 'Page2.html') { + $file = Join-Path -Path $folder -ChildPath $name + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + Set-Content -LiteralPath $file -Value $name + } + + $result = @(Get-ChildItem2 -Path $folder -Filter $Filter -ErrorAction Stop) + + ($result.Name | Sort-Object) -join ',' | Should -Be (($Expected | Sort-Object) -join ',') + } + It 'Should stop a recursive pipeline without recording an enumeration error' { $result = @(Get-ChildItem2 -Path $tree -Recurse -ErrorVariable childErrors -ErrorAction SilentlyContinue | Select-Object -First 1) diff --git a/Tests/ObjectApis.Tests.ps1 b/Tests/ObjectApis.Tests.ps1 index 8ad1c53..4bfa014 100644 --- a/Tests/ObjectApis.Tests.ps1 +++ b/Tests/ObjectApis.Tests.ps1 @@ -1,5 +1,5 @@ <# - Tests the public object APIs used with cmdlet output, without changing an item's security descriptor. + Tests the public object APIs used with cmdlet output, on files and folders in a sandbox folder. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.' @@ -376,8 +376,9 @@ Describe 'Access rule helpers that take a path' { $entries[0].FileSystemRights | Should -Be ([System.Security.AccessControl.FileSystemRights] 'ReadData, Synchronize') } - # The overload for several accounts is an iterator, so it writes nothing until the caller enumerates the result. - It 'Should add the entries of several accounts to a by its path only when the result is enumerated' -ForEach @( + # The overload that takes a path returns an iterator, so the caller must enumerate the result to write the entries. + # The overloads that take an item write them at once; this test doesn't pin the difference. + It 'Should add the entries of several accounts to a by its path when the result is enumerated' -ForEach @( @{ Kind = 'file'; Directory = $false } @{ Kind = 'folder'; Directory = $true } ) { @@ -388,8 +389,6 @@ Describe 'Access rule helpers that take a path' { $path, $accounts, [Security2.FileSystemRights2]::ReadData, $allow, $noInheritance, $noPropagation ) - @(Get-ExplicitEntries -Path $path) | Should -BeNullOrEmpty - @(Get-ExplicitEntries -Path $path -Account 'S-1-5-32-545') | Should -BeNullOrEmpty @($pending) | Should -HaveCount 2 @(Get-ExplicitEntries -Path $path) | Should -HaveCount 1 @(Get-ExplicitEntries -Path $path -Account 'S-1-5-32-545') | Should -HaveCount 1 @@ -565,9 +564,13 @@ Describe 'Audit rule helpers that take a path' -Skip:(-not $holdsSecurityPrivile $entries | Should -HaveCount 1 $entries[0].AuditFlags | Should -Be 'Success' $entries[0].FileSystemRights | Should -Be ([System.Security.AccessControl.FileSystemRights]::Delete) + $found = @([Security2.FileSystemAuditRule2]::GetFileSystemAuditRules($path, $true, $false)) + $found | Should -HaveCount 1 + $found[0].Account.Sid | Should -BeExactly 'S-1-1-0' + $found[0].FullName | Should -BeExactly $path } - It 'Should add the entries of several accounts to a by its path only when the result is enumerated, and remove them again' -ForEach @( + It 'Should add the entries of several accounts to a by its path when the result is enumerated, and remove them again' -ForEach @( @{ Kind = 'file'; Directory = $false } @{ Kind = 'folder'; Directory = $true } ) { @@ -580,7 +583,6 @@ Describe 'Audit rule helpers that take a path' -Skip:(-not $holdsSecurityPrivile $path, $accounts, [Security2.FileSystemRights2]::Delete, $success, $noInheritance, $noPropagation ) - @(Get-AuditEntries -Path $path) | Should -BeNullOrEmpty @($pending) | Should -HaveCount 2 @(Get-AuditEntries -Path $path) | Should -HaveCount 1 @(Get-AuditEntries -Path $path -Account 'S-1-5-32-545') | Should -HaveCount 1 @@ -668,6 +670,21 @@ Describe 'Inheritance helpers that take a path' { (Get-Acl -LiteralPath $path).AreAccessRulesProtected | Should -BeFalse } + # The overloads that take a path do nothing for a path that is neither a file nor a folder. + It ' should change nothing for a path that does not exist' -ForEach @( + @{ Method = 'EnableAccessInheritance' } + @{ Method = 'DisableAccessInheritance' } + @{ Method = 'EnableAuditInheritance' } + @{ Method = 'DisableAuditInheritance' } + ) { + $missing = Join-Path -Path $sandbox -ChildPath ('Missing-{0}' -f [guid]::NewGuid().ToString('N')) + Assert-TestSandboxPath -Sandbox $sandbox -Path $missing + + { [Security2.FileSystemInheritanceInfo]::$Method($missing, $true) } | Should -Not -Throw + + Test-Path -LiteralPath $missing | Should -BeFalse + } + It 'Should block and restore the audit inheritance of a by its path' -Skip:(-not $holdsSecurityPrivilege) -ForEach @( @{ Kind = 'file'; Directory = $false } @{ Kind = 'folder'; Directory = $true } @@ -718,6 +735,24 @@ Describe 'Owner and descriptor objects' { Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) | Should -HaveCount 1 } + # The item decides where Write puts the sections that the descriptor was read with, and Name and FullName follow it. + It 'Should write a descriptor to the item that the caller assigns' { + $source = New-TestSandboxItem -Sandbox $sandbox -Name 'RetargetSource' + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'RetargetTarget' + Add-NTFSAccess -Path $source -Account 'S-1-1-0' -AccessRights ReadData + $descriptor = Get-NTFSSecurityDescriptor -Path $source + $descriptor.Item = Get-Item2 -Path $target + + $descriptor.FullName | Should -BeExactly $target + $descriptor.Name | Should -BeExactly (Split-Path -Path $target -Leaf) + $descriptor.Write() + + foreach ($path in $source, $target) { + @((Get-Acl -LiteralPath $path).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) | Should -HaveCount 1 + } + } + It 'Should name the missing path when it writes a descriptor to an item that does not exist' { $source = New-TestSandboxItem -Sandbox $sandbox -Name 'DescriptorMissingSource' $missing = Join-Path -Path $sandbox -ChildPath ('Missing-{0}' -f [guid]::NewGuid().ToString('N')) diff --git a/Tests/PathErrors.Tests.ps1 b/Tests/PathErrors.Tests.ps1 index 96fcee5..30fea73 100644 --- a/Tests/PathErrors.Tests.ps1 +++ b/Tests/PathErrors.Tests.ps1 @@ -281,7 +281,8 @@ Describe 'An item whose owner may not change its permissions' { # with the right in the DACL, which the cleared DACL no longer holds. The cmdlet reports the owner it cannot set back. It 'Clear-NTFSAccess -DisableInheritance should report RestoreOwnerError for a previous owner that it cannot set back' -Skip:(-not $holdsRestorePrivilege) { $user = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value - $owner | Should -Not -Be $user + Set-TestOwner -Sandbox $sandbox -Path $file -Sid 'S-1-5-32-544' + (Get-Privileges | Where-Object -Property Privilege -EQ -Value 'Restore').PrivilegeState | Should -Be 'Disabled' Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-3-4' = 'ChangePermissions' } diff --git a/Tests/Privileges.Tests.ps1 b/Tests/Privileges.Tests.ps1 index 7dd4fc5..6c984d9 100644 --- a/Tests/Privileges.Tests.ps1 +++ b/Tests/Privileges.Tests.ps1 @@ -268,7 +268,20 @@ Describe 'The PrivilegeEnabler class' { BeforeAll { $privateData['EnablePrivileges'] = $false $backup = [ProcessPrivileges.Privilege]::Backup + $changeNotify = [ProcessPrivileges.Privilege]::ChangeNotify $currentProcess = [System.Diagnostics.Process]::GetCurrentProcess() + + # The enabler goes out of scope in the function, so that nothing but the caller's handle refers to what it owns. + function New-AbandonedHandle { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Test helper that only creates an object.' + )] + param ($Process) + + $enabler = New-Object -TypeName 'ProcessPrivileges.PrivilegeEnabler' -ArgumentList $Process + $field = [ProcessPrivileges.PrivilegeEnabler].GetField('accessTokenHandle', [System.Reflection.BindingFlags] 'NonPublic, Instance') + $field.GetValue($enabler) + } } AfterAll { @@ -335,21 +348,51 @@ Describe 'The PrivilegeEnabler class' { It 'Should enable a privilege through an access token handle that the caller owns' -Skip:(-not $holdsPrivileges) { $rights = [ProcessPrivileges.TokenAccessRights]::AdjustPrivileges -bor [ProcessPrivileges.TokenAccessRights]::Query $handle = [ProcessPrivileges.ProcessExtensions]::GetAccessTokenHandle($currentProcess, $rights) + $enabler = $null try { $enabler = New-Object -TypeName 'ProcessPrivileges.PrivilegeEnabler' -ArgumentList $handle, $backup Get-BackupPrivilegeState | Should -Be 'Enabled' $enabler.Dispose() + $enabler = $null Get-BackupPrivilegeState | Should -Be 'Disabled' $handle.IsClosed | Should -BeFalse } finally { + # The enabler first: a handle that is closed under an enabler that still owns a privilege fails when the + # enabler disables the privilege. + if ($enabler) { + $enabler.Dispose() + } $handle.Dispose() } $handle.IsClosed | Should -BeTrue } + # The finalizer closes the token handle that an abandoned enabler opened and drops its registration, so that the next + # enabler for the process opens a handle of its own instead of taking a closed one. The handle is private, so the test + # reads it by reflection. An enabler that enabled a privilege stays referenced by a static list until it is disposed, + # so it is never finalized and its privilege stays enabled; only an enabler without a privilege can be abandoned. + It 'Should close the token handle of an enabler that was never disposed when it is finalized' { + $handle = New-AbandonedHandle -Process $currentProcess + $handle.IsClosed | Should -BeFalse + + for ($attempt = 0; $attempt -lt 10 -and -not $handle.IsClosed; $attempt++) { + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + } + + $handle.IsClosed | Should -BeTrue + $enabler = New-Object -TypeName 'ProcessPrivileges.PrivilegeEnabler' -ArgumentList $currentProcess + try { + $enabler.EnablePrivilege($changeNotify) | Should -Be 'None' + } + finally { + $enabler.Dispose() + } + } + # The access tokens of administrators don't hold the privilege to create a token, and those of basic users don't hold # most of the others. It 'Should leave a privilege that the access token does not hold alone' { @@ -383,6 +426,67 @@ Describe 'The PrivilegeEnabler class' { } } +# Every access token holds the privilege to bypass traverse checking, enabled. The tests use it because they need no other +# privilege and change nothing: a handle that lacks a right fails before it adjusts anything. +Describe 'The access token handle of a process' { + BeforeAll { + $currentProcess = [System.Diagnostics.Process]::GetCurrentProcess() + $changeNotify = [ProcessPrivileges.Privilege]::ChangeNotify + $tokenRights = [ProcessPrivileges.TokenAccessRights] + } + + It 'Should open a handle with all access rights when the caller names none and close it on dispose' { + $handle = [ProcessPrivileges.ProcessExtensions]::GetAccessTokenHandle($currentProcess) + try { + $handle.IsInvalid | Should -BeFalse + @([ProcessPrivileges.ProcessExtensions]::GetPrivileges($handle)) | Should -Not -BeNullOrEmpty + [ProcessPrivileges.ProcessExtensions]::GetPrivilegeState($handle, $changeNotify) | Should -Be 'Enabled' + } + finally { + $handle.Dispose() + } + + $handle.IsClosed | Should -BeTrue + } + + It 'Should refuse to enable a privilege through a handle that may only query' { + $handle = [ProcessPrivileges.ProcessExtensions]::GetAccessTokenHandle($currentProcess, $tokenRights::Query) + try { + $failure = { [ProcessPrivileges.ProcessExtensions]::EnablePrivilege($handle, $changeNotify) } | Should -Throw -PassThru + + $failure.Exception.InnerException | Should -BeOfType [System.ComponentModel.Win32Exception] + $failure.Exception.InnerException.NativeErrorCode | Should -Be 5 + [ProcessPrivileges.ProcessExtensions]::GetPrivilegeState($handle, $changeNotify) | Should -Be 'Enabled' + } + finally { + $handle.Dispose() + } + } + + It 'Should refuse to through a handle that may only adjust privileges' -ForEach @( + @{ Operation = 'list the privileges' } + @{ Operation = 'read the state of a privilege' } + ) { + $handle = [ProcessPrivileges.ProcessExtensions]::GetAccessTokenHandle($currentProcess, $tokenRights::AdjustPrivileges) + try { + $failure = { + if ($Operation -eq 'list the privileges') { + [ProcessPrivileges.ProcessExtensions]::GetPrivileges($handle) + } + else { + [ProcessPrivileges.ProcessExtensions]::GetPrivilegeState($handle, $changeNotify) + } + } | Should -Throw -PassThru + + $failure.Exception.InnerException | Should -BeOfType [System.ComponentModel.Win32Exception] + $failure.Exception.InnerException.NativeErrorCode | Should -Be 5 + } + finally { + $handle.Dispose() + } + } +} + Describe 'The PrivilegeControl class' { BeforeAll { $privateData['EnablePrivileges'] = $false diff --git a/Tests/TestHelpers.psm1 b/Tests/TestHelpers.psm1 index 89c7ab9..c7dab5a 100644 --- a/Tests/TestHelpers.psm1 +++ b/Tests/TestHelpers.psm1 @@ -422,6 +422,113 @@ function ConvertTo-TestAdminSharePath { '\\localhost\{0}${1}' -f $Path.Substring(0, 1), $Path.Substring(2) } +function New-TestDriveMapping { + <# + .SYNOPSIS + Maps a free drive letter to a folder of the sandbox with subst and returns the root of the drive, such as Z:\. + Returns nothing when the process cannot define a drive letter, as the restricted token of a basic user cannot. + .DESCRIPTION + For Windows and for the module, the root of the mapped drive is the root folder of a drive, so that a test can + change it without changing a volume. The helper checks the folder with Assert-TestSandboxPath first and unmaps + the letter again, with an error, when a marker file of the folder is not visible through it, so that a mapping + that points elsewhere is never used. Remove the mapping with Remove-TestDriveMapping. + .PARAMETER Sandbox + The sandbox folder that New-TestSandbox returned. + .PARAMETER Path + The full path of the folder to map, in the sandbox. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Test helper that only maps sandbox folders.' + )] + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [string] + $Sandbox, + + [Parameter(Mandatory)] + [string] + $Path + ) + + Assert-TestSandboxPath -Sandbox $Sandbox -Path $Path + $marker = [guid]::NewGuid().ToString('N') + $markerPath = Join-Path -Path $Path -ChildPath $marker + Assert-TestSandboxPath -Sandbox $Sandbox -Path $markerPath + Set-Content -LiteralPath $markerPath -Value $marker + $subst = Join-Path -Path $env:SystemRoot -ChildPath 'System32\subst.exe' + + # A test run in parallel can map a letter at the same moment, which makes subst fail for that letter. + foreach ($letter in 'Z', 'Y', 'X', 'W', 'V', 'U', 'T', 'S') { + $root = '{0}:\' -f $letter + if (Test-Path -LiteralPath $root) { + continue + } + + & $subst ('{0}:' -f $letter) $Path *> $null + if ($LASTEXITCODE -ne 0) { + continue + } + + if (Test-Path -LiteralPath (Join-Path -Path $root -ChildPath $marker)) { + return $root + } + + & $subst ('{0}:' -f $letter) /d *> $null + throw "The drive '$root' does not show the sandbox folder '$Path'." + } +} + +function Remove-TestDriveMapping { + <# + .SYNOPSIS + Removes a mapping of New-TestDriveMapping. + .PARAMETER Root + The root of the drive that New-TestDriveMapping returned, such as Z:\. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Test helper that only removes its own mapping.' + )] + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [ValidatePattern('^[A-Z]:\\$')] + [string] + $Root + ) + + & (Join-Path -Path $env:SystemRoot -ChildPath 'System32\subst.exe') $Root.TrimEnd('\') /d *> $null + if (Test-Path -LiteralPath $Root) { + Write-Error -Message "The drive mapping '$Root' could not be removed." + } +} + +function Test-DriveMappingAvailable { + <# + .SYNOPSIS + Returns $true when the process can define a drive letter for a folder with subst, which the restricted token of + the basic-user runner cannot. + #> + [CmdletBinding()] + [OutputType([bool])] + param () + + $sandbox = New-TestSandbox -Name 'DriveProbe' + try { + $root = New-TestDriveMapping -Sandbox $sandbox -Path $sandbox + if ($root) { + Remove-TestDriveMapping -Root $root + } + + [bool] $root + } + finally { + Remove-TestSandbox -Sandbox $sandbox + } +} + Export-ModuleMember -Function New-TestSandbox, Assert-TestSandboxPath, Remove-TestSandbox, New-TestSandboxItem, Block-TestReadPermission, Block-TestWritePermission, Add-TestDenyRule, Set-TestOwner, Test-IsElevated, - Test-PrivilegeHeld, Test-AdminShareAvailable, ConvertTo-TestAdminSharePath + Test-PrivilegeHeld, Test-AdminShareAvailable, ConvertTo-TestAdminSharePath, New-TestDriveMapping, + Remove-TestDriveMapping, Test-DriveMappingAvailable