diff --git a/.azdevops/ReleaseBuildPipeline.yml b/.azdevops/ReleaseBuildPipeline.yml index cf05994..c8bf600 100644 --- a/.azdevops/ReleaseBuildPipeline.yml +++ b/.azdevops/ReleaseBuildPipeline.yml @@ -38,7 +38,7 @@ stages: includePreviewVersions: true - pwsh: | - & $(Build.SourcesDirectory)/Microsoft.PowerShell.Archive/SimpleBuild.ps1 + & $(Build.SourcesDirectory)/Microsoft.PowerShell.Archive/Build.ps1 displayName: Build Microsoft.PowerShell.Archive module - pwsh: | diff --git a/.azdevops/RunTests.ps1 b/.azdevops/RunTests.ps1 index 0361d4f..f85198e 100644 --- a/.azdevops/RunTests.ps1 +++ b/.azdevops/RunTests.ps1 @@ -21,7 +21,7 @@ Import-Module -Name "Pester" -MinimumVersion $pesterMinVersion -MaximumVersion $ # Run tests $OutputFile = "$PWD/build-unit-tests.xml" $results = $null -$results = Invoke-Pester -Script ./Tests/Compress-Archive.Tests.ps1 -OutputFile $OutputFile -PassThru -OutputFormat NUnitXml -Show Failed, Context, Describe, Fails +$results = Invoke-Pester -Script ./Tests -OutputFile $OutputFile -PassThru -OutputFormat NUnitXml -Show Failed, Context, Describe, Fails Write-Host "##vso[artifact.upload containerfolder=testResults;artifactname=testResults]$OutputFile" if(!$results -or $results.FailedCount -gt 0 -or !$results.TotalCount) { diff --git a/.azdevops/SignAndPackageModule.ps1 b/.azdevops/SignAndPackageModule.ps1 index 06427ac..5d0db53 100644 --- a/.azdevops/SignAndPackageModule.ps1 +++ b/.azdevops/SignAndPackageModule.ps1 @@ -12,6 +12,7 @@ $BuildOutputDir = Join-Path $root "\src\bin\Release" $ManifestPath = "${BuildOutputDir}\${Name}.psd1" $ManifestData = Import-PowerShellDataFile -Path $ManifestPath $Version = $ManifestData.ModuleVersion +#$Prerelease = $ManifestPath.PrivateData.PSData.Prerelease # this takes the files for the module and publishes them to a created, local repository # so the nupkg can be used to publish to the PSGallery @@ -31,7 +32,9 @@ function Export-Module Publish-Module -Path $packageRoot -Repository $repoName Unregister-PSRepository -Name $repoName Get-ChildItem -Recurse -Name $packageRoot | Write-Verbose - $nupkgName = "{0}.{1}-preview1.nupkg" -f ${Name},${Version} + $nupkgName = "{0}.{1}-preview2.nupkg" -f ${Name},${Version} + + $nupkgPath = Join-Path $packageRoot $nupkgName if ($env:TF_BUILD) { # In Azure DevOps diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..58c5e7e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +## 2.0.1-preview2 + +- Rewrote `Expand-Archive` cmdlet in C# +- Added `-Format` parameter to `Expand-Archive` +- Added `-WriteMode` parameter to `Expand-Archive` +- Added support for zip64 to `Expand-Archive` +- Fixed a bug where the entry names of files in a directory would not be correct when compressing an archive +- `Compress-Archive` by default skips writing an entry to an archive if an error occurs while doing so + +## 2.0.1-preview1 + +- Rewrote `Compress-Archive` cmdlet in C# +- Added `-Format` parameter to `Compress-Archive` +- Added `-WriteMode` parameter to `Compress-Archive` +- Added support for relative path structure preservation when paths relative to the working directory are specified to `-Path` or `-LiteralPath` in `Compress-Archive` +- Added support for zip64 to `Compress-Archive` +- Fixed a bug where empty directories would not be compressed +- Fixed a bug where an abrupt stop when compressing empty directories would not delete the newly created archive diff --git a/Tests/Assertions/Should-BeArchiveOnlyContaining.psm1 b/Tests/Assertions/Should-BeArchiveOnlyContaining.psm1 new file mode 100644 index 0000000..3772ffc --- /dev/null +++ b/Tests/Assertions/Should-BeArchiveOnlyContaining.psm1 @@ -0,0 +1,42 @@ +function Should-BeArchiveOnlyContaining { + <# + .SYNOPSIS + Checks if a zip archive contains the entries $ExpectedValue + .EXAMPLE + "C:\Users\\archive.zip" | Should -BeZipArchiveContaining @("file1.txt") + + Checks if archive.zip only contains file1.txt + #> + + [CmdletBinding()] + Param ( + [string] $ActualValue, + [string[]] $ExpectedValue, + [switch] $Negate, + [string] $Because, + [switch] $LiteralPath, + $CallerSessionState, + [string] $Format + ) + + if ($Format -eq "Zip") { + return Should-BeZipArchiveOnlyContaining -ActualValue $ActualValue -ExpectedValue $ExpectedValue -Negate:$Negate -Because $Because -LiteralPath:$LiteralPath -CallerSessionState $CallerSessionState + } + if ($Format -eq "Tar") { + return Should-BeTarArchiveOnlyContaining -ActualValue $ActualValue -ExpectedValue $ExpectedValue -Negate:$Negate -Because $Because -LiteralPath:$LiteralPath -CallerSessionState $CallerSessionState + } + if ($Format -eq "Tgz") { + # Get a temp file + $gzipFolderPath = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName()) + New-Item -Path $gzipFolderPath -ItemType Directory + "7z e $ActualValue -o${gzipFolderPath} -tgzip" | Invoke-Expression + $tarFilePath = (Get-ChildItem $gzipFolderPath)[0].FullName + return Should-BeTarArchiveOnlyContaining -ActualValue $tarFilePath -ExpectedValue $ExpectedValue -Negate:$Negate -Because $Because -LiteralPath:$LiteralPath -CallerSessionState $CallerSessionState + } + return [pscustomobject]@{ + Succeeded = $false + FailureMessage = "Format ${Format} is not supported." + } + +} +Add-ShouldOperator -Name BeArchiveOnlyContaining -InternalName 'Should-BeArchiveOnlyContaining' -Test ${function:Should-BeArchiveOnlyContaining} \ No newline at end of file diff --git a/Tests/Assertions/Should-BeTarArchiveOnlyContaining.psm1 b/Tests/Assertions/Should-BeTarArchiveOnlyContaining.psm1 new file mode 100644 index 0000000..5c7fc91 --- /dev/null +++ b/Tests/Assertions/Should-BeTarArchiveOnlyContaining.psm1 @@ -0,0 +1,148 @@ +function Should-BeTarArchiveOnlyContaining { + <# + .SYNOPSIS + Checks if a tar archive contains the entries $ExpectedValue + .EXAMPLE + "C:\Users\\archive.zip" | Should -BeZipArchiveContaining @("file1.txt") + + Checks if archive.zip only contains file1.txt + #> + + [CmdletBinding()] + Param ( + [string] $ActualValue, + [string[]] $ExpectedValue, + [switch] $Negate, + [string] $Because, + [switch] $LiteralPath, + $CallerSessionState + ) + + # ActualValue is supposed to be a path to an archive + # It could be a path to a custom PSDrive, so it needes to be converted + if ($LiteralPath) { + $ActualValue = Convert-Path -LiteralPath $ActualValue + } else { + $ActualValue = Convert-Path -Path $ActualValue + } + + + # Ensure ActualValue is a valid path + if ($LiteralPath) { + $testPathResult = Test-Path -LiteralPath $ActualValue + } else { + $testPathResult = Test-Path -Path $ActualValue + } + + # Don't continue processing if ActualValue is not an actual path + # Determine if the assertion succeeded or failed and then return + if (-not $testPathResult) { + $succeeded = $Negate + if (-not $succeeded) { + $failureMessage = "The path ${ActualValue} does not exist" + } + return [pscustomobject]@{ + Succeeded = $succeeded + FailureMessage = $failureMessage + } + } + + # Get 7-zip to list the contents of the archive + if ($IsWindows) { + $output = 7z.exe l $ActualValue -ba -ttar + } else { + $output = 7z l $ActualValue -ba -ttar + } + + # Check if the output is null + if ($null -eq $output) { + if ($null -eq $ExpectedValue -or $ExpectedValue.Length -eq 0) { + $succeeded = -not $Negate + } else { + $succeeded = $Negate + } + + if (-not $succeeded) { + $failureMessage = "Archive {0} contains nothing, but it was expected to contain something" + } + + return [pscustomobject]@{ + Succeeded = $succeeded + FailureMessage = $failureMessage + } + } + + # Filter the output line by line + $lines = $output -split [System.Environment]::NewLine + + # Stores the entry names + $entryNames = @() + + # Go through each line and split it by whitespace + foreach ($line in $lines) { + $lineComponents = $line -split " +" + + # Example of some lines: + #2022-08-05 15:54:04 D.... 0 0 SourceDir + #2022-08-05 15:54:04 ..... 11 11 SourceDir/Sample-1.txt + + # First component is date + # 2nd component is time + # 3rd componnent is attributes + # 4th component is size + # 5th component is compressed size + # 6th component is entry name + + $entryName = $lineComponents[$lineComponents.Length - 1] + + # Since 7zip does not show trailing forwardslash for directories, we need to check the attributes to see if it starts with 'D' + # If so, it means the entry is a directory and we should append a forwardslash to the entry name + + if ($lineComponents[2].StartsWith('D')) { + $entryName += '/' + } + + # Replace backslashes to forwardslashes + $dirSeperatorChar = [System.IO.Path]::DirectorySeparatorChar + $entryName = $entryName.Replace($dirSeperatorChar, "/") + + $entryNames += $entryName + } + + $itemsNotInArchive = @() + + # Go through each item in ExpectedValue and ensure it is in entryNames + foreach ($expectedItem in $ExpectedValue) { + if ($entryNames -notcontains $expectedItem) { + $itemsNotInArchive += $expectedItem + } + } + + if ($itemsNotInArchive.Length -gt 0 -and -not $Negate) { + # Create a comma-seperated string from $itemsNotInEnryName + $commaSeperatedItemsNotInArchive = $itemsNotInArchive -join "," + $failureMessage = "'$ActualValue' does not contain $commaSeperatedItemsNotInArchive $(if($Because) { "because $Because"})." + $succeeded = $false + } + + # Ensure the length of $entryNames is equal to that of $ExpectedValue + if ($null -eq $succeeded -and $entryNames.Length -ne $ExpectedValue.Length -and -not $Negate) { + $failureMessage = "${ActualValue} does not contain the same number of items as ${ExpectedValue -join ""} (expected ${ExpectedValue.Length} entries but found ${entryNames.Length}) $(if($Because) { "because $Because"})." + $succeeded = $false + } + + if ($null -eq $succeeded) { + $succeeded = -not $Negate + if (-not $succeeded) { + $failureMessage = "Expected ${ActualValue} to not contain the entries ${ExpectedValue -join ""} only $(if($Because) { "because $Because"})." + } + } + + $ObjProperties = @{ + Succeeded = $succeeded + FailureMessage = $failureMessage + } + return New-Object PSObject -Property $ObjProperties +} + +Add-ShouldOperator -Name BeTarArchiveOnlyContaining -InternalName 'Should-BeTarArchiveOnlyContaining' -Test ${function:Should-BeTarArchiveOnlyContaining} \ No newline at end of file diff --git a/Tests/Assertions/Should-BeZipArchiveOnlyContaining.psm1 b/Tests/Assertions/Should-BeZipArchiveOnlyContaining.psm1 index 833fc6c..52e448e 100644 --- a/Tests/Assertions/Should-BeZipArchiveOnlyContaining.psm1 +++ b/Tests/Assertions/Should-BeZipArchiveOnlyContaining.psm1 @@ -49,9 +49,9 @@ function Should-BeZipArchiveOnlyContaining { # Get 7-zip to list the contents of the archive if ($IsWindows) { - $output = 7z.exe l $ActualValue -ba + $output = 7z.exe l $ActualValue -ba -tzip } else { - $output = 7z l $ActualValue -ba + $output = 7z l $ActualValue -ba -tzip } # Check if the output is null diff --git a/Tests/Compress-Archive.Tests.ps1 b/Tests/Compress-Archive.Tests.ps1 index cfa3ad9..95d5d06 100644 --- a/Tests/Compress-Archive.Tests.ps1 +++ b/Tests/Compress-Archive.Tests.ps1 @@ -2,559 +2,1130 @@ # Licensed under the MIT License. BeforeDiscovery { - # Loads and registers custom assertion. Ignores usage of unapproved verb with -DisableNameChecking - Import-Module "$PSScriptRoot/Assertions/Should-BeZipArchiveOnlyContaining.psm1" -DisableNameChecking + # Loads and registers custom assertion. Ignores usage of unapproved verb with -DisableNameChecking + Import-Module "$PSScriptRoot/Assertions/Should-BeZipArchiveOnlyContaining.psm1" -DisableNameChecking + Import-Module "$PSScriptRoot/Assertions/Should-BeTarArchiveOnlyContaining.psm1" -DisableNameChecking + Import-Module "$PSScriptRoot/Assertions/Should-BeArchiveOnlyContaining.psm1" -DisableNameChecking } - Describe("Microsoft.PowerShell.Archive tests") { - BeforeAll { - - $originalProgressPref = $ProgressPreference - $ProgressPreference = "SilentlyContinue" - $originalPSModulePath = $env:PSModulePath - } - - AfterAll { - $global:ProgressPreference = $originalProgressPref - $env:PSModulePath = $originalPSModulePath - } - - Context "Parameter set validation tests" { +Describe("Microsoft.PowerShell.Archive tests") { + BeforeAll { + + $originalProgressPref = $ProgressPreference + $ProgressPreference = "SilentlyContinue" + $originalPSModulePath = $env:PSModulePath + + function Add-FileExtensionBasedOnFormat { + Param ( + [string] $Path, + [string] $Format + ) + + if ($Format -eq "Zip") { + return $Path += ".zip" + } + if ($Format -eq "Tar") { + return $Path += ".tar" + } + if ($Format -eq "Tgz") { + return $Path += ".tar.gz" + } + throw "Format type is not supported" + } + } + + AfterAll { + $global:ProgressPreference = $originalProgressPref + $env:PSModulePath = $originalPSModulePath + } + + Context "Parameter set validation tests" { + BeforeAll { + # Set up files for tests + New-Item TestDrive:/SourceDir -Type Directory + "Some Data" | Out-File -FilePath TestDrive:/SourceDir/Sample-1.txt + New-Item TestDrive:/EmptyDirectory -Type Directory | Out-Null + } + + + It "Validate errors from Compress-Archive with null and empty values for Path, LiteralPath, and DestinationPath parameters" -ForEach @( + @{ Path = $null; DestinationPath = "TestDrive:/archive1.zip" } + @{ Path = "TestDrive:/SourceDir"; DestinationPath = $null } + @{ Path = $null; DestinationPath = $null } + @{ Path = ""; DestinationPath = "TestDrive:/archive1.zip" } + @{ Path = "TestDrive:/SourceDir"; DestinationPath = "" } + @{ Path = ""; DestinationPath = "" } + ) { + try + { + Compress-Archive -Path $Path -DestinationPath $DestinationPath + throw "ValidateNotNullOrEmpty attribute is missing on one of parameters belonging to LiteralPath parameterset." + } + catch + { + $_.FullyQualifiedErrorId | Should -Be "ParameterArgumentValidationError,Microsoft.PowerShell.Archive.CompressArchiveCommand" + } + + try + { + Compress-Archive -LiteralPath $Path -DestinationPath $DestinationPath + throw "ValidateNotNullOrEmpty attribute is missing on one of parameters belonging to LiteralPath parameterset." + } + catch + { + $_.FullyQualifiedErrorId | Should -Be "ParameterArgumentValidationError,Microsoft.PowerShell.Archive.CompressArchiveCommand" + } + } + + It "Validate errors from Compress-Archive when invalid path is supplied for Path or LiteralPath parameters" -ForEach @( + @{ Path = "Variable:/PWD" } + @{ Path = @("TestDrive:/", "Variable:/PWD") } + ) { + $DestinationPath = "TestDrive:/archive2.zip" + + Compress-Archive -Path $Path -DestinationPath $DestinationPath -ErrorAction SilentlyContinue -ErrorVariable error + $error.Count | Should -Be 1 + $error[0].FullyQualifiedErrorId | Should -Be "InvalidPath,Microsoft.PowerShell.Archive.CompressArchiveCommand" + Remove-Item -Path $DestinationPath + + Compress-Archive -LiteralPath $Path -DestinationPath $DestinationPath -ErrorAction SilentlyContinue -ErrorVariable error + $error.Count | Should -Be 1 + $error[0].FullyQualifiedErrorId | Should -Be "InvalidPath,Microsoft.PowerShell.Archive.CompressArchiveCommand" + Remove-Item -Path $DestinationPath + } + + It "Throws terminating error when non-existing path is supplied for Path or LiteralPath parameters" -ForEach @( + @{ Path = "TestDrive:/DoesNotExist" } + @{ Path = @("TestDrive:/", "TestDrive:/DoesNotExist") } + ) -Tag this2 { + $DestinationPath = "TestDrive:/archive3.zip" + + try + { + Compress-Archive -Path $Path -DestinationPath $DestinationPath + throw "Failed to validate that an invalid Path was supplied as input to Compress-Archive cmdlet." + } + catch + { + $_.FullyQualifiedErrorId | Should -Be "PathNotFound,Microsoft.PowerShell.Archive.CompressArchiveCommand" + } + + try + { + Compress-Archive -LiteralPath $Path -DestinationPath $DestinationPath + throw "Failed to validate that an invalid LiteralPath was supplied as input to Compress-Archive cmdlet." + } + catch + { + $_.FullyQualifiedErrorId | Should -Be "PathNotFound,Microsoft.PowerShell.Archive.CompressArchiveCommand" + } + } + + It "Validate error from Compress-Archive when duplicate paths are supplied as input to Path parameter" { + $sourcePath = @( + "TestDrive:/SourceDir/Sample-1.txt", + "TestDrive:/SourceDir/Sample-1.txt") + $destinationPath = "TestDrive:/DuplicatePaths.zip" + + try + { + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath + throw "Failed to detect that duplicate Path $sourcePath is supplied as input to Path parameter." + } + catch + { + $_.FullyQualifiedErrorId | Should -Be "DuplicatePaths,Microsoft.PowerShell.Archive.CompressArchiveCommand" + } + } + + It "Validate error from Compress-Archive when duplicate paths are supplied as input to LiteralPath parameter" { + $sourcePath = @( + "TestDrive:/SourceDir/Sample-1.txt", + "TestDrive:/SourceDir/Sample-1.txt") + $destinationPath = "TestDrive:/DuplicatePaths.zip" + + try + { + Compress-Archive -LiteralPath $sourcePath -DestinationPath $destinationPath + throw "Failed to detect that duplicate Path $sourcePath is supplied as input to LiteralPath parameter." + } + catch + { + $_.FullyQualifiedErrorId | Should -Be "DuplicatePaths,Microsoft.PowerShell.Archive.CompressArchiveCommand" + } + } + + ## From 504 + It "Validate that Source Path can be at SystemDrive location" -Skip { + $sourcePath = "$env:SystemDrive/SourceDir" + $destinationPath = "TestDrive:/SampleFromSystemDrive.zip" + New-Item $sourcePath -Type Directory | Out-Null # not enough permissions to write to drive root on Linux + "Some Data" | Out-File -FilePath $sourcePath/SampleSourceFileForArchive.txt + try + { + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath + Test-Path $destinationPath | Should -Be $true + } + finally + { + Remove-Item "$sourcePath" -Force -Recurse -ErrorAction SilentlyContinue + } + } + + It "Throws an error when Path and DestinationPath are the same and -Update is specified" { + $sourcePath = "TestDrive:/SourceDir/Sample-1.txt" + $destinationPath = $sourcePath + + try { + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -WriteMode Update + throw "Failed to detect an error when Path and DestinationPath are the same and -Update is specified" + } catch { + $_.FullyQualifiedErrorId | Should -Be "SamePathAndDestinationPath,Microsoft.PowerShell.Archive.CompressArchiveCommand" + } + } + + It "Throws an error when Path and DestinationPath are the same and -Overwrite is specified" { + $sourcePath = "TestDrive:/EmptyDirectory" + $destinationPath = $sourcePath + + try { + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -WriteMode Overwrite + throw "Failed to detect an error when Path and DestinationPath are the same and -Overwrite is specified" + } catch { + $_.FullyQualifiedErrorId | Should -Be "SamePathAndDestinationPath,Microsoft.PowerShell.Archive.CompressArchiveCommand" + } + } + + It "Throws an error when LiteralPath and DestinationPath are the same and -Update is specified" { + $sourcePath = "TestDrive:/SourceDir/Sample-1.txt" + $destinationPath = $sourcePath + + try { + Compress-Archive -LiteralPath $sourcePath -DestinationPath $destinationPath -WriteMode Update + throw "Failed to detect an error when LiteralPath and DestinationPath are the same and -Update is specified" + } catch { + $_.FullyQualifiedErrorId | Should -Be "SameLiteralPathAndDestinationPath,Microsoft.PowerShell.Archive.CompressArchiveCommand" + } + } + + It "Throws an error when LiteralPath and DestinationPath are the same and -Overwrite is specified" { + $sourcePath = "TestDrive:/EmptyDirectory" + $destinationPath = $sourcePath + + try { + Compress-Archive -LiteralPath $sourcePath -DestinationPath $destinationPath -WriteMode Overwrite + throw "Failed to detect an error when LiteralPath and DestinationPath are the same and -Overwrite is specified" + } catch { + $_.FullyQualifiedErrorId | Should -Be "SameLiteralPathAndDestinationPath,Microsoft.PowerShell.Archive.CompressArchiveCommand" + } + } + + It "Throws an error when an invalid path is supplied to DestinationPath" { + $sourcePath = "TestDrive:/SourceDir" + $destinationPath = "Variable:/PWD" + + try { + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath + throw "Failed to detect an error when an invalid path is supplied to DestinationPath" + } catch { + $_.FullyQualifiedErrorId | Should -Be "InvalidPath,Microsoft.PowerShell.Archive.CompressArchiveCommand" + } + } + } + + Context "WriteMode tests" { + BeforeAll { + New-Item TestDrive:/SourceDir -Type Directory | Out-Null + + $content = "Some Data" + $content | Out-File -FilePath TestDrive:/SourceDir/Sample-1.txt + } + + It "Throws a terminating error when an incorrect value is supplied to -WriteMode" { + $sourcePath = "TestDrive:/SourceDir" + $destinationPath = "TestDrive:/archive1.zip" + + try { + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -WriteMode mode + } catch { + $_.FullyQualifiedErrorId | Should -Be "CannotConvertArgumentNoMessage,Microsoft.PowerShell.Archive.CompressArchiveCommand" + } + } + + It "-WriteMode Create works" { + $sourcePath = "TestDrive:/SourceDir" + $destinationPath = "TestDrive:/archive1.zip" + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath + $destinationPath | Should -BeZipArchiveOnlyContaining @('SourceDir/', 'SourceDir/Sample-1.txt') + + + } + } + + Context "Basic functional tests" -ForEach @( + @{Format = "Zip"}, + @{Format = "Tar"}, + @{Format = "Tgz"} + ) { + BeforeAll { + New-Item TestDrive:/SourceDir -Type Directory | Out-Null + New-Item TestDrive:/SourceDir/ChildDir-1 -Type Directory | Out-Null + New-Item TestDrive:/SourceDir/ChildDir-2 -Type Directory | Out-Null + New-Item TestDrive:/SourceDir/ChildEmptyDir -Type Directory | Out-Null + + # create an empty directory + New-Item TestDrive:/EmptyDir -Type Directory | Out-Null + + $content = "Some Data" + $content | Out-File -FilePath TestDrive:/SourceDir/Sample-1.txt + $content | Out-File -FilePath TestDrive:/SourceDir/ChildDir-1/Sample-2.txt + $content | Out-File -FilePath TestDrive:/SourceDir/ChildDir-2/Sample-3.txt + + "Hello, World!" | Out-File -FilePath TestDrive:/HelloWorld.txt + + # Create a zero-byte file + New-Item TestDrive:/EmptyFile -Type File | Out-Null + } + + It "Compresses a single file with format " { + $sourcePath = "TestDrive:/SourceDir/ChildDir-1/Sample-2.txt" + $destinationPath = Add-FileExtensionBasedOnFormat -Path "TestDrive:/archive1" -Format $Format + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -Format $Format + $destinationPath | Should -BeArchiveOnlyContaining @('Sample-2.txt') -Format $Format + } + + It "Compresses a non-empty directory with format " { + $sourcePath = "TestDrive:/SourceDir/ChildDir-1" + $destinationPath = Add-FileExtensionBasedOnFormat -Path "TestDrive:/archive2" -Format $Format + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -Format $Format + $destinationPath | Should -BeArchiveOnlyContaining @('ChildDir-1/', 'ChildDir-1/Sample-2.txt') -Format $Format + } + + It "Compresses an empty directory with format " { + $sourcePath = "TestDrive:/EmptyDir" + $destinationPath = Add-FileExtensionBasedOnFormat -Path "TestDrive:/archive3" -Format $Format + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -Format $Format + $destinationPath | Should -BeArchiveOnlyContaining @('EmptyDir/') -Format $Format + } + + It "Compresses multiple files with format " { + $sourcePath = @("TestDrive:/SourceDir/ChildDir-1/Sample-2.txt", "TestDrive:/SourceDir/Sample-1.txt") + $destinationPath = Add-FileExtensionBasedOnFormat "TestDrive:/archive4" -Format $Format + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -Format $Format + $destinationPath | Should -BeArchiveOnlyContaining @('Sample-1.txt', 'Sample-2.txt') -Format $Format + } + + It "Compresses multiple files and a single empty directory with format " { + $sourcePath = @("TestDrive:/SourceDir/ChildDir-1/Sample-2.txt", "TestDrive:/SourceDir/Sample-1.txt", + "TestDrive:/SourceDir/ChildEmptyDir") + $destinationPath = Add-FileExtensionBasedOnFormat "TestDrive:/archive5" -Format $Format + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -Format $Format + $destinationPath | Should -BeArchiveOnlyContaining @('Sample-1.txt', 'Sample-2.txt', 'ChildEmptyDir/') -Format $Format + } + + It "Compresses multiple files and a single non-empty directory with format " { + $sourcePath = @("TestDrive:/SourceDir/ChildDir-1/Sample-2.txt", "TestDrive:/SourceDir/Sample-1.txt", + "TestDrive:/SourceDir/ChildDir-2") + $destinationPath = Add-FileExtensionBasedOnFormat "TestDrive:/archive6.zip" -Format $Format + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -Format $Format + $destinationPath | Should -BeArchiveOnlyContaining @('Sample-1.txt', 'Sample-2.txt', 'ChildDir-2/', 'ChildDir-2/Sample-3.txt') -Format $Format + } + + It "Compresses multiple files and non-empty directories with format " { + $sourcePath = @("TestDrive:/HelloWorld.txt", "TestDrive:/SourceDir/Sample-1.txt", + "TestDrive:/SourceDir/ChildDir-1", "TestDrive:/SourceDir/ChildDir-2") + $destinationPath = Add-FileExtensionBasedOnFormat "TestDrive:/archive7.zip" -Format $Format + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -Format $Format + $destinationPath | Should -BeArchiveOnlyContaining @('Sample-1.txt', 'HelloWorld.txt', 'ChildDir-1/', 'ChildDir-2/', + 'ChildDir-1/Sample-2.txt', 'ChildDir-2/Sample-3.txt') -Format $Format + } + + It "Compresses multiple files, non-empty directories, and an empty directory with format " { + $sourcePath = @("TestDrive:/HelloWorld.txt", "TestDrive:/SourceDir/Sample-1.txt", + "TestDrive:/SourceDir/ChildDir-1", "TestDrive:/SourceDir/ChildDir-2", "TestDrive:/SourceDir/ChildEmptyDir") + $destinationPath = Add-FileExtensionBasedOnFormat "TestDrive:/archive8.zip" -Format $Format + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -Format $Format + $destinationPath | Should -BeArchiveOnlyContaining @('Sample-1.txt', 'HelloWorld.txt', 'ChildDir-1/', 'ChildDir-2/', + 'ChildDir-1/Sample-2.txt', 'ChildDir-2/Sample-3.txt', "ChildEmptyDir/") -Format $Format + } + + It "Compresses a directory containing files, non-empty directories, and an empty directory can be compressed with format " { + $sourcePath = "TestDrive:/SourceDir" + $destinationPath = Add-FileExtensionBasedOnFormat "TestDrive:/archive9.zip" -Format $Format + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -Format $Format + $contents = @('SourceDir/', 'SourceDir/ChildDir-1/', 'SourceDir/ChildDir-2/', 'SourceDir/ChildEmptyDir/', 'SourceDir/Sample-1.txt', + 'SourceDir/ChildDir-1/Sample-2.txt', 'SourceDir/ChildDir-2/Sample-3.txt') + $destinationPath | Should -BeArchiveOnlyContaining $contents -Format $Format + } + + It "Compresses a zero-byte file with format " { + $sourcePath = "TestDrive:/EmptyFile" + $destinationPath = Add-FileExtensionBasedOnFormat "TestDrive:/archive10.zip" -Format $Format + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -Format $Format + $destinationPath | Should -BeArchiveOnlyContaining @('EmptyFile') -Format $Format + } + } + + Context "Zip-specific tests" -Tag Slow { BeforeAll { - # Set up files for tests - New-Item TestDrive:/SourceDir -Type Directory - "Some Data" | Out-File -FilePath TestDrive:/SourceDir/Sample-1.txt - New-Item TestDrive:/EmptyDirectory -Type Directory | Out-Null + # Create a file whose last write time is before 1980 + $content | Out-File -FilePath TestDrive:/OldFile.txt + Set-ItemProperty -Path TestDrive:/OldFile.txt -Name LastWriteTime -Value '1974-01-16 14:44' + + # Create a directory whose last write time is before 1980 + New-Item -Path "TestDrive:/olddirectory" -ItemType Directory + Set-ItemProperty -Path "TestDrive:/olddirectory" -Name "LastWriteTime" -Value '1974-01-16 14:44' + + + $numberOfBytes = 512 + $bytes = [byte[]]::new($numberOfBytes) + for ($i = 0; $i -lt $numberOfBytes; $i++) { + $bytes[$i] = 1 + } + + # Create a large file containing 1's + $largeFilePath = Join-Path $TestDrive "file1" + $fileWith1s = [System.IO.File]::Create($largeFilePath) + + $numberOfTimesToWrite = 5GB / $numberOfBytes + for ($i=0; $i -lt $numberOfTimesToWrite; $i++) { + $fileWith1s.Write($bytes, 0, $numberOfBytes) + } + $fileWith1s.Close() } + It "Compresses a file whose last write time is before 1980" { + $sourcePath = "TestDrive:/OldFile.txt" + $destinationPath = "${TestDrive}/archive11.zip" - It "Validate errors from Compress-Archive with null and empty values for Path, LiteralPath, and DestinationPath parameters" -ForEach @( - @{ Path = $null; DestinationPath = "TestDrive:/archive1.zip" } - @{ Path = "TestDrive:/SourceDir"; DestinationPath = $null } - @{ Path = $null; DestinationPath = $null } - @{ Path = ""; DestinationPath = "TestDrive:/archive1.zip" } - @{ Path = "TestDrive:/SourceDir"; DestinationPath = "" } - @{ Path = ""; DestinationPath = "" } - ) { - try - { - Compress-Archive -Path $Path -DestinationPath $DestinationPath - throw "ValidateNotNullOrEmpty attribute is missing on one of parameters belonging to LiteralPath parameterset." - } - catch - { - $_.FullyQualifiedErrorId | Should -Be "ParameterArgumentValidationError,Microsoft.PowerShell.Archive.CompressArchiveCommand" - } + # Assert the last write time of the file is before 1980 + $dateProperty = Get-ItemPropertyValue -Path $sourcePath -Name "LastWriteTime" + $dateProperty.Year | Should -BeLessThan 1980 - try - { - Compress-Archive -LiteralPath $Path -DestinationPath $DestinationPath - throw "ValidateNotNullOrEmpty attribute is missing on one of parameters belonging to LiteralPath parameterset." - } - catch - { - $_.FullyQualifiedErrorId | Should -Be "ParameterArgumentValidationError,Microsoft.PowerShell.Archive.CompressArchiveCommand" - } + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath + $destinationPath | Should -BeZipArchiveOnlyContaining @('OldFile.txt') + + # Get the archive + $fileMode = [System.IO.FileMode]::Open + $archiveStream = New-Object -TypeName System.IO.FileStream -ArgumentList $destinationPath,$fileMode + $zipArchiveMode = [System.IO.Compression.ZipArchiveMode]::Read + $archive = New-Object -TypeName System.IO.Compression.ZipArchive -ArgumentList $archiveStream,$zipArchiveMode + $entry = $archive.GetEntry("OldFile.txt") + $entry | Should -Not -BeNullOrEmpty + + $entry.LastWriteTime.Year | Should -BeExactly 1980 + $entry.LastWriteTime.Month| Should -BeExactly 1 + $entry.LastWriteTime.Day | Should -BeExactly 1 + $entry.LastWriteTime.Hour | Should -BeExactly 0 + $entry.LastWriteTime.Minute | Should -BeExactly 0 + $entry.LastWriteTime.Second | Should -BeExactly 0 + $entry.LastWriteTime.Millisecond | Should -BeExactly 0 + + + $archive.Dispose() + $archiveStream.Dispose() } - It "Validate errors from Compress-Archive when invalid path is supplied for Path or LiteralPath parameters" -ForEach @( - @{ Path = "Variable:/PWD" } - @{ Path = @("TestDrive:/", "Variable:/PWD") } - ) { - $DestinationPath = "TestDrive:/archive2.zip" + It "Compresses a directory whose last write time is before 1980 with format " { + $sourcePath = "TestDrive:/olddirectory" + $destinationPath = "${TestDrive}/archive12.zip" + + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath + $destinationPath | Should -BeZipArchiveOnlyContaining @('olddirectory/') + + # Get the archive + $fileMode = [System.IO.FileMode]::Open + $archiveStream = New-Object -TypeName System.IO.FileStream -ArgumentList $destinationPath,$fileMode + $zipArchiveMode = [System.IO.Compression.ZipArchiveMode]::Read + $archive = New-Object -TypeName System.IO.Compression.ZipArchive -ArgumentList $archiveStream,$zipArchiveMode + $entry = $archive.GetEntry("olddirectory/") + $entry | Should -Not -BeNullOrEmpty + + $entry.LastWriteTime.Year | Should -BeExactly 1980 + $entry.LastWriteTime.Month| Should -BeExactly 1 + $entry.LastWriteTime.Day | Should -BeExactly 1 + $entry.LastWriteTime.Hour | Should -BeExactly 0 + $entry.LastWriteTime.Minute | Should -BeExactly 0 + $entry.LastWriteTime.Second | Should -BeExactly 0 + $entry.LastWriteTime.Millisecond | Should -BeExactly 0 + + + $archive.Dispose() + $archiveStream.Dispose() + } + + It "Writes a warning when compressing a file whose last write time is before 1980 with format " { + $sourcePath = "TestDrive:/OldFile.txt" + $destinationPath = "${TestDrive}/archive13.zip" - Compress-Archive -Path $Path -DestinationPath $DestinationPath -ErrorAction SilentlyContinue -ErrorVariable error - $error.Count | Should -Be 1 - $error[0].FullyQualifiedErrorId | Should -Be "InvalidPath,Microsoft.PowerShell.Archive.CompressArchiveCommand" - Remove-Item -Path $DestinationPath + # Assert the last write time of the file is before 1980 + $dateProperty = Get-ItemPropertyValue -Path $sourcePath -Name "LastWriteTime" + $dateProperty.Year | Should -BeLessThan 1980 - Compress-Archive -LiteralPath $Path -DestinationPath $DestinationPath -ErrorAction SilentlyContinue -ErrorVariable error - $error.Count | Should -Be 1 - $error[0].FullyQualifiedErrorId | Should -Be "InvalidPath,Microsoft.PowerShell.Archive.CompressArchiveCommand" - Remove-Item -Path $DestinationPath + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -WarningVariable warnings + $warnings.Length | Should -Be 1 } - It "Throws terminating error when non-existing path is supplied for Path or LiteralPath parameters" -ForEach @( - @{ Path = "TestDrive:/DoesNotExist" } - @{ Path = @("TestDrive:/", "TestDrive:/DoesNotExist") } - ) -Tag this2 { - $DestinationPath = "TestDrive:/archive3.zip" + It "Writes a warning when compresing a directory whose last write time is before 1980 with format " { + $sourcePath = "TestDrive:/olddirectory" + $destinationPath = "${TestDrive}/archive14.zip" - try - { - Compress-Archive -Path $Path -DestinationPath $DestinationPath - throw "Failed to validate that an invalid Path was supplied as input to Compress-Archive cmdlet." - } - catch - { - $_.FullyQualifiedErrorId | Should -Be "PathNotFound,Microsoft.PowerShell.Archive.CompressArchiveCommand" - } + # Assert the last write time of the file is before 1980 + $dateProperty = Get-ItemPropertyValue -Path $sourcePath -Name "LastWriteTime" + $dateProperty.Year | Should -BeLessThan 1980 - try - { - Compress-Archive -LiteralPath $Path -DestinationPath $DestinationPath - throw "Failed to validate that an invalid LiteralPath was supplied as input to Compress-Archive cmdlet." - } - catch - { - $_.FullyQualifiedErrorId | Should -Be "PathNotFound,Microsoft.PowerShell.Archive.CompressArchiveCommand" - } + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -WarningVariable warnings + $warnings.Length | Should -Be 1 } - It "Validate error from Compress-Archive when duplicate paths are supplied as input to Path parameter" { - $sourcePath = @( - "TestDrive:/SourceDir/Sample-1.txt", - "TestDrive:/SourceDir/Sample-1.txt") - $destinationPath = "TestDrive:/DuplicatePaths.zip" + It "Creates an archive containing files > 4GB" { + (Get-Item "TestDrive:/file1").Length | Should -BeGreaterThan 4GB + Compress-Archive -Path "TestDrive:/file1" -DestinationPath "TestDrive:/archive1.zip" + "TestDrive:/archive1.zip" | Should -BeArchiveOnlyContaining @("file1") -Format Zip + } - try - { - Compress-Archive -Path $sourcePath -DestinationPath $destinationPath - throw "Failed to detect that duplicate Path $sourcePath is supplied as input to Path parameter." - } - catch - { - $_.FullyQualifiedErrorId | Should -Be "DuplicatePaths,Microsoft.PowerShell.Archive.CompressArchiveCommand" - } + It "Creates an an archive > 4GB in size" { + $destinationPath = "TestDrive:/archive2.zip" + Compress-Archive -Path "TestDrive:/file1" -DestinationPath $destinationPath -CompressionLevel NoCompression + $destinationPath | Should -BeArchiveOnlyContaining @("file1") -Format Zip + (Get-Item $destinationPath).Length | Should -BeGreaterThan 4GB } + } + + Context "DestinationPath and -WriteMode Overwrite tests" { + BeforeAll { + New-Item TestDrive:/SourceDir -Type Directory | Out-Null + + $content = "Some Data" + $content | Out-File -FilePath TestDrive:/SourceDir/Sample-1.txt + + New-Item TestDrive:/archive3.zip -Type Directory | Out-Null + + New-Item TestDrive:/EmptyDirectory -Type Directory | Out-Null + + # Create a read-only archive + $readOnlyArchivePath = "TestDrive:/readonly.zip" + Compress-Archive -Path TestDrive:/SourceDir/Sample-1.txt -DestinationPath $readOnlyArchivePath + Set-ItemProperty -Path $readOnlyArchivePath -Name IsReadOnly -Value $true + + # Create TestDrive:/archive.zip + Compress-Archive -Path TestDrive:/SourceDir/Sample-1.txt -DestinationPath "TestDrive:/archive.zip" + + # Create Sample-2.txt + $content | Out-File -FilePath TestDrive:/Sample-2.txt + } + + It "Throws an error when archive file already exists and -Update and -Overwrite parameters are not specified" { + $sourcePath = "TestDrive:/SourceDir" + $destinationPath = "TestDrive:/archive1.zip" + + try + { + "Some Data" > $destinationPath + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath + throw "Failed to validate that an archive file format $destinationPath already exists and -Update switch parameter is not specified." + } + catch + { + $_.FullyQualifiedErrorId | Should -Be "DestinationExists,Microsoft.PowerShell.Archive.CompressArchiveCommand" + } + } + + It "Throws a terminating error when archive file exists and -Update is specified but the archive is read-only" { + $sourcePath = "TestDrive:/SourceDir" + $destinationPath = "TestDrive:/readonly.zip" + + try + { + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -WriteMode Update + throw "Failed to detect an that an error was thrown when archive $destinationPath already exists but it is read-only and -WriteMode Update is specified." + } + catch + { + $_.FullyQualifiedErrorId | Should -Be "ArchiveReadOnly,Microsoft.PowerShell.Archive.CompressArchiveCommand" + } + } + + It "Throws a terminating error when archive already exists as a directory and -Update and -Overwrite parameters are not specified" { + $sourcePath = "TestDrive:/SourceDir/Sample-1.txt" + $destinationPath = "TestDrive:/SourceDir" + + try + { + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath + throw "Failed to detect an error was thrown when archive $destinationPath exists as a directory and -WriteMode Update or -WriteMode Overwrite is not specified." + } + catch + { + $_.FullyQualifiedErrorId | Should -Be "DestinationExistsAsDirectory,Microsoft.PowerShell.Archive.CompressArchiveCommand" + } + } + + It "Throws a terminating error when DestinationPath is a directory and -Update is specified" { + $sourcePath = "TestDrive:/SourceDir" + $destinationPath = "TestDrive:/archive3.zip" + + try + { + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -WriteMode Update + throw "Failed to validate that a directory $destinationPath exists and -Update switch parameter is specified." + } + catch + { + $_.FullyQualifiedErrorId | Should -Be "DestinationExistsAsDirectory,Microsoft.PowerShell.Archive.CompressArchiveCommand" + } + } + + It "Throws a terminating error when DestinationPath is a folder containing at least 1 item and Overwrite is specified" { + $sourcePath = "TestDrive:/SourceDir" + $destinationPath = "TestDrive:" + + try + { + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -WriteMode Overwrite + throw "Failed to detect an error when $destinationPath is an existing directory containing at least 1 item and -Overwrite switch parameter is specified." + } + catch + { + $_.FullyQualifiedErrorId | Should -Be "DestinationIsNonEmptyDirectory,Microsoft.PowerShell.Archive.CompressArchiveCommand" + } + } + + It "Throws a terminating error when archive does not exist and -Update mode is specified" { + $sourcePath = "TestDrive:/SourceDir" + $destinationPath = "TestDrive:/archive2.zip" + + try + { + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -WriteMode Update + throw "Failed to validate that an archive file format $destinationPath does not exist and -Update switch parameter is specified." + } + catch + { + $_.FullyQualifiedErrorId | Should -Be "ArchiveDoesNotExist,Microsoft.PowerShell.Archive.CompressArchiveCommand" + } + } + + ## Overwrite tests + It "Throws an error when trying to overwrite an empty directory, which is the working directory" { + $sourcePath = "TestDrive:/Sample-2.txt" + $destinationPath = "TestDrive:/EmptyDirectory" + + Push-Location $destinationPath + + try { + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -WriteMode Overwrite + } catch { + $_.FullyQualifiedErrorId | Should -Be "CannotOverwriteWorkingDirectory,Microsoft.PowerShell.Archive.CompressArchiveCommand" + } + + Pop-Location + } + + It "Overwrites a directory containing no items when -Overwrite is specified" { + $sourcePath = "TestDrive:/SourceDir" + $destinationPath = "TestDrive:/EmptyDirectory" + + # Ensure $destinationPath is a directory + Test-Path $destinationPath -PathType Container | Should -Be $true + + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -WriteMode Overwrite + + # Ensure $destinationPath is now a file + Test-Path $destinationPath -PathType Leaf | Should -Be $true + } + + It "Overwrites an archive that already exists" { + $destinationPath = "TestDrive:/archive.zip" + + # Ensure the original archive contains Sample-1.txt + $destinationPath | Should -BeZipArchiveOnlyContaining @("Sample-1.txt") + + # Overwrite the archive + $sourcePath = "TestDrive:/Sample-2.txt" + Compress-Archive -Path $sourcePath -DestinationPath "TestDrive:/archive.zip" -WriteMode Overwrite + + # Ensure the original entries and different than the new entries + $destinationPath | Should -BeZipArchiveOnlyContaining @("Sample-2.txt") + } + } + + Context "Special and Wildcard Characters Tests" { + BeforeAll { + New-Item TestDrive:/SourceDir -Type Directory + + New-Item -Path "TestDrive:/Source`[`]Dir" -Type Directory + + $content = "Some Data" + $content | Out-File -FilePath TestDrive:/SourceDir/Sample-1.txt + $content | Out-File -LiteralPath TestDrive:/file1[].txt - It "Validate error from Compress-Archive when duplicate paths are supplied as input to LiteralPath parameter" { - $sourcePath = @( - "TestDrive:/SourceDir/Sample-1.txt", - "TestDrive:/SourceDir/Sample-1.txt") - $destinationPath = "TestDrive:/DuplicatePaths.zip" + $content = "Some Data" + $content | Out-File -FilePath TestDrive:/SourceDir/Sample-1.txt + } + It "Accepts DestinationPath parameter with wildcard characters that resolves to one path" { + $sourcePath = "TestDrive:/SourceDir/Sample-1.txt" + $destinationPath = "TestDrive:/Sample[]SingleFile.zip" + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath + Test-Path -LiteralPath $destinationPath | Should -Be $true + Remove-Item -LiteralPath $destinationPath + } + + It "Accepts DestinationPath parameter with [ but no matching ]" { + $sourcePath = "TestDrive:/SourceDir" + $destinationPath = "TestDrive:/archive[2.zip" + + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath + $destinationPath | Should -BeZipArchiveOnlyContaining @("SourceDir/", "SourceDir/Sample-1.txt") -LiteralPath + Remove-Item -LiteralPath $destinationPath -Force + } + + It "Accepts LiteralPath parameter for a directory with special characters in the directory name" -skip:(($PSVersionTable.psversion.Major -lt 5) -and ($PSVersionTable.psversion.Minor -lt 0)) { + $sourcePath = "TestDrive:/Source[]Dir" + "Some Random Content" | Out-File -LiteralPath "$sourcePath/Sample[]File.txt" + $destinationPath = "TestDrive:/archive3.zip" try { Compress-Archive -LiteralPath $sourcePath -DestinationPath $destinationPath - throw "Failed to detect that duplicate Path $sourcePath is supplied as input to LiteralPath parameter." + $destinationPath | Should -Exist } - catch + finally { - $_.FullyQualifiedErrorId | Should -Be "DuplicatePaths,Microsoft.PowerShell.Archive.CompressArchiveCommand" + Remove-Item -LiteralPath $sourcePath -Force -Recurse } } - ## From 504 - It "Validate that Source Path can be at SystemDrive location" -Skip { - $sourcePath = "$env:SystemDrive/SourceDir" - $destinationPath = "TestDrive:/SampleFromSystemDrive.zip" - New-Item $sourcePath -Type Directory | Out-Null # not enough permissions to write to drive root on Linux - "Some Data" | Out-File -FilePath $sourcePath/SampleSourceFileForArchive.txt + It "Accepts LiteralPath parameter for a file with wildcards in the filename" { + $sourcePath = "TestDrive:/file1[].txt" + $destinationPath = "TestDrive:/archive4.zip" try { - Compress-Archive -Path $sourcePath -DestinationPath $destinationPath - Test-Path $destinationPath | Should -Be $true + Compress-Archive -LiteralPath $sourcePath -DestinationPath $destinationPath + $destinationPath | Should -BeZipArchiveOnlyContaining @("file1[].txt") } finally { - Remove-Item "$sourcePath" -Force -Recurse -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $sourcePath -Force -Recurse } } + } - # This cannot happen in -WriteMode Create because another error will be throw before - It "Throws an error when Path and DestinationPath are the same" -Skip { - $sourcePath = "TestDrive:/SourceDir/Sample-1.txt" - $destinationPath = $sourcePath - - try { - # Note the cmdlet performs validation on $destinationPath - Compress-Archive -Path $sourcePath -DestinationPath $destinationPath - throw "Failed to detect an error when Path and DestinationPath are the same" - } catch { - $_.FullyQualifiedErrorId | Should -Be "SamePathAndDestinationPath,Microsoft.PowerShell.Archive.CompressArchiveCommand" - } + Context "PassThru tests" { + BeforeAll { + New-Item -Path TestDrive:/file.txt -ItemType File } - It "Throws an error when Path and DestinationPath are the same and -Update is specified" { - $sourcePath = "TestDrive:/SourceDir/Sample-1.txt" - $destinationPath = $sourcePath + It "Returns an object of type System.IO.FileInfo when PassThru is specified" { + $output = Compress-Archive -Path TestDrive:/file.txt -DestinationPath TestDrive:/archive1.zip -PassThru + $output | Should -BeOfType System.IO.FileInfo + $destinationPath = Join-Path $TestDrive "archive1.zip" + $output.FullName | Should -Be $destinationPath + } - try { - Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -WriteMode Update - throw "Failed to detect an error when Path and DestinationPath are the same and -Update is specified" - } catch { - $_.FullyQualifiedErrorId | Should -Be "SamePathAndDestinationPath,Microsoft.PowerShell.Archive.CompressArchiveCommand" - } + It "Does not return an object when PassThru is not specified" { + $output = Compress-Archive -Path TestDrive:/file.txt -DestinationPath TestDrive:/archive2.zip + $output | Should -BeNullOrEmpty } - It "Throws an error when Path and DestinationPath are the same and -Overwrite is specified" { - $sourcePath = "TestDrive:/EmptyDirectory" - $destinationPath = $sourcePath + It "Does not return an object when PassThru is false" { + $output = Compress-Archive -Path TestDrive:/file.txt -DestinationPath TestDrive:/archive3.zip -PassThru:$false + $output | Should -BeNullOrEmpty + } + } - try { - Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -WriteMode Overwrite - throw "Failed to detect an error when Path and DestinationPath are the same and -Overwrite is specified" - } catch { - $_.FullyQualifiedErrorId | Should -Be "SamePathAndDestinationPath,Microsoft.PowerShell.Archive.CompressArchiveCommand" + Context "File permissions, attributes, etc. tests" { + BeforeAll { + New-Item TestDrive:/file.txt -ItemType File + "Hello, World!" | Out-File -Path TestDrive:/file.txt + + # Create a read-only file + New-Item TestDrive:/readonly.txt -ItemType File + "Hello, World!" | Out-File -Path TestDrive:/readonly.txt + + # Create a hidden file + New-Item TestDrive:/.hiddenfile -ItemType File + "Hello, World!" | Out-File -Path TestDrive:/.hiddenfile + + # Create a hidden directory + New-Item TestDrive:/.hiddendirectory -ItemType Directory + New-Item TestDrive:/.hiddendirectory/file.txt -ItemType File -Force + "Hello, World!" | Out-File -Path TestDrive:/.hiddendirectory/file.txt + + # Create a directory containing a hidden file and directory + New-Item "TestDrive:/directory_with_hidden_items" -ItemType Directory + New-Item "TestDrive:/directory_with_hidden_items/.hiddenfile" -ItemType File + New-Item "TestDrive:/directory_with_hidden_items/.hiddendirectory" -ItemType Directory + if ($IsWindows) { + (Get-Item "TestDrive:/directory_with_hidden_items/.hiddenfile").Attributes += 'Hidden' + (Get-Item "TestDrive:/directory_with_hidden_items/.hiddendirectory").Attributes += 'Hidden' } } - It "Throws an error when LiteralPath and DestinationPath are the same" -Skip { - $sourcePath = "TestDrive:/SourceDir/Sample-1.txt" - $destinationPath = $sourcePath + It "Skips archiving a file in use" { + $fileMode = [System.IO.FileMode]::Open + $fileAccess = [System.IO.FileAccess]::Write + $fileShare = [System.IO.FileShare]::None + $archiveInUseStream = New-Object -TypeName "System.IO.FileStream" -ArgumentList "${TestDrive}/file.txt",$fileMode,$fileAccess,$fileShare - try { - Compress-Archive -LiteralPath $sourcePath -DestinationPath $destinationPath - throw "Failed to detect an error when LiteralPath and DestinationPath are the same" - } catch { - $_.FullyQualifiedErrorId | Should -Be "SameLiteralPathAndDestinationPath,Microsoft.PowerShell.Archive.CompressArchiveCommand" - } + Compress-Archive -Path TestDrive:/file.txt -DestinationPath TestDrive:/archive_in_use.zip -ErrorAction SilentlyContinue + # Ensure it creates an empty zip archive + "TestDrive:/archive_in_use.zip" | Should -BeZipArchiveOnlyContaining @() + + $archiveInUseStream.Dispose() } - It "Throws an error when LiteralPath and DestinationPath are the same and -Update is specified" { - $sourcePath = "TestDrive:/SourceDir/Sample-1.txt" - $destinationPath = $sourcePath + It "Compresses a read-only file" { + $destinationPath = "TestDrive:/archive_with_readonly_file.zip" + Compress-Archive -Path TestDrive:/readonly.txt -DestinationPath $destinationPath + $destinationPath | Should -BeArchiveOnlyContaining @("readonly.txt") -Format Zip + } - try { - Compress-Archive -LiteralPath $sourcePath -DestinationPath $destinationPath -WriteMode Update - throw "Failed to detect an error when LiteralPath and DestinationPath are the same and -Update is specified" - } catch { - $_.FullyQualifiedErrorId | Should -Be "SameLiteralPathAndDestinationPath,Microsoft.PowerShell.Archive.CompressArchiveCommand" + It "Compresses a hidden file" { + $path = "TestDrive:/.hiddenfile" + if ($IsWindows) { + (Get-Item $path).Attributes += 'Hidden' } + $destinationPath = "TestDrive:/archive3.zip" + Compress-Archive -Path $path -DestinationPath $destinationPath + $destinationPath | Should -BeArchiveOnlyContaining @(".hiddenfile") -Format Zip } - It "Throws an error when LiteralPath and DestinationPath are the same and -Overwrite is specified" { - $sourcePath = "TestDrive:/EmptyDirectory" - $destinationPath = $sourcePath - - try { - Compress-Archive -LiteralPath $sourcePath -DestinationPath $destinationPath -WriteMode Overwrite - throw "Failed to detect an error when LiteralPath and DestinationPath are the same and -Overwrite is specified" - } catch { - $_.FullyQualifiedErrorId | Should -Be "SameLiteralPathAndDestinationPath,Microsoft.PowerShell.Archive.CompressArchiveCommand" + It "Compresses a hidden directory" { + $path = "TestDrive:/.hiddendirectory" + if ($IsWindows) { + (Get-Item $path).Attributes += 'Hidden' } + $destinationPath = "TestDrive:/archive4.zip" + Compress-Archive -Path $path -DestinationPath $destinationPath + $destinationPath | Should -BeArchiveOnlyContaining @(".hiddendirectory/", ".hiddendirectory/file.txt") -Format Zip + } + + It "Compresses a directory containing a hidden file and directory" { + $path = "TestDrive:/directory_with_hidden_items" + $destinationPath = "TestDrive:/archive5.zip" + Compress-Archive -Path $path -DestinationPath $destinationPath + $destinationPath | Should -BeArchiveOnlyContaining @("directory_with_hidden_items/", "directory_with_hidden_items/.hiddendirectory/", "directory_with_hidden_items/.hiddenfile") -Format Zip } } - Context "WriteMode tests" { + Context "CompressionLevel tests" { BeforeAll { - New-Item TestDrive:/SourceDir -Type Directory | Out-Null - - $content = "Some Data" - $content | Out-File -FilePath TestDrive:/SourceDir/Sample-1.txt + New-Item -Path TestDrive:/file1.txt -ItemType File + "Hello, World!" | Out-File -FilePath TestDrive:/file1.txt } - It "Throws a terminating error when an incorrect value is supplied to -WriteMode" { - $sourcePath = "TestDrive:/SourceDir" - $destinationPath = "TestDrive:/archive1.zip" - + It "Throws an error when an invalid value is supplied to CompressionLevel" { try { - Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -WriteMode mode + Compress-Archive -Path TestDrive:/file1.txt -DestinationPath TestDrive:/archive1.zip -CompressionLevel fakelevel } catch { $_.FullyQualifiedErrorId | Should -Be "CannotConvertArgumentNoMessage,Microsoft.PowerShell.Archive.CompressArchiveCommand" } } - - It "-WriteMode Create works" -Tag td1 { - $sourcePath = "TestDrive:/SourceDir" - $destinationPath = "TestDrive:/archive1.zip" - Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -Verbose - if ($IsWindows) { - $t = Convert-Path $destinationPath - 7z l "${t}" | Write-Verbose -Verbose - } - $destinationPath | Should -BeZipArchiveOnlyContaining @('SourceDir/', 'SourceDir/Sample-1.txt') - - - } } - Context "Basic functional tests" { + Context "Path Structure Preservation Tests" { BeforeAll { + New-Item -Path TestDrive:/file1.txt -ItemType File + "Hello, World!" | Out-File -FilePath TestDrive:/file1.txt + + New-Item -Path TestDrive:/directory1 -ItemType Directory + New-Item -Path TestDrive:/directory1/subdir1 -ItemType Directory + New-Item -Path TestDrive:/directory1/subdir1/file.txt -ItemType File + "Hello, World!" | Out-File -FilePath TestDrive:/file.txt + New-Item TestDrive:/SourceDir -Type Directory | Out-Null New-Item TestDrive:/SourceDir/ChildDir-1 -Type Directory | Out-Null - New-Item TestDrive:/SourceDir/ChildDir-2 -Type Directory | Out-Null - New-Item TestDrive:/SourceDir/ChildEmptyDir -Type Directory | Out-Null - - # create an empty directory - New-Item TestDrive:/EmptyDir -Type Directory | Out-Null $content = "Some Data" $content | Out-File -FilePath TestDrive:/SourceDir/Sample-1.txt $content | Out-File -FilePath TestDrive:/SourceDir/ChildDir-1/Sample-2.txt - $content | Out-File -FilePath TestDrive:/SourceDir/ChildDir-2/Sample-3.txt } - It "Validate that a single file can be compressed" { - $sourcePath = "TestDrive:/SourceDir/ChildDir-1/Sample-2.txt" + It "Creates an archive containing only a file when the path to that file is not relative to the working directory" { $destinationPath = "TestDrive:/archive1.zip" - Compress-Archive -Path $sourcePath -DestinationPath $destinationPath - $destinationPath | Should -BeZipArchiveOnlyContaining @('Sample-2.txt') - } - It "Validate that an empty folder can be compressed" { - $sourcePath = "TestDrive:/EmptyDir" - $destinationPath = "TestDrive:/archive2.zip" - Compress-Archive -Path $sourcePath -DestinationPath $destinationPath - $destinationPath | Should -BeZipArchiveOnlyContaining @('EmptyDir/') - } + Push-Location TestDrive:/directory1 + + Compress-Archive -Path TestDrive:/file1.txt -DestinationPath $destinationPath + $destinationPath | Should -BeArchiveOnlyContaining @("file1.txt") -Format Zip - It "Validate a folder containing files, non-empty folders, and empty folders can be compressed" { - $sourcePath = "TestDrive:/SourceDir" - $destinationPath = "TestDrive:/archive3.zip" - Compress-Archive -Path $sourcePath -DestinationPath $destinationPath - $destinationPath | Should -BeZipArchiveOnlyContaining @('SourceDir/', 'SourceDir/ChildDir-1/', 'SourceDir/ChildDir-2/', 'SourceDir/ChildEmptyDir/', 'SourceDir/Sample-1.txt', 'SourceDir/ChildDir-1/Sample-2.txt', 'SourceDir/ChildDir-2/Sample-3.txt') + Pop-Location } - } - Context "Update tests" -Skip { - - } + It "Creates an archive containing a file and its parent directories when the path to the file and its parent directories are descendents of the working directory" { + $destinationPath = "TestDrive:/archive2.zip" - Context "DestinationPath and -WriteMode Overwrite tests" { - BeforeAll { - New-Item TestDrive:/SourceDir -Type Directory | Out-Null - - $content = "Some Data" - $content | Out-File -FilePath TestDrive:/SourceDir/Sample-1.txt + Push-Location TestDrive:/ - New-Item TestDrive:/archive3.zip -Type Directory | Out-Null + Compress-Archive -Path directory1/subdir1/file.txt -DestinationPath $destinationPath + $destinationPath | Should -BeArchiveOnlyContaining @("directory1/subdir1/file.txt") -Format Zip - New-Item TestDrive:/EmptyDirectory -Type Directory | Out-Null + Pop-Location + } - # Create a read-only archive - $readOnlyArchivePath = "TestDrive:/readonly.zip" - Compress-Archive -Path TestDrive:/SourceDir/Sample-1.txt -DestinationPath $readOnlyArchivePath - Set-ItemProperty -Path $readOnlyArchivePath -Name IsReadOnly -Value $true + It "Compressing a relative path containing .. preserves the relative path structure" { + $destinationPath = "TestDrive:/archive3.zip" - # Create TestDrive:/archive.zip - Compress-Archive -Path TestDrive:/SourceDir/Sample-1.txt -DestinationPath "TestDrive:/archive.zip" + Push-Location TestDrive:/ + + Compress-Archive -Path directory1/../directory1/subdir1/file.txt -DestinationPath $destinationPath + $destinationPath | Should -BeArchiveOnlyContaining @("directory1/subdir1/file.txt") -Format Zip - # Create Sample-2.txt - $content | Out-File -FilePath TestDrive:/Sample-2.txt + Pop-Location } - It "Throws an error when archive file already exists and -Update and -Overwrite parameters are not specified" { - $sourcePath = "TestDrive:/SourceDir" - $destinationPath = "TestDrive:/archive1.zip" + It "Compressing a relative path containing ~ works when the home directory is relative to the working directory" { + $destinationPath = "TestDrive:/archive4.zip" + $path = "~/file.txt" + New-Item -Path $path -ItemType File - try - { - "Some Data" > $destinationPath - Compress-Archive -Path $sourcePath -DestinationPath $destinationPath - throw "Failed to validate that an archive file format $destinationPath already exists and -Update switch parameter is not specified." - } - catch - { - $_.FullyQualifiedErrorId | Should -Be "ArchiveExists,Microsoft.PowerShell.Archive.CompressArchiveCommand" - } - } + $homeDirectory = New-Object -TypeName System.IO.DirectoryInfo -ArgumentList $HOME + $homeDirectoryName = $homeDirectory.Name - It "Throws a terminating error when archive file exists and -Update is specified but the archive is read-only" { - $sourcePath = "TestDrive:/SourceDir" - $destinationPath = "TestDrive:/readonly.zip" + Push-Location "~/.." + + Compress-Archive -Path $path -DestinationPath $destinationPath + $destinationPath | Should -BeArchiveOnlyContaining @("${homeDirectoryName}/file.txt") -Format Zip + + Remove-Item $path + Pop-Location - try - { - Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -WriteMode Update - throw "Failed to detect an that an error was thrown when archive $destinationPath already exists but it is read-only and -WriteMode Update is specified." - } - catch - { - $_.FullyQualifiedErrorId | Should -Be "ArchiveReadOnly,Microsoft.PowerShell.Archive.CompressArchiveCommand" - } } - It "Throws a terminating error when archive already exists as a directory and -Update and -Overwrite parameters are not specified" { - $sourcePath = "TestDrive:/SourceDir/Sample-1.txt" - $destinationPath = "TestDrive:/SourceDir" + it "Compressing a relative path containing wildcard characters preserves the relative directory structure" { + $destinationPath = "TestDrive:/archive5.zip" + + Push-Location TestDrive:/ + + Compress-Archive -Path directory1/subdir1/* -DestinationPath $destinationPath + $destinationPath | Should -BeArchiveOnlyContaining @("directory1/subdir1/file.txt") -Format Zip + Pop-Location + } + + It "Validate that relative path can be specified as Path parameter of Compress-Archive cmdlet" { + $sourcePath = "./SourceDir" + $destinationPath = "RelativePathForPathParameter.zip" try { + Push-Location TestDrive:/ Compress-Archive -Path $sourcePath -DestinationPath $destinationPath - throw "Failed to detect an error was thrown when archive $destinationPath exists as a directory and -WriteMode Update or -WriteMode Overwrite is not specified." + Test-Path $destinationPath | Should -Be $true } - catch + finally { - $_.FullyQualifiedErrorId | Should -Be "ArchiveExistsAsDirectory,Microsoft.PowerShell.Archive.CompressArchiveCommand" + Pop-Location } } - - It "Throws a terminating error when DestinationPath is a directory and -Update is specified" { - $sourcePath = "TestDrive:/SourceDir" - $destinationPath = "TestDrive:/archive3.zip" - + + It "Validate that relative path can be specified as LiteralPath parameter of Compress-Archive cmdlet" { + $sourcePath = "./SourceDir" + $destinationPath = "RelativePathForLiteralPathParameter.zip" try { - Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -WriteMode Update - throw "Failed to validate that a directory $destinationPath exists and -Update switch parameter is specified." + Push-Location TestDrive:/ + Compress-Archive -LiteralPath $sourcePath -DestinationPath $destinationPath + Test-Path $destinationPath | Should -Be $true } - catch + finally { - $_.FullyQualifiedErrorId | Should -Be "ArchiveExistsAsDirectory,Microsoft.PowerShell.Archive.CompressArchiveCommand" + Pop-Location } } - - It "Throws a terminating error when DestinationPath is a folder containing at least 1 item and Overwrite is specified" { + + It "Validate that relative path can be specified as DestinationPath parameter of Compress-Archive cmdlet" { $sourcePath = "TestDrive:/SourceDir" - $destinationPath = "TestDrive:" - + $destinationPath = "./RelativePathForDestinationPathParameter.zip" try { - Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -WriteMode Overwrite - throw "Failed to detect an error when $destinationPath is an existing directory containing at least 1 item and -Overwrite switch parameter is specified." + Push-Location TestDrive:/ + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath + Test-Path $destinationPath | Should -Be $true } - catch + finally { - $_.FullyQualifiedErrorId | Should -Be "ArchiveIsNonEmptyDirectory,Microsoft.PowerShell.Archive.CompressArchiveCommand" + Pop-Location } } + } - It "Throws a terminating error when archive does not exist and -Update mode is specified" { - $sourcePath = "TestDrive:/SourceDir" - $destinationPath = "TestDrive:/archive2.zip" - - try - { - Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -WriteMode Update - throw "Failed to validate that an archive file format $destinationPath does not exist and -Update switch parameter is specified." - } - catch - { - $_.FullyQualifiedErrorId | Should -Be "ArchiveDoesNotExist,Microsoft.PowerShell.Archive.CompressArchiveCommand" - } + Context "-Format tests" { + BeforeAll { + New-Item -Path TestDrive:/file1.txt -ItemType File + "Hello, World!" | Out-File -FilePath TestDrive:/file1.txt } - ## Overwrite tests - It "Throws an error when trying to overwrite an empty directory, which is the working directory" { - $sourcePath = "TestDrive:/Sample-2.txt" - $destinationPath = "TestDrive:/EmptyDirectory" - - Push-Location $destinationPath - + It "Throws an error when an invalid value is supplied to -Format" { try { - Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -WriteMode Overwrite + Compress-Archive -Path TestDrive:/file1.txt -DestinationPath TestDrive:/archive1 -Format fakeformat } catch { - $_.FullyQualifiedErrorId | Should -Be "CannotOverwriteWorkingDirectory,Microsoft.PowerShell.Archive.CompressArchiveCommand" + $_.FullyQualifiedErrorId | Should -Be "CannotConvertArgumentNoMessage,Microsoft.PowerShell.Archive.CompressArchiveCommand" } - - Pop-Location } - It "Overwrites a directory containing no items when -Overwrite is specified" { - $sourcePath = "TestDrive:/SourceDir" - $destinationPath = "TestDrive:/EmptyDirectory" + It "Creates a zip archive when -Format is not specified and the destination path does not have a matching extension" { + Compress-Archive -Path TestDrive:/file1.txt -DestinationPath TestDrive:/archive1 + "TestDrive:/archive1" | Should -BeArchiveOnlyContaining @("file1.txt") -Format Zip + } - # Ensure $destinationPath is a directory - Test-Path $destinationPath -PathType Container | Should -Be $true - - Compress-Archive -Path $sourcePath -DestinationPath $destinationPath -WriteMode Overwrite + It "Emits a warning when DestinationPath does not have an extension that matches the value of -Format" { + Compress-Archive -Path TestDrive:/file1.txt -DestinationPath TestDrive:/archive2.tar -Format Zip -WarningVariable warnings + "TestDrive:/archive2.tar" | Should -BeArchiveOnlyContaining @("file1.txt") -Format Zip + $warnings.Count | Should -Be 1 + } - # Ensure $destinationPath is now a file - Test-Path $destinationPath -PathType Leaf | Should -Be $true + It "Emits a warning when DestinationPath does not have an extension that matches any extension for a supported archive format" { + Compress-Archive -Path TestDrive:/file1.txt -DestinationPath TestDrive:/archive3.notmatching -WarningVariable warnings + "TestDrive:/archive3.notmatching" | Should -BeArchiveOnlyContaining @("file1.txt") -Format Zip + $warnings.Count | Should -Be 1 } - It "Overwrites an archive that already exists" { - $destinationPath = "TestDrive:/archive.zip" + It "Emits a warning when DestinationPath has no extension and -Format is specified" { + Compress-Archive -Path TestDrive:/file1.txt -DestinationPath TestDrive:/archive4 -Format Zip -WarningVariable warnings + "TestDrive:/archive4" | Should -BeArchiveOnlyContaining @("file1.txt") -Format Zip + $warnings.Count | Should -Be 1 + } - # Ensure the original archive contains Sample-1.txt - $destinationPath | Should -BeZipArchiveOnlyContaining @("Sample-1.txt") + It "Emits a warning when DestinationPath has no extension and -Format is not specified" { + Compress-Archive -Path TestDrive:/file1.txt -DestinationPath TestDrive:/archive5 -WarningVariable warnings + "TestDrive:/archive5" | Should -BeArchiveOnlyContaining @("file1.txt") -Format Zip + $warnings.Count | Should -Be 1 + } - # Overwrite the archive - $sourcePath = "TestDrive:/Sample-2.txt" - Compress-Archive -Path $sourcePath -DestinationPath "TestDrive:/archive.zip" -WriteMode Overwrite + It "Does not emit a warning when DestinationPath has an extension that matches the value supplied to -Format" -ForEach @( + @{DestinationPath = "TestDrive:/archive6.zip"; Format = "Zip"} + @{DestinationPath = "TestDrive:/archive6.tar"; Format = "Tar"} + ) { + Compress-Archive -Path TestDrive:/file1.txt -DestinationPath $DestinationPath -Format $Format -WarningVariable warnings + $DestinationPath | Should -BeArchiveOnlyContaining @("file1.txt") -Format $Format + $warnings.Count | Should -Be 0 + } - # Ensure the original entries and different than the new entries - $destinationPath | Should -BeZipArchiveOnlyContaining @("Sample-2.txt") + It "Does not emit a warning when DestinationPath has a .zip extension and -Format is not specified" { + Compress-Archive -Path TestDrive:/file1.txt -DestinationPath TestDrive:/archive7.zip -WarningVariable warnings + "TestDrive:/archive4" | Should -BeArchiveOnlyContaining @("file1.txt") -Format Zip + $warnings.Count | Should -Be 0 } } - Context "Relative Path tests" { + Context "Pipeline tests" { BeforeAll { - New-Item TestDrive:/SourceDir -Type Directory | Out-Null - New-Item TestDrive:/SourceDir/ChildDir-1 -Type Directory | Out-Null - - $content = "Some Data" - $content | Out-File -FilePath TestDrive:/SourceDir/Sample-1.txt - $content | Out-File -FilePath TestDrive:/SourceDir/ChildDir-1/Sample-2.txt + New-Item -Path TestDrive:/file1.txt -ItemType File + "Hello, World!" | Out-File -FilePath TestDrive:/file1.txt + New-Item -Path TestDrive:/file2.txt -ItemType File + "Hello, PowerShell!" | Out-File -FilePath TestDrive:/file2.txt } - # From 568 - It "Validate that relative path can be specified as Path parameter of Compress-Archive cmdlet" { - $sourcePath = "./SourceDir" - $destinationPath = "RelativePathForPathParameter.zip" - try - { - Push-Location TestDrive:/ - Compress-Archive -Path $sourcePath -DestinationPath $destinationPath - Test-Path $destinationPath | Should -Be $true - } - finally - { - Pop-Location - } + It "Creates an archives when paths are passed via pipeline" { + $destinationPath = "TestDrive:/archive1.zip" + @("TestDrive:/file1.txt", "TestDrive:/file2.txt") | Compress-Archive -DestinationPath $destinationPath + $destinationPath | Should -BeArchiveOnlyContaining @("file1.txt", "file2.txt") -Format Zip } - # From 582 - It "Validate that relative path can be specified as LiteralPath parameter of Compress-Archive cmdlet" { - $sourcePath = "./SourceDir" - $destinationPath = "RelativePathForLiteralPathParameter.zip" - try - { - Push-Location TestDrive:/ - Compress-Archive -LiteralPath $sourcePath -DestinationPath $destinationPath - Test-Path $destinationPath | Should -Be $true - } - finally - { - Pop-Location - } + It "Creates an archives when paths are passed to -Path via pipeline by name" { + $destinationPath = "TestDrive:/archive2.zip" + $path = [pscustomobject]@{Path = @("TestDrive:/file1.txt", "TestDrive:/file2.txt")} + $path | Compress-Archive -DestinationPath $destinationPath + $destinationPath | Should -BeArchiveOnlyContaining @("file1.txt", "file2.txt") -Format Zip } - # From 596 - It "Validate that relative path can be specified as DestinationPath parameter of Compress-Archive cmdlet" -Tag this3 { - $sourcePath = "TestDrive:/SourceDir" - $destinationPath = "./RelativePathForDestinationPathParameter.zip" - try - { - Push-Location TestDrive:/ - Compress-Archive -Path $sourcePath -DestinationPath $destinationPath - Test-Path $destinationPath | Should -Be $true - } - finally - { - Pop-Location - } + It "Creates an archives when paths are passed to -LiteralPath via pipeline by name" { + $destinationPath = "TestDrive:/archive3.zip" + $path = [pscustomobject]@{LiteralPath = @("TestDrive:/file1.txt", "TestDrive:/file2.txt")} + $path | Compress-Archive -DestinationPath $destinationPath + $destinationPath | Should -BeArchiveOnlyContaining @("file1.txt", "file2.txt") -Format Zip } } - Context "Special and Wildcard Characters Tests" { + Context "Large file tests" -Tag Slow { + + } + + Context "Update tests" { BeforeAll { - New-Item TestDrive:/SourceDir -Type Directory | Out-Null - - $content = "Some Data" - $content | Out-File -FilePath TestDrive:/SourceDir/Sample-1.txt + New-Item -Path TestDrive:/file1.txt -ItemType File + "Hello, World!" | Out-File -FilePath TestDrive:/file1.txt + New-Item -Path TestDrive:/file2.txt -ItemType File + "Hello, PowerShell!" | Out-File -FilePath TestDrive:/file2.txt + + Compress-Archive -Path TestDrive:/file1.txt -DestinationPath TestDrive:/archive1.zip + Compress-Archive -Path TestDrive:/file1.txt -DestinationPath TestDrive:/archive_to_append.zip + Compress-Archive -Path TestDrive:/file1.txt -DestinationPath TestDrive:/archive_to_update.zip + + # Create an archive containing a directory for updating + New-Item -Path TestDrive:/directory1 -ItemType Directory + New-Item -Path TestDrive:/directory1/file1.txt -ItemType File + + Compress-Archive -Path TestDrive:/directory1 -DestinationPath TestDrive:/archive_with_directory.zip + + New-Item -Path TestDrive:/directory1/file2.txt -ItemType File + + Compress-Archive -Path TestDrive:/file1.txt -DestinationPath TestDrive:/cantupdate.tar.gz -Format Tgz } + It "Does not throw an error when -Update is specified and the archive already exists" { + Compress-Archive -Path TestDrive:/file2.txt -DestinationPath TestDrive:/archive1.zip -WriteMode Update -ErrorVariable errors + $errors.Count | Should -Be 0 + } - It "Accepts DestinationPath parameter with wildcard characters that resolves to one path" { - $sourcePath = "TestDrive:/SourceDir/Sample-1.txt" - $destinationPath = "TestDrive:/Sample[]SingleFile.zip" - Compress-Archive -Path $sourcePath -DestinationPath $destinationPath - Test-Path -LiteralPath $destinationPath | Should -Be $true - Remove-Item -LiteralPath $destinationPath + It "Appends a file to an archive when -WriteMode Update is specified" { + Compress-Archive -Path TestDrive:/file2.txt -DestinationPath TestDrive:/archive_to_append.zip -WriteMode Update + "TestDrive:/archive_to_append.zip" | Should -BeArchiveOnlyContaining @("file1.txt", "file2.txt") -Format Zip } - It "Accepts DestinationPath parameter with [ but no matching ]" { - $sourcePath = "TestDrive:/SourceDir" - $destinationPath = "TestDrive:/archive[2.zip" + It "Modifies a pre-existing file in an archive when -WriteMode Update is specified" { + "File has been modified." | Out-File -FilePath TestDrive:/file1.txt + Compress-Archive -Path TestDrive:/file1.txt -DestinationPath TestDrive:/archive_to_update.zip -WriteMode Update + "TestDrive:/archive_to_update.zip" | Should -BeArchiveOnlyContaining @("file1.txt") -Format Zip - Compress-Archive -Path $sourcePath -DestinationPath $destinationPath - $destinationPath | Should -BeZipArchiveOnlyContaining @("SourceDir/", "SourceDir/Sample-1.txt") -LiteralPath - Remove-Item -LiteralPath $destinationPath + # Expand the archive and ensure it has the new contents + Expand-Archive -Path TestDrive:/archive_to_update.zip -DestinationPath TestDrive:/archive_to_update_contents + + Get-Content TestDrive:/archive_to_update_contents/file1.txt | Should -Be "File has been modified." + } + + It "Adds directory's children when updating a directory in the archive" { + $archivePath = "TestDrive:/archive_with_directory.zip" + Compress-Archive -Path TestDrive:/directory1 -DestinationPath $archivePath -WriteMode Update + $archivePath | Should -BeArchiveOnlyContaining @("directory1/", "directory1/file1.txt", "directory1/file2.txt") -Format Zip + } + + It "Throws an error when trying to update a tgz archive" -Tag hi { + try { + Compress-Archive -Path TestDrive:/directory1 -DestinationPath TestDrive:/cantupdate.tar.gz -WriteMode Update + } catch { + $_.FullyQualifiedErrorId | Should -Be "ArchiveIsNotUpdateable,Microsoft.PowerShell.Archive.CompressArchiveCommand" + } } } - Context "test" -Tag lol { + Context "Flatten tests" { BeforeAll { - $content = "Some Data" - $content | Out-File -FilePath TestDrive:/Sample-1.txt - Compress-Archive -Path TestDrive:/Sample-1.txt -DestinationPath TestDrive:/archive1.zip + New-Item -Path TestDrive:/directory1 -ItemType Directory + New-Item -Path TestDrive:/directory1/file1.txt -ItemType File + "Hello, World!" | Out-File -FilePath TestDrive:/directory1/file1.txt + + New-Item -Path TestDrive:/directory2 -ItemType Directory + New-Item -Path TestDrive:/directory2/file1.txt -ItemType File + "Hello, PowerShell!" | Out-File -FilePath TestDrive:/directory2/file1.txt } - It "test custom assetion" { - "${TestDrive}/archive1.zip" | Should -BeZipArchiveOnlyContaining @("Sample-1.txt") + It "Creates a flat archive with -Flatten" { + $path = "TestDrive:/directory1" + $destinationPath = "TestDrive:/archive1.zip" + + Compress-Archive -Path $path -DestinationPath $destinationPath -Flatten + $destinationPath | Should -BeArchiveOnlyContaining @("file1.txt") -Format Zip + } + + It "Does not throw an error when multiple files have the same entry name when -Flatten is specified" { + $path = "TestDrive:/directory1","TestDrive:/directory2" + $destinationPath = "TestDrive:/archive2.zip" + + Compress-Archive -Path $path -DestinationPath $destinationPath -Flatten -ErrorVariable errors + $errors.Count | Should -Be 0 + $destinationPath | Should -BeArchiveOnlyContaining @("file1.txt") -Format Zip } } -} +} \ No newline at end of file diff --git a/Tests/Expand-Archive.Tests.ps1 b/Tests/Expand-Archive.Tests.ps1 new file mode 100644 index 0000000..0d9d16a --- /dev/null +++ b/Tests/Expand-Archive.Tests.ps1 @@ -0,0 +1,832 @@ +# Tests for Expand-Archive + +Describe("Expand-Archive Tests") { + BeforeAll { + $CmdletClassName = "Microsoft.PowerShell.Archive.ExpandArchiveCommand" + + # Progress perference + $originalProgressPref = $ProgressPreference + $ProgressPreference = "SilentlyContinue" + + function Add-FileExtensionBasedOnFormat { + Param ( + [string] $Path, + [string] $Format + ) + + if ($Format -eq "Zip") { + return $Path += ".zip" + } + if ($Format -eq "Tar") { + return $Path += ".tar" + } + if (Format -eq "Tgz") { + return $Path += ".tar.gz" + } + throw "Format type is not supported" + } + } + + AfterAll { + $global:ProgressPreference = $originalProgressPref + } + + Context "Parameter set validation tests" { + BeforeAll { + # Set up files for tests + New-Item TestDrive:/SourceDir -Type Directory | Out-Null + $content = "Some Data" + $content | Out-File -FilePath TestDrive:/Sample-1.txt + + # Create archives called archive1.zip and archive2.zip + Compress-Archive -Path TestDrive:/Sample-1.txt -DestinationPath TestDrive:/archive1.zip + Compress-Archive -Path TestDrive:/Sample-1.txt -DestinationPath TestDrive:/archive2.zip + } + + + It "Validate errors with NULL & EMPTY values for Path, LiteralPath, and DestinationPath" -ForEach @( + @{ Path = $null; DestinationPath = "TestDrive:/destination" } + @{ Path = "TestDrive:/archive1.zip"; DestinationPath = $null } + @{ Path = $null; DestinationPath = $null } + @{ Path = ""; DestinationPath = "TestDrive:/destination" } + @{ Path = "TestDrive:/archive1.zip"; DestinationPath = "" } + @{ Path = ""; DestinationPath = "" } + ) { + + try + { + Expand-Archive -Path $Path -DestinationPath $DestinationPath + throw "ValidateNotNullOrEmpty attribute is missing on one of parameters belonging to Path parameterset." + } + catch + { + $_.FullyQualifiedErrorId | Should -Be "ParameterArgumentValidationError,$CmdletClassName" + } + + try + { + Expand-Archive -LiteralPath $Path -DestinationPath $DestinationPath + throw "ValidateNotNullOrEmpty attribute is missing on one of parameters belonging to LiteralPath parameterset." + } + catch + { + $_.FullyQualifiedErrorId | Should -Be "ParameterArgumentValidationError,$CmdletClassName" + } + } + + It "Throws when non-existing path is supplied for Path or LiteralPath parameters" { + $path = "TestDrive:/non-existant.zip" + $destinationPath = "TestDrive:($DS)DestinationFolder" + try + { + Expand-Archive -Path $path -DestinationPath $destinationPath + throw "Failed to validate that an invalid Path $invalidPath was supplied as input to Expand-Archive cmdlet." + } + catch + { + $_.FullyQualifiedErrorId | Should -Be "PathNotFound,$CmdletClassName" + } + + try + { + Expand-Archive -LiteralPath $path -DestinationPath $destinationPath + throw "Failed to validate that an invalid LiteralPath $invalidPath was supplied as input to Expand-Archive cmdlet." + } + catch + { + $_.FullyQualifiedErrorId | Should -Be "PathNotFound,$CmdletClassName" + } + } + + It "Throws when path non-filesystem path is supplied for Path or LiteralPath parameters" { + $path = "Variable:/PWD" + $destinationPath = "TestDrive:($DS)DestinationFolder" + try + { + Expand-Archive -Path $path -DestinationPath $destinationPath + throw "Failed to validate that an invalid Path $invalidPath was supplied as input to Expand-Archive cmdlet." + } + catch + { + $_.FullyQualifiedErrorId | Should -Be "InvalidPath,$CmdletClassName" + } + + try + { + Expand-Archive -LiteralPath $path -DestinationPath $destinationPath + throw "Failed to validate that an invalid LiteralPath $invalidPath was supplied as input to Expand-Archive cmdlet." + } + catch + { + $_.FullyQualifiedErrorId | Should -Be "InvalidPath,$CmdletClassName" + } + } + + It "Throws an error when multiple paths are supplied as input to Path parameter" { + $sourcePath = @( + "TestDrive:/SourceDir/archive1.zip", + "TestDrive:/SourceDir/archive2.zip") + $destinationPath = "TestDrive:/DestinationFolder" + + try + { + Expand-Archive -Path $sourcePath -DestinationPath $destinationPath + throw "Failed to detect that duplicate Path $sourcePath is supplied as input to Path parameter." + } + catch + { + $_.FullyQualifiedErrorId | Should -Be "CannotConvertArgument,$CmdletClassName" + } + } + + It "Throws an error when multiple paths are supplied as input to LiteralPath parameter" { + $sourcePath = @( + "TestDrive:/SourceDir/archive1.zip", + "TestDrive:/SourceDir/archive2.zip") + $destinationPath = "TestDrive:/DestinationFolder" + + try + { + Expand-Archive -LiteralPath $sourcePath -DestinationPath $destinationPath + throw "Failed to detect that duplicate Path $sourcePath is supplied as input to LiteralPath parameter." + } + catch + { + $_.FullyQualifiedErrorId | Should -Be "CannotConvertArgument,$CmdletClassName" + } + } + + ## From 504 + It "Validate that Source Path can be at SystemDrive location" -Skip { + $sourcePath = "$env:SystemDrive/SourceDir" + $destinationPath = "TestDrive:/SampleFromSystemDrive.zip" + New-Item $sourcePath -Type Directory | Out-Null # not enough permissions to write to drive root on Linux + "Some Data" | Out-File -FilePath $sourcePath/SampleSourceFileForArchive.txt + try + { + Compress-Archive -Path $sourcePath -DestinationPath $destinationPath + Test-Path $destinationPath | Should -Be $true + } + finally + { + Remove-Item "$sourcePath" -Force -Recurse -ErrorAction SilentlyContinue + } + } + + It "Throws an error when Path and DestinationPath are the same and -WriteMode Overwrite is specified" { + $sourcePath = "TestDrive:/archive1.zip" + $destinationPath = $sourcePath + + try { + Expand-Archive -Path $sourcePath -DestinationPath $destinationPath -WriteMode Overwrite + throw "Failed to detect an error when Path and DestinationPath are the same and -Overwrite is specified" + } catch { + $_.FullyQualifiedErrorId | Should -Be "SamePathAndDestinationPath,$CmdletClassName" + } + } + + It "Throws an error when LiteralPath and DestinationPath are the same and WriteMode -Overwrite is specified" { + $sourcePath = "TestDrive:/archive1.zip" + $destinationPath = $sourcePath + + try { + Expand-Archive -LiteralPath $sourcePath -DestinationPath $destinationPath -WriteMode Overwrite + throw "Failed to detect an error when LiteralPath and DestinationPath are the same and -Overwrite is specified" + } catch { + $_.FullyQualifiedErrorId | Should -Be "SameLiteralPathAndDestinationPath,$CmdletClassName" + } + } + + It "Throws an error when an invalid path is supplied to DestinationPath" { + $sourcePath = "TestDrive:/archive1.zip" + $destinationPath = "Variable:/PWD" + + try { + Expand-Archive -LiteralPath $sourcePath -DestinationPath $destinationPath + throw "Failed to detect an error when an invalid path is supplied to DestinationPath" + } catch { + $_.FullyQualifiedErrorId | Should -Be "InvalidPath,$CmdletClassName" + } + } + } + + Context "DestinationPath and Overwrite Tests" { + BeforeAll { + New-Item -Path "TestDrive:/file1.txt" -ItemType File + "Hello, World!" | Out-File -FilePath "TestDrive:/file1.txt" + Compress-Archive -Path "TestDrive:/file1.txt" -DestinationPath "TestDrive:/archive1.zip" + + New-Item -Path "TestDrive:/directory1" -ItemType Directory + + # Create archive2.zip containing directory1 + Compress-Archive -Path "TestDrive:/directory1" -DestinationPath "TestDrive:/archive2.zip" + + New-Item -Path "TestDrive:/ParentDir" -ItemType Directory + New-Item -Path "TestDrive:/ParentDir/file1.txt" -ItemType Directory + + # Create a dir that is a container for items to be overwritten + New-Item -Path "TestDrive:/ItemsToOverwriteContainer" -ItemType Directory + New-Item -Path "TestDrive:/ItemsToOverwriteContainer/file2" -ItemType File + New-Item -Path "TestDrive:/ItemsToOverwriteContainer/subdir1" -ItemType Directory + New-Item -Path "TestDrive:/ItemsToOverwriteContainer/subdir1/file1.txt" -ItemType File + New-Item -Path "TestDrive:/ItemsToOverwriteContainer/subdir2" -ItemType Directory + New-Item -Path "TestDrive:/ItemsToOverwriteContainer/subdir2/file1.txt" -ItemType Directory + New-Item -Path "TestDrive:/ItemsToOverwriteContainer/subdir4" -ItemType Directory + New-Item -Path "TestDrive:/ItemsToOverwriteContainer/subdir4/file1.txt" -ItemType Directory + New-Item -Path "TestDrive:/ItemsToOverwriteContainer/subdir4/file1.txt/somefile" -ItemType File + + # Create directory to override + New-Item -Path "TestDrive:/ItemsToOverwriteContainer/subdir3" -ItemType Directory + New-Item -Path "TestDrive:/ItemsToOverwriteContainer/subdir3/directory1" -ItemType File + + # Set the error action preference so non-terminating errors aren't displayed + $ErrorActionPreference = 'SilentlyContinue' + } + + AfterAll { + # Reset to default value + $ErrorActionPreference = 'Continue' + } + + It "Throws an error when DestinationPath is an existing file" -Tag debug2 { + $sourcePath = "TestDrive:/archive1.zip" + $destinationPath = "TestDrive:/file1.txt" + + try { + Expand-Archive -Path $sourcePath -DestinationPath $destinationPath + } catch { + $_.FullyQualifiedErrorId | Should -Be "DestinationExists,$CmdletClassName" + } + } + + It "Does not throw an error when a directory in the archive has the same destination path as an existing directory" { + $sourcePath = "TestDrive:/archive2.zip" + $destinationPath = "TestDrive:" + + try { + Expand-Archive -Path $sourcePath -DestinationPath $destinationPath -ErrorAction Stop + } catch { + throw "An error was thrown but an error was not expected" + } + } + + It "Writes a non-terminating error when a file in the archive has a destination path that already exists" { + $sourcePath = "TestDrive:/archive1.zip" + $destinationPath = "TestDrive:" + + Expand-Archive -Path $sourcePath -DestinationPath $destinationPath -ErrorVariable error + $error.Count | Should -Be 1 + $error[0].FullyQualifiedErrorId | Should -Be "DestinationExists,$CmdletClassName" + } + + It "Writes a non-terminating error when a file in the archive has a destination path that is an existing directory containing at least 1 item and -WriteMode Overwrite is specified" { + $sourcePath = "TestDrive:/archive1.zip" + $destinationPath = "TestDrive:/ItemsToOverwriteContainer/subdir4" + + Expand-Archive -Path $sourcePath -DestinationPath $destinationPath -WriteMode Overwrite -ErrorVariable error + $error.Count | Should -Be 1 + $error[0].FullyQualifiedErrorId | Should -Be "DestinationIsNonEmptyDirectory,$CmdletClassName" + } + + It "Writes a non-terminating error when a file in the archive has a destination path that is the working directory and -WriteMode Overwrite is specified" { + $sourcePath = "TestDrive:/archive1.zip" + $destinationPath = "TestDrive:/ParentDir" + + Push-Location "$destinationPath/file1.txt" + + try { + Expand-Archive -Path $sourcePath -DestinationPath $destinationPath -WriteMode Overwrite -ErrorVariable error + $error.Count | Should -Be 1 + $error[0].FullyQualifiedErrorId | Should -Be "CannotOverwriteWorkingDirectory,$CmdletClassName" + } finally { + Pop-Location + } + } + + It "Overwrites a file when it is DestinationPath and -WriteMode Overwrite is specified" { + $sourcePath = "TestDrive:/archive1.zip" + $destinationPath = "TestDrive:/ItemsToOverwriteContainer/file2" + + Expand-Archive -Path $sourcePath -DestinationPath $destinationPath -WriteMode Overwrite -ErrorVariable error + $error.Count | Should -Be 0 + + # Ensure the file in archive1.zip was expanded + Test-Path "TestDrive:/ItemsToOverwriteContainer/file2/file1.txt" -PathType Leaf + } + + It "Overwrites a file whose path is the same as the destination path of a file in the archive when -WriteMode Overwrite is specified" -Tag td { + $sourcePath = "TestDrive:/archive1.zip" + $destinationPath = "TestDrive:/ItemsToOverwriteContainer/subdir1" + + Expand-Archive -Path $sourcePath -DestinationPath $destinationPath -WriteMode Overwrite -ErrorVariable error + $error.Count | Should -Be 0 + + # Ensure the file in archive1.zip was expanded + Test-Path "TestDrive:/ItemsToOverwriteContainer/subdir1/file1.txt" -PathType Leaf + + # Ensure the contents of file1.txt is "Hello, World!" + Get-Content -Path "TestDrive:/ItemsToOverwriteContainer/subdir1/file1.txt" | Should -Be "Hello, World!" + } + + It "Overwrites a directory whose path is the same as the destination path of a file in the archive when -WriteMode Overwrite is specified" { + $sourcePath = "TestDrive:/archive1.zip" + $destinationPath = "TestDrive:/ItemsToOverwriteContainer/subdir2" + + Expand-Archive -Path $sourcePath -DestinationPath $destinationPath -WriteMode Overwrite -ErrorVariable error + $error.Count | Should -Be 0 + + # Ensure the file in archive1.zip was expanded + Test-Path "TestDrive:/ItemsToOverwriteContainer/subdir2/file1.txt" -PathType Leaf + + # Ensure the contents of file1.txt is "Hello, World!" + Get-Content -Path "TestDrive:/ItemsToOverwriteContainer/subdir2/file1.txt" | Should -Be "Hello, World!" + } + + It "Overwrites a file whose path is the same as the destination path of a directory in the archive when -WriteMode Overwrite is specified" { + $sourcePath = "TestDrive:/archive2.zip" + $destinationPath = "TestDrive:/ItemsToOverwriteContainer/subdir3" + Expand-Archive -Path $sourcePath -DestinationPath $destinationPath -WriteMode Overwrite -ErrorVariable error + $error.Count | Should -Be 0 + + # Ensure the file in archive1.zip was expanded + Test-Path "TestDrive:/ItemsToOverwriteContainer/subdir3/directory1" -PathType Container + } + } + + Context "Basic functionality tests" -ForEach @( + @{Format = "Zip"}, + @{Format = "Tar"}, + @{Format = "Tgz"} + ) { + # extract to a directory works + # extract to working directory works when DestinationPath is specified + # expand archive works when -DestinationPath is not specified (and a single top level item which is a directory) + # expand archive works when -DestinationPath is not specified (and there are mutiple top level items) + + BeforeAll { + New-Item -Path "TestDrive:/file1.txt" -ItemType File + "Hello, World!" | Out-File -FilePath "TestDrive:/file1.txt" + Compress-Archive -Path "TestDrive:/file1.txt" -DestinationPath (Add-FileExtensionBasedOnFormat "TestDrive:/archive1" -Format $Format) + + New-Item -Path "TestDrive:/directory2" -ItemType Directory + New-Item -Path "TestDrive:/directory3" -ItemType Directory + New-Item -Path "TestDrive:/directory4" -ItemType Directory + New-Item -Path "TestDrive:/directory5" -ItemType Directory + New-Item -Path "TestDrive:/directory6" -ItemType Directory + + New-Item -Path "TestDrive:/DirectoryToArchive" -ItemType Directory + Compress-Archive -Path "TestDrive:/DirectoryToArchive" -DestinationPath (Add-FileExtensionBasedOnFormat "TestDrive:/archive2" -Format $Format) + + # Create an archive containing a file and an empty folder + Compress-Archive -Path "TestDrive:/file1.txt","TestDrive:/DirectoryToArchive" -DestinationPath "TestDrive:/archive3" + } + + It "Expands an archive when a non-existent directory is specified as -DestinationPath with format " { + $sourcePath = Add-FileExtensionBasedOnFormat "TestDrive:/archive1" -Format $Format + $destinationPath = "TestDrive:/directory1" + + Expand-Archive -Path $sourcePath -DestinationPath $destinationPath -Format $Format + + $itemsInDestinationPath = Get-ChildItem $destinationPath -Recurse + $itemsInDestinationPath.Count | Should -Be 1 + $itemsInDestinationPath[0].Name | Should -Be "file1.txt" + } + + It "Expands an archive when DestinationPath is an existing directory" { + $sourcePath = Add-FileExtensionBasedOnFormat "TestDrive:/archive1" -Format $Format + $destinationPath = "TestDrive:/directory2" + + try { + Expand-Archive -Path $sourcePath -DestinationPath $destinationPath -ErrorAction Stop + } catch { + throw "An error was thrown but an error was not expected" + } + } + + It "Expands an archive to the working directory when it is specified as -DestinationPath" { + $sourcePath = Add-FileExtensionBasedOnFormat "TestDrive:/archive1" -Format $Format + $destinationPath = "TestDrive:/directory3" + + Push-Location $destinationPath + + Expand-Archive -Path $sourcePath -DestinationPath $PWD + + $itemsInDestinationPath = Get-ChildItem $PWD -Recurse + $itemsInDestinationPath.Count | Should -Be 1 + $itemsInDestinationPath[0].Name | Should -Be "file1.txt" + + Pop-Location + } + + It "Expands an archive to a directory with that archive's name when -DestinationPath is not specified" { + $sourcePath = Add-FileExtensionBasedOnFormat "TestDrive:/archive2" -Format $Format + $destinationPath = "TestDrive:/directory4" + + Push-Location $destinationPath + + Expand-Archive -Path $sourcePath + + $itemsInDestinationPath = Get-ChildItem $destinationPath -Recurse + $itemsInDestinationPath.Count | Should -Be 2 + + $directoryContents = @() + $directoryContents += $itemsInDestinationPath[0].FullName + $directoryContents += $itemsInDestinationPath[1].FullName + + $directoryContents | Should -Contain (Join-Path $TestDrive "directory4/archive2") + $directoryContents | Should -Contain (Join-Path $TestDrive "directory4/archive2/DirectoryToArchive") + + Pop-Location + } + + It "Throws an error when expanding an archive whose name does not have an extension and -DestinationPath is not specified" { + Push-Location "TestDrive:/" + try { + Expand-Archive -Path "TestDrive:/archive3" + } + catch { + $_.FullyQualifiedErrorId | Should -Be "CannotDetermineDestinationPath,${CmdletClassName}" + } + finally { + Pop-Location + } + } + + It "Expands an archive containing multiple files, non-empty directories, and empty directories" { + + # Create an archive containing multiple files, non-empty directories, and empty directories + New-Item -Path "TestDrive:/file2.txt" -ItemType File + "Hello, World!" | Out-File -FilePath "TestDrive:/file2.txt" + New-Item -Path "TestDrive:/file3.txt" -ItemType File + "Hello, World!" | Out-File -FilePath "TestDrive:/file3.txt" + + New-Item -Path "TestDrive:/emptydirectory1" -ItemType Directory + New-Item -Path "TestDrive:/emptydirectory2" -ItemType Directory + + New-Item -Path "TestDrive:/nonemptydirectory1" -ItemType Directory + New-Item -Path "TestDrive:/nonemptydirectory2" -ItemType Directory + + New-Item -Path "TestDrive:/nonemptydirectory1/subfile1.txt" -ItemType File + New-Item -Path "TestDrive:/nonemptydirectory2/subemptydirectory1" -ItemType Directory + + $archive4Paths = @("TestDrive:/file2.txt", "TestDrive:/file3.txt", "TestDrive:/emptydirectory1", "TestDrive:/emptydirectory2", "TestDrive:/nonemptydirectory1", "TestDrive:/nonemptydirectory2") + + $sourcePath = Add-FileExtensionBasedOnFormat "TestDrive:/archive4" -Format $Format + Compress-Archive -Path $archive4Paths -DestinationPath $sourcePath -Format $Format + + + $destinationPath = "TestDrive:/directory6" + + Expand-Archive -Path $sourcePath -DestinationPath $destinationPath -Format $Format + + $expandedItems = Get-ChildItem $destinationPath -Recurse -Name + + $itemsInArchive = @("file2.txt", "file3.txt", "emptydirectory1", "emptydirectory2", "nonemptydirectory1", "nonemptydirectory2", (Join-Path "nonemptydirectory1" "subfile1.txt"), (Join-Path "nonemptydirectory2" "subemptydirectory1")) + + $expandedItems.Length | Should -Be $itemsInArchive.Count + foreach ($item in $itemsInArchive) { + $item | Should -BeIn $expandedItems + } + } + + It "Expands an archive containing a file whose LastWriteTime is in the past" { + New-Item -Path "TestDrive:/oldfile.txt" -ItemType File + Set-ItemProperty -Path "TestDrive:/oldfile.txt" -Name "LastWriteTime" -Value '2003-01-16 14:44' + $sourcePath = Add-FileExtensionBasedOnFormat "TestDrive:/archive_oldfile" -Format $Format + Compress-Archive -Path "TestDrive:/oldfile.txt" -DestinationPath $sourcePath -Format $Format + + + $destinationPath = "TestDrive:/destination7" + Expand-Archive -Path $sourcePath -DestinationPath $destinationPath -Format $Format + + $lastWriteTime = Get-ItemPropertyValue -Path (Join-Path $destinationPath "oldfile.txt") -Name "LastWriteTime" + + $lastWriteTime.Year | Should -Be 2003 + $lastWriteTime.Month | Should -Be 1 + $lastWriteTime.Day | Should -Be 16 + $lastWriteTime.Hour | Should -Be 14 + $lastWriteTime.Minute | Should -Be 44 + $lastWriteTime.Second | Should -Be 0 + $lastWriteTime.Millisecond | Should -Be 0 + } + + It "Expands an archive containing a directory whose LastWriteTime is in the past" { + New-Item -Path "TestDrive:/olddirectory" -ItemType Directory + Set-ItemProperty -Path "TestDrive:/olddirectory" -Name "LastWriteTime" -Value '2003-01-16 14:44' + + $sourcePath = Add-FileExtensionBasedOnFormat "TestDrive:/archive_olddirectory" -Format $Format + Compress-Archive -Path "TestDrive:/olddirectory" -DestinationPath $sourcePath -Format $Format + + + $destinationPath = "TestDrive:/destination_olddirectory" + Expand-Archive -Path $sourcePath -DestinationPath $destinationPath -Format $Format + + $lastWriteTime = Get-ItemPropertyValue -Path "TestDrive:/destination_olddirectory/olddirectory" -Name "LastWriteTime" + + $lastWriteTime.Year | Should -Be 2003 + $lastWriteTime.Month | Should -Be 1 + $lastWriteTime.Day | Should -Be 16 + $lastWriteTime.Hour | Should -Be 14 + $lastWriteTime.Minute | Should -Be 44 + $lastWriteTime.Second | Should -Be 0 + $lastWriteTime.Millisecond | Should -Be 0 + } + } + + Context "PassThru tests" { + BeforeAll { + New-Item -Path TestDrive:/file1.txt -ItemType File + "Hello, World!" | Out-File -Path TestDrive:/file1.txt + $archivePath = "TestDrive:/archive.zip" + Compress-Archive -Path TestDrive:/file1.txt -DestinationPath $archivePath + } + + It "Returns a System.IO.DirectoryInfo object when PassThru is specified" { + $destinationPath = "TestDrive:/archive_contents" + $output = Expand-Archive -Path $archivePath -DestinationPath $destinationPath -PassThru + $output | Should -BeOfType System.IO.DirectoryInfo + $output.FullName | Should -Be (Convert-Path $destinationPath) + } + + It "Does not return an object when PassThru is not specified" { + $output = Expand-Archive -Path $archivePath -DestinationPath TestDrive:/archive_contents2 + $output | Should -BeNullOrEmpty + } + + It "Does not return an object when PassThru is false" { + $output = Expand-Archive -Path $archivePath -DestinationPath TestDrive:/archive_contents3 -PassThru:$false + $output | Should -BeNullOrEmpty + } + } + + Context "Special and Wildcard Character Tests" { + BeforeAll { + New-Item TestDrive:/file.txt -ItemType File + "Hello, World!" | Out-File -Path TestDrive:/file.txt + + Compress-Archive -Path TestDrive:/file.txt -DestinationPath TestDrive:/archive_containing_file.zip + Compress-Archive -Path TestDrive:/file.txt -DestinationPath TestDrive:/archive_with_number_1.zip + Compress-Archive -Path TestDrive:/file.txt -DestinationPath TestDrive:/archive_with_number_2.zip + Compress-Archive -Path TestDrive:/file.txt -DestinationPath TestDrive:/archive_with_[.zip + } + + AfterAll { + Remove-Item -LiteralPath "TestDrive:/archive_with_[.zip" + } + + It "Expands an archive when -Path contains wildcard character and resolves to 1 path" { + Expand-Archive -Path TestDrive:/archive_containing* -DestinationPath TestDrive:/destination1 + (Convert-Path TestDrive:/destination1/file.txt) | Should -Exist + } + + It "Throws a terminating error when archive when -Path contains wildcard character and resolves to multiple paths" { + try { + Expand-Archive -Path TestDrive:/archive_with* -DestinationPath TestDrive:/destination2 + } catch { + $_.FullyQualifiedErrorId | Should -Be "PathResolvedToMultiplePaths,$CmdletClassName" + } + } + + It "Expands an archive when -LiteralPath contains [ but no matching ]" { + Expand-Archive -LiteralPath TestDrive:/archive_with_[.zip -DestinationPath TestDrive:/destination3 + (Convert-Path TestDrive:/destination3/file.txt) | Should -Exist + } + + It "Expands an archive when -DestinationPath contains [ but no matching ]" { + Expand-Archive -Path TestDrive:/archive_containing_file.zip -DestinationPath TestDrive:/destination[ + Test-Path -LiteralPath TestDrive:/destination[/file.txt | Should -Be $true + Remove-Item -LiteralPath "${TestDrive}/destination[" -Recurse + } + } + + Context "File permssions, attributes, etc tests" -Tag td2 { + BeforeAll { + New-Item TestDrive:/file.txt -ItemType File + "Hello, World!" | Out-File -Path TestDrive:/file.txt + + # Create a readonly archive + Compress-Archive -Path TestDrive:/file.txt -DestinationPath TestDrive:/readonly.zip + Set-ItemProperty -Path TestDrive:/readonly.zip -Name "IsReadOnly" -Value $true + + # Create an archive in-use + Compress-Archive -Path TestDrive:/file.txt -DestinationPath TestDrive:/archive_in_use.zip + $fileMode = [System.IO.FileMode]::Open + $fileAccess = [System.IO.FileAccess]::Read + $fileShare = [System.IO.FileShare]::Read + $archiveInUseStream = New-Object -TypeName "System.IO.FileStream" -ArgumentList "${TestDrive}/archive_in_use.zip",$fileMode,$fileAccess,$fileShare + + [console]::InputEncoding = [console]::OutputEncoding = New-Object System.Text.UTF8Encoding + # Create an archive containing an entry with non-latin characters + New-Item TestDrive:/ملف -ItemType File + "Hello, World!" | Out-File -Path TestDrive:/ملف + $archiveWithNonLatinEntryPath = Join-Path $TestDrive "archive_with_nonlatin_entry.zip" + if ($IsWindows) { + 7z.exe a $archiveWithNonLatinEntryPath (Join-Path $TestDrive ملف) + } else { + 7z a $archiveWithNonLatinEntryPath (Join-Path $TestDrive ملف) + } + + } + + AfterAll { + $archiveInUseStream.Dispose() + } + + It "Expands a read-only archive" { + Expand-Archive -Path TestDrive:/readonly.zip -DestinationPath TestDrive:/readonly_output + "TestDrive:/readonly_output/file.txt" | Should -Exist + } + + It "Expands an archive in-use" { + Expand-Archive -Path TestDrive:/archive_in_use.zip -DestinationPath TestDrive:/archive_in_use_output + "TestDrive:/archive_in_use_output/file.txt" | Should -Exist + } + + It "Expands an archive containing an entry with non-latin characters" { + Expand-Archive -Path $archiveWithNonLatinEntryPath -DestinationPath TestDrive:/archive_with_nonlatin_entry_output + "TestDrive:/archive_with_nonlatin_entry_output/ملف" | Should -Exist + } + } + + Context "Large File Tests" { + BeforeAll { + $numberOfBytes = 512 + $bytes = [byte[]]::new($numberOfBytes) + for ($i = 0; $i -lt $numberOfBytes; $i++) { + $bytes[$i] = 1 + } + + # Create a large file containing 1's + $largeFilePath = Join-Path $TestDrive "file1" + $fileWith1s = [System.IO.File]::Create($largeFilePath) + + $numberOfTimesToWrite = 5GB / $numberOfBytes + for ($i=0; $i -lt $numberOfTimesToWrite; $i++) { + $fileWith1s.Write($bytes, 0, $numberOfBytes) + } + $fileWith1s.Close() + + Compress-Archive -Path TestDrive:/file1 -DestinationPath TestDrive:/large_entry_archive.zip + Compress-Archive -Path TestDrive:/file1 -DestinationPath TestDrive:/large_archive.zip -CompressionLevel NoCompression + } + + It "Expands an archive whose size is > 4GB" { + $destinationPath = "TestDrive:/large_archive_output" + { Expand-Archive -Path TestDrive:/large_archive.zip -DestinationPath $destinationPath } | Should -Not -Throw + $childItems = Get-ChildItem $destinationPath + $childItems.Count | Should -Be 1 + $childItems[0].Name | Should -Be "file1" + } + + It "Expands an archive containing an entry whose size is > 4GB" { + $destinationPath = "TestDrive:/large_archive_output2" + { Expand-Archive -Path TestDrive:/large_entry_archive.zip -DestinationPath $destinationPath } | Should -Not -Throw + $childItems = Get-ChildItem $destinationPath + $childItems.Count | Should -Be 1 + $childItems[0].Name | Should -Be "file1" + } + } + + Context "Pipeline tests" { + BeforeAll { + New-Item TestDrive:/file.txt -ItemType File + "Hello, World!" | Out-File -Path TestDrive:/file.txt + + + Compress-Archive -Path TestDrive:/file.txt -DestinationPath TestDrive:/archive1.zip + Compress-Archive -Path TestDrive:/file.txt -DestinationPath TestDrive:/archive2.zip + } + + It "Expands an archive when -Path is passed by pipeline" { + $destinationPath = "TestDrive:/archive_output1" + "TestDrive:/archive1.zip" | Expand-Archive -DestinationPath $destinationPath + $childItems = Get-ChildItem $destinationPath + $childItems.Count | Should -Be 1 + $childItems[0].FullName | Should -Be (Join-Path $TestDrive "archive_output1" "file.txt") + } + + It "Expands an archive when -Path is passed by pipeline by name" { + $destinationPath = "TestDrive:/archive_output2" + $path = [pscustomobject]@{Path = "TestDrive:/archive1.zip"} + $path | Expand-Archive -DestinationPath $destinationPath + $childItems = Get-ChildItem $destinationPath -Verbose + $childItems.Count | Should -Be 1 + $childItems[0].FullName | Should -Be (Join-Path $TestDrive "archive_output2" "file.txt") + } + + It "Throws an error when multiple paths are passed by pipeline" { + try { + $destinationPath = "TestDrive:/archive_output3" + $path = @("TestDrive:/archive1.zip", "TestDrive:/archive2.zip") + $path | Expand-Archive -DestinationPath $destinationPath + } + catch { + $_.FullyQualifiedErrorId | Should -Be "MultiplePathsPassed,${CmdletClassName}" + } + } + } + + Context "Relative Path Tests" { + BeforeAll { + New-Item TestDrive:/file.txt -ItemType File + "Hello, World!" | Out-File -Path TestDrive:/file.txt + + New-Item -Path TestDrive:/directory1 -ItemType Directory + Compress-Archive -Path TestDrive:/file.txt -DestinationPath TestDrive:/directory1/archive1.zip + Compress-Archive -Path TestDrive:/file.txt -DestinationPath TestDrive:/archive2.zip + New-Item -Path TestDrive:/directory2 -ItemType Directory + } + + It "Expands an archive when -Path is a relative path" { + Push-Location TestDrive:/directory1 + $destinationPath = "TestDrive:/relative_path_directory" + Expand-Archive -Path archive1.zip -DestinationPath $destinationPath + $childItems = Get-ChildItem $destinationPath -Verbose + $childItems.Count | Should -Be 1 + $childItems[0].Name | Should -Be "file.txt" + Pop-Location + } + + It "Expands an archive when -LiteralPath is a relative path" { + Push-Location TestDrive:/directory1 + $destinationPath = "TestDrive:/relative_literal_path_directory" + Expand-Archive -LiteralPath archive1.zip -DestinationPath $destinationPath + $childItems = Get-ChildItem $destinationPath -Verbose + $childItems.Count | Should -Be 1 + $childItems[0].Name | Should -Be "file.txt" + Pop-Location + } + + It "Expands an archive when -DestinationPath is a relative path" { + Push-Location TestDrive:/directory2 + $destinationPath = "destination_path_output" + Expand-Archive -Path "TestDrive:/directory1/archive1.zip" -DestinationPath $destinationPath + $childItems = Get-ChildItem $destinationPath -Verbose + $childItems.Count | Should -Be 1 + $childItems[0].Name | Should -Be "file.txt" + Pop-Location + } + } + + Context "-Format tests" { + BeforeAll { + New-Item TestDrive:/file.txt -ItemType File + "Hello, World!" | Out-File -Path TestDrive:/file.txt + Compress-Archive -Path TestDrive:/file.txt -DestinationPath TestDrive:/archive1.zip + } + + It "Throws an error when an invalid value is supplied to -Format" { + try { + Expand-Archive -Path TestDrive:/archive1.zip -DestinationPath TestDrive:/output_directory + } catch { + $_.FullyQualifiedErrorId | Should -Be "CannotConvertArgumentNoMessage,${CmdletClassName}" + } + } + } + + Context "Module tests" { + It "Validate module can be imported when current language is not en-US" { + $currentCulture = [System.Threading.Thread]::CurrentThread.CurrentUICulture + try { + [System.Threading.Thread]::CurrentThread.CurrentCulture = [CultureInfo]::new("he-IL") + { Import-Module Microsoft.PowerShell.Archive -Force -ErrorAction Stop } | Should -Not -Throw + } + finally { + [System.Threading.Thread]::CurrentThread.CurrentCulture = $currentCulture + } + } + } + + Context "Filter tests" { + BeforeAll { + New-Item TestDrive:/file1.txt -ItemType File + "Hello, World!" | Out-File -Path TestDrive:/file.txt + New-Item TestDrive:/file2.rtf -ItemType File + "Content" | Out-File -Path TestDrive:/file.rtf + Compress-Archive -Path TestDrive:/file1.txt,TestDrive:/file2.rtf -DestinationPath TestDrive:/archive1.zip + + # Create an archive containing a directory + New-Item TestDrive:/directory1 -ItemType Directory + New-Item TestDrive:/directory1/file1.txt -ItemType File + "Hello, World!" | Out-File -Path TestDrive:/directory1/file1.txt + Compress-Archive -Path TestDrive:/directory1 -DestinationPath TestDrive:/archive_containing_directory1 + } + + It "Filter works" { + $destinationPath = "TestDrive:/filter_works_output" + Expand-Archive -Path TestDrive:/archive1.zip -DestinationPath $destinationPath -Filter *.txt + $childItems = Get-ChildItem $destinationPath + $childItems.Count | Should -Be 1 + $childItems[0].Name | Should -Be "file1.txt" + } + + It "Expands parent directory of a file that matches filter when the parent directory does not match the filter" { + $destinationPath = "TestDrive:/expand_parent_directory_output" + Expand-Archive -Path TestDrive:/archive_containing_directory1 -DestinationPath $destinationPath -Filter *.txt + $childItems = Get-ChildItem $destinationPath -Recurse -Name + $childItems.Count | Should -Be 2 + $childItems | Should -Contain "directory1" + $childItems | Should -Contain (Join-Path "directory1" "file1.txt") + } + } +} \ No newline at end of file diff --git a/src/ArchiveAddition.cs b/src/ArchiveAddition.cs index d0e8337..2256488 100644 --- a/src/ArchiveAddition.cs +++ b/src/ArchiveAddition.cs @@ -11,7 +11,7 @@ namespace Microsoft.PowerShell.Archive /// ArchiveAddition represents an filesystem entry that we want to add to or update in the archive. /// ArchiveAddition DOES NOT represent an entry in the archive -- rather, it represents an entry to be created or updated using the information contained in an instance of this class. /// - internal class ArchiveAddition + public class ArchiveAddition { /// /// The name of the file or directory in the archive. diff --git a/src/ArchiveCommandBase.cs b/src/ArchiveCommandBase.cs new file mode 100644 index 0000000..1259d39 --- /dev/null +++ b/src/ArchiveCommandBase.cs @@ -0,0 +1,52 @@ +using Microsoft.PowerShell.Archive.Localized; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerShell.Archive +{ + /// + /// This class is meant to be a base class for all cmdlets in the archive module + /// + public class ArchiveCommandBase : PSCmdlet + { + protected ArchiveFormat DetermineArchiveFormat(string destinationPath, ArchiveFormat? archiveFormat) + { + // Check if cmdlet is able to determine the format of the archive based on the extension of DestinationPath + bool ableToDetermineArchiveFormat = ArchiveFactory.TryGetArchiveFormatFromExtension(path: destinationPath, archiveFormat: out var archiveFormatBasedOnExt); + // If the user did not specify which archive format to use, try to determine it automatically + if (archiveFormat is null) + { + if (ableToDetermineArchiveFormat) + { + archiveFormat = archiveFormatBasedOnExt; + } + else + { + // If the archive format could not be determined, use zip by default and emit a warning + var warningMsg = String.Format(Messages.ArchiveFormatCouldNotBeDeterminedWarning, destinationPath); + WriteWarning(warningMsg); + archiveFormat = ArchiveFormat.Zip; + } + // Write a verbose message saying that Format is not specified and a format was determined automatically + string verboseMessage = String.Format(Messages.ArchiveFormatDeterminedVerboseMessage, archiveFormat); + WriteVerbose(verboseMessage); + } + // If the user did specify which archive format to use, emit a warning if DestinationPath does not match the chosen archive format + else + { + if (archiveFormat is null || archiveFormat.Value != archiveFormat.Value) + { + var warningMsg = String.Format(Messages.ArchiveExtensionDoesNotMatchArchiveFormatWarning, destinationPath); + WriteWarning(warningMsg); + } + } + + // archiveFormat is never null at this point + return archiveFormat.Value; + } + } +} diff --git a/src/ArchiveFactory.cs b/src/ArchiveFactory.cs index fd0e636..6502506 100644 --- a/src/ArchiveFactory.cs +++ b/src/ArchiveFactory.cs @@ -21,8 +21,8 @@ internal static IArchive GetArchive(ArchiveFormat format, string archivePath, Ar return format switch { ArchiveFormat.Zip => new ZipArchive(archivePath, archiveMode, archiveFileStream, compressionLevel), - //ArchiveFormat.tar => new TarArchive(archivePath, archiveMode, archiveFileStream), - // TODO: Add Tar.gz here + ArchiveFormat.Tar => new TarArchive(archivePath, archiveMode, archiveFileStream), + ArchiveFormat.Tgz => new TarGzArchive(archivePath, archiveMode, archiveFileStream, compressionLevel), _ => throw new ArgumentOutOfRangeException(nameof(archiveMode)) }; } @@ -32,9 +32,9 @@ internal static bool TryGetArchiveFormatFromExtension(string path, out ArchiveFo archiveFormat = Path.GetExtension(path).ToLowerInvariant() switch { ".zip" => ArchiveFormat.Zip, - /* Disable support for tar and tar.gz for preview1 release - ".gz" => path.EndsWith(".tar.gz) ? ArchiveFormat.Tgz : null, - */ + ".tar" => ArchiveFormat.Tar, + ".gz" => path.EndsWith(".tar.gz") ? ArchiveFormat.Tgz : null, + ".tgz" => ArchiveFormat.Tgz, _ => null }; return archiveFormat is not null; diff --git a/src/ArchiveFormat.cs b/src/ArchiveFormat.cs index 5ee6fef..f8032a9 100644 --- a/src/ArchiveFormat.cs +++ b/src/ArchiveFormat.cs @@ -10,8 +10,7 @@ namespace Microsoft.PowerShell.Archive public enum ArchiveFormat { Zip, - /* Removing these formats for preview relase Tar, - Tgz*/ + Tgz } } diff --git a/src/CompressArchiveCommand.cs b/src/Cmdlets/CompressArchiveCommand.cs similarity index 79% rename from src/CompressArchiveCommand.cs rename to src/Cmdlets/CompressArchiveCommand.cs index de521d9..05f7662 100644 --- a/src/CompressArchiveCommand.cs +++ b/src/Cmdlets/CompressArchiveCommand.cs @@ -16,12 +16,7 @@ namespace Microsoft.PowerShell.Archive { [Cmdlet("Compress", "Archive", SupportsShouldProcess = true)] [OutputType(typeof(FileInfo))] - public sealed class CompressArchiveCommand : PSCmdlet - { - - // TODO: Add filter parameter - // TODO: Add flatten parameter - // TODO: Add comments to methods + public sealed class CompressArchiveCommand : PSCmdlet { // TODO: Add tar support private enum ParameterSet @@ -55,18 +50,25 @@ private enum ParameterSet [NotNull] public string? DestinationPath { get; set; } - [Parameter()] + [Parameter] public WriteMode WriteMode { get; set; } - [Parameter()] + [Parameter] public SwitchParameter PassThru { get; set; } - [Parameter()] + [Parameter] [ValidateNotNullOrEmpty] public CompressionLevel CompressionLevel { get; set; } - [Parameter()] - public ArchiveFormat? Format { get; set; } = null; + [Parameter] + public ArchiveFormat? Format { get; set; } + + [Parameter] + [ValidateNotNullOrEmpty] + public string? Filter { get; set; } + + [Parameter] + public SwitchParameter Flatten { get; set; } private readonly PathHelper _pathHelper; @@ -99,7 +101,7 @@ public CompressArchiveCommand() protected override void BeginProcessing() { // This resolves the path to a fully qualified path and handles provider exceptions - DestinationPath = _pathHelper.GetUnresolvedPathFromPSProviderPath(DestinationPath); + DestinationPath = _pathHelper.GetUnresolvedPathFromPSProviderPath(path: DestinationPath, pathMustExist: false); ValidateDestinationPath(); } @@ -109,7 +111,7 @@ protected override void ProcessRecord() { Debug.Assert(Path is not null); foreach (var path in Path) { - var resolvedPaths = _pathHelper.GetResolvedPathFromPSProviderPath(path, _nonexistentPaths); + var resolvedPaths = _pathHelper.GetResolvedPathFromPSProviderPathWhileCapturingNonexistentPaths(path, _nonexistentPaths); if (resolvedPaths is not null) { foreach (var resolvedPath in resolvedPaths) { // Add resolvedPath to _path @@ -123,7 +125,7 @@ protected override void ProcessRecord() { Debug.Assert(LiteralPath is not null); foreach (var path in LiteralPath) { - var unresolvedPath = _pathHelper.GetUnresolvedPathFromPSProviderPath(path, _nonexistentPaths); + var unresolvedPath = _pathHelper.GetUnresolvedPathFromPSProviderPathWhileCapturingNonexistentPaths(path, _nonexistentPaths); if (unresolvedPath is not null) { // Add unresolvedPath to _path AddPathToPaths(pathToAdd: unresolvedPath); @@ -160,6 +162,9 @@ protected override void EndProcessing() // Get archive entries // If a path causes an exception (e.g., SecurityException), _pathHelper should handle it + Debug.Assert(_paths is not null); + _pathHelper.Flatten = Flatten; + _pathHelper.Filter = Filter; List archiveAdditions = _pathHelper.GetArchiveAdditions(_paths); // Remove references to _paths, Path, and LiteralPath to free up memory @@ -181,7 +186,9 @@ protected override void EndProcessing() IArchive? archive = null; try { - if (ShouldProcess(target: DestinationPath, action: Messages.Create)) + // If the archive is in Update mode, we want to skip the ShouldProcess check + // This is necessary if we want to check if the archive is updateable + if (WriteMode == WriteMode.Update || ShouldProcess(target: DestinationPath, action: Messages.Create)) { // If the WriteMode is overwrite, delete the existing archive if (WriteMode == WriteMode.Overwrite) @@ -193,6 +200,13 @@ protected override void EndProcessing() archive = ArchiveFactory.GetArchive(format: Format ?? ArchiveFormat.Zip, archivePath: DestinationPath, archiveMode: archiveMode, compressionLevel: CompressionLevel); _didCreateNewArchive = archiveMode != ArchiveMode.Update; } + + // If the cmdlet is in Update mode and the archive does not support updates, throw an error + if (WriteMode == WriteMode.Update && archive is not null && !archive.IsUpdateable) + { + var errorRecord = ErrorMessages.GetErrorRecord(ErrorCode.ArchiveIsNotUpdateable, DestinationPath); + ThrowTerminatingError(errorRecord); + } long numberOfAdditions = archiveAdditions.Count; long numberOfAddedItems = 0; @@ -203,15 +217,48 @@ protected override void EndProcessing() { // Update progress var percentComplete = numberOfAddedItems / (float)numberOfAdditions * 100f; - progressRecord.StatusDescription = string.Format(Messages.ProgressDisplay, "{percentComplete:0.0}"); + var statusDescription = string.Format(Messages.ProgressDisplay, $"{percentComplete:0.0}"); + progressRecord = new ProgressRecord(activityId: 1, activity: "Compress-Archive", statusDescription: statusDescription); + progressRecord.PercentComplete = (int)percentComplete; WriteProgress(progressRecord); if (ShouldProcess(target: entry.FileSystemInfo.FullName, action: Messages.Add)) { - archive?.AddFileSystemEntry(entry); - // Write a verbose message saying this item was added to the archive - var addedItemMessage = string.Format(Messages.AddedItemToArchiveVerboseMessage, entry.FileSystemInfo.FullName); - WriteVerbose(addedItemMessage); + // Warn the user if the LastWriteTime of the file/directory is before 1980 + if (entry.FileSystemInfo.LastWriteTime.Year < 1980 && Format == ArchiveFormat.Zip) + { + WriteWarning(string.Format(Messages.LastWriteTimeBefore1980Warning, entry.FileSystemInfo.FullName)); + } + + // Use this to track of an exception that occurs when adding an entry to the archive + // so a non-terminating error can be reported + Exception? exception = null; + try + { + archive?.AddFileSystemEntry(entry); + // Write a verbose message saying this item was added to the archive + var addedItemMessage = string.Format(Messages.AddedItemToArchiveVerboseMessage, entry.FileSystemInfo.FullName); + WriteVerbose(addedItemMessage); + } + // This catches PathTooLongException as well + catch (IOException ioException) + { + exception = ioException; + } + catch (UnauthorizedAccessException unauthorizedAccessException) + { + exception = unauthorizedAccessException; + } + catch (System.NotSupportedException notSupportedException) + { + exception = notSupportedException; + } + + if (exception is not null) + { + var errorRecord = new ErrorRecord(exception, nameof(ErrorCode.ExceptionOccuredWhileAddingEntry), ErrorCategory.InvalidOperation, entry.EntryName); + WriteError(errorRecord); + } } // Keep track of number of items added to the archive numberOfAddedItems++; @@ -219,7 +266,8 @@ protected override void EndProcessing() // Once all items in the archive are processed, show progress as 100% // This code is here and not in the loop because we want it to run even if there are no items to add to the archive - progressRecord.StatusDescription = string.Format(Messages.ProgressDisplay, "100.0"); + progressRecord = new ProgressRecord(1, "Compress-Archive", string.Format(Messages.ProgressDisplay, "100.0")); + progressRecord.PercentComplete = 100; WriteProgress(progressRecord); } finally @@ -258,12 +306,12 @@ private void ValidateDestinationPath() // Throw an error if DestinationPath exists and the cmdlet is not in Update mode or Overwrite is not specified if (WriteMode == WriteMode.Create) { - errorCode = ErrorCode.ArchiveExistsAsDirectory; + errorCode = ErrorCode.DestinationExistsAsDirectory; } // Throw an error if the DestinationPath is a directory and the cmdlet is in Update mode else if (WriteMode == WriteMode.Update) { - errorCode = ErrorCode.ArchiveExistsAsDirectory; + errorCode = ErrorCode.DestinationExistsAsDirectory; } // Throw an error if the DestinationPath is the current working directory and the cmdlet is in Overwrite mode else if (WriteMode == WriteMode.Overwrite && DestinationPath == SessionState.Path.CurrentFileSystemLocation.ProviderPath) @@ -273,7 +321,7 @@ private void ValidateDestinationPath() // Throw an error if the DestinationPath is a directory with at 1 least item and the cmdlet is in Overwrite mode else if (WriteMode == WriteMode.Overwrite && Directory.GetFileSystemEntries(DestinationPath).Length > 0) { - errorCode = ErrorCode.ArchiveIsNonEmptyDirectory; + errorCode = ErrorCode.DestinationIsNonEmptyDirectory; } } // If DestinationPath is an existing file @@ -282,7 +330,7 @@ private void ValidateDestinationPath() // Throw an error if DestinationPath exists and the cmdlet is not in Update mode or Overwrite is not specified if (WriteMode == WriteMode.Create) { - errorCode = ErrorCode.ArchiveExists; + errorCode = ErrorCode.DestinationExists; } // Throw an error if the cmdlet is in Update mode but the archive is read only else if (WriteMode == WriteMode.Update && File.GetAttributes(DestinationPath).HasFlag(FileAttributes.ReadOnly)) diff --git a/src/Cmdlets/ExpandArchiveCommand.cs b/src/Cmdlets/ExpandArchiveCommand.cs new file mode 100644 index 0000000..1e69a8c --- /dev/null +++ b/src/Cmdlets/ExpandArchiveCommand.cs @@ -0,0 +1,369 @@ +using Microsoft.PowerShell.Archive.Localized; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.IO.Enumeration; +using System.Linq; +using System.Management.Automation; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerShell.Archive +{ + [Cmdlet("Expand", "Archive", SupportsShouldProcess = true)] + [OutputType(typeof(System.IO.FileSystemInfo))] + public class ExpandArchiveCommand: ArchiveCommandBase + { + private enum ParameterSet { + Path, + LiteralPath + } + + [Parameter(Position=0, Mandatory = true, ParameterSetName = nameof(ParameterSet.Path), ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [ValidateNotNullOrEmpty] + public string Path { get; set; } = String.Empty; + + [Parameter(Mandatory = true, ParameterSetName = nameof(ParameterSet.LiteralPath))] + [ValidateNotNullOrEmpty] + public string LiteralPath { get; set; } = String.Empty; + + [Parameter(Position = 2)] + [ValidateNotNullOrEmpty] + public string? DestinationPath { get; set; } + + [Parameter] + public ExpandArchiveWriteMode WriteMode { get; set; } = ExpandArchiveWriteMode.Expand; + + [Parameter()] + public ArchiveFormat? Format { get; set; } = null; + + [Parameter] + public SwitchParameter PassThru { get; set; } + + [Parameter] + [ValidateNotNullOrEmpty] + public string? Filter { get; set; } + + #region PrivateMembers + + private PathHelper _pathHelper; + + private bool _didCreateOutput; + + private string? _sourcePath; + + private bool _didCallProcessRecord; + + private WildcardPattern? _wildcardPattern; + + #endregion + + public ExpandArchiveCommand() + { + _pathHelper = new PathHelper(cmdlet: this); + } + + protected override void BeginProcessing() + { + + } + + protected override void ProcessRecord() + { + if (_didCallProcessRecord) { + // Throw a terminating error if ProcessRecord was called multiple times (if multiple passed were passed by pipeline) + var errorRecord = ErrorMessages.GetErrorRecord(ErrorCode.MultiplePathsSpecified); + ThrowTerminatingError(errorRecord); + } else { + _didCallProcessRecord = true; + } + } + + protected override void EndProcessing() + { + // Resolve Path or LiteralPath + ValidateSourcePath(ParameterSetName == nameof(ParameterSet.Path) ? Path : LiteralPath); + Debug.Assert(_sourcePath is not null); + + // Determine archive format based on sourcePath + Format = DetermineArchiveFormat(destinationPath: _sourcePath, archiveFormat: Format); + + try + { + // Get an archive from source path -- this is where we will switch between different types of archives + using IArchive archive = ArchiveFactory.GetArchive(format: Format ?? ArchiveFormat.Zip, archivePath: _sourcePath, archiveMode: ArchiveMode.Extract, compressionLevel: System.IO.Compression.CompressionLevel.NoCompression); + + if (DestinationPath is null) + { + // If DestinationPath was not specified, try to determine it automatically based on the source path + // We should do this here because the destination path depends on whether the archive contains a single top-level directory or not + DestinationPath = DetermineDestinationPath(archive); + } else { + // Resolve DestinationPath and validate it + DestinationPath = _pathHelper.GetUnresolvedPathFromPSProviderPath(path: DestinationPath, pathMustExist: false); + } + ValidateDestinationPath(); + Debug.Assert(DestinationPath is not null); + + // If the destination path is a file that needs to be overwriten, delete it + if (File.Exists(DestinationPath) && WriteMode == ExpandArchiveWriteMode.Overwrite) + { + if (ShouldProcess(target: DestinationPath, action: "Overwrite")) + { + File.Delete(DestinationPath); + System.IO.Directory.CreateDirectory(DestinationPath); + } + } + + // If the destination path does not exist, create it + if (!Directory.Exists(DestinationPath) && ShouldProcess(target: DestinationPath, action: "Create")) + { + System.IO.Directory.CreateDirectory(DestinationPath); + } + + long numberOfExpandedItems = 0; + + // Show a progress bar + if (Format == ArchiveFormat.Zip && archive is ZipArchive) { + var statusDescription = string.Format(Messages.ProgressDisplay, "0.0"); + var progressRecord = new ProgressRecord(1, "Expand-Archive", statusDescription); + progressRecord.PercentComplete = 0; + WriteProgress(progressRecord); + } + + // Write a verbose message saying "Expanding archive ..." + WriteVerbose(string.Format(Messages.ExpandingArchiveMessage, DestinationPath)); + + // If a value has been supplied to -Filter, create the object that will perform wildcard matching + if (Filter is not null) { + _wildcardPattern = new WildcardPattern(Filter); + } + + // Get the next entry in the archive and process it + var nextEntry = archive.GetNextEntry(); + while (nextEntry != null) + { + // If a value has been supplied to -Filter and the entry name of nextEntry does not match the filter + // skip the entry + if ((Filter is null) || (_wildcardPattern is not null && _wildcardPattern.IsMatch(nextEntry.Name))) + { + ProcessArchiveEntry(nextEntry); + } + + // Update progress info + numberOfExpandedItems++; + if (Format == ArchiveFormat.Zip && archive is not null && archive is ZipArchive zipArchive) { + var percentComplete = numberOfExpandedItems / (float)zipArchive.NumberOfEntries * 100f; + var statusDescription = string.Format(Messages.ProgressDisplay, $"{percentComplete:0.0}"); + var progressRecord = new ProgressRecord(1, "Expand-Archive", statusDescription); + progressRecord.PercentComplete = (int)percentComplete; + WriteProgress(progressRecord); + } + + nextEntry = archive.GetNextEntry(); + } + + // Show progress as 100% complete + // Show a progress bar + if (Format == ArchiveFormat.Zip && archive is ZipArchive) { + var statusDescription = string.Format(Messages.ProgressDisplay, "100.0"); + var progressRecord = new ProgressRecord(1, "Expand-Archive", statusDescription); + progressRecord.PercentComplete = 100; + WriteProgress(progressRecord); + } + + + } catch (System.UnauthorizedAccessException unauthorizedAccessException) + { + // TODO: Change this later to write an error + throw unauthorizedAccessException; + } + + // If PassThru is true, return a System.IO.DirectoryInfo object pointing to directory where archive expanded + if (PassThru) { + WriteObject(new DirectoryInfo(DestinationPath)); + } + } + + protected override void StopProcessing() + { + // Do clean up if the user abruptly stops execution + } + + #region PrivateMethods + + private void ProcessArchiveEntry(IEntry entry) + { + Debug.Assert(DestinationPath is not null); + + // The location of the entry post-expanding of the archive + string postExpandPath = GetPostExpansionPath(entryName: entry.Name, destinationPath: DestinationPath); + + // If postExpandPath has a terminating `/`, remove it (there is case where overwriting a file may fail because of this) + if (postExpandPath.EndsWith(System.IO.Path.DirectorySeparatorChar)) + { + postExpandPath = postExpandPath.Remove(postExpandPath.Length - 1); + } + + // If the entry name is invalid, write a non-terminating error and stop processing the entry + if (IsPathInvalid(postExpandPath)) + { + var errorRecord = ErrorMessages.GetErrorRecord(ErrorCode.InvalidPath, postExpandPath); + WriteError(errorRecord); + return; + } + + System.IO.FileSystemInfo postExpandPathInfo = new System.IO.FileInfo(postExpandPath); + + // Use this variable to keep track if there is a collision + // If the postExpandPath is a file, then no matter if the entry is a file or directory, it is a collision + bool hasCollision = postExpandPathInfo.Exists; + + if (System.IO.Directory.Exists(postExpandPath)) + { + var directoryInfo = new System.IO.DirectoryInfo(postExpandPath); + + // If the entry is a directory and postExpandPath is a directory, no collision occurs (because there is no need to overwrite directories) + hasCollision = !entry.IsDirectory; + + // If postExpandPath is an existing directory containing files and/or directories, then write an error + if (hasCollision && directoryInfo.GetFileSystemInfos().Length > 0) + { + var errorRecord = ErrorMessages.GetErrorRecord(ErrorCode.DestinationIsNonEmptyDirectory, postExpandPath); + WriteError(errorRecord); + return; + } + // If postExpandPath is the same as the working directory, then write an error + if (hasCollision && postExpandPath == SessionState.Path.CurrentFileSystemLocation.ProviderPath) + { + var errorRecord = ErrorMessages.GetErrorRecord(ErrorCode.CannotOverwriteWorkingDirectory, postExpandPath); + WriteError(errorRecord); + return; + } + postExpandPathInfo = directoryInfo; + } + + // Throw an error if the cmdlet is not in Overwrite mode but the postExpandPath exists + if (hasCollision && WriteMode != ExpandArchiveWriteMode.Overwrite) + { + var errorRecord = ErrorMessages.GetErrorRecord(ErrorCode.DestinationExists, postExpandPath); + WriteError(errorRecord); + return; + } + + string expandAction = hasCollision ? "Overwrite and Expand" : "Expand"; + if (ShouldProcess(target: postExpandPath, action: expandAction)) + { + if (hasCollision) + { + postExpandPathInfo.Delete(); + } + // Only expand the entry if there is a need to expand + // There is a need to expand unless the entry is a directory and the postExpandPath is also a directory + if (!(entry.IsDirectory && postExpandPathInfo.Attributes.HasFlag(FileAttributes.Directory) && postExpandPathInfo.Exists)) + { + entry.ExpandTo(postExpandPath); + } + } + } + + private void ValidateDestinationPath() + { + Debug.Assert(DestinationPath is not null); + + // Throw an error if DestinationPath exists and the cmdlet is not in Overwrite mode + if (File.Exists(DestinationPath) && WriteMode == ExpandArchiveWriteMode.Expand) { + var errorRecord = ErrorMessages.GetErrorRecord(errorCode: ErrorCode.DestinationExists, errorItem: DestinationPath); + ThrowTerminatingError(errorRecord); + } + + // Ensure sourcePath is not the same as the destination path when the cmdlet is in overwrite mode + // When the cmdlet is not in overwrite mode, other errors will be thrown when validating DestinationPath before it even gets to this line + if (_sourcePath == DestinationPath && WriteMode == ExpandArchiveWriteMode.Overwrite) + { + ErrorCode errorCode = (ParameterSetName == nameof(ParameterSet.Path)) ? ErrorCode.SamePathAndDestinationPath : ErrorCode.SameLiteralPathAndDestinationPath; + var errorRecord = ErrorMessages.GetErrorRecord(errorCode: errorCode, errorItem: DestinationPath); + ThrowTerminatingError(errorRecord); + } + } + + private void ValidateSourcePath(string path) + { + // Resolve path + if (ParameterSetName == nameof(ParameterSet.Path)) { + // Set nonexistentPaths to null because we don't want to capture any nonexistent paths + var resolvedPaths = _pathHelper.GetResolvedPathFromPSProviderPath(path: path, pathMustExist: true); + Debug.Assert(resolvedPaths is not null); + + // If the path resolves to multiple paths, throw a terminating error + if (resolvedPaths.Count > 1) { + var errorRecord = ErrorMessages.GetErrorRecord(ErrorCode.PathResolvedToMultiplePaths, path); + ThrowTerminatingError(errorRecord); + } + + // Set _sourcePath to the first & only path in resolvedPaths + _sourcePath = resolvedPaths[0]; + } else { + // Set nonexistentPaths to null because we don't want to capture any nonexistent paths + var resolvedPath = _pathHelper.GetUnresolvedPathFromPSProviderPath(path: path, pathMustExist: true); + Debug.Assert(resolvedPath is not null); + // Set _sourcePath to resolvedPath + _sourcePath = resolvedPath; + } + + // Throw a terminating error if _sourcePath is a directory + if (Directory.Exists(_sourcePath)) + { + var errorRecord = ErrorMessages.GetErrorRecord(errorCode: ErrorCode.DestinationExistsAsDirectory, errorItem: _sourcePath); + ThrowTerminatingError(errorRecord); + } + } + + private string GetPostExpansionPath(string entryName, string destinationPath) + { + // Normalize entry name - on Windows, replace forwardslash with backslash + string normalizedEntryName = entryName.Replace(System.IO.Path.AltDirectorySeparatorChar, System.IO.Path.DirectorySeparatorChar); + return System.IO.Path.Combine(destinationPath, normalizedEntryName); + } + + private bool IsPathInvalid(string path) + { + foreach (var invalidCharacter in System.IO.Path.GetInvalidPathChars()) + { + if (path.Contains(invalidCharacter)) + { + return true; + } + } + return false; + } + + // Used to determine what the DestinationPath should be when it is not specified + private string DetermineDestinationPath(IArchive archive) + { + var workingDirectory = SessionState.Path.CurrentFileSystemLocation.ProviderPath; + string? destinationDirectory = null; + + var filename = System.IO.Path.GetFileName(archive.Path); + // If filename does have an exension, remove the extension and set the filename minus extension as destinationDirectory + if (System.IO.Path.GetExtension(filename) != string.Empty) + { + int indexOfLastPeriod = filename.LastIndexOf('.'); + destinationDirectory = filename.Substring(0, indexOfLastPeriod); + } + + + if (destinationDirectory is null) + { + var errorRecord = ErrorMessages.GetErrorRecord(ErrorCode.CannotDetermineDestinationPath); + ThrowTerminatingError(errorRecord); + } + Debug.Assert(destinationDirectory is not null); + return System.IO.Path.Combine(workingDirectory, destinationDirectory); + } + + #endregion + } +} diff --git a/src/ErrorMessages.cs b/src/Error.cs similarity index 68% rename from src/ErrorMessages.cs rename to src/Error.cs index 80ec0c5..84adc58 100644 --- a/src/ErrorMessages.cs +++ b/src/Error.cs @@ -30,16 +30,20 @@ internal static string GetErrorMessage(ErrorCode errorCode) ErrorCode.PathNotFound => Messages.PathNotFoundMessage, ErrorCode.InvalidPath => Messages.InvalidPathMessage, ErrorCode.DuplicatePaths => Messages.DuplicatePathsMessage, - ErrorCode.ArchiveExists => Messages.ArchiveExistsMessage, - ErrorCode.ArchiveExistsAsDirectory => Messages.ArchiveExistsAsDirectoryMessage, + ErrorCode.DestinationExists => Messages.DestinationExistsMessage, + ErrorCode.DestinationExistsAsDirectory => Messages.DestinationExistsAsDirectoryMessage, ErrorCode.ArchiveReadOnly => Messages.ArchiveIsReadOnlyMessage, ErrorCode.ArchiveDoesNotExist => Messages.ArchiveDoesNotExistMessage, - ErrorCode.ArchiveIsNonEmptyDirectory => Messages.ArchiveIsNonEmptyDirectory, + ErrorCode.DestinationIsNonEmptyDirectory => Messages.DestinationIsNonEmptyDirectory, ErrorCode.SamePathAndDestinationPath => Messages.SamePathAndDestinationPathMessage, ErrorCode.SameLiteralPathAndDestinationPath => Messages.SameLiteralPathAndDestinationPathMessage, ErrorCode.InsufficientPermissionsToAccessPath => Messages.InsufficientPermssionsToAccessPathMessage, ErrorCode.OverwriteDestinationPathFailed => Messages.OverwriteDestinationPathFailed, ErrorCode.CannotOverwriteWorkingDirectory => Messages.CannotOverwriteWorkingDirectoryMessage, + ErrorCode.PathResolvedToMultiplePaths => Messages.PathResolvedToMultiplePathsMessage, + ErrorCode.CannotDetermineDestinationPath => Messages.CannotDetermineDestinationPath, + ErrorCode.MultiplePathsSpecified => Messages.MultiplePathsSpecifiedMessage, + ErrorCode.ArchiveIsNotUpdateable => Messages.ArchiveIsNotUpdateableMessage, _ => throw new ArgumentOutOfRangeException(nameof(errorCode)) }; } @@ -53,12 +57,12 @@ internal enum ErrorCode InvalidPath, // Used when when a path has been supplied to the cmdlet at least twice DuplicatePaths, - // Used when DestinationPath is an existing file - ArchiveExists, + // Used when DestinationPath is an existing file (used in Compress-Archive & Expand-Archive) + DestinationExists, // Used when DestinationPath is an existing directory - ArchiveExistsAsDirectory, + DestinationExistsAsDirectory, // Used when DestinationPath is a non-empty directory and Action Overwrite is specified - ArchiveIsNonEmptyDirectory, + DestinationIsNonEmptyDirectory, // Used when Compress-Archive cmdlet is in Update mode but the archive is read-only ArchiveReadOnly, // Used when DestinationPath does not exist and the Compress-Archive cmdlet is in Update mode @@ -72,6 +76,17 @@ internal enum ErrorCode // Used when the cmdlet could not overwrite DestinationPath OverwriteDestinationPathFailed, // Used when the user enters the working directory as DestinationPath and it is an existing folder and -WriteMode Overwrite is specified - CannotOverwriteWorkingDirectory + // Used in Compress-Archive, Expand-Archive + CannotOverwriteWorkingDirectory, + // Expand-Archive: used when a path resolved to multiple paths when only one was needed + PathResolvedToMultiplePaths, + // Expand-Archive: used when the DestinationPath could not be determined + CannotDetermineDestinationPath, + // Compress-Archive: Used when an exception occurs when adding an entry to an archive + ExceptionOccuredWhileAddingEntry, + // Expand:Archive: Used when multiple paths are passed by pipeline + MultiplePathsSpecified, + // Compress-Archive: Used when the archive does not support being updated + ArchiveIsNotUpdateable } } diff --git a/src/Formats/GzipArchive.cs b/src/Formats/GzipArchive.cs new file mode 100644 index 0000000..0838686 --- /dev/null +++ b/src/Formats/GzipArchive.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Formats.Tar; +using System.IO; +using System.IO.Compression; +using System.Diagnostics; + +namespace Microsoft.PowerShell.Archive +{ + internal class GzipArchive : IArchive + { + private bool _disposedValue; + + private readonly ArchiveMode _mode; + + private readonly string _path; + + private readonly FileStream _fileStream; + + private readonly CompressionLevel _compressionLevel; + + private bool _addedFile; + + private bool _didCallGetNextEntry; + + ArchiveMode IArchive.Mode => _mode; + + string IArchive.Path => _path; + + public bool IsUpdateable => false; + + public GzipArchive(string path, ArchiveMode mode, FileStream fileStream, CompressionLevel compressionLevel) + { + _mode = mode; + _path = path; + _fileStream = fileStream; + _compressionLevel = compressionLevel; + } + + public void AddFileSystemEntry(ArchiveAddition entry) + { + if (_mode == ArchiveMode.Extract) + { + throw new ArgumentException("Adding entries to the archive is not supported on Extract mode."); + } + if (_mode == ArchiveMode.Update) + { + throw new ArgumentException("Updating a Gzip file in not supported."); + } + if (_addedFile) + { + throw new ArgumentException("Adding a Gzip file in not supported."); + } + if (entry.FileSystemInfo.Attributes.HasFlag(FileAttributes.Directory)) { + throw new ArgumentException("Compressing directories is not supported"); + } + using var gzipCompressor = new GZipStream(_fileStream, _compressionLevel, leaveOpen: true); + using var fileToCopy = new FileStream(entry.FileSystemInfo.FullName, FileMode.Open, FileAccess.Read, FileShare.Read); + fileToCopy.CopyTo(gzipCompressor); + _addedFile = true; + } + + public IEntry? GetNextEntry() + { + // Gzip has no concept of entries + if (!_didCallGetNextEntry) { + _didCallGetNextEntry = true; + return new GzipArchiveEntry(this); + } + return null; + } + + private void Dispose(bool disposing) + { + if (!_disposedValue) + { + if (disposing) + { + // TODO: dispose managed state (managed objects) + _fileStream.Dispose(); + } + + // TODO: free unmanaged resources (unmanaged objects) and override finalizer + // TODO: set large fields to null + _disposedValue = true; + } + } + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + internal class GzipArchiveEntry : IEntry { + + private GzipArchive _gzipArchive; + + // Gzip has no concept of entries, so getting the entry name is not supported + string IEntry.Name => throw new NotSupportedException(); + + // Gzip does not compress directories, so this is always false + bool IEntry.IsDirectory => false; + + public GzipArchiveEntry(GzipArchive gzipArchive) + { + _gzipArchive = gzipArchive; + } + + void IEntry.ExpandTo(string destinationPath) + { + using var destinationFileStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None); + using var gzipDecompressor = new GZipStream(_gzipArchive._fileStream, CompressionMode.Decompress); + gzipDecompressor.CopyTo(destinationFileStream); + } + } + } +} diff --git a/src/Formats/TarArchive.cs b/src/Formats/TarArchive.cs new file mode 100644 index 0000000..6749446 --- /dev/null +++ b/src/Formats/TarArchive.cs @@ -0,0 +1,197 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Formats.Tar; +using System.IO; +using System.Diagnostics; + +namespace Microsoft.PowerShell.Archive +{ + internal class TarArchive : IArchive + { + private bool _disposedValue; + + private readonly ArchiveMode _mode; + + private readonly string _path; + + private TarWriter? _tarWriter; + + private TarReader? _tarReader; + + private readonly FileStream _fileStream; + + private FileStream? _copyStream; + + private string? _copyPath; + + ArchiveMode IArchive.Mode => _mode; + + string IArchive.Path => _path; + + public bool IsUpdateable => true; + + public TarArchive(string path, ArchiveMode mode, FileStream fileStream) + { + _mode = mode; + _path = path; + _fileStream = fileStream; + } + + public void AddFileSystemEntry(ArchiveAddition entry) + { + if (_mode == ArchiveMode.Extract) + { + throw new ArgumentException("Adding entries to the archive is not supported on Extract mode."); + } + + // If the archive is in Update mode, we want to update the archive by copying it to a new archive + // and then adding the entries to that archive + if (_mode == ArchiveMode.Update) + { + if (_copyStream is null) + { + CreateCopyStream(); + } + } + else if (_tarWriter is null) + { + _tarWriter = new TarWriter(_fileStream, TarEntryFormat.Pax, true); + } + + // Replace '\' with '/' + var entryName = entry.EntryName.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + Debug.Assert(_tarWriter is not null); + _tarWriter.WriteEntry(fileName: entry.FileSystemInfo.FullName, entryName: entryName); + } + + public IEntry? GetNextEntry() + { + // If _tarReader is null, create it + if (_tarReader is null) + { + _fileStream.Position = 0; + _tarReader = new TarReader(archiveStream: _fileStream, leaveOpen: true); + } + var entry = _tarReader.GetNextEntry(); + if (entry is null) + { + return null; + } + // Create and return a TarArchiveEntry, which is a wrapper around entry + return new TarArchiveEntry(entry); + } + + private void CreateCopyStream() { + // Determine an appropritae and random filenname + string copyName = Path.GetRandomFileName(); + + // Directory of the copy will be the same as the directory of the archive + string? directory = Path.GetDirectoryName(_path); + Debug.Assert(directory is not null); + + _copyPath = Path.Combine(directory, copyName); + _copyStream = new FileStream(_copyPath, FileMode.Create, FileAccess.ReadWrite, FileShare.None); + + // Create a tar reader that will read the contents of the archive + _tarReader = new TarReader(_fileStream, leaveOpen: false); + + // Create a tar writer that will write the contents of the archive to the copy + _tarWriter = new TarWriter(_copyStream, TarEntryFormat.Pax, true); + + var entry = _tarReader.GetNextEntry(); + while (entry is not null) + { + _tarWriter.WriteEntry(entry); + entry = _tarReader.GetNextEntry(); + } + } + + private void ReplaceArchiveWithCopy() { + Debug.Assert(_copyPath is not null); + // Delete the archive + File.Delete(_path); + // Move copy to archive path + File.Move(_copyPath, _path); + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposedValue) + { + if (disposing) + { + // TODO: dispose managed state (managed objects) + _tarWriter?.Dispose(); + _copyStream?.Dispose(); + _tarReader?.Dispose(); + _fileStream.Dispose(); + + if (_mode == ArchiveMode.Update) + { + ReplaceArchiveWithCopy(); + } + } + + // TODO: free unmanaged resources (unmanaged objects) and override finalizer + // TODO: set large fields to null + _disposedValue = true; + } + } + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + internal class TarArchiveEntry : IEntry { + + // Underlying object is System.Formats.Tar.TarEntry + private TarEntry _entry; + + public string Name => _entry.Name; + + public bool IsDirectory => _entry.EntryType == TarEntryType.Directory; + + public TarArchiveEntry(TarEntry entry) + { + _entry = entry; + } + + void IEntry.ExpandTo(string destinationPath) + { + // If the parent directory does not exist, create it + string? parentDirectory = Path.GetDirectoryName(destinationPath); + if (parentDirectory is not null && !Directory.Exists(parentDirectory)) + { + Directory.CreateDirectory(parentDirectory); + } + + var lastWriteTime = _entry.ModificationTime.LocalDateTime; + if (IsDirectory) + { + Directory.CreateDirectory(destinationPath); + Directory.SetLastWriteTime(destinationPath, lastWriteTime); + } else + { + _entry.ExtractToFile(destinationPath, overwrite: false); + File.SetLastWriteTime(destinationPath, lastWriteTime); + } + + SetFileAttributes(destinationPath); + } + + private void SetFileAttributes(string destinationPath) { + if (System.Environment.OSVersion.Platform == System.PlatformID.Unix + || System.Environment.OSVersion.Platform == System.PlatformID.MacOSX) { + + File.SetUnixFileMode(destinationPath, _entry.Mode); + } + } + } + } +} diff --git a/src/Formats/TarGzArchive.cs b/src/Formats/TarGzArchive.cs new file mode 100644 index 0000000..cc45250 --- /dev/null +++ b/src/Formats/TarGzArchive.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.IO.Compression; +using System.Diagnostics; + +namespace Microsoft.PowerShell.Archive +{ + internal class TarGzArchive : IArchive + { + + // Use a tar archive because .tar.gz file is a compressed tar file + private TarArchive? _tarArchive; + + private FileStream? _tarFileStream; + + private string? _tarFilePath; + + private string _path; + + private bool _disposedValue; + + private bool _didCallGetNextEntry; + + private readonly ArchiveMode _mode; + + private readonly FileStream _fileStream; + + private readonly CompressionLevel _compressionLevel; + + public string Path => _path; + + ArchiveMode IArchive.Mode => _mode; + + string IArchive.Path => _path; + + public bool IsUpdateable => false; + + public TarGzArchive(string path, ArchiveMode mode, FileStream fileStream, CompressionLevel compressionLevel) + { + _path = path; + _mode = mode; + _fileStream = fileStream; + _compressionLevel = compressionLevel; + } + + public void AddFileSystemEntry(ArchiveAddition entry) + { + if (_mode == ArchiveMode.Extract || _mode == ArchiveMode.Update) + { + throw new ArgumentException("Adding entries to the archive is not supported in extract or update mode"); + } + + if (_tarArchive is null) + { + // This will create a temp file and return the path + _tarFilePath = System.IO.Path.GetTempFileName(); + // When creating the stream, the file already exists + _tarFileStream = new FileStream(_tarFilePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + _tarArchive = new TarArchive(_tarFilePath, ArchiveMode.Create, _tarFileStream); + + } + _tarArchive.AddFileSystemEntry(entry); + } + + public IEntry? GetNextEntry() + { + if (_mode == ArchiveMode.Create) + { + throw new ArgumentException("Getting next entry is not supported when the archive is in Create mode"); + } + + if (_tarArchive is null) + { + // Create a Gzip archive + using var gzipArchive = new GzipArchive(_path, _mode, _fileStream, _compressionLevel); + // Where to put the tar file when expanding the tar.gz archive + _tarFilePath = System.IO.Path.GetTempFileName(); + // Expand the gzip portion + var entry = gzipArchive.GetNextEntry(); + Debug.Assert(entry is not null); + entry.ExpandTo(_tarFilePath); + // Create a TarArchive pointing to the newly expanded out tar file from the tar.gz file + FileStream tarFileStream = new FileStream(_tarFilePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + _tarArchive = new TarArchive(_tarFilePath, ArchiveMode.Extract, tarFileStream); + } + return _tarArchive?.GetNextEntry(); + } + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + // Performs gzip compression on _path + private void CompressArchive() { + Debug.Assert(_tarFilePath is not null); + _tarFileStream = new FileStream(_tarFilePath, FileMode.Open, FileAccess.Read); + using var gzipCompressor = new GZipStream(_fileStream, _compressionLevel, true); + _tarFileStream.CopyTo(gzipCompressor); + _tarFileStream.Dispose(); + } + + private void Dispose(bool disposing) + { + if (!_disposedValue) + { + if (disposing) + { + // Do this before compression because disposing a tar archive will add necessary EOF markers + _tarArchive?.Dispose(); + if (_mode == ArchiveMode.Create) { + CompressArchive(); + } + _fileStream.Dispose(); + if (_tarFilePath is not null) { + // Delete the tar file created in the process of created the tar.gz file + File.Delete(_tarFilePath); + } + } + + // TODO: free unmanaged resources (unmanaged objects) and override finalizer + // TODO: set large fields to null + _disposedValue = true; + } + } + } +} diff --git a/src/ZipArchive.cs b/src/Formats/ZipArchive.cs similarity index 60% rename from src/ZipArchive.cs rename to src/Formats/ZipArchive.cs index 4c74147..a406928 100644 --- a/src/ZipArchive.cs +++ b/src/Formats/ZipArchive.cs @@ -3,8 +3,8 @@ using System; using System.Collections.Generic; +using System.IO; using System.IO.Compression; -using System.Text; namespace Microsoft.PowerShell.Archive { @@ -16,19 +16,25 @@ internal class ZipArchive : IArchive private readonly string _archivePath; - private readonly System.IO.FileStream _archiveStream; + private readonly FileStream _archiveStream; private readonly System.IO.Compression.ZipArchive _zipArchive; - private readonly System.IO.Compression.CompressionLevel _compressionLevel; + private readonly CompressionLevel _compressionLevel; private const char ZipArchiveDirectoryPathTerminator = '/'; + private int _entryIndex; + ArchiveMode IArchive.Mode => _mode; string IArchive.Path => _archivePath; - public ZipArchive(string archivePath, ArchiveMode mode, System.IO.FileStream archiveStream, CompressionLevel compressionLevel) + public bool IsUpdateable => true; + + internal int NumberOfEntries => _zipArchive.Entries.Count; + + public ZipArchive(string archivePath, ArchiveMode mode, FileStream archiveStream, CompressionLevel compressionLevel) { _disposedValue = false; _mode = mode; @@ -36,6 +42,7 @@ public ZipArchive(string archivePath, ArchiveMode mode, System.IO.FileStream arc _archiveStream = archiveStream; _zipArchive = new System.IO.Compression.ZipArchive(stream: archiveStream, mode: ConvertToZipArchiveMode(_mode), leaveOpen: true); _compressionLevel = compressionLevel; + _entryIndex = -1; } // If a file is added to the archive when it already contains a folder with the same name, @@ -68,7 +75,18 @@ void IArchive.AddFileSystemEntry(ArchiveAddition addition) entryName += ZipArchiveDirectoryPathTerminator; } - _zipArchive.CreateEntry(entryName); + entryInArchive = _zipArchive.CreateEntry(entryName); + + // Set the last write time + if (entryInArchive != null) + { + var lastWriteTime = addition.FileSystemInfo.LastWriteTime; + if (lastWriteTime.Year < 1980 || lastWriteTime.Year > 2107) + { + lastWriteTime = new DateTime(1980, 1, 1, 0, 0, 0); + } + entryInArchive.LastWriteTime = lastWriteTime; + } } } else @@ -84,14 +102,24 @@ void IArchive.AddFileSystemEntry(ArchiveAddition addition) } } - string[] IArchive.GetEntries() + IEntry? IArchive.GetNextEntry() { - throw new NotImplementedException(); - } + if (_entryIndex < 0) + { + _entryIndex = 0; + } - void IArchive.Expand(string destinationPath) - { - throw new NotImplementedException(); + // If there are no entries, return null + if (_zipArchive.Entries.Count == 0 || _entryIndex >= _zipArchive.Entries.Count) + { + return null; + } + + // Create an ZipArchive.ZipArchiveEntry object + var nextEntry = _zipArchive.Entries[_entryIndex]; + _entryIndex++; + + return new ZipArchiveEntry(nextEntry); } private static System.IO.Compression.ZipArchiveMode ConvertToZipArchiveMode(ArchiveMode archiveMode) @@ -125,5 +153,43 @@ public void Dispose() Dispose(disposing: true); GC.SuppressFinalize(this); } + + internal class ZipArchiveEntry : IEntry + { + // Underlying object is System.IO.Compression.ZipArchiveEntry + + private System.IO.Compression.ZipArchiveEntry _entry; + + string IEntry.Name => _entry.FullName; + + bool IEntry.IsDirectory => _entry.FullName.EndsWith(System.IO.Path.AltDirectorySeparatorChar); + + void IEntry.ExpandTo(string destinationPath) + { + // If the parent directory does not exist, create it + string? parentDirectory = Path.GetDirectoryName(destinationPath); + if (parentDirectory is not null && !Directory.Exists(parentDirectory)) + { + Directory.CreateDirectory(parentDirectory); + } + + // .NET APIs differentiate a file and directory by a terminating `/` + // If the entry name ends with `/`, it is a directory + if (_entry.FullName.EndsWith(System.IO.Path.AltDirectorySeparatorChar)) + { + System.IO.Directory.CreateDirectory(destinationPath); + var lastWriteTime = _entry.LastWriteTime; + System.IO.Directory.SetLastWriteTime(destinationPath, lastWriteTime.DateTime); + } else + { + _entry.ExtractToFile(destinationPath); + } + } + + internal ZipArchiveEntry(System.IO.Compression.ZipArchiveEntry entry) + { + _entry = entry; + } + } } } diff --git a/src/IArchive.cs b/src/IArchive.cs index a785a5f..a94389a 100644 --- a/src/IArchive.cs +++ b/src/IArchive.cs @@ -7,25 +7,22 @@ namespace Microsoft.PowerShell.Archive { - internal interface IArchive: IDisposable + interface IArchive: IDisposable { + // Can the archive be updated? + public bool IsUpdateable { get; } + // Get what mode the archive is in - internal ArchiveMode Mode { get; } + public ArchiveMode Mode { get; } // Get the fully qualified path of the archive - internal string Path { get; } + public string Path { get; } // Add a file or folder to the archive. The entry name of the added item in the // will be ArchiveEntry.Name. // Throws an exception if the archive is in read mode. - internal void AddFileSystemEntry(ArchiveAddition entry); - - // Get the entries in the archive. - // Throws an exception if the archive is in create mode. - internal string[] GetEntries(); + public void AddFileSystemEntry(ArchiveAddition entry); - // Expands an archive to a destination folder. - // Throws an exception if the archive is not in read mode. - internal void Expand(string destinationPath); + public IEntry? GetNextEntry(); } } diff --git a/src/IEntry.cs b/src/IEntry.cs new file mode 100644 index 0000000..13307b7 --- /dev/null +++ b/src/IEntry.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerShell.Archive +{ + public interface IEntry + { + public string Name { get; } + + public bool IsDirectory { get; } + + public void ExpandTo(string destinationPath); + } +} diff --git a/src/Localized/Messages.resx b/src/Localized/Messages.resx index e230be9..42fe119 100644 --- a/src/Localized/Messages.resx +++ b/src/Localized/Messages.resx @@ -126,12 +126,6 @@ The archive {0} does not exist. - - The destination path {0} is a directory. - - - The destination path {0} already exists. - The archive {0} does not have an extension or an extension that matches the chosen archive format. @@ -141,9 +135,6 @@ The -Format was not specified, so the archive format was determined to be {0} based on its extension. - - The archive {0} cannot be overwritten because it is a non-empty directory. - The archive at {0} is read-only. @@ -153,6 +144,18 @@ Create + + The destination path {0} is a directory. + + + The destination path {0} already exists. + + + The destination {0} cannot be overwritten because it is a non-empty directory. + + + Create + The path(s) {0} have been specified more than once. @@ -171,13 +174,37 @@ The path {0} could not be found. + + The path {0} refers to multiple paths, but only one was expected. Perhaps this occured because multiple paths matched the wildcard (if applicable). + {0}% complete + + The path {0} refers to multiple paths, but only one was expected. Perhaps this occured because multiple paths matched the wildcard (if applicable). + A path supplied to -LiteralPath is the same as the path supplied to -DestinationPath. A path supplied to -Path is the same as the path supplied to -DestinationPath. + + The destination path cannot be determined automatically. Please use the -DestinationPath parameter to enter a destination path. + + + The last write time of the path {0} is before 1980. Since zip does not support dates before January 1, 1980, the last write time will be set to January 1, 1980. + + + Expanding archive {0} + + + Expanding archive entry {0} to destination {1} + + + Multiple paths were specified to the cmdlet, but only one is allowed. + + + The archive {0} could not be updated because the format does not support updates. + \ No newline at end of file diff --git a/src/Microsoft.PowerShell.Archive.csproj b/src/Microsoft.PowerShell.Archive.csproj index b8bd133..f8bb9f9 100644 --- a/src/Microsoft.PowerShell.Archive.csproj +++ b/src/Microsoft.PowerShell.Archive.csproj @@ -25,14 +25,6 @@ - - - True - True - Messages.resx - - - ResXFileCodeGenerator diff --git a/src/Microsoft.PowerShell.Archive.psd1 b/src/Microsoft.PowerShell.Archive.psd1 index a047d5a..2d79fbc 100644 --- a/src/Microsoft.PowerShell.Archive.psd1 +++ b/src/Microsoft.PowerShell.Archive.psd1 @@ -1,22 +1,36 @@ @{ -ModuleVersion = '2.0.1' -GUID = '06a335eb-dd10-4d25-b753-4f6a80163516' -Author = 'Microsoft' -CompanyName = 'Microsoft' -Copyright = '(c) Microsoft. All rights reserved.' -Description = 'PowerShell module for creating and expanding archives.' -PowerShellVersion = '7.2.5' -NestedModules = @('Microsoft.PowerShell.Archive.dll') -CmdletsToExport = @('Compress-Archive') -PrivateData = @{ - PSData = @{ - Tags = @('Archive', 'Zip', 'Compress') - ProjectUri = 'https://github.com/PowerShell/Microsoft.PowerShell.Archive' - ReleaseNotes = @' - ## 2.0.1-preview1 - - Rewrote Compress-Archive cmdlet in C# + ModuleVersion = '2.0.1' + GUID = '06a335eb-dd10-4d25-b753-4f6a80163516' + Author = 'Microsoft' + CompanyName = 'Microsoft' + Copyright = '(c) Microsoft. All rights reserved.' + Description = 'PowerShell module for creating and expanding archives.' + PowerShellVersion = '7.3.0' + NestedModules = @('Microsoft.PowerShell.Archive.dll') + CmdletsToExport = @('Compress-Archive', 'Expand-Archive') + PrivateData = @{ + PSData = @{ + Tags = @('Archive', 'Zip', 'Compress') + ProjectUri = 'https://github.com/PowerShell/Microsoft.PowerShell.Archive' + LicenseUri = 'https://go.microsoft.com/fwlink/?linkid=2203619' + ReleaseNotes = @' + ## 2.0.1-preview2 + - Rewrote `Expand-Archive` cmdlet in C# + - Added `-Format` parameter to `Expand-Archive` + - Added `-WriteMode` parameter to `Expand-Archive` + - Added support for zip64 to `Expand-Archive` + - Fixed a bug where the entry names of files in a directory would not be correct when compressing an archive + - `Compress-Archive` by default skips writing an entry to an archive if an error occurs while doing so + ## 2.0.1-preview1 + - Rewrote `Compress-Archive` cmdlet in C# + - Added `-Format` parameter to `Compress-Archive` + - Added `-WriteMode` parameter to `Compress-Archive` + - Added support for relative path structure preservating when paths relative to the working directory are specified to `-Path` or `-LiteralPath` in `Compress-Archive` + - Added support for zip64 to `Compress-Archive` + - Fixed a bug where empty directories would not be compressed + - Fixed a bug where an abrupt stop when compressing empty directories would not delete the newly created archive '@ - Prerelease = 'preview1' + Prerelease = 'preview2' + } } -} -} +} \ No newline at end of file diff --git a/src/PathHelper.cs b/src/PathHelper.cs index b5262ed..a799ff5 100644 --- a/src/PathHelper.cs +++ b/src/PathHelper.cs @@ -16,6 +16,18 @@ internal class PathHelper private const string FileSystemProviderName = "FileSystem"; + internal bool Flatten { get; set; } + + internal string? Filter { get; set; } + + internal WildcardPattern? _wildCardPattern; + + // These are the paths to add + internal HashSet? _fullyQualifiedPaths; + + // This is used only when flattening to track entry names, so duplicate entry names can be removed + internal HashSet? _entryNames; + internal PathHelper(PSCmdlet cmdlet) { _cmdlet = cmdlet; @@ -23,14 +35,25 @@ internal PathHelper(PSCmdlet cmdlet) internal List GetArchiveAdditions(HashSet fullyQualifiedPaths) { + if (Filter is not null) + { + _wildCardPattern = new WildcardPattern(Filter); + } + if (Flatten) + { + _entryNames = new HashSet(); + } List archiveAdditions = new List(fullyQualifiedPaths.Count); foreach (var path in fullyQualifiedPaths) { // Assume each path is valid, fully qualified, and existing Debug.Assert(Path.Exists(path)); Debug.Assert(Path.IsPathFullyQualified(path)); - AddAdditionForFullyQualifiedPath(path, archiveAdditions); + AddAdditionForFullyQualifiedPath(path, archiveAdditions, entryName: null, parentMatchesFilter: false); } + + // If the mode is flatten, there could be + return archiveAdditions; } @@ -40,7 +63,7 @@ internal List GetArchiveAdditions(HashSet fullyQualifie /// The fully qualified path /// The list where to add the ArchiveAddition object for the path /// If true, relative path structure will be preserved. If false, relative path structure will NOT be preserved. - private void AddAdditionForFullyQualifiedPath(string path, List additions) + private void AddAdditionForFullyQualifiedPath(string path, List additions, string? entryName, bool parentMatchesFilter) { Debug.Assert(Path.Exists(path)); FileSystemInfo fileSystemInfo; @@ -60,15 +83,43 @@ private void AddAdditionForFullyQualifiedPath(string path, List fileSystemInfo = new FileInfo(path); } - // Get the entry name of the file or directory in the archive - // The cmdlet will preserve the directory structure as long as the path is relative to the working directory - var entryName = GetEntryName(fileSystemInfo, out bool doesPreservePathStructure); - additions.Add(new ArchiveAddition(entryName: entryName, fileSystemInfo: fileSystemInfo)); + bool doesMatchFilter = true; + if (!parentMatchesFilter && _wildCardPattern is not null) + { + doesMatchFilter = _wildCardPattern.IsMatch(fileSystemInfo.Name); + } + + // if entryName, then set it as the entry name of the file or directory in the archive + // The entry name will preserve the directory structure as long as the path is relative to the working directory + if (entryName is null) + { + entryName = GetEntryName(fileSystemInfo, out bool doesPreservePathStructure); + } + + + // Number of elements in additions before adding this item and its descendents if it is a directory + int initialAdditions = additions.Count; // Recurse through the child items and add them to additions - if (fileSystemInfo.Attributes.HasFlag(FileAttributes.Directory) && fileSystemInfo is DirectoryInfo directoryInfo) { - AddDescendentEntries(directoryInfo: directoryInfo, additions: additions, shouldPreservePathStructure: doesPreservePathStructure); + if (fileSystemInfo.Attributes.HasFlag(FileAttributes.Directory) && fileSystemInfo is DirectoryInfo directoryInfo) + { + AddDescendentEntries(directoryInfo, additions, doesMatchFilter); + } + + // Number of elements in additions after adding this item's descendents (if directory) + int finalAdditions = additions.Count; + + // If the item being added is a file, finalAdditions - initialAdditions = 0 + // If the item being added is a directory and does not have any descendent files that match the filter, finalAdditions - initialAdditions = 0 + // If the item being added is a directory and has descendent files that match the filter, finalAdditions > initialAdditions + + if (doesMatchFilter || (!doesMatchFilter && finalAdditions - initialAdditions > 0)) { + if (!Flatten || (Flatten && fileSystemInfo is not DirectoryInfo && _entryNames is not null && _entryNames.Add(entryName))) + { + additions.Add(new ArchiveAddition(entryName: entryName, fileSystemInfo: fileSystemInfo)); + } } + } /// @@ -77,28 +128,43 @@ private void AddAdditionForFullyQualifiedPath(string path, List /// A fully qualifed path referring to a directory /// Where the ArchiveAddtion object for each child item of the directory will be added /// See above - private void AddDescendentEntries(System.IO.DirectoryInfo directoryInfo, List additions, bool shouldPreservePathStructure) + private void AddDescendentEntries(System.IO.DirectoryInfo directoryInfo, List additions, bool parentMatchesFilter) { try { // pathPrefix is used to construct the entry names of the descendents of the directory var pathPrefix = GetPrefixForPath(directoryInfo: directoryInfo); - foreach (var childFileSystemInfo in directoryInfo.EnumerateFileSystemInfos("*", SearchOption.AllDirectories)) + // If the parent directory matches the filter, then we don't have to check if each individual descendent of the directory + // matches the filter. + // This reduces the total number of method calls + SearchOption searchOption = parentMatchesFilter ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + foreach (var childFileSystemInfo in directoryInfo.EnumerateFileSystemInfos("*", searchOption)) { string entryName; - // If the cmdlet should preserve the path structure, then use the relative path - if (shouldPreservePathStructure) + if (Flatten) { - entryName = GetEntryName(childFileSystemInfo, out bool doesPreservePathStructure); - Debug.Assert(doesPreservePathStructure); - } - // Otherwise, get the entry name using the prefix - else + entryName = childFileSystemInfo.Name; + } else { entryName = GetEntryNameUsingPrefix(path: childFileSystemInfo.FullName, prefix: pathPrefix); + } + + // Add an entry for each descendent of the directory + if (parentMatchesFilter) + { + // If the parent directory matches the filter, all its contents are included in the archive + // Just add the entry for each child without needing to check whether the child matches the filter + if (!Flatten || (Flatten && childFileSystemInfo is not DirectoryInfo && _entryNames is not null && _entryNames.Add(entryName))) + { + additions.Add(new ArchiveAddition(entryName: entryName, fileSystemInfo: childFileSystemInfo)); + } + } + else + { + // If the parent directory does not match the filter, we want to call this function + // because this function will check if the name of the child matches the filter and if so, will add it + AddAdditionForFullyQualifiedPath(childFileSystemInfo.FullName, additions, entryName, parentMatchesFilter: false); } - // Add an entry for each descendent of the directory - additions.Add(new ArchiveAddition(entryName: entryName, fileSystemInfo: childFileSystemInfo)); } } // Write a non-terminating error if a securityException occurs @@ -121,11 +187,14 @@ private string GetEntryName(FileSystemInfo fileSystemInfo, out bool doesPreserve string entryName; doesPreservePathStructure = false; // If the path is relative to the current working directory, return the relative path as name - if (TryGetPathRelativeToCurrentWorkingDirectory(path: fileSystemInfo.FullName, out var relativePath)) + if (!Flatten && TryGetPathRelativeToCurrentWorkingDirectory(path: fileSystemInfo.FullName, out var relativePath)) { Debug.Assert(relativePath is not null); doesPreservePathStructure = true; entryName = relativePath; + + // In case the relative path contains parent directories that have not been entered by the user, + // check for these paths and add them } // Otherwise, return the name of the directory or file else @@ -201,18 +270,83 @@ private static string GetPrefixForPath(System.IO.DirectoryInfo directoryInfo) private bool TryGetPathRelativeToCurrentWorkingDirectory(string path, out string? relativePathToWorkingDirectory) { Debug.Assert(!string.IsNullOrEmpty(path)); - string? workingDirectoryRoot = Path.GetPathRoot(_cmdlet.SessionState.Path.CurrentFileSystemLocation.Path); + string workingDirectory = _cmdlet.SessionState.Path.CurrentFileSystemLocation.ProviderPath; + string? workingDirectoryRoot = Path.GetPathRoot(workingDirectory); string? pathRoot = Path.GetPathRoot(path); if (workingDirectoryRoot != pathRoot) { relativePathToWorkingDirectory = null; return false; } - string relativePath = Path.GetRelativePath(_cmdlet.SessionState.Path.CurrentFileSystemLocation.Path, path); + string relativePath = Path.GetRelativePath(workingDirectory, path); relativePathToWorkingDirectory = relativePath.Contains("..") ? null : relativePath; return relativePathToWorkingDirectory is not null; } - internal System.Collections.ObjectModel.Collection? GetResolvedPathFromPSProviderPath(string path, HashSet nonexistentPaths) { + // Adds the parent directories in a path to the list of fully qualified paths + private void AddParentDirectoriesToFullyQualifiedPaths(string path) { + + } + + internal System.Collections.ObjectModel.Collection? GetResolvedPathFromPSProviderPath(string path, bool pathMustExist) { + // Keep the exception at the top, then when an error occurs, use the exception to create an ErrorRecord + Exception? exception = null; + System.Collections.ObjectModel.Collection? fullyQualifiedPaths = null; + try + { + // Resolve path + var resolvedPaths = _cmdlet.SessionState.Path.GetResolvedProviderPathFromPSPath(path, out var providerInfo); + + // If the path is from the filesystem, set it to fullyQualifiedPaths so it can be returned + // Otherwise, create an exception so an error will be written + if (providerInfo.Name != FileSystemProviderName) + { + var exceptionMsg = ErrorMessages.GetErrorMessage(ErrorCode.InvalidPath); + exception = new ArgumentException(exceptionMsg); + } else { + fullyQualifiedPaths = resolvedPaths; + } + } + catch (System.Management.Automation.ProviderNotFoundException providerNotFoundException) + { + exception = providerNotFoundException; + } + catch (System.Management.Automation.DriveNotFoundException driveNotFoundException) + { + exception = driveNotFoundException; + } + catch (System.Management.Automation.ProviderInvocationException providerInvocationException) + { + exception = providerInvocationException; + } + catch (System.Management.Automation.PSNotSupportedException notSupportedException) + { + exception = notSupportedException; + } + catch (System.Management.Automation.PSInvalidOperationException invalidOperationException) + { + exception = invalidOperationException; + } + // If a path can't be found, add it the set of non-existant paths + catch (System.Management.Automation.ItemNotFoundException itemNotFoundException) + { + if (pathMustExist) { + var errorRecord = ErrorMessages.GetErrorRecord(ErrorCode.PathNotFound); + _cmdlet.ThrowTerminatingError(errorRecord); + } + } + + // If an exception was caught, write a non-terminating error + if (exception is not null) + { + var errorRecord = new ErrorRecord(exception: exception, errorId: nameof(ErrorCode.InvalidPath), errorCategory: ErrorCategory.InvalidArgument, + targetObject: path); + _cmdlet.ThrowTerminatingError(errorRecord); + } + + return fullyQualifiedPaths; + } + + internal System.Collections.ObjectModel.Collection? GetResolvedPathFromPSProviderPathWhileCapturingNonexistentPaths(string path, HashSet nonexistentPaths) { // Keep the exception at the top, then when an error occurs, use the exception to create an ErrorRecord Exception? exception = null; System.Collections.ObjectModel.Collection? fullyQualifiedPaths = null; @@ -251,8 +385,8 @@ private bool TryGetPathRelativeToCurrentWorkingDirectory(string path, out string { exception = invalidOperationException; } - // If a path can't be found, write an error - catch (System.Management.Automation.ItemNotFoundException) + // If a path can't be found, add it the set of non-existant paths + catch (System.Management.Automation.ItemNotFoundException itemNotFoundException) { nonexistentPaths.Add(path); } @@ -268,12 +402,13 @@ private bool TryGetPathRelativeToCurrentWorkingDirectory(string path, out string return fullyQualifiedPaths; } - // Resolves a literal path. Does not check if the path exists. - // If an exception occurs with a provider, it throws a terminating error - internal string? GetUnresolvedPathFromPSProviderPath(string path) { + // Resolves a literal path. If it does not exist, it adds the path to nonexistentPaths. + // If an exception occurs with a provider, it writes a non-terminating error + internal string? GetUnresolvedPathFromPSProviderPath(string path, bool pathMustExist) { // Keep the exception at the top, then when an error occurs, use the exception to create an ErrorRecord Exception? exception = null; string? fullyQualifiedPath = null; + ErrorCode errorCode = ErrorCode.InvalidPath; try { // Resolve path @@ -286,6 +421,12 @@ private bool TryGetPathRelativeToCurrentWorkingDirectory(string path, out string var exceptionMsg = ErrorMessages.GetErrorMessage(ErrorCode.InvalidPath); exception = new ArgumentException(exceptionMsg); } + // If the path does not exist, create an exception + else if (pathMustExist && !Path.Exists(resolvedPath)) { + errorCode = ErrorCode.PathNotFound; + var exceptionMsg = ErrorMessages.GetErrorMessage(errorCode); + throw new ItemNotFoundException(exceptionMsg); + } else { fullyQualifiedPath = resolvedPath; @@ -310,12 +451,14 @@ private bool TryGetPathRelativeToCurrentWorkingDirectory(string path, out string catch (System.Management.Automation.PSInvalidOperationException invalidOperationException) { exception = invalidOperationException; + } catch (System.Management.Automation.ItemNotFoundException itemNotFoundException) { + exception = itemNotFoundException; } - // If an exception was caught, write a non-terminating error of throwError == false. Otherwise, throw a terminating errror + // If an exception was caught, write a non-terminating error if (exception is not null) { - var errorRecord = new ErrorRecord(exception: exception, errorId: nameof(ErrorCode.InvalidPath), errorCategory: ErrorCategory.InvalidArgument, + var errorRecord = new ErrorRecord(exception: exception, errorId: errorCode.ToString(), errorCategory: ErrorCategory.InvalidArgument, targetObject: path); _cmdlet.ThrowTerminatingError(errorRecord); } @@ -325,7 +468,7 @@ private bool TryGetPathRelativeToCurrentWorkingDirectory(string path, out string // Resolves a literal path. If it does not exist, it adds the path to nonexistentPaths. // If an exception occurs with a provider, it writes a non-terminating error - internal string? GetUnresolvedPathFromPSProviderPath(string path, HashSet nonexistentPaths) { + internal string? GetUnresolvedPathFromPSProviderPathWhileCapturingNonexistentPaths(string path, HashSet nonexistentPaths) { // Keep the exception at the top, then when an error occurs, use the exception to create an ErrorRecord Exception? exception = null; string? fullyQualifiedPath = null; @@ -341,7 +484,7 @@ private bool TryGetPathRelativeToCurrentWorkingDirectory(string path, out string var exceptionMsg = ErrorMessages.GetErrorMessage(ErrorCode.InvalidPath); exception = new ArgumentException(exceptionMsg); } - // If the path does not exist, create an exception + // If the path does not exist, capture it else if (!Path.Exists(resolvedPath)) { nonexistentPaths.Add(resolvedPath); } diff --git a/src/TarArchive.cs b/src/TarArchive.cs deleted file mode 100644 index da25815..0000000 --- a/src/TarArchive.cs +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System; -using System.Collections.Generic; -using System.Formats.Tar; -using System.IO; -using System.Text; - -namespace Microsoft.PowerShell.Archive -{ - internal class TarArchive : IArchive - { - private bool disposedValue; - - private readonly ArchiveMode _mode; - - private readonly string _path; - - private readonly TarWriter _tarWriter; - - private readonly FileStream _fileStream; - - ArchiveMode IArchive.Mode => _mode; - - string IArchive.Path => _path; - - public TarArchive(string path, ArchiveMode mode, FileStream fileStream) - { - _mode = mode; - _path = path; - _tarWriter = new TarWriter(archiveStream: fileStream, format: TarEntryFormat.Pax, leaveOpen: false); - _fileStream = fileStream; - } - - void IArchive.AddFileSystemEntry(ArchiveAddition entry) - { - _tarWriter.WriteEntry(fileName: entry.FileSystemInfo.FullName, entryName: entry.EntryName); - } - - string[] IArchive.GetEntries() - { - throw new NotImplementedException(); - } - - void IArchive.Expand(string destinationPath) - { - throw new NotImplementedException(); - } - - protected virtual void Dispose(bool disposing) - { - if (!disposedValue) - { - if (disposing) - { - // TODO: dispose managed state (managed objects) - _tarWriter.Dispose(); - _fileStream.Dispose(); - } - - // TODO: free unmanaged resources (unmanaged objects) and override finalizer - // TODO: set large fields to null - disposedValue = true; - } - } - - public void Dispose() - { - // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - Dispose(disposing: true); - GC.SuppressFinalize(this); - } - } -} diff --git a/src/WriteMode.cs b/src/WriteMode.cs index 00efad3..5a1ae75 100644 --- a/src/WriteMode.cs +++ b/src/WriteMode.cs @@ -13,4 +13,10 @@ public enum WriteMode Update, Overwrite } + + public enum ExpandArchiveWriteMode + { + Expand, + Overwrite + } }