diff --git a/Tests/ParameterSets.Items.Tests.ps1 b/Tests/ParameterSets.Items.Tests.ps1 index 6441a31..ef6eca4 100644 --- a/Tests/ParameterSets.Items.Tests.ps1 +++ b/Tests/ParameterSets.Items.Tests.ps1 @@ -14,6 +14,10 @@ param () BeforeDiscovery { Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force $canCreateSymbolicLinks = Test-PrivilegeHeld -Name 'SeCreateSymbolicLinkPrivilege' + # The restricted token of the basic-user runner cannot map a drive letter or reach the administrative share. + $canMapDrive = Test-DriveMappingAvailable + $canUseAdminShare = Test-AdminShareAvailable + $isElevated = Test-IsElevated } BeforeAll { @@ -593,3 +597,792 @@ Describe 'Get-FileHash2 with pipeline input and ordered failures' { $relativeResult.Hash | Should -BeExactly $result[0].Hash } } + +Describe 'Bracket and wildcard characters in a path are treated literally' { + # A bracket is an ordinary character in a path, as the existing -Filter test already pins for Get-ChildItem2 + # (ItemCmdlets.Tests.ps1:202); this closes the same gap for the path parameter of the other item cmdlets. + It ' should treat a path with brackets in the name as a literal file name' -ForEach @( + @{ Command = 'Copy-Item2' } + @{ Command = 'Move-Item2' } + @{ Command = 'Get-Item2' } + @{ Command = 'Remove-Item2' } + ) { + $sourceFolder = New-TestSandboxItem -Sandbox $sandbox -Name ('Bracket{0}Source' -f $Command.Replace('-', '')) -Directory + $bracketFile = Join-Path -Path $sourceFolder -ChildPath 'Report[1].txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $bracketFile + Set-Content -LiteralPath $bracketFile -Value 'Bracket' + + switch ($Command) { + 'Copy-Item2' { + $destination = Join-Path -Path $sourceFolder -ChildPath 'Copied' + New-Item -ItemType Directory -Path $destination | Out-Null + + $result = Copy-Item2 -Path $bracketFile -Destination $destination -PassThru $true -ErrorAction Stop + + $result.Name | Should -Be 'Report[1].txt' + Get-Content -LiteralPath (Join-Path -Path $destination -ChildPath 'Report[1].txt') | Should -Be 'Bracket' + # Should -Exist uses a non-literal Test-Path, which reads the brackets as a character class and + # would misreport a file that is actually there; [IO.File]::Exists is a literal, independent oracle. + [IO.File]::Exists($bracketFile) | Should -BeTrue + } + 'Move-Item2' { + $destination = Join-Path -Path $sourceFolder -ChildPath 'Moved' + New-Item -ItemType Directory -Path $destination | Out-Null + + $result = Move-Item2 -Path $bracketFile -Destination $destination -PassThru $true -ErrorAction Stop + + $result.Name | Should -Be 'Report[1].txt' + [IO.File]::Exists($bracketFile) | Should -BeFalse + Get-Content -LiteralPath (Join-Path -Path $destination -ChildPath 'Report[1].txt') | Should -Be 'Bracket' + } + 'Get-Item2' { + $result = Get-Item2 -Path $bracketFile -ErrorAction Stop + + $result.Name | Should -Be 'Report[1].txt' + $result | Should -BeOfType [Alphaleonis.Win32.Filesystem.FileInfo] + } + 'Remove-Item2' { + $result = Remove-Item2 -Path $bracketFile -PassThru -ErrorAction Stop + + $result.Name | Should -Be 'Report[1].txt' + [IO.File]::Exists($bracketFile) | Should -BeFalse + } + } + } + + # '*' and '?' are not in [IO.Path]::GetInvalidPathChars() in either edition (only '|' and the control characters + # are, and Windows PowerShell additionally rejects '"', '<', '>'), so a literal asterisk or question mark reaches + # the file system as an ordinary character instead of raising ArgumentException: AlphaFS, like System.IO, looks + # for the exact literal name and reports it missing rather than expanding the character as a search pattern. + It ' should not expand an asterisk in a path as a wildcard and should report FileNotFound' -ForEach @( + @{ Command = 'Move-Item2'; ErrorId = 'FileNotFound' } + @{ Command = 'Remove-Item2'; ErrorId = 'FileNotFound' } + ) { + $sourceFolder = New-TestSandboxItem -Sandbox $sandbox -Name ('Wildcard{0}Source' -f $Command.Replace('-', '')) -Directory + $fileA = Join-Path -Path $sourceFolder -ChildPath 'FileA.txt' + $fileB = Join-Path -Path $sourceFolder -ChildPath 'FileB.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $fileA, $fileB + Set-Content -LiteralPath $fileA -Value 'A' + Set-Content -LiteralPath $fileB -Value 'B' + $wildcardPath = Join-Path -Path $sourceFolder -ChildPath 'File*.txt' + + switch ($Command) { + 'Move-Item2' { + $destination = Join-Path -Path $sourceFolder -ChildPath 'Destination' + New-Item -ItemType Directory -Path $destination | Out-Null + + Move-Item2 -Path $wildcardPath -Destination $destination -ErrorVariable itemErrors -ErrorAction SilentlyContinue + } + 'Remove-Item2' { + Remove-Item2 -Path $wildcardPath -ErrorVariable itemErrors -ErrorAction SilentlyContinue + } + } + + $itemErrors | Should -HaveCount 1 + $itemErrors[0].FullyQualifiedErrorId | Should -BeLike "$ErrorId,*" + $itemErrors[0].CategoryInfo.Category | Should -Be 'ObjectNotFound' + $itemErrors[0].TargetObject | Should -Be $wildcardPath + $fileA | Should -Exist + $fileB | Should -Exist + Get-Content -LiteralPath $fileA | Should -Be 'A' + Get-Content -LiteralPath $fileB | Should -Be 'B' + } +} + +Describe 'Items whose path is longer than 260 characters' { + BeforeAll { + Add-Type -Path (Join-Path -Path (Get-Module -Name NTFSSecurity).ModuleBase -ChildPath 'AlphaFS.dll') + + # Builds a fresh folder tree and file below it whose full path is longer than 260 characters, in its own + # branch of the sandbox, and returns the file's full path. Mirrors ItemCmdlets.Tests.ps1:1029: only the short + # root goes through Assert-TestSandboxPath, because [IO.Path] (which it calls) throws PathTooLongException + # for the long path itself in Windows PowerShell; AlphaFS does not have that limit. A script block, not a + # function: PowerShell does not parse an attribute on a function statement nested inside another block, so + # PSUseShouldProcessForStateChangingFunctions (its name starts with New) could not be suppressed that way. + $newTestLongPathFile = { + param ([Parameter(Mandatory)] [string] $Name) + + $root = New-TestSandboxItem -Sandbox $sandbox -Name $Name -Directory + $deep = Join-Path -Path $root -ChildPath ((('A' * 100), ('B' * 100), ('C' * 100)) -join '\') + [Alphaleonis.Win32.Filesystem.Directory]::CreateDirectory($deep) | Out-Null + $file = Join-Path -Path $deep -ChildPath 'Long.txt' + [Alphaleonis.Win32.Filesystem.File]::WriteAllText($file, 'LongPathContent') + if ($file.Length -le 260) { + throw "The test path '$file' is not longer than 260 characters." + } + $file + } + } + + It 'Get-Item2 should return the file and its folder' { + $file = & $newTestLongPathFile -Name 'GetItemLong' + + $fileResult = Get-Item2 -Path $file -ErrorAction Stop + $folderResult = Get-Item2 -Path (Split-Path -Path $file -Parent) -ErrorAction Stop + + $fileResult | Should -BeOfType [Alphaleonis.Win32.Filesystem.FileInfo] + $fileResult.FullName | Should -Be $file + $folderResult | Should -BeOfType [Alphaleonis.Win32.Filesystem.DirectoryInfo] + } + + It 'Get-ChildItem2 should list the content of the folder' { + $file = & $newTestLongPathFile -Name 'ChildItemLong' + + $result = @(Get-ChildItem2 -Path (Split-Path -Path $file -Parent) -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $file + } + + It 'Get-FileHash2 should hash the file and match an independent SHA256 read through AlphaFS' { + $file = & $newTestLongPathFile -Name 'HashLong' + $bytes = [Alphaleonis.Win32.Filesystem.File]::ReadAllBytes($file) + $expectedHash = -join (([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)) | ForEach-Object -Process { $_.ToString('X2') }) + + $result = Get-FileHash2 -Path $file -ErrorAction Stop + + $result.Hash | Should -BeExactly $expectedHash + } + + It 'Copy-Item2 should copy the file to a short destination and leave the source in place' { + $file = & $newTestLongPathFile -Name 'CopyLong' + $destination = New-TestSandboxItem -Sandbox $sandbox -Name 'CopyLongDestination' -Directory + + $result = Copy-Item2 -Path $file -Destination $destination -PassThru $true -ErrorAction Stop + + $result | Should -BeOfType [Alphaleonis.Win32.Filesystem.FileInfo] + $copied = Join-Path -Path $destination -ChildPath 'Long.txt' + Get-Content -LiteralPath $copied | Should -Be 'LongPathContent' + [Alphaleonis.Win32.Filesystem.File]::Exists($file) | Should -BeTrue + } + + It 'Move-Item2 should move the file to a short destination and remove the source' { + $file = & $newTestLongPathFile -Name 'MoveLong' + $destination = New-TestSandboxItem -Sandbox $sandbox -Name 'MoveLongDestination' -Directory + + $result = Move-Item2 -Path $file -Destination $destination -PassThru $true -ErrorAction Stop + + $result | Should -BeOfType [Alphaleonis.Win32.Filesystem.FileInfo] + [Alphaleonis.Win32.Filesystem.File]::Exists($file) | Should -BeFalse + $moved = Join-Path -Path $destination -ChildPath 'Long.txt' + Get-Content -LiteralPath $moved | Should -Be 'LongPathContent' + } +} + +Describe 'Item cmdlets at the root of a mapped drive' -Skip:(-not $canMapDrive) { + # NEVER the real root of a volume: a drive letter that subst maps to a sandbox folder is the root of a drive for + # Windows and for the module (see Tests\DriveRoot.Tests.ps1), without touching a real volume. + BeforeAll { + $driveSandbox = New-TestSandbox -Name 'ItemsDriveRoot' + $mapped = New-TestSandboxItem -Sandbox $driveSandbox -Name 'Mapped' -Directory + $driveRoot = New-TestDriveMapping -Sandbox $driveSandbox -Path $mapped + if (-not $driveRoot) { + throw 'No drive letter could be mapped to the sandbox folder.' + } + $existingFile = Join-Path -Path $mapped -ChildPath 'Existing.txt' + Assert-TestSandboxPath -Sandbox $driveSandbox -Path $existingFile + Set-Content -LiteralPath $existingFile -Value 'Existing' + } + + AfterAll { + if ($driveRoot) { + Remove-TestDriveMapping -Root $driveRoot + } + Remove-TestSandbox -Sandbox $driveSandbox + } + + It 'Get-Item2 should return a DirectoryInfo object for the root of the mapped drive' { + $result = Get-Item2 -Path $driveRoot -ErrorAction Stop + + $result | Should -BeOfType [Alphaleonis.Win32.Filesystem.DirectoryInfo] + $result.FullName.TrimEnd('\') | Should -Be $driveRoot.TrimEnd('\') + } + + It 'Get-ChildItem2 should list the content at the root of the mapped drive' { + $result = @(Get-ChildItem2 -Path $driveRoot -ErrorAction Stop) + + $result.Name | Should -Contain 'Existing.txt' + } + + It 'Test-Path2 should find the root of the mapped drive as a container' { + Test-Path2 -Path $driveRoot -PathType Container -ErrorAction Stop | Should -BeTrue + } + + It 'Copy-Item2 should copy a file to the root of the mapped drive' { + $source = New-TestSandboxItem -Sandbox $driveSandbox -Name 'CopyToDriveRootSource' + Set-Content -LiteralPath $source -Value 'CopyToRoot' + + $result = Copy-Item2 -Path $source -Destination $driveRoot -PassThru $true -ErrorAction Stop + + $result | Should -BeOfType [Alphaleonis.Win32.Filesystem.FileInfo] + $copied = Join-Path -Path $mapped -ChildPath (Split-Path -Path $source -Leaf) + Get-Content -LiteralPath $copied | Should -Be 'CopyToRoot' + $source | Should -Exist + } + + It 'Move-Item2 should move a file to the root of the mapped drive' { + $source = New-TestSandboxItem -Sandbox $driveSandbox -Name 'MoveToDriveRootSource' + Set-Content -LiteralPath $source -Value 'MoveToRoot' + + $result = Move-Item2 -Path $source -Destination $driveRoot -PassThru $true -ErrorAction Stop + + $result | Should -BeOfType [Alphaleonis.Win32.Filesystem.FileInfo] + $source | Should -Not -Exist + $moved = Join-Path -Path $mapped -ChildPath (Split-Path -Path $source -Leaf) + Get-Content -LiteralPath $moved | Should -Be 'MoveToRoot' + } +} + +Describe 'Omitted -Path falls back to the current location' { + BeforeAll { + $currentLocationFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'OmittedPathLocation' -Directory + $currentLocationFile = Join-Path -Path $currentLocationFolder -ChildPath 'Child.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $currentLocationFile + Set-Content -LiteralPath $currentLocationFile -Value 'Child' + } + + It 'Get-Item2 should return the folder of the current location' { + Push-Location -LiteralPath $currentLocationFolder + try { + $result = Get-Item2 -ErrorAction Stop + } + finally { + Pop-Location + } + + $result | Should -BeOfType [Alphaleonis.Win32.Filesystem.DirectoryInfo] + $result.FullName.TrimEnd('\') | Should -Be $currentLocationFolder.TrimEnd('\') + } + + It 'Get-ChildItem2 should list the content of the current location' { + Push-Location -LiteralPath $currentLocationFolder + try { + $result = @(Get-ChildItem2 -ErrorAction Stop) + } + finally { + Pop-Location + } + + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $currentLocationFile + } +} + +Describe 'Get-ChildItem2 parameter interactions' { + BeforeAll { + $interactionFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'ParamInteractions' -Directory + $interactionSubFolder = Join-Path -Path $interactionFolder -ChildPath 'Sub' + $interactionTopFile = Join-Path -Path $interactionFolder -ChildPath 'Top.txt' + $interactionNestedFile = Join-Path -Path $interactionSubFolder -ChildPath 'Nested.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $interactionSubFolder, $interactionTopFile, $interactionNestedFile + New-Item -ItemType Directory -Path $interactionSubFolder | Out-Null + Set-Content -LiteralPath $interactionTopFile -Value 'Top' + Set-Content -LiteralPath $interactionNestedFile -Value 'Nested' + + $attributeFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'AttributesIgnored' -Directory + $hiddenSystemFile = Join-Path -Path $attributeFolder -ChildPath 'HiddenSystem.txt' + $systemFile = Join-Path -Path $attributeFolder -ChildPath 'System.txt' + $hiddenFile = Join-Path -Path $attributeFolder -ChildPath 'Hidden.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $hiddenSystemFile, $systemFile, $hiddenFile + Set-Content -LiteralPath $hiddenSystemFile -Value 'HS' + Set-Content -LiteralPath $systemFile -Value 'S' + Set-Content -LiteralPath $hiddenFile -Value 'H' + [IO.File]::SetAttributes($hiddenSystemFile, [IO.FileAttributes] 'Hidden, System') + [IO.File]::SetAttributes($systemFile, [IO.FileAttributes] 'System') + [IO.File]::SetAttributes($hiddenFile, [IO.FileAttributes] 'Hidden') + } + + It 'Should resolve a relative -Path against the current location' { + Push-Location -LiteralPath $interactionFolder + try { + $result = @(Get-ChildItem2 -Path (Split-Path -Path $interactionSubFolder -Leaf) -ErrorAction Stop) + } + finally { + Pop-Location + } + + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $interactionNestedFile + } + + It 'Should return only the folder when -Directory and -File are given together' { + $result = @(Get-ChildItem2 -Path $interactionFolder -Directory -File -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0] | Should -BeOfType [Alphaleonis.Win32.Filesystem.DirectoryInfo] + $result[0].FullName | Should -Be $interactionSubFolder + } + + # The cmdlet page documents that -Attributes makes the cmdlet ignore -Force, -Hidden, -System, and -ReadOnly, and + # that it returns a matching hidden item without -Force; each row returns the same names as -Attributes alone, + # which these switches would narrow or widen if they were not ignored. + It 'Should return the same matches for -Attributes regardless of -Force, -Hidden, -System, and -ReadOnly ()' -ForEach @( + @{ Case = 'alone'; AttributesValue = 'System'; Switches = @{} } + @{ Case = 'with every other switch set'; AttributesValue = 'System'; Switches = @{ Force = $true; Hidden = $true; System = $true; ReadOnly = $true } } + @{ Case = 'alone, matching a hidden item without -Force'; AttributesValue = 'Hidden'; Switches = @{} } + @{ Case = 'with -ReadOnly set'; AttributesValue = 'Hidden'; Switches = @{ ReadOnly = $true } } + ) { + $expected = if ($AttributesValue -eq 'System') { @('HiddenSystem.txt', 'System.txt') } else { @('Hidden.txt', 'HiddenSystem.txt') } + + $result = @(Get-ChildItem2 -Path $attributeFolder -Attributes $AttributesValue @Switches -ErrorAction Stop) + + @($result.Name | Sort-Object) | Should -Be $expected + } + + It 'Should have no effect without -Recurse' { + $withDepth = @(Get-ChildItem2 -Path $interactionFolder -Depth 0 -ErrorAction Stop) + $withoutDepth = @(Get-ChildItem2 -Path $interactionFolder -ErrorAction Stop) + + @($withDepth.Name | Sort-Object) | Should -Be @('Sub', 'Top.txt') + @($withDepth.Name | Sort-Object) | Should -Be @($withoutDepth.Name | Sort-Object) + } + + It 'Should list the same items through the dir2 alias' { + $result = @(dir2 -Path $interactionFolder -ErrorAction Stop) + + @($result.Name | Sort-Object) | Should -Be @('Sub', 'Top.txt') + } + + It 'Should write AttributesEmpty with its category and target for an empty -Attributes value' { + $caught = $null + + try { + Get-ChildItem2 -Path $interactionFolder -Attributes 0 -ErrorAction Stop + } + catch { + $caught = $_ + } + + $caught.FullyQualifiedErrorId | Should -Be 'AttributesEmpty,NTFSSecurity.GetChildItem2' + $caught.CategoryInfo.Category | Should -Be 'InvalidArgument' + $caught.TargetObject | Should -Be ([IO.FileAttributes] 0) + } +} + +Describe 'Copy-Item2 destination conflicts and output' { + It 'Should replace an existing file directly with -Force, with PassThru=<_>' -ForEach @($false, $true) { + $sourceFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'CopyForceSource' -Directory + $destinationFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'CopyForceDestination' -Directory + $source = Join-Path -Path $sourceFolder -ChildPath 'Replace.txt' + $target = Join-Path -Path $destinationFolder -ChildPath 'Replace.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $source, $target + Set-Content -LiteralPath $source -Value 'NewContent' + Set-Content -LiteralPath $target -Value 'OldContent' + + $result = @(Copy-Item2 -Path $source -Destination $destinationFolder -Force -PassThru $_ -ErrorAction Stop) + + Get-Content -LiteralPath $target | Should -Be 'NewContent' + $source | Should -Exist + if ($_) { + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $target + } + else { + $result | Should -BeNullOrEmpty + } + } + + # The folder case of the same conflict (ItemCmdlets.Tests.ps1:633) already asserts ID, category, and target; this + # file case was asserted by error count only. + It 'Should write DestinationFileAlreadyExists with its category and target for a file that exists at the destination' { + $source = New-TestSandboxItem -Sandbox $sandbox -Name 'FileConflictSource' + $destinationFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'FileConflictDestination' -Directory + $existing = Join-Path -Path $destinationFolder -ChildPath (Split-Path -Path $source -Leaf) + Assert-TestSandboxPath -Sandbox $sandbox -Path $existing + Set-Content -LiteralPath $existing -Value 'Existing' + + Copy-Item2 -Path $source -Destination $destinationFolder -ErrorVariable itemErrors -ErrorAction SilentlyContinue + + $itemErrors | Should -HaveCount 1 + $itemErrors[0].FullyQualifiedErrorId | Should -BeLike 'DestinationFileAlreadyExists,*' + $itemErrors[0].CategoryInfo.Category | Should -Be 'ResourceExists' + $itemErrors[0].TargetObject | Should -Be $existing + Get-Content -LiteralPath $existing | Should -Be 'Existing' + } + + It '-PassThru should return the output type for a file and for a folder, and write nothing without -PassThru' { + $sourceFile = New-TestSandboxItem -Sandbox $sandbox -Name 'PassThruTypeFile' + $sourceFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'PassThruTypeFolder' -Directory + $destination = New-TestSandboxItem -Sandbox $sandbox -Name 'PassThruTypeDestination' -Directory + + $fileResult = @(Copy-Item2 -Path $sourceFile -Destination $destination -PassThru $true -ErrorAction Stop) + $folderResult = @(Copy-Item2 -Path $sourceFolder -Destination (Join-Path -Path $destination -ChildPath 'CopiedFolder') -PassThru $true -ErrorAction Stop) + $quietResult = @(Copy-Item2 -Path $sourceFile -Destination (Join-Path -Path $destination -ChildPath 'Quiet') -ErrorAction Stop) + + $fileResult | Should -HaveCount 1 + $fileResult[0] | Should -BeOfType [Alphaleonis.Win32.Filesystem.FileInfo] + $folderResult | Should -HaveCount 1 + $folderResult[0] | Should -BeOfType [Alphaleonis.Win32.Filesystem.DirectoryInfo] + $quietResult | Should -BeNullOrEmpty + } +} + +Describe 'Move-Item2 folder trees, in-place renames, and -Force' { + It 'Should move a non-empty folder tree to a new destination' { + $sourceFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'MoveTreeSource' -Directory + $nestedFolder = Join-Path -Path $sourceFolder -ChildPath 'Nested' + $topFile = Join-Path -Path $sourceFolder -ChildPath 'Top.txt' + $nestedFile = Join-Path -Path $nestedFolder -ChildPath 'Inner.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $nestedFolder, $topFile, $nestedFile + New-Item -ItemType Directory -Path $nestedFolder | Out-Null + Set-Content -LiteralPath $topFile -Value 'Top' + Set-Content -LiteralPath $nestedFile -Value 'Inner' + $destinationParent = New-TestSandboxItem -Sandbox $sandbox -Name 'MoveTreeDestinationParent' -Directory + + $result = Move-Item2 -Path $sourceFolder -Destination $destinationParent -PassThru $true -ErrorAction Stop + + $movedFolder = Join-Path -Path $destinationParent -ChildPath (Split-Path -Path $sourceFolder -Leaf) + $result | Should -BeOfType [Alphaleonis.Win32.Filesystem.DirectoryInfo] + $sourceFolder | Should -Not -Exist + Get-Content -LiteralPath (Join-Path -Path $movedFolder -ChildPath 'Top.txt') | Should -Be 'Top' + Get-Content -LiteralPath (Join-Path -Path $movedFolder -ChildPath 'Nested\Inner.txt') | Should -Be 'Inner' + } + + It 'Should rename an item in place on the same volume' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'RenameSource' + Set-Content -LiteralPath $file -Value 'RenameContent' + $renamed = Join-Path -Path (Split-Path -Path $file -Parent) -ChildPath 'RenameTarget.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $renamed + + $result = Move-Item2 -Path $file -Destination $renamed -PassThru $true -ErrorAction Stop + + $file | Should -Not -Exist + $renamed | Should -Exist + Get-Content -LiteralPath $renamed | Should -Be 'RenameContent' + $result.FullName | Should -Be $renamed + } + + # Unlike Copy-Item2 -Force, which merges into an existing folder of the same name (ItemCmdlets.Tests.ps1:681), + # Move-Item2 -Force replaces it outright: run first with diagnostics instead of an assumed error (the + # destinationExists check of MoveItem2.cs is bypassed by -Force, since it is guarded by "!force"), the existing + # folder at the destination was gone afterwards, together with the file it held, and the moved folder took its + # place with no error, which this pins as observed. + It 'Should replace an existing folder outright with -Force, not merge it, and write no error' { + $sourceFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'MoveForceFolderSource' -Directory + $destinationParent = New-TestSandboxItem -Sandbox $sandbox -Name 'MoveForceFolderDestinationParent' -Directory + $existingFolder = Join-Path -Path $destinationParent -ChildPath (Split-Path -Path $sourceFolder -Leaf) + Assert-TestSandboxPath -Sandbox $sandbox -Path $existingFolder + New-Item -ItemType Directory -Path $existingFolder | Out-Null + $sourceContent = Join-Path -Path $sourceFolder -ChildPath 'FromSource.txt' + $existingContent = Join-Path -Path $existingFolder -ChildPath 'AlreadyThere.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $sourceContent, $existingContent + Set-Content -LiteralPath $sourceContent -Value 'FromSource' + Set-Content -LiteralPath $existingContent -Value 'AlreadyThere' + + $result = Move-Item2 -Path $sourceFolder -Destination $destinationParent -Force -PassThru $true -ErrorVariable itemErrors -ErrorAction SilentlyContinue + + $itemErrors | Should -BeNullOrEmpty + $result | Should -BeOfType [Alphaleonis.Win32.Filesystem.DirectoryInfo] + $result.FullName | Should -Be $existingFolder + [IO.Directory]::Exists($sourceFolder) | Should -BeFalse + Get-Content -LiteralPath (Join-Path -Path $existingFolder -ChildPath 'FromSource.txt') | Should -Be 'FromSource' + [IO.File]::Exists($existingContent) | Should -BeFalse + } +} + + +Describe 'Remove-Item2 defaults, aliases, and error details' { + It 'Should do nothing when -Path is omitted' { + $omittedPathFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveOmittedPath' -Directory + $kept = Join-Path -Path $omittedPathFolder -ChildPath 'Kept.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $kept + Set-Content -LiteralPath $kept -Value 'Kept' + + Push-Location -LiteralPath $omittedPathFolder + try { + $result = @(Remove-Item2 -PassThru -ErrorVariable removeErrors -ErrorAction SilentlyContinue) + } + finally { + Pop-Location + } + + $removeErrors | Should -BeNullOrEmpty + $result | Should -BeNullOrEmpty + $kept | Should -Exist + $omittedPathFolder | Should -Exist + } + + It 'Should remove the file through the alias' -ForEach @( + @{ Alias = 'rm2' } + @{ Alias = 'del2' } + ) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name "RemoveAlias$Alias" + + & $Alias -Path $file -ErrorAction Stop + + $file | Should -Not -Exist + } + + It 'Should write FileNotFound with its category and target for a path that does not exist' { + $missing = Join-Path -Path $sandbox -ChildPath ('RemoveMissing-{0}' -f [guid]::NewGuid().ToString('N').Substring(0, 8)) + + Remove-Item2 -Path $missing -ErrorVariable removeErrors -ErrorAction SilentlyContinue + + $removeErrors | Should -HaveCount 1 + $removeErrors[0].FullyQualifiedErrorId | Should -BeLike 'FileNotFound,*' + $removeErrors[0].CategoryInfo.Category | Should -Be 'ObjectNotFound' + $removeErrors[0].TargetObject | Should -Be $missing + } + + It 'Should write nothing for a successful removal without -PassThru' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveQuiet' + + $result = @(Remove-Item2 -Path $file -ErrorAction Stop) + + $result | Should -BeNullOrEmpty + $file | Should -Not -Exist + } + + # The generic catch of RemoveItem2.cs (category NotSpecified) is reached by anything that is not an IOException; + # a Deny ACE for Delete makes the file system refuse with an UnauthorizedAccessException, a sibling of + # IOException, not a subclass of it, so it falls through to that generic branch instead of the InvalidData one + # that the locked-file and non-empty-folder cases already cover (Remove-Item2.Tests.ps1:36,97). NTFS allows a + # delete when either the file itself allows it or its parent grants DeleteSubdirectoriesAndFiles, so a dedicated + # folder denies the second route as well; otherwise the inherited folder permission of the sandbox still lets the + # delete through despite the Deny entry on the file alone (seen first-hand: no error was written). + It 'Should write DeleteError with category NotSpecified when a Deny ACE refuses the delete' { + $deniedFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveAccessDeniedFolder' -Directory + $file = Join-Path -Path $deniedFolder -ChildPath 'Denied.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + Set-Content -LiteralPath $file -Value 'Denied' + Add-TestDenyRule -Sandbox $sandbox -Path $deniedFolder -Rights @{ 'S-1-1-0' = 'DeleteSubdirectoriesAndFiles' } + Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-1-0' = 'Delete' } + + Remove-Item2 -Path $file -ErrorVariable removeErrors -ErrorAction SilentlyContinue + + $removeErrors | Should -HaveCount 1 + $removeErrors[0].FullyQualifiedErrorId | Should -BeLike 'DeleteError,*' + $removeErrors[0].CategoryInfo.Category | Should -Be 'NotSpecified' + $removeErrors[0].TargetObject | Should -Be $file + $removeErrors[0].Exception | Should -BeOfType [System.UnauthorizedAccessException] + $file | Should -Exist + } +} + +Describe 'Test-Path2 with several path shapes' { + It 'Should accept several piped objects and bind -PathType by property name for each' { + $pipedFile = New-TestSandboxItem -Sandbox $sandbox -Name 'PipedPathTypeFile' + $pipedFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'PipedPathTypeFolder' -Directory + $requests = @( + [pscustomobject]@{ FullName = $pipedFile; PathType = 'Leaf' } + [pscustomobject]@{ FullName = $pipedFile; PathType = 'Container' } + [pscustomobject]@{ FullName = $pipedFolder; PathType = 'Container' } + ) + + $result = @($requests | Test-Path2 -ErrorAction Stop) + + $result -join ',' | Should -Be 'True,False,True' + } + + It 'Should find an item whose name contains brackets' { + $bracketFile = Join-Path -Path $sandbox -ChildPath 'TestPathBracket[2].txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $bracketFile + Set-Content -LiteralPath $bracketFile -Value 'Bracket' + + Test-Path2 -Path $bracketFile -PathType Leaf -ErrorAction Stop | Should -BeTrue + } + + It 'Should find an item through a UNC path of the administrative share' -Skip:(-not $canUseAdminShare) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'UncPathFile' + $uncPath = ConvertTo-TestAdminSharePath -Sandbox $sandbox -Path $file + + Test-Path2 -Path $uncPath -PathType Leaf -ErrorAction Stop | Should -BeTrue + } +} + +Describe 'Get-FileHash2 binds -Algorithm by property name' { + It 'Should hash with the algorithm bound by property name from the pipeline' { + # Stored in a variable, like FileHash.Tests.ps1 and the MD5 call above: PSScriptAnalyzer flags a literal + # weak-algorithm argument, not one that comes from a variable. + $propertyAlgorithm = 'MD5' + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'HashAlgorithmByProperty' + Set-Content -LiteralPath $file -Value 'AlgorithmPropertyContent' + $request = [pscustomobject]@{ FullName = $file; Algorithm = $propertyAlgorithm } + + $result = $request | Get-FileHash2 -ErrorAction Stop + + $result.Algorithm | Should -Be $propertyAlgorithm + $result.Hash | Should -BeExactly (Get-FileHash -LiteralPath $file -Algorithm $propertyAlgorithm).Hash + } +} + +Describe 'Get-DiskSpace free-space values and validation error' { + It 'Should report free-space values close to an independent .NET read for the system drive' { + $before = New-Object -TypeName 'System.IO.DriveInfo' -ArgumentList $env:SystemDrive + + $result = Get-DiskSpace -DriveLetter $env:SystemDrive -ErrorAction Stop + + $after = New-Object -TypeName 'System.IO.DriveInfo' -ArgumentList $env:SystemDrive + # Free space can change between two live reads of a system drive under normal system activity, so the + # comparison admits a tolerance around the values read immediately before and after the call, instead of + # exact equality; the tolerance is still tight enough to catch a swapped or a wrong field. + $tolerance = 500MB + $result.TotalNumberOfFreeBytes | Should -BeGreaterOrEqual ([Math]::Min($before.TotalFreeSpace, $after.TotalFreeSpace) - $tolerance) + $result.TotalNumberOfFreeBytes | Should -BeLessOrEqual ([Math]::Max($before.TotalFreeSpace, $after.TotalFreeSpace) + $tolerance) + $result.FreeBytesAvailable | Should -BeGreaterOrEqual ([Math]::Min($before.AvailableFreeSpace, $after.AvailableFreeSpace) - $tolerance) + $result.FreeBytesAvailable | Should -BeLessOrEqual ([Math]::Max($before.AvailableFreeSpace, $after.AvailableFreeSpace) + $tolerance) + } + + # PowerShell's own parameter-validation engine raises this error before the cmdlet runs, so unlike the cmdlet's + # own WriteError calls it carries no target object; this pins that engine behavior as observed, not assumed. + It 'Should write the validation error for a bad -DriveLetter with its category, and no target object' { + $caught = $null + + try { + Get-DiskSpace -DriveLetter 'C' -ErrorAction Stop + } + catch { + $caught = $_ + } + + $caught.FullyQualifiedErrorId | Should -Be 'ParameterArgumentValidationError,NTFSSecurity.GetDiskSpace' + $caught.CategoryInfo.Category | Should -Be 'InvalidData' + $caught.TargetObject | Should -BeNullOrEmpty + } +} + +Describe 'New-NTFSHardLink positional binding, FileInfo input, and share errors' { + It 'Should bind -Path and -Target positionally' { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'PositionalHardLinkTarget' + Set-Content -LiteralPath $target -Value 'PositionalTarget' + $link = Join-Path -Path $sandbox -ChildPath 'PositionalHardLinkLink.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + + New-NTFSHardLink $link $target -ErrorAction Stop + + $link | Should -Exist + Get-Content -LiteralPath $link | Should -Be 'PositionalTarget' + } + + # Target has no FullName alias, so a FileInfo object can reach it only by value, through the + # FileSystemPathTransformationAttribute of BaseCmdlets.cs, which reads .FullName for a FileSystemInfo argument. + # -Path is bound explicitly by name here, which keeps it out of the pipeline-by-value binding so that the piped + # FileInfo binds to -Target only. + It 'Should accept a FileInfo object piped by value for -Target' { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'FileInfoBoundTarget' + Set-Content -LiteralPath $target -Value 'FileInfoTarget' + $targetItem = Get-Item2 -Path $target -ErrorAction Stop + $link = Join-Path -Path $sandbox -ChildPath 'FileInfoBoundLink.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + + $targetItem | New-NTFSHardLink -Path $link -ErrorAction Stop + + $link | Should -Exist + Get-Content -LiteralPath $link | Should -Be 'FileInfoTarget' + } + + # The existing share test (Links.Tests.ps1:150) asserts only the ID of this error. + It '-PassThru should write GetHardLinkError with category ReadError and the link path as target on a network share' -Skip:(-not $canUseAdminShare) { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'ShareCategoryTarget' + $link = Join-Path -Path $sandbox -ChildPath 'ShareCategoryLink.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + $sharePath = ConvertTo-TestAdminSharePath -Sandbox $sandbox -Path $link + $shareTarget = ConvertTo-TestAdminSharePath -Sandbox $sandbox -Path $target + + $result = @(New-NTFSHardLink -Path $sharePath -Target $shareTarget -PassThru -ErrorVariable linkErrors -ErrorAction SilentlyContinue) + + $link | Should -Exist + $linkErrors | Should -HaveCount 1 + $linkErrors[0].FullyQualifiedErrorId | Should -BeLike 'GetHardLinkError,*' + $linkErrors[0].CategoryInfo.Category | Should -Be 'ReadError' + $linkErrors[0].TargetObject | Should -Be $sharePath + $result | Should -BeNullOrEmpty + } +} + +Describe 'Get-NTFSHardLink category on a network share' { + # The existing share test (Links.Tests.ps1:247) asserts the ID and the target but not the category. + It 'Should write GetHardLinkError with category ReadError for a file on a network share' -Skip:(-not $canUseAdminShare) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'HardLinkShareCategoryFile' + $sharePath = ConvertTo-TestAdminSharePath -Sandbox $sandbox -Path $file + + Get-NTFSHardLink -Path $sharePath -ErrorVariable linkErrors -ErrorAction SilentlyContinue + + $linkErrors | Should -HaveCount 1 + $linkErrors[0].FullyQualifiedErrorId | Should -BeLike 'GetHardLinkError,*' + $linkErrors[0].CategoryInfo.Category | Should -Be 'ReadError' + $linkErrors[0].TargetObject | Should -Be $sharePath + } +} + +Describe 'New-NTFSSymbolicLink positional binding and folder links' { + It 'Should bind -Path and -Target positionally' -Skip:(-not $canCreateSymbolicLinks) { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'PositionalSymbolicTarget' + Set-Content -LiteralPath $target -Value 'PositionalSymbolicTarget' + $link = Join-Path -Path $sandbox -ChildPath 'PositionalSymbolicLink.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + + New-NTFSSymbolicLink $link $target -ErrorAction Stop + + (Get-Item -LiteralPath $link -Force).LinkType | Should -Be 'SymbolicLink' + Get-Content -LiteralPath $link | Should -Be 'PositionalSymbolicTarget' + } + + # The existing folder-link test (Links.Tests.ps1:304) checks only the Directory attribute and reachability; only + # the file-link test (Links.Tests.ps1:297) asserts LinkType and the resolved target. + It 'Should set LinkType and the resolved target for a link to a folder' -Skip:(-not $canCreateSymbolicLinks) { + $targetFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'FolderLinkTypeTarget' -Directory + $link = Join-Path -Path $sandbox -ChildPath 'FolderLinkType' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + + New-NTFSSymbolicLink -Path $link -Target $targetFolder -ErrorAction Stop + + (Get-Item -LiteralPath $link -Force).LinkType | Should -Be 'SymbolicLink' + # Windows PowerShell returns the target as an array, PowerShell 7 as a string (Links.Tests.ps1:286). + @((Get-Item -LiteralPath $link -Force).Target)[0] | Should -Be $targetFolder + } +} + +Describe 'Enable-Privileges -PassThru privilege states' { + AfterEach { + # Leaves the process token as the rest of the file expects it, regardless of which assertion above failed. + Disable-Privileges -ErrorAction SilentlyContinue -WarningAction SilentlyContinue | Out-Null + } + + # Only Get-Privileges was used as the read-back before; this reads the four states directly from the -PassThru + # objects and cross-checks them against whoami /priv, an independent source outside the module. + It 'Should report Enabled for TakeOwnership, Restore, Backup, and Security in -PassThru, matching whoami /priv' -Skip:(-not $isElevated) { + $result = @(Enable-Privileges -PassThru -ErrorAction Stop) + $whoamiPrivileges = & whoami.exe /priv /fo csv | Select-Object -Skip 1 | ConvertFrom-Csv -Header 'Name', 'Description', 'State' + + foreach ($name in 'TakeOwnership', 'Restore', 'Backup', 'Security') { + $entry = @($result | Where-Object -Property Privilege -EQ -Value $name) + $entry | Should -HaveCount 1 -Because "Enable-Privileges -PassThru should list $name" + $entry[0].PrivilegeState | Should -Be 'Enabled' + + $whoamiEntry = @($whoamiPrivileges | Where-Object -Property Name -EQ -Value ('Se{0}Privilege' -f $name)) + # The State column is localized; only the rows that whoami prints in English are compared (Get-Privileges + # compared with an independent source, above). + if ($whoamiEntry.Count -gt 0 -and $whoamiEntry[0].State -in 'Enabled', 'Disabled') { + $whoamiEntry[0].State | Should -Be 'Enabled' + } + } + } +} + +Describe 'Disable-Privileges -PassThru privilege states' { + It 'Should report Disabled for TakeOwnership, Restore, and Backup in -PassThru after a successful disable' -Skip:(-not $isElevated) { + Enable-Privileges -ErrorAction Stop + + $result = @(Disable-Privileges -PassThru -ErrorAction Stop) + + foreach ($name in 'TakeOwnership', 'Restore', 'Backup') { + $entry = @($result | Where-Object -Property Privilege -EQ -Value $name) + $entry | Should -HaveCount 1 -Because "Disable-Privileges -PassThru should list $name" + $entry[0].PrivilegeState | Should -Be 'Disabled' + } + } +} + +# NOT DONE: "the Removed PrivilegeState value" (WORKLIST row 14) is not reachable through Get-Privileges. A probe +# (elevated, PowerShell 7) that removed the Backup privilege of a child process and compared Get-Privileges before +# and after showed the entry disappear from the list entirely instead of remaining with PrivilegeState=Removed: +# BEFORE: ...,Backup=0,Restore=0,... AFTER: ...,Restore=0,... (Backup is gone, not listed as Removed) +# Privileges.GetPrivileges(accessTokenHandle) (ProcessPrivileges\Privileges.cs) builds its collection only from what +# GetTokenInformation(TokenPrivileges) currently returns, which drops an entry once SE_PRIVILEGE_REMOVED takes effect +# (matching the documented Win32 behavior of that attribute); the Removed state is produced only by the unrelated +# Privileges.GetPrivilegeAttributes(privilege, collection) lookup helper, which Get-Privileges never calls. No input +# makes Get-Privileges itself emit a Removed entry, so this item is left out instead of pinning a test that cannot +# exercise the branch it names; this is a structural gap of the same kind as the "Limit" rows, not a defect of a +# cmdlet, so nothing is reported under DEFECTS. +