Browse Source

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>
pull/118/head
Raimund Andree 2 days ago
parent
commit
40bf6a807b
  1. 24
      CHANGELOG.md
  2. 14
      Docs/Cmdlets/Get-ChildItem2.md
  3. 75
      NTFSSecurity/BaseCmdlets.cs
  4. 2
      NTFSSecurity/ItemCmdlets/CopyItem2.cs
  5. 16
      NTFSSecurity/ItemCmdlets/GetChildItem2.cs
  6. 20
      NTFSSecurity/ItemCmdlets/GetDiskSpace.cs
  7. 2
      NTFSSecurity/ItemCmdlets/MoveItem2.cs
  8. 2
      NTFSSecurity/ItemCmdlets/RemoveItem2.cs
  9. 6
      NTFSSecurity/MiscCmdlets/GetFileHash2.cs
  10. 2
      NTFSSecurity/OwnerCmdlets/SetOwner.cs
  11. 4
      NTFSSecurity/SecurityDescriptorCmdlets/GetSecurityDescriptor.cs
  12. 8
      NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs
  13. 2
      NTFSSecurity/SimpleAccessCmdlets/SimpleAccessCmdlets.cs
  14. 14
      NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml
  15. 57
      Security2/Win32/Lib.cs
  16. 31
      Tests/ItemCmdlets.Tests.ps1
  17. 152
      Tests/PipelineControl.Tests.ps1
  18. 2
      Tests/SecurityDescriptor.Tests.ps1
  19. 21
      Tests/TestHelpers.Tests.ps1

24
CHANGELOG.md

@ -122,20 +122,24 @@ The format is based on
whose folder Windows cannot name, such as for an item that was deleted 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 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 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`, - Fix `Remove-Item2`, `Copy-Item2`, `Move-Item2`, `Set-NTFSOwner`,
`Set-NTFSSecurityDescriptor`, `Get-NTFSSecurityDescriptor`, `Set-NTFSSecurityDescriptor`, `Get-NTFSSecurityDescriptor`,
`Get-NTFSSimpleAccess`, `Get-DiskSpace`, and `Get-ChildItem2` below the `Get-NTFSSimpleAccess`, `Get-FileHash2`, `Get-DiskSpace`, and
first folder, which went on with the next item when a later command ended `Get-ChildItem2` below the first folder, which went on with the next item
the pipeline: a `break` or `continue` or `Select-Object -First` became an when a later command ended the pipeline: a `break` or `continue`,
error of the item, so that `Remove-Item2 -PassThru | Select-Object -First 1` `Select-Object -First`, or a `throw` was handled as a failure of the item,
removed every item. They now stop and write no error 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 - 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, 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 such as `Report[1].txt`, for that name; only `*` and `?` are wildcards. A
is an ordinary character, so `*.*` returns only the names that contain a dot null `-Filter` is rejected as a parameter error
(unlike `Get-ChildItem`), and a null `-Filter` is rejected as a parameter - Fix `Get-ChildItem2 -Filter *.*`, which returned only the items with a dot
error 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 - Fix `Get-Help`, which showed only the syntax: ship the help file
`en-US\NTFSSecurity.dll-Help.xml` generated from the cmdlet documentation, `en-US\NTFSSecurity.dll-Help.xml` generated from the cmdlet documentation,
including the links that `Get-Help -Online` opens, instead of the outdated including the links that `Get-Help -Online` opens, instead of the outdated

14
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`. 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 ## PARAMETERS
### -Attributes ### -Attributes
@ -134,7 +142,7 @@ Accept wildcard characters: False
### -Filter ### -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 ```yaml
Type: String 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, 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 ## RELATED LINKS

