From 40bf6a807b738f9ef4adb287f389f03dc5959718 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 16:47:59 +0000 Subject: [PATCH] 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 (