From c77ecbf1a96880c9429acbb541b8ae41d456b26b Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 15:31:29 +0000 Subject: [PATCH] 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 + } +}