75
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 /// 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 /// 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 /// 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.
/// </summary> /// </summary>
internal static class PipelineControl internal static class PipelineControl
{ {
@ -44,6 +45,78 @@ namespace NTFSSecurity
protected List<string> paths = new List<string>(); protected List<string> paths = new List<string>();
protected List<FileSystemSecurity2> securityDescriptors = new List<FileSystemSecurity2>(); protected List<FileSystemSecurity2> securityDescriptors = new List<FileSystemSecurity2>();
// 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;
/// <summary>Writes the object to the pipeline and notes what a later command raises, see IsFromLaterCommand.</summary>
public new void WriteObject(object sendToPipeline)
{
try
{
base.WriteObject(sendToPipeline);
}
catch (Exception ex)
{
laterCommandException = ex;
throw;
}
}
/// <summary>Writes the object to the pipeline and notes what a later command raises, see IsFromLaterCommand.</summary>
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.
/// <summary>Writes a verbose message and notes what a later command raises, see IsFromLaterCommand.</summary>
public new void WriteVerbose(string text)
{
try
{
base.WriteVerbose(text);
}
catch (Exception ex)
{
laterCommandException = ex;
throw;
}
}
/// <summary>Writes a debug message and notes what a later command raises, see IsFromLaterCommand.</summary>
public new void WriteDebug(string text)
{
try
{
base.WriteDebug(text);
}
catch (Exception ex)
{
laterCommandException = ex;
throw;
}
}
/// <summary>
/// Whether the exception comes from a later command of the pipeline, not from the item that the cmdlet processes.
/// </summary>
protected bool IsFromLaterCommand(Exception exception)
{
return ReferenceEquals(exception, laterCommandException) || PipelineControl.IsEnd(exception);
}
protected override void BeginProcessing() protected override void BeginProcessing()
{ {
base.BeginProcessing(); base.BeginProcessing();

2
NTFSSecurity/ItemCmdlets/CopyItem2.cs

@ -149,7 +149,7 @@ namespace NTFSSecurity
} }
catch (Exception ex) catch (Exception ex)
{ {
if (PipelineControl.IsEnd(ex)) if (IsFromLaterCommand(ex))
{ {
throw; throw;
} }

16
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 // 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 // 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. // returned for its name. The enumeration returns every item for *.* as Windows does, so the comparison does
wildcard = new WildcardPattern(filter.Replace("`", "``").Replace("[", "`[").Replace("]", "`]"), WildcardOptions.Compiled | WildcardOptions.IgnoreCase); // 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"); modeMethodInfo = typeof(FileSystemCodeMembers).GetMethod("Mode");
@ -250,9 +252,9 @@ namespace NTFSSecurity
} }
catch (Exception ex) catch (Exception ex)
{ {
// Not what a later command raises to end the pipeline or the loop around it, which this catch // Not what a later command raises, which this catch would hide; the verbose message is for a
// would hide; the verbose message is for a folder that can't be listed. // folder that can't be listed.
if (PipelineControl.IsEnd(ex)) if (IsFromLaterCommand(ex))
{ {
throw; 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)); WriteError(new ErrorRecord(ex, "DirUnauthorizedAccessError", ErrorCategory.PermissionDenied, di.FullName));
} }
@ -271,7 +273,7 @@ namespace NTFSSecurity
} }
catch (Exception ex) catch (Exception ex)
{ {
if (PipelineControl.IsEnd(ex)) if (IsFromLaterCommand(ex))
{ {
throw; throw;
} }

20
NTFSSecurity/ItemCmdlets/GetDiskSpace.cs

@ -36,22 +36,22 @@ namespace NTFSSecurity
foreach (var letter in driveLetter) foreach (var letter in driveLetter)
{ {
var diskSpaceInfo = new DiskSpaceInfo(letter); var diskSpaceInfo = new DiskSpaceInfo(letter);
var hasSpace = false;
try try
{ {
diskSpaceInfo.Refresh(); diskSpaceInfo.Refresh();
if (diskSpaceInfo.TotalNumberOfBytes > 0) hasSpace = diskSpaceInfo.TotalNumberOfBytes > 0;
{
this.WriteObject(diskSpaceInfo);
}
} }
catch (Exception ex) catch (Exception)
{ {
if (PipelineControl.IsEnd(ex))
{
throw;
}
this.WriteWarning(string.Format("Could not get drive details for '{0}'", letter)); 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);
} }
} }
} }

2
NTFSSecurity/ItemCmdlets/MoveItem2.cs

@ -160,7 +160,7 @@ namespace NTFSSecurity
} }
catch (Exception ex) catch (Exception ex)
{ {
if (PipelineControl.IsEnd(ex)) if (IsFromLaterCommand(ex))
{ {
throw; throw;
} }

2
NTFSSecurity/ItemCmdlets/RemoveItem2.cs

@ -102,7 +102,7 @@ namespace NTFSSecurity
} }
catch (Exception ex) catch (Exception ex)
{ {
if (PipelineControl.IsEnd(ex)) if (IsFromLaterCommand(ex))
{ {
throw; throw;
} }

6
NTFSSecurity/MiscCmdlets/GetFileHash2.cs

@ -78,6 +78,12 @@ namespace NTFSSecurity
} }
catch (Exception ex) 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)); WriteError(new ErrorRecord(ex, "ReadFileError", ErrorCategory.OpenError, path));
continue; continue;
} }

2
NTFSSecurity/OwnerCmdlets/SetOwner.cs

@ -87,7 +87,7 @@ namespace NTFSSecurity
} }
catch (Exception ex) catch (Exception ex)
{ {
if (PipelineControl.IsEnd(ex)) if (IsFromLaterCommand(ex))
{ {
throw; throw;
} }

4
NTFSSecurity/SecurityDescriptorCmdlets/GetSecurityDescriptor.cs

@ -64,7 +64,7 @@ namespace NTFSSecurity
} }
catch (Exception ex2) catch (Exception ex2)
{ {
if (PipelineControl.IsEnd(ex2)) if (IsFromLaterCommand(ex2))
{ {
throw; throw;
} }
@ -75,7 +75,7 @@ namespace NTFSSecurity
} }
catch (Exception ex) catch (Exception ex)
{ {
if (PipelineControl.IsEnd(ex)) if (IsFromLaterCommand(ex))
{ {
throw; throw;
} }

8
NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs

@ -67,6 +67,12 @@ namespace NTFSSecurity
} }
catch (Exception ex) 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)); WriteError(new ErrorRecord(ex, "WriteSdError", ErrorCategory.WriteError, sd.Item));
continue; continue;
} }
@ -81,7 +87,7 @@ namespace NTFSSecurity
} }
catch (Exception ex) catch (Exception ex)
{ {
if (PipelineControl.IsEnd(ex)) if (IsFromLaterCommand(ex))
{ {
throw; throw;
} }

2
NTFSSecurity/SimpleAccessCmdlets/SimpleAccessCmdlets.cs

@ -111,7 +111,7 @@ namespace NTFSSecurity
} }
catch (Exception ex) catch (Exception ex)
{ {
if (PipelineControl.IsEnd(ex)) if (IsFromLaterCommand(ex))
{ {
throw; throw;
} }

14
NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml

@ -3520,7 +3520,7 @@ PS C:\&gt; Disable-Privileges</dev:code>
<command:parameter required="false" variableLength="true" globbing="false" pipelineInput="False" position="2" aliases="none"> <command:parameter required="false" variableLength="true" globbing="false" pipelineInput="False" position="2" aliases="none">
<maml:name>Filter</maml:name> <maml:name>Filter</maml:name>
<maml:description> <maml:description>
<maml:para>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.</maml:para> <maml:para>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.</maml:para>
</maml:description> </maml:description>
<command:parameterValue required="true" variableLength="false">String</command:parameterValue> <command:parameterValue required="true" variableLength="false">String</command:parameterValue>
<dev:type> <dev:type>
@ -3724,7 +3724,7 @@ PS C:\&gt; Disable-Privileges</dev:code>
<command:parameter required="false" variableLength="true" globbing="false" pipelineInput="False" position="2" aliases="none"> <command:parameter required="false" variableLength="true" globbing="false" pipelineInput="False" position="2" aliases="none">
<maml:name>Filter</maml:name> <maml:name>Filter</maml:name>
<maml:description> <maml:description>
<maml:para>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.</maml:para> <maml:para>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.</maml:para>
</maml:description> </maml:description>
<command:parameterValue required="true" variableLength="false">String</command:parameterValue> <command:parameterValue required="true" variableLength="false">String</command:parameterValue>
<dev:type> <dev:type>
@ -3866,7 +3866,8 @@ PS C:\&gt; Disable-Privileges</dev:code>
<maml:para>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.</maml:para> <maml:para>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.</maml:para>
<maml:para>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.</maml:para> <maml:para>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.</maml:para>
<maml:para>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.</maml:para> <maml:para>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.</maml:para>
<maml:para>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.</maml:para> <maml:para>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.</maml:para>
<maml:para>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.</maml:para>
</maml:alert> </maml:alert>
</maml:alertSet> </maml:alertSet>
<command:examples> <command:examples>
@ -3898,6 +3899,13 @@ PS C:\&gt; Disable-Privileges</dev:code>
<maml:para>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`.</maml:para> <maml:para>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`.</maml:para>
</dev:remarks> </dev:remarks>
</command:example> </command:example>
<command:example>
<maml:title>Example 5: Return every item, with or without a dot in its name</maml:title>
<dev:code>PS C:\&gt; Get-ChildItem2 -Path C:\Data -Filter *.*</dev:code>
<dev:remarks>
<maml:para>Returns every item of `C:\Data`, also the files and folders whose names have no dot, as `Get-ChildItem` does for this filter.</maml:para>
</dev:remarks>
</command:example>
</command:examples> </command:examples>
<command:relatedLinks> <command:relatedLinks>
<maml:navigationLink> <maml:navigationLink>

57
Security2/Win32/Lib.cs

@ -71,35 +71,42 @@ namespace Security2
var pInheritInfo = Marshal.AllocHGlobal(aceCount * Marshal.SizeOf(typeof(PINHERITED_FROM))); var pInheritInfo = Marshal.AllocHGlobal(aceCount * Marshal.SizeOf(typeof(PINHERITED_FROM)));
returnValue = GetInheritanceSource( try
path,
ResourceType.FileObject,
aclType,
isContainer,
IntPtr.Zero,
0,
aclBytes,
IntPtr.Zero,
ref genericMap,
pInheritInfo
);
if (returnValue != 0)
{ {
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++) for (int i = 0; i < aceCount; i++)
{ {
var inheritInfo = pInheritInfo.ElementAt<PINHERITED_FROM>(i); var inheritInfo = pInheritInfo.ElementAt<PINHERITED_FROM>(i);
inheritedFrom.Add( inheritedFrom.Add(
!string.IsNullOrEmpty(inheritInfo.AncestorName) && inheritInfo.AncestorName.StartsWith(@"\\?\") ? inheritInfo.AncestorName.Substring(4) : inheritInfo.AncestorName !string.IsNullOrEmpty(inheritInfo.AncestorName) && inheritInfo.AncestorName.StartsWith(@"\\?\") ? inheritInfo.AncestorName.Substring(4) : inheritInfo.AncestorName
); );
} }
FreeInheritedFromArray(pInheritInfo, (ushort)aceCount, IntPtr.Zero); FreeInheritedFromArray(pInheritInfo, (ushort)aceCount, IntPtr.Zero);
Marshal.FreeHGlobal(pInheritInfo); }
finally
{
// Also after a failed call, which the fallback of GetInheritedFrom now expects.
Marshal.FreeHGlobal(pInheritInfo);
}
return inheritedFrom; return inheritedFrom;
} }

31
Tests/ItemCmdlets.Tests.ps1

@ -176,10 +176,10 @@ Describe 'Get-ChildItem2' {
$result[0].Name | Should -BeExactly 'Report[1].txt' $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 # The dot is an ordinary character of the pattern, but not in *.*, which Windows, Get-ChildItem, and .NET read as
# alone would return every item for it, as Get-ChildItem and cmd.exe do; the cmdlet then compares each name with # every item. Before 5.0.0, the cmdlet compared each name with the pattern again and dropped the items without a
# the pattern and drops the items whose names have no dot, files and folders alike. # dot, files and folders alike, so that a listing of a tree with this filter missed most of its folders.
It 'Should return only the items with a dot in their names for -Filter *.*' { It 'Should return every item for -Filter *.*, also the ones without a dot in their names' {
$folder = New-TestSandboxItem -Sandbox $sandbox -Name 'FilterDot' -Directory $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'FilterDot' -Directory
$paths = @('Page.htm', 'NoExtension', 'NoExtensionFolder') | ForEach-Object -Process { Join-Path -Path $folder -ChildPath $_ } $paths = @('Page.htm', 'NoExtension', 'NoExtensionFolder') | ForEach-Object -Process { Join-Path -Path $folder -ChildPath $_ }
Assert-TestSandboxPath -Sandbox $sandbox -Path $paths Assert-TestSandboxPath -Sandbox $sandbox -Path $paths
@ -189,7 +189,22 @@ Describe 'Get-ChildItem2' {
$result = @(Get-ChildItem2 -Path $folder -Filter '*.*' -ErrorAction Stop) $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' { 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' } @{ Command = 'Move-Item2'; ErrorId = 'MoveError' }
) { ) {
$used = @((Get-PSDrive -PSProvider FileSystem).Name) + @([System.IO.DriveInfo]::GetDrives() | ForEach-Object -Process { $_.Name.Substring(0, 1) }) $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) { if (-not $letter) {
Set-ItResult -Skipped -Because 'every drive letter is in use' Set-ItResult -Skipped -Because 'every drive letter is in use'
return return
@ -1022,7 +1038,8 @@ Describe 'Get-DiskSpace' {
It 'Should warn and return nothing for a drive letter without a volume' { 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) }) $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) { if (-not $letter) {
Set-ItResult -Skipped -Because 'every drive letter is in use' Set-ItResult -Skipped -Because 'every drive letter is in use'
return return

152
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 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 ends it: a break or continue in a script block, Select-Object -First, or a terminating error such as a throw. The
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 exception that carries it passes through the cmdlet while it writes an object, a verbose message, or a debug message.
and go on with the next one: a cmdlet that removes, copies, moves, or changes items would change them all, although 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
the caller ended the pipeline. Every test works on files and folders in a sandbox. 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( [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.' 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.'
@ -41,6 +42,25 @@ BeforeDiscovery {
$auditStopCases = foreach ($name in $auditNames) { $auditStopCases = foreach ($name in $auditNames) {
@{ Name = $name } @{ 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 { BeforeAll {
@ -53,6 +73,7 @@ BeforeAll {
$sidType = [System.Security.Principal.SecurityIdentifier] $sidType = [System.Security.Principal.SecurityIdentifier]
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
$orphan = 'S-1-5-21-1-2-3-1001' $orphan = 'S-1-5-21-1-2-3-1001'
$privateData = (Get-Module -Name NTFSSecurity).PrivateData
function New-Pair { function New-Pair {
[Diagnostics.CodeAnalysis.SuppressMessageAttribute( [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 # 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. # pipeline before the next statement of the loop runs.
function Assert-LoopControl { function Assert-LoopControl {
@ -306,6 +351,93 @@ BeforeAll {
(& $case.Untouched $context) | Should -BeTrue (& $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 { AfterAll {
@ -330,4 +462,16 @@ Describe 'A later command that ends the pipeline' {
It '<Name> should stop after the first object for Select-Object -First 1 and change nothing else' -Skip:(-not $canReadAudit) -ForEach $auditStopCases { 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 Assert-PipelineStop -Name $Name
} }
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
}
} }

2
Tests/SecurityDescriptor.Tests.ps1

@ -273,6 +273,8 @@ Describe 'Set-NTFSSecurityDescriptor' {
Set-TestOwner -Sandbox $sandbox -Path $file -Sid $administrators Set-TestOwner -Sandbox $sandbox -Path $file -Sid $administrators
Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-3-4' = 'ChangePermissions' } Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-3-4' = 'ChangePermissions' }
Get-RestorePrivilegeState | Should -Be 'Disabled' Get-RestorePrivilegeState | Should -Be 'Disabled'
# A plain write of the DACL is denied, so that the cmdlet has to take ownership for its write.
{ Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-5-32-546' = 'ReadData' } } | Should -Throw
$sd = Get-NTFSSecurityDescriptor -Path $file $sd = Get-NTFSSecurityDescriptor -Path $file
Add-NTFSAccess -SecurityDescriptor $sd -Account 'Everyone' -AccessRights ReadData Add-NTFSAccess -SecurityDescriptor $sd -Account 'Everyone' -AccessRights ReadData

21
Tests/TestHelpers.Tests.ps1

@ -304,4 +304,25 @@ Describe 'Test helpers' {
Test-AdminShareAvailable | Should -BeFalse Test-AdminShareAvailable | Should -BeFalse
} }
} }
# subst maps a letter for the whole logon session, so the guard has to stop before it runs, for every configuration.
Context 'New-TestDriveMapping and Remove-TestDriveMapping' {
BeforeAll {
$sandbox = New-TestSandbox -Name 'Helpers'
}
AfterAll {
Remove-TestSandbox -Sandbox $sandbox
}
It 'Should refuse a folder outside the sandbox before it maps anything' {
{ New-TestDriveMapping -Sandbox $sandbox -Path "$sandbox-Other\Folder" } |
Should -Throw -ExpectedMessage 'Refusing to change*'
}
It 'Should refuse a value that is not the root of a drive' {
{ Remove-TestDriveMapping -Root 'C:\Windows' } |
Should -Throw -ErrorId 'ParameterArgumentValidationError,Remove-TestDriveMapping'
}
}
} }

Loading…
Cancel
Save