diff --git a/CHANGELOG.md b/CHANGELOG.md index 5db9b63..0b9045b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -368,4 +368,7 @@ The format is based on `localhost` in lowercase, such as `.`, `LOCALHOST`, or the computer name, where the computer doesn't offer the remote access check +- Fix [Get-ChildItem2](Docs/Cmdlets/Get-ChildItem2.md) with `-Hidden`, + which omitted the first hidden item unless `-Force` was also supplied + [Unreleased]: https://github.com/raandree/NTFSSecurity/compare/4.2.6...HEAD diff --git a/Docs/Cmdlets/Get-ChildItem2.md b/Docs/Cmdlets/Get-ChildItem2.md index af729af..b2e3519 100644 --- a/Docs/Cmdlets/Get-ChildItem2.md +++ b/Docs/Cmdlets/Get-ChildItem2.md @@ -307,7 +307,7 @@ The `PrivateData` section of the module manifest `NTFSSecurity.psd1` contains tw 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. +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. ## RELATED LINKS diff --git a/NTFSSecurity/ItemCmdlets/GetChildItem2.cs b/NTFSSecurity/ItemCmdlets/GetChildItem2.cs index bb908de..49d915a 100644 --- a/NTFSSecurity/ItemCmdlets/GetChildItem2.cs +++ b/NTFSSecurity/ItemCmdlets/GetChildItem2.cs @@ -279,7 +279,7 @@ namespace NTFSSecurity continue; } - var writeItem = force.ToBool(); + var writeItem = force.ToBool() || hidden.ToBool(); if (MyInvocation.BoundParameters.ContainsKey("Attributes")) { @@ -291,9 +291,6 @@ namespace NTFSSecurity } else { - if (hidden) - force = true; - if ((current.Attributes & global::System.IO.FileAttributes.Hidden) != global::System.IO.FileAttributes.Hidden) writeItem = true; diff --git a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml index fe60c28..251b553 100644 --- a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml +++ b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml @@ -3865,7 +3865,7 @@ PS C:\> Disable-Privileges The default table view shows the `Mode`, `Inherits`, `LastWriteTime`, `Size(M)`, and `Name` columns. `Inherits` is `False` for an item whose access inheritance is disabled. Reading that value costs one access to the ACL of each displayed item, which slows down the display of large listings; to avoid it, select the properties you need, for example with `Format-Table -Property Mode, LastWriteTime, Length, Name`. Objects that you pipe to another command are not affected. Before 5.0.0, the column showed `True` for every item. 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. + 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. diff --git a/Tests/ItemCmdlets.Tests.ps1 b/Tests/ItemCmdlets.Tests.ps1 index 760d83b..3b565f2 100644 --- a/Tests/ItemCmdlets.Tests.ps1 +++ b/Tests/ItemCmdlets.Tests.ps1 @@ -11,6 +11,7 @@ BeforeDiscovery { # A path on the administrative share of the drive of the sandboxes is another volume for Windows, like a share of a # file server. $canUseAdminShare = Test-AdminShareAvailable + $canCreateSymbolicLinks = Test-PrivilegeHeld -Name 'SeCreateSymbolicLinkPrivilege' } BeforeAll { @@ -62,6 +63,189 @@ Describe 'Get-ChildItem2' { } } + Context 'Attribute switches' { + BeforeAll { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'Switches' -Directory + $attributeCases = @{ + 'Plain.txt' = 'Normal' + 'Hidden.txt' = 'Hidden' + 'System.txt' = 'System' + 'ReadOnly.txt' = 'ReadOnly' + 'HiddenSystem.txt' = 'Hidden, System' + 'HiddenReadOnly.txt' = 'Hidden, ReadOnly' + 'All.txt' = 'Hidden, ReadOnly, System' + } + foreach ($name in $attributeCases.Keys) { + $path = Join-Path -Path $folder -ChildPath $name + Assert-TestSandboxPath -Sandbox $sandbox -Path $path + Set-Content -LiteralPath $path -Value $name + [IO.File]::SetAttributes($path, [IO.FileAttributes] $attributeCases[$name]) + } + } + + It 'Should apply without broadening the other attribute filters' -ForEach @( + @{ Case = 'default'; Parameters = @{}; Expected = @('Plain.txt', 'System.txt', 'ReadOnly.txt') } + @{ Case = 'Force'; Parameters = @{ Force = $true }; Expected = @('Plain.txt', 'Hidden.txt', 'System.txt', 'ReadOnly.txt', 'HiddenSystem.txt', 'HiddenReadOnly.txt', 'All.txt') } + @{ Case = 'Hidden'; Parameters = @{ Hidden = $true }; Expected = @('Hidden.txt', 'HiddenSystem.txt', 'HiddenReadOnly.txt', 'All.txt') } + @{ Case = 'System'; Parameters = @{ System = $true }; Expected = @('System.txt') } + @{ Case = 'System with Force'; Parameters = @{ System = $true; Force = $true }; Expected = @('System.txt', 'HiddenSystem.txt', 'All.txt') } + @{ Case = 'ReadOnly'; Parameters = @{ ReadOnly = $true }; Expected = @('ReadOnly.txt') } + @{ Case = 'ReadOnly with Force'; Parameters = @{ ReadOnly = $true; Force = $true }; Expected = @('ReadOnly.txt', 'HiddenReadOnly.txt', 'All.txt') } + @{ Case = 'Hidden and System'; Parameters = @{ Hidden = $true; System = $true }; Expected = @('HiddenSystem.txt', 'All.txt') } + @{ Case = 'Hidden and ReadOnly'; Parameters = @{ Hidden = $true; ReadOnly = $true }; Expected = @('HiddenReadOnly.txt', 'All.txt') } + @{ Case = 'System and ReadOnly with Force'; Parameters = @{ System = $true; ReadOnly = $true; Force = $true }; Expected = @('All.txt') } + ) { + $result = @(Get-ChildItem2 -Path $folder @Parameters -ErrorAction Stop) + + ($result.Name | Sort-Object) -join ',' | Should -Be (($Expected | Sort-Object) -join ',') + } + } + + It 'Should include the first hidden item without requiring explicit -Force' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'FirstHidden' -Directory + $file = Join-Path -Path $folder -ChildPath 'Only.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + Set-Content -LiteralPath $file -Value 'Hidden' + [IO.File]::SetAttributes($file, [IO.FileAttributes]::Hidden) + + $result = @(Get-ChildItem2 -Path $folder -Hidden -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $file + } + + Context 'Recursion, type filters, and depth' { + BeforeAll { + $tree = New-TestSandboxItem -Sandbox $sandbox -Name 'EnumerationTree' -Directory + $grandchild = Join-Path -Path $tree -ChildPath 'Child\Grandchild' + $paths = @('Root.txt', 'Child\Child.log', 'Child\Grandchild\Grand.TXT') | ForEach-Object { Join-Path -Path $tree -ChildPath $_ } + Assert-TestSandboxPath -Sandbox $sandbox -Path (@($grandchild) + @($paths)) + New-Item -ItemType Directory -Path $grandchild -Force | Out-Null + foreach ($path in $paths) { Set-Content -LiteralPath $path -Value 'Tree' } + } + + It 'Should return the exact tree for ' -ForEach @( + @{ Case = 'immediate children'; Parameters = @{}; Expected = @('Child', 'Root.txt') } + @{ Case = 'all descendants'; Parameters = @{ Recurse = $true }; Expected = @('Child', 'Root.txt', 'Child\Grandchild', 'Child\Child.log', 'Child\Grandchild\Grand.TXT') } + @{ Case = 'depth zero'; Parameters = @{ Recurse = $true; Depth = 0 }; Expected = @('Child', 'Root.txt') } + @{ Case = 'depth one'; Parameters = @{ Recurse = $true; Depth = 1 }; Expected = @('Child', 'Root.txt', 'Child\Grandchild', 'Child\Child.log') } + @{ Case = 'depth two'; Parameters = @{ Recurse = $true; Depth = 2 }; Expected = @('Child', 'Root.txt', 'Child\Grandchild', 'Child\Child.log', 'Child\Grandchild\Grand.TXT') } + @{ Case = 'directories'; Parameters = @{ Recurse = $true; Directory = $true }; Expected = @('Child', 'Child\Grandchild') } + @{ Case = 'files'; Parameters = @{ Recurse = $true; File = $true }; Expected = @('Root.txt', 'Child\Child.log', 'Child\Grandchild\Grand.TXT') } + @{ Case = 'case-insensitive file filter'; Parameters = @{ Recurse = $true; File = $true; Filter = '*.txt' }; Expected = @('Root.txt', 'Child\Grandchild\Grand.TXT') } + ) { + $result = @(Get-ChildItem2 -Path $tree @Parameters -ErrorAction Stop) + $relative = @($result | ForEach-Object { $_.FullName.Substring($tree.Length + 1) }) + + ($relative | Sort-Object) -join ',' | Should -Be (($Expected | Sort-Object) -join ',') + } + + It 'Should stop a recursive pipeline without recording an enumeration error' { + $result = @(Get-ChildItem2 -Path $tree -Recurse -ErrorVariable childErrors -ErrorAction SilentlyContinue | Select-Object -First 1) + + $result | Should -HaveCount 1 + $childErrors | Should -BeNullOrEmpty + } + } + + Context 'Unreadable directories' { + It 'Should report the denied folder and continue with the next path' { + $blocked = New-TestSandboxItem -Sandbox $sandbox -Name 'CannotList' -Directory + $next = New-TestSandboxItem -Sandbox $sandbox -Name 'CanList' -Directory + $file = Join-Path -Path $next -ChildPath 'Next.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + Set-Content -LiteralPath $file -Value 'Next' + Add-TestDenyRule -Sandbox $sandbox -Path $blocked -Rights @{ 'S-1-1-0' = 'ReadData' } + + $result = @(Get-ChildItem2 -Path $blocked, $next -ErrorVariable childErrors -ErrorAction SilentlyContinue) + + $childErrors | Should -HaveCount 1 + $childErrors[0].FullyQualifiedErrorId | Should -BeLike 'DirUnauthorizedAccessError,*' + $childErrors[0].CategoryInfo.Category | Should -Be 'PermissionDenied' + $childErrors[0].TargetObject | Should -Be $blocked + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $file + } + } + + Context 'Link traversal' { + It 'Should return a junction itself but skip its contents with -SkipMountPoints' { + $root = New-TestSandboxItem -Sandbox $sandbox -Name 'JunctionListing' -Directory + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'JunctionTarget' -Directory + $file = Join-Path -Path $target -ChildPath 'Target.txt' + $link = Join-Path -Path $root -ChildPath 'Link' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file, $link + Set-Content -LiteralPath $file -Value 'Target' + New-Item -ItemType Junction -Path $link -Value $target | Out-Null + + $result = @(Get-ChildItem2 -Path $root -Recurse -SkipMountPoints -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $link + Get-Content -LiteralPath $file | Should -Be 'Target' + } + + It 'Should return a symbolic link itself but skip its contents with -SkipSymbolicLinks' -Skip:(-not $canCreateSymbolicLinks) { + $root = New-TestSandboxItem -Sandbox $sandbox -Name 'SymbolicListing' -Directory + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'SymbolicTarget' -Directory + $file = Join-Path -Path $target -ChildPath 'Target.txt' + $link = Join-Path -Path $root -ChildPath 'Link' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file, $link + Set-Content -LiteralPath $file -Value 'Target' + New-NTFSSymbolicLink -Path $link -Target $target -ErrorAction Stop + + $result = @(Get-ChildItem2 -Path $root -Recurse -SkipSymbolicLinks -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $link + Get-Content -LiteralPath $file | Should -Be 'Target' + } + } + + Context 'Optional object properties' { + BeforeEach { + $settings = (Get-Module -Name NTFSSecurity).PrivateData + $savedMode = $settings['GetFileSystemModeProperty'] + $savedHardLinks = $settings['IdentifyHardLinks'] + } + + AfterEach { + $settings['GetFileSystemModeProperty'] = $savedMode + $settings['IdentifyHardLinks'] = $savedHardLinks + } + + It 'Should honor Mode and HardLinkCount enabled=<_>' -ForEach @($true, $false) { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'ObjectProperties' -Directory + $file = Join-Path -Path $folder -ChildPath 'File.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + Set-Content -LiteralPath $file -Value 'Properties' + [IO.File]::SetAttributes($file, [IO.FileAttributes]::Archive) + $settings['GetFileSystemModeProperty'] = $_ + $settings['IdentifyHardLinks'] = $_ + + $item = Get-ChildItem2 -Path $file -ErrorAction Stop + + if ($_) { + $item.Mode | Should -BeExactly '-a---' + $item.HardLinkCount | Should -Be 1 + } + else { + $item.PSObject.Properties['Mode'] | Should -BeNullOrEmpty + $item.PSObject.Properties['HardLinkCount'] | Should -BeNullOrEmpty + } + } + + It 'Should render read-only, hidden, and system bits in the Mode property' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'ModeBits' + [IO.File]::SetAttributes($file, [IO.FileAttributes] 'ReadOnly, Hidden, System') + $settings['GetFileSystemModeProperty'] = $true + + $item = Get-ChildItem2 -Path $file -Force -ErrorAction Stop + + $item.Mode | Should -BeExactly '--rhs' + } + } + Context 'Default table view' { BeforeAll { $viewFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'View' -Directory