Browse Source

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 <ai@example.com>
pull/118/head
Raimund Andree 2 days ago
parent
commit
d0bd1afb9a
  1. 60
      Tests/Access.Tests.ps1
  2. 22
      Tests/Audit.Tests.ps1
  3. 18
      Tests/Links.Tests.ps1
  4. 93
      Tests/ObjectApis.Tests.ps1
  5. 34
      Tests/TestHelpers.Tests.ps1
  6. 54
      Tests/TestHelpers.psm1

60
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 <Source> and return no access entry' -ForEach @(

22
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

18
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' {

93
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 <Case>' -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 <Library>' -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 <Library>' -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 <Library>' -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]
}
}
}

34
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]

54
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

Loading…
Cancel
Save