From b14c90b038aa608cbec437584f9652c03dfb702e Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 12:42:48 +0000 Subject: [PATCH 01/28] fix: guard remaining object and cmdlet behavior paths Reproduce and fix public rule paths, simplified audit comparisons and ReadData conversion, and boxed privilege equality. Add behavior guards for descriptor inheritance, unresolved identities, audit capability and recursive denial. Freeze this source for Release matrix measurement; final gate evidence and independent review follow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- .memory-bank/activeContext.md | 28 +- CHANGELOG.md | 10 + Docs/FAQ.md | 18 ++ ProcessPrivileges/PrivilegeAndAttributes.cs | 2 +- .../FileSystemAccessRule2.cs | 1 + .../FileSystemAuditRule2.cs | 1 + .../FileSystem/SimpleFileSystemAuditRule.cs | 5 +- Tests/Access.Tests.ps1 | 35 +++ Tests/Audit.Tests.ps1 | 101 +++++++ Tests/Inheritance.Tests.ps1 | 78 ++++++ Tests/ItemCmdlets.Tests.ps1 | 33 +++ Tests/ObjectApis.Tests.ps1 | 263 ++++++++++++++++++ 12 files changed, 561 insertions(+), 14 deletions(-) create mode 100644 Tests/ObjectApis.Tests.ps1 diff --git a/.memory-bank/activeContext.md b/.memory-bank/activeContext.md index 52c5dab..ce702e4 100644 --- a/.memory-bank/activeContext.md +++ b/.memory-bank/activeContext.md @@ -9,11 +9,15 @@ source: current task evidence ## Current focus -Quality-gate follow-up is implemented and validated locally on -`ai/quality-gate-coverage`, based on `d25647d` of #116. Code/test baseline -`3442194`, lab regression/acceptance `7594e0c`; final records follow. -No remote mutation. Architecture/cmdlet-design choices remain deferred; -Decision 22 is still proposed. Stable 5.0.0 is not ready (Decision 21). +Handoff 1 is in progress on `ai/quality-gate-paths`, from reviewed #117 +head `f11ff41`. Both stacked PRs are open and green; rc6 remains the latest +published candidate and 4.2.6 the stable Gallery version. Public rule-path, +simplified-audit, and boxed privilege-comparison defects were reproduced +and fixed test-first. New descriptor, native-identity, audit-capability and +recursive-denial guards pass focused checks. Frozen full-suite coverage, +complete path explanations and the requested independent review follow. +The shared lab and remotes are unchanged. Decisions 21/22 and stable 5.0.0 +remain gated; Decision 22 is proposed, not accepted. ## Evidence @@ -56,10 +60,10 @@ Decision 22 is still proposed. Stable 5.0.0 is not ready (Decision 21). ## Next step -1. Maintainer pushes/reviews this follow-up; retain separate commits and - stacked-PR merge order (15). #116's Decision 22 review remains required. -2. Integrate and pass CI, then publish/test the next candidate package. -3. Close the remaining-path inventory (918 points, 562 for finer review), - decide/provision the OS matrix, obtain or explicitly accept #34 feedback. -4. Only then release 5.0.0 through documented CI steps; never claim the - current coverage percentage alone meets the quality gate. +1. Finish Handoff 1: freeze Release source, prove characterization guards, + run all four configurations, classify every refreshed gap and review. +2. Send code fixes and persistent evidence to gate 3 before publication; + repeat affected packaged acceptance after integration. No local upload. +3. Retain stacked-PR order (15), obtain Decision 22 review, finish the OS + matrix and obtain or explicitly accept #34 feedback through other gates. +4. Do not release stable 5.0.0 or equate a percentage with gate closure. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b9045b..3c79c5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,16 @@ The format is based on ### Fixed +- Retain the supplied path in the public access- and audit-rule constructors + so their `FullName`, `Name`, and simplified audit conversions identify + the item +- Reduce `ReadData` to `Read` in simplified audit entries, and compare them + with audit entries rather than access entries, preserving equality with + themselves and with equivalent simplified audit objects +- Compare boxed privilege output values by their privilege and attributes; + the object overload rejected privilege values and recursively compared + an attributes enum instead + - Fix `Get-Help`, which showed only the syntax: ship the help file `en-US\NTFSSecurity.dll-Help.xml` generated from the cmdlet documentation, including the links that `Get-Help -Online` opens, instead of the outdated diff --git a/Docs/FAQ.md b/Docs/FAQ.md index 1232611..6ebfeb4 100644 --- a/Docs/FAQ.md +++ b/Docs/FAQ.md @@ -93,3 +93,21 @@ Compare-Object -ReferenceObject (Get-NTFSAccess -Path C:\Data\A) -DifferenceObje The same works for the entries of `Get-NTFSAudit`, with `AuditFlags` in place of `AccessControlType`. See [Get-NTFSAccess](Cmdlets/Get-NTFSAccess.md). + +## How do the public object APIs compare and convert entries? + +The public `FileSystemAccessRule2` and `FileSystemAuditRule2` constructors +that take a .NET rule and a string path retain that path in `FullName` and +its last component in `Name`. They do not read or change the item. This is +useful when an application constructs a rule before replaying it through +the public rule helpers. + +`ToSimpleFileSystemAuditRule2()` retains the path and account and reduces +`ReadData` to `Read`, like the simplified access-rule helper. Simplified +audit objects compare only with other simplified audit objects; they are +not equal to access objects. This does not change the reference-based +comparison of the full access and audit entries described above. + +The values returned by `Get-Privileges` compare by `Privilege` and +`PrivilegeAttributes`. Typed and boxed .NET comparisons agree, and an +unrelated object is not equal to a privilege value. diff --git a/ProcessPrivileges/PrivilegeAndAttributes.cs b/ProcessPrivileges/PrivilegeAndAttributes.cs index 065ade9..6ed6684 100644 --- a/ProcessPrivileges/PrivilegeAndAttributes.cs +++ b/ProcessPrivileges/PrivilegeAndAttributes.cs @@ -83,7 +83,7 @@ namespace ProcessPrivileges /// Value indicating whether this instance and a specified object are equal. public override bool Equals(object obj) { - return obj is PrivilegeAttributes ? this.Equals((PrivilegeAttributes)obj) : false; + return obj is PrivilegeAndAttributes ? this.Equals((PrivilegeAndAttributes)obj) : false; } /// Indicates whether this instance and another instance are equal. diff --git a/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.cs b/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.cs index bcba319..4b1a200 100644 --- a/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.cs +++ b/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.cs @@ -37,6 +37,7 @@ namespace Security2 public FileSystemAccessRule2(FileSystemAccessRule fileSystemAccessRule, string path) { this.fileSystemAccessRule = fileSystemAccessRule; + this.fullName = path; } public static implicit operator FileSystemAccessRule(FileSystemAccessRule2 ace2) diff --git a/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.cs b/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.cs index f8c729f..8699568 100644 --- a/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.cs +++ b/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.cs @@ -37,6 +37,7 @@ namespace Security2 public FileSystemAuditRule2(FileSystemAuditRule fileSystemAuditRule, string path) { this.fileSystemAuditRule = fileSystemAuditRule; + this.fullName = path; } #region Conversion diff --git a/Security2/FileSystem/SimpleFileSystemAuditRule.cs b/Security2/FileSystem/SimpleFileSystemAuditRule.cs index fbf2a58..d399186 100644 --- a/Security2/FileSystem/SimpleFileSystemAuditRule.cs +++ b/Security2/FileSystem/SimpleFileSystemAuditRule.cs @@ -42,6 +42,9 @@ namespace Security2 if ((accessRights & FileSystemRights2.Read) == FileSystemRights2.Read) { result |= SimpleFileSystemAccessRights.Read; } + if ((accessRights & FileSystemRights2.ReadData) == FileSystemRights2.ReadData) + { result |= SimpleFileSystemAccessRights.Read; } + if ((accessRights & FileSystemRights2.CreateFiles) == FileSystemRights2.CreateFiles) { result |= SimpleFileSystemAccessRights.Write; } @@ -109,7 +112,7 @@ namespace Security2 public override bool Equals(object obj) { - var compareObject = obj as SimpleFileSystemAccessRule; + var compareObject = obj as SimpleFileSystemAuditRule; if (compareObject == null) { diff --git a/Tests/Access.Tests.ps1 b/Tests/Access.Tests.ps1 index 32d0fa5..0c3efdc 100644 --- a/Tests/Access.Tests.ps1 +++ b/Tests/Access.Tests.ps1 @@ -955,3 +955,38 @@ Describe 'InheritedFrom of access entries' { } } } +Describe 'Get-NTFSEffectiveAccess for an unresolved identity' { + It 'Should report the native identity error for each and return no access entry' -ForEach @( + @{ Source = 'Path' } + @{ Source = 'SecurityDescriptor' } + ) { + $first = New-TestSandboxItem -Sandbox $sandbox -Name 'UnresolvedFirst' + $next = New-TestSandboxItem -Sandbox $sandbox -Name 'UnresolvedNext' + $identity = [Security2.IdentityReference2] 'S-1-5-21-1-2-3-1001' + $identity.AccountName | Should -BeNullOrEmpty + $identity.LastError | Should -Not -BeNullOrEmpty + $before = @((Get-Acl -LiteralPath $first).Sddl, (Get-Acl -LiteralPath $next).Sddl) + $parameters = @{ Account = $identity; WarningAction = 'SilentlyContinue'; ErrorAction = 'SilentlyContinue' } + if ($Source -eq 'Path') { + $parameters.Path = @($first, $next) + } + else { + $parameters.SecurityDescriptor = @(Get-NTFSSecurityDescriptor -Path $first, $next) + } + + $result = @(Get-NTFSEffectiveAccess @parameters -ErrorVariable accessErrors) + + $result | Should -BeNullOrEmpty + $accessErrors | Should -HaveCount 2 + for ($index = 0; $index -lt 2; $index++) { + $accessErrors[$index].FullyQualifiedErrorId | Should -BeLike 'GetEffectiveAccessError,*' + $accessErrors[$index].CategoryInfo.Category | Should -Be 'ReadError' + $accessErrors[$index].TargetObject.FullName | Should -BeExactly @($first, $next)[$index] + $cause = $accessErrors[$index].Exception.GetBaseException() + $cause | Should -BeOfType [System.ComponentModel.Win32Exception] + $cause.NativeErrorCode | Should -Be 1332 + } + (Get-Acl -LiteralPath $first).Sddl | Should -BeExactly $before[0] + (Get-Acl -LiteralPath $next).Sddl | Should -BeExactly $before[1] + } +} diff --git a/Tests/Audit.Tests.ps1 b/Tests/Audit.Tests.ps1 index f9fa2a8..e521adf 100644 --- a/Tests/Audit.Tests.ps1 +++ b/Tests/Audit.Tests.ps1 @@ -512,3 +512,104 @@ Describe 'InheritedFrom of audit entries' { $inherited[0].InheritedFrom | Should -Be $folder } } +Describe 'Audit changes with the Security privilege disabled' { + BeforeAll { + $holdsSecurityForOperations = Test-PrivilegeHeld -Name 'SeSecurityPrivilege' + } + + BeforeEach { + $savedEnablePrivileges = $privateData['EnablePrivileges'] + $securityWasEnabled = (Get-Privileges | Where-Object -Property Privilege -EQ -Value 'Security').PrivilegeState -eq 'Enabled' + } + + AfterEach { + $privateData['EnablePrivileges'] = $savedEnablePrivileges + if ($securityWasEnabled) { + $null = [ProcessPrivileges.ProcessExtensions]::EnablePrivilege( + [Diagnostics.Process]::GetCurrentProcess(), [ProcessPrivileges.Privilege]::Security + ) + } + else { + $null = [ProcessPrivileges.ProcessExtensions]::DisablePrivilege( + [Diagnostics.Process]::GetCurrentProcess(), [ProcessPrivileges.Privilege]::Security + ) + } + } + + It ' should use a held privilege or report a missing one and continue to the next path' -ForEach @( + @{ Command = 'Add-NTFSAudit'; ErrorId = 'AddAceError'; Parameters = @{ Account = 'S-1-1-0'; AccessRights = 'ReadData'; PassThru = $true } } + @{ Command = 'Remove-NTFSAudit'; ErrorId = 'RemoveAceError'; Parameters = @{ Account = 'S-1-1-0'; AccessRights = 'Delete'; PassThru = $true } } + @{ Command = 'Clear-NTFSAudit'; ErrorId = 'ClearAclError'; Parameters = @{ DisableInheritance = $true } } + @{ Command = 'Enable-NTFSAuditInheritance'; ErrorId = 'ModifySdError'; Parameters = @{ PassThru = $true; RemoveExplicitAuditRules = $true } } + @{ Command = 'Disable-NTFSAuditInheritance'; ErrorId = 'ModifySdError'; Parameters = @{ PassThru = $true; RemoveInheritedAuditRules = $true } } + ) { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'DisabledSecurity' + $missing = Join-Path -Path $sandbox -ChildPath ('MissingAudit-{0}' -f [guid]::NewGuid().ToString('N')) + Assert-TestSandboxPath -Sandbox $sandbox -Path $path, $missing + if ($holdsSecurityForOperations) { + $privateData['EnablePrivileges'] = $true + Add-NTFSAudit -Path $path -Account 'S-1-1-0' -AccessRights Delete -AuditFlags Success -AppliesTo ThisFolderOnly + $saclBefore = (Get-NTFSSecurityDescriptor -Path $path).SecurityDescriptor.GetSecurityDescriptorSddlForm('Audit') + } + $before = (Get-Acl -LiteralPath $path).Sddl + $privateData['EnablePrivileges'] = $false + $null = [ProcessPrivileges.ProcessExtensions]::DisablePrivilege( + [Diagnostics.Process]::GetCurrentProcess(), [ProcessPrivileges.Privilege]::Security + ) + + (Get-Privileges | Where-Object -Property Privilege -EQ -Value 'Security').PrivilegeState | Should -Not -Be 'Enabled' + + $result = @(& $Command -Path $path, $missing @Parameters -ErrorVariable auditErrors -ErrorAction SilentlyContinue) + + (Get-Acl -LiteralPath $path).Sddl | Should -BeExactly $before + if ($holdsSecurityForOperations) { + # AlphaFS temporarily enables a held Security privilege for SACL access, even with automatic privileges off. + (Get-Privileges | Where-Object -Property Privilege -EQ -Value 'Security').PrivilegeState | Should -Be 'Disabled' + $auditErrors | Should -HaveCount 1 + $auditErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadFileError,*' + $auditErrors[0].CategoryInfo.Category | Should -Be 'OpenError' + $auditErrors[0].TargetObject | Should -BeExactly $missing + $written = Get-NTFSSecurityDescriptor -Path $path + $rules = @($written.SecurityDescriptor.GetAuditRules( + $true, $false, [System.Security.Principal.SecurityIdentifier] + )) + switch ($Command) { + 'Add-NTFSAudit' { + $result | Should -Not -BeNullOrEmpty + $result | ForEach-Object { $_.FullName | Should -BeExactly $path } + @($rules | Where-Object { + $_.IdentityReference.Value -eq 'S-1-1-0' -and $_.FileSystemRights.HasFlag( + [System.Security.AccessControl.FileSystemRights]::ReadData + ) + }).Count | Should -BeGreaterThan 0 + } + 'Disable-NTFSAuditInheritance' { + $result | Should -HaveCount 1 + $result[0].AuditInheritanceEnabled | Should -BeFalse + $rules | Should -HaveCount 1 + $rules[0].FileSystemRights | Should -Be ([System.Security.AccessControl.FileSystemRights]::Delete) + } + 'Enable-NTFSAuditInheritance' { + $result | Should -HaveCount 1 + $result[0].AuditInheritanceEnabled | Should -BeTrue + $rules | Should -BeNullOrEmpty + } + default { + $result | Should -BeNullOrEmpty + $rules | Should -BeNullOrEmpty + } + } + $written.SecurityDescriptor.GetSecurityDescriptorSddlForm('Audit') | Should -Not -BeExactly $saclBefore + } + else { + $result | Should -BeNullOrEmpty + $auditErrors | Should -HaveCount 2 + $auditErrors[0].FullyQualifiedErrorId | Should -BeLike "$ErrorId,*" + $auditErrors[0].CategoryInfo.Category | Should -Be 'WriteError' + $auditErrors[0].TargetObject | Should -BeExactly $path + $auditErrors[1].FullyQualifiedErrorId | Should -BeLike 'ReadFileError,*' + $auditErrors[1].CategoryInfo.Category | Should -Be 'OpenError' + $auditErrors[1].TargetObject | Should -BeExactly $missing + } + } +} diff --git a/Tests/Inheritance.Tests.ps1 b/Tests/Inheritance.Tests.ps1 index a4e41fe..bf473cc 100644 --- a/Tests/Inheritance.Tests.ps1 +++ b/Tests/Inheritance.Tests.ps1 @@ -430,3 +430,81 @@ Describe 'Access inheritance cmdlets' { } } } +Describe 'Set-NTFSInheritance with an in-memory descriptor' { + It 'Should set access inheritance enabled= on a only when the descriptor is written' -ForEach @( + @{ Type = 'file'; Enable = $false } + @{ Type = 'file'; Enable = $true } + @{ Type = 'folder'; Enable = $false } + @{ Type = 'folder'; Enable = $true } + ) { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'DescriptorAccessState' -Directory:($Type -eq 'folder') + Assert-TestSandboxPath -Sandbox $sandbox -Path $path + Set-NTFSInheritance -Path $path -AccessInheritanceEnabled (-not $Enable) -ErrorAction Stop + Add-NTFSAccess -Path $path -Account 'S-1-1-0' -AccessRights ReadData -AppliesTo ThisFolderOnly + $before = (Get-Acl -LiteralPath $path).GetSecurityDescriptorSddlForm('Access') + $ownerBefore = (Get-Acl -LiteralPath $path).Owner + $sd = Get-NTFSSecurityDescriptor -Path $path + + $result = @(Set-NTFSInheritance -SecurityDescriptor $sd -AccessInheritanceEnabled $Enable -PassThru -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].FullName | Should -BeExactly $path + $result[0].Name | Should -BeExactly ([IO.Path]::GetFileName($path)) + $result[0].AccessInheritanceEnabled | Should -Be $Enable + $sd.SecurityDescriptor.AreAccessRulesProtected | Should -Be (-not $Enable) + (Get-Acl -LiteralPath $path).GetSecurityDescriptorSddlForm('Access') | Should -BeExactly $before + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop + (Get-Acl -LiteralPath $path).AreAccessRulesProtected | Should -Be (-not $Enable) + (Get-Acl -LiteralPath $path).Owner | Should -BeExactly $ownerBefore + @(Get-NTFSAccess -Path $path -ExcludeInherited | Where-Object { $_.Account.Sid -eq 'S-1-1-0' }) | + Should -HaveCount 1 + } + + It 'Should set audit inheritance enabled= on a without changing its DACL or owner' -Skip:(-not $canChangeAudit) -ForEach @( + @{ Type = 'file'; Enable = $false } + @{ Type = 'file'; Enable = $true } + @{ Type = 'folder'; Enable = $false } + @{ Type = 'folder'; Enable = $true } + ) { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'DescriptorAuditState' -Directory:($Type -eq 'folder') + Assert-TestSandboxPath -Sandbox $sandbox -Path $path + Set-NTFSInheritance -Path $path -AuditInheritanceEnabled (-not $Enable) -ErrorAction Stop + Add-NTFSAudit -Path $path -Account 'S-1-1-0' -AccessRights Delete -AuditFlags Success -AppliesTo ThisFolderOnly + $before = (Get-NTFSSecurityDescriptor -Path $path).SecurityDescriptor.GetSecurityDescriptorSddlForm('Audit') + $daclBefore = (Get-Acl -LiteralPath $path).GetSecurityDescriptorSddlForm('Access') + $ownerBefore = (Get-Acl -LiteralPath $path).Owner + $sd = Get-NTFSSecurityDescriptor -Path $path + + $result = @(Set-NTFSInheritance -SecurityDescriptor $sd -AuditInheritanceEnabled $Enable -PassThru -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].AuditInheritanceEnabled | Should -Be $Enable + $sd.SecurityDescriptor.AreAuditRulesProtected | Should -Be (-not $Enable) + (Get-NTFSSecurityDescriptor -Path $path).SecurityDescriptor.GetSecurityDescriptorSddlForm('Audit') | + Should -BeExactly $before + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop + (Get-NTFSInheritance -Path $path).AuditInheritanceEnabled | Should -Be $Enable + (Get-Acl -LiteralPath $path).GetSecurityDescriptorSddlForm('Access') | Should -BeExactly $daclBefore + (Get-Acl -LiteralPath $path).Owner | Should -BeExactly $ownerBefore + @(Get-NTFSAudit -Path $path -ExcludeInherited | Where-Object { $_.Account.Sid -eq 'S-1-1-0' }) | + Should -HaveCount 1 + } + + It 'Should keep audit inheritance unknown when requested enabled=<_> on an access-only descriptor' -ForEach @($false, $true) { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'DescriptorUnknownAudit' + $sd = New-Object -TypeName 'Security2.FileSystemSecurity2' -ArgumentList ( + (Get-Item2 -Path $path), [System.Security.AccessControl.AccessControlSections]::Access + ) + $before = (Get-Acl -LiteralPath $path).Sddl + + $result = @(Set-NTFSInheritance -SecurityDescriptor $sd -AuditInheritanceEnabled $_ -PassThru -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].AuditInheritanceEnabled | Should -BeNullOrEmpty + $raw = New-Object -TypeName 'System.Security.AccessControl.RawSecurityDescriptor' -ArgumentList ( + $sd.SecurityDescriptor.GetSecurityDescriptorBinaryForm(), 0 + ) + $null -eq $raw.SystemAcl | Should -BeTrue + (Get-Acl -LiteralPath $path).Sddl | Should -BeExactly $before + } +} diff --git a/Tests/ItemCmdlets.Tests.ps1 b/Tests/ItemCmdlets.Tests.ps1 index d21eea2..324098d 100644 --- a/Tests/ItemCmdlets.Tests.ps1 +++ b/Tests/ItemCmdlets.Tests.ps1 @@ -777,3 +777,36 @@ Describe 'Get-DiskSpace' { Should -Throw -ErrorId 'ParameterArgumentValidationError,NTFSSecurity.GetDiskSpace' } } +Describe 'Get-ChildItem2 when recursive enumeration becomes denied' { + It 'Should name the failed recursion in verbose output and continue with the next path' { + $root = New-TestSandboxItem -Sandbox $sandbox -Name 'ChangingReadPermission' -Directory + $child = Join-Path -Path $root -ChildPath 'Child' + $first = Join-Path -Path $root -ChildPath 'First.txt' + $nested = Join-Path -Path $child -ChildPath 'Nested.txt' + $next = New-TestSandboxItem -Sandbox $sandbox -Name 'NextRecursivePath' -Directory + $nextFile = Join-Path -Path $next -ChildPath 'Next.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $root, $child, $first, $nested, $nextFile + New-Item -ItemType Directory -Path $child | Out-Null + Set-Content -LiteralPath $first -Value 'First' + Set-Content -LiteralPath $nested -Value 'Nested' + Set-Content -LiteralPath $nextFile -Value 'Next' + $ownerBefore = (Get-Acl -LiteralPath $root).Owner + + # The first file is emitted before the separate recursive directory enumeration opens the folder again. + $records = @(Get-ChildItem2 -Path $root, $next -File -Recurse -Verbose -ErrorVariable childErrors -ErrorAction SilentlyContinue 4>&1 | + ForEach-Object { + if ($_ -is [Alphaleonis.Win32.Filesystem.FileInfo] -and $_.FullName -eq $first) { + Add-TestDenyRule -Sandbox $sandbox -Path $root -Rights @{ 'S-1-1-0' = 'ReadData' } + } + $_ + }) + + $childErrors | Should -BeNullOrEmpty + $files = @($records | Where-Object { $_ -is [Alphaleonis.Win32.Filesystem.FileInfo] }) + @($files.FullName | Sort-Object) | Should -Be @(@($first, $nextFile) | Sort-Object) + $messages = @($records | Where-Object { $_ -is [System.Management.Automation.VerboseRecord] }) + $messages.Message | Should -Contain "Cannot access folder '$root' for recursive operation" + (Get-Acl -LiteralPath $root).Owner | Should -BeExactly $ownerBefore + Get-Content -LiteralPath $nested | Should -BeExactly 'Nested' + } +} diff --git a/Tests/ObjectApis.Tests.ps1 b/Tests/ObjectApis.Tests.ps1 new file mode 100644 index 0000000..d5ea1dc --- /dev/null +++ b/Tests/ObjectApis.Tests.ps1 @@ -0,0 +1,263 @@ +<# + Tests the public object APIs used with cmdlet output, without changing an item's security descriptor. +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.' +)] +param () + +BeforeAll { + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force + $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\NTFSSecurity\bin\Release\NTFSSecurity.psd1' + Import-Module -Name $modulePath -Force -ErrorAction Stop + $sandbox = New-TestSandbox -Name 'ObjectApis' + $objectPath = Join-Path -Path $sandbox -ChildPath 'Rule.txt' + $identity = [Security2.IdentityReference2] 'S-1-1-0' + $sid = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList 'S-1-1-0' +} + +AfterAll { + Remove-TestSandbox -Sandbox $sandbox + Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue +} + +Describe 'Rule constructors with a path' { + It 'Should preserve the supplied path and name of an rule' -ForEach @( + @{ Kind = 'access' } + @{ Kind = 'audit' } + ) { + if ($Kind -eq 'access') { + $raw = New-Object -TypeName 'System.Security.AccessControl.FileSystemAccessRule' -ArgumentList ( + $sid, [System.Security.AccessControl.FileSystemRights]::ReadData, + [System.Security.AccessControl.AccessControlType]::Allow + ) + $rule = New-Object -TypeName 'Security2.FileSystemAccessRule2' -ArgumentList $raw, $objectPath + } + else { + $raw = New-Object -TypeName 'System.Security.AccessControl.FileSystemAuditRule' -ArgumentList ( + $sid, [System.Security.AccessControl.FileSystemRights]::ReadData, + [System.Security.AccessControl.AuditFlags]::Success + ) + $rule = New-Object -TypeName 'Security2.FileSystemAuditRule2' -ArgumentList $raw, $objectPath + } + + $rule.FullName | Should -BeExactly $objectPath + $rule.Name | Should -BeExactly 'Rule.txt' + $rule.InheritanceEnabled = $true + $rule.InheritedFrom = $sandbox + $rule.InheritanceEnabled | Should -BeTrue + $rule.InheritedFrom | Should -BeExactly $sandbox + $rule.GetHashCode() | Should -Be $raw.GetHashCode() + } +} + +Describe 'Simplified audit entries' { + It 'Should reduce to ' -ForEach @( + @{ Rights = 'None'; Expected = 'None' } + @{ Rights = 'ReadData'; Expected = 'Read' } + @{ Rights = 'Read'; Expected = 'Read' } + @{ Rights = 'CreateFiles'; Expected = 'Write' } + @{ Rights = 'AppendData'; Expected = 'Write' } + @{ Rights = 'ReadExtendedAttributes'; Expected = 'Read' } + @{ Rights = 'WriteExtendedAttributes'; Expected = 'Write' } + @{ Rights = 'ExecuteFile'; Expected = 'Read' } + @{ Rights = 'DeleteSubdirectoriesAndFiles'; Expected = 'Delete' } + @{ Rights = 'ReadAttributes'; Expected = 'Read' } + @{ Rights = 'WriteAttributes'; Expected = 'Write' } + @{ Rights = 'Delete'; Expected = 'Delete' } + @{ Rights = 'ReadPermissions'; Expected = 'Read' } + @{ Rights = 'ChangePermissions'; Expected = 'Write' } + @{ Rights = 'TakeOwnership'; Expected = 'Write' } + @{ Rights = 'Synchronize'; Expected = 'Read' } + @{ Rights = 'FullControl'; Expected = 'Read, Write, Delete' } + @{ Rights = 'GenericRead'; Expected = 'Read' } + @{ Rights = 'GenericWrite'; Expected = 'Write' } + @{ Rights = 'GenericExecute'; Expected = 'Read' } + @{ Rights = 'GenericAll'; Expected = 'Read, Write, Delete' } + ) { + $rule = New-Object -TypeName 'Security2.SimpleFileSystemAuditRule' -ArgumentList ( + $objectPath, $identity, [Security2.FileSystemRights2] $Rights + ) + + $rule.AccessRights | Should -Be ([Security2.SimpleFileSystemAccessRights] $Expected) + $rule.FullName | Should -BeExactly $objectPath + $rule.Name | Should -BeExactly 'Rule.txt' + $rule.Identity.Sid | Should -BeExactly 'S-1-1-0' + } + + It 'Should compare audit entries reflexively and symmetrically, never as access entries' { + $first = New-Object -TypeName 'Security2.SimpleFileSystemAuditRule' -ArgumentList ( + $objectPath, $identity, [Security2.FileSystemRights2]::Read + ) + $second = New-Object -TypeName 'Security2.SimpleFileSystemAuditRule' -ArgumentList ( + $objectPath, $identity, [Security2.FileSystemRights2]::Read + ) + $access = New-Object -TypeName 'Security2.SimpleFileSystemAccessRule' -ArgumentList ( + $objectPath, $identity, [Security2.FileSystemRights2]::Read, + [System.Security.AccessControl.AccessControlType]::Allow + ) + + $first.Equals($first) | Should -BeTrue + $first.Equals($second) | Should -BeTrue + $second.Equals($first) | Should -BeTrue + $first.GetHashCode() | Should -Be $second.GetHashCode() + $first.Equals($access) | Should -BeFalse + $access.Equals($first) | Should -BeFalse + $first.Equals($null) | Should -BeFalse + $first.Equals('Read') | Should -BeFalse + $second.AccessControlType = 'Deny' + $first.Equals($second) | Should -BeFalse + } + + It 'Should preserve the path, account and ReadData when converting an audit entry' { + $raw = New-Object -TypeName 'System.Security.AccessControl.FileSystemAuditRule' -ArgumentList ( + $sid, [System.Security.AccessControl.FileSystemRights]::ReadData, + [System.Security.AccessControl.AuditFlags]::Success + ) + $wrapped = New-Object -TypeName 'Security2.FileSystemAuditRule2' -ArgumentList $raw, $objectPath + + $simple = $wrapped.ToSimpleFileSystemAuditRule2() + + $simple.FullName | Should -BeExactly $objectPath + $simple.Identity.Sid | Should -BeExactly 'S-1-1-0' + $simple.AccessRights | Should -Be ([Security2.SimpleFileSystemAccessRights]::Read) + $wrapped.ToString() | Should -BeExactly $raw.ToString() + } +} + +Describe 'Identity comparisons and conversions' { + It 'Should compare with the identity by SID or resolved name' -ForEach @( + @{ Value = 'self'; Expected = $true } + @{ Value = 'same SID'; Expected = $true } + @{ Value = 'different SID'; Expected = $false } + @{ Value = 'SecurityIdentifier'; Expected = $true } + @{ Value = 'NTAccount'; Expected = $true } + @{ Value = 'SID string'; Expected = $true } + @{ Value = 'account name'; Expected = $true } + @{ Value = 'different string'; Expected = $false } + @{ Value = 'null'; Expected = $false } + @{ Value = 'other type'; Expected = $false } + ) { + $other = switch ($Value) { + 'self' { $identity } + 'same SID' { [Security2.IdentityReference2] 'S-1-1-0' } + 'different SID' { [Security2.IdentityReference2] 'S-1-5-32-546' } + 'SecurityIdentifier' { $sid } + 'NTAccount' { $sid.Translate([System.Security.Principal.NTAccount]) } + 'SID string' { 'S-1-1-0' } + 'account name' { $identity.AccountName.ToUpperInvariant() } + 'different string' { 'NTFSSecurity-not-an-account' } + 'null' { $null } + 'other type' { 42 } + } + + $identity.Equals($other) | Should -Be $Expected + } + + It 'Should compare null operands and distinct instances through both operators' { + $same = [Security2.IdentityReference2] 'S-1-1-0' + $different = [Security2.IdentityReference2] 'S-1-5-32-546' + + [Security2.IdentityReference2]::op_Equality($null, $null) | Should -BeTrue + [Security2.IdentityReference2]::op_Equality($identity, $null) | Should -BeFalse + [Security2.IdentityReference2]::op_Equality($null, $identity) | Should -BeFalse + [Security2.IdentityReference2]::op_Equality($identity, $same) | Should -BeTrue + [Security2.IdentityReference2]::op_Inequality($identity, $identity) | Should -BeFalse + [Security2.IdentityReference2]::op_Inequality($identity, $null) | Should -BeTrue + [Security2.IdentityReference2]::op_Inequality($null, $identity) | Should -BeTrue + [Security2.IdentityReference2]::op_Inequality($identity, $different) | Should -BeTrue + [Security2.IdentityReference2]::op_Inequality($identity, $same) | Should -BeFalse + $identity.GetHashCode() | Should -Be $same.GetHashCode() + } + + It 'Should round-trip native identities and the binary SID without changing the account' { + $account = $sid.Translate([System.Security.Principal.NTAccount]) + $fromSid = [Security2.IdentityReference2]::op_Explicit($sid) + $fromAccount = [Security2.IdentityReference2]::op_Explicit($account) + + $fromSid.Sid | Should -BeExactly 'S-1-1-0' + $fromAccount.Sid | Should -BeExactly $fromSid.Sid + ([System.Security.Principal.SecurityIdentifier] $fromSid).Value | Should -BeExactly 'S-1-1-0' + ([System.Security.Principal.NTAccount] $fromAccount).Value | Should -BeExactly $account.Value + $binarySid = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList ( + $fromSid.GetBinaryForm(), 0 + ) + $binarySid.Value | Should -BeExactly 'S-1-1-0' + } +} + +Describe 'Unrepresentable inheritance flags' { + It 'Should reject propagation flags without inheritance instead of inventing an AppliesTo value' { + $failure = $null + try { + $null = [Security2.FileSystemSecurity2]::ConvertToApplyTo('None', 'InheritOnly') + } + catch { + $failure = $_.Exception.GetBaseException() + } + + $failure | Should -BeOfType [Security2.RightsConverionException] + $failure.Message | Should -BeExactly 'The combination of InheritanceFlags and PropagationFlags could not be translated' + } +} +Describe 'Privilege output comparisons and formatting' { + It 'Should compare boxed and typed privilege values consistently without accepting an attributes enum' { + $values = @(Get-Privileges) + $values.Count | Should -BeGreaterThan 0 + $first = $values[0].PSObject.BaseObject + $copy = $values[0].PSObject.BaseObject + # PowerShell prefers the typed overload; reflection selects the public boxed-object contract explicitly. + $equalsObject = [ProcessPrivileges.PrivilegeAndAttributes].GetMethod('Equals', [type[]] @([object])) + + $equalsObject.Invoke($first, [object[]] @($copy)) | Should -BeTrue + $first.Equals($copy) | Should -BeTrue + [ProcessPrivileges.PrivilegeAndAttributes]::op_Equality($first, $copy) | Should -BeTrue + [ProcessPrivileges.PrivilegeAndAttributes]::op_Inequality($first, $copy) | Should -BeFalse + $first.GetHashCode() | Should -Be $copy.GetHashCode() + $equalsObject.Invoke($first, [object[]] @($null)) | Should -BeFalse + $equalsObject.Invoke($first, [object[]] @('Backup')) | Should -BeFalse + $equalsObject.Invoke($first, [object[]] @([ProcessPrivileges.PrivilegeAttributes]::Disabled)) | Should -BeFalse + } + + It 'Should format the collection with one aligned privilege and attributes row per value' { + $control = New-Object -TypeName 'Security2.PrivilegeControl' + $values = $control.GetPrivileges() + $expectedWidth = ($values | ForEach-Object { $_.Privilege.ToString().Length } | Measure-Object -Maximum).Maximum + + $text = $values.ToString() + + $rows = @($text.TrimEnd("`r", "`n") -split '\r?\n') + $rows | Should -HaveCount $values.Count + for ($index = 0; $index -lt $values.Count; $index++) { + $value = $values[$index] + $rows[$index] | Should -BeExactly ('{0} => {1}' -f $value.Privilege.ToString().PadRight($expectedWidth), + $value.PrivilegeAttributes) + } + } +} + +Describe 'Legacy effective-permission output objects' { + It 'Should retain a mask and report the supplied path and identity without a native access check' -ForEach @( + @{ Mask = 0; ObjectName = 'Effective.txt' } + @{ Mask = 1; ObjectName = 'Effective.txt' } + @{ Mask = 3; ObjectName = $null } + ) { + $path = if ($ObjectName) { Join-Path -Path $sandbox -ChildPath $ObjectName } else { $null } + $entry = New-Object -TypeName 'Security2.FileSystemEffectivePermissionEntry' -ArgumentList ( + $identity, [uint32] $Mask, $path + ) + + $entry.Account.Sid | Should -BeExactly 'S-1-1-0' + $entry.AccessMask | Should -Be $Mask + [int] $entry.AccessRights | Should -Be $Mask + $entry.FullName | Should -BeExactly ([string] $path) + $entry.Name | Should -BeExactly $ObjectName + $entry.AccessAsString | Should -Not -BeNullOrEmpty + if ($Mask -eq 0) { + $entry.AccessAsString | Should -Be @('None') + } + else { + $entry.AccessAsString | Should -Not -Contain 'None' + } + } +} From 73a0a7eae619fc7e1bedbbf49f051d5f8f70e525 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 13:38:35 +0000 Subject: [PATCH 02/28] test: exercise remaining descriptor and comparison outputs Cover descriptor PassThru wiring, audit clearing with inheritance protection, generic simplified access masks and unequal value branches. Keep source-backed untestable paths distinct from reachable output behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/Audit.Tests.ps1 | 25 +++++++++++++++ Tests/Inheritance.Tests.ps1 | 46 ++++++++++++++++++++++++++++ Tests/ObjectApis.Tests.ps1 | 61 +++++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+) diff --git a/Tests/Audit.Tests.ps1 b/Tests/Audit.Tests.ps1 index e521adf..57eb3b3 100644 --- a/Tests/Audit.Tests.ps1 +++ b/Tests/Audit.Tests.ps1 @@ -613,3 +613,28 @@ Describe 'Audit changes with the Security privilege disabled' { } } } +Describe 'Clear-NTFSAudit descriptor inheritance' { + It 'Should clear and protect the descriptor SACL without writing the ' -Skip:(-not $canReadAudit) -ForEach @( + @{ Type = 'file' } + @{ Type = 'folder' } + ) { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'ClearAuditDescriptor' -Directory:($Type -eq 'folder') + Assert-TestSandboxPath -Sandbox $sandbox -Path $path + Add-NTFSAudit -Path $path -Account 'S-1-1-0' -AccessRights Delete -AuditFlags Success -AppliesTo ThisFolderOnly + $before = Get-NTFSSecurityDescriptor -Path $path + $auditBefore = $before.SecurityDescriptor.GetSecurityDescriptorSddlForm('Audit') + $daclBefore = (Get-Acl -LiteralPath $path).Sddl + $sd = Get-NTFSSecurityDescriptor -Path $path + + Clear-NTFSAudit -SecurityDescriptor $sd -DisableInheritance -ErrorAction Stop + + $sd.SecurityDescriptor.AreAuditRulesProtected | Should -BeTrue + @($sd.SecurityDescriptor.GetAuditRules($true, $true, $sidType)) | Should -BeNullOrEmpty + (Get-NTFSSecurityDescriptor -Path $path).SecurityDescriptor.GetSecurityDescriptorSddlForm('Audit') | + Should -BeExactly $auditBefore + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop + (Get-NTFSInheritance -Path $path).AuditInheritanceEnabled | Should -BeFalse + @(Get-NTFSAudit -Path $path) | Should -BeNullOrEmpty + (Get-Acl -LiteralPath $path).Sddl | Should -BeExactly $daclBefore + } +} diff --git a/Tests/Inheritance.Tests.ps1 b/Tests/Inheritance.Tests.ps1 index bf473cc..e50746d 100644 --- a/Tests/Inheritance.Tests.ps1 +++ b/Tests/Inheritance.Tests.ps1 @@ -508,3 +508,49 @@ Describe 'Set-NTFSInheritance with an in-memory descriptor' { (Get-Acl -LiteralPath $path).Sddl | Should -BeExactly $before } } +Describe 'Dedicated inheritance descriptor output' { + It ' should return the changed descriptor state without writing the ' -ForEach @( + @{ Command = 'Enable-NTFSAccessInheritance'; Type = 'file'; Enable = $true } + @{ Command = 'Enable-NTFSAccessInheritance'; Type = 'folder'; Enable = $true } + @{ Command = 'Disable-NTFSAccessInheritance'; Type = 'file'; Enable = $false } + @{ Command = 'Disable-NTFSAccessInheritance'; Type = 'folder'; Enable = $false } + ) { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'DedicatedAccessDescriptor' -Directory:($Type -eq 'folder') + Assert-TestSandboxPath -Sandbox $sandbox -Path $path + Set-NTFSInheritance -Path $path -AccessInheritanceEnabled (-not $Enable) -ErrorAction Stop + $before = (Get-Acl -LiteralPath $path).Sddl + $sd = Get-NTFSSecurityDescriptor -Path $path + + $result = @(& $Command -SecurityDescriptor $sd -PassThru -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].FullName | Should -BeExactly $path + $result[0].AccessInheritanceEnabled | Should -Be $Enable + (Get-Acl -LiteralPath $path).Sddl | Should -BeExactly $before + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop + (Get-Acl -LiteralPath $path).AreAccessRulesProtected | Should -Be (-not $Enable) + } + + It ' should return the changed audit state without writing the ' -Skip:(-not $canChangeAudit) -ForEach @( + @{ Command = 'Enable-NTFSAuditInheritance'; Type = 'file'; Enable = $true } + @{ Command = 'Enable-NTFSAuditInheritance'; Type = 'folder'; Enable = $true } + @{ Command = 'Disable-NTFSAuditInheritance'; Type = 'file'; Enable = $false } + @{ Command = 'Disable-NTFSAuditInheritance'; Type = 'folder'; Enable = $false } + ) { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'DedicatedAuditDescriptor' -Directory:($Type -eq 'folder') + Assert-TestSandboxPath -Sandbox $sandbox -Path $path + Set-NTFSInheritance -Path $path -AuditInheritanceEnabled (-not $Enable) -ErrorAction Stop + $before = (Get-NTFSSecurityDescriptor -Path $path).SecurityDescriptor.GetSecurityDescriptorSddlForm('Audit') + $sd = Get-NTFSSecurityDescriptor -Path $path + + $result = @(& $Command -SecurityDescriptor $sd -PassThru -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].FullName | Should -BeExactly $path + $result[0].AuditInheritanceEnabled | Should -Be $Enable + (Get-NTFSSecurityDescriptor -Path $path).SecurityDescriptor.GetSecurityDescriptorSddlForm('Audit') | + Should -BeExactly $before + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop + (Get-NTFSInheritance -Path $path).AuditInheritanceEnabled | Should -Be $Enable + } +} diff --git a/Tests/ObjectApis.Tests.ps1 b/Tests/ObjectApis.Tests.ps1 index d5ea1dc..bf508ca 100644 --- a/Tests/ObjectApis.Tests.ps1 +++ b/Tests/ObjectApis.Tests.ps1 @@ -261,3 +261,64 @@ Describe 'Legacy effective-permission output objects' { } } } +Describe 'Simplified entry comparison branches' { + It 'Should distinguish identities, rights and types in entries and keep equal hashes consistent' -ForEach @( + @{ Kind = 'access' } + @{ Kind = 'audit' } + ) { + $typeName = if ($Kind -eq 'access') { 'Security2.SimpleFileSystemAccessRule' } else { 'Security2.SimpleFileSystemAuditRule' } + $arguments = @($objectPath, $identity, [Security2.FileSystemRights2]::Read) + if ($Kind -eq 'access') { $arguments += [System.Security.AccessControl.AccessControlType]::Allow } + $first = New-Object -TypeName $typeName -ArgumentList $arguments + $equal = New-Object -TypeName $typeName -ArgumentList $arguments + $arguments[1] = [Security2.IdentityReference2] 'S-1-5-32-546' + $differentIdentity = New-Object -TypeName $typeName -ArgumentList $arguments + $arguments[1] = $identity + $arguments[2] = [Security2.FileSystemRights2]::Delete + $differentRights = New-Object -TypeName $typeName -ArgumentList $arguments + + $first.Equals($equal) | Should -BeTrue + $first.GetHashCode() | Should -Be $equal.GetHashCode() + $first.Equals($differentIdentity) | Should -BeFalse + $first.Equals($differentRights) | Should -BeFalse + $first.Equals($null) | Should -BeFalse + $equal.AccessControlType = 'Deny' + $first.Equals($equal) | Should -BeFalse + $first.Name | Should -BeExactly 'Rule.txt' + } + + It 'Should reduce the generic mask in access entries' -ForEach @( + @{ Rights = 'GenericRead'; Expected = 'Read' } + @{ Rights = 'GenericWrite'; Expected = 'Write' } + @{ Rights = 'GenericExecute'; Expected = 'Read' } + @{ Rights = 'GenericAll'; Expected = 'Read, Write, Delete' } + ) { + $entry = New-Object -TypeName 'Security2.SimpleFileSystemAccessRule' -ArgumentList ( + $objectPath, $identity, [Security2.FileSystemRights2] $Rights, + [System.Security.AccessControl.AccessControlType]::Allow + ) + + $entry.AccessRights | Should -Be ([Security2.SimpleFileSystemAccessRights] $Expected) + } + + It 'Should convert an identity implicitly to its resolved display name and reject an unresolved name' { + $conversion = [Security2.IdentityReference2].GetMethods([Reflection.BindingFlags] 'Public, Static') | + Where-Object { $_.Name -eq 'op_Implicit' -and $_.ReturnType -eq [string] } + $conversion.Invoke($null, [object[]] @($identity.PSObject.BaseObject)) | Should -BeExactly $identity.ToString() + $unresolved = [Security2.IdentityReference2] 'S-1-5-21-1-2-3-1001' + $unresolved.Equals('Not-resolved') | Should -BeFalse + $identity.Equals($identity.AccountName) | Should -BeTrue + } + + It 'Should compare different privilege values as unequal' { + $constructor = [ProcessPrivileges.PrivilegeAndAttributes].GetConstructor( + [Reflection.BindingFlags] 'NonPublic, Instance', $null, + [type[]] @([ProcessPrivileges.Privilege], [ProcessPrivileges.PrivilegeAttributes]), $null + ) + $first = $constructor.Invoke(@([ProcessPrivileges.Privilege]::Backup, [ProcessPrivileges.PrivilegeAttributes]::Disabled)) + $different = $constructor.Invoke(@([ProcessPrivileges.Privilege]::Restore, [ProcessPrivileges.PrivilegeAttributes]::Disabled)) + + $first.Equals($different) | Should -BeFalse + [ProcessPrivileges.PrivilegeAndAttributes]::op_Inequality($first, $different) | Should -BeTrue + } +} From c7a03836449ea8e26f333d9a308393738dc39247 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 14:19:32 +0000 Subject: [PATCH 03/28] fix: skip setting back an owner that did not change Clear-NTFSAccess -DisableInheritance and Set-NTFSSecurityDescriptor took ownership of an item that the user owned already, left a DACL without the right to set an owner, and then reported a RestoreOwnerError for setting the same owner back. Skip the restore when the previous owner is the current user. The guards fail without the fix in all four configurations and also cover the owner that cannot be set back without the Restore privilege, the missing path of Get-ChildItem2, and the descriptor write that retries as owner. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- CHANGELOG.md | 5 +++ NTFSSecurity/BaseCmdlets.cs | 20 ++++++---- .../SetSecurityDescriptor.cs | 9 +++-- Tests/PathErrors.Tests.ps1 | 38 +++++++++++++++++++ Tests/SecurityDescriptor.Tests.ps1 | 38 +++++++++++++++++++ 5 files changed, 100 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c79c5c..f97915f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,6 +113,11 @@ The format is based on - Compare boxed privilege output values by their privilege and attributes; the object overload rejected privilege values and recursively compared an attributes enum instead +- Fix `Clear-NTFSAccess -DisableInheritance` and `Set-NTFSSecurityDescriptor`, + which reported a `RestoreOwnerError` for an owner that had not changed: + after they took ownership of an item that the user owned already and left a + DACL without the right to set an owner, they failed to set the same owner + back - Fix `Get-Help`, which showed only the syntax: ship the help file `en-US\NTFSSecurity.dll-Help.xml` generated from the cmdlet documentation, diff --git a/NTFSSecurity/BaseCmdlets.cs b/NTFSSecurity/BaseCmdlets.cs index 9dd30f1..07e7040 100644 --- a/NTFSSecurity/BaseCmdlets.cs +++ b/NTFSSecurity/BaseCmdlets.cs @@ -192,6 +192,8 @@ namespace NTFSSecurity /// /// Takes ownership of the item, runs the action, and restores the previous owner on every exit path. /// A failure to restore the owner is written as a RestoreOwnerError and doesn't hide an error of the action. + /// An owner that the action did not change, because the current user owned the item already, isn't set again: + /// an action such as clearing the DACL can leave nobody the right to do so. /// /// The file or folder to take ownership of. /// The path the user specified, used as the error target. @@ -199,8 +201,9 @@ namespace NTFSSecurity protected void InvokeAsOwner(Alphaleonis.Win32.Filesystem.FileSystemInfo item, string path, Action action) { var previousOwner = FileSystemOwner.GetOwner(item).Owner; + IdentityReference2 currentUser = System.Security.Principal.WindowsIdentity.GetCurrent().User; - FileSystemOwner.SetOwner(item, System.Security.Principal.WindowsIdentity.GetCurrent().User); + FileSystemOwner.SetOwner(item, currentUser); try { @@ -208,13 +211,16 @@ namespace NTFSSecurity } finally { - try + if (previousOwner != currentUser) { - FileSystemOwner.SetOwner(item, previousOwner); - } - catch (Exception ex) - { - WriteError(new ErrorRecord(ex, "RestoreOwnerError", ErrorCategory.WriteError, path)); + try + { + FileSystemOwner.SetOwner(item, previousOwner); + } + catch (Exception ex) + { + WriteError(new ErrorRecord(ex, "RestoreOwnerError", ErrorCategory.WriteError, path)); + } } } } diff --git a/NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs b/NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs index 9b6f299..b3a6100 100644 --- a/NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs +++ b/NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs @@ -88,13 +88,16 @@ namespace NTFSSecurity } // Like InvokeAsOwner, takes ownership for the write and sets the previous owner back on every exit path, but not - // after a successful write of a descriptor that sets the owner itself, which would undo that owner. + // after a successful write of a descriptor that sets the owner itself, which would undo that owner, and not when the + // current user owned the item already, so that nothing changed and a descriptor that leaves nobody the right to set + // an owner can't make it fail. private void WriteChangesAsOwner(FileSystemSecurity2 sd) { var setsOwner = (sd.ChangedSections & AccessControlSections.Owner) == AccessControlSections.Owner; var previousOwner = FileSystemOwner.GetOwner(sd.Item).Owner; + IdentityReference2 currentUser = System.Security.Principal.WindowsIdentity.GetCurrent().User; - FileSystemOwner.SetOwner(sd.Item, System.Security.Principal.WindowsIdentity.GetCurrent().User); + FileSystemOwner.SetOwner(sd.Item, currentUser); var written = false; try @@ -104,7 +107,7 @@ namespace NTFSSecurity } finally { - if (!(written && setsOwner)) + if (!(written && setsOwner) && previousOwner != currentUser) { try { diff --git a/Tests/PathErrors.Tests.ps1 b/Tests/PathErrors.Tests.ps1 index eb613bd..96fcee5 100644 --- a/Tests/PathErrors.Tests.ps1 +++ b/Tests/PathErrors.Tests.ps1 @@ -35,6 +35,7 @@ BeforeDiscovery { @{ Command = 'Set-NTFSOwner'; Parameters = @{ Account = $currentUser }; ErrorId = 'ReadFileError'; Output = $false } @{ Command = 'Get-NTFSSecurityDescriptor'; Parameters = @{}; ErrorId = 'ReadFileError'; Output = $true } @{ Command = 'Get-Item2'; Parameters = @{}; ErrorId = 'FileNotFound'; Output = $true } + @{ Command = 'Get-ChildItem2'; Parameters = @{}; ErrorId = 'FileNotFound'; Output = $true } @{ Command = 'Get-FileHash2'; Parameters = @{}; ErrorId = 'ReadFileError'; Output = $true } @{ Command = 'Get-NTFSHardLink'; Parameters = @{}; ErrorId = 'FileNotFound'; Output = $true } ) @@ -259,6 +260,43 @@ Describe 'An item whose owner may not change its permissions' { @($acl.GetAccessRules($true, $false, $sidType)) | Should -BeNullOrEmpty } + # With the DACL cleared and protected, nobody keeps the right to set an owner, so setting the previous owner back + # fails. The user owned the item already, so there is no owner to set back. + It 'Clear-NTFSAccess -DisableInheritance should take ownership, clear and protect the DACL, and not set an unchanged owner back' { + $user = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + Set-TestOwner -Sandbox $sandbox -Path $file -Sid $user + Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData + Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-3-4' = 'ChangePermissions' } + + Clear-NTFSAccess -Path $file -DisableInheritance -ErrorVariable changeErrors -ErrorAction SilentlyContinue + + $changeErrors | Should -BeNullOrEmpty + $acl = Get-TestAcl -Path $file + $acl.GetOwner($sidType).Value | Should -Be $user + $acl.AreAccessRulesProtected | Should -BeTrue + @($acl.GetAccessRules($true, $true, $sidType)) | Should -BeNullOrEmpty + } + + # Windows lets the owner of an item set another owner only with the Restore privilege, which the tests turn off, or + # 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 + Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData + Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-3-4' = 'ChangePermissions' } + + Clear-NTFSAccess -Path $file -DisableInheritance -ErrorVariable changeErrors -ErrorAction SilentlyContinue + + $changeErrors | Should -HaveCount 1 + $changeErrors[0].FullyQualifiedErrorId | Should -BeLike 'RestoreOwnerError,*' + $changeErrors[0].CategoryInfo.Category | Should -Be 'WriteError' + $changeErrors[0].TargetObject | Should -Be $file + $acl = Get-TestAcl -Path $file + $acl.GetOwner($sidType).Value | Should -Be $user + $acl.AreAccessRulesProtected | Should -BeTrue + @($acl.GetAccessRules($true, $true, $sidType)) | Should -BeNullOrEmpty + } + It 'Disable-NTFSAccessInheritance should take ownership, protect the DACL, and set the owner back' { Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-3-4' = 'ChangePermissions' } diff --git a/Tests/SecurityDescriptor.Tests.ps1 b/Tests/SecurityDescriptor.Tests.ps1 index d60cb25..b77a470 100644 --- a/Tests/SecurityDescriptor.Tests.ps1 +++ b/Tests/SecurityDescriptor.Tests.ps1 @@ -225,6 +225,44 @@ Describe 'Set-NTFSSecurityDescriptor' { $result[0].FullName | Should -Be $file $result[0].SecurityDescriptor.GetOwner($sidType).Value | Should -Be 'S-1-5-32-544' } + + # With a cleared, protected DACL, nobody keeps the right to set an owner, so setting the previous owner back would + # fail. The user owned the item already, so there is no owner to set back. + It 'Should not report an owner that did not change when the write that took ownership leaves an empty DACL' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'RetryUnchangedOwner' + Set-TestOwner -Sandbox $sandbox -Path $file -Sid $currentUser + Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-3-4' = 'ChangePermissions' } + $sd = Get-NTFSSecurityDescriptor -Path $file + Clear-NTFSAccess -SecurityDescriptor $sd -DisableInheritance -ErrorAction Stop + + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorVariable setErrors -ErrorAction SilentlyContinue + + $setErrors | Should -BeNullOrEmpty + $acl = Get-Acl -LiteralPath $file + $acl.GetOwner($sidType).Value | Should -Be $currentUser + $acl.AreAccessRulesProtected | Should -BeTrue + @($acl.GetAccessRules($true, $true, $sidType)) | Should -BeNullOrEmpty + } + + # Without the Restore privilege, the user can't set an owner such as TrustedInstaller back. The cmdlet reports it + # after it wrote the descriptor. + It 'Should report RestoreOwnerError for a previous owner that it cannot set back after the write' -Skip:(-not $canAssignAnyOwner) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'RetryRestoreDenied' + Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ $currentUser = 'ChangePermissions' } + Set-TestOwner -Sandbox $sandbox -Path $file -Sid $trustedInstaller + Get-RestorePrivilegeState | Should -Be 'Disabled' + $sd = Get-NTFSSecurityDescriptor -Path $file + Add-NTFSAccess -SecurityDescriptor $sd -Account 'Everyone' -AccessRights ReadData + + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorVariable setErrors -ErrorAction SilentlyContinue + + $setErrors | Should -HaveCount 1 + $setErrors[0].FullyQualifiedErrorId | Should -BeLike 'RestoreOwnerError,*' + $setErrors[0].CategoryInfo.Category | Should -Be 'WriteError' + $setErrors[0].TargetObject.FullName | Should -Be $file + @(Get-EveryoneRule -Path $file) | Should -HaveCount 1 + (Get-Acl -LiteralPath $file).GetOwner($sidType).Value | Should -Be $currentUser + } } Context 'A descriptor that cannot be written' { From 360417a5c3cee53523a62fbc8e647ef0bfca8fb4 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 14:19:33 +0000 Subject: [PATCH 04/28] test: exercise item, link and descriptor failure paths Cover locked and denied copy and move, recursive enumeration that stops or meets a broken junction, hard link counts on a network share, denied link creation, the default root folder of a drive root, ownerless -PassThru and an undefined hash algorithm. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/Access.Tests.ps1 | 13 ++++ Tests/FileHash.Tests.ps1 | 11 ++++ Tests/ItemCmdlets.Tests.ps1 | 126 ++++++++++++++++++++++++++++++++++++ Tests/Links.Tests.ps1 | 53 +++++++++++++++ Tests/Owner.Tests.ps1 | 14 ++++ 5 files changed, 217 insertions(+) diff --git a/Tests/Access.Tests.ps1 b/Tests/Access.Tests.ps1 index 0c3efdc..7e2b81d 100644 --- a/Tests/Access.Tests.ps1 +++ b/Tests/Access.Tests.ps1 @@ -388,6 +388,19 @@ Describe 'Get-NTFSSimpleAccess' { @($result | Where-Object -Property FullName -EQ -Value $root) | Should -HaveCount $rootAlone.Count } + # With -IncludeRootFolder, the default, the cmdlet reports the parent folder of the first path first. A drive root + # has none, so it reports the root itself, once. The test reads the entries of the drive root only. + It 'Should report no parent folder in front of a drive root by default' { + $root = [IO.Path]::GetPathRoot($child) + $rootAlone = @(Get-NTFSSimpleAccess -Path $root -IncludeRootFolder:$false -ErrorAction Stop) + + $result = @(Get-NTFSSimpleAccess -Path $root -ErrorVariable simpleErrors -ErrorAction SilentlyContinue) + + $simpleErrors | Should -BeNullOrEmpty + $result | Should -HaveCount $rootAlone.Count + $result | ForEach-Object -Process { $_.FullName | Should -Be $root } + } + # Windows doesn't distinguish paths by case. Before 5.0.0-rc7, the cmdlet didn't recognize the parent folder of a # folder whose path differed from it in case, and left the folder out. It 'Should compare a folder with its parent folder also when their paths differ in case' { diff --git a/Tests/FileHash.Tests.ps1 b/Tests/FileHash.Tests.ps1 index 821ed2f..04184d1 100644 --- a/Tests/FileHash.Tests.ps1 +++ b/Tests/FileHash.Tests.ps1 @@ -61,6 +61,17 @@ Describe 'Get-FileHash2' { $hashError.FullyQualifiedErrorId | Should -BeLike 'HashAlgorithmNotAvailable,*' } + # PowerShell binds only the named algorithms to -Algorithm, so a program that calls the public method with an + # undefined value is the only way to get here. + It 'Should refuse an algorithm that the enumeration does not define when the public method creates it' { + $unknown = [Enum]::ToObject([Security2.FileSystem.FileInfo.HashAlgorithms], 99) + + $failure = { [Security2.FileSystem.FileInfo.Extensions]::CreateHashAlgorithm($unknown) } | Should -Throw -PassThru + + $failure.Exception.GetBaseException() | Should -BeOfType [System.ArgumentOutOfRangeException] + $failure.Exception.GetBaseException().ParamName | Should -BeExactly 'algorithm' + } + It 'Should warn once that MACTripleDES is deprecated' -Skip:$isCore { $results = @(Get-FileHash2 -Path $first, $second -Algorithm MACTripleDES -WarningVariable hashWarnings -WarningAction SilentlyContinue) diff --git a/Tests/ItemCmdlets.Tests.ps1 b/Tests/ItemCmdlets.Tests.ps1 index 324098d..6c194a1 100644 --- a/Tests/ItemCmdlets.Tests.ps1 +++ b/Tests/ItemCmdlets.Tests.ps1 @@ -146,6 +146,32 @@ Describe 'Get-ChildItem2' { $result | Should -HaveCount 1 $childErrors | Should -BeNullOrEmpty } + + # The second file comes from a sub folder, so the pipeline stops while the cmdlet is inside the recursion. + It 'Should stop a recursive pipeline inside a sub folder without recording an enumeration error' { + $result = @(Get-ChildItem2 -Path $tree -Recurse -File -ErrorVariable childErrors -ErrorAction SilentlyContinue | Select-Object -First 2) + + $result | Should -HaveCount 2 + $childErrors | Should -BeNullOrEmpty + } + + # A break or continue in a later pipeline stage passes through the cmdlet as an exception, which it must not + # report as a failed folder. + It 'Should end a recursive enumeration for in a later pipeline stage without recording an enumeration error' -ForEach @( + @{ Keyword = 'break' } + @{ Keyword = 'continue' } + ) { + $names = [System.Collections.Generic.List[string]]::new() + foreach ($round in 1) { + Get-ChildItem2 -Path $tree -Recurse -File -ErrorVariable childErrors -ErrorAction SilentlyContinue | ForEach-Object -Process { + $names.Add($_.Name) + if ($Keyword -eq 'break') { break } else { continue } + } + } + + $names | Should -HaveCount 1 + $childErrors | Should -BeNullOrEmpty + } } Context 'Unreadable directories' { @@ -200,6 +226,31 @@ Describe 'Get-ChildItem2' { $result[0].FullName | Should -Be $link Get-Content -LiteralPath $file | Should -Be 'Target' } + + # A junction whose target is gone passes the existence check, but the folder behind it can't be opened. The + # error belongs to that folder, and the enumeration goes on with the next one. + It 'Should report a junction whose target was removed as a DirUnspecifiedError and continue with the next folder' { + $root = New-TestSandboxItem -Sandbox $sandbox -Name 'BrokenJunction' -Directory + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'RemovedTarget' -Directory + $link = Join-Path -Path $root -ChildPath 'Broken' + $sibling = Join-Path -Path $root -ChildPath 'Sibling' + $file = Join-Path -Path $sibling -ChildPath 'Sibling.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link, $sibling, $file + New-Item -ItemType Directory -Path $sibling | Out-Null + Set-Content -LiteralPath $file -Value 'Sibling' + New-Item -ItemType Junction -Path $link -Value $target | Out-Null + Remove-Item -LiteralPath $target -Force + + $result = @(Get-ChildItem2 -Path $root -Recurse -ErrorVariable childErrors -ErrorAction SilentlyContinue) + + $childErrors | Should -HaveCount 1 + $childErrors[0].FullyQualifiedErrorId | Should -BeLike 'DirUnspecifiedError,*' + $childErrors[0].CategoryInfo.Category | Should -Be 'NotSpecified' + $childErrors[0].TargetObject | Should -Be $link + $childErrors[0].Exception | Should -BeOfType [System.IO.DirectoryNotFoundException] + @($result.FullName | Sort-Object) | Should -Be @(@($link, $sibling, $file) | Sort-Object) + Get-Content -LiteralPath $file | Should -Be 'Sibling' + } } Context 'Optional object properties' { @@ -244,6 +295,29 @@ Describe 'Get-ChildItem2' { $item.Mode | Should -BeExactly '--rhs' } + + # Windows can't list the hard links of a file on a network share, (50) "The request is not supported". The cmdlet + # still returns the file, without HardLinkCount, and says why in a debug message. The test sets the preference, + # because -Debug would prompt in Windows PowerShell. + It 'Should return a file on a network share without HardLinkCount and say why in a debug message' -Skip:(-not $canUseAdminShare) { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'ShareProperties' -Directory + $file = Join-Path -Path $folder -ChildPath 'Share.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + Set-Content -LiteralPath $file -Value 'Share' + $sharePath = ConvertTo-TestAdminSharePath -Sandbox $sandbox -Path $file + $settings['IdentifyHardLinks'] = $true + $DebugPreference = 'Continue' + + $output = @(Get-ChildItem2 -Path $sharePath -ErrorVariable childErrors -ErrorAction SilentlyContinue 5>&1) + + $childErrors | Should -BeNullOrEmpty + $items = @($output | Where-Object -FilterScript { $_ -isnot [Management.Automation.DebugRecord] }) + $items | Should -HaveCount 1 + $items[0].Name | Should -BeExactly 'Share.txt' + $items[0].PSObject.Properties['HardLinkCount'] | Should -BeNullOrEmpty + $messages = @($output | Where-Object -FilterScript { $_ -is [Management.Automation.DebugRecord] } | ForEach-Object -Process { $_.Message }) + $messages | Should -Contain "Could not read hard links for '$sharePath'" + } } Context 'Default table view' { @@ -557,6 +631,58 @@ Describe 'Copy-Item2, Move-Item2, and Remove-Item2 with several paths' { $itemErrors[0].Exception.Message | Should -BeLike "*'$missingShare'*" $first | Should -Exist } + + # A sharing violation is an IOException, which both cmdlets write as InvalidData; the error belongs to its source + # only, and no object comes out for it with -PassThru. + It ' should write a for a source that another process has locked and continue with the next path' -ForEach @( + @{ Command = 'Copy-Item2'; ErrorId = 'CopyError' } + @{ Command = 'Move-Item2'; ErrorId = 'MoveError' } + ) { + $stream = [IO.File]::Open($first, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::None) + try { + $result = @(& $Command -Path $first, $second -Destination $destination -PassThru $true -ErrorVariable itemErrors -ErrorAction SilentlyContinue) + } + finally { + $stream.Dispose() + } + + $itemErrors | Should -HaveCount 1 + $itemErrors[0].FullyQualifiedErrorId | Should -BeLike "$ErrorId,*" + $itemErrors[0].CategoryInfo.Category | Should -Be 'InvalidData' + $itemErrors[0].TargetObject | Should -Be $first + $itemErrors[0].Exception | Should -BeOfType [System.IO.IOException] + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be (Join-Path -Path $destination -ChildPath 'Second.txt') + Join-Path -Path $destination -ChildPath 'First.txt' | Should -Not -Exist + Get-Content -LiteralPath $first | Should -Be 'First' + Get-Content -LiteralPath (Join-Path -Path $destination -ChildPath 'Second.txt') | Should -Be 'Second' + } + + # Any other failure of Windows is not an IOException, and both cmdlets write it as NotSpecified. A deny entry for + # Everyone also applies to an administrator, who doesn't bypass the DACL without a backup privilege. + It ' should write a for each source when the destination folder denies new files' -ForEach @( + @{ Command = 'Copy-Item2'; ErrorId = 'CopyError' } + @{ Command = 'Move-Item2'; ErrorId = 'MoveError' } + ) { + $denied = Join-Path -Path $folder -ChildPath 'Denied' + Assert-TestSandboxPath -Sandbox $sandbox -Path $denied + New-Item -ItemType Directory -Path $denied | Out-Null + Add-TestDenyRule -Sandbox $sandbox -Path $denied -Rights @{ 'S-1-1-0' = 'CreateFiles' } + + $result = @(& $Command -Path $first, $second -Destination $denied -PassThru $true -ErrorVariable itemErrors -ErrorAction SilentlyContinue) + + $itemErrors | Should -HaveCount 2 + for ($index = 0; $index -lt 2; $index++) { + $itemErrors[$index].FullyQualifiedErrorId | Should -BeLike "$ErrorId,*" + $itemErrors[$index].CategoryInfo.Category | Should -Be 'NotSpecified' + $itemErrors[$index].TargetObject | Should -Be @($first, $second)[$index] + $itemErrors[$index].Exception | Should -BeOfType [System.UnauthorizedAccessException] + } + $result | Should -BeNullOrEmpty + @(Get-ChildItem -LiteralPath $denied -Force) | Should -BeNullOrEmpty + Get-Content -LiteralPath $first | Should -Be 'First' + Get-Content -LiteralPath $second | Should -Be 'Second' + } } Describe 'Move-Item2' { diff --git a/Tests/Links.Tests.ps1 b/Tests/Links.Tests.ps1 index 5bc938b..b3bb3b4 100644 --- a/Tests/Links.Tests.ps1 +++ b/Tests/Links.Tests.ps1 @@ -159,6 +159,25 @@ Describe 'New-NTFSHardLink' { $linkErrors[0].FullyQualifiedErrorId | Should -BeLike 'GetHardLinkError,*' $result | Should -BeNullOrEmpty } + + It 'Should write a PermissionDenied error and create no link in a folder that denies new files' { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'DeniedTarget' + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'DeniedFolder' -Directory + $link = Join-Path -Path $folder -ChildPath 'Link.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + Add-TestDenyRule -Sandbox $sandbox -Path $folder -Rights @{ 'S-1-1-0' = 'CreateFiles' } + + $result = @(New-NTFSHardLink -Path $link -Target $target -PassThru -ErrorVariable linkErrors -ErrorAction SilentlyContinue) + + $linkErrors | Should -HaveCount 1 + $linkErrors[0].FullyQualifiedErrorId | Should -BeLike 'CreateHardLinkError,*' + $linkErrors[0].CategoryInfo.Category | Should -Be 'PermissionDenied' + $linkErrors[0].TargetObject | Should -Be $link + $linkErrors[0].Exception | Should -BeOfType [System.UnauthorizedAccessException] + $result | Should -BeNullOrEmpty + $link | Should -Not -Exist + Get-Content -LiteralPath $target | Should -Be 'DeniedTarget' + } } Describe 'Get-NTFSHardLink' { @@ -368,6 +387,26 @@ Describe 'New-NTFSSymbolicLink' { '0x{0:X8}' -f $linkErrors[0].Exception.HResult | Should -Be '0x80070522' Test-Path2 -Path $link | Should -BeFalse } + + # With the right to create symbolic links, Windows refuses the link only for the folder of the link, which denies + # new files here. + It 'Should write a PermissionDenied error and create no link in a folder that denies new files' -Skip:(-not $canCreateSymbolicLinks) { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'SymbolicDeniedTarget' + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'SymbolicDeniedFolder' -Directory + $link = Join-Path -Path $folder -ChildPath 'Link.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + Add-TestDenyRule -Sandbox $sandbox -Path $folder -Rights @{ 'S-1-1-0' = 'CreateFiles' } + + $result = @(New-NTFSSymbolicLink -Path $link -Target $target -PassThru -ErrorVariable linkErrors -ErrorAction SilentlyContinue) + + $linkErrors | Should -HaveCount 1 + $linkErrors[0].FullyQualifiedErrorId | Should -BeLike 'CreateSymbolicLinkError,*' + $linkErrors[0].CategoryInfo.Category | Should -Be 'PermissionDenied' + $linkErrors[0].TargetObject | Should -Be $link + $linkErrors[0].Exception | Should -BeOfType [System.UnauthorizedAccessException] + $result | Should -BeNullOrEmpty + Test-Path2 -Path $link | Should -BeFalse + } } # Each error names its item, so that the errors of many links can be told apart. Before 5.0.0-rc7, the errors of @@ -456,4 +495,18 @@ Describe 'Parameters of the cmdlets that create links' { $sets | Should -Not -BeNullOrEmpty $sets | ForEach-Object -Process { $_.IsMandatory | Should -BeTrue } } + + # PowerShell reads a parameter that takes pipeline input before it binds the input, so the getter must not fail + # while the cmdlet has no -Path. Before 5.0.0-rc7, it threw an index error, and every piped object failed with + # GetDefaultValueFailed. + It ' should return no -Path until it has one, and the first one afterwards' -ForEach @( + @{ Type = 'NTFSSecurity.NewHardLink' } + @{ Type = 'NTFSSecurity.NewSymbolicLink' } + ) { + $cmdlet = New-Object -TypeName $Type + + $cmdlet.Path | Should -BeNullOrEmpty + $cmdlet.Path = 'C:\NTFSSecurity\Link.txt' + $cmdlet.Path | Should -BeExactly 'C:\NTFSSecurity\Link.txt' + } } diff --git a/Tests/Owner.Tests.ps1 b/Tests/Owner.Tests.ps1 index 00e4697..324f749 100644 --- a/Tests/Owner.Tests.ps1 +++ b/Tests/Owner.Tests.ps1 @@ -299,5 +299,19 @@ Describe 'Set-NTFSOwner' { Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop Get-TestOwner -Path $file | Should -Be $currentUser } + + It 'Should write nothing without -PassThru and leave the owner of the item unchanged' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'SetOwnerDescriptorQuiet' + $owner = Get-TestOwner -Path $file + $sd = Get-NTFSSecurityDescriptor -Path $file + $sidType = [System.Security.Principal.SecurityIdentifier] + $sd.SecurityDescriptor.GetOwner($sidType).Value | Should -Not -Be 'S-1-1-0' + + $result = @(Set-NTFSOwner -SecurityDescriptor $sd -Account 'S-1-1-0' -ErrorAction Stop) + + $result | Should -BeNullOrEmpty + $sd.SecurityDescriptor.GetOwner($sidType).Value | Should -Be 'S-1-1-0' + Get-TestOwner -Path $file | Should -Be $owner + } } } From 92e09485951c15c2695e271434a825257c2166ff Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 14:29:28 +0000 Subject: [PATCH 05/28] test: exercise public rule, inheritance and privilege helpers Cover the access and audit rule helpers that take a path, including the lazy iterator overloads and exact versus partial removal, the inheritance helpers for paths, owner and descriptor objects, generic rights mapping, identity construction errors and the PrivilegeEnabler class. These public APIs have no cmdlet caller. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/ObjectApis.Tests.ps1 | 388 +++++++++++++++++++++++++++++++++++++ Tests/Privileges.Tests.ps1 | 118 +++++++++++ 2 files changed, 506 insertions(+) diff --git a/Tests/ObjectApis.Tests.ps1 b/Tests/ObjectApis.Tests.ps1 index bf508ca..84a8030 100644 --- a/Tests/ObjectApis.Tests.ps1 +++ b/Tests/ObjectApis.Tests.ps1 @@ -6,6 +6,11 @@ )] param () +BeforeDiscovery { + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force + $holdsSecurityPrivilege = Test-PrivilegeHeld -Name 'SeSecurityPrivilege' +} + BeforeAll { Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\NTFSSecurity\bin\Release\NTFSSecurity.psd1' @@ -322,3 +327,386 @@ Describe 'Simplified entry comparison branches' { [ProcessPrivileges.PrivilegeAndAttributes]::op_Inequality($first, $different) | Should -BeTrue } } + +Describe 'Access rule helpers that take a path' { + BeforeAll { + $allow = [System.Security.AccessControl.AccessControlType]::Allow + $noInheritance = [System.Security.AccessControl.InheritanceFlags]::None + $noPropagation = [System.Security.AccessControl.PropagationFlags]::None + $users = [Security2.IdentityReference2] 'S-1-5-32-545' + + function Get-ExplicitEntries { + param ([string] $Path, [string] $Account = 'S-1-1-0') + + $acl = Get-Acl -LiteralPath $Path + @($acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq $Account }) + } + + function New-AccountList { + $accounts = New-Object -TypeName 'System.Collections.Generic.List[Security2.IdentityReference2]' + $accounts.Add($identity) + $accounts.Add($users) + , $accounts + } + } + + It 'Should add an allow entry with Synchronize to a by its path' -ForEach @( + @{ Kind = 'file'; Directory = $false } + @{ Kind = 'folder'; Directory = $true } + ) { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'AddByPath' -Directory:$Directory + + $rule = [Security2.FileSystemAccessRule2]::AddFileSystemAccessRule( + $path, $identity, [Security2.FileSystemRights2]::ReadData, $allow, $noInheritance, $noPropagation + ) + + $rule.Account.Sid | Should -BeExactly 'S-1-1-0' + $entries = @(Get-ExplicitEntries -Path $path) + $entries | Should -HaveCount 1 + $entries[0].AccessControlType | Should -Be 'Allow' + $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 @( + @{ Kind = 'file'; Directory = $false } + @{ Kind = 'folder'; Directory = $true } + ) { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'AddListByPath' -Directory:$Directory + $accounts = New-AccountList + + $pending = [Security2.FileSystemAccessRule2]::AddFileSystemAccessRule( + $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 + } + + It 'Should add the entry of a rule that carries its path' { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'AddRule' + $raw = New-Object -TypeName 'System.Security.AccessControl.FileSystemAccessRule' -ArgumentList ( + $sid, [System.Security.AccessControl.FileSystemRights]::ReadData, $allow + ) + $rule = New-Object -TypeName 'Security2.FileSystemAccessRule2' -ArgumentList $raw, $path + + [Security2.FileSystemAccessRule2]::AddFileSystemAccessRule($rule) + + @(Get-ExplicitEntries -Path $path) | Should -HaveCount 1 + } + + # RemoveSpecific removes only an entry that matches exactly; without it, Windows removes the named rights from the + # matching entry. + It 'Should remove only an exactly matching entry with removeSpecific and the named rights without it' { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveByPath' + [void] [Security2.FileSystemAccessRule2]::AddFileSystemAccessRule( + $path, $identity, [Security2.FileSystemRights2] 'ReadData, WriteData', $allow, $noInheritance, $noPropagation + ) + + [Security2.FileSystemAccessRule2]::RemoveFileSystemAccessRule( + $path, $identity, [Security2.FileSystemRights2]::ReadData, $allow, $noInheritance, $noPropagation, $true + ) + @(Get-ExplicitEntries -Path $path)[0].FileSystemRights | + Should -Be ([System.Security.AccessControl.FileSystemRights] 'ReadData, WriteData, Synchronize') + + [Security2.FileSystemAccessRule2]::RemoveFileSystemAccessRule( + $path, $identity, [Security2.FileSystemRights2]::ReadData, $allow, $noInheritance, $noPropagation, $false + ) + @(Get-ExplicitEntries -Path $path)[0].FileSystemRights | + Should -Be ([System.Security.AccessControl.FileSystemRights] 'WriteData, Synchronize') + + [Security2.FileSystemAccessRule2]::RemoveFileSystemAccessRule( + $path, $identity, [Security2.FileSystemRights2]::WriteData, $allow, $noInheritance, $noPropagation, $true + ) + @(Get-ExplicitEntries -Path $path) | Should -BeNullOrEmpty + } + + It 'Should remove the entries of several accounts by path' { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveListByPath' + $accounts = New-AccountList + @([Security2.FileSystemAccessRule2]::AddFileSystemAccessRule( + $path, $accounts, [Security2.FileSystemRights2]::ReadData, $allow, $noInheritance, $noPropagation + )) | Should -HaveCount 2 + + [Security2.FileSystemAccessRule2]::RemoveFileSystemAccessRule( + $path, $accounts, [Security2.FileSystemRights2]::ReadData, $allow, $noInheritance, $noPropagation, $false + ) + + @(Get-ExplicitEntries -Path $path) | Should -BeNullOrEmpty + @(Get-ExplicitEntries -Path $path -Account 'S-1-5-32-545') | Should -BeNullOrEmpty + } + + It 'Should remove the entry that a rule object describes from an item' { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveRuleObject' + [void] [Security2.FileSystemAccessRule2]::AddFileSystemAccessRule( + $path, $identity, [Security2.FileSystemRights2]::ReadData, $allow, $noInheritance, $noPropagation + ) + $raw = New-Object -TypeName 'System.Security.AccessControl.FileSystemAccessRule' -ArgumentList ( + $sid, [System.Security.AccessControl.FileSystemRights]::ReadData, $allow + ) + $item = New-Object -TypeName 'Alphaleonis.Win32.Filesystem.FileInfo' -ArgumentList $path + + [Security2.FileSystemAccessRule2]::RemoveFileSystemAccessRule($item, $raw, $false) + + @(Get-ExplicitEntries -Path $path) | Should -BeNullOrEmpty + } + + It 'Should return the explicit entries of a folder, and its inherited ones when asked, by its path' { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'GetByPath' -Directory + [void] [Security2.FileSystemAccessRule2]::AddFileSystemAccessRule( + $path, $identity, [Security2.FileSystemRights2]::ReadData, $allow, $noInheritance, $noPropagation + ) + + $explicit = @([Security2.FileSystemAccessRule2]::GetFileSystemAccessRules($path, $true, $false, $false)) + $all = @([Security2.FileSystemAccessRule2]::GetFileSystemAccessRules($path, $true, $true, $true)) + + $explicit | Should -HaveCount 1 + $explicit[0].Account.Sid | Should -BeExactly 'S-1-1-0' + $all.Count | Should -BeGreaterThan 1 + @($all | Where-Object -FilterScript { $_.Account.Sid -eq 'S-1-1-0' }) | Should -HaveCount 1 + } +} + +Describe 'Audit rule helpers that take a path' -Skip:(-not $holdsSecurityPrivilege) { + BeforeAll { + $success = [System.Security.AccessControl.AuditFlags]::Success + $noInheritance = [System.Security.AccessControl.InheritanceFlags]::None + $noPropagation = [System.Security.AccessControl.PropagationFlags]::None + $users = [Security2.IdentityReference2] 'S-1-5-32-545' + + function Get-AuditEntries { + param ([string] $Path, [string] $Account = 'S-1-1-0') + + $descriptor = Get-NTFSSecurityDescriptor -Path $Path + @($descriptor.SecurityDescriptor.GetAuditRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq $Account }) + } + } + + It 'Should add an audit entry to a by its path' -ForEach @( + @{ Kind = 'file'; Directory = $false } + @{ Kind = 'folder'; Directory = $true } + ) { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'AuditByPath' -Directory:$Directory + + $rule = [Security2.FileSystemAuditRule2]::AddFileSystemAuditRule( + $path, $identity, [Security2.FileSystemRights2]::Delete, $success, $noInheritance, $noPropagation + ) + + $rule.Account.Sid | Should -BeExactly 'S-1-1-0' + $entries = @(Get-AuditEntries -Path $path) + $entries | Should -HaveCount 1 + $entries[0].AuditFlags | Should -Be 'Success' + $entries[0].FileSystemRights | Should -Be ([System.Security.AccessControl.FileSystemRights]::Delete) + } + + It 'Should add the entries of several accounts to a by its path only when the result is enumerated, and remove them again' -ForEach @( + @{ Kind = 'file'; Directory = $false } + @{ Kind = 'folder'; Directory = $true } + ) { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'AuditListByPath' -Directory:$Directory + $accounts = New-Object -TypeName 'System.Collections.Generic.List[Security2.IdentityReference2]' + $accounts.Add($identity) + $accounts.Add($users) + + $pending = [Security2.FileSystemAuditRule2]::AddFileSystemAuditRule( + $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 + [Security2.FileSystemAuditRule2]::RemoveFileSystemAuditRule( + $path, $identity, [Security2.FileSystemRights2]::Delete, $success, $noInheritance, $noPropagation, $true + ) + @(Get-AuditEntries -Path $path) | Should -BeNullOrEmpty + @(Get-AuditEntries -Path $path -Account 'S-1-5-32-545') | Should -HaveCount 1 + [Security2.FileSystemAuditRule2]::RemoveFileSystemAuditRule( + $path, $users, [Security2.FileSystemRights2]::Delete, $success, $noInheritance, $noPropagation, $false + ) + @(Get-AuditEntries -Path $path -Account 'S-1-5-32-545') | Should -BeNullOrEmpty + } + + It 'Should name the item of a rule, replay it, read it by path, and remove it by its rule object' { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'AuditReplay' + $raw = New-Object -TypeName 'System.Security.AccessControl.FileSystemAuditRule' -ArgumentList ( + $sid, [System.Security.AccessControl.FileSystemRights]::Delete, $success + ) + $item = New-Object -TypeName 'Alphaleonis.Win32.Filesystem.FileInfo' -ArgumentList $path + $rule = New-Object -TypeName 'Security2.FileSystemAuditRule2' -ArgumentList $raw, $item + $rule.FullName | Should -BeExactly $path + $rule.Name | Should -BeExactly (Split-Path -Path $path -Leaf) + + [Security2.FileSystemAuditRule2]::AddFileSystemAuditRule($rule) + + @(Get-AuditEntries -Path $path) | Should -HaveCount 1 + $found = @([Security2.FileSystemAuditRule2]::GetFileSystemAuditRules($path, $true, $true)) + $found | Should -HaveCount 1 + $found[0].Account.Sid | Should -BeExactly 'S-1-1-0' + [Security2.FileSystemAuditRule2]::RemoveFileSystemAuditRule($item, $raw) + @(Get-AuditEntries -Path $path) | Should -BeNullOrEmpty + } + + It 'Should remove a rule object from an item without audit entries and change nothing' { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'AuditNothing' + $raw = New-Object -TypeName 'System.Security.AccessControl.FileSystemAuditRule' -ArgumentList ( + $sid, [System.Security.AccessControl.FileSystemRights]::Delete, $success + ) + $item = New-Object -TypeName 'Alphaleonis.Win32.Filesystem.FileInfo' -ArgumentList $path + $before = (Get-Acl -LiteralPath $path).Sddl + + [Security2.FileSystemAuditRule2]::RemoveFileSystemAuditRule($item, $raw) + + (Get-Acl -LiteralPath $path).Sddl | Should -BeExactly $before + @(Get-AuditEntries -Path $path) | Should -BeNullOrEmpty + } +} + +Describe 'Inheritance helpers that take a path' { + It 'Should block and restore the access inheritance of a by its path' -ForEach @( + @{ Kind = 'file'; Directory = $false; Remove = $false } + @{ Kind = 'folder'; Directory = $true; Remove = $true } + ) { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'InheritanceByPath' -Directory:$Directory + $inherited = @((Get-Acl -LiteralPath $path).GetAccessRules($false, $true, [System.Security.Principal.SecurityIdentifier])).Count + $inherited | Should -BeGreaterThan 0 + + [Security2.FileSystemInheritanceInfo]::DisableAccessInheritance($path, $Remove) + + $acl = Get-Acl -LiteralPath $path + $acl.AreAccessRulesProtected | Should -BeTrue + @($acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])).Count | + Should -Be $(if ($Remove) { 0 } else { $inherited }) + + [Security2.FileSystemInheritanceInfo]::EnableAccessInheritance($path, $Remove) + + $acl = Get-Acl -LiteralPath $path + $acl.AreAccessRulesProtected | Should -BeFalse + @($acl.GetAccessRules($false, $true, [System.Security.Principal.SecurityIdentifier])).Count | Should -Be $inherited + } + + It 'Should read the access inheritance of a file by its path and keep what the caller sets on the result' { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'InheritanceInfo' + + $info = [Security2.FileSystemInheritanceInfo]::GetFileSystemInheritanceInfo($path) + + $info.AccessInheritanceEnabled | Should -BeTrue + $info.Item.FullName | Should -BeExactly $path + $info.AccessInheritanceEnabled = $false + $info.AuditInheritanceEnabled = $true + $info.Item = New-Object -TypeName 'Alphaleonis.Win32.Filesystem.FileInfo' -ArgumentList $path + $info.AccessInheritanceEnabled | Should -BeFalse + $info.AuditInheritanceEnabled | Should -BeTrue + (Get-Acl -LiteralPath $path).AreAccessRulesProtected | 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 } + ) { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'AuditInheritanceByPath' -Directory:$Directory + (Get-NTFSInheritance -Path $path).AuditInheritanceEnabled | Should -BeTrue + + [Security2.FileSystemInheritanceInfo]::DisableAuditInheritance($path, $false) + + (Get-NTFSInheritance -Path $path).AuditInheritanceEnabled | Should -BeFalse + + [Security2.FileSystemInheritanceInfo]::EnableAuditInheritance($path, $false) + + (Get-NTFSInheritance -Path $path).AuditInheritanceEnabled | Should -BeTrue + } +} + +Describe 'Owner and descriptor objects' { + It 'Should name the item and the account of an owner object' { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'OwnerObject' + + $owner = Get-NTFSOwner -Path $path + + $owner.Item.FullName | Should -BeExactly $path + $owner.FullName | Should -BeExactly $path + $owner.Account.Sid | Should -BeExactly $owner.Owner.Sid + } + + It 'Should read the owner of a drive root also for a lowercase drive letter' { + $root = [IO.Path]::GetPathRoot($sandbox) + $expected = (Get-NTFSOwner -Path $root).Owner.Sid + + $owner = Get-NTFSOwner -Path $root.ToLowerInvariant() + + $owner.Owner.Sid | Should -BeExactly $expected + } + + It 'Should name the item of a descriptor and write it to a folder by its path' { + $source = New-TestSandboxItem -Sandbox $sandbox -Name 'DescriptorSource' -Directory + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'DescriptorTarget' -Directory + Add-NTFSAccess -Path $source -Account 'S-1-1-0' -AccessRights ReadData -AppliesTo ThisFolderOnly + $descriptor = Get-NTFSSecurityDescriptor -Path $source + + $descriptor.Name | Should -BeExactly (Split-Path -Path $source -Leaf) + $descriptor.Write([string] $target) + + @((Get-Acl -LiteralPath $target).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')) + Assert-TestSandboxPath -Sandbox $sandbox -Path $missing + $descriptor = Get-NTFSSecurityDescriptor -Path $source + + $failure = { $descriptor.Write($missing) } | Should -Throw -PassThru + + $failure.Exception.GetBaseException() | Should -BeOfType [System.IO.FileNotFoundException] + $failure.Exception.GetBaseException().FileName | Should -BeExactly $missing + } + + It 'Should leave both flags unset for an AppliesTo value that no case names' { + $inheritance = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit + $propagation = [System.Security.AccessControl.PropagationFlags]::InheritOnly + + [Security2.FileSystemSecurity2]::ConvertToFileSystemFlags( + [Enum]::ToObject([Security2.ApplyTo], 99), [ref] $inheritance, [ref] $propagation + ) + + $inheritance | Should -Be 'None' + $propagation | Should -Be 'None' + } +} + +Describe 'Generic access rights and identity errors' { + It 'Should map the generic mask to the file system rights ' -ForEach @( + @{ Mask = '80000000'; Expected = '00120089' } + @{ Mask = '40000000'; Expected = '00120116' } + @{ Mask = '20000000'; Expected = '001200A0' } + @{ Mask = '10000000'; Expected = '001F01FF' } + @{ Mask = 'C0000000'; Expected = '0012019F' } + @{ Mask = '80010000'; Expected = '00130089' } + @{ Mask = '001F01FF'; Expected = '001F01FF' } + @{ Mask = '00120089'; Expected = '00120089' } + @{ Mask = '02000000'; Expected = '02000000' } + @{ Mask = '82000000'; Expected = '02120089' } + @{ Mask = '00000000'; Expected = '00000000' } + ) { + $rights = [Security2.FileSystemSecurity2]::MapGenericRightsToFileSystemRights([Convert]::ToUInt32($Mask, 16)) + + [int] $rights | Should -Be ([Convert]::ToInt32($Expected, 16)) + } + + It 'Should reject when it creates an identity' -ForEach @( + @{ Case = 'an empty value'; Value = ''; Expected = [System.ArgumentException] } + @{ Case = 'a SID with too many sub authorities'; Value = ('S-1-' + (('1-' * 20) + '1')); Expected = [System.InvalidCastException] } + @{ Case = 'an account that does not exist'; Value = 'NTFSSecurityNoSuchAccount'; Expected = [System.Security.Principal.IdentityNotMappedException] } + ) { + $failure = { [Security2.IdentityReference2]::new($Value) } | Should -Throw -PassThru + + # PowerShell wraps the exception of a constructor, which here wraps the cause of an invalid SID in turn. + $failure.Exception.InnerException | Should -BeOfType $Expected + } +} diff --git a/Tests/Privileges.Tests.ps1 b/Tests/Privileges.Tests.ps1 index e75e18f..bc311f8 100644 --- a/Tests/Privileges.Tests.ps1 +++ b/Tests/Privileges.Tests.ps1 @@ -262,3 +262,121 @@ Describe 'Privileges that another command in the pipeline changes' { Get-EnabledFileSystemPrivilege | Should -BeNullOrEmpty } } + +# The library class of the module that the cmdlets leave unused; the tests change only the privileges of the test process. +Describe 'The PrivilegeEnabler class' { + BeforeAll { + $privateData['EnablePrivileges'] = $false + $backup = [ProcessPrivileges.Privilege]::Backup + $currentProcess = [System.Diagnostics.Process]::GetCurrentProcess() + } + + AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges + } + + BeforeEach { + Disable-Privileges -ErrorAction SilentlyContinue -WarningAction SilentlyContinue + } + + AfterEach { + Disable-Privileges -ErrorAction SilentlyContinue -WarningAction SilentlyContinue + } + + It 'Should enable a disabled privilege until it is disposed' -Skip:(-not $holdsPrivileges) { + Get-BackupPrivilegeState | Should -Be 'Disabled' + + $enabler = New-Object -TypeName 'ProcessPrivileges.PrivilegeEnabler' -ArgumentList $currentProcess, $backup + try { + Get-BackupPrivilegeState | Should -Be 'Enabled' + } + finally { + $enabler.Dispose() + } + + Get-BackupPrivilegeState | Should -Be 'Disabled' + $enabler.Dispose() + Get-BackupPrivilegeState | Should -Be 'Disabled' + } + + It 'Should report a privilege that it modified once and leave it to the instance that enabled it' -Skip:(-not $holdsPrivileges) { + $first = New-Object -TypeName 'ProcessPrivileges.PrivilegeEnabler' -ArgumentList $currentProcess + $second = New-Object -TypeName 'ProcessPrivileges.PrivilegeEnabler' -ArgumentList $currentProcess + try { + $first.EnablePrivilege($backup) | Should -Be 'PrivilegeModified' + Get-BackupPrivilegeState | Should -Be 'Enabled' + $first.EnablePrivilege($backup) | Should -Be 'None' + $second.EnablePrivilege($backup) | Should -Be 'None' + $second.Dispose() + Get-BackupPrivilegeState | Should -Be 'Enabled' + } + finally { + $first.Dispose() + $second.Dispose() + } + + Get-BackupPrivilegeState | Should -Be 'Disabled' + } + + It 'Should not disable a privilege that was enabled before' -Skip:(-not $holdsPrivileges) { + $null = [ProcessPrivileges.ProcessExtensions]::EnablePrivilege($currentProcess, $backup) + + $enabler = New-Object -TypeName 'ProcessPrivileges.PrivilegeEnabler' -ArgumentList $currentProcess, $backup + try { + $enabler.EnablePrivilege($backup) | Should -Be 'None' + } + finally { + $enabler.Dispose() + } + + Get-BackupPrivilegeState | Should -Be 'Enabled' + } + + 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) + try { + $enabler = New-Object -TypeName 'ProcessPrivileges.PrivilegeEnabler' -ArgumentList $handle, $backup + Get-BackupPrivilegeState | Should -Be 'Enabled' + $enabler.Dispose() + + Get-BackupPrivilegeState | Should -Be 'Disabled' + $handle.IsClosed | Should -BeFalse + } + finally { + $handle.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' { + $removed = [ProcessPrivileges.Privilege]::CreateToken + [ProcessPrivileges.ProcessExtensions]::GetPrivilegeState($currentProcess, $removed) | Should -Be 'Removed' + + $enabler = New-Object -TypeName 'ProcessPrivileges.PrivilegeEnabler' -ArgumentList $currentProcess + try { + $enabler.EnablePrivilege($removed) | Should -Be 'None' + } + finally { + $enabler.Dispose() + } + + [ProcessPrivileges.ProcessExtensions]::GetPrivilegeState($currentProcess, $removed) | Should -Be 'Removed' + } + + # The enabled flag decides first, then the removed flag; the attributes are not a flags enumeration in .NET. + It 'Should derive the state from the attribute value ' -ForEach @( + @{ Value = 0; Expected = 'Disabled' } + @{ Value = 1; Expected = 'Disabled' } + @{ Value = 2; Expected = 'Enabled' } + @{ Value = 3; Expected = 'Enabled' } + @{ Value = 4; Expected = 'Removed' } + @{ Value = 6; Expected = 'Enabled' } + @{ Value = -2147483648; Expected = 'Disabled' } + ) { + $attributes = [Enum]::ToObject([ProcessPrivileges.PrivilegeAttributes], $Value) + + [ProcessPrivileges.ProcessExtensions]::GetPrivilegeState($attributes) | Should -Be $Expected + } +} From 51412e0af85262e155e8d484d2bed2ae1c69172f Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 14:30:00 +0000 Subject: [PATCH 06/28] test: declare the plural helper names of the rule tests PSScriptAnalyzer flagged the plural nouns and the state-changing verb of three helpers; the repository suppresses that rule for test helpers with a justification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/ObjectApis.Tests.ps1 | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Tests/ObjectApis.Tests.ps1 b/Tests/ObjectApis.Tests.ps1 index 84a8030..fbd23ed 100644 --- a/Tests/ObjectApis.Tests.ps1 +++ b/Tests/ObjectApis.Tests.ps1 @@ -336,6 +336,9 @@ Describe 'Access rule helpers that take a path' { $users = [Security2.IdentityReference2] 'S-1-5-32-545' function Get-ExplicitEntries { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseSingularNouns', '', Justification = 'The helper returns the explicit entries of an item.' + )] param ([string] $Path, [string] $Account = 'S-1-1-0') $acl = Get-Acl -LiteralPath $Path @@ -344,6 +347,11 @@ Describe 'Access rule helpers that take a path' { } function New-AccountList { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Test helper that only creates a list.' + )] + param () + $accounts = New-Object -TypeName 'System.Collections.Generic.List[Security2.IdentityReference2]' $accounts.Add($identity) $accounts.Add($users) @@ -479,6 +487,9 @@ Describe 'Audit rule helpers that take a path' -Skip:(-not $holdsSecurityPrivile $users = [Security2.IdentityReference2] 'S-1-5-32-545' function Get-AuditEntries { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseSingularNouns', '', Justification = 'The helper returns the audit entries of an item.' + )] param ([string] $Path, [string] $Account = 'S-1-1-0') $descriptor = Get-NTFSSecurityDescriptor -Path $Path From 8f07330bfb9eb82d0b4af9d6f7ee0faf4aa81b0c Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 14:34:46 +0000 Subject: [PATCH 07/28] test: cover relative paths, a missing drive and privilege control Resolve relative paths with Get-Item2, report copies and moves to a drive that does not exist, warn once about MACTripleDES across pipeline objects, and check the exceptions of PrivilegeControl for held, repeated and missing privileges. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/FileHash.Tests.ps1 | 8 +++++ Tests/ItemCmdlets.Tests.ps1 | 63 +++++++++++++++++++++++++++++++++++++ Tests/Privileges.Tests.ps1 | 53 +++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+) diff --git a/Tests/FileHash.Tests.ps1 b/Tests/FileHash.Tests.ps1 index 04184d1..1babcfb 100644 --- a/Tests/FileHash.Tests.ps1 +++ b/Tests/FileHash.Tests.ps1 @@ -80,6 +80,14 @@ Describe 'Get-FileHash2' { $hashWarnings | Should -HaveCount 1 $hashWarnings[0].Message | Should -BeLike '*MACTripleDES*random key*deprecated*' } + + # PowerShell calls the cmdlet once for each object in the pipeline; the warning belongs to the command. + It 'Should warn once that MACTripleDES is deprecated for several objects in the pipeline' -Skip:$isCore { + $results = @($first, $second | Get-FileHash2 -Algorithm MACTripleDES -WarningVariable hashWarnings -WarningAction SilentlyContinue) + + $results | Should -HaveCount 2 + $hashWarnings | Should -HaveCount 1 + } } Context 'When -Path contains a folder' { It 'Should skip the folder and hash the files that follow it' { diff --git a/Tests/ItemCmdlets.Tests.ps1 b/Tests/ItemCmdlets.Tests.ps1 index 6c194a1..f06a0b4 100644 --- a/Tests/ItemCmdlets.Tests.ps1 +++ b/Tests/ItemCmdlets.Tests.ps1 @@ -683,6 +683,30 @@ Describe 'Copy-Item2, Move-Item2, and Remove-Item2 with several paths' { Get-Content -LiteralPath $first | Should -Be 'First' Get-Content -LiteralPath $second | Should -Be 'Second' } + + # A destination on a drive letter without a volume has no folder that the cmdlet could name, so Windows reports the + # drive as not ready, which AlphaFS raises as an IOException. + It ' should write a for a destination on a drive that does not exist and keep the source' -ForEach @( + @{ Command = 'Copy-Item2'; ErrorId = 'CopyError' } + @{ Command = 'Move-Item2'; ErrorId = 'MoveError' } + ) { + $used = @((Get-PSDrive -PSProvider FileSystem).Name) + @([System.IO.DriveInfo]::GetDrives() | ForEach-Object -Process { $_.Name.Substring(0, 1) }) + $letter = [char[]](68..90) | Where-Object -FilterScript { [string] $_ -notin $used } | Select-Object -Last 1 + if (-not $letter) { + Set-ItResult -Skipped -Because 'every drive letter is in use' + return + } + + $result = @(& $Command -Path $first -Destination "${letter}:\" -PassThru $true -ErrorVariable itemErrors -ErrorAction SilentlyContinue) + + $itemErrors | Should -HaveCount 1 + $itemErrors[0].FullyQualifiedErrorId | Should -BeLike "$ErrorId,*" + $itemErrors[0].CategoryInfo.Category | Should -Be 'InvalidData' + $itemErrors[0].TargetObject | Should -Be $first + $itemErrors[0].Exception | Should -BeOfType [System.IO.IOException] + $result | Should -BeNullOrEmpty + Get-Content -LiteralPath $first | Should -Be 'First' + } } Describe 'Move-Item2' { @@ -766,6 +790,45 @@ Describe 'Copy-Item2' { } } +Describe 'Relative paths' { + BeforeAll { + $parent = New-TestSandboxItem -Sandbox $sandbox -Name 'RelativeParent' -Directory + $child = Join-Path -Path $parent -ChildPath 'Child' + $sibling = Join-Path -Path $parent -ChildPath 'Sibling' + $siblingFile = Join-Path -Path $sibling -ChildPath 'Sibling.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $child, $sibling, $siblingFile + New-Item -ItemType Directory -Path $child, $sibling | Out-Null + Set-Content -LiteralPath $siblingFile -Value 'Sibling' + } + + It 'Get-Item2 should resolve against the current location' -ForEach @( + @{ Path = '.'; Expected = 'Child' } + @{ Path = '.\'; Expected = 'Child' } + @{ Path = '..'; Expected = 'Parent' } + @{ Path = '..\Sibling'; Expected = 'Sibling' } + @{ Path = '..\Sibling\Sibling.txt'; Expected = 'SiblingFile' } + @{ Path = '..\..'; Expected = 'Grandparent' } + ) { + $expectedPath = switch ($Expected) { + 'Child' { $child } + 'Parent' { $parent } + 'Sibling' { $sibling } + 'SiblingFile' { $siblingFile } + 'Grandparent' { Split-Path -Path $parent -Parent } + } + Push-Location -LiteralPath $child + try { + $result = @(Get-Item2 -Path $Path -ErrorAction Stop) + } + finally { + Pop-Location + } + + $result | Should -HaveCount 1 + $result[0].FullName.TrimEnd('\') | Should -Be $expectedPath + } +} + Describe 'Test-Path2' { BeforeAll { $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'TestPath' -Directory diff --git a/Tests/Privileges.Tests.ps1 b/Tests/Privileges.Tests.ps1 index bc311f8..b47ef99 100644 --- a/Tests/Privileges.Tests.ps1 +++ b/Tests/Privileges.Tests.ps1 @@ -380,3 +380,56 @@ Describe 'The PrivilegeEnabler class' { [ProcessPrivileges.ProcessExtensions]::GetPrivilegeState($attributes) | Should -Be $Expected } } + +Describe 'The PrivilegeControl class' { + BeforeAll { + $privateData['EnablePrivileges'] = $false + $control = New-Object -TypeName 'Security2.PrivilegeControl' + $backup = [ProcessPrivileges.Privilege]::Backup + } + + AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges + } + + BeforeEach { + Disable-Privileges -ErrorAction SilentlyContinue -WarningAction SilentlyContinue + } + + AfterEach { + Disable-Privileges -ErrorAction SilentlyContinue -WarningAction SilentlyContinue + } + + It 'Should refuse to a privilege that the access token does not hold' -ForEach @( + @{ Operation = 'enable' } + @{ Operation = 'disable' } + ) { + $failure = { + if ($Operation -eq 'enable') { + $control.EnablePrivilege([ProcessPrivileges.Privilege]::CreateToken) + } + else { + $control.DisablePrivilege([ProcessPrivileges.Privilege]::CreateToken) + } + } | Should -Throw -PassThru + + $failure.Exception.InnerException | Should -BeOfType [System.Security.AccessControl.PrivilegeNotHeldException] + $failure.Exception.InnerException.PrivilegeName | Should -BeExactly 'CreateToken' + } + + It 'Should enable and disable a held privilege and refuse to repeat either' -Skip:(-not $holdsPrivileges) { + Get-BackupPrivilegeState | Should -Be 'Disabled' + $failure = { $control.DisablePrivilege($backup) } | Should -Throw -PassThru + $failure.Exception.InnerException | Should -BeOfType [Security2.AdjustPriviledgeException] + $failure.Exception.InnerException.Message | Should -BeExactly 'Priviledge already disabled' + + $control.EnablePrivilege($backup) | Should -Be 'PrivilegeModified' + Get-BackupPrivilegeState | Should -Be 'Enabled' + $failure = { $control.EnablePrivilege($backup) } | Should -Throw -PassThru + $failure.Exception.InnerException | Should -BeOfType [Security2.AdjustPriviledgeException] + $failure.Exception.InnerException.Message | Should -BeExactly 'Priviledge already enabled' + + $control.DisablePrivilege($backup) | Should -Be 'PrivilegeModified' + Get-BackupPrivilegeState | Should -Be 'Disabled' + } +} From d0acda3b6da2fb1ba17caaf39ccad102f4595894 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 14:36:48 +0000 Subject: [PATCH 08/28] test: render the Mode of a folder and of no object Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/ItemCmdlets.Tests.ps1 | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Tests/ItemCmdlets.Tests.ps1 b/Tests/ItemCmdlets.Tests.ps1 index f06a0b4..e0b65dd 100644 --- a/Tests/ItemCmdlets.Tests.ps1 +++ b/Tests/ItemCmdlets.Tests.ps1 @@ -296,6 +296,23 @@ Describe 'Get-ChildItem2' { $item.Mode | Should -BeExactly '--rhs' } + It 'Should render a folder with a d in the Mode property' { + $parent = New-TestSandboxItem -Sandbox $sandbox -Name 'ModeFolder' -Directory + $folder = Join-Path -Path $parent -ChildPath 'Inner' + Assert-TestSandboxPath -Sandbox $sandbox -Path $folder + New-Item -ItemType Directory -Path $folder | Out-Null + $settings['GetFileSystemModeProperty'] = $true + + $item = Get-ChildItem2 -Path $parent -ErrorAction Stop + + $item | Should -BeOfType [Alphaleonis.Win32.Filesystem.DirectoryInfo] + $item.Mode | Should -BeExactly 'd----' + } + + It 'Should return an empty Mode for no object' { + [NTFSSecurity.FileSystemCodeMembers]::Mode($null) | Should -BeExactly '' + } + # Windows can't list the hard links of a file on a network share, (50) "The request is not supported". The cmdlet # still returns the file, without HardLinkCount, and says why in a debug message. The test sets the preference, # because -Debug would prompt in Windows PowerShell. From 95b43c0c9f7c1295ae024f6585688ee286509ca7 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 14:42:00 +0000 Subject: [PATCH 09/28] test: cover the GetInheritedFrom setting, deny entries and an empty DACL Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/Access.Tests.ps1 | 16 ++++++++++++++++ Tests/Audit.Tests.ps1 | 22 ++++++++++++++++++++++ Tests/ObjectApis.Tests.ps1 | 29 +++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/Tests/Access.Tests.ps1 b/Tests/Access.Tests.ps1 index 7e2b81d..493b40a 100644 --- a/Tests/Access.Tests.ps1 +++ b/Tests/Access.Tests.ps1 @@ -953,6 +953,22 @@ Describe 'InheritedFrom of access entries' { $inherited[0].InheritedFrom | Should -Be $parent } + # The module setting GetInheritedFrom turns off the lookup of the sources, which costs a call for each item. + It 'Should leave InheritedFrom empty when the module setting GetInheritedFrom is off' { + $saved = $privateData['GetInheritedFrom'] + $privateData['GetInheritedFrom'] = $false + try { + $result = @(Get-NTFSAccess -Path $inheritedFromFile -ErrorAction Stop) + } + finally { + $privateData['GetInheritedFrom'] = $saved + } + + $inherited = @($result | Where-Object -FilterScript { $_.IsInherited }) + $inherited | Should -Not -BeNullOrEmpty + $inherited | ForEach-Object -Process { $_.InheritedFrom | Should -BeNullOrEmpty } + } + It 'Should read a security descriptor with audit entries and name the same folders' -Skip:(-not $holdsSecurityPrivilege) { $file = New-TestSandboxItem -Sandbox $sandbox -Name 'InheritedFromAudit' Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData diff --git a/Tests/Audit.Tests.ps1 b/Tests/Audit.Tests.ps1 index 57eb3b3..9bd4218 100644 --- a/Tests/Audit.Tests.ps1 +++ b/Tests/Audit.Tests.ps1 @@ -511,7 +511,29 @@ Describe 'InheritedFrom of audit entries' { $inherited | Should -HaveCount 1 $inherited[0].InheritedFrom | Should -Be $folder } + + # The module setting GetInheritedFrom turns off the lookup of the sources, which costs a call for each item. + It 'Should leave InheritedFrom empty when the module setting GetInheritedFrom is off' -Skip:(-not $canReadAudit) { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'InheritedFromOff' -Directory + Add-NTFSAudit -Path $folder -Account 'S-1-1-0' -AccessRights ReadData -InheritanceFlags 'ContainerInherit, ObjectInherit' -PropagationFlags None + $file = Join-Path -Path $folder -ChildPath 'File.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + Set-Content -LiteralPath $file -Value 'File' + $saved = $privateData['GetInheritedFrom'] + $privateData['GetInheritedFrom'] = $false + try { + $result = @(Get-NTFSAudit -Path $file -ErrorAction Stop) + } + finally { + $privateData['GetInheritedFrom'] = $saved + } + + $inherited = @($result | Where-Object -FilterScript { $_.IsInherited }) + $inherited | Should -HaveCount 1 + $inherited[0].InheritedFrom | Should -BeNullOrEmpty + } } + Describe 'Audit changes with the Security privilege disabled' { BeforeAll { $holdsSecurityForOperations = Test-PrivilegeHeld -Name 'SeSecurityPrivilege' diff --git a/Tests/ObjectApis.Tests.ps1 b/Tests/ObjectApis.Tests.ps1 index fbd23ed..c5df152 100644 --- a/Tests/ObjectApis.Tests.ps1 +++ b/Tests/ObjectApis.Tests.ps1 @@ -395,6 +395,35 @@ Describe 'Access rule helpers that take a path' { @(Get-ExplicitEntries -Path $path -Account 'S-1-5-32-545') | Should -HaveCount 1 } + It 'Should add and remove a deny entry by its path without adding Synchronize' { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'DenyByPath' + $deny = [System.Security.AccessControl.AccessControlType]::Deny + + [void] [Security2.FileSystemAccessRule2]::AddFileSystemAccessRule( + $path, $identity, [Security2.FileSystemRights2]::ReadData, $deny, $noInheritance, $noPropagation + ) + + $entries = @(Get-ExplicitEntries -Path $path) + $entries | Should -HaveCount 1 + $entries[0].AccessControlType | Should -Be 'Deny' + $entries[0].FileSystemRights | Should -Be ([System.Security.AccessControl.FileSystemRights]::ReadData) + + [Security2.FileSystemAccessRule2]::RemoveFileSystemAccessRule( + $path, $identity, [Security2.FileSystemRights2]::ReadData, $deny, $noInheritance, $noPropagation + ) + + @(Get-ExplicitEntries -Path $path) | Should -BeNullOrEmpty + } + + It 'Should return no entries for an empty DACL when the sources of inherited entries are requested' { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'EmptyDacl' + Clear-NTFSAccess -Path $path -DisableInheritance -ErrorAction Stop + + $rules = @([Security2.FileSystemAccessRule2]::GetFileSystemAccessRules($path, $true, $true, $true)) + + $rules | Should -BeNullOrEmpty + } + It 'Should add the entry of a rule that carries its path' { $path = New-TestSandboxItem -Sandbox $sandbox -Name 'AddRule' $raw = New-Object -TypeName 'System.Security.AccessControl.FileSystemAccessRule' -ArgumentList ( From cd56f495c008c562f6c9a16df356944e8cf31fbe Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 14:43:08 +0000 Subject: [PATCH 10/28] test: assert that disposing an access token handle closes it Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/Privileges.Tests.ps1 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tests/Privileges.Tests.ps1 b/Tests/Privileges.Tests.ps1 index b47ef99..7dd4fc5 100644 --- a/Tests/Privileges.Tests.ps1 +++ b/Tests/Privileges.Tests.ps1 @@ -346,6 +346,8 @@ Describe 'The PrivilegeEnabler class' { finally { $handle.Dispose() } + + $handle.IsClosed | Should -BeTrue } # The access tokens of administrators don't hold the privilege to create a token, and those of basic users don't hold From 06edaf9e7f0a6d980a3390cc34919fa8d60cde35 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 14:49:23 +0000 Subject: [PATCH 11/28] test: cover folder removal by path and deny entries of several accounts The removal helpers for a path ran only against a file. Run both removal tests for a file and a folder, and add the deny variant of the iterator overload that adds the entries of several accounts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/ObjectApis.Tests.ps1 | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/Tests/ObjectApis.Tests.ps1 b/Tests/ObjectApis.Tests.ps1 index c5df152..8ad1c53 100644 --- a/Tests/ObjectApis.Tests.ps1 +++ b/Tests/ObjectApis.Tests.ps1 @@ -395,6 +395,23 @@ Describe 'Access rule helpers that take a path' { @(Get-ExplicitEntries -Path $path -Account 'S-1-5-32-545') | Should -HaveCount 1 } + It 'Should add the deny entries of several accounts without Synchronize when the result is enumerated' { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'DenyListByPath' + $accounts = New-AccountList + $deny = [System.Security.AccessControl.AccessControlType]::Deny + + @([Security2.FileSystemAccessRule2]::AddFileSystemAccessRule( + $path, $accounts, [Security2.FileSystemRights2]::ReadData, $deny, $noInheritance, $noPropagation + )) | Should -HaveCount 2 + + foreach ($account in 'S-1-1-0', 'S-1-5-32-545') { + $entries = @(Get-ExplicitEntries -Path $path -Account $account) + $entries | Should -HaveCount 1 + $entries[0].AccessControlType | Should -Be 'Deny' + $entries[0].FileSystemRights | Should -Be ([System.Security.AccessControl.FileSystemRights]::ReadData) + } + } + It 'Should add and remove a deny entry by its path without adding Synchronize' { $path = New-TestSandboxItem -Sandbox $sandbox -Name 'DenyByPath' $deny = [System.Security.AccessControl.AccessControlType]::Deny @@ -438,8 +455,11 @@ Describe 'Access rule helpers that take a path' { # RemoveSpecific removes only an entry that matches exactly; without it, Windows removes the named rights from the # matching entry. - It 'Should remove only an exactly matching entry with removeSpecific and the named rights without it' { - $path = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveByPath' + It 'Should remove only an exactly matching entry of a with removeSpecific and the named rights without it' -ForEach @( + @{ Kind = 'file'; Directory = $false } + @{ Kind = 'folder'; Directory = $true } + ) { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveByPath' -Directory:$Directory [void] [Security2.FileSystemAccessRule2]::AddFileSystemAccessRule( $path, $identity, [Security2.FileSystemRights2] 'ReadData, WriteData', $allow, $noInheritance, $noPropagation ) @@ -462,8 +482,11 @@ Describe 'Access rule helpers that take a path' { @(Get-ExplicitEntries -Path $path) | Should -BeNullOrEmpty } - It 'Should remove the entries of several accounts by path' { - $path = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveListByPath' + It 'Should remove the entries of several accounts of a by path' -ForEach @( + @{ Kind = 'file'; Directory = $false } + @{ Kind = 'folder'; Directory = $true } + ) { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveListByPath' -Directory:$Directory $accounts = New-AccountList @([Security2.FileSystemAccessRule2]::AddFileSystemAccessRule( $path, $accounts, [Security2.FileSystemRights2]::ReadData, $allow, $noInheritance, $noPropagation From 2909a1c3446bc6b06be081f3b5d1eec8bf655afc Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 15:16:49 +0000 Subject: [PATCH 12/28] fix: show unknown parent in InheritedFrom, and only for inherited entries When Windows cannot name the folder of an inherited entry, such as for an item that was deleted after its descriptor was read or for a folder above it that the user cannot read, the module fills InheritedFrom with a fallback text. The callers removed the last character of every source, which belongs to the trailing backslash of a real folder, so the fallback read 'unknown paren'. The fallback also named an unknown parent for explicit entries, which have no source. Remove only a trailing backslash, and name an unknown parent for inherited entries only. The regression tests fail without the fix in all four configurations for access entries and in both elevated configurations for audit entries. The cmdlet pages name the text, and the help file is generated again. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- CHANGELOG.md | 6 ++++- Docs/Cmdlets/Get-NTFSAccess.md | 4 +++- Docs/Cmdlets/Get-NTFSAudit.md | 4 +++- NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml | 6 +++-- ...temAccessRule2.GetFileSystemAccessRules.cs | 3 ++- ...ystemAuditRule2.GetFileSystemAuditRules.cs | 3 ++- Security2/Win32/Lib.cs | 4 +++- Tests/Access.Tests.ps1 | 24 +++++++++++++++++++ Tests/Audit.Tests.ps1 | 23 ++++++++++++++++++ 9 files changed, 69 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f97915f..bf3063e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,7 +118,11 @@ The format is based on after they took ownership of an item that the user owned already and left a DACL without the right to set an owner, they failed to set the same owner back - +- Fix `InheritedFrom` of `Get-NTFSAccess` and `Get-NTFSAudit` for an entry + whose folder Windows cannot name, such as for an item that was deleted + after it was read or a folder above it that the user cannot read: the text + read `unknown paren`, and the explicit entries showed it as well. An + inherited entry now shows `unknown parent`, and an explicit entry no source - Fix `Get-Help`, which showed only the syntax: ship the help file `en-US\NTFSSecurity.dll-Help.xml` generated from the cmdlet documentation, including the links that `Get-Help -Online` opens, instead of the outdated diff --git a/Docs/Cmdlets/Get-NTFSAccess.md b/Docs/Cmdlets/Get-NTFSAccess.md index 1b092c6..90835d1 100644 --- a/Docs/Cmdlets/Get-NTFSAccess.md +++ b/Docs/Cmdlets/Get-NTFSAccess.md @@ -33,7 +33,7 @@ In the `Path` parameter set the cmdlet reads the item from disk; relative paths By default both explicit and inherited entries are returned. `-ExcludeInherited` limits the result to the entries defined on the item itself, `-ExcludeExplicit` limits it to the entries the item inherits from its parents, and combining both returns nothing. `-Account` filters the result to a single account; an entry matches when the account resolves to the same SID. -When the module setting `GetInheritedFrom` is `$true`, which is the default in the `PrivateData` section of NTFSSecurity.psd1, the `InheritedFrom` property of every inherited entry contains the path of the folder the entry originates from. The default table view shows the account, the rights, the scope of the ACE in the wording of the Windows security dialog, the access type, and the inheritance information; setting `ShowAccountSid` to `$true` adds the SID to the account column. +When the module setting `GetInheritedFrom` is `$true`, which is the default in the `PrivateData` section of NTFSSecurity.psd1, the `InheritedFrom` property of every inherited entry contains the path of the folder the entry originates from, or `unknown parent` when Windows cannot name that folder, for example because the user cannot read a folder above the item. The default table view shows the account, the rights, the scope of the ACE in the wording of the Windows security dialog, the access type, and the inheritance information; setting `ShowAccountSid` to `$true` adds the SID to the account column. ## EXAMPLES @@ -188,6 +188,8 @@ Before 5.0.0, after a path whose ACL could not be read, the cmdlet returned the Before 5.0.0-rc6, with `-ExcludeExplicit`, each inherited entry showed the `InheritedFrom` path of another entry, and for a security descriptor with audit entries, such as one that `Get-NTFSSecurityDescriptor` reads in an elevated session, the cmdlet stopped with an `ArgumentOutOfRangeException`. +Before 5.0.0, when Windows could not name the folder of an inherited entry, `InheritedFrom` read `unknown paren`, and the explicit entries of the item showed it as well. + For the root of a drive, such as `C:\`, or of a volume, such as `\\?\Volume{GUID}\`, the cmdlets that read and change security use the root folder of the volume, like Explorer, `icacls`, and `Get-Acl`. Before 5.0.0, they read and changed the security descriptor of the drive itself, a device object with other entries. ## RELATED LINKS diff --git a/Docs/Cmdlets/Get-NTFSAudit.md b/Docs/Cmdlets/Get-NTFSAudit.md index bfbb742..0ad8fbf 100644 --- a/Docs/Cmdlets/Get-NTFSAudit.md +++ b/Docs/Cmdlets/Get-NTFSAudit.md @@ -33,7 +33,7 @@ In the `Path` parameter set the cmdlet reads the security descriptor of every it By default the cmdlet returns explicit and inherited entries. Use `-ExcludeInherited` to return only the entries that are set on the item itself, and `-ExcludeExplicit` to return only the entries that the item inherits from a parent folder. `-Account` filters the result to a single account; the comparison is made on the security identifier (SID), so an account name and its SID select the same entries. -The `InheritedFrom` property is filled only when the module setting `GetInheritedFrom` is `$true`, which is the default in the `PrivateData` section of `NTFSSecurity.psd1`. +The `InheritedFrom` property is filled only when the module setting `GetInheritedFrom` is `$true`, which is the default in the `PrivateData` section of `NTFSSecurity.psd1`. It contains `unknown parent` for an inherited entry when Windows cannot name the folder that the entry comes from. ## EXAMPLES @@ -187,6 +187,8 @@ Before 5.0.0, the cmdlet returned no entries and no error without the Security p Before 5.0.0-rc6, with `-ExcludeExplicit`, each inherited entry showed the `InheritedFrom` path of another entry. +Before 5.0.0, when Windows could not name the folder of an inherited entry, `InheritedFrom` read `unknown paren`, and the explicit entries of the item showed it as well. + ## RELATED LINKS [Add-NTFSAudit](Add-NTFSAudit.md) diff --git a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml index 251b553..cce32d4 100644 --- a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml +++ b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml @@ -4380,7 +4380,7 @@ PS C:\> Disable-Privileges Reads the discretionary access control list (DACL) of a file or a folder and writes one `Security2.FileSystemAccessRule2` object for every access control entry (ACE) it contains. Each object carries the account, the rights, the access type, the inheritance and propagation flags, whether the ACE is inherited, and the path of the item it was read from. In the `Path` parameter set the cmdlet reads the item from disk; relative paths are resolved against the current location, and when `-Path` is omitted the current location is used. In the `SD` parameter set it reads the ACEs from a `Security2.FileSystemSecurity2` object returned by `Get-NTFSSecurityDescriptor`, which also reflects changes that have not been written back yet. By default both explicit and inherited entries are returned. `-ExcludeInherited` limits the result to the entries defined on the item itself, `-ExcludeExplicit` limits it to the entries the item inherits from its parents, and combining both returns nothing. `-Account` filters the result to a single account; an entry matches when the account resolves to the same SID. - When the module setting `GetInheritedFrom` is `$true`, which is the default in the `PrivateData` section of NTFSSecurity.psd1, the `InheritedFrom` property of every inherited entry contains the path of the folder the entry originates from. The default table view shows the account, the rights, the scope of the ACE in the wording of the Windows security dialog, the access type, and the inheritance information; setting `ShowAccountSid` to `$true` adds the SID to the account column. + When the module setting `GetInheritedFrom` is `$true`, which is the default in the `PrivateData` section of NTFSSecurity.psd1, the `InheritedFrom` property of every inherited entry contains the path of the folder the entry originates from, or `unknown parent` when Windows cannot name that folder, for example because the user cannot read a folder above the item. The default table view shows the account, the rights, the scope of the ACE in the wording of the Windows security dialog, the access type, and the inheritance information; setting `ShowAccountSid` to `$true` adds the SID to the account column. @@ -4589,6 +4589,7 @@ PS C:\> Disable-Privileges Entries whose account cannot be translated into a name are returned with their SID. Use `Get-NTFSOrphanedAccess` to list only those entries. Before 5.0.0, after a path whose ACL could not be read, the cmdlet returned the entries of the previous item again. Before 5.0.0-rc6, with `-ExcludeExplicit`, each inherited entry showed the `InheritedFrom` path of another entry, and for a security descriptor with audit entries, such as one that `Get-NTFSSecurityDescriptor` reads in an elevated session, the cmdlet stopped with an `ArgumentOutOfRangeException`. + Before 5.0.0, when Windows could not name the folder of an inherited entry, `InheritedFrom` read `unknown paren`, and the explicit entries of the item showed it as well. For the root of a drive, such as `C:`, or of a volume, such as `\?\Volume{GUID}`, the cmdlets that read and change security use the root folder of the volume, like Explorer, `icacls`, and `Get-Acl`. Before 5.0.0, they read and changed the security descriptor of the drive itself, a device object with other entries. @@ -4666,7 +4667,7 @@ PS C:\> Disable-Privileges The `Get-NTFSAudit` cmdlet returns the audit entries that are stored in the system access control list (SACL) of a file or folder. Each entry is a `Security2.FileSystemAuditRule2` object that reports the audited account, the audited access rights, the audit flags (`Success`, `Failure`, or both), the inheritance and propagation flags, whether the entry is inherited, and the item it is inherited from. The access rights are the same values that `Add-NTFSAccess` and `Add-NTFSAudit` use; for what each right permits, see Concepts (../Concepts.md). In the `Path` parameter set the cmdlet reads the security descriptor of every item in `-Path`. Relative paths are resolved against the current location, and when you omit `-Path` the cmdlet uses the current location. The parameter accepts pipeline input by value and by property name through its `FullName` alias, so the output of `Get-ChildItem`, `Get-ChildItem2`, and `Get-Item2` binds to it. In the `SD` parameter set the cmdlet reads the audit entries from an in-memory `Security2.FileSystemSecurity2` object that `Get-NTFSSecurityDescriptor` returned instead of reading the item again. By default the cmdlet returns explicit and inherited entries. Use `-ExcludeInherited` to return only the entries that are set on the item itself, and `-ExcludeExplicit` to return only the entries that the item inherits from a parent folder. `-Account` filters the result to a single account; the comparison is made on the security identifier (SID), so an account name and its SID select the same entries. - The `InheritedFrom` property is filled only when the module setting `GetInheritedFrom` is `$true`, which is the default in the `PrivateData` section of `NTFSSecurity.psd1`. + The `InheritedFrom` property is filled only when the module setting `GetInheritedFrom` is `$true`, which is the default in the `PrivateData` section of `NTFSSecurity.psd1`. It contains `unknown parent` for an inherited entry when Windows cannot name the folder that the entry comes from. @@ -4873,6 +4874,7 @@ PS C:\> Disable-Privileges If reading the audit entries is denied, the cmdlet writes a `ReadSecurityError` with the category `PermissionDenied`. It doesn't take ownership of the item, because ownership grants no access to the SACL. Before 5.0.0, the cmdlet returned no entries and no error without the Security privilege, and after a path whose security descriptor could not be read, it returned the entries of the previous item again. The `InheritanceEnabled` property of the entries also reported whether the access entries were inherited instead of the audit entries. Before 5.0.0-rc6, with `-ExcludeExplicit`, each inherited entry showed the `InheritedFrom` path of another entry. + Before 5.0.0, when Windows could not name the folder of an inherited entry, `InheritedFrom` read `unknown paren`, and the explicit entries of the item showed it as well. diff --git a/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.GetFileSystemAccessRules.cs b/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.GetFileSystemAccessRules.cs index eb0c35c..8946af0 100644 --- a/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.GetFileSystemAccessRules.cs +++ b/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.GetFileSystemAccessRules.cs @@ -43,7 +43,8 @@ namespace Security2 var ace2 = new FileSystemAccessRule2(ace) { FullName = sd.Item.FullName, InheritanceEnabled = !sd.SecurityDescriptor.AreAccessRulesProtected }; if (getInheritedFrom && inheritedFrom.Count > 0) { - ace2.inheritedFrom = string.IsNullOrEmpty(source) ? "" : source.Substring(0, source.Length - 1); + // Windows names a folder with a trailing backslash; the text for an unknown parent has none. + ace2.inheritedFrom = string.IsNullOrEmpty(source) ? "" : source.TrimEnd('\\'); } aceList.Add(ace2); diff --git a/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.GetFileSystemAuditRules.cs b/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.GetFileSystemAuditRules.cs index 772c509..fa18d6c 100644 --- a/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.GetFileSystemAuditRules.cs +++ b/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.GetFileSystemAuditRules.cs @@ -43,7 +43,8 @@ namespace Security2 var ace2 = new FileSystemAuditRule2(ace) { FullName = sd.Item.FullName, InheritanceEnabled = !sd.SecurityDescriptor.AreAuditRulesProtected }; if (getInheritedFrom && inheritedFrom.Count > 0) { - ace2.inheritedFrom = string.IsNullOrEmpty(source) ? "" : source.Substring(0, source.Length - 1); + // Windows names a folder with a trailing backslash; the text for an unknown parent has none. + ace2.inheritedFrom = string.IsNullOrEmpty(source) ? "" : source.TrimEnd('\\'); } aceList.Add(ace2); diff --git a/Security2/Win32/Lib.cs b/Security2/Win32/Lib.cs index 94d630e..3cd29e5 100644 --- a/Security2/Win32/Lib.cs +++ b/Security2/Win32/Lib.cs @@ -45,10 +45,12 @@ namespace Security2 } catch { + // Windows can't name the folders, for example because the item is gone or a folder above it can't + // be read. An explicit entry has no source in any case. inheritedFrom = new List(); for (int i = 0; i < aceCount; i++) { - inheritedFrom.Add("unknown parent"); + inheritedFrom.Add(acl[i].IsInherited ? "unknown parent" : string.Empty); } } } diff --git a/Tests/Access.Tests.ps1 b/Tests/Access.Tests.ps1 index 493b40a..6972150 100644 --- a/Tests/Access.Tests.ps1 +++ b/Tests/Access.Tests.ps1 @@ -969,6 +969,30 @@ Describe 'InheritedFrom of access entries' { $inherited | ForEach-Object -Process { $_.InheritedFrom | Should -BeNullOrEmpty } } + # Windows can't name the folders when the item is gone, for example deleted by another process after its security + # descriptor was read, or when a folder above it can't be read. The entries still come back. Before 5.0.0, the + # text lost its last character, and an explicit entry, which has no source, got it as well. + It 'Should name an unknown parent for an inherited entry and no source for an explicit entry when Windows cannot resolve the folders' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'InheritedFromGone' -Directory + $file = Join-Path -Path $folder -ChildPath 'Gone.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + Set-Content -LiteralPath $file -Value 'Gone' + Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData -ErrorAction Stop + $sd = Get-NTFSSecurityDescriptor -Path $file -ErrorAction Stop + Remove-Item -LiteralPath $file -Force + + $entries = @([Security2.FileSystemAccessRule2]::GetFileSystemAccessRules($sd, $true, $true, $true)) + + $inherited = @($entries | Where-Object -FilterScript { $_.IsInherited }) + $inherited | Should -Not -BeNullOrEmpty + foreach ($entry in $inherited) { + $entry.InheritedFrom | Should -BeExactly 'unknown parent' + } + $explicit = @($entries | Where-Object -FilterScript { -not $_.IsInherited }) + $explicit | Should -HaveCount 1 + $explicit[0].InheritedFrom | Should -BeNullOrEmpty + } + It 'Should read a security descriptor with audit entries and name the same folders' -Skip:(-not $holdsSecurityPrivilege) { $file = New-TestSandboxItem -Sandbox $sandbox -Name 'InheritedFromAudit' Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData diff --git a/Tests/Audit.Tests.ps1 b/Tests/Audit.Tests.ps1 index 9bd4218..bd23df2 100644 --- a/Tests/Audit.Tests.ps1 +++ b/Tests/Audit.Tests.ps1 @@ -512,6 +512,29 @@ Describe 'InheritedFrom of audit entries' { $inherited[0].InheritedFrom | Should -Be $folder } + # Windows names the folders of audit entries only for a caller whose Security privilege is enabled, and the cmdlets + # enable it. A caller of the library that doesn't gets the entries without sources. Before 5.0.0, the text lost its + # last character, and an explicit entry, which has no source, got it as well. + It 'Should name an unknown parent for an inherited entry and no source for an explicit entry when the privilege is disabled' -Skip:(-not $canReadAudit) { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'InheritedFromDisabled' -Directory + Add-NTFSAudit -Path $folder -Account 'S-1-1-0' -AccessRights ReadData -InheritanceFlags 'ContainerInherit, ObjectInherit' -PropagationFlags None + $file = Join-Path -Path $folder -ChildPath 'File.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + Set-Content -LiteralPath $file -Value 'File' + Add-NTFSAudit -Path $file -Account 'S-1-5-32-546' -AccessRights Delete -InheritanceFlags None -PropagationFlags None + $sd = Get-NTFSSecurityDescriptor -Path $file -ErrorAction Stop + Disable-Privileges -ErrorAction SilentlyContinue -WarningAction SilentlyContinue + + $entries = @([Security2.FileSystemAuditRule2]::GetFileSystemAuditRules($sd, $true, $true, $true)) + + $inherited = @($entries | Where-Object -FilterScript { $_.IsInherited }) + $inherited | Should -HaveCount 1 + $inherited[0].InheritedFrom | Should -BeExactly 'unknown parent' + $explicit = @($entries | Where-Object -FilterScript { -not $_.IsInherited }) + $explicit | Should -HaveCount 1 + $explicit[0].InheritedFrom | Should -BeNullOrEmpty + } + # The module setting GetInheritedFrom turns off the lookup of the sources, which costs a call for each item. It 'Should leave InheritedFrom empty when the module setting GetInheritedFrom is off' -Skip:(-not $canReadAudit) { $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'InheritedFromOff' -Directory From c77ecbf1a96880c9429acbb541b8ae41d456b26b Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 15:31:29 +0000 Subject: [PATCH 13/28] fix: end the cmdlet when a later command ends the pipeline A catch for the failures of an item wrapped the write of its object. When a later command ended the pipeline, a break or continue in a script block or Select-Object -First, the exception passed through that catch, which reported it as an error of the item and went on with the next one. Remove-Item2, Copy-Item2, and Move-Item2 with -PassThru then removed, copied, or moved every item although the caller had ended the pipeline, and Set-NTFSOwner and Set-NTFSSecurityDescriptor changed every item. Get-NTFSSecurityDescriptor, Get-NTFSSimpleAccess, and Get-DiskSpace ignored the break, and Get-ChildItem2 ignored it for items below the first folder. A helper recognizes the end of the pipeline and the control-flow exceptions of PowerShell by their base type, and these catches pass them on. The new table-driven tests run every cmdlet that writes objects: 26 cases for the nine cmdlets fail without the fix in all four configurations and the 64 cases of the others pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- CHANGELOG.md | 7 + NTFSSecurity/BaseCmdlets.cs | 31 ++ NTFSSecurity/ItemCmdlets/CopyItem2.cs | 5 + NTFSSecurity/ItemCmdlets/GetChildItem2.cs | 15 +- NTFSSecurity/ItemCmdlets/GetDiskSpace.cs | 8 +- NTFSSecurity/ItemCmdlets/MoveItem2.cs | 5 + NTFSSecurity/ItemCmdlets/RemoveItem2.cs | 5 + NTFSSecurity/OwnerCmdlets/SetOwner.cs | 5 + .../GetSecurityDescriptor.cs | 10 + .../SetSecurityDescriptor.cs | 5 + .../SimpleAccessCmdlets.cs | 5 + Tests/PipelineControl.Tests.ps1 | 333 ++++++++++++++++++ 12 files changed, 429 insertions(+), 5 deletions(-) create mode 100644 Tests/PipelineControl.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index bf3063e..fa997d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,6 +123,13 @@ The format is based on after it was read or a folder above it that the user cannot read: the text read `unknown paren`, and the explicit entries showed it as well. An inherited entry now shows `unknown parent`, and an explicit entry no source +- Fix `Remove-Item2`, `Copy-Item2`, `Move-Item2`, `Set-NTFSOwner`, + `Set-NTFSSecurityDescriptor`, `Get-NTFSSecurityDescriptor`, + `Get-NTFSSimpleAccess`, `Get-DiskSpace`, and `Get-ChildItem2` below the + first folder, which went on with the next item when a later command ended + the pipeline: a `break` or `continue` or `Select-Object -First` became an + error of the item, so that `Remove-Item2 -PassThru | Select-Object -First 1` + removed every item. They now stop and write no error - Fix `Get-Help`, which showed only the syntax: ship the help file `en-US\NTFSSecurity.dll-Help.xml` generated from the cmdlet documentation, including the links that `Get-Help -Online` opens, instead of the outdated diff --git a/NTFSSecurity/BaseCmdlets.cs b/NTFSSecurity/BaseCmdlets.cs index 07e7040..9258d5a 100644 --- a/NTFSSecurity/BaseCmdlets.cs +++ b/NTFSSecurity/BaseCmdlets.cs @@ -8,6 +8,37 @@ using System.Collections; namespace NTFSSecurity { + /// + /// Recognizes what a later command in the pipeline raises to end the pipeline or the loop around it: the end of the + /// pipeline, for example for Select-Object -First, and a break or continue in a script block. These exceptions pass + /// through a cmdlet while it writes an object. A catch for the failures of an item must pass them on: reported as + /// the error of that item, they would end nothing, and the cmdlet would go on with the next item. + /// + internal static class PipelineControl + { + /// + /// Whether the exception ends the pipeline or the loop around it. PowerShell doesn't make the exceptions of break + /// and continue public, so they are recognized by the name of their base type. + /// + internal static bool IsEnd(Exception exception) + { + if (exception is PipelineStoppedException) + { + return true; + } + + for (var type = exception.GetType(); type != null; type = type.BaseType) + { + if (type.FullName == "System.Management.Automation.FlowControlException") + { + return true; + } + } + + return false; + } + } + public class BaseCmdlet : PSCmdlet { protected List paths = new List(); diff --git a/NTFSSecurity/ItemCmdlets/CopyItem2.cs b/NTFSSecurity/ItemCmdlets/CopyItem2.cs index 064f888..3541113 100644 --- a/NTFSSecurity/ItemCmdlets/CopyItem2.cs +++ b/NTFSSecurity/ItemCmdlets/CopyItem2.cs @@ -149,6 +149,11 @@ namespace NTFSSecurity } catch (Exception ex) { + if (PipelineControl.IsEnd(ex)) + { + throw; + } + WriteError(new ErrorRecord(ex, "CopyError", ErrorCategory.NotSpecified, resolvedPath)); } } diff --git a/NTFSSecurity/ItemCmdlets/GetChildItem2.cs b/NTFSSecurity/ItemCmdlets/GetChildItem2.cs index 49d915a..ac8a3da 100644 --- a/NTFSSecurity/ItemCmdlets/GetChildItem2.cs +++ b/NTFSSecurity/ItemCmdlets/GetChildItem2.cs @@ -244,8 +244,15 @@ namespace NTFSSecurity { throw ex; } - catch (Exception) + catch (Exception ex) { + // Not what a later command raises to end the pipeline or the loop around it, which this catch + // would hide; the verbose message is for a folder that can't be listed. + if (PipelineControl.IsEnd(ex)) + { + throw; + } + WriteVerbose(string.Format("Cannot access folder '{0}' for recursive operation", di)); } } @@ -260,11 +267,11 @@ namespace NTFSSecurity } catch (Exception ex) { - //System.Management.Automation.BreakException or System.Management.Automation.ContinueException cannot be caught due to its protection level in PowerShell v2 - if (ex.GetType().FullName == "System.Management.Automation.BreakException" | ex.GetType().FullName == "System.Management.Automation.ContinueException") + if (PipelineControl.IsEnd(ex)) { - throw ex; + throw; } + WriteError(new ErrorRecord(ex, "DirUnspecifiedError", ErrorCategory.NotSpecified, di.FullName)); } } diff --git a/NTFSSecurity/ItemCmdlets/GetDiskSpace.cs b/NTFSSecurity/ItemCmdlets/GetDiskSpace.cs index c5af08d..7a66305 100644 --- a/NTFSSecurity/ItemCmdlets/GetDiskSpace.cs +++ b/NTFSSecurity/ItemCmdlets/GetDiskSpace.cs @@ -1,4 +1,5 @@ using Alphaleonis.Win32.Filesystem; +using System; using System.Linq; using System.Management.Automation; @@ -43,8 +44,13 @@ namespace NTFSSecurity this.WriteObject(diskSpaceInfo); } } - catch + catch (Exception ex) { + if (PipelineControl.IsEnd(ex)) + { + throw; + } + this.WriteWarning(string.Format("Could not get drive details for '{0}'", letter)); } } diff --git a/NTFSSecurity/ItemCmdlets/MoveItem2.cs b/NTFSSecurity/ItemCmdlets/MoveItem2.cs index fe7a61a..8c8a112 100644 --- a/NTFSSecurity/ItemCmdlets/MoveItem2.cs +++ b/NTFSSecurity/ItemCmdlets/MoveItem2.cs @@ -160,6 +160,11 @@ namespace NTFSSecurity } catch (Exception ex) { + if (PipelineControl.IsEnd(ex)) + { + throw; + } + WriteError(new ErrorRecord(ex, "MoveError", ErrorCategory.NotSpecified, resolvedPath)); } } diff --git a/NTFSSecurity/ItemCmdlets/RemoveItem2.cs b/NTFSSecurity/ItemCmdlets/RemoveItem2.cs index 59db095..788b4fb 100644 --- a/NTFSSecurity/ItemCmdlets/RemoveItem2.cs +++ b/NTFSSecurity/ItemCmdlets/RemoveItem2.cs @@ -102,6 +102,11 @@ namespace NTFSSecurity } catch (Exception ex) { + if (PipelineControl.IsEnd(ex)) + { + throw; + } + WriteError(new ErrorRecord(ex, "DeleteError", ErrorCategory.NotSpecified, path)); } } diff --git a/NTFSSecurity/OwnerCmdlets/SetOwner.cs b/NTFSSecurity/OwnerCmdlets/SetOwner.cs index 705e250..cd69449 100644 --- a/NTFSSecurity/OwnerCmdlets/SetOwner.cs +++ b/NTFSSecurity/OwnerCmdlets/SetOwner.cs @@ -87,6 +87,11 @@ namespace NTFSSecurity } catch (Exception ex) { + if (PipelineControl.IsEnd(ex)) + { + throw; + } + WriteError(new ErrorRecord(ex, "SetOwnerError", ErrorCategory.WriteError, path)); continue; } diff --git a/NTFSSecurity/SecurityDescriptorCmdlets/GetSecurityDescriptor.cs b/NTFSSecurity/SecurityDescriptorCmdlets/GetSecurityDescriptor.cs index 4d2f634..270e37e 100644 --- a/NTFSSecurity/SecurityDescriptorCmdlets/GetSecurityDescriptor.cs +++ b/NTFSSecurity/SecurityDescriptorCmdlets/GetSecurityDescriptor.cs @@ -64,12 +64,22 @@ namespace NTFSSecurity } catch (Exception ex2) { + if (PipelineControl.IsEnd(ex2)) + { + throw; + } + WriteError(new ErrorRecord(ex2, "ReadSecurityError", ErrorCategory.WriteError, path)); continue; } } catch (Exception ex) { + if (PipelineControl.IsEnd(ex)) + { + throw; + } + WriteError(new ErrorRecord(ex, "ReadSecurityError", ErrorCategory.OpenError, path)); } } diff --git a/NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs b/NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs index b3a6100..ef4c70d 100644 --- a/NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs +++ b/NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs @@ -81,6 +81,11 @@ namespace NTFSSecurity } catch (Exception ex) { + if (PipelineControl.IsEnd(ex)) + { + throw; + } + WriteError(new ErrorRecord(ex, "ReadSecurityError", ErrorCategory.ReadError, sd.Item)); } } diff --git a/NTFSSecurity/SimpleAccessCmdlets/SimpleAccessCmdlets.cs b/NTFSSecurity/SimpleAccessCmdlets/SimpleAccessCmdlets.cs index 0da312e..61fd4eb 100644 --- a/NTFSSecurity/SimpleAccessCmdlets/SimpleAccessCmdlets.cs +++ b/NTFSSecurity/SimpleAccessCmdlets/SimpleAccessCmdlets.cs @@ -111,6 +111,11 @@ namespace NTFSSecurity } catch (Exception ex) { + if (PipelineControl.IsEnd(ex)) + { + throw; + } + WriteError(new ErrorRecord(ex, "ReadError", ErrorCategory.OpenError, p)); } } diff --git a/Tests/PipelineControl.Tests.ps1 b/Tests/PipelineControl.Tests.ps1 new file mode 100644 index 0000000..b69d9af --- /dev/null +++ b/Tests/PipelineControl.Tests.ps1 @@ -0,0 +1,333 @@ +<# + Tests how the cmdlets of the module built in NTFSSecurity\bin\Release behave when a later command in the pipeline + ends it: a break or continue in a script block, or Select-Object -First. The exception that carries it passes through + the cmdlet while it writes an object. A catch for the failures of an item must not report it as an error of that item + and go on with the next one: a cmdlet that removes, copies, moves, or changes items would change them all, although + the caller ended the pipeline. Every test works on files and folders in a sandbox. +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.' +)] +param () + +BeforeDiscovery { + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force + $canReadAudit = Test-PrivilegeHeld -Name 'SeSecurityPrivilege' + + $names = @( + 'Get-ChildItem2', 'Get-DiskSpace', 'Get-FileHash2', 'Get-Item2', 'Get-NTFSAccess', 'Get-NTFSEffectiveAccess', + 'Get-NTFSHardLink', 'Get-NTFSInheritance', 'Get-NTFSOrphanedAccess', 'Get-NTFSOwner', 'Get-NTFSSecurityDescriptor', + 'Get-NTFSSimpleAccess', 'Get-Privileges', 'Test-Path2', 'Add-NTFSAccess', 'Remove-NTFSAccess', + 'Disable-NTFSAccessInheritance', 'Enable-NTFSAccessInheritance', 'Set-NTFSInheritance', 'Set-NTFSOwner', + 'Set-NTFSSecurityDescriptor', 'Copy-Item2', 'Move-Item2', 'Remove-Item2' + ) + $auditNames = @( + 'Get-NTFSAudit', 'Get-NTFSOrphanedAudit', 'Add-NTFSAudit', 'Remove-NTFSAudit', 'Disable-NTFSAuditInheritance', + 'Enable-NTFSAuditInheritance' + ) + $loopCases = foreach ($name in $names) { + foreach ($keyword in 'break', 'continue') { + @{ Name = $name; Keyword = $keyword } + } + } + $stopCases = foreach ($name in $names) { + @{ Name = $name } + } + $auditLoopCases = foreach ($name in $auditNames) { + foreach ($keyword in 'break', 'continue') { + @{ Name = $name; Keyword = $keyword } + } + } + $auditStopCases = foreach ($name in $auditNames) { + @{ Name = $name } + } +} + +BeforeAll { + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force + $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\NTFSSecurity\bin\Release\NTFSSecurity.psd1' + Import-Module -Name $modulePath -Force -ErrorAction Stop + $sandbox = New-TestSandbox -Name 'PipelineControl' + Push-Location -LiteralPath $sandbox + + $sidType = [System.Security.Principal.SecurityIdentifier] + $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value + $orphan = 'S-1-5-21-1-2-3-1001' + + function New-Pair { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Test helper that only writes to the sandbox.' + )] + param ([switch] $Directory) + + @{ + First = New-TestSandboxItem -Sandbox $sandbox -Name 'First' -Directory:$Directory + Second = New-TestSandboxItem -Sandbox $sandbox -Name 'Second' -Directory:$Directory + } + } + + function Test-ExplicitEntry { + param ([string] $Path, [string] $Account) + + @((Get-Acl -LiteralPath $Path).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq $Account }).Count -gt 0 + } + + # Each case runs one command over the two items of its context. Untouched tells whether the second item is as it + # was, which it is only when the command stopped after the first one. + $cases = @{ + 'Get-ChildItem2' = @{ + # The first file is two levels below the folder, so the exception passes the frames of the recursion. + Prepare = { + $top = New-TestSandboxItem -Sandbox $sandbox -Name 'Tree' -Directory + foreach ($relative in 'A\B\Two.txt', 'C\Three.txt') { + $file = Join-Path -Path $top -ChildPath $relative + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + New-Item -ItemType Directory -Path (Split-Path -Path $file -Parent) -Force | Out-Null + Set-Content -LiteralPath $file -Value 'Tree' + } + @{ Top = $top } + } + Run = { param ($Context) Get-ChildItem2 -Path $Context.Top -Recurse -File -ErrorAction SilentlyContinue } + } + 'Get-DiskSpace' = @{ + Prepare = { @{} } + Run = { Get-DiskSpace -ErrorAction SilentlyContinue } + } + 'Get-FileHash2' = @{ + Prepare = { New-Pair } + Run = { param ($Context) Get-FileHash2 -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue } + } + 'Get-Item2' = @{ + Prepare = { New-Pair } + Run = { param ($Context) Get-Item2 -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue } + } + 'Get-NTFSAccess' = @{ + Prepare = { New-Pair } + Run = { param ($Context) Get-NTFSAccess -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue } + } + 'Get-NTFSEffectiveAccess' = @{ + Prepare = { New-Pair } + Run = { param ($Context) Get-NTFSEffectiveAccess -Path $Context.First, $Context.Second -WarningAction SilentlyContinue -ErrorAction SilentlyContinue } + } + 'Get-NTFSHardLink' = @{ + Prepare = { New-Pair } + Run = { param ($Context) Get-NTFSHardLink -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue } + } + 'Get-NTFSInheritance' = @{ + Prepare = { New-Pair } + Run = { param ($Context) Get-NTFSInheritance -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue } + } + 'Get-NTFSOrphanedAccess' = @{ + Prepare = { + $context = New-Pair + Add-NTFSAccess -Path $context.First, $context.Second -Account $orphan -AccessRights ReadData -ErrorAction Stop + $context + } + Run = { param ($Context) Get-NTFSOrphanedAccess -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue } + } + 'Get-NTFSOwner' = @{ + Prepare = { New-Pair } + Run = { param ($Context) Get-NTFSOwner -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue } + } + 'Get-NTFSSecurityDescriptor' = @{ + Prepare = { New-Pair } + Run = { param ($Context) Get-NTFSSecurityDescriptor -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue } + } + 'Get-NTFSSimpleAccess' = @{ + Prepare = { New-Pair -Directory } + Run = { param ($Context) Get-NTFSSimpleAccess -Path $Context.First, $Context.Second -IncludeRootFolder:$false -ErrorAction SilentlyContinue } + } + 'Get-Privileges' = @{ + Prepare = { @{} } + Run = { Get-Privileges -ErrorAction SilentlyContinue } + } + 'Test-Path2' = @{ + Prepare = { New-Pair } + Run = { param ($Context) Test-Path2 -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue } + } + 'Add-NTFSAccess' = @{ + Prepare = { New-Pair } + Run = { param ($Context) Add-NTFSAccess -Path $Context.First, $Context.Second -Account 'S-1-1-0' -AccessRights ReadData -PassThru -ErrorAction SilentlyContinue } + Untouched = { param ($Context) -not (Test-ExplicitEntry -Path $Context.Second -Account 'S-1-1-0') } + } + 'Remove-NTFSAccess' = @{ + Prepare = { + $context = New-Pair + Add-NTFSAccess -Path $context.First, $context.Second -Account 'S-1-1-0' -AccessRights ReadData -ErrorAction Stop + $context + } + Run = { param ($Context) Remove-NTFSAccess -Path $Context.First, $Context.Second -Account 'S-1-1-0' -AccessRights ReadData -PassThru -ErrorAction SilentlyContinue } + Untouched = { param ($Context) Test-ExplicitEntry -Path $Context.Second -Account 'S-1-1-0' } + } + 'Disable-NTFSAccessInheritance' = @{ + Prepare = { New-Pair } + Run = { param ($Context) Disable-NTFSAccessInheritance -Path $Context.First, $Context.Second -PassThru -ErrorAction SilentlyContinue } + Untouched = { param ($Context) -not (Get-Acl -LiteralPath $Context.Second).AreAccessRulesProtected } + } + 'Enable-NTFSAccessInheritance' = @{ + Prepare = { + $context = New-Pair + Disable-NTFSAccessInheritance -Path $context.First, $context.Second -ErrorAction Stop + $context + } + Run = { param ($Context) Enable-NTFSAccessInheritance -Path $Context.First, $Context.Second -PassThru -ErrorAction SilentlyContinue } + Untouched = { param ($Context) (Get-Acl -LiteralPath $Context.Second).AreAccessRulesProtected } + } + 'Set-NTFSInheritance' = @{ + Prepare = { New-Pair } + Run = { param ($Context) Set-NTFSInheritance -Path $Context.First, $Context.Second -AccessInheritanceEnabled $false -PassThru -ErrorAction SilentlyContinue } + Untouched = { param ($Context) -not (Get-Acl -LiteralPath $Context.Second).AreAccessRulesProtected } + } + 'Set-NTFSOwner' = @{ + Prepare = { New-Pair } + Run = { param ($Context) Set-NTFSOwner -Path $Context.First, $Context.Second -Account $currentUser -PassThru -ErrorAction SilentlyContinue } + } + 'Set-NTFSSecurityDescriptor' = @{ + Prepare = { + $context = New-Pair + $context.Descriptors = @(Get-NTFSSecurityDescriptor -Path $context.First, $context.Second -ErrorAction Stop) + Add-NTFSAccess -SecurityDescriptor $context.Descriptors -Account 'S-1-1-0' -AccessRights ReadData -ErrorAction Stop + $context + } + Run = { param ($Context) Set-NTFSSecurityDescriptor -SecurityDescriptor $Context.Descriptors -PassThru -ErrorAction SilentlyContinue } + Untouched = { param ($Context) -not (Test-ExplicitEntry -Path $Context.Second -Account 'S-1-1-0') } + } + 'Copy-Item2' = @{ + Prepare = { + $context = New-Pair + $context.Destination = New-TestSandboxItem -Sandbox $sandbox -Name 'CopyTo' -Directory + $context + } + Run = { param ($Context) Copy-Item2 -Path $Context.First, $Context.Second -Destination $Context.Destination -PassThru $true -ErrorAction SilentlyContinue } + Untouched = { param ($Context) -not (Test-Path -LiteralPath (Join-Path -Path $Context.Destination -ChildPath (Split-Path -Path $Context.Second -Leaf))) } + } + 'Move-Item2' = @{ + Prepare = { + $context = New-Pair + $context.Destination = New-TestSandboxItem -Sandbox $sandbox -Name 'MoveTo' -Directory + $context + } + Run = { param ($Context) Move-Item2 -Path $Context.First, $Context.Second -Destination $Context.Destination -PassThru $true -ErrorAction SilentlyContinue } + Untouched = { param ($Context) Test-Path -LiteralPath $Context.Second } + } + 'Remove-Item2' = @{ + Prepare = { New-Pair } + Run = { param ($Context) Remove-Item2 -Path $Context.First, $Context.Second -PassThru -ErrorAction SilentlyContinue } + Untouched = { param ($Context) Test-Path -LiteralPath $Context.Second } + } + 'Get-NTFSAudit' = @{ + Prepare = { + $context = New-Pair + Add-NTFSAudit -Path $context.First, $context.Second -Account 'S-1-1-0' -AccessRights Delete -AuditFlags Success -ErrorAction Stop + $context + } + Run = { param ($Context) Get-NTFSAudit -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue } + } + 'Get-NTFSOrphanedAudit' = @{ + Prepare = { + $context = New-Pair + Add-NTFSAudit -Path $context.First, $context.Second -Account $orphan -AccessRights Delete -AuditFlags Success -ErrorAction Stop + $context + } + Run = { param ($Context) Get-NTFSOrphanedAudit -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue } + } + 'Add-NTFSAudit' = @{ + Prepare = { New-Pair } + Run = { param ($Context) Add-NTFSAudit -Path $Context.First, $Context.Second -Account 'S-1-1-0' -AccessRights Delete -AuditFlags Success -PassThru -ErrorAction SilentlyContinue } + Untouched = { param ($Context) @(Get-NTFSAudit -Path $Context.Second -ErrorAction Stop).Count -eq 0 } + } + 'Remove-NTFSAudit' = @{ + # The entry of the orphan goes; the one of Everyone stays, so that there is an object to write. + Prepare = { + $context = New-Pair + foreach ($account in $orphan, 'S-1-1-0') { + Add-NTFSAudit -Path $context.First, $context.Second -Account $account -AccessRights Delete -AuditFlags Success -ErrorAction Stop + } + $context + } + Run = { param ($Context) Remove-NTFSAudit -Path $Context.First, $Context.Second -Account $orphan -AccessRights Delete -AuditFlags Success -PassThru -ErrorAction SilentlyContinue } + Untouched = { param ($Context) @(Get-NTFSAudit -Path $Context.Second -Account $orphan -ErrorAction Stop).Count -gt 0 } + } + 'Disable-NTFSAuditInheritance' = @{ + Prepare = { New-Pair } + Run = { param ($Context) Disable-NTFSAuditInheritance -Path $Context.First, $Context.Second -PassThru -ErrorAction SilentlyContinue } + Untouched = { param ($Context) (Get-NTFSInheritance -Path $Context.Second -ErrorAction Stop).AuditInheritanceEnabled } + } + 'Enable-NTFSAuditInheritance' = @{ + Prepare = { + $context = New-Pair + Disable-NTFSAuditInheritance -Path $context.First, $context.Second -ErrorAction Stop + $context + } + Run = { param ($Context) Enable-NTFSAuditInheritance -Path $Context.First, $Context.Second -PassThru -ErrorAction SilentlyContinue } + Untouched = { param ($Context) -not (Get-NTFSInheritance -Path $Context.Second -ErrorAction Stop).AuditInheritanceEnabled } + } + } + + # The command writes its first object, and the break or continue of the later command ends the loop around the + # pipeline before the next statement of the loop runs. + function Assert-LoopControl { + param ([string] $Name, [string] $Keyword) + + $case = $cases[$Name] + $context = & $case.Prepare + $emitted = 0 + $reachedEnd = $false + $Error.Clear() + foreach ($round in 1) { + & $case.Run $context | ForEach-Object -Process { + $emitted++ + if ($Keyword -eq 'break') { break } else { continue } + } + $reachedEnd = $true + } + + $emitted | Should -Be 1 + $reachedEnd | Should -BeFalse + $Error.Count | Should -Be 0 + if ($case.Untouched) { + (& $case.Untouched $context) | Should -BeTrue + } + } + + function Assert-PipelineStop { + param ([string] $Name) + + $case = $cases[$Name] + $context = & $case.Prepare + $Error.Clear() + + $result = @(& $case.Run $context | Select-Object -First 1) + + $result | Should -HaveCount 1 + $Error.Count | Should -Be 0 + if ($case.Untouched) { + (& $case.Untouched $context) | Should -BeTrue + } + } +} + +AfterAll { + Pop-Location + Remove-TestSandbox -Sandbox $sandbox + Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue +} + +Describe 'A later command that ends the pipeline' { + It ' should leave the loop for after its first object and change nothing else' -ForEach $loopCases { + Assert-LoopControl -Name $Name -Keyword $Keyword + } + + It ' should stop after the first object for Select-Object -First 1 and change nothing else' -ForEach $stopCases { + Assert-PipelineStop -Name $Name + } + + It ' should leave the loop for after its first object and change nothing else' -Skip:(-not $canReadAudit) -ForEach $auditLoopCases { + Assert-LoopControl -Name $Name -Keyword $Keyword + } + + It ' should stop after the first object for Select-Object -First 1 and change nothing else' -Skip:(-not $canReadAudit) -ForEach $auditStopCases { + Assert-PipelineStop -Name $Name + } +} From 630926f897c5a28cd10d9814efcc0e00799f85c0 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 15:35:09 +0000 Subject: [PATCH 14/28] test: cover token handles, drive-root writes, name filters, and descriptor APIs New guards cover the default AllAccess token handle, token handles that lack the right to query or to adjust privileges, and the finalizer of an abandoned PrivilegeEnabler, which closes the handle that it owns; writes to the root of a drive that subst maps to a sandbox folder, which skip for the restricted token of the basic-user runner that cannot define a drive letter; Get-ChildItem2 -Filter against a match by short name; retargeting a descriptor with Item; the path overloads of the inheritance helpers for a missing path and of the audit rule reader for folders; removing a generic right while another account holds an exact entry; and an empty -ServerName. Follow-ups of the independent review: dispose the enabler in the finally block before its handle, don't pin that the iterator overloads write only when enumerated, arrange the previous owner of the RestoreOwnerError test, and describe what ObjectApis.Tests.ps1 does. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/Access.Tests.ps1 | 32 +++++++++++ Tests/DriveRoot.Tests.ps1 | 70 ++++++++++++++++++++++- Tests/ItemCmdlets.Tests.ps1 | 20 +++++++ Tests/ObjectApis.Tests.ps1 | 49 +++++++++++++--- Tests/PathErrors.Tests.ps1 | 3 +- Tests/Privileges.Tests.ps1 | 104 ++++++++++++++++++++++++++++++++++ Tests/TestHelpers.psm1 | 109 +++++++++++++++++++++++++++++++++++- 7 files changed, 376 insertions(+), 11 deletions(-) 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 From ee7c105d40a39038c9aadff5fbf94a1de1080172 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 15:45:04 +0000 Subject: [PATCH 15/28] fix: treat brackets in the filter of Get-ChildItem2 as characters The cmdlet compares the name of every item that the enumeration returns with the pattern again, and it built that comparison with the wildcard syntax of PowerShell. A bracket then began a character class, so Report[1].txt was returned by the enumeration, which treats a bracket as itself, and dropped by the comparison, and a file with brackets in its name could not be found for its name with -Filter, which Get-ChildItem does. The documentation names only * and ? as wildcards, so a bracket and a backtick now stand for themselves in the comparison. The regression test fails without the fix in all four configurations. The probe also showed that AlphaFS compares only the long name, so the test for *.htm guards the documented contract and no 8.3 behavior. The page of the cmdlet names the rule and the help file is generated again. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- CHANGELOG.md | 3 +++ Docs/Cmdlets/Get-ChildItem2.md | 4 +++- NTFSSecurity/ItemCmdlets/GetChildItem2.cs | 5 ++++- NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml | 5 +++-- Tests/ItemCmdlets.Tests.ps1 | 22 +++++++++++++++++--- 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa997d1..b494b2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -130,6 +130,9 @@ The format is based on the pipeline: a `break` or `continue` or `Select-Object -First` became an error of the item, so that `Remove-Item2 -PassThru | Select-Object -First 1` removed every item. They now stop and write no error +- Fix `Get-ChildItem2 -Filter`, which read a bracket as the start of a + character class, so that it did not return a file with brackets in its name, + such as `Report[1].txt`, for that name; only `*` and `?` are wildcards - Fix `Get-Help`, which showed only the syntax: ship the help file `en-US\NTFSSecurity.dll-Help.xml` generated from the cmdlet documentation, including the links that `Get-Help -Online` opens, instead of the outdated diff --git a/Docs/Cmdlets/Get-ChildItem2.md b/Docs/Cmdlets/Get-ChildItem2.md index b2e3519..c39bbaf 100644 --- a/Docs/Cmdlets/Get-ChildItem2.md +++ b/Docs/Cmdlets/Get-ChildItem2.md @@ -134,7 +134,7 @@ Accept wildcard characters: False ### -Filter -Specifies a name pattern that an item must match to be returned. The pattern supports the `*` and `?` wildcard characters, and the match ignores case. The default value is `*`, which returns every item. The pattern is applied to the name of each item, not to its path, and during a recursive listing it restricts only the returned items; the cmdlet still descends into every subfolder. +Specifies a name pattern that an item must match to be returned. The pattern supports the `*` and `?` wildcard characters, and the match ignores case; any other character, such as a bracket, stands for itself, so `Report[1].txt` returns the file of that name. The default value is `*`, which returns every item. The pattern is applied to the name of each item, not to its path, and during a recursive listing it restricts only the returned items; the cmdlet still descends into every subfolder. ```yaml Type: String @@ -309,6 +309,8 @@ A folder that cannot be read produces a non-terminating error with the ID `DirUn Before 5.0.0, a `-Path` value that points to a file stopped the cmdlet with an `InvalidCastException`, `-Attributes` returned only the items that had all the listed attributes, and an empty `-Attributes` value returned every item, also the hidden ones. Earlier builds, including the 5.0.0 prereleases, could also omit the first hidden item with `-Hidden` unless `-Force` was explicitly supplied. +Before 5.0.0, `-Filter` read a bracket as the start of a character class, so a file with brackets in its name, such as `Report[1].txt`, was not returned for its name, and a `break` or `continue` in a later command of the pipeline did not end the cmdlet for an item below the first folder. + ## RELATED LINKS [Get-Item2](Get-Item2.md) diff --git a/NTFSSecurity/ItemCmdlets/GetChildItem2.cs b/NTFSSecurity/ItemCmdlets/GetChildItem2.cs index ac8a3da..58b126d 100644 --- a/NTFSSecurity/ItemCmdlets/GetChildItem2.cs +++ b/NTFSSecurity/ItemCmdlets/GetChildItem2.cs @@ -145,7 +145,10 @@ namespace NTFSSecurity paths = new List() { GetCurrentLocation() }; } - wildcard = new WildcardPattern(filter, WildcardOptions.Compiled | WildcardOptions.IgnoreCase); + // Only * and ? are wildcards, like in the pattern that the enumeration matches; a bracket or a backtick stands + // for itself. Before 5.0.0, [1] was read as a character class, so a file with brackets in its name was not + // returned for its name. + wildcard = new WildcardPattern(filter.Replace("`", "``").Replace("[", "`[").Replace("]", "`]"), WildcardOptions.Compiled | WildcardOptions.IgnoreCase); modeMethodInfo = typeof(FileSystemCodeMembers).GetMethod("Mode"); diff --git a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml index cce32d4..276123f 100644 --- a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml +++ b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml @@ -3520,7 +3520,7 @@ PS C:\> Disable-Privileges Filter - Specifies a name pattern that an item must match to be returned. The pattern supports the ` ` and `?` wildcard characters, and the match ignores case. The default value is ` `, which returns every item. The pattern is applied to the name of each item, not to its path, and during a recursive listing it restricts only the returned items; the cmdlet still descends into every subfolder. + Specifies a name pattern that an item must match to be returned. The pattern supports the ` ` and `?` wildcard characters, and the match ignores case; any other character, such as a bracket, stands for itself, so `Report[1].txt` returns the file of that name. The default value is ` `, which returns every item. The pattern is applied to the name of each item, not to its path, and during a recursive listing it restricts only the returned items; the cmdlet still descends into every subfolder. String @@ -3724,7 +3724,7 @@ PS C:\> Disable-Privileges Filter - Specifies a name pattern that an item must match to be returned. The pattern supports the ` ` and `?` wildcard characters, and the match ignores case. The default value is ` `, which returns every item. The pattern is applied to the name of each item, not to its path, and during a recursive listing it restricts only the returned items; the cmdlet still descends into every subfolder. + Specifies a name pattern that an item must match to be returned. The pattern supports the ` ` and `?` wildcard characters, and the match ignores case; any other character, such as a bracket, stands for itself, so `Report[1].txt` returns the file of that name. The default value is ` `, which returns every item. The pattern is applied to the name of each item, not to its path, and during a recursive listing it restricts only the returned items; the cmdlet still descends into every subfolder. String @@ -3866,6 +3866,7 @@ PS C:\> Disable-Privileges The `PrivateData` section of the module manifest `NTFSSecurity.psd1` contains two settings that this cmdlet reads when it starts. `GetFileSystemModeProperty` adds the calculated `Mode` property to every item. `IdentifyHardLinks` adds the `HardLinkCount` property to every file, which requires an extra call into the file system for each file and therefore slows down large listings noticeably. Set either value to `$false` in the manifest and import the module again if you prefer the faster enumeration over the additional properties. A folder that cannot be read produces a non-terminating error with the ID `DirUnauthorizedAccessError` for an access denial or `DirUnspecifiedError` for any other failure, and a path that does not exist produces the error `FileNotFound`. In each case the cmdlet continues with the next path. Failures that occur while `-Recurse` collects the subfolders of a folder are reported as verbose messages only, not as errors. Before 5.0.0, a `-Path` value that points to a file stopped the cmdlet with an `InvalidCastException`, `-Attributes` returned only the items that had all the listed attributes, and an empty `-Attributes` value returned every item, also the hidden ones. Earlier builds, including the 5.0.0 prereleases, could also omit the first hidden item with `-Hidden` unless `-Force` was explicitly supplied. + Before 5.0.0, `-Filter` read a bracket as the start of a character class, so a file with brackets in its name, such as `Report[1].txt`, was not returned for its name, and a `break` or `continue` in a later command of the pipeline did not end the cmdlet for an item below the first folder. diff --git a/Tests/ItemCmdlets.Tests.ps1 b/Tests/ItemCmdlets.Tests.ps1 index 8458488..9e22b86 100644 --- a/Tests/ItemCmdlets.Tests.ps1 +++ b/Tests/ItemCmdlets.Tests.ps1 @@ -140,9 +140,8 @@ 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. + # The pattern must match the name of the item, not its short name (8.3), which Get-ChildItem in Windows PowerShell + # also compares: there, *.htm returns Page2.html on a volume that creates short names. It 'Should return only the items whose name matches -Filter ' -ForEach @( @{ Filter = '*.htm'; Expected = @('Page.htm') } @{ Filter = 'Page?.html'; Expected = @('Page2.html') } @@ -160,6 +159,23 @@ Describe 'Get-ChildItem2' { ($result.Name | Sort-Object) -join ',' | Should -Be (($Expected | Sort-Object) -join ',') } + # Only * and ? are wildcards in -Filter. A bracket stands for itself, so a file with brackets in its name is found + # by its name, as Get-ChildItem finds it, and the file that the brackets would select as a character class is not. + # Before 5.0.0, the cmdlet read [1] as a character class and returned nothing. + It 'Should find a file whose name contains brackets by that name with -Filter' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'FilterBrackets' -Directory + foreach ($name in 'Report[1].txt', 'Report1.txt') { + $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 'Report[1].txt' -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].Name | Should -BeExactly 'Report[1].txt' + } + 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) From 3c1974722a177a3eeeb9697a1e3dfc3e0d521c48 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 15:45:04 +0000 Subject: [PATCH 16/28] test: set a previous owner back that the user can assign The cases for the retry of Set-NTFSSecurityDescriptor covered a descriptor that sets the owner, an owner that did not change, and an owner that the user cannot assign without the Restore privilege. The success path was not tested: the user can set a group of its access token back as the owner, such as Administrators in an elevated session, so the cmdlet restores the owner and reports nothing. The test runs elevated only, because the filtered token of the basic user cannot assign that group. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/SecurityDescriptor.Tests.ps1 | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Tests/SecurityDescriptor.Tests.ps1 b/Tests/SecurityDescriptor.Tests.ps1 index b77a470..76507fe 100644 --- a/Tests/SecurityDescriptor.Tests.ps1 +++ b/Tests/SecurityDescriptor.Tests.ps1 @@ -263,6 +263,24 @@ Describe 'Set-NTFSSecurityDescriptor' { @(Get-EveryoneRule -Path $file) | Should -HaveCount 1 (Get-Acl -LiteralPath $file).GetOwner($sidType).Value | Should -Be $currentUser } + + # The user can set a group of its access token back as the owner without the Restore privilege, such as the group + # Administrators of an elevated session, so the cmdlet restores the owner and reports nothing. + It 'Should set a previous owner back that the user can assign after the write that took ownership' -Skip:(-not $canAssignAnyOwner) { + $administrators = 'S-1-5-32-544' + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'RetryRestored' + Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ $currentUser = 'ChangePermissions' } + Set-TestOwner -Sandbox $sandbox -Path $file -Sid $administrators + Get-RestorePrivilegeState | Should -Be 'Disabled' + $sd = Get-NTFSSecurityDescriptor -Path $file + Add-NTFSAccess -SecurityDescriptor $sd -Account 'Everyone' -AccessRights ReadData + + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorVariable setErrors -ErrorAction SilentlyContinue + + $setErrors | Should -BeNullOrEmpty + @(Get-EveryoneRule -Path $file) | Should -HaveCount 1 + (Get-Acl -LiteralPath $file).GetOwner($sidType).Value | Should -Be $administrators + } } Context 'A descriptor that cannot be written' { From 7d1b4c53024a4089307dc226675a2859dfffaa7a Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 15:57:43 +0000 Subject: [PATCH 17/28] test: reach the owner restore of a descriptor retry and remove a deny entry from a descriptor The restore test of Set-NTFSSecurityDescriptor used a deny entry for the user, which a member of the owner group Administrators does not feel, so the first write succeeded and the ownership retry never ran. A deny entry for OWNER RIGHTS stops that write also for the owner; taking ownership drops the entry, the cmdlet writes the descriptor, and the user sets the group back without the Restore privilege. A probe shows the plain write is denied in that setup in both editions. Remove-NTFSAccess with -SecurityDescriptor and -AccessType Deny had no test, although the overload for a descriptor adds Synchronize to allow entries only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/Access.Tests.ps1 | 18 ++++++++++++++++++ Tests/SecurityDescriptor.Tests.ps1 | 5 +++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Tests/Access.Tests.ps1 b/Tests/Access.Tests.ps1 index c351038..8efdedd 100644 --- a/Tests/Access.Tests.ps1 +++ b/Tests/Access.Tests.ps1 @@ -856,6 +856,24 @@ Describe 'Security descriptor parameter sets' { Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' } $rule.InheritanceFlags | Should -Be ([System.Security.AccessControl.InheritanceFlags]::None) } + + # A deny entry has no Synchronize right to add or remove, unlike an allow entry. + It 'Remove-NTFSAccess should remove a deny entry from the descriptor and leave the item unchanged' { + $item = New-TestSandboxItem -Sandbox $sandbox -Name 'DenyInMemory' + $sd = Get-NTFSSecurityDescriptor -Path $item + Add-NTFSAccess -SecurityDescriptor $sd -Account 'Everyone' -AccessRights ReadData -AccessType Deny + $entries = @($sd.SecurityDescriptor.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) + $entries | Should -HaveCount 1 + $entries[0].AccessControlType | Should -Be 'Deny' + + Remove-NTFSAccess -SecurityDescriptor $sd -Account 'Everyone' -AccessRights ReadData -AccessType Deny -ErrorAction Stop + + @($sd.SecurityDescriptor.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) | Should -BeNullOrEmpty + @((Get-Acl -LiteralPath $item).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) | Should -BeNullOrEmpty + } } Describe 'Clear-NTFSAccess' { diff --git a/Tests/SecurityDescriptor.Tests.ps1 b/Tests/SecurityDescriptor.Tests.ps1 index 76507fe..0595635 100644 --- a/Tests/SecurityDescriptor.Tests.ps1 +++ b/Tests/SecurityDescriptor.Tests.ps1 @@ -265,12 +265,13 @@ Describe 'Set-NTFSSecurityDescriptor' { } # The user can set a group of its access token back as the owner without the Restore privilege, such as the group - # Administrators of an elevated session, so the cmdlet restores the owner and reports nothing. + # Administrators of an elevated session, so the cmdlet restores the owner and reports nothing. A deny entry for + # OWNER RIGHTS stops the first write, also for the owner; taking ownership drops that entry. It 'Should set a previous owner back that the user can assign after the write that took ownership' -Skip:(-not $canAssignAnyOwner) { $administrators = 'S-1-5-32-544' $file = New-TestSandboxItem -Sandbox $sandbox -Name 'RetryRestored' - Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ $currentUser = 'ChangePermissions' } Set-TestOwner -Sandbox $sandbox -Path $file -Sid $administrators + Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-3-4' = 'ChangePermissions' } Get-RestorePrivilegeState | Should -Be 'Disabled' $sd = Get-NTFSSecurityDescriptor -Path $file Add-NTFSAccess -SecurityDescriptor $sd -Account 'Everyone' -AccessRights ReadData From ae3078f99ae854c4a4faa3fae61eefb6b6a9569f Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 16:08:10 +0000 Subject: [PATCH 18/28] fix: reject a null -Filter of Get-ChildItem2 and document the dot of the pattern The escape of brackets in the pattern read the filter before the pattern could reject it, so a null filter ended in a NullReferenceException. The parameter now rejects null with a validation error that names it; an empty filter still matches no item. The cmdlet compares each name that the enumeration returns with the pattern again. The two disagree for *.*: the enumeration returns every item, as Get-ChildItem does, and the comparison drops the names without a dot, files and folders alike. A test pins this and reaches the branch that drops an item; the help says that a dot is an ordinary character. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- CHANGELOG.md | 5 ++++- Docs/Cmdlets/Get-ChildItem2.md | 2 +- NTFSSecurity/ItemCmdlets/GetChildItem2.cs | 1 + NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml | 4 ++-- Tests/ItemCmdlets.Tests.ps1 | 21 ++++++++++++++++++++ 5 files changed, 29 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b494b2f..c0c2148 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,7 +132,10 @@ The format is based on removed every item. They now stop and write no error - Fix `Get-ChildItem2 -Filter`, which read a bracket as the start of a character class, so that it did not return a file with brackets in its name, - such as `Report[1].txt`, for that name; only `*` and `?` are wildcards + such as `Report[1].txt`, for that name; only `*` and `?` are wildcards. A dot + is an ordinary character, so `*.*` returns only the names that contain a dot + (unlike `Get-ChildItem`), and a null `-Filter` is rejected as a parameter + error - Fix `Get-Help`, which showed only the syntax: ship the help file `en-US\NTFSSecurity.dll-Help.xml` generated from the cmdlet documentation, including the links that `Get-Help -Online` opens, instead of the outdated diff --git a/Docs/Cmdlets/Get-ChildItem2.md b/Docs/Cmdlets/Get-ChildItem2.md index c39bbaf..c26ec60 100644 --- a/Docs/Cmdlets/Get-ChildItem2.md +++ b/Docs/Cmdlets/Get-ChildItem2.md @@ -134,7 +134,7 @@ Accept wildcard characters: False ### -Filter -Specifies a name pattern that an item must match to be returned. The pattern supports the `*` and `?` wildcard characters, and the match ignores case; any other character, such as a bracket, stands for itself, so `Report[1].txt` returns the file of that name. The default value is `*`, which returns every item. The pattern is applied to the name of each item, not to its path, and during a recursive listing it restricts only the returned items; the cmdlet still descends into every subfolder. +Specifies a name pattern that an item must match to be returned. The pattern supports the `*` and `?` wildcard characters, and the match ignores case; any other character stands for itself. A bracket is an ordinary character, so `Report[1].txt` returns the file of that name, and so is a dot, so `*.*` returns only the items whose names contain a dot, not every item as it does for `Get-ChildItem`. The default value is `*`, which returns every item. The pattern is applied to the name of each item, not to its path, and during a recursive listing it restricts only the returned items; the cmdlet still descends into every subfolder. ```yaml Type: String diff --git a/NTFSSecurity/ItemCmdlets/GetChildItem2.cs b/NTFSSecurity/ItemCmdlets/GetChildItem2.cs index 58b126d..e77f67a 100644 --- a/NTFSSecurity/ItemCmdlets/GetChildItem2.cs +++ b/NTFSSecurity/ItemCmdlets/GetChildItem2.cs @@ -45,6 +45,7 @@ namespace NTFSSecurity } [Parameter(Position = 2)] + [ValidateNotNull] public string Filter { get { return filter; } diff --git a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml index 276123f..7e5b100 100644 --- a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml +++ b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml @@ -3520,7 +3520,7 @@ PS C:\> Disable-Privileges Filter - Specifies a name pattern that an item must match to be returned. The pattern supports the ` ` and `?` wildcard characters, and the match ignores case; any other character, such as a bracket, stands for itself, so `Report[1].txt` returns the file of that name. The default value is ` `, which returns every item. The pattern is applied to the name of each item, not to its path, and during a recursive listing it restricts only the returned items; the cmdlet still descends into every subfolder. + Specifies a name pattern that an item must match to be returned. The pattern supports the ` ` and `?` wildcard characters, and the match ignores case; any other character stands for itself. A bracket is an ordinary character, so `Report[1].txt` returns the file of that name, and so is a dot, so ` . ` returns only the items whose names contain a dot, not every item as it does for `Get-ChildItem`. The default value is ` `, which returns every item. The pattern is applied to the name of each item, not to its path, and during a recursive listing it restricts only the returned items; the cmdlet still descends into every subfolder. String @@ -3724,7 +3724,7 @@ PS C:\> Disable-Privileges Filter - Specifies a name pattern that an item must match to be returned. The pattern supports the ` ` and `?` wildcard characters, and the match ignores case; any other character, such as a bracket, stands for itself, so `Report[1].txt` returns the file of that name. The default value is ` `, which returns every item. The pattern is applied to the name of each item, not to its path, and during a recursive listing it restricts only the returned items; the cmdlet still descends into every subfolder. + Specifies a name pattern that an item must match to be returned. The pattern supports the ` ` and `?` wildcard characters, and the match ignores case; any other character stands for itself. A bracket is an ordinary character, so `Report[1].txt` returns the file of that name, and so is a dot, so ` . ` returns only the items whose names contain a dot, not every item as it does for `Get-ChildItem`. The default value is ` `, which returns every item. The pattern is applied to the name of each item, not to its path, and during a recursive listing it restricts only the returned items; the cmdlet still descends into every subfolder. String diff --git a/Tests/ItemCmdlets.Tests.ps1 b/Tests/ItemCmdlets.Tests.ps1 index 9e22b86..cb7e4bf 100644 --- a/Tests/ItemCmdlets.Tests.ps1 +++ b/Tests/ItemCmdlets.Tests.ps1 @@ -176,6 +176,27 @@ Describe 'Get-ChildItem2' { $result[0].Name | Should -BeExactly 'Report[1].txt' } + # The dot is an ordinary character of the pattern, so *.* selects the names that contain a dot. The enumeration + # alone would return every item for it, as Get-ChildItem and cmd.exe do; the cmdlet then compares each name with + # the pattern and drops the items whose names have no dot, files and folders alike. + It 'Should return only the items with a dot in their names for -Filter *.*' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'FilterDot' -Directory + $paths = @('Page.htm', 'NoExtension', 'NoExtensionFolder') | ForEach-Object -Process { Join-Path -Path $folder -ChildPath $_ } + Assert-TestSandboxPath -Sandbox $sandbox -Path $paths + Set-Content -LiteralPath $paths[0] -Value 'Page' + Set-Content -LiteralPath $paths[1] -Value 'NoExtension' + New-Item -ItemType Directory -Path $paths[2] | Out-Null + + $result = @(Get-ChildItem2 -Path $folder -Filter '*.*' -ErrorAction Stop) + + ($result.Name -join ',') | Should -BeExactly 'Page.htm' + } + + It 'Should reject a null -Filter' { + { Get-ChildItem2 -Path $tree -Filter $null -ErrorAction Stop } | + Should -Throw -ErrorId 'ParameterArgumentValidationError,NTFSSecurity.GetChildItem2' -ExpectedMessage "*'Filter'*" + } + 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) From 40bf6a807b738f9ef4adb287f389f03dc5959718 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 16:47:59 +0000 Subject: [PATCH 19/28] fix: pass on what a later command raises, and return every item for -Filter *.* A throw in a later command, or an error with -ErrorAction Stop, reaches a cmdlet through its Write call as an ordinary exception. The catch for the failures of an item reported it as the error of that item and went on, so that Remove-Item2 -PassThru removed the next item after a throw, and the caller never saw the exception. The earlier check found only the end of the pipeline and a break or continue. BaseCmdlet now notes the exception that its WriteObject, WriteVerbose, and WriteDebug raised, and every catch that can enclose a write passes it on; Get-DiskSpace writes outside its try. Set-NTFSSecurityDescriptor and Get-FileHash2 also caught it at a verbose message. Get-ChildItem2 -Filter *.* returns every item, as Get-ChildItem does. The cmdlet compared each name with the pattern again and dropped the items without a dot, most folders among them; the dot stays an ordinary character in other patterns. The failed lookup of InheritedFrom frees its native buffer. The help paragraph of -Filter has no pair of asterisks, which platyPS turns into emphasis, and the page has an example for *.*. Review of the independent pass: the restored-owner test asserts that a plain write is denied, the drive-mapping helper has guard tests and takes letters that the no-volume tests do not, and the pipeline tests cover a throw, an error with -ErrorAction Stop, and the verbose and debug streams for every cmdlet that can reach the code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- CHANGELOG.md | 24 +-- Docs/Cmdlets/Get-ChildItem2.md | 14 +- NTFSSecurity/BaseCmdlets.cs | 75 ++++++++- NTFSSecurity/ItemCmdlets/CopyItem2.cs | 2 +- NTFSSecurity/ItemCmdlets/GetChildItem2.cs | 16 +- NTFSSecurity/ItemCmdlets/GetDiskSpace.cs | 20 +-- NTFSSecurity/ItemCmdlets/MoveItem2.cs | 2 +- NTFSSecurity/ItemCmdlets/RemoveItem2.cs | 2 +- NTFSSecurity/MiscCmdlets/GetFileHash2.cs | 6 + NTFSSecurity/OwnerCmdlets/SetOwner.cs | 2 +- .../GetSecurityDescriptor.cs | 4 +- .../SetSecurityDescriptor.cs | 8 +- .../SimpleAccessCmdlets.cs | 2 +- NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml | 14 +- Security2/Win32/Lib.cs | 57 ++++--- Tests/ItemCmdlets.Tests.ps1 | 31 +++- Tests/PipelineControl.Tests.ps1 | 152 +++++++++++++++++- Tests/SecurityDescriptor.Tests.ps1 | 2 + Tests/TestHelpers.Tests.ps1 | 21 +++ 19 files changed, 377 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0c2148..f77d40e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -122,20 +122,24 @@ The format is based on whose folder Windows cannot name, such as for an item that was deleted after it was read or a folder above it that the user cannot read: the text read `unknown paren`, and the explicit entries showed it as well. An - inherited entry now shows `unknown parent`, and an explicit entry no source + inherited entry now shows `unknown parent`, and an explicit entry no source; + the failed lookup no longer leaks its native buffer - Fix `Remove-Item2`, `Copy-Item2`, `Move-Item2`, `Set-NTFSOwner`, `Set-NTFSSecurityDescriptor`, `Get-NTFSSecurityDescriptor`, - `Get-NTFSSimpleAccess`, `Get-DiskSpace`, and `Get-ChildItem2` below the - first folder, which went on with the next item when a later command ended - the pipeline: a `break` or `continue` or `Select-Object -First` became an - error of the item, so that `Remove-Item2 -PassThru | Select-Object -First 1` - removed every item. They now stop and write no error + `Get-NTFSSimpleAccess`, `Get-FileHash2`, `Get-DiskSpace`, and + `Get-ChildItem2` below the first folder, which went on with the next item + when a later command ended the pipeline: a `break` or `continue`, + `Select-Object -First`, or a `throw` was handled as a failure of the item, + so that `Remove-Item2 -PassThru | Select-Object -First 1` removed every + item, and the caller never saw the `throw`. They now stop and write no + error, and the error of the later command reaches the caller - Fix `Get-ChildItem2 -Filter`, which read a bracket as the start of a character class, so that it did not return a file with brackets in its name, - such as `Report[1].txt`, for that name; only `*` and `?` are wildcards. A dot - is an ordinary character, so `*.*` returns only the names that contain a dot - (unlike `Get-ChildItem`), and a null `-Filter` is rejected as a parameter - error + such as `Report[1].txt`, for that name; only `*` and `?` are wildcards. A + null `-Filter` is rejected as a parameter error +- Fix `Get-ChildItem2 -Filter *.*`, which returned only the items with a dot + in their names and dropped the other files and folders, most folders among + them, instead of every item as `Get-ChildItem` does - Fix `Get-Help`, which showed only the syntax: ship the help file `en-US\NTFSSecurity.dll-Help.xml` generated from the cmdlet documentation, including the links that `Get-Help -Online` opens, instead of the outdated diff --git a/Docs/Cmdlets/Get-ChildItem2.md b/Docs/Cmdlets/Get-ChildItem2.md index c26ec60..3783233 100644 --- a/Docs/Cmdlets/Get-ChildItem2.md +++ b/Docs/Cmdlets/Get-ChildItem2.md @@ -65,6 +65,14 @@ PS C:\> dir2 -Path C:\Data -Attributes Hidden, System Uses the `dir2` alias and returns the items of `C:\Data` that have the hidden or the system attribute, like `Get-ChildItem -Attributes Hidden, System`. +### Example 5: Return every item, with or without a dot in its name + +```PowerShell +PS C:\> Get-ChildItem2 -Path C:\Data -Filter *.* +``` + +Returns every item of `C:\Data`, also the files and folders whose names have no dot, as `Get-ChildItem` does for this filter. + ## PARAMETERS ### -Attributes @@ -134,7 +142,7 @@ Accept wildcard characters: False ### -Filter -Specifies a name pattern that an item must match to be returned. The pattern supports the `*` and `?` wildcard characters, and the match ignores case; any other character stands for itself. A bracket is an ordinary character, so `Report[1].txt` returns the file of that name, and so is a dot, so `*.*` returns only the items whose names contain a dot, not every item as it does for `Get-ChildItem`. The default value is `*`, which returns every item. The pattern is applied to the name of each item, not to its path, and during a recursive listing it restricts only the returned items; the cmdlet still descends into every subfolder. +Specifies a name pattern that an item must match to be returned. The pattern supports the asterisk and the question mark as wildcard characters, an asterisk for any number of characters and a question mark for exactly one, and the match ignores case. Any other character stands for itself; a bracket is an ordinary character, so `Report[1].txt` returns the file of that name. As for `Get-ChildItem`, a pattern of an asterisk, a dot, and an asterisk returns every item, also an item without a dot in its name. The default value is `*`, which returns every item. The pattern is applied to the name of each item, not to its path, and during a recursive listing it restricts only the returned items; the cmdlet still descends into every subfolder. ```yaml Type: String @@ -309,7 +317,9 @@ A folder that cannot be read produces a non-terminating error with the ID `DirUn Before 5.0.0, a `-Path` value that points to a file stopped the cmdlet with an `InvalidCastException`, `-Attributes` returned only the items that had all the listed attributes, and an empty `-Attributes` value returned every item, also the hidden ones. Earlier builds, including the 5.0.0 prereleases, could also omit the first hidden item with `-Hidden` unless `-Force` was explicitly supplied. -Before 5.0.0, `-Filter` read a bracket as the start of a character class, so a file with brackets in its name, such as `Report[1].txt`, was not returned for its name, and a `break` or `continue` in a later command of the pipeline did not end the cmdlet for an item below the first folder. +Before 5.0.0, `-Filter` read a bracket as the start of a character class, so a file with brackets in its name, such as `Report[1].txt`, was not returned for its name, and a pattern of an asterisk, a dot, and an asterisk dropped the items without a dot in their names, most folders among them. + +Before 5.0.0, a `break` or `continue` in a later command of the pipeline did not end the cmdlet for an item below the first folder. ## RELATED LINKS diff --git a/NTFSSecurity/BaseCmdlets.cs b/NTFSSecurity/BaseCmdlets.cs index 9258d5a..cb3da6e 100644 --- a/NTFSSecurity/BaseCmdlets.cs +++ b/NTFSSecurity/BaseCmdlets.cs @@ -12,7 +12,8 @@ namespace NTFSSecurity /// Recognizes what a later command in the pipeline raises to end the pipeline or the loop around it: the end of the /// pipeline, for example for Select-Object -First, and a break or continue in a script block. These exceptions pass /// through a cmdlet while it writes an object. A catch for the failures of an item must pass them on: reported as - /// the error of that item, they would end nothing, and the cmdlet would go on with the next item. + /// the error of that item, they would end nothing, and the cmdlet would go on with the next item. Anything else that + /// a Write method raises is recognized by BaseCmdlet.IsFromLaterCommand. /// internal static class PipelineControl { @@ -44,6 +45,78 @@ namespace NTFSSecurity protected List paths = new List(); protected List securityDescriptors = new List(); + // The exception that a Write method of this cmdlet raised last. A Write method runs the later commands of the + // pipeline and so raises what they raise: a throw in a script block, an error with -ErrorAction Stop, the end of the + // pipeline, a break or a continue. None of it is a failure of the item that the cmdlet processes. A catch for those + // failures must pass it on (IsFromLaterCommand), or the cmdlet reports it as the error of that item, goes on with + // the next one, and the caller never sees the exception. + private Exception laterCommandException; + + /// Writes the object to the pipeline and notes what a later command raises, see IsFromLaterCommand. + public new void WriteObject(object sendToPipeline) + { + try + { + base.WriteObject(sendToPipeline); + } + catch (Exception ex) + { + laterCommandException = ex; + throw; + } + } + + /// Writes the object to the pipeline and notes what a later command raises, see IsFromLaterCommand. + public new void WriteObject(object sendToPipeline, bool enumerateCollection) + { + try + { + base.WriteObject(sendToPipeline, enumerateCollection); + } + catch (Exception ex) + { + laterCommandException = ex; + throw; + } + } + + // The streams that a later command can take, for example Select-Object -First with 4>&1. + /// Writes a verbose message and notes what a later command raises, see IsFromLaterCommand. + public new void WriteVerbose(string text) + { + try + { + base.WriteVerbose(text); + } + catch (Exception ex) + { + laterCommandException = ex; + throw; + } + } + + /// Writes a debug message and notes what a later command raises, see IsFromLaterCommand. + public new void WriteDebug(string text) + { + try + { + base.WriteDebug(text); + } + catch (Exception ex) + { + laterCommandException = ex; + throw; + } + } + + /// + /// Whether the exception comes from a later command of the pipeline, not from the item that the cmdlet processes. + /// + protected bool IsFromLaterCommand(Exception exception) + { + return ReferenceEquals(exception, laterCommandException) || PipelineControl.IsEnd(exception); + } + protected override void BeginProcessing() { base.BeginProcessing(); diff --git a/NTFSSecurity/ItemCmdlets/CopyItem2.cs b/NTFSSecurity/ItemCmdlets/CopyItem2.cs index 3541113..608773e 100644 --- a/NTFSSecurity/ItemCmdlets/CopyItem2.cs +++ b/NTFSSecurity/ItemCmdlets/CopyItem2.cs @@ -149,7 +149,7 @@ namespace NTFSSecurity } catch (Exception ex) { - if (PipelineControl.IsEnd(ex)) + if (IsFromLaterCommand(ex)) { throw; } diff --git a/NTFSSecurity/ItemCmdlets/GetChildItem2.cs b/NTFSSecurity/ItemCmdlets/GetChildItem2.cs index e77f67a..102e4f1 100644 --- a/NTFSSecurity/ItemCmdlets/GetChildItem2.cs +++ b/NTFSSecurity/ItemCmdlets/GetChildItem2.cs @@ -148,8 +148,10 @@ namespace NTFSSecurity // Only * and ? are wildcards, like in the pattern that the enumeration matches; a bracket or a backtick stands // for itself. Before 5.0.0, [1] was read as a character class, so a file with brackets in its name was not - // returned for its name. - wildcard = new WildcardPattern(filter.Replace("`", "``").Replace("[", "`[").Replace("]", "`]"), WildcardOptions.Compiled | WildcardOptions.IgnoreCase); + // returned for its name. The enumeration returns every item for *.* as Windows does, so the comparison does + // too; with the dot as an ordinary character, it would drop the items without a dot, most folders. + var pattern = filter == "*.*" ? "*" : filter; + wildcard = new WildcardPattern(pattern.Replace("`", "``").Replace("[", "`[").Replace("]", "`]"), WildcardOptions.Compiled | WildcardOptions.IgnoreCase); modeMethodInfo = typeof(FileSystemCodeMembers).GetMethod("Mode"); @@ -250,9 +252,9 @@ namespace NTFSSecurity } catch (Exception ex) { - // Not what a later command raises to end the pipeline or the loop around it, which this catch - // would hide; the verbose message is for a folder that can't be listed. - if (PipelineControl.IsEnd(ex)) + // Not what a later command raises, which this catch would hide; the verbose message is for a + // folder that can't be listed. + if (IsFromLaterCommand(ex)) { throw; } @@ -261,7 +263,7 @@ namespace NTFSSecurity } } } - catch (UnauthorizedAccessException ex) + catch (UnauthorizedAccessException ex) when (!IsFromLaterCommand(ex)) { WriteError(new ErrorRecord(ex, "DirUnauthorizedAccessError", ErrorCategory.PermissionDenied, di.FullName)); } @@ -271,7 +273,7 @@ namespace NTFSSecurity } catch (Exception ex) { - if (PipelineControl.IsEnd(ex)) + if (IsFromLaterCommand(ex)) { throw; } diff --git a/NTFSSecurity/ItemCmdlets/GetDiskSpace.cs b/NTFSSecurity/ItemCmdlets/GetDiskSpace.cs index 7a66305..908abab 100644 --- a/NTFSSecurity/ItemCmdlets/GetDiskSpace.cs +++ b/NTFSSecurity/ItemCmdlets/GetDiskSpace.cs @@ -36,22 +36,22 @@ namespace NTFSSecurity foreach (var letter in driveLetter) { var diskSpaceInfo = new DiskSpaceInfo(letter); + var hasSpace = false; try { diskSpaceInfo.Refresh(); - if (diskSpaceInfo.TotalNumberOfBytes > 0) - { - this.WriteObject(diskSpaceInfo); - } + hasSpace = diskSpaceInfo.TotalNumberOfBytes > 0; } - catch (Exception ex) + catch (Exception) { - if (PipelineControl.IsEnd(ex)) - { - throw; - } - this.WriteWarning(string.Format("Could not get drive details for '{0}'", letter)); + continue; + } + + // Outside the try: what a later command raises while it takes the object is not a failure of the drive. + if (hasSpace) + { + this.WriteObject(diskSpaceInfo); } } } diff --git a/NTFSSecurity/ItemCmdlets/MoveItem2.cs b/NTFSSecurity/ItemCmdlets/MoveItem2.cs index 8c8a112..f7ce340 100644 --- a/NTFSSecurity/ItemCmdlets/MoveItem2.cs +++ b/NTFSSecurity/ItemCmdlets/MoveItem2.cs @@ -160,7 +160,7 @@ namespace NTFSSecurity } catch (Exception ex) { - if (PipelineControl.IsEnd(ex)) + if (IsFromLaterCommand(ex)) { throw; } diff --git a/NTFSSecurity/ItemCmdlets/RemoveItem2.cs b/NTFSSecurity/ItemCmdlets/RemoveItem2.cs index 788b4fb..2c171a2 100644 --- a/NTFSSecurity/ItemCmdlets/RemoveItem2.cs +++ b/NTFSSecurity/ItemCmdlets/RemoveItem2.cs @@ -102,7 +102,7 @@ namespace NTFSSecurity } catch (Exception ex) { - if (PipelineControl.IsEnd(ex)) + if (IsFromLaterCommand(ex)) { throw; } diff --git a/NTFSSecurity/MiscCmdlets/GetFileHash2.cs b/NTFSSecurity/MiscCmdlets/GetFileHash2.cs index b688cd9..74e07d9 100644 --- a/NTFSSecurity/MiscCmdlets/GetFileHash2.cs +++ b/NTFSSecurity/MiscCmdlets/GetFileHash2.cs @@ -78,6 +78,12 @@ namespace NTFSSecurity } catch (Exception ex) { + // Not what a later command raises, for example when it takes the verbose message. + if (IsFromLaterCommand(ex)) + { + throw; + } + WriteError(new ErrorRecord(ex, "ReadFileError", ErrorCategory.OpenError, path)); continue; } diff --git a/NTFSSecurity/OwnerCmdlets/SetOwner.cs b/NTFSSecurity/OwnerCmdlets/SetOwner.cs index cd69449..0d1ca7a 100644 --- a/NTFSSecurity/OwnerCmdlets/SetOwner.cs +++ b/NTFSSecurity/OwnerCmdlets/SetOwner.cs @@ -87,7 +87,7 @@ namespace NTFSSecurity } catch (Exception ex) { - if (PipelineControl.IsEnd(ex)) + if (IsFromLaterCommand(ex)) { throw; } diff --git a/NTFSSecurity/SecurityDescriptorCmdlets/GetSecurityDescriptor.cs b/NTFSSecurity/SecurityDescriptorCmdlets/GetSecurityDescriptor.cs index 270e37e..901daa0 100644 --- a/NTFSSecurity/SecurityDescriptorCmdlets/GetSecurityDescriptor.cs +++ b/NTFSSecurity/SecurityDescriptorCmdlets/GetSecurityDescriptor.cs @@ -64,7 +64,7 @@ namespace NTFSSecurity } catch (Exception ex2) { - if (PipelineControl.IsEnd(ex2)) + if (IsFromLaterCommand(ex2)) { throw; } @@ -75,7 +75,7 @@ namespace NTFSSecurity } catch (Exception ex) { - if (PipelineControl.IsEnd(ex)) + if (IsFromLaterCommand(ex)) { throw; } diff --git a/NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs b/NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs index ef4c70d..86b4a83 100644 --- a/NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs +++ b/NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs @@ -67,6 +67,12 @@ namespace NTFSSecurity } catch (Exception ex) { + // Not what a later command raises, for example when it takes the verbose message. + if (IsFromLaterCommand(ex)) + { + throw; + } + WriteError(new ErrorRecord(ex, "WriteSdError", ErrorCategory.WriteError, sd.Item)); continue; } @@ -81,7 +87,7 @@ namespace NTFSSecurity } catch (Exception ex) { - if (PipelineControl.IsEnd(ex)) + if (IsFromLaterCommand(ex)) { throw; } diff --git a/NTFSSecurity/SimpleAccessCmdlets/SimpleAccessCmdlets.cs b/NTFSSecurity/SimpleAccessCmdlets/SimpleAccessCmdlets.cs index 61fd4eb..758b49a 100644 --- a/NTFSSecurity/SimpleAccessCmdlets/SimpleAccessCmdlets.cs +++ b/NTFSSecurity/SimpleAccessCmdlets/SimpleAccessCmdlets.cs @@ -111,7 +111,7 @@ namespace NTFSSecurity } catch (Exception ex) { - if (PipelineControl.IsEnd(ex)) + if (IsFromLaterCommand(ex)) { throw; } diff --git a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml index 7e5b100..b45b6de 100644 --- a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml +++ b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml @@ -3520,7 +3520,7 @@ PS C:\> Disable-Privileges Filter - Specifies a name pattern that an item must match to be returned. The pattern supports the ` ` and `?` wildcard characters, and the match ignores case; any other character stands for itself. A bracket is an ordinary character, so `Report[1].txt` returns the file of that name, and so is a dot, so ` . ` returns only the items whose names contain a dot, not every item as it does for `Get-ChildItem`. The default value is ` `, which returns every item. The pattern is applied to the name of each item, not to its path, and during a recursive listing it restricts only the returned items; the cmdlet still descends into every subfolder. + Specifies a name pattern that an item must match to be returned. The pattern supports the asterisk and the question mark as wildcard characters, an asterisk for any number of characters and a question mark for exactly one, and the match ignores case. Any other character stands for itself; a bracket is an ordinary character, so `Report[1].txt` returns the file of that name. As for `Get-ChildItem`, a pattern of an asterisk, a dot, and an asterisk returns every item, also an item without a dot in its name. The default value is `*`, which returns every item. The pattern is applied to the name of each item, not to its path, and during a recursive listing it restricts only the returned items; the cmdlet still descends into every subfolder. String @@ -3724,7 +3724,7 @@ PS C:\> Disable-Privileges Filter - Specifies a name pattern that an item must match to be returned. The pattern supports the ` ` and `?` wildcard characters, and the match ignores case; any other character stands for itself. A bracket is an ordinary character, so `Report[1].txt` returns the file of that name, and so is a dot, so ` . ` returns only the items whose names contain a dot, not every item as it does for `Get-ChildItem`. The default value is ` `, which returns every item. The pattern is applied to the name of each item, not to its path, and during a recursive listing it restricts only the returned items; the cmdlet still descends into every subfolder. + Specifies a name pattern that an item must match to be returned. The pattern supports the asterisk and the question mark as wildcard characters, an asterisk for any number of characters and a question mark for exactly one, and the match ignores case. Any other character stands for itself; a bracket is an ordinary character, so `Report[1].txt` returns the file of that name. As for `Get-ChildItem`, a pattern of an asterisk, a dot, and an asterisk returns every item, also an item without a dot in its name. The default value is `*`, which returns every item. The pattern is applied to the name of each item, not to its path, and during a recursive listing it restricts only the returned items; the cmdlet still descends into every subfolder. String @@ -3866,7 +3866,8 @@ PS C:\> Disable-Privileges The `PrivateData` section of the module manifest `NTFSSecurity.psd1` contains two settings that this cmdlet reads when it starts. `GetFileSystemModeProperty` adds the calculated `Mode` property to every item. `IdentifyHardLinks` adds the `HardLinkCount` property to every file, which requires an extra call into the file system for each file and therefore slows down large listings noticeably. Set either value to `$false` in the manifest and import the module again if you prefer the faster enumeration over the additional properties. A folder that cannot be read produces a non-terminating error with the ID `DirUnauthorizedAccessError` for an access denial or `DirUnspecifiedError` for any other failure, and a path that does not exist produces the error `FileNotFound`. In each case the cmdlet continues with the next path. Failures that occur while `-Recurse` collects the subfolders of a folder are reported as verbose messages only, not as errors. Before 5.0.0, a `-Path` value that points to a file stopped the cmdlet with an `InvalidCastException`, `-Attributes` returned only the items that had all the listed attributes, and an empty `-Attributes` value returned every item, also the hidden ones. Earlier builds, including the 5.0.0 prereleases, could also omit the first hidden item with `-Hidden` unless `-Force` was explicitly supplied. - Before 5.0.0, `-Filter` read a bracket as the start of a character class, so a file with brackets in its name, such as `Report[1].txt`, was not returned for its name, and a `break` or `continue` in a later command of the pipeline did not end the cmdlet for an item below the first folder. + Before 5.0.0, `-Filter` read a bracket as the start of a character class, so a file with brackets in its name, such as `Report[1].txt`, was not returned for its name, and a pattern of an asterisk, a dot, and an asterisk dropped the items without a dot in their names, most folders among them. + Before 5.0.0, a `break` or `continue` in a later command of the pipeline did not end the cmdlet for an item below the first folder. @@ -3898,6 +3899,13 @@ PS C:\> Disable-Privileges Uses the `dir2` alias and returns the items of `C:\Data` that have the hidden or the system attribute, like `Get-ChildItem -Attributes Hidden, System`. + + Example 5: Return every item, with or without a dot in its name + PS C:\> Get-ChildItem2 -Path C:\Data -Filter *.* + + Returns every item of `C:\Data`, also the files and folders whose names have no dot, as `Get-ChildItem` does for this filter. + + diff --git a/Security2/Win32/Lib.cs b/Security2/Win32/Lib.cs index 3cd29e5..957584b 100644 --- a/Security2/Win32/Lib.cs +++ b/Security2/Win32/Lib.cs @@ -71,35 +71,42 @@ namespace Security2 var pInheritInfo = Marshal.AllocHGlobal(aceCount * Marshal.SizeOf(typeof(PINHERITED_FROM))); - returnValue = GetInheritanceSource( - path, - ResourceType.FileObject, - aclType, - isContainer, - IntPtr.Zero, - 0, - aclBytes, - IntPtr.Zero, - ref genericMap, - pInheritInfo - ); - - if (returnValue != 0) + try { - throw new System.ComponentModel.Win32Exception((int)returnValue); - } + returnValue = GetInheritanceSource( + path, + ResourceType.FileObject, + aclType, + isContainer, + IntPtr.Zero, + 0, + aclBytes, + IntPtr.Zero, + ref genericMap, + pInheritInfo + ); + + if (returnValue != 0) + { + throw new System.ComponentModel.Win32Exception((int)returnValue); + } - for (int i = 0; i < aceCount; i++) - { - var inheritInfo = pInheritInfo.ElementAt(i); + for (int i = 0; i < aceCount; i++) + { + var inheritInfo = pInheritInfo.ElementAt(i); - inheritedFrom.Add( - !string.IsNullOrEmpty(inheritInfo.AncestorName) && inheritInfo.AncestorName.StartsWith(@"\\?\") ? inheritInfo.AncestorName.Substring(4) : inheritInfo.AncestorName - ); - } + inheritedFrom.Add( + !string.IsNullOrEmpty(inheritInfo.AncestorName) && inheritInfo.AncestorName.StartsWith(@"\\?\") ? inheritInfo.AncestorName.Substring(4) : inheritInfo.AncestorName + ); + } - FreeInheritedFromArray(pInheritInfo, (ushort)aceCount, IntPtr.Zero); - Marshal.FreeHGlobal(pInheritInfo); + FreeInheritedFromArray(pInheritInfo, (ushort)aceCount, IntPtr.Zero); + } + finally + { + // Also after a failed call, which the fallback of GetInheritedFrom now expects. + Marshal.FreeHGlobal(pInheritInfo); + } return inheritedFrom; } diff --git a/Tests/ItemCmdlets.Tests.ps1 b/Tests/ItemCmdlets.Tests.ps1 index cb7e4bf..518a722 100644 --- a/Tests/ItemCmdlets.Tests.ps1 +++ b/Tests/ItemCmdlets.Tests.ps1 @@ -176,10 +176,10 @@ Describe 'Get-ChildItem2' { $result[0].Name | Should -BeExactly 'Report[1].txt' } - # The dot is an ordinary character of the pattern, so *.* selects the names that contain a dot. The enumeration - # alone would return every item for it, as Get-ChildItem and cmd.exe do; the cmdlet then compares each name with - # the pattern and drops the items whose names have no dot, files and folders alike. - It 'Should return only the items with a dot in their names for -Filter *.*' { + # The dot is an ordinary character of the pattern, but not in *.*, which Windows, Get-ChildItem, and .NET read as + # every item. Before 5.0.0, the cmdlet compared each name with the pattern again and dropped the items without a + # dot, files and folders alike, so that a listing of a tree with this filter missed most of its folders. + It 'Should return every item for -Filter *.*, also the ones without a dot in their names' { $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'FilterDot' -Directory $paths = @('Page.htm', 'NoExtension', 'NoExtensionFolder') | ForEach-Object -Process { Join-Path -Path $folder -ChildPath $_ } Assert-TestSandboxPath -Sandbox $sandbox -Path $paths @@ -189,7 +189,22 @@ Describe 'Get-ChildItem2' { $result = @(Get-ChildItem2 -Path $folder -Filter '*.*' -ErrorAction Stop) - ($result.Name -join ',') | Should -BeExactly 'Page.htm' + ($result.Name | Sort-Object) -join ',' | Should -BeExactly 'NoExtension,NoExtensionFolder,Page.htm' + } + + # The enumeration returns every item for *.*.*, as Get-ChildItem does. The cmdlet compares each name with the whole + # pattern again, so that the dots of the pattern are characters of the name, as the help says. + It 'Should return only the items that match the whole pattern for -Filter *.*.*' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'FilterDots' -Directory + foreach ($name in 'Page.htm', 'NoExtension', 'Two.dots.txt') { + $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 '*.*.*' -ErrorAction Stop) + + ($result.Name -join ',') | Should -BeExactly 'Two.dots.txt' } It 'Should reject a null -Filter' { @@ -765,7 +780,8 @@ Describe 'Copy-Item2, Move-Item2, and Remove-Item2 with several paths' { @{ Command = 'Move-Item2'; ErrorId = 'MoveError' } ) { $used = @((Get-PSDrive -PSProvider FileSystem).Name) + @([System.IO.DriveInfo]::GetDrives() | ForEach-Object -Process { $_.Name.Substring(0, 1) }) - $letter = [char[]](68..90) | Where-Object -FilterScript { [string] $_ -notin $used } | Select-Object -Last 1 + # The lowest free letter: New-TestDriveMapping takes letters from Z downward, also in a run in parallel. + $letter = [char[]](68..90) | Where-Object -FilterScript { [string] $_ -notin $used } | Select-Object -First 1 if (-not $letter) { Set-ItResult -Skipped -Because 'every drive letter is in use' return @@ -1022,7 +1038,8 @@ Describe 'Get-DiskSpace' { It 'Should warn and return nothing for a drive letter without a volume' { $used = @((Get-PSDrive -PSProvider FileSystem).Name) + @([System.IO.DriveInfo]::GetDrives() | ForEach-Object -Process { $_.Name.Substring(0, 1) }) - $letter = [char[]](68..90) | Where-Object -FilterScript { [string] $_ -notin $used } | Select-Object -Last 1 + # The lowest free letter: New-TestDriveMapping takes letters from Z downward, also in a run in parallel. + $letter = [char[]](68..90) | Where-Object -FilterScript { [string] $_ -notin $used } | Select-Object -First 1 if (-not $letter) { Set-ItResult -Skipped -Because 'every drive letter is in use' return diff --git a/Tests/PipelineControl.Tests.ps1 b/Tests/PipelineControl.Tests.ps1 index b69d9af..7507f8a 100644 --- a/Tests/PipelineControl.Tests.ps1 +++ b/Tests/PipelineControl.Tests.ps1 @@ -1,9 +1,10 @@ <# Tests how the cmdlets of the module built in NTFSSecurity\bin\Release behave when a later command in the pipeline - ends it: a break or continue in a script block, or Select-Object -First. The exception that carries it passes through - the cmdlet while it writes an object. A catch for the failures of an item must not report it as an error of that item - and go on with the next one: a cmdlet that removes, copies, moves, or changes items would change them all, although - the caller ended the pipeline. Every test works on files and folders in a sandbox. + ends it: a break or continue in a script block, Select-Object -First, or a terminating error such as a throw. The + exception that carries it passes through the cmdlet while it writes an object, a verbose message, or a debug message. + A catch for the failures of an item must not report it as an error of that item and go on with the next one: a + cmdlet that removes, copies, moves, or changes items would change them all, although the caller ended the pipeline, + and the caller would never see the exception. Every test works on files and folders in a sandbox. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.' @@ -41,6 +42,25 @@ BeforeDiscovery { $auditStopCases = foreach ($name in $auditNames) { @{ Name = $name } } + $failureCases = foreach ($name in $names) { + foreach ($style in 'throw', 'throw UnauthorizedAccessException', 'Write-Error -ErrorAction Stop') { + @{ Name = $name; Style = $style } + } + } + $auditFailureCases = foreach ($name in $auditNames) { + foreach ($style in 'throw', 'throw UnauthorizedAccessException', 'Write-Error -ErrorAction Stop') { + @{ Name = $name; Style = $style } + } + } + $streamCases = foreach ($case in @( + @{ Name = 'Get-FileHash2'; Stream = 'verbose' } + @{ Name = 'Set-NTFSSecurityDescriptor'; Stream = 'verbose' } + @{ Name = 'Set-NTFSOwner'; Stream = 'debug' } + )) { + foreach ($style in 'Select-Object -First 1', 'throw') { + @{ Name = $case.Name; Stream = $case.Stream; Style = $style } + } + } } BeforeAll { @@ -53,6 +73,7 @@ BeforeAll { $sidType = [System.Security.Principal.SecurityIdentifier] $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value $orphan = 'S-1-5-21-1-2-3-1001' + $privateData = (Get-Module -Name NTFSSecurity).PrivateData function New-Pair { [Diagnostics.CodeAnalysis.SuppressMessageAttribute( @@ -265,6 +286,30 @@ BeforeAll { } } + # The commands that write a verbose or a debug message inside the try of their loop, which the later command takes. + # The first record that reaches Select-Object ends the pipeline there. The preference of the debug stream is set by + # Assert-StreamStop: the Debug switch would ask before every message. + $streamRuns = @{ + 'Get-FileHash2/verbose' = @{ + # The first path is a folder, which the cmdlet skips with a verbose message. + Prepare = { + $context = New-Pair -Directory + $context.File = New-TestSandboxItem -Sandbox $sandbox -Name 'Hashed' + $context + } + Run = { param ($Context) Get-FileHash2 -Path $Context.First, $Context.File -Verbose 4>&1 } + } + 'Set-NTFSSecurityDescriptor/verbose' = @{ + Prepare = $cases['Set-NTFSSecurityDescriptor'].Prepare + Run = { param ($Context) Set-NTFSSecurityDescriptor -SecurityDescriptor $Context.Descriptors -Verbose 4>&1 } + Untouched = $cases['Set-NTFSSecurityDescriptor'].Untouched + } + 'Set-NTFSOwner/debug' = @{ + Prepare = { New-Pair } + Run = { param ($Context) Set-NTFSOwner -Path $Context.First, $Context.Second -Account $currentUser 5>&1 } + } + } + # The command writes its first object, and the break or continue of the later command ends the loop around the # pipeline before the next statement of the loop runs. function Assert-LoopControl { @@ -306,6 +351,93 @@ BeforeAll { (& $case.Untouched $context) | Should -BeTrue } } + + # A later command that fails with a terminating error ends the pipeline for the commands before it. The error is the + # caller's: the cmdlet must neither report it as an error of an item nor go on with the next item. The second style + # raises the type that some catch of the cmdlets handles on its own, for a folder that cannot be read. + function Assert-DownstreamFailure { + param ([string] $Name, [string] $Style) + + $case = $cases[$Name] + $context = & $case.Prepare + $emitted = 0 + $caught = $null + $Error.Clear() + try { + & $case.Run $context | ForEach-Object -Process { + $emitted++ + switch ($Style) { + 'throw' { throw 'Downstream failure' } + 'throw UnauthorizedAccessException' { throw [System.UnauthorizedAccessException]::new('Downstream failure') } + default { Write-Error -Message 'Downstream failure' -ErrorAction Stop } + } + } + } + catch { + $caught = $_ + } + + $caught.Exception.Message | Should -BeLike '*Downstream failure*' + $emitted | Should -Be 1 + @($Error | Where-Object -FilterScript { $_.Exception.Message -notlike '*Downstream failure*' }) | Should -BeNullOrEmpty + if ($case.Untouched) { + (& $case.Untouched $context) | Should -BeTrue + } + } + + # The first verbose or debug record reaches the later command, which ends the pipeline inside the try of the loop: + # Select-Object raises the end of the pipeline, a throw raises an exception of its own. With the privileges enabled, + # the cmdlet writes a message before that, outside the try, so they stay off here. + function Assert-StreamStop { + param ([string] $Name, [string] $Stream, [string] $Style) + + $case = $streamRuns["$Name/$Stream"] + $recordType = if ($Stream -eq 'debug') { [System.Management.Automation.DebugRecord] } else { [System.Management.Automation.VerboseRecord] } + $context = & $case.Prepare + $saved = $privateData['EnablePrivileges'] + $savedDebugPreference = $DebugPreference + $privateData['EnablePrivileges'] = $false + $DebugPreference = if ($Stream -eq 'debug') { 'Continue' } else { $savedDebugPreference } + $emitted = 0 + $caught = $null + $result = @() + $Error.Clear() + try { + if ($Style -eq 'throw') { + try { + & $case.Run $context | ForEach-Object -Process { + $emitted++ + throw 'Downstream failure' + } + } + catch { + $caught = $_ + } + } + else { + $result = @(& $case.Run $context | Select-Object -First 1) + } + } + finally { + $privateData['EnablePrivileges'] = $saved + $DebugPreference = $savedDebugPreference + } + + if ($Style -eq 'throw') { + $caught.Exception.Message | Should -BeLike '*Downstream failure*' + $emitted | Should -Be 1 + @($Error | Where-Object -FilterScript { $_.Exception.Message -notlike '*Downstream failure*' }) | Should -BeNullOrEmpty + } + else { + $result | Should -HaveCount 1 + $result[0] | Should -BeOfType $recordType + $Error.Count | Should -Be 0 + } + + if ($case.Untouched) { + (& $case.Untouched $context) | Should -BeTrue + } + } } AfterAll { @@ -330,4 +462,16 @@ Describe 'A later command that ends the pipeline' { It ' should stop after the first object for Select-Object -First 1 and change nothing else' -Skip:(-not $canReadAudit) -ForEach $auditStopCases { Assert-PipelineStop -Name $Name } + + It ' should stop for a terminating error (