From 404cecad33ac7603f41448997a564784cd624d91 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 20 Oct 2026 14:27:27 +0000 Subject: [PATCH] test(inheritance): close budget gaps for no-op, OpenError category, and unchanged-section pins Adds 15 It blocks (25 instances, Tests\ParameterSets.Inheritance.Tests.ps1) closing the budget items of worklist-inheritance.md rows 1-16: - The documented no-op / current-location behavior without -Path, pinned with Push-Location into a dedicated sandbox folder, for Disable/Enable-NTFSAccessInheritance, Disable/Enable- NTFSAuditInheritance, Set-NTFSInheritance, Get-NTFSOwner, Set-NTFSOwner, and Get-NTFSSecurityDescriptor. - The OpenError category for a path that does not exist, for Disable/Enable-NTFSAccessInheritance and Set-NTFSInheritance. - Owner unchanged after Disable-NTFSAccessInheritance -SecurityDescriptor protects the DACL; an independent Get-Acl -Audit oracle for Set-NTFSInheritance; the -RemoveInheritedAuditRules / -RemoveExplicitAuditRules switches on a -SecurityDescriptor with the owner and DACL confirmed unchanged after the write. - The Name property and a DACL protected without the module for Get-NTFSInheritance (-Path and -SecurityDescriptor); Get-NTFSOwner on a descriptor read without its Owner section; the SACL unchanged after Set-NTFSOwner changes the owner; the DACL unchanged and the -PassThru type for Set-NTFSOwner -SecurityDescriptor. - The by-property-name pipeline and the category/target of the -PassThru ReadSecurityError for Set-NTFSSecurityDescriptor. Every assertion reads state with an independent .NET oracle (Get-Acl, Get-Acl -Audit) or asserts an ErrorId/category/target triple; none repeats an existing test. Items marked Limit, would-pin, or lab in the worklist are out of scope and untouched. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/ParameterSets.Inheritance.Tests.ps1 | 346 ++++++++++++++++++++++ 1 file changed, 346 insertions(+) diff --git a/Tests/ParameterSets.Inheritance.Tests.ps1 b/Tests/ParameterSets.Inheritance.Tests.ps1 index f9bc66a..5455dbe 100644 --- a/Tests/ParameterSets.Inheritance.Tests.ps1 +++ b/Tests/ParameterSets.Inheritance.Tests.ps1 @@ -90,6 +90,38 @@ BeforeDiscovery { @{ Command = 'Disable-NTFSAuditInheritance'; Scope = 'audit'; PreDisable = $false; Expected = $false } @{ Command = 'Enable-NTFSAuditInheritance'; Scope = 'audit'; PreDisable = $true; Expected = $true } ) + + # No -Path: the four cmdlets that the docs say do nothing at all when no path arrives, directly or piped. + $noPathInheritanceCases = @( + @{ Command = 'Disable-NTFSAccessInheritance'; Parameters = @{}; Scope = 'access' } + @{ Command = 'Enable-NTFSAccessInheritance'; Parameters = @{}; Scope = 'access' } + @{ Command = 'Disable-NTFSAuditInheritance'; Parameters = @{}; Scope = 'audit' } + @{ Command = 'Enable-NTFSAuditInheritance'; Parameters = @{}; Scope = 'audit' } + @{ Command = 'Set-NTFSInheritance'; Parameters = @{ AccessInheritanceEnabled = $false }; Scope = 'access' } + ) + + # Error: a missing path, asserting the OpenError category, for the three Path-set cmdlets that $missingPathOpenErrorCases + # above does not cover. + $missingPathOpenErrorPathSetCases = @( + @{ Command = 'Disable-NTFSAccessInheritance'; Parameters = @{} } + @{ Command = 'Enable-NTFSAccessInheritance'; Parameters = @{} } + @{ Command = 'Set-NTFSInheritance'; Parameters = @{ AccessInheritanceEnabled = $false } } + ) + + # State: the -RemoveInheritedAuditRules/-RemoveExplicitAuditRules switches with a -SecurityDescriptor, mirroring + # $sdAccessSwitchCases above but read back with the independent .NET audit oracle, not Get-NTFSAudit. + $sdAuditSwitchCases = @( + @{ Command = 'Disable-NTFSAuditInheritance'; SwitchName = 'RemoveInheritedAuditRules'; Remove = $true } + @{ Command = 'Enable-NTFSAuditInheritance'; SwitchName = 'RemoveExplicitAuditRules'; Remove = $false } + @{ Command = 'Enable-NTFSAuditInheritance'; SwitchName = 'RemoveExplicitAuditRules'; Remove = $true } + ) + + # State: Set-NTFSInheritance -AuditInheritanceEnabled, read with the independent .NET audit oracle, not + # Get-NTFSInheritance/Get-NTFSAudit. + $setInheritanceAuditOracleCases = @( + @{ Enable = $false } + @{ Enable = $true } + ) } BeforeAll { @@ -144,6 +176,22 @@ BeforeAll { [System.IO.FileSystemAclExtensions]::GetAccessControl($info, $sections) } } + + function Protect-TestDaclWithoutModule { + # Protects the DACL of an item directly through .NET, keeping its inherited entries as explicit copies, so a + # test can read back a protected state that no NTFSSecurity cmdlet created. Not Set-Acl: see Remove-TestSandbox. + param ([string] $Path) + + $acl = Get-Acl -LiteralPath $Path + $acl.SetAccessRuleProtection($true, $true) + $item = Get-Item -LiteralPath $Path + if ($PSVersionTable.PSEdition -eq 'Core') { + [System.IO.FileSystemAclExtensions]::SetAccessControl($item, $acl) + } + else { + $item.SetAccessControl($acl) + } + } } AfterAll { @@ -749,3 +797,301 @@ Describe 'Pipeline: binding into the -Path and -SecurityDescriptor sets that no $byProperty[0].SecurityDescriptor.GetSecurityDescriptorSddlForm('Access') | Should -Be $expectedSddl } } + +Describe 'No -Path: the documented behavior of each cmdlet without a path or a security descriptor' { + # Push-Location moves the session into its own, empty sandbox folder first, so that even a cmdlet that wrongly + # fell back to the current location could only ever reach this folder, never one of another test. + It ' should do nothing without -Path, not even to the current location' -ForEach $noPathInheritanceCases { + if ($Scope -eq 'audit' -and -not $canChangeAudit) { + Set-ItResult -Skipped -Because 'reading the SACL back needs the Security privilege' + return + } + $here = New-TestSandboxItem -Sandbox $sandbox -Name 'NoPathLocation' -Directory + Push-Location -LiteralPath $here + try { + $before = (Get-Acl -LiteralPath $here).Sddl + $auditBefore = if ($Scope -eq 'audit') { (Get-TestAuditAcl -Path $here).GetSecurityDescriptorSddlForm('Audit') } + + $result = @(& $Command @Parameters -PassThru -ErrorVariable cmdErrors -ErrorAction SilentlyContinue) + + $cmdErrors | Should -BeNullOrEmpty + $result | Should -BeNullOrEmpty + (Get-Acl -LiteralPath $here).Sddl | Should -BeExactly $before + if ($Scope -eq 'audit') { + (Get-TestAuditAcl -Path $here).GetSecurityDescriptorSddlForm('Audit') | Should -BeExactly $auditBefore + } + } + finally { + Pop-Location + } + } + + It 'Set-NTFSOwner should do nothing without -Path, not even to the current location' { + $here = New-TestSandboxItem -Sandbox $sandbox -Name 'NoPathOwnerLocation' -Directory + Push-Location -LiteralPath $here + try { + $before = (Get-Acl -LiteralPath $here).Sddl + + $result = @(Set-NTFSOwner -Account $currentUser -PassThru -ErrorVariable cmdErrors -ErrorAction SilentlyContinue) + + $cmdErrors | Should -BeNullOrEmpty + $result | Should -BeNullOrEmpty + (Get-Acl -LiteralPath $here).Sddl | Should -BeExactly $before + } + finally { + Pop-Location + } + } + + It 'Get-NTFSOwner should return nothing without -Path, not even for the current location' { + $here = New-TestSandboxItem -Sandbox $sandbox -Name 'NoPathGetOwnerLocation' -Directory + Push-Location -LiteralPath $here + try { + $result = @(Get-NTFSOwner -ErrorVariable ownerErrors -ErrorAction SilentlyContinue) + + $ownerErrors | Should -BeNullOrEmpty + $result | Should -BeNullOrEmpty + } + finally { + Pop-Location + } + } + + It ' should report the current location when no -Path is given, not an empty result' -ForEach @( + @{ Command = 'Get-NTFSInheritance' } + @{ Command = 'Get-NTFSSecurityDescriptor' } + ) { + $here = New-TestSandboxItem -Sandbox $sandbox -Name 'CurrentLocationFallback' -Directory + Push-Location -LiteralPath $here + try { + $result = @(& $Command -ErrorVariable cmdErrors -ErrorAction SilentlyContinue) + + $cmdErrors | Should -BeNullOrEmpty + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $here + } + finally { + Pop-Location + } + } +} + +Describe 'Errors: OpenError category for a missing path, for the cmdlets not yet pinned by the existing OpenError test' { + It ' should report ReadFileError with category OpenError for a path that does not exist, and continue with the next path' -ForEach $missingPathOpenErrorPathSetCases { + $missing = Join-Path -Path $sandbox -ChildPath ('Missing-{0}' -f [guid]::NewGuid().ToString('N').Substring(0, 8)) + Assert-TestSandboxPath -Sandbox $sandbox -Path $missing + $next = New-TestSandboxItem -Sandbox $sandbox -Name 'NextAfterMissingPathSet' + + $output = @(& $Command -Path $missing, $next @Parameters -PassThru -ErrorVariable pathErrors -ErrorAction SilentlyContinue) + + $pathErrors | Should -HaveCount 1 + $pathErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadFileError,*' + $pathErrors[0].CategoryInfo.Category | Should -Be 'OpenError' + $pathErrors[0].TargetObject | Should -Be $missing + $output | Should -HaveCount 1 + $output[0].FullName | Should -Be $next + } +} + +Describe 'State: additional independent-oracle and unchanged-section coverage' { + It 'Disable-NTFSAccessInheritance -SecurityDescriptor should leave the owner unchanged once Set-NTFSSecurityDescriptor writes the protected DACL' { + $parent = New-TestSandboxItem -Sandbox $sandbox -Name 'DisableAccessOwnerParent' -Directory + $file = Join-Path -Path $parent -ChildPath 'Child' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + Add-NTFSAccess -Path $parent -Account 'S-1-5-21-1-2-3-4951' -AccessRights ReadData -ErrorAction Stop + Set-Content -LiteralPath $file -Value 'Child' + $ownerBefore = (Get-Acl -LiteralPath $file).GetOwner($sidType).Value + $sd = Get-NTFSSecurityDescriptor -Path $file + + Disable-NTFSAccessInheritance -SecurityDescriptor $sd -RemoveInheritedAccessRules -ErrorAction Stop + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop + + $acl = Get-Acl -LiteralPath $file + $acl.AreAccessRulesProtected | Should -BeTrue + $acl.GetOwner($sidType).Value | Should -Be $ownerBefore + } + + It 'Set-NTFSInheritance should set the SACL protected flag and audit-rule counts for enabled=, read independently with Get-Acl -Audit' -Skip:(-not $canChangeAudit) -ForEach $setInheritanceAuditOracleCases { + $parent = New-TestSandboxItem -Sandbox $sandbox -Name 'SetInheritanceAuditOracleParent' -Directory + $path = Join-Path -Path $parent -ChildPath 'Child' + Assert-TestSandboxPath -Sandbox $sandbox -Path $path + $parentAccount = 'S-1-5-21-1-2-3-4954' + Add-NTFSAudit -Path $parent -Account $parentAccount -AccessRights ReadData -AuditFlags Success -ErrorAction Stop + New-Item -ItemType Directory -Path $path | Out-Null + $inheritedBefore = @((Get-Acl -LiteralPath $path -Audit).GetAuditRules($false, $true, $sidType) | Where-Object -FilterScript { $_.IdentityReference.Value -eq $parentAccount }) + $inheritedBefore | Should -HaveCount 1 + if ($Enable) { + # -RemoveInheritedAuditRules, so re-enabling below restores exactly one entry, not a second one next to a + # leftover explicit copy (Enable alone keeps explicit entries, as Inheritance.Tests.ps1:422 pins). + Disable-NTFSAuditInheritance -Path $path -RemoveInheritedAuditRules -ErrorAction Stop + } + + $result = @(Set-NTFSInheritance -Path $path -AuditInheritanceEnabled $Enable -PassThru -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].AuditInheritanceEnabled | Should -Be $Enable + $acl = Get-Acl -LiteralPath $path -Audit + $acl.AreAuditRulesProtected | Should -Be (-not $Enable) + $parentRules = @($acl.GetAuditRules($true, $true, $sidType) | Where-Object -FilterScript { $_.IdentityReference.Value -eq $parentAccount }) + $parentRules | Should -HaveCount 1 + $parentRules[0].IsInherited | Should -Be $Enable + } + + It ' -SecurityDescriptor should change the in-memory audit entries per -:, applied to the item only after Set-NTFSSecurityDescriptor writes it, leaving the owner and DACL unchanged' -Skip:(-not $canChangeAudit) -ForEach $sdAuditSwitchCases { + $parent = New-TestSandboxItem -Sandbox $sandbox -Name 'SdAuditSwitchParent' -Directory + $file = Join-Path -Path $parent -ChildPath 'Child' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + Add-NTFSAudit -Path $parent -Account 'S-1-5-21-1-2-3-4952' -AccessRights ReadData -AuditFlags Success -ErrorAction Stop + Set-Content -LiteralPath $file -Value 'Child' + if ($Command -eq 'Enable-NTFSAuditInheritance') { + Add-NTFSAudit -Path $file -Account 'S-1-5-21-1-2-3-4953' -AccessRights Delete -AuditFlags Failure -AppliesTo ThisFolderOnly -ErrorAction Stop + Disable-NTFSAuditInheritance -Path $file -RemoveInheritedAuditRules -ErrorAction Stop + } + $before = (Get-Acl -LiteralPath $file).Sddl + $sd = Get-NTFSSecurityDescriptor -Path $file + $switchParameters = @{ $SwitchName = $Remove } + + & $Command -SecurityDescriptor $sd @switchParameters -ErrorAction Stop + + $sd.SecurityDescriptor.AreAuditRulesProtected | Should -Be ($Command -eq 'Disable-NTFSAuditInheritance') + (Get-Acl -LiteralPath $file).Sddl | Should -BeExactly $before + + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop + + $acl = Get-TestAuditAcl -Path $file + if ($Command -eq 'Disable-NTFSAuditInheritance') { + $acl.AreAuditRulesProtected | Should -BeTrue + $inherited = @($acl.GetAuditRules($false, $true, $sidType) | Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-4952' }) + $explicitCopy = @($acl.GetAuditRules($true, $false, $sidType) | Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-4952' }) + if ($Remove) { + $inherited | Should -BeNullOrEmpty + $explicitCopy | Should -BeNullOrEmpty + } + else { + $explicitCopy | Should -HaveCount 1 + } + } + else { + $acl.AreAuditRulesProtected | Should -BeFalse + $explicitChild = @($acl.GetAuditRules($true, $false, $sidType) | Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-4953' }) + if ($Remove) { $explicitChild | Should -BeNullOrEmpty } else { $explicitChild | Should -HaveCount 1 } + } + (Get-Acl -LiteralPath $file).Sddl | Should -BeExactly $before + } + + It 'Get-NTFSInheritance should name the item in the Name property and report a DACL protected without the module' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'InheritanceNameAndExternalProtect' + Protect-TestDaclWithoutModule -Path $file + (Get-Acl -LiteralPath $file).AreAccessRulesProtected | Should -BeTrue + + $result = Get-NTFSInheritance -Path $file -ErrorAction Stop + + $result.Name | Should -Be ([IO.Path]::GetFileName($file)) + $result.AccessInheritanceEnabled | Should -BeFalse + } + + It 'Get-NTFSInheritance -SecurityDescriptor should return a typed object for a descriptor protected without the module' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'SdInheritanceExternalProtect' + Protect-TestDaclWithoutModule -Path $file + $sd = Get-NTFSSecurityDescriptor -Path $file + + $result = Get-NTFSInheritance -SecurityDescriptor $sd -ErrorAction Stop + + $result | Should -BeOfType ([Security2.FileSystemInheritanceInfo]) + $result.AccessInheritanceEnabled | Should -BeFalse + } + + It 'Get-NTFSOwner -SecurityDescriptor should return the real owner of a descriptor that was read without its Owner section' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'OwnerSectionless' + $expected = (Get-Acl -LiteralPath $file).GetOwner($sidType).Value + $sd = New-Object -TypeName 'Security2.FileSystemSecurity2' -ArgumentList ( + (Get-Item2 -Path $file), [System.Security.AccessControl.AccessControlSections]::Access + ) + + $result = @(Get-NTFSOwner -SecurityDescriptor $sd -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0] | Should -BeOfType ([Security2.FileSystemOwner]) + $result[0].Owner.Sid | Should -Be $expected + $result[0].FullName | Should -Be $file + } + + It 'Set-NTFSOwner should leave the SACL unchanged after it changes the owner' -Skip:(-not $canChangeAudit) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'OwnerSaclUnchanged' + Add-NTFSAudit -Path $file -Account 'S-1-1-0' -AccessRights Delete -AuditFlags Success -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + $before = Get-Acl -LiteralPath $file + if ($before.GetOwner($sidType).Value -eq $currentUser) { + Set-ItResult -Skipped -Because 'the user already owns new items, so Set-NTFSOwner would not change the owner' + return + } + $auditBefore = (Get-TestAuditAcl -Path $file).GetSecurityDescriptorSddlForm('Audit') + + Set-NTFSOwner -Path $file -Account $currentUser -ErrorAction Stop + + (Get-Acl -LiteralPath $file).GetOwner($sidType).Value | Should -Be $currentUser + (Get-TestAuditAcl -Path $file).GetSecurityDescriptorSddlForm('Audit') | Should -BeExactly $auditBefore + } + + It 'Set-NTFSOwner -SecurityDescriptor should change only the owner on disk once Set-NTFSSecurityDescriptor writes it, leaving the DACL unchanged, and -PassThru should return a FileSystemOwner object' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'SdOwnerWriteDacl' + Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData -ErrorAction Stop + $ownerBefore = (Get-Acl -LiteralPath $file).GetOwner($sidType).Value + if ($ownerBefore -eq $currentUser) { + Set-ItResult -Skipped -Because 'the user already owns new items, so Set-NTFSOwner would not change the owner' + return + } + $daclBefore = (Get-Acl -LiteralPath $file).GetSecurityDescriptorSddlForm('Access') + $sd = Get-NTFSSecurityDescriptor -Path $file + + $result = @(Set-NTFSOwner -SecurityDescriptor $sd -Account $currentUser -PassThru -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0] | Should -BeOfType ([Security2.FileSystemOwner]) + (Get-Acl -LiteralPath $file).GetSecurityDescriptorSddlForm('Access') | Should -BeExactly $daclBefore + + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop + + $acl = Get-Acl -LiteralPath $file + $acl.GetOwner($sidType).Value | Should -Be $currentUser + $acl.GetSecurityDescriptorSddlForm('Access') | Should -BeExactly $daclBefore + } +} + +Describe 'Set-NTFSSecurityDescriptor: the by-property-name pipeline and a denied PassThru re-read' { + It 'Should take a descriptor by property name through the pipeline, not only by value' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'SdSetByPropertyName' + $sd = Get-NTFSSecurityDescriptor -Path $file + Add-NTFSAccess -SecurityDescriptor $sd -Account 'S-1-5-21-1-2-3-4955' -AccessRights Delete -ErrorAction Stop + + $result = @([pscustomobject]@{ SecurityDescriptor = $sd } | Set-NTFSSecurityDescriptor -PassThru -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $file + @(Get-NTFSAccess -Path $file -Account 'S-1-5-21-1-2-3-4955' -ExcludeInherited) | Should -HaveCount 1 + } + + Context 'When the written descriptor denies reading it again' { + BeforeAll { + $privateData['EnablePrivileges'] = $false + } + + AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges + } + + It 'Should report the PassThru ReadSecurityError with category ReadError and the item as target' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'PassThruReadDeniedCategory' + $sd = Get-NTFSSecurityDescriptor -Path $file + # A deny entry for OWNER RIGHTS replaces the right of the owner to read the security descriptor. + Add-NTFSAccess -SecurityDescriptor $sd -Account 'S-1-3-4' -AccessRights ReadPermissions -AccessType Deny -AppliesTo ThisFolderOnly -ErrorAction Stop + + $result = @(Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -PassThru -ErrorVariable setErrors -ErrorAction SilentlyContinue) + + $result | Should -BeNullOrEmpty + $setErrors | Should -HaveCount 1 + $setErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadSecurityError,*' + $setErrors[0].CategoryInfo.Category | Should -Be 'ReadError' + $setErrors[0].TargetObject.FullName | Should -Be $file + } + } +}