powershellzipcompress-archive

`Compress-Archive` with the `-Update` parameter erases the archive when given an empty list


I need to create a ZIP archive from a directory (data) with the following properties:

The idea is to create an archive from the initial file (.foo) and subsequently -Update the archive with the remaining files. Given this directory structure

/
├─ + data
│  ├─ .foo
│  └─ bar
└─ + script
   └─ make.ps1

the following script accomplishes that.

script/make.ps1:

$root_dir = Join-Path -Path $PSScriptRoot -ChildPath '..' -Resolve
$out_dir = $root_dir + '\out'

if (!(Test-Path -Path $out_dir)) {
    [void](New-Item -Path $out_dir -ItemType 'Directory')
}

$out_file = $out_dir + '\a.zip'
$data_dir = $root_dir + '\data'

# Create archive from a single file
Compress-Archive -Path ($data_dir + '\.foo') -DestinationPath $out_file -Force -CompressionLevel 'NoCompression'

# Add remaining files to archive
Get-ChildItem -Path ($data_dir + '\*') -Exclude '.foo' -File
| Compress-Archive -DestinationPath $out_file -Update

However, if I remove bar from the data directory, the output directory turns up empty. I would have expected an archive with just the .foo file, but apparently Compress-Archive with the -Update parameter on an empty pipeline erases the archive altogether.

Clearly, I don't understand how pipelines work in PowerShell. What's causing the issue, and how do I solve the issue (i.e., have Compress-Archive ignore an empty input)?


Solution

  • It isn't your fault, the function is just overall badly designed and this can probably be considered a bug (not sure if an issue exists for it).


    EDIT

    This is a bug reported in #112 and #142, thanks to mclayton for finding them.


    An easier way to repro the issue:

    New-Item test.txt | Compress-Archive -DestinationPath test.zip
    $archive = Get-Item test.zip
    & { } | Compress-Archive -DestinationPath test.zip -Update # empty pipeline here
    $archive.Refresh()
    $archive.Exists # False
    

    Because of the way Compress-Archive is designed, after opening the ZipArchive it holds all content in a memory stream then when finishes compression it deletes the destination and writes the stream to a file... inspecting the source, this is likely where the issue originates (presumably, this function is an incredible mess):

    try {
        # StopProcessing is not available in Script cmdlets. However the pipeline execution
        # is terminated when ever 'CTRL + C' is entered by user to terminate the cmdlet execution.
        # The finally block is executed whenever pipeline is terminated.
        # $isArchiveFileProcessingComplete variable is used to track if 'CTRL + C' is entered by the
        # user.
        $isArchiveFileProcessingComplete = $false
        $numberOfItemsArchived = CompressArchiveHelper $sourcePath $DestinationPath $CompressionLevel $Update
        $isArchiveFileProcessingComplete = $true
    }
    finally {
        # The $isArchiveFileProcessingComplete would be set to $false if user has typed 'CTRL + C' to
        # terminate the cmdlet execution or if an unhandled exception is thrown.
        # $numberOfItemsArchived contains the count of number of files or directories add to the archive file.
        # If the newly created archive file is empty then we delete it as it's not usable.
        if (($isArchiveFileProcessingComplete -eq $false) -or ($numberOfItemsArchived -eq 0)) {
            $DeleteArchiveFileMessage = ($LocalizedData.DeleteArchiveFile -f $DestinationPath)
            Write-Verbose $DeleteArchiveFileMessage
    
            # delete the partial archive file created. <--- WHAT???!!!
            if (Test-Path $DestinationPath) {
                Remove-Item -LiteralPath $DestinationPath -Force -Recurse -ErrorAction SilentlyContinue
            }
        }
    

    So, it looks like if CompressArchiveHelper outputs 0 they decided that the destination file should be deleted, because who knows ? If you want to dig deeper into this helper function you can get the source like this:

    & (Get-Module Microsoft.PowerShell.Archive) { $function:CompressArchiveHelper }
    

    Be aware, the module is filled with these helpers and it's not gonna be fun.

    In addition to this bug, this function has other issues like the one mentioned in Description:

    The Compress-Archive cmdlet uses the System.IO.Compression.ZipArchive API to compress files. The API limits the maximum file size to 2GB. For more information, see System.IO.Compression.ZipArchive.

    This is not true, the API allows creating archives of any size. A limitation does exist but only for appending to an existing archive bigger than 2GB in Update mode.

    To solve the issue you can add a condition checking if files were found, and only then compress them:

    $items = Get-ChildItem -Path $data_dir\* -Exclude '.foo' -File
    if ($items) {
        $items | Compress-Archive -DestinationPath $out_file -Update
    }
    

    Alternatively, if you want to avoid both issues, you can use Compress-ZipArchive, installed through the PowerShell Gallery with:

    Install-Module PSCompression -Scope CurrentUser
    

    Then your code as is works fine and no file will get deleted, simply change the cmdlet:

    Get-ChildItem -Path $data_dir\* -Exclude '.foo' -File
    | Compress-ZipArchive -Destination $out_file -Update
    

    You can also technically use just the cmdlet since it has an -Exclude parameter:

    Compress-ZipArchive $data_dir\* -Destination $out_file -Exclude *.foo