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 (