From 360417a5c3cee53523a62fbc8e647ef0bfca8fb4 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 14:19:33 +0000 Subject: [PATCH] test: exercise item, link and descriptor failure paths Cover locked and denied copy and move, recursive enumeration that stops or meets a broken junction, hard link counts on a network share, denied link creation, the default root folder of a drive root, ownerless -PassThru and an undefined hash algorithm. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/Access.Tests.ps1 | 13 ++++ Tests/FileHash.Tests.ps1 | 11 ++++ Tests/ItemCmdlets.Tests.ps1 | 126 ++++++++++++++++++++++++++++++++++++ Tests/Links.Tests.ps1 | 53 +++++++++++++++ Tests/Owner.Tests.ps1 | 14 ++++ 5 files changed, 217 insertions(+) diff --git a/Tests/Access.Tests.ps1 b/Tests/Access.Tests.ps1 index 0c3efdc..7e2b81d 100644 --- a/Tests/Access.Tests.ps1 +++ b/Tests/Access.Tests.ps1 @@ -388,6 +388,19 @@ Describe 'Get-NTFSSimpleAccess' { @($result | Where-Object -Property FullName -EQ -Value $root) | Should -HaveCount $rootAlone.Count } + # With -IncludeRootFolder, the default, the cmdlet reports the parent folder of the first path first. A drive root + # has none, so it reports the root itself, once. The test reads the entries of the drive root only. + It 'Should report no parent folder in front of a drive root by default' { + $root = [IO.Path]::GetPathRoot($child) + $rootAlone = @(Get-NTFSSimpleAccess -Path $root -IncludeRootFolder:$false -ErrorAction Stop) + + $result = @(Get-NTFSSimpleAccess -Path $root -ErrorVariable simpleErrors -ErrorAction SilentlyContinue) + + $simpleErrors | Should -BeNullOrEmpty + $result | Should -HaveCount $rootAlone.Count + $result | ForEach-Object -Process { $_.FullName | Should -Be $root } + } + # Windows doesn't distinguish paths by case. Before 5.0.0-rc7, the cmdlet didn't recognize the parent folder of a # folder whose path differed from it in case, and left the folder out. It 'Should compare a folder with its parent folder also when their paths differ in case' { diff --git a/Tests/FileHash.Tests.ps1 b/Tests/FileHash.Tests.ps1 index 821ed2f..04184d1 100644 --- a/Tests/FileHash.Tests.ps1 +++ b/Tests/FileHash.Tests.ps1 @@ -61,6 +61,17 @@ Describe 'Get-FileHash2' { $hashError.FullyQualifiedErrorId | Should -BeLike 'HashAlgorithmNotAvailable,*' } + # PowerShell binds only the named algorithms to -Algorithm, so a program that calls the public method with an + # undefined value is the only way to get here. + It 'Should refuse an algorithm that the enumeration does not define when the public method creates it' { + $unknown = [Enum]::ToObject([Security2.FileSystem.FileInfo.HashAlgorithms], 99) + + $failure = { [Security2.FileSystem.FileInfo.Extensions]::CreateHashAlgorithm($unknown) } | Should -Throw -PassThru + + $failure.Exception.GetBaseException() | Should -BeOfType [System.ArgumentOutOfRangeException] + $failure.Exception.GetBaseException().ParamName | Should -BeExactly 'algorithm' + } + It 'Should warn once that MACTripleDES is deprecated' -Skip:$isCore { $results = @(Get-FileHash2 -Path $first, $second -Algorithm MACTripleDES -WarningVariable hashWarnings -WarningAction SilentlyContinue) diff --git a/Tests/ItemCmdlets.Tests.ps1 b/Tests/ItemCmdlets.Tests.ps1 index 324098d..6c194a1 100644 --- a/Tests/ItemCmdlets.Tests.ps1 +++ b/Tests/ItemCmdlets.Tests.ps1 @@ -146,6 +146,32 @@ Describe 'Get-ChildItem2' { $result | Should -HaveCount 1 $childErrors | Should -BeNullOrEmpty } + + # The second file comes from a sub folder, so the pipeline stops while the cmdlet is inside the recursion. + It 'Should stop a recursive pipeline inside a sub folder without recording an enumeration error' { + $result = @(Get-ChildItem2 -Path $tree -Recurse -File -ErrorVariable childErrors -ErrorAction SilentlyContinue | Select-Object -First 2) + + $result | Should -HaveCount 2 + $childErrors | Should -BeNullOrEmpty + } + + # A break or continue in a later pipeline stage passes through the cmdlet as an exception, which it must not + # report as a failed folder. + It 'Should end a recursive enumeration for in a later pipeline stage without recording an enumeration error' -ForEach @( + @{ Keyword = 'break' } + @{ Keyword = 'continue' } + ) { + $names = [System.Collections.Generic.List[string]]::new() + foreach ($round in 1) { + Get-ChildItem2 -Path $tree -Recurse -File -ErrorVariable childErrors -ErrorAction SilentlyContinue | ForEach-Object -Process { + $names.Add($_.Name) + if ($Keyword -eq 'break') { break } else { continue } + } + } + + $names | Should -HaveCount 1 + $childErrors | Should -BeNullOrEmpty + } } Context 'Unreadable directories' { @@ -200,6 +226,31 @@ Describe 'Get-ChildItem2' { $result[0].FullName | Should -Be $link Get-Content -LiteralPath $file | Should -Be 'Target' } + + # A junction whose target is gone passes the existence check, but the folder behind it can't be opened. The + # error belongs to that folder, and the enumeration goes on with the next one. + It 'Should report a junction whose target was removed as a DirUnspecifiedError and continue with the next folder' { + $root = New-TestSandboxItem -Sandbox $sandbox -Name 'BrokenJunction' -Directory + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'RemovedTarget' -Directory + $link = Join-Path -Path $root -ChildPath 'Broken' + $sibling = Join-Path -Path $root -ChildPath 'Sibling' + $file = Join-Path -Path $sibling -ChildPath 'Sibling.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link, $sibling, $file + New-Item -ItemType Directory -Path $sibling | Out-Null + Set-Content -LiteralPath $file -Value 'Sibling' + New-Item -ItemType Junction -Path $link -Value $target | Out-Null + Remove-Item -LiteralPath $target -Force + + $result = @(Get-ChildItem2 -Path $root -Recurse -ErrorVariable childErrors -ErrorAction SilentlyContinue) + + $childErrors | Should -HaveCount 1 + $childErrors[0].FullyQualifiedErrorId | Should -BeLike 'DirUnspecifiedError,*' + $childErrors[0].CategoryInfo.Category | Should -Be 'NotSpecified' + $childErrors[0].TargetObject | Should -Be $link + $childErrors[0].Exception | Should -BeOfType [System.IO.DirectoryNotFoundException] + @($result.FullName | Sort-Object) | Should -Be @(@($link, $sibling, $file) | Sort-Object) + Get-Content -LiteralPath $file | Should -Be 'Sibling' + } } Context 'Optional object properties' { @@ -244,6 +295,29 @@ Describe 'Get-ChildItem2' { $item.Mode | Should -BeExactly '--rhs' } + + # Windows can't list the hard links of a file on a network share, (50) "The request is not supported". The cmdlet + # still returns the file, without HardLinkCount, and says why in a debug message. The test sets the preference, + # because -Debug would prompt in Windows PowerShell. + It 'Should return a file on a network share without HardLinkCount and say why in a debug message' -Skip:(-not $canUseAdminShare) { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'ShareProperties' -Directory + $file = Join-Path -Path $folder -ChildPath 'Share.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + Set-Content -LiteralPath $file -Value 'Share' + $sharePath = ConvertTo-TestAdminSharePath -Sandbox $sandbox -Path $file + $settings['IdentifyHardLinks'] = $true + $DebugPreference = 'Continue' + + $output = @(Get-ChildItem2 -Path $sharePath -ErrorVariable childErrors -ErrorAction SilentlyContinue 5>&1) + + $childErrors | Should -BeNullOrEmpty + $items = @($output | Where-Object -FilterScript { $_ -isnot [Management.Automation.DebugRecord] }) + $items | Should -HaveCount 1 + $items[0].Name | Should -BeExactly 'Share.txt' + $items[0].PSObject.Properties['HardLinkCount'] | Should -BeNullOrEmpty + $messages = @($output | Where-Object -FilterScript { $_ -is [Management.Automation.DebugRecord] } | ForEach-Object -Process { $_.Message }) + $messages | Should -Contain "Could not read hard links for '$sharePath'" + } } Context 'Default table view' { @@ -557,6 +631,58 @@ Describe 'Copy-Item2, Move-Item2, and Remove-Item2 with several paths' { $itemErrors[0].Exception.Message | Should -BeLike "*'$missingShare'*" $first | Should -Exist } + + # A sharing violation is an IOException, which both cmdlets write as InvalidData; the error belongs to its source + # only, and no object comes out for it with -PassThru. + It ' should write a for a source that another process has locked and continue with the next path' -ForEach @( + @{ Command = 'Copy-Item2'; ErrorId = 'CopyError' } + @{ Command = 'Move-Item2'; ErrorId = 'MoveError' } + ) { + $stream = [IO.File]::Open($first, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::None) + try { + $result = @(& $Command -Path $first, $second -Destination $destination -PassThru $true -ErrorVariable itemErrors -ErrorAction SilentlyContinue) + } + finally { + $stream.Dispose() + } + + $itemErrors | Should -HaveCount 1 + $itemErrors[0].FullyQualifiedErrorId | Should -BeLike "$ErrorId,*" + $itemErrors[0].CategoryInfo.Category | Should -Be 'InvalidData' + $itemErrors[0].TargetObject | Should -Be $first + $itemErrors[0].Exception | Should -BeOfType [System.IO.IOException] + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be (Join-Path -Path $destination -ChildPath 'Second.txt') + Join-Path -Path $destination -ChildPath 'First.txt' | Should -Not -Exist + Get-Content -LiteralPath $first | Should -Be 'First' + Get-Content -LiteralPath (Join-Path -Path $destination -ChildPath 'Second.txt') | Should -Be 'Second' + } + + # Any other failure of Windows is not an IOException, and both cmdlets write it as NotSpecified. A deny entry for + # Everyone also applies to an administrator, who doesn't bypass the DACL without a backup privilege. + It ' should write a for each source when the destination folder denies new files' -ForEach @( + @{ Command = 'Copy-Item2'; ErrorId = 'CopyError' } + @{ Command = 'Move-Item2'; ErrorId = 'MoveError' } + ) { + $denied = Join-Path -Path $folder -ChildPath 'Denied' + Assert-TestSandboxPath -Sandbox $sandbox -Path $denied + New-Item -ItemType Directory -Path $denied | Out-Null + Add-TestDenyRule -Sandbox $sandbox -Path $denied -Rights @{ 'S-1-1-0' = 'CreateFiles' } + + $result = @(& $Command -Path $first, $second -Destination $denied -PassThru $true -ErrorVariable itemErrors -ErrorAction SilentlyContinue) + + $itemErrors | Should -HaveCount 2 + for ($index = 0; $index -lt 2; $index++) { + $itemErrors[$index].FullyQualifiedErrorId | Should -BeLike "$ErrorId,*" + $itemErrors[$index].CategoryInfo.Category | Should -Be 'NotSpecified' + $itemErrors[$index].TargetObject | Should -Be @($first, $second)[$index] + $itemErrors[$index].Exception | Should -BeOfType [System.UnauthorizedAccessException] + } + $result | Should -BeNullOrEmpty + @(Get-ChildItem -LiteralPath $denied -Force) | Should -BeNullOrEmpty + Get-Content -LiteralPath $first | Should -Be 'First' + Get-Content -LiteralPath $second | Should -Be 'Second' + } } Describe 'Move-Item2' { diff --git a/Tests/Links.Tests.ps1 b/Tests/Links.Tests.ps1 index 5bc938b..b3bb3b4 100644 --- a/Tests/Links.Tests.ps1 +++ b/Tests/Links.Tests.ps1 @@ -159,6 +159,25 @@ Describe 'New-NTFSHardLink' { $linkErrors[0].FullyQualifiedErrorId | Should -BeLike 'GetHardLinkError,*' $result | Should -BeNullOrEmpty } + + It 'Should write a PermissionDenied error and create no link in a folder that denies new files' { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'DeniedTarget' + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'DeniedFolder' -Directory + $link = Join-Path -Path $folder -ChildPath 'Link.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + Add-TestDenyRule -Sandbox $sandbox -Path $folder -Rights @{ 'S-1-1-0' = 'CreateFiles' } + + $result = @(New-NTFSHardLink -Path $link -Target $target -PassThru -ErrorVariable linkErrors -ErrorAction SilentlyContinue) + + $linkErrors | Should -HaveCount 1 + $linkErrors[0].FullyQualifiedErrorId | Should -BeLike 'CreateHardLinkError,*' + $linkErrors[0].CategoryInfo.Category | Should -Be 'PermissionDenied' + $linkErrors[0].TargetObject | Should -Be $link + $linkErrors[0].Exception | Should -BeOfType [System.UnauthorizedAccessException] + $result | Should -BeNullOrEmpty + $link | Should -Not -Exist + Get-Content -LiteralPath $target | Should -Be 'DeniedTarget' + } } Describe 'Get-NTFSHardLink' { @@ -368,6 +387,26 @@ Describe 'New-NTFSSymbolicLink' { '0x{0:X8}' -f $linkErrors[0].Exception.HResult | Should -Be '0x80070522' Test-Path2 -Path $link | Should -BeFalse } + + # With the right to create symbolic links, Windows refuses the link only for the folder of the link, which denies + # new files here. + It 'Should write a PermissionDenied error and create no link in a folder that denies new files' -Skip:(-not $canCreateSymbolicLinks) { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'SymbolicDeniedTarget' + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'SymbolicDeniedFolder' -Directory + $link = Join-Path -Path $folder -ChildPath 'Link.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + Add-TestDenyRule -Sandbox $sandbox -Path $folder -Rights @{ 'S-1-1-0' = 'CreateFiles' } + + $result = @(New-NTFSSymbolicLink -Path $link -Target $target -PassThru -ErrorVariable linkErrors -ErrorAction SilentlyContinue) + + $linkErrors | Should -HaveCount 1 + $linkErrors[0].FullyQualifiedErrorId | Should -BeLike 'CreateSymbolicLinkError,*' + $linkErrors[0].CategoryInfo.Category | Should -Be 'PermissionDenied' + $linkErrors[0].TargetObject | Should -Be $link + $linkErrors[0].Exception | Should -BeOfType [System.UnauthorizedAccessException] + $result | Should -BeNullOrEmpty + Test-Path2 -Path $link | Should -BeFalse + } } # Each error names its item, so that the errors of many links can be told apart. Before 5.0.0-rc7, the errors of @@ -456,4 +495,18 @@ Describe 'Parameters of the cmdlets that create links' { $sets | Should -Not -BeNullOrEmpty $sets | ForEach-Object -Process { $_.IsMandatory | Should -BeTrue } } + + # PowerShell reads a parameter that takes pipeline input before it binds the input, so the getter must not fail + # while the cmdlet has no -Path. Before 5.0.0-rc7, it threw an index error, and every piped object failed with + # GetDefaultValueFailed. + It ' should return no -Path until it has one, and the first one afterwards' -ForEach @( + @{ Type = 'NTFSSecurity.NewHardLink' } + @{ Type = 'NTFSSecurity.NewSymbolicLink' } + ) { + $cmdlet = New-Object -TypeName $Type + + $cmdlet.Path | Should -BeNullOrEmpty + $cmdlet.Path = 'C:\NTFSSecurity\Link.txt' + $cmdlet.Path | Should -BeExactly 'C:\NTFSSecurity\Link.txt' + } } diff --git a/Tests/Owner.Tests.ps1 b/Tests/Owner.Tests.ps1 index 00e4697..324f749 100644 --- a/Tests/Owner.Tests.ps1 +++ b/Tests/Owner.Tests.ps1 @@ -299,5 +299,19 @@ Describe 'Set-NTFSOwner' { Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop Get-TestOwner -Path $file | Should -Be $currentUser } + + It 'Should write nothing without -PassThru and leave the owner of the item unchanged' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'SetOwnerDescriptorQuiet' + $owner = Get-TestOwner -Path $file + $sd = Get-NTFSSecurityDescriptor -Path $file + $sidType = [System.Security.Principal.SecurityIdentifier] + $sd.SecurityDescriptor.GetOwner($sidType).Value | Should -Not -Be 'S-1-1-0' + + $result = @(Set-NTFSOwner -SecurityDescriptor $sd -Account 'S-1-1-0' -ErrorAction Stop) + + $result | Should -BeNullOrEmpty + $sd.SecurityDescriptor.GetOwner($sidType).Value | Should -Be 'S-1-1-0' + Get-TestOwner -Path $file | Should -Be $owner + } } }