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 <ai@example.com>
3 weeks ago
<#
Tests how the cmdlets of the module built in NTFSSecurity \ bin \ Release behave when a later command in the pipeline
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 <ai@example.com>
3 weeks ago
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 .
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 <ai@example.com>
3 weeks ago
#>
[ 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 }
}
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 <ai@example.com>
3 weeks ago
$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 }
}
}
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 <ai@example.com>
3 weeks ago
}
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'
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 <ai@example.com>
3 weeks ago
$privateData = ( Get-Module -Name NTFSSecurity ) . PrivateData
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 <ai@example.com>
3 weeks ago
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 }
}
}
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 <ai@example.com>
3 weeks ago
# 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 }
}
}
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 <ai@example.com>
3 weeks ago
# 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
}
}
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 <ai@example.com>
3 weeks ago
# 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
}
}
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 <ai@example.com>
3 weeks ago
}
AfterAll {
Pop-Location
Remove-TestSandbox -Sandbox $sandbox
Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue
}
Describe 'A later command that ends the pipeline' {
It '<Name> should leave the loop for <Keyword> after its first object and change nothing else' -ForEach $loopCases {
Assert-LoopControl -Name $Name -Keyword $Keyword
}
It '<Name> should stop after the first object for Select-Object -First 1 and change nothing else' -ForEach $stopCases {
Assert-PipelineStop -Name $Name
}
It '<Name> should leave the loop for <Keyword> after its first object and change nothing else' -Skip : ( -not $canReadAudit ) -ForEach $auditLoopCases {
Assert-LoopControl -Name $Name -Keyword $Keyword
}
It '<Name> should stop after the first object for Select-Object -First 1 and change nothing else' -Skip : ( -not $canReadAudit ) -ForEach $auditStopCases {
Assert-PipelineStop -Name $Name
}
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 <ai@example.com>
3 weeks ago
It '<Name> should stop for a terminating error (<Style>) of the later command and change nothing else' -ForEach $failureCases {
Assert-DownstreamFailure -Name $Name -Style $Style
}
It '<Name> should stop for a terminating error (<Style>) of the later command and change nothing else' -Skip : ( -not $canReadAudit ) -ForEach $auditFailureCases {
Assert-DownstreamFailure -Name $Name -Style $Style
}
It '<Name> should stop at the <Stream> message for <Style> of the later command and change nothing else' -ForEach $streamCases {
Assert-StreamStop -Name $Name -Stream $Stream -Style $Style
}
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 <ai@example.com>
3 weeks ago
}
# A cmdlet also meets the end of the pipeline where it did not write: another call of PowerShell can raise it too. The
# check that every catch makes recognizes the exceptions by their types. PowerShell keeps the exceptions of break and
# continue internal, so they are recognized by the name of their base type, which a stand-in with that name shows.
Describe 'Recognizing the end of a pipeline by the type of the exception' {
BeforeAll {
if ( -not ( 'NtfsSecurityTests.StandInForBreak' -as [type] ) ) {
# The compiler warns that the stand-in has the name of an imported type, and Add-Type treats a warning as an error.
Add-Type -IgnoreWarnings -TypeDefinition @ '
namespace System . Management . Automation { public class FlowControlException : System . Exception { } }
namespace NtfsSecurityTests { public class StandInForBreak : System . Management . Automation . FlowControlException { } }
' @
}
$isEnd = [NTFSSecurity.BaseCmdlet] . Assembly . GetType ( 'NTFSSecurity.PipelineControl' ) . GetMethod (
'IsEnd' , [System.Reflection.BindingFlags] 'NonPublic, Static' )
}
It 'Should recognize a PipelineStoppedException' {
$isEnd . Invoke ( $null , @ ( [System.Management.Automation.PipelineStoppedException] :: new ( ) ) ) | Should -BeTrue
}
It 'Should recognize an exception whose base type is the flow control exception of PowerShell' {
$isEnd . Invoke ( $null , @ ( [NtfsSecurityTests.StandInForBreak] :: new ( ) ) ) | Should -BeTrue
}
It 'Should not recognize <Description>' -ForEach @ (
@ { Description = 'a failure of an item' ; Exception = [System.InvalidOperationException] :: new ( 'Failure' ) }
@ { Description = 'an access denial' ; Exception = [System.UnauthorizedAccessException] :: new ( 'Denied' ) }
@ { Description = 'a failure with an inner exception that ends the pipeline' ; Exception = [System.InvalidOperationException] :: new ( 'Failure' , [System.Management.Automation.PipelineStoppedException] :: new ( ) ) }
) {
$isEnd . Invoke ( $null , @ ( $Exception ) ) | Should -BeFalse
}
}