From 2ae7e32a5c8fccb155e30a72947b8b65951aa2ce Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Sun, 4 Oct 2026 23:52:29 +0200 Subject: [PATCH] test: add sandbox, path guard, and elevation helpers for the tests Tests/TestHelpers.psm1 lets a test that changes files, links, ACLs, owners, audit entries, or inheritance work in its own sandbox below $env:TEMP\NTFSSecurity.Tests: - New-TestSandbox creates the folder; Assert-TestSandboxPath throws unless every target, relative ones resolved against the location, is inside it. - Remove-TestSandbox removes the links first without following them (Windows PowerShell 5.1 follows directory links when it removes a folder), resets ACL changes, and deletes the folder. - Test-IsElevated and Test-PrivilegeHeld decide which tests can run; CI runners are elevated, the workstation isn't. Tests/TestHelpers.Tests.ps1 (14 tests) covers the helpers. CI runs every *.Tests.ps1 file in Tests, so new test files need no workflow change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/TestHelpers.Tests.ps1 | 119 ++++++++++++++++++++++++++ Tests/TestHelpers.psm1 | 163 ++++++++++++++++++++++++++++++++++++ 2 files changed, 282 insertions(+) create mode 100644 Tests/TestHelpers.Tests.ps1 create mode 100644 Tests/TestHelpers.psm1 diff --git a/Tests/TestHelpers.Tests.ps1 b/Tests/TestHelpers.Tests.ps1 new file mode 100644 index 0000000..d850384 --- /dev/null +++ b/Tests/TestHelpers.Tests.ps1 @@ -0,0 +1,119 @@ +<# + Tests the shared helpers in TestHelpers.psm1, which keep the tests that change files, links, and security + descriptors inside their sandbox folders. CI runs every *.Tests.ps1 file of this folder. +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.' +)] +param () + +BeforeAll { + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force +} + +AfterAll { + Remove-Module -Name TestHelpers -Force -ErrorAction SilentlyContinue +} + +Describe 'Test helpers' { + Context 'New-TestSandbox' { + BeforeAll { + $sandbox = New-TestSandbox -Name 'Helpers' + } + + AfterAll { + Remove-TestSandbox -Sandbox $sandbox + } + + It 'Should create an empty folder below $env:TEMP\NTFSSecurity.Tests' { + $sandbox | Should -Exist + Get-ChildItem -LiteralPath $sandbox -Force | Should -BeNullOrEmpty + $expectedParent = [IO.Path]::GetFullPath((Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'NTFSSecurity.Tests')) + Split-Path -Path $sandbox -Parent | Should -Be $expectedParent + Split-Path -Path $sandbox -Leaf | Should -BeLike 'Helpers-*' + } + } + + Context 'Assert-TestSandboxPath' { + BeforeAll { + $sandbox = New-TestSandbox -Name 'Helpers' + Push-Location -LiteralPath $sandbox + } + + AfterAll { + Pop-Location + Remove-TestSandbox -Sandbox $sandbox + } + + It 'Should accept a full path inside the sandbox' { + { Assert-TestSandboxPath -Sandbox $sandbox -Path (Join-Path -Path $sandbox -ChildPath 'Folder\File.txt') } | + Should -Not -Throw + } + + It 'Should resolve a relative path against the current location' { + { Assert-TestSandboxPath -Sandbox $sandbox -Path '.\File.txt', 'Folder\File.txt' } | Should -Not -Throw + } + + It 'Should reject <_>' -ForEach @('..', '..\Other', 'C:\Windows', '\Windows') { + { Assert-TestSandboxPath -Sandbox $sandbox -Path $_ } | Should -Throw -ExpectedMessage 'Refusing to change*' + } + + It 'Should reject a sibling folder whose name starts with the name of the sandbox' { + { Assert-TestSandboxPath -Sandbox $sandbox -Path "$sandbox-Other\File.txt" } | + Should -Throw -ExpectedMessage 'Refusing to change*' + } + + It 'Should reject a sandbox that New-TestSandbox did not create' { + { Assert-TestSandboxPath -Sandbox $env:TEMP -Path (Join-Path -Path $env:TEMP -ChildPath 'File.txt') } | + Should -Throw -ExpectedMessage '*is not a test sandbox*' + } + } + + Context 'Remove-TestSandbox' { + BeforeAll { + $sandbox = New-TestSandbox -Name 'Helpers' + $otherSandbox = New-TestSandbox -Name 'Helpers' + $target = Join-Path -Path $otherSandbox -ChildPath 'Target' + $link = Join-Path -Path $sandbox -ChildPath 'Link' + $locked = Join-Path -Path $sandbox -ChildPath 'Locked' + + Assert-TestSandboxPath -Sandbox $otherSandbox -Path $target + New-Item -ItemType Directory -Path $target | Out-Null + Set-Content -LiteralPath (Join-Path -Path $target -ChildPath 'Keep.txt') -Value 'Keep' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link, $locked + New-Item -ItemType Junction -Path $link -Value $target | Out-Null + New-Item -ItemType Directory -Path $locked | Out-Null + Set-Content -LiteralPath (Join-Path -Path $locked -ChildPath 'File.txt') -Value 'Locked' + # An empty, protected DACL that grants nobody access, as some tests leave behind + & icacls.exe $locked /inheritance:r *> $null + + Remove-TestSandbox -Sandbox $sandbox + } + + AfterAll { + Remove-TestSandbox -Sandbox $otherSandbox + } + + It 'Should remove the sandbox, even with a folder that denies access' { + $sandbox | Should -Not -Exist + } + + It 'Should remove a junction without removing the files of its target' { + Join-Path -Path $target -ChildPath 'Keep.txt' | Should -Exist + } + } + + Context 'Test-IsElevated and Test-PrivilegeHeld' { + It 'Should tell whether the process is elevated' { + Test-IsElevated | Should -BeOfType [bool] + } + + It 'Should find SeChangeNotifyPrivilege, which every access token holds' { + Test-PrivilegeHeld -Name 'SeChangeNotifyPrivilege' | Should -BeTrue + } + + It 'Should not find a privilege that does not exist' { + Test-PrivilegeHeld -Name 'SeNoSuchPrivilege' | Should -BeFalse + } + } +} diff --git a/Tests/TestHelpers.psm1 b/Tests/TestHelpers.psm1 new file mode 100644 index 0000000..3ac1f64 --- /dev/null +++ b/Tests/TestHelpers.psm1 @@ -0,0 +1,163 @@ +<# + Shared helpers for the Pester tests. A test that changes files, links, ACLs, owners, audit entries, or + inheritance works in its own sandbox folder below $env:TEMP\NTFSSecurity.Tests: it creates the sandbox with + New-TestSandbox, sets the location to it, checks every target with Assert-TestSandboxPath before the change, + and removes the sandbox with Remove-TestSandbox. +#> + +$script:sandboxRoot = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'NTFSSecurity.Tests' + +function New-TestSandbox { + <# + .SYNOPSIS + Creates an empty sandbox folder for one test file or block and returns its full path. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Test helper that only creates sandboxes.' + )] + [CmdletBinding()] + [OutputType([string])] + param ( + [ValidatePattern('^[\w-]+$')] + [string] + $Name = 'Sandbox' + ) + + $path = Join-Path -Path $script:sandboxRoot -ChildPath ('{0}-{1}' -f $Name, [guid]::NewGuid().ToString('N').Substring(0, 8)) + (New-Item -ItemType Directory -Path $path -Force).FullName +} + +function Assert-TestSandboxPath { + <# + .SYNOPSIS + Throws unless each path is inside the given sandbox, which itself must be a sandbox of New-TestSandbox. + Relative paths resolve against the current location. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string] + $Sandbox, + + [Parameter(Mandatory, ValueFromPipeline)] + [string[]] + $Path + ) + + begin { + $sandboxFullName = [IO.Path]::GetFullPath($Sandbox).TrimEnd('\') + $rootFullName = [IO.Path]::GetFullPath($script:sandboxRoot).TrimEnd('\') + '\' + if (-not $sandboxFullName.StartsWith($rootFullName, [StringComparison]::OrdinalIgnoreCase) -or + $sandboxFullName.Length -le $rootFullName.Length) { + throw "'$Sandbox' is not a test sandbox below '$rootFullName'." + } + $sandboxPrefix = $sandboxFullName + '\' + } + + process { + foreach ($item in $Path) { + $location = (Get-Location -PSProvider FileSystem).ProviderPath + $fullName = [IO.Path]::GetFullPath([IO.Path]::Combine($location, $item)).TrimEnd('\') + if (-not ($fullName + '\').StartsWith($sandboxPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to change '$fullName', which is outside the test sandbox '$sandboxFullName'." + } + } + } +} + +function Remove-TestSandbox { + <# + .SYNOPSIS + Removes a sandbox of New-TestSandbox: first its links, without following them, then its ACL changes, then + the folder. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Test helper that only removes sandboxes.' + )] + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string] + $Sandbox + ) + + Assert-TestSandboxPath -Sandbox $Sandbox -Path $Sandbox + if (-not (Test-Path -LiteralPath $Sandbox)) { + return + } + + # Windows PowerShell 5.1 follows directory links when it removes a folder recursively, so the links go first. + & icacls.exe $Sandbox /reset /T /C /Q *> $null + $pending = New-Object -TypeName 'System.Collections.Generic.Stack[string]' + $pending.Push($Sandbox) + while ($pending.Count -gt 0) { + foreach ($entry in [IO.Directory]::GetFileSystemEntries($pending.Pop())) { + $attributes = [IO.File]::GetAttributes($entry) + if ($attributes -band [IO.FileAttributes]::ReparsePoint) { + if ($attributes -band [IO.FileAttributes]::Directory) { + [IO.Directory]::Delete($entry, $false) + } + else { + [IO.File]::Delete($entry) + } + } + elseif ($attributes -band [IO.FileAttributes]::Directory) { + $pending.Push($entry) + } + } + } + & icacls.exe $Sandbox /reset /T /C /Q *> $null + Get-ChildItem -LiteralPath $Sandbox -Recurse -Force | ForEach-Object -Process { + $_.Attributes = [IO.FileAttributes]::Normal + } + Remove-Item -LiteralPath $Sandbox -Recurse -Force + try { + # Fails while another sandbox exists, also one of a test run in parallel + [IO.Directory]::Delete($script:sandboxRoot, $false) + } + catch { + Write-Verbose -Message "Keeping '$script:sandboxRoot': $($_.Exception.Message)" + } +} + +function Test-IsElevated { + <# + .SYNOPSIS + Returns $true when the process runs elevated as an administrator. + #> + [CmdletBinding()] + [OutputType([bool])] + param () + + $principal = New-Object -TypeName 'Security.Principal.WindowsPrincipal' -ArgumentList ( + [Security.Principal.WindowsIdentity]::GetCurrent() + ) + $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Test-PrivilegeHeld { + <# + .SYNOPSIS + Returns $true when the access token of the process holds the privilege, enabled or not. + .PARAMETER Name + The privilege name, such as SeSecurityPrivilege or SeBackupPrivilege. + #> + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory)] + [ValidatePattern('^Se\w+Privilege$')] + [string] + $Name + ) + + # The column names are localized; the privilege names in the first column are not. + $output = & whoami.exe /priv /fo csv 2>$null + if ($LASTEXITCODE -ne 0) { + return $false + } + [bool] ($output | Select-Object -Skip 1 | ConvertFrom-Csv -Header 'Name', 'Description', 'State' | + Where-Object -Property Name -EQ -Value $Name) +} + +Export-ModuleMember -Function New-TestSandbox, Assert-TestSandboxPath, Remove-TestSandbox, Test-IsElevated, Test-PrivilegeHeld