From d0bd1afb9a45f1894dcd016df526326f5a0e0c0f Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 17:57:31 +0000 Subject: [PATCH] test: cover deny entries without rights, a NULL DACL, hard links with read rights denied, and the extension methods Add-NTFSAccess, Remove-NTFSAccess, and Add-NTFSAudit report an AddAceError or RemoveAceError for each item when .NET refuses an entry without rights, change nothing, and return nothing with -PassThru. Get-NTFSAccess returns the one entry that .NET reports for a NULL DACL without a source; the new helper Set-TestNullDacl writes it through SetNamedSecurityInfo inside the sandbox guard. Get-NTFSHardLink lists the names of a file whose read rights are denied, which is why its UnauthorizedAccessException handler has no trigger. The public Extensions.ForEach and GetParent helpers, which a static scan listed as unused although cmdlets call them, are tested directly. All of these characterize behavior that was already correct, so none was red before; the mutations that prove their detection run against the frozen measurement commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/Access.Tests.ps1 | 60 ++++++++++++++++++++++++ Tests/Audit.Tests.ps1 | 22 +++++++++ Tests/Links.Tests.ps1 | 18 +++++++ Tests/ObjectApis.Tests.ps1 | 93 +++++++++++++++++++++++++++++++++++++ Tests/TestHelpers.Tests.ps1 | 34 ++++++++++++++ Tests/TestHelpers.psm1 | 54 ++++++++++++++++++++- 6 files changed, 280 insertions(+), 1 deletion(-) diff --git a/Tests/Access.Tests.ps1 b/Tests/Access.Tests.ps1 index 8efdedd..62bc669 100644 --- a/Tests/Access.Tests.ps1 +++ b/Tests/Access.Tests.ps1 @@ -528,6 +528,29 @@ Describe 'Remove-NTFSAccess' { } } + # .NET refuses to build a deny entry without rights, also to find the entry to remove. The cmdlet reports the + # exception for the item and goes on. + Context 'With -AccessRights None for a deny entry' { + It 'Should write a RemoveAceError for each item, change nothing, and return nothing with -PassThru' { + $first = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveDenyNoneFirst' + $second = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveDenyNoneSecond' + $before = @((Get-Acl -LiteralPath $first).Sddl, (Get-Acl -LiteralPath $second).Sddl) + + $result = @(Remove-NTFSAccess -Path $first, $second -Account 'Everyone' -AccessRights None -AccessType Deny -PassThru -ErrorVariable removeErrors -ErrorAction SilentlyContinue) + + $result | Should -BeNullOrEmpty + $removeErrors | Should -HaveCount 2 + for ($index = 0; $index -lt 2; $index++) { + $removeErrors[$index].FullyQualifiedErrorId | Should -BeLike 'RemoveAceError,*' + $removeErrors[$index].CategoryInfo.Category | Should -Be 'WriteError' + $removeErrors[$index].TargetObject | Should -BeExactly @($first, $second)[$index] + $removeErrors[$index].Exception | Should -BeOfType [System.ArgumentException] + } + (Get-Acl -LiteralPath $first).Sddl | Should -BeExactly $before[0] + (Get-Acl -LiteralPath $second).Sddl | Should -BeExactly $before[1] + } + } + Context 'When the item has an owner that the user cannot assign' { BeforeAll { $privateData['EnablePrivileges'] = $false @@ -707,6 +730,28 @@ Describe 'Add-NTFSAccess' { } } + # .NET refuses to build a deny entry without rights. The cmdlet reports the exception for the item and goes on. + Context 'With -AccessRights None for a deny entry' { + It 'Should write an AddAceError for each item, change nothing, and return nothing with -PassThru' { + $first = New-TestSandboxItem -Sandbox $sandbox -Name 'DenyNoneFirst' + $second = New-TestSandboxItem -Sandbox $sandbox -Name 'DenyNoneSecond' + $before = @((Get-Acl -LiteralPath $first).Sddl, (Get-Acl -LiteralPath $second).Sddl) + + $result = @(Add-NTFSAccess -Path $first, $second -Account 'Everyone' -AccessRights None -AccessType Deny -PassThru -ErrorVariable addErrors -ErrorAction SilentlyContinue) + + $result | Should -BeNullOrEmpty + $addErrors | Should -HaveCount 2 + for ($index = 0; $index -lt 2; $index++) { + $addErrors[$index].FullyQualifiedErrorId | Should -BeLike 'AddAceError,*' + $addErrors[$index].CategoryInfo.Category | Should -Be 'WriteError' + $addErrors[$index].TargetObject | Should -BeExactly @($first, $second)[$index] + $addErrors[$index].Exception | Should -BeOfType [System.ArgumentException] + } + (Get-Acl -LiteralPath $first).Sddl | Should -BeExactly $before[0] + (Get-Acl -LiteralPath $second).Sddl | Should -BeExactly $before[1] + } + } + Context 'When the item has an owner that the user cannot assign' { BeforeAll { $privateData['EnablePrivileges'] = $false @@ -1057,6 +1102,21 @@ Describe 'InheritedFrom of access entries' { $entry.InheritedFrom | Should -Be $expectedSource["$($entry.Account.Sid)"] } } + + # A NULL DACL gives everyone every access. It has no entries, so Windows names no source, and .NET reports one + # entry for Everyone nevertheless. The sources are looked up by the index of an entry, which this entry exceeds. + It 'Should return the one entry that .NET reports for a NULL DACL, without a source' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'InheritedFromNullDacl' + Set-TestNullDacl -Sandbox $sandbox -Path $file + $privateData['GetInheritedFrom'] | Should -BeTrue + + $entries = @(Get-NTFSAccess -Path $file -ErrorAction Stop) + + $entries | Should -HaveCount 1 + "$($entries[0].Account.Sid)" | Should -Be 'S-1-1-0' + $entries[0].IsInherited | Should -BeFalse + $entries[0].InheritedFrom | Should -BeNullOrEmpty + } } Describe 'Get-NTFSEffectiveAccess for an unresolved identity' { It 'Should report the native identity error for each and return no access entry' -ForEach @( diff --git a/Tests/Audit.Tests.ps1 b/Tests/Audit.Tests.ps1 index bd23df2..f6b148b 100644 --- a/Tests/Audit.Tests.ps1 +++ b/Tests/Audit.Tests.ps1 @@ -137,6 +137,28 @@ Describe 'Add-NTFSAudit' { } } + # .NET refuses to build an audit entry without rights. The cmdlet reports the exception for the item and goes on. + Context 'With -AccessRights None' -Skip:(-not $canReadAudit) { + It 'Should write an AddAceError for each item, change nothing, and return nothing with -PassThru' { + $first = New-TestSandboxItem -Sandbox $sandbox -Name 'AuditNoneFirst' + $second = New-TestSandboxItem -Sandbox $sandbox -Name 'AuditNoneSecond' + $before = @((Get-Acl -LiteralPath $first -Audit).Sddl, (Get-Acl -LiteralPath $second -Audit).Sddl) + + $result = @(Add-NTFSAudit -Path $first, $second -Account 'Everyone' -AccessRights None -AuditFlags Success -PassThru -ErrorVariable auditErrors -ErrorAction SilentlyContinue) + + $result | Should -BeNullOrEmpty + $auditErrors | Should -HaveCount 2 + for ($index = 0; $index -lt 2; $index++) { + $auditErrors[$index].FullyQualifiedErrorId | Should -BeLike 'AddAceError,*' + $auditErrors[$index].CategoryInfo.Category | Should -Be 'WriteError' + $auditErrors[$index].TargetObject | Should -BeExactly @($first, $second)[$index] + $auditErrors[$index].Exception | Should -BeOfType [System.ArgumentException] + } + (Get-Acl -LiteralPath $first -Audit).Sddl | Should -BeExactly $before[0] + (Get-Acl -LiteralPath $second -Audit).Sddl | Should -BeExactly $before[1] + } + } + Context 'When the item has an owner that the user cannot assign' { BeforeAll { $privateData['EnablePrivileges'] = $false diff --git a/Tests/Links.Tests.ps1 b/Tests/Links.Tests.ps1 index b3bb3b4..ef2fd93 100644 --- a/Tests/Links.Tests.ps1 +++ b/Tests/Links.Tests.ps1 @@ -256,6 +256,24 @@ Describe 'Get-NTFSHardLink' { $linkErrors[0].TargetObject | Should -Be $sharePath $result.FullName | Should -Be $other } + + # Windows lists the names of a file without opening it, so no deny entry stops the cmdlet. This is why a test cannot + # reach the handler for an UnauthorizedAccessException of the cmdlet. + It 'Should list the names of a file whose read rights are denied for its owner and for the user' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'ReadDenied' + $link = Join-Path -Path $sandbox -ChildPath 'ReadDeniedLink.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + New-NTFSHardLink -Path $link -Target $file -ErrorAction Stop + $readRights = 'ReadAttributes, ReadData, ReadPermissions' + $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value + Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-3-4' = $readRights; $currentUser = $readRights } + { Get-Content -LiteralPath $file -ErrorAction Stop } | Should -Throw + + $result = @(Get-NTFSHardLink -Path $file -ErrorVariable linkErrors -ErrorAction SilentlyContinue) + + $linkErrors | Should -BeNullOrEmpty + ($result.FullName | Sort-Object) -join '|' | Should -Be ((@($file, $link) | Sort-Object) -join '|') + } } Describe 'New-NTFSSymbolicLink' { diff --git a/Tests/ObjectApis.Tests.ps1 b/Tests/ObjectApis.Tests.ps1 index 4bfa014..d608a17 100644 --- a/Tests/ObjectApis.Tests.ps1 +++ b/Tests/ObjectApis.Tests.ps1 @@ -808,3 +808,96 @@ Describe 'Generic access rights and identity errors' { $failure.Exception.InnerException | Should -BeOfType $Expected } } + +# The helpers of the cmdlets are public extension methods, so a script can call them. The cmdlets pass what the guards +# of ForEach refuse and a parent that is a folder, never a file; the generic method is invoked through reflection, +# because Windows PowerShell can't name the type argument of a call. +Describe 'The extension methods of the cmdlets' { + BeforeAll { + $forEachMethod = [NTFSSecurity.Extensions].GetMethod('ForEach').MakeGenericMethod([string]) + + function New-ItemObject { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Test helper that only creates an object.' + )] + param ([string] $Library, [string] $Path) + + if ($Library -eq 'AlphaFS') { + [Alphaleonis.Win32.Filesystem.FileInfo]::new($Path) + } + else { + [System.IO.FileInfo]::new($Path) + } + } + } + + It 'ForEach should reject ' -ForEach @( + @{ Case = 'a source that is null'; NullSource = $true } + @{ Case = 'an action that is null'; NullSource = $false } + ) { + $arguments = [object[]]::new(2) + if ($NullSource) { + $arguments[1] = [System.Action[string]] { param ($Element) $null = $Element } + } + else { + $arguments[0] = [string[]] @('One') + } + + $failure = { $forEachMethod.Invoke($null, $arguments) } | Should -Throw -PassThru + + # Reflection wraps the exception of the method, and PowerShell wraps that in turn. + $failure.Exception.GetBaseException() | Should -BeOfType [System.ArgumentException] + } + + It 'ForEach should run the action for each element in order' { + $seen = New-Object -TypeName 'System.Collections.Generic.List[string]' + $arguments = [object[]]::new(2) + $arguments[0] = [string[]] @('One', 'Two', 'Three') + $arguments[1] = [System.Action[string]] { param ($Element) $seen.Add($Element) } + + $forEachMethod.Invoke($null, $arguments) | Out-Null + + $seen -join ',' | Should -BeExactly 'One,Two,Three' + } + + Context 'GetParent' { + BeforeAll { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'ParentFolder' -Directory + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'ParentFile' + $inFolder = Join-Path -Path $folder -ChildPath 'Child.txt' + $belowFile = Join-Path -Path $file -ChildPath 'Child.txt' + $belowMissing = Join-Path -Path $sandbox -ChildPath 'Missing\Child.txt' + } + + It 'Should return a folder as a DirectoryInfo of ' -ForEach @( + @{ Library = 'AlphaFS'; TypeName = 'Alphaleonis.Win32.Filesystem.DirectoryInfo' } + @{ Library = 'System.IO'; TypeName = 'System.IO.DirectoryInfo' } + ) { + $parent = [NTFSSecurity.Extensions]::GetParent((New-ItemObject -Library $Library -Path $inFolder)) + + $parent.GetType().FullName | Should -BeExactly $TypeName + $parent.FullName | Should -BeExactly $folder + } + + # A FileInfo can name a path below a file, which no file system holds, so the parent path names a file. + It 'Should return a parent path that names a file as a FileInfo of ' -ForEach @( + @{ Library = 'AlphaFS'; TypeName = 'Alphaleonis.Win32.Filesystem.FileInfo' } + @{ Library = 'System.IO'; TypeName = 'System.IO.FileInfo' } + ) { + $parent = [NTFSSecurity.Extensions]::GetParent((New-ItemObject -Library $Library -Path $belowFile)) + + $parent.GetType().FullName | Should -BeExactly $TypeName + $parent.FullName | Should -BeExactly $file + } + + It 'Should throw a FileNotFoundException for a parent that does not exist, for ' -ForEach @( + @{ Library = 'AlphaFS' } + @{ Library = 'System.IO' } + ) { + $failure = { [NTFSSecurity.Extensions]::GetParent((New-ItemObject -Library $Library -Path $belowMissing)) } | + Should -Throw -PassThru + + $failure.Exception.GetBaseException() | Should -BeOfType [System.IO.FileNotFoundException] + } + } +} diff --git a/Tests/TestHelpers.Tests.ps1 b/Tests/TestHelpers.Tests.ps1 index 1de8b7e..32bbd74 100644 --- a/Tests/TestHelpers.Tests.ps1 +++ b/Tests/TestHelpers.Tests.ps1 @@ -257,6 +257,40 @@ Describe 'Test helpers' { } } + Context 'Set-TestNullDacl' { + BeforeAll { + $sandbox = New-TestSandbox -Name 'Helpers' + } + + AfterAll { + Remove-TestSandbox -Sandbox $sandbox + } + + It 'Should replace the DACL of an item in the sandbox with a protected NULL DACL' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'NullDacl' + $before = [System.Security.AccessControl.RawSecurityDescriptor]::new((Get-Acl -LiteralPath $file).GetSecurityDescriptorBinaryForm(), 0) + $before.ControlFlags.HasFlag([System.Security.AccessControl.ControlFlags]::DiscretionaryAclPresent) | Should -BeTrue + + Set-TestNullDacl -Sandbox $sandbox -Path $file + + $after = [System.Security.AccessControl.RawSecurityDescriptor]::new((Get-Acl -LiteralPath $file).GetSecurityDescriptorBinaryForm(), 0) + $after.ControlFlags.HasFlag([System.Security.AccessControl.ControlFlags]::DiscretionaryAclPresent) | Should -BeFalse + $after.ControlFlags.HasFlag([System.Security.AccessControl.ControlFlags]::DiscretionaryAclProtected) | Should -BeTrue + } + + It 'Should refuse an item outside the sandbox' { + { Set-TestNullDacl -Sandbox $sandbox -Path "$sandbox-Other\File.txt" } | + Should -Throw -ExpectedMessage 'Refusing to change*' + } + + It 'Should throw its own error when Windows refuses' { + $missing = Join-Path -Path $sandbox -ChildPath 'Missing.txt' + + { Set-TestNullDacl -Sandbox $sandbox -Path $missing } | + Should -Throw -ExpectedMessage 'SetNamedSecurityInfo could not set a NULL DACL*' + } + } + Context 'Test-IsElevated and Test-PrivilegeHeld' { It 'Should tell whether the process is elevated' { Test-IsElevated | Should -BeOfType [bool] diff --git a/Tests/TestHelpers.psm1 b/Tests/TestHelpers.psm1 index c7dab5a..251a766 100644 --- a/Tests/TestHelpers.psm1 +++ b/Tests/TestHelpers.psm1 @@ -341,6 +341,58 @@ function Set-TestOwner { } } +function Set-TestNullDacl { + <# + .SYNOPSIS + Replaces the DACL of an item in the sandbox with a NULL DACL, which gives everyone every access. + .DESCRIPTION + Neither Set-Acl nor icacls can write a NULL DACL, so the helper calls SetNamedSecurityInfo. The DACL is + protected, so the item inherits no entries. Everyone can delete the item, so Remove-TestSandbox removes it. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Test helper that only writes to sandboxes.' + )] + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string] + $Sandbox, + + [Parameter(Mandatory)] + [string] + $Path + ) + + Assert-TestSandboxPath -Sandbox $Sandbox -Path $Path + if (-not ('NtfsSecurityTests.NativeAcl' -as [type])) { + Add-Type -TypeDefinition @' +namespace NtfsSecurityTests +{ + public static class NativeAcl + { + [System.Runtime.InteropServices.DllImport("advapi32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)] + private static extern uint SetNamedSecurityInfoW(string objectName, int objectType, uint securityInfo, + System.IntPtr owner, System.IntPtr group, System.IntPtr dacl, System.IntPtr sacl); + + // SE_FILE_OBJECT, with DACL_SECURITY_INFORMATION and PROTECTED_DACL_SECURITY_INFORMATION and no DACL + public static uint SetNullDacl(string path) + { + return SetNamedSecurityInfoW(path, 1, 0x00000004u | 0x80000000u, + System.IntPtr.Zero, System.IntPtr.Zero, System.IntPtr.Zero, System.IntPtr.Zero); + } + } +} +'@ + } + + $location = (Get-Location -PSProvider FileSystem).ProviderPath + $fullName = [IO.Path]::GetFullPath([IO.Path]::Combine($location, $Path)) + $result = [NtfsSecurityTests.NativeAcl]::SetNullDacl($fullName) + if ($result -ne 0) { + throw "SetNamedSecurityInfo could not set a NULL DACL on '$fullName' (error $result)." + } +} + function Test-IsElevated { <# .SYNOPSIS @@ -529,6 +581,6 @@ function Test-DriveMappingAvailable { } Export-ModuleMember -Function New-TestSandbox, Assert-TestSandboxPath, Remove-TestSandbox, New-TestSandboxItem, - Block-TestReadPermission, Block-TestWritePermission, Add-TestDenyRule, Set-TestOwner, Test-IsElevated, + Block-TestReadPermission, Block-TestWritePermission, Add-TestDenyRule, Set-TestOwner, Set-TestNullDacl, Test-IsElevated, Test-PrivilegeHeld, Test-AdminShareAvailable, ConvertTo-TestAdminSharePath, New-TestDriveMapping, Remove-TestDriveMapping, Test-DriveMappingAvailable