diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..b777aaa8 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,19 @@ +name: Linter + +on: [push, pull_request] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout rubyzip code + uses: actions/checkout@v4 + + - name: Install and set up ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.0' + bundler-cache: true + + - name: Rubocop + run: bundle exec rubocop diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..a09e80d8 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,78 @@ +name: Tests + +on: [push, pull_request] + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu] + ruby: ['3.0', '3.1', '3.2', '3.3', '3.4', head, jruby, jruby-head, truffleruby, truffleruby-head] + include: + - { os: macos , ruby: '3.0' } + - { os: windows, ruby: '3.0' } + # head builds + - { os: windows, ruby: ucrt } + - { os: windows, ruby: mswin } + runs-on: ${{ matrix.os }}-latest + continue-on-error: ${{ endsWith(matrix.ruby, 'head') || matrix.os == 'windows' }} + steps: + - name: Checkout rubyzip code + uses: actions/checkout@v4 + + - name: Install and set up ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: latest + bundler-cache: true + + - name: Run the tests + env: + RUBYOPT: -v + JRUBY_OPTS: --debug + FULL_ZIP64_TEST: 1 + run: bundle exec rake + + - name: Coveralls + if: matrix.os == 'ubuntu' && !endsWith(matrix.ruby, 'head') + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.github_token }} + flag-name: ${{ matrix.ruby }} + parallel: true + + test-yjit: + strategy: + fail-fast: false + matrix: + os: [ubuntu, macos] + ruby: ['3.1', '3.2', '3.3', '3.4', head] + runs-on: ${{ matrix.os }}-latest + continue-on-error: true + steps: + - name: Checkout rubyzip code + uses: actions/checkout@v4 + + - name: Install and set up ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + + - name: Run the tests + env: + RUBYOPT: --enable-yjit -v + FULL_ZIP64_TEST: 1 + run: bundle exec rake + + finish: + needs: test + runs-on: ubuntu-latest + steps: + - name: Coveralls Finished + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.github_token }} + parallel-finished: true diff --git a/.gitignore b/.gitignore index f870b5cb..17c0cbd8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,9 @@ Gemfile.lock +samples/*.zip +samples/*.zip.* +samples/zipdialogui.rb coverage +html/ pkg/ .ruby-gemset .ruby-version diff --git a/.rubocop.yml b/.rubocop.yml index 3fd8ffae..49bed3dd 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,9 +1,20 @@ +require: + - rubocop-performance + - rubocop-rake + inherit_from: .rubocop_todo.yml # Set this to the minimum supported ruby in the gemspec. Otherwise # we get errors if our ruby version doesn't match. AllCops: - TargetRubyVersion: 2.4 + SuggestExtensions: false + TargetRubyVersion: 3.0 + NewCops: enable + +# Allow this in this file because adding the extra lines is pointless. +Layout/EmptyLineBetweenDefs: + Exclude: + - 'lib/zip/errors.rb' Layout/HashAlignment: EnforcedHashRocketStyle: table @@ -12,10 +23,13 @@ Layout/HashAlignment: # Set a workable line length, given the current state of the code, # and turn off for the tests. Layout/LineLength: - Max: 135 + Max: 100 Exclude: - 'test/**/*.rb' +Lint/EmptyClass: + Enabled: false + # In some cases we just need to catch an exception, rather than # actually handle it. Allow the tests to make use of this shortcut. Lint/SuppressedException: @@ -24,7 +38,7 @@ Lint/SuppressedException: - 'test/**/*.rb' # Allow this "useless" test, as we are testing <=> here. -Lint/UselessComparison: +Lint/BinaryOperatorWithIdenticalOperands: Exclude: - 'test/entry_test.rb' @@ -35,9 +49,10 @@ Metrics/AbcSize: Exclude: - 'test/**/*.rb' -# Turn block length metrics off for the tests. +# Turn block length metrics off for the tests and gemspec. Metrics/BlockLength: Exclude: + - 'rubyzip.gemspec' - 'test/**/*.rb' # Turn class length metrics off for the tests. @@ -50,10 +65,31 @@ Metrics/MethodLength: Exclude: - 'test/**/*.rb' +# These tests are just better with snake_case numbers. +Naming/VariableNumber: + Exclude: + - 'test/file_permissions_test.rb' + +# Need to allow accessors in Entry to be separated for doc purposes. +Style/AccessorGrouping: + Exclude: + - 'lib/zip/entry.rb' + # Set a consistent way of checking types. Style/ClassCheck: EnforcedStyle: kind_of? +Style/HashEachMethods: + Enabled: true + +Style/HashTransformValues: + Enabled: true + +# Allow non-default behaviour for Zip. +Style/ModuleFunction: + Exclude: + - 'lib/zip.rb' + # Allow this multi-line block chain as it actually reads better # than the alternatives. Style/MultilineBlockChain: diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 7776a745..ff5238cd 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,54 +1,61 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2020-02-08 14:58:51 +0000 using RuboCop version 0.79.0. +# on 2021-06-18 14:28:03 UTC using RuboCop version 1.12.1. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 15 -# Configuration parameters: CountComments. +Gemspec/DevelopmentDependencies: + Enabled: false + +# Offense count: 7 +Lint/MissingSuper: + Exclude: + - 'lib/zip/extra_field.rb' + - 'lib/zip/extra_field/ntfs.rb' + - 'lib/zip/extra_field/old_unix.rb' + - 'lib/zip/extra_field/universal_time.rb' + - 'lib/zip/extra_field/unix.rb' + - 'lib/zip/extra_field/zip64.rb' + - 'lib/zip/extra_field/zip64_placeholder.rb' + +# Offense count: 5 +# Configuration parameters: CountComments, CountAsOne. Metrics/ClassLength: - Max: 580 + Max: 650 -# Offense count: 26 +# Offense count: 21 +# Configuration parameters: IgnoredMethods. Metrics/CyclomaticComplexity: Max: 14 -# Offense count: 120 -# Configuration parameters: CountComments, ExcludedMethods. +# Offense count: 47 +# Configuration parameters: CountComments, CountAsOne, ExcludedMethods, IgnoredMethods. Metrics/MethodLength: - Max: 30 + Max: 34 -# Offense count: 2 +# Offense count: 5 # Configuration parameters: CountKeywordArgs. Metrics/ParameterLists: - Max: 10 + Max: 11 + MaxOptionalParameters: 9 -# Offense count: 21 +# Offense count: 14 +# Configuration parameters: IgnoredMethods. Metrics/PerceivedComplexity: Max: 15 -# Offense count: 9 +# Offense count: 7 Naming/AccessorMethodName: Exclude: - 'lib/zip/entry.rb' - - 'lib/zip/filesystem.rb' - 'lib/zip/input_stream.rb' - 'lib/zip/streamable_stream.rb' -# Offense count: 7 -# Configuration parameters: EnforcedStyle. -# SupportedStyles: inline, group -Style/AccessModifierDeclarations: - Exclude: - - 'lib/zip/central_directory.rb' - - 'lib/zip/extra_field/zip64.rb' - - 'lib/zip/filesystem.rb' - # Offense count: 7 # Cop supports --auto-correct. -# Configuration parameters: AutoCorrect, EnforcedStyle. +# Configuration parameters: EnforcedStyle. # SupportedStyles: nested, compact Style/ClassAndModuleChildren: Exclude: @@ -57,71 +64,49 @@ Style/ClassAndModuleChildren: - 'lib/zip/extra_field/old_unix.rb' - 'lib/zip/extra_field/universal_time.rb' - 'lib/zip/extra_field/unix.rb' + - 'lib/zip/extra_field/unknown.rb' - 'lib/zip/extra_field/zip64.rb' - 'lib/zip/extra_field/zip64_placeholder.rb' -# Offense count: 26 +# Offense count: 22 +# Configuration parameters: AllowedConstants. Style/Documentation: Enabled: false -# Offense count: 3 -# Configuration parameters: . -# SupportedStyles: annotated, template, unannotated -Style/FormatStringToken: - EnforcedStyle: unannotated - -# Offense count: 95 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle. -# SupportedStyles: always, never -Style/FrozenStringLiteralComment: - Enabled: false - -# Offense count: 17 +# Offense count: 13 # Cop supports --auto-correct. Style/IfUnlessModifier: Exclude: - 'lib/zip/entry.rb' - - 'lib/zip/extra_field/generic.rb' - - 'lib/zip/file.rb' - - 'lib/zip/filesystem.rb' - - 'lib/zip/input_stream.rb' + - 'lib/zip/file_split.rb' + - 'lib/zip/filesystem/dir.rb' + - 'lib/zip/filesystem/file.rb' - 'lib/zip/pass_thru_decompressor.rb' - 'lib/zip/streamable_stream.rb' # Offense count: 1 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, Autocorrect. -# SupportedStyles: module_function, extend_self -Style/ModuleFunction: - Exclude: - - 'lib/zip.rb' - -# Offense count: 56 -# Cop supports --auto-correct. # Configuration parameters: EnforcedStyle. # SupportedStyles: literals, strict Style/MutableConstant: - Enabled: false + Exclude: + - 'lib/zip/extra_field.rb' -# Offense count: 23 +# Offense count: 21 # Cop supports --auto-correct. -# Configuration parameters: AutoCorrect, EnforcedStyle, IgnoredMethods. +# Configuration parameters: EnforcedStyle, IgnoredMethods. # SupportedStyles: predicate, comparison Style/NumericPredicate: Exclude: - - 'spec/**/*' - 'lib/zip/entry.rb' - 'lib/zip/extra_field/old_unix.rb' - 'lib/zip/extra_field/universal_time.rb' - 'lib/zip/extra_field/unix.rb' - 'lib/zip/file.rb' - - 'lib/zip/filesystem.rb' + - 'lib/zip/filesystem/file.rb' - 'lib/zip/input_stream.rb' - 'lib/zip/ioextras.rb' - 'lib/zip/ioextras/abstract_input_stream.rb' - - 'test/file_split_test.rb' - - 'test/test_helper.rb' # Offense count: 17 # Cop supports --auto-correct. diff --git a/.simplecov b/.simplecov index 770d7dc6..91415dfd 100644 --- a/.simplecov +++ b/.simplecov @@ -1,9 +1,22 @@ -require 'coveralls' +# frozen_string_literal: true + +require 'simplecov-lcov' + +SimpleCov::Formatter::LcovFormatter.config do |c| + c.output_directory = 'coverage' + c.lcov_file_name = 'lcov.info' + c.report_with_single_file = true + c.single_report_path = 'coverage/lcov.info' +end + +SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new( + [ + SimpleCov::Formatter::HTMLFormatter, + SimpleCov::Formatter::LcovFormatter + ] +) -SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new([ - SimpleCov::Formatter::HTMLFormatter, - Coveralls::SimpleCov::Formatter -]) SimpleCov.start do - add_filter '/test' + enable_coverage :branch + add_filter ['/test/', '/samples/'] end diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index b903c3b3..00000000 --- a/.travis.yml +++ /dev/null @@ -1,34 +0,0 @@ -language: ruby -dist: xenial -cache: bundler -rvm: - - 2.4 - - 2.5 - - 2.6 - - 2.7 - - ruby-head -matrix: - fast_finish: true - include: - - rvm: jruby-9.2 - jdk: openjdk8 - - rvm: jruby-9.2 - jdk: openjdk11 - - rvm: jruby-head - jdk: openjdk11 - - rvm: rbx-4 - allow_failures: - - rvm: ruby-head - - rvm: rbx-4 - - rvm: jruby-head -before_install: - - gem --version -before_script: - - echo `whereis zip` - - echo `whereis unzip` -env: - global: - - JRUBY_OPTS="--debug" - - COVERALLS_PARALLEL=true -notifications: - webhooks: https://coveralls.io/webhook diff --git a/Changelog.md b/Changelog.md index 5131d210..7e0b9052 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,4 +1,103 @@ -# X.X.X (Next) +# 3.0.0 (Next) + +- Fix de facto regression for input streams. +- Fix `File#write_buffer` to always return the given `io`. +- Add `Entry#absolute_time?` and `DOSTime#absolute_time?` methods. +- Use explicit named parameters for `File` methods. +- Ensure that entries can be extracted safely without path traversal. [#540](https://github.com/rubyzip/rubyzip/issues/540) +- Enable Zip64 by default. +- Rename `GPFBit3Error` to `StreamingError`. +- Ensure that `Entry.ftype` is correct via `InputStream`. [#533](https://github.com/rubyzip/rubyzip/issues/533) +- Add `Entry#zip64?` as a better way detect Zip64 entries. +- Implement `Zip::FileSystem::ZipFsFile#symlink?`. +- Remove `File::add_buffer` from the API. +- Fix `OutputStream#put_next_entry` to preserve `StreamableStream`s. [#503](https://github.com/rubyzip/rubyzip/issues/503) +- Ensure `File.open_buffer` doesn't rewrite unchanged data. +- Add `CentralDirectory#count_entries` and `File::count_entries`. +- Fix reading unknown extra fields. [#505](https://github.com/rubyzip/rubyzip/issues/505) +- Fix reading zip files with max length file comment. [#508](https://github.com/rubyzip/rubyzip/issues/508) +- Fix reading zip64 files with max length file comment. [#509](https://github.com/rubyzip/rubyzip/issues/509) +- Don't silently alter zip files opened with `Zip::sort_entries`. [#329](https://github.com/rubyzip/rubyzip/issues/329) +- Use named parameters for optional arguments in the public API. +- Raise an error if entry names exceed 65,535 characters. [#247](https://github.com/rubyzip/rubyzip/issues/247) +- Remove the `ZipXError` v1 legacy classes. +- Raise an error on reading a split archive with `InputStream`. [#349](https://github.com/rubyzip/rubyzip/issues/349) +- Ensure `InputStream` raises `GPFBit3Error` for OSX Archive files. [#493](https://github.com/rubyzip/rubyzip/issues/493) +- Improve documentation and error messages for `InputStream`. [#196](https://github.com/rubyzip/rubyzip/issues/196) +- Fix zip file-level comment is not read from zip64 files. [#492](https://github.com/rubyzip/rubyzip/issues/492) +- Fix `Zip::OutputStream.write_buffer` doesn't work with Tempfiles. [#265](https://github.com/rubyzip/rubyzip/issues/265) +- Reinstate normalising pathname separators to /. [#487](https://github.com/rubyzip/rubyzip/pull/487) +- Fix restore options consistency. [#486](https://github.com/rubyzip/rubyzip/pull/486) +- View and/or preserve original date created, date modified? (Windows). [#336](https://github.com/rubyzip/rubyzip/issues/336) +- Fix frozen string literal error. [#475](https://github.com/rubyzip/rubyzip/pull/475) +- Set the default `Entry` time to the file's mtime on Windows. [#465](https://github.com/rubyzip/rubyzip/issues/465) +- Ensure that `Entry#time=` sets times as `DOSTime` objects. [#481](https://github.com/rubyzip/rubyzip/issues/481) +- Replace and deprecate `Zip::DOSTime#dos_equals`. [#464](https://github.com/rubyzip/rubyzip/pull/464) +- Fix loading extra fields. [#459](https://github.com/rubyzip/rubyzip/pull/459) +- Set compression level on a per-zipfile basis. [#448](https://github.com/rubyzip/rubyzip/pull/448) +- Fix input stream partial read error. [#462](https://github.com/rubyzip/rubyzip/pull/462) +- Fix zlib deflate buffer growth. [#447](https://github.com/rubyzip/rubyzip/pull/447) + +Tooling/internal: + +- Add a test to ensure correct version number format. +- Update the README with new Ruby version compatability information. +- Fix various issues with JRuby tests. +- Update gem dependency versions. +- Add Ruby 3.4 to the CI. +- Fix mispelled variable names in the crypto classes. +- Only use the Zip64 CDIR end locator if needed. +- Prevent unnecessary Zip64 data being stored. +- Abstract marking various things as 'dirty' into `Dirtyable` for reuse. +- Properly test `File#mkdir`. +- Remove unused private method `File#directory?`. +- Expose the `EntrySet` more cleanly through `CentralDirectory`. +- `Zip::File` no longer subclasses `Zip::CentralDirectory`. +- Configure Coveralls to not report a failure on minor decreases of test coverage. [#491](https://github.com/rubyzip/rubyzip/issues/491) +- Extract the file splitting code out into its own module. +- Refactor, and tidy up, the `Zip::Filesystem` classes for improved maintainability. +- Fix Windows tests. [#489](https://github.com/rubyzip/rubyzip/pull/489) +- Refactor `assert_forwarded` so it does not need `ObjectSpace._id2ref` or `eval`. [#483](https://github.com/rubyzip/rubyzip/pull/483) +- Add GitHub Actions CI infrastructure. [#469](https://github.com/rubyzip/rubyzip/issues/469) +- Add Ruby 3.0 to CI. [#474](https://github.com/rubyzip/rubyzip/pull/474) +- Fix the compression level tests to compare relative sizes. [#473](https://github.com/rubyzip/rubyzip/pull/473) +- Simplify assertions in basic_zip_file_test. [#470](https://github.com/rubyzip/rubyzip/pull/470) +- Remove compare_enumerables from test_helper.rb. [#468](https://github.com/rubyzip/rubyzip/pull/468) +- Use correct SPDX license identifier. [#458](https://github.com/rubyzip/rubyzip/pull/458) +- Enable truffle ruby in Travis CI. [#450](https://github.com/rubyzip/rubyzip/pull/450) +- Update rubocop again and run it in CI. [#444](https://github.com/rubyzip/rubyzip/pull/444) +- Fix a test that was incorrect on big-endian architectures. [#445](https://github.com/rubyzip/rubyzip/pull/445) + +# 2.4.1 (2025-01-05) + +*This is a re-release of version 2.4 with a full version number string. We need to move to version 2.4.1 due to the canonical version number 2.4 now being taken in Rubygems.* + +Tooling: + +- Opt-in for MFA requirement explicitly on 2.4 branch. + +# 2.4 (2025-01-04) - Yanked + +*Yanked due to incorrect version number format (2.4 vs 2.4.0).* + +- Ensure compatibility with `--enable-frozen-string-literal`. +- Ensure `File.open_buffer` doesn't rewrite unchanged data. This is a backport of the fix on the 3.x branch. +- Enable use of the version 3 calling style (mainly named parameters) wherever possible, while retaining version 2.x compatibility. +- Add (switchable) warning messages to methods that are changed or removed in version 3.x. + +Tooling: + +- Switch to using GitHub Actions (from Travis). +- Update Rubocop versions and configuration. +- Update actions with latest rubies. + +# 2.3.2 (2021-07-05) + +- A "dummy" release to warn about breaking changes coming in version 3.0. This updated version uses the Gem `post_install_message` instead of printing to `STDERR`. + +# 2.3.1 (2021-07-03) + +- A "dummy" release to warn about breaking changes coming in version 3.0. # 2.3.0 (2020-03-14) diff --git a/Gemfile b/Gemfile index fa75df15..bdfaab79 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,12 @@ +# frozen_string_literal: true + source 'https://rubygems.org' gemspec + +# TODO: remove when JRuby 9.4.10.0 will be released and available on CI +# Ref: https://github.com/jruby/jruby/issues/7262 +if RUBY_PLATFORM.include?('java') + gem 'jar-dependencies', '0.4.1' + gem 'ruby-maven', '3.3.13' +end diff --git a/Guardfile b/Guardfile index 1508e4c9..bf0c0f89 100644 --- a/Guardfile +++ b/Guardfile @@ -1,6 +1,8 @@ +# frozen_string_literal: true + guard :minitest do # with Minitest::Unit - watch(%r{^test/(.*)\/?(.*)_test\.rb$}) + watch(%r{^test/(.*)/?(.*)_test\.rb$}) watch(%r{^lib/zip/(.*/)?([^/]+)\.rb$}) { |m| "test/#{m[1]}#{m[2]}_test.rb" } watch(%r{^test/test_helper\.rb$}) { 'test' } end diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..5673f4bc --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,24 @@ +BSD 2-Clause License + +Copyright (c) 2002-2025, The Rubyzip Developers + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index 059f22d1..dcf01eda 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,32 @@ # rubyzip [![Gem Version](https://badge.fury.io/rb/rubyzip.svg)](http://badge.fury.io/rb/rubyzip) -[![Build Status](https://secure.travis-ci.org/rubyzip/rubyzip.svg)](http://travis-ci.org/rubyzip/rubyzip) +[![Tests](https://github.com/rubyzip/rubyzip/actions/workflows/tests.yml/badge.svg)](https://github.com/rubyzip/rubyzip/actions/workflows/tests.yml) +[![Linter](https://github.com/rubyzip/rubyzip/actions/workflows/lint.yml/badge.svg)](https://github.com/rubyzip/rubyzip/actions/workflows/lint.yml) [![Code Climate](https://codeclimate.com/github/rubyzip/rubyzip.svg)](https://codeclimate.com/github/rubyzip/rubyzip) [![Coverage Status](https://img.shields.io/coveralls/rubyzip/rubyzip.svg)](https://coveralls.io/r/rubyzip/rubyzip?branch=master) Rubyzip is a ruby library for reading and writing zip files. -## Important note +## Important notes -The Rubyzip interface has changed!!! No need to do `require "zip/zip"` and `Zip` prefix in class names removed. +### Updating to version 3.0 -If you have issues with any third-party gems that require an old version of rubyzip, you can use this workaround: +The public API of some classes has been modernized to use named parameters for optional arguments. Please check your usage of the following Rubyzip classes: +* `File` +* `Entry` +* `InputStream` +* `OutputStream` -```ruby -gem 'rubyzip', '>= 1.0.0' # will load new rubyzip version -gem 'zip-zip' # will load compatibility for old rubyzip API. -``` +**Please see [Updating to version 3.x](https://github.com/rubyzip/rubyzip/wiki/Updating-to-version-3.x) in the wiki for details.** ## Requirements -- Ruby 2.4 or greater (for rubyzip 2.0; use 1.x for older rubies) +Version 3.x requires at least Ruby 3.0. + +Version 2.x requires at least Ruby 2.4, and is known to work on Ruby 3.x. + +It is not recommended to use any versions of Rubyzip earlier than 2.3 due to security issues. ## Installation @@ -49,7 +55,7 @@ input_filenames = ['image.jpg', 'description.txt', 'stats.csv'] zipfile_name = "/Users/me/Desktop/archive.zip" -Zip::File.open(zipfile_name, Zip::File::CREATE) do |zipfile| +Zip::File.open(zipfile_name, create: true) do |zipfile| input_filenames.each do |filename| # Two arguments: # - The name of the file as it will appear in the archive @@ -88,7 +94,7 @@ class ZipFileGenerator def write entries = Dir.entries(@input_dir) - %w[. ..] - ::Zip::File.open(@output_file, ::Zip::File::CREATE) do |zipfile| + ::Zip::File.open(@output_file, create: true) do |zipfile| write_entries entries, '', zipfile end end @@ -121,9 +127,9 @@ class ZipFileGenerator end ``` -### Save zip archive entries in sorted by name state +### Save zip archive entries sorted by name -To save zip archives in sorted order like below, you need to set `::Zip.sort_entries` to `true` +To save zip archives with their entries sorted by name (see below), set `::Zip.sort_entries` to `true` ``` Vegetable/ @@ -137,7 +143,7 @@ fruit/mango fruit/orange ``` -After this, entries in the zip archive will be saved in ordered state. +Opening an existing zip file with this option set will not change the order of the entries automatically. Altering the zip file - adding an entry, renaming an entry, adding or changing the archive comment, etc - will cause the ordering to be applied when closing the file. ### Default permissions of zip archives @@ -173,28 +179,71 @@ Zip::File.open('foo.zip') do |zip_file| end ``` -#### Notice about ::Zip::InputStream +### Notes on `Zip::InputStream` -`::Zip::InputStream` usable for fast reading zip file content because it not read Central directory. +`Zip::InputStream` can be used for faster reading of zip file content because it does not read the Central directory up front. -But there is one exception when it is not working - General Purpose Flag Bit 3. +There is one exception where it can not work however, and this is if the file does not contain enough information in the local entry headers to extract an entry. This is indicated in an entry by the General Purpose Flag bit 3 being set. -> If bit 3 (0x08) of the general-purpose flags field is set, then the CRC-32 and file sizes are not known when the header is written. The fields in the local header are filled with zero, and the CRC-32 and size are appended in a 12-byte structure (optionally preceded by a 4-byte signature) immediately after the compressed data +> If bit 3 (0x08) of the general-purpose flags field is set, then the CRC-32 and file sizes are not known when the header is written. The fields in the local header are filled with zero, and the CRC-32 and size are appended in a 12-byte structure (optionally preceded by a 4-byte signature) immediately after the compressed data. -If `::Zip::InputStream` finds such entry in the zip archive it will raise an exception. +If `Zip::InputStream` finds such an entry in the zip archive it will raise an exception (`Zip::StreamingError`). + +`Zip::InputStream` is not designed to be used for random access in a zip file. When performing any operations on an entry that you are accessing via `Zip::InputStream.get_next_entry` then you should complete any such operations before the next call to `get_next_entry`. + +```ruby +zip_stream = Zip::InputStream.new(File.open('file.zip')) + +while entry = zip_stream.get_next_entry + # All required operations on `entry` go here. +end +``` + +Any attempt to move about in a zip file opened with `Zip::InputStream` could result in the incorrect entry being accessed and/or Zlib buffer errors. If you need random access in a zip file, use `Zip::File`. ### Password Protection (Experimental) Rubyzip supports reading/writing zip files with traditional zip encryption (a.k.a. "ZipCrypto"). AES encryption is not yet supported. It can be used with buffer streams, e.g.: +#### Version 2.x + ```ruby -Zip::OutputStream.write_buffer(::StringIO.new(''), Zip::TraditionalEncrypter.new('password')) do |out| - out.put_next_entry("my_file.txt") - out.write my_data -end.string +# Writing. +enc = Zip::TraditionalEncrypter.new('password') +buffer = Zip::OutputStream.write_buffer(::StringIO.new(''), enc) do |output| + output.put_next_entry("my_file.txt") + output.write my_data +end + +# Reading. +dec = Zip::TraditionalDecrypter.new('password') +Zip::InputStream.open(buffer, 0, dec) do |input| + entry = input.get_next_entry + puts "Contents of '#{entry.name}':" + puts input.read +end +``` + +#### Version 3.x + +```ruby +# Writing. +enc = Zip::TraditionalEncrypter.new('password') +buffer = Zip::OutputStream.write_buffer(encrypter: enc) do |output| + output.put_next_entry("my_file.txt") + output.write my_data +end + +# Reading. +dec = Zip::TraditionalDecrypter.new('password') +Zip::InputStream.open(buffer, decrypter: dec) do |input| + entry = input.get_next_entry + puts "Contents of '#{entry.name}':" + puts input.read +end ``` -This is an experimental feature and the interface for encryption may change in future versions. +_This is an experimental feature and the interface for encryption may change in future versions._ ## Known issues @@ -208,7 +257,7 @@ buffer = Zip::OutputStream.write_buffer do |out| unless [DOCUMENT_FILE_PATH, RELS_FILE_PATH].include?(e.name) out.put_next_entry(e.name) out.write e.get_input_stream.read - end + end end out.put_next_entry(DOCUMENT_FILE_PATH) @@ -288,25 +337,37 @@ Zip.validate_entry_sizes = false Note that if you use the lower level `Zip::InputStream` interface, `rubyzip` does *not* check the entry `size`s. In this case, the caller is responsible for making sure it does not read more data than expected from the input stream. -### Default Compression +### Compression level + +When adding entries to a zip archive you can set the compression level to trade-off compressed size against compression speed. By default this is set to the same as the underlying Zlib library's default (`Zlib::DEFAULT_COMPRESSION`), which is somewhere in the middle. -You can set the default compression level like so: +You can configure the default compression level with: ```ruby -Zip.default_compression = Zlib::DEFAULT_COMPRESSION +Zip.default_compression = X ``` -It defaults to `Zlib::DEFAULT_COMPRESSION`. Possible values are `Zlib::BEST_COMPRESSION`, `Zlib::DEFAULT_COMPRESSION` and `Zlib::NO_COMPRESSION` +Where X is an integer between 0 and 9, inclusive. If this option is set to 0 (`Zlib::NO_COMPRESSION`) then entries will be stored in the zip archive uncompressed. A value of 1 (`Zlib::BEST_SPEED`) gives the fastest compression and 9 (`Zlib::BEST_COMPRESSION`) gives the smallest compressed file size. + +This can also be set for each archive as an option to `Zip::File`: + +```ruby +Zip::File.open('foo.zip', create:true, compression_level: 9) do |zip| + zip.add ... +end +``` ### Zip64 Support -By default, Zip64 support is disabled for writing. To enable it do this: +Since version 3.0, Zip64 support is enabled for writing by default. To disable it do this: ```ruby -Zip.write_zip64_support = true +Zip.write_zip64_support = false ``` -_NOTE_: If you will enable Zip64 writing then you will need zip extractor with Zip64 support to extract archive. +Prior to version 3.0, Zip64 support is disabled for writing by default. + +_NOTE_: If Zip64 write support is enabled then any extractor subsequently used may also require Zip64 support to read from the resultant archive. ### Block Form @@ -321,15 +382,50 @@ You can set multiple settings at the same time by using a block: end ``` +## Compatibility + +Rubyzip is known to run on a number of platforms and under a number of different Ruby versions. + +### Version 2.3.x + +Rubyzip 2.3 is known to work on MRI 2.4 to 3.4 on Linux and Mac, and JRuby and Truffleruby on Linux. There are known issues with Windows which have been fixed on the development branch. Please [let us know](https://github.com/rubyzip/rubyzip/pulls) if you know Rubyzip 2.3 works on a platform/Ruby combination not listed here, or [raise an issue](https://github.com/rubyzip/rubyzip/issues) if you see a failure where we think it should work. + +### Next (version 3.0.0) + +Please see the table below for what we think the current situation is. Note: an empty cell means "unknown", not "does not work". + +| OS/Ruby | 3.0 | 3.1 | 3.2 | 3.3 | 3.4 | Head | JRuby 9.4.9.0 | JRuby Head | Truffleruby 24.1.1 | Truffleruby Head | +|---------|-----|-----|-----|-----|-----|------|---------------|------------|--------------------|------------------| +|Ubuntu 22.04| CI | CI | CI | CI | CI | ci | CI | ci | CI | ci | +|Mac OS 14.7.2| CI | CI | CI | CI | CI | ci | x | | x | | +|Windows Server 2022| CI | | | | CI mswin
CI ucrt | | | | | | + +Key: `CI` - tested in CI, should work; `ci` - tested in CI, might fail; `x` - known working; `o` - known failing. + +Rubies 3.1+ are also tested separately with YJIT turned on (Ubuntu and Mac OS). + +See [the Actions tab](https://github.com/rubyzip/rubyzip/actions) in GitHub for full details. + +Please [raise a PR](https://github.com/rubyzip/rubyzip/pulls) if you know Rubyzip works on a platform/Ruby combination not listed here, or [raise an issue](https://github.com/rubyzip/rubyzip/issues) if you see a failure where we think it should work. + ## Developing -To run the test you need to do this: +Install the dependencies: -``` +```shell bundle install +``` + +Run the tests with `rake`: + +```shell rake ``` +Please also run `rubocop` over your changes. + +Our CI runs on [GitHub Actions](https://github.com/rubyzip/rubyzip/actions). Please note that `rubocop` is run as part of the CI configuration and will fail a build if errors are found. + ## Website and Project Home http://github.com/rubyzip/rubyzip @@ -338,17 +434,29 @@ http://rdoc.info/github/rubyzip/rubyzip/master/frames ## Authors -Alexander Simonov ( alex at simonov.me) +See https://github.com/rubyzip/rubyzip/graphs/contributors for a comprehensive list. -Alan Harper ( alan at aussiegeek.net) +### Current maintainers -Thomas Sondergaard (thomas at sondergaard.cc) +* Robert Haines (@hainesr) +* John Lees-Miller (@jdleesmiller) +* Oleksandr Simonov (@simonoff) -Technorama Ltd. (oss-ruby-zip at technorama.net) +### Original author -extra-field support contributed by Tatsuki Sugiura (sugi at nemui.org) +* Thomas Sondergaard ## License -Rubyzip is distributed under the same license as ruby. See -http://www.ruby-lang.org/en/LICENSE.txt +Rubyzip is distributed under the same license as Ruby. In practice this means you can use it under the terms of the Ruby License or the 2-Clause BSD License. See https://www.ruby-lang.org/en/about/license.txt and LICENSE.md for details. + +## Research notice +Please note that this repository is participating in a study into sustainability + of open source projects. Data will be gathered about this repository for + approximately the next 12 months, starting from June 2021. + +Data collected will include number of contributors, number of PRs, time taken to + close/merge these PRs, and issues closed. + +For more information, please visit +[our informational page](https://sustainable-open-science-and-software.github.io/) or download our [participant information sheet](https://sustainable-open-science-and-software.github.io/assets/PIS_sustainable_software.pdf). diff --git a/Rakefile b/Rakefile index 717c6b73..f1e81470 100644 --- a/Rakefile +++ b/Rakefile @@ -1,5 +1,8 @@ +# frozen_string_literal: true + require 'bundler/gem_tasks' require 'rake/testtask' +require 'rdoc/task' require 'rubocop/rake_task' task default: :test @@ -11,11 +14,12 @@ Rake::TestTask.new(:test) do |test| test.verbose = true end -RuboCop::RakeTask.new +RDoc::Task.new do |rdoc| + rdoc.main = 'README.md' + rdoc.rdoc_files.include('README.md', 'lib/**/*.rb') + rdoc.options << '--markup=markdown' + rdoc.options << '--tab-width=2' + rdoc.options << "-t Rubyzip version #{Zip::VERSION}" +end -# Rake::TestTask.new(:zip64_full_test) do |test| -# test.libs << File.join(File.dirname(__FILE__), 'lib') -# test.libs << File.join(File.dirname(__FILE__), 'test') -# test.pattern = File.join(File.dirname(__FILE__), 'test/zip64_full_test.rb') -# test.verbose = true -# end +RuboCop::RakeTask.new diff --git a/TODO b/TODO deleted file mode 100644 index 16b9a2e7..00000000 --- a/TODO +++ /dev/null @@ -1,15 +0,0 @@ - -* ZipInputStream: Support zip-files with trailing data descriptors -* Adjust rdoc stylesheet to advertise inherited methods if possible -* Suggestion: Add ZipFile/ZipInputStream example that demonstrates extracting all entries. -* Suggestion: ZipFile#extract destination should default to "." -* Suggestion: ZipEntry should have extract(), get_input_stream() methods etc -* (is buffering used anywhere with write?) -* Inflater.sysread should pass the buffer to produce_input. -* Implement ZipFsDir.glob -* ZipFile.checkIntegrity method -* non-MSDOS permission attributes -** See mail from Ned Konz to ruby-talk subj. "Re: SV: [ANN] Archive 0.2" -* Packager version, required unpacker version in zip headers -** See mail from Ned Konz to ruby-talk subj. "Re: SV: [ANN] Archive 0.2" -* implement storing attributes and ownership information diff --git a/bin/console b/bin/console index 6df9a590..15e7b977 100755 --- a/bin/console +++ b/bin/console @@ -1,4 +1,5 @@ #!/usr/bin/env ruby +# frozen_string_literal: true require 'bundler/setup' require 'zip' diff --git a/lib/zip.rb b/lib/zip.rb index 8cf982a5..f85e7fb0 100644 --- a/lib/zip.rb +++ b/lib/zip.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'English' require 'delegate' require 'singleton' @@ -33,6 +35,11 @@ require 'zip/streamable_directory' require 'zip/errors' +# Rubyzip is a ruby module for reading and writing zip files. +# +# The main entry points are File, InputStream and OutputStream. For a +# file/directory interface in the style of the standard ruby ::File and +# ::Dir APIs then `require 'zip/filesystem'` and see FileSystem. module Zip extend self attr_accessor :unicode_names, @@ -46,19 +53,27 @@ module Zip :force_entry_names_encoding, :validate_entry_sizes - def reset! + DEFAULT_RESTORE_OPTIONS = { + restore_ownership: false, + restore_permissions: true, + restore_times: true + }.freeze # :nodoc: + + def reset! # :nodoc: @_ran_once = false @unicode_names = false @on_exists_proc = false @continue_on_exists_proc = false @sort_entries = false - @default_compression = ::Zlib::DEFAULT_COMPRESSION - @write_zip64_support = false + @default_compression = Zlib::DEFAULT_COMPRESSION + @write_zip64_support = true @warn_invalid_date = true @case_insensitive_match = false + @force_entry_names_encoding = nil @validate_entry_sizes = true end + # Set options for RubyZip in one block. def setup yield self unless @_ran_once @_ran_once = true diff --git a/lib/zip/central_directory.rb b/lib/zip/central_directory.rb index 9975884c..61be6fd3 100644 --- a/lib/zip/central_directory.rb +++ b/lib/zip/central_directory.rb @@ -1,45 +1,77 @@ +# frozen_string_literal: true + +require 'forwardable' + +require_relative 'dirtyable' + module Zip - class CentralDirectory - include Enumerable + class CentralDirectory # :nodoc: + extend Forwardable + include Dirtyable + + END_OF_CD_SIG = 0x06054b50 + ZIP64_END_OF_CD_SIG = 0x06064b50 + ZIP64_EOCD_LOCATOR_SIG = 0x07064b50 - END_OF_CDS = 0x06054b50 - ZIP64_END_OF_CDS = 0x06064b50 - ZIP64_EOCD_LOCATOR = 0x07064b50 - MAX_END_OF_CDS_SIZE = 65_536 + 18 STATIC_EOCD_SIZE = 22 + ZIP64_STATIC_EOCD_SIZE = 56 + ZIP64_EOCD_LOC_SIZE = 20 + MAX_FILE_COMMENT_SIZE = (1 << 16) - 1 + MAX_END_OF_CD_SIZE = + MAX_FILE_COMMENT_SIZE + STATIC_EOCD_SIZE + ZIP64_EOCD_LOC_SIZE - attr_reader :comment + attr_accessor :comment - # Returns an Enumerable containing the entries. - def entries - @entry_set.entries - end + def_delegators :@entry_set, + :<<, :delete, :each, :entries, :find_entry, :glob, + :include?, :size + + mark_dirty :<<, :comment=, :delete - def initialize(entries = EntrySet.new, comment = '') #:nodoc: - super() + def initialize(entries = EntrySet.new, comment = '') # :nodoc: + super(dirty_on_create: false) @entry_set = entries.kind_of?(EntrySet) ? entries : EntrySet.new(entries) @comment = comment end - def write_to_stream(io) #:nodoc: + def read_from_stream(io) + read_eocds(io) + read_central_directory_entries(io) + end + + def write_to_stream(io) # :nodoc: cdir_offset = io.tell @entry_set.each { |entry| entry.write_c_dir_entry(io) } eocd_offset = io.tell cdir_size = eocd_offset - cdir_offset - if ::Zip.write_zip64_support - need_zip64_eocd = cdir_offset > 0xFFFFFFFF || cdir_size > 0xFFFFFFFF || @entry_set.size > 0xFFFF - need_zip64_eocd ||= @entry_set.any? { |entry| entry.extra['Zip64'] } - if need_zip64_eocd - write_64_e_o_c_d(io, cdir_offset, cdir_size) - write_64_eocd_locator(io, eocd_offset) - end + if Zip.write_zip64_support && + (cdir_offset > 0xFFFFFFFF || cdir_size > 0xFFFFFFFF || @entry_set.size > 0xFFFF) + write_64_e_o_c_d(io, cdir_offset, cdir_size) + write_64_eocd_locator(io, eocd_offset) end write_e_o_c_d(io, cdir_offset, cdir_size) end - def write_e_o_c_d(io, offset, cdir_size) #:nodoc: + # Reads the End of Central Directory Record (and the Zip64 equivalent if + # needs be) and returns the number of entries in the archive. This is a + # convenience method that avoids reading in all of the entry data to get a + # very quick entry count. + def count_entries(io) + read_eocds(io) + @size + end + + def ==(other) # :nodoc: + return false unless other.kind_of?(CentralDirectory) + + @entry_set.entries.sort == other.entries.sort && comment == other.comment + end + + private + + def write_e_o_c_d(io, offset, cdir_size) # :nodoc: tmp = [ - END_OF_CDS, + END_OF_CD_SIG, 0, # @numberOfThisDisk 0, # @numberOfDiskWithStartOfCDir @entry_set ? [@entry_set.size, 0xFFFF].min : 0, @@ -52,11 +84,9 @@ def write_e_o_c_d(io, offset, cdir_size) #:nodoc: io << @comment end - private :write_e_o_c_d - - def write_64_e_o_c_d(io, offset, cdir_size) #:nodoc: + def write_64_e_o_c_d(io, offset, cdir_size) # :nodoc: tmp = [ - ZIP64_END_OF_CDS, + ZIP64_END_OF_CD_SIG, 44, # size of zip64 end of central directory record (excludes signature and field itself) VERSION_MADE_BY, VERSION_NEEDED_TO_EXTRACT_ZIP64, @@ -70,11 +100,9 @@ def write_64_e_o_c_d(io, offset, cdir_size) #:nodoc: io << tmp.pack('VQ 'FAT'.freeze, - FSTYPE_AMIGA => 'Amiga'.freeze, - FSTYPE_VMS => 'VMS (Vax or Alpha AXP)'.freeze, - FSTYPE_UNIX => 'Unix'.freeze, - FSTYPE_VM_CMS => 'VM/CMS'.freeze, - FSTYPE_ATARI => 'Atari ST'.freeze, - FSTYPE_HPFS => 'OS/2 or NT HPFS'.freeze, - FSTYPE_MAC => 'Macintosh'.freeze, - FSTYPE_Z_SYSTEM => 'Z-System'.freeze, - FSTYPE_CPM => 'CP/M'.freeze, - FSTYPE_TOPS20 => 'TOPS-20'.freeze, - FSTYPE_NTFS => 'NTFS'.freeze, - FSTYPE_QDOS => 'SMS/QDOS'.freeze, - FSTYPE_ACORN => 'Acorn RISC OS'.freeze, - FSTYPE_VFAT => 'Win32 VFAT'.freeze, - FSTYPE_MVS => 'MVS'.freeze, - FSTYPE_BEOS => 'BeOS'.freeze, - FSTYPE_TANDEM => 'Tandem NSK'.freeze, - FSTYPE_THEOS => 'Theos'.freeze, - FSTYPE_MAC_OSX => 'Mac OS/X (Darwin)'.freeze, - FSTYPE_ATHEOS => 'AtheOS'.freeze + FSTYPE_FAT => 'FAT', + FSTYPE_AMIGA => 'Amiga', + FSTYPE_VMS => 'VMS (Vax or Alpha AXP)', + FSTYPE_UNIX => 'Unix', + FSTYPE_VM_CMS => 'VM/CMS', + FSTYPE_ATARI => 'Atari ST', + FSTYPE_HPFS => 'OS/2 or NT HPFS', + FSTYPE_MAC => 'Macintosh', + FSTYPE_Z_SYSTEM => 'Z-System', + FSTYPE_CPM => 'CP/M', + FSTYPE_TOPS20 => 'TOPS-20', + FSTYPE_NTFS => 'NTFS', + FSTYPE_QDOS => 'SMS/QDOS', + FSTYPE_ACORN => 'Acorn RISC OS', + FSTYPE_VFAT => 'Win32 VFAT', + FSTYPE_MVS => 'MVS', + FSTYPE_BEOS => 'BeOS', + FSTYPE_TANDEM => 'Tandem NSK', + FSTYPE_THEOS => 'Theos', + FSTYPE_MAC_OSX => 'Mac OS/X (Darwin)', + FSTYPE_ATHEOS => 'AtheOS' }.freeze COMPRESSION_METHOD_STORE = 0 @@ -112,4 +118,6 @@ module Zip COMPRESSION_METHOD_PPMD => 'PPMd version I, Rev 1', COMPRESSION_METHOD_AES => 'AES encryption' }.freeze + + # :startdoc: end diff --git a/lib/zip/crypto/decrypted_io.rb b/lib/zip/crypto/decrypted_io.rb index 61a377da..a38004fa 100644 --- a/lib/zip/crypto/decrypted_io.rb +++ b/lib/zip/crypto/decrypted_io.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + module Zip - class DecryptedIo #:nodoc:all + class DecryptedIo # :nodoc:all CHUNK_SIZE = 32_768 def initialize(io, decrypter) @@ -16,7 +18,7 @@ def read(length = nil, outbuf = +'') buffer << produce_input end - outbuf.replace(buffer.slice!(0...(length || output_buffer.bytesize))) + outbuf.replace(buffer.slice!(0...(length || buffer.bytesize))) end private diff --git a/lib/zip/crypto/encryption.rb b/lib/zip/crypto/encryption.rb index 4351be1c..b9c96c67 100644 --- a/lib/zip/crypto/encryption.rb +++ b/lib/zip/crypto/encryption.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + module Zip - class Encrypter #:nodoc:all + class Encrypter # :nodoc:all end - class Decrypter + class Decrypter # :nodoc:all end end diff --git a/lib/zip/crypto/null_encryption.rb b/lib/zip/crypto/null_encryption.rb index a93f707c..97764b73 100644 --- a/lib/zip/crypto/null_encryption.rb +++ b/lib/zip/crypto/null_encryption.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + module Zip - module NullEncryption + module NullEncryption # :nodoc: def header_bytesize 0 end @@ -9,7 +11,7 @@ def gp_flags end end - class NullEncrypter < Encrypter + class NullEncrypter < Encrypter # :nodoc: include NullEncryption def header(_mtime) @@ -20,14 +22,14 @@ def encrypt(data) data end - def data_descriptor(_crc32, _compressed_size, _uncomprssed_size) + def data_descriptor(_crc32, _compressed_size, _uncompressed_size) '' end def reset!; end end - class NullDecrypter < Decrypter + class NullDecrypter < Decrypter # :nodoc: include NullEncryption def decrypt(data) diff --git a/lib/zip/crypto/traditional_encryption.rb b/lib/zip/crypto/traditional_encryption.rb index 270e9efd..dc92c822 100644 --- a/lib/zip/crypto/traditional_encryption.rb +++ b/lib/zip/crypto/traditional_encryption.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + module Zip - module TraditionalEncryption + module TraditionalEncryption # :nodoc: def initialize(password) @password = password reset_keys! @@ -26,7 +28,7 @@ def reset_keys! def update_keys(num) @key0 = ~Zlib.crc32(num, ~@key0) - @key1 = ((@key1 + (@key0 & 0xff)) * 134_775_813 + 1) & 0xffffffff + @key1 = (((@key1 + (@key0 & 0xff)) * 134_775_813) + 1) & 0xffffffff @key2 = ~Zlib.crc32((@key1 >> 24).chr, ~@key2) end @@ -36,7 +38,7 @@ def decrypt_byte end end - class TraditionalEncrypter < Encrypter + class TraditionalEncrypter < Encrypter # :nodoc: include TraditionalEncryption def header(mtime) @@ -53,8 +55,8 @@ def encrypt(data) data.unpack('C*').map { |x| encode x }.pack('C*') end - def data_descriptor(crc32, compressed_size, uncomprssed_size) - [0x08074b50, crc32, compressed_size, uncomprssed_size].pack('VVVV') + def data_descriptor(crc32, compressed_size, uncompressed_size) + [0x08074b50, crc32, compressed_size, uncompressed_size].pack('VVVV') end def reset! @@ -70,7 +72,7 @@ def encode(num) end end - class TraditionalDecrypter < Decrypter + class TraditionalDecrypter < Decrypter # :nodoc: include TraditionalEncryption def decrypt(data) diff --git a/lib/zip/decompressor.rb b/lib/zip/decompressor.rb index 2f89545c..e75aba92 100644 --- a/lib/zip/decompressor.rb +++ b/lib/zip/decompressor.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + module Zip - class Decompressor #:nodoc:all + class Decompressor # :nodoc:all CHUNK_SIZE = 32_768 def self.decompressor_classes @@ -14,8 +16,7 @@ def self.find_by_compression_method(compression_method) decompressor_classes[compression_method] end - attr_reader :input_stream - attr_reader :decompressed_size + attr_reader :decompressed_size, :input_stream def initialize(input_stream, decompressed_size = nil) super() diff --git a/lib/zip/deflater.rb b/lib/zip/deflater.rb index 8509cf47..a899e118 100644 --- a/lib/zip/deflater.rb +++ b/lib/zip/deflater.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + module Zip - class Deflater < Compressor #:nodoc:all + class Deflater < Compressor # :nodoc:all def initialize(output_stream, level = Zip.default_compression, encrypter = NullEncrypter.new) super() @output_stream = output_stream @@ -13,16 +15,16 @@ def <<(data) val = data.to_s @crc = Zlib.crc32(val, @crc) @size += val.bytesize - buffer = @zlib_deflater.deflate(data) - if buffer.empty? - @output_stream - else - @output_stream << @encrypter.encrypt(buffer) - end + buffer = @zlib_deflater.deflate(data, Zlib::SYNC_FLUSH) + return @output_stream if buffer.empty? + + @output_stream << @encrypter.encrypt(buffer) end def finish - @output_stream << @encrypter.encrypt(@zlib_deflater.finish) until @zlib_deflater.finished? + buffer = @zlib_deflater.finish + @output_stream << @encrypter.encrypt(buffer) unless buffer.empty? + @zlib_deflater.close end attr_reader :size, :crc diff --git a/lib/zip/dirtyable.rb b/lib/zip/dirtyable.rb new file mode 100644 index 00000000..4e2104e4 --- /dev/null +++ b/lib/zip/dirtyable.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Zip + module Dirtyable # :nodoc:all + def initialize(dirty_on_create: true) + @dirty = dirty_on_create + end + + def dirty? + @dirty + end + + module ClassMethods # :nodoc: + def mark_dirty(*symbols) # :nodoc: + # Move the original method and call it after we've set the dirty flag. + symbols.each do |symbol| + orig_name = "orig_#{symbol}" + alias_method orig_name, symbol + + define_method(symbol) do |param| + @dirty = true + send(orig_name, param) + end + end + end + end + + def self.included(base) + base.extend(ClassMethods) + end + end +end diff --git a/lib/zip/dos_time.rb b/lib/zip/dos_time.rb index 1d77aa40..ac80fac5 100644 --- a/lib/zip/dos_time.rb +++ b/lib/zip/dos_time.rb @@ -1,5 +1,9 @@ +# frozen_string_literal: true + +require 'rubygems' + module Zip - class DOSTime < Time #:nodoc:all + class DOSTime < Time # :nodoc:all # MS-DOS File Date and Time format as used in Interrupt 21H Function 57H: # Register CX, the Time: @@ -12,6 +16,14 @@ class DOSTime < Time #:nodoc:all # bits 5-8 month (1-12) # bits 9-15 year (four digit year minus 1980) + attr_writer :absolute_time # :nodoc: + + def absolute_time? + # If absolute time is not set, we can assume it is an absolute time + # because times do have timezone information by default. + @absolute_time.nil? ? true : @absolute_time + end + def to_binary_dos_time (sec / 2) + (min << 5) + @@ -24,9 +36,16 @@ def to_binary_dos_date ((year - 1980) << 9) end - # Dos time is only stored with two seconds accuracy def dos_equals(other) - to_i / 2 == other.to_i / 2 + warn 'Zip::DOSTime#dos_equals is deprecated. Use `==` instead.' + self == other + end + + # Dos time is only stored with two seconds accuracy. + def <=>(other) + return unless other.kind_of?(Time) + + (to_i / 2) <=> (other.to_i / 2) end # Create a DOSTime instance from a vanilla Time instance. @@ -41,9 +60,36 @@ def self.parse_binary_dos_format(bin_dos_date, bin_dos_time) day = (0b11111 & bin_dos_date) month = (0b111100000 & bin_dos_date) >> 5 year = ((0b1111111000000000 & bin_dos_date) >> 9) + 1980 - begin - local(year, month, day, hour, minute, second) + + time = local(year, month, day, hour, minute, second) + time.absolute_time = false + time + end + + if defined? JRUBY_VERSION && Gem::Version.new(JRUBY_VERSION) < '9.2.18.0' + module JRubyCMP # :nodoc: + def ==(other) + (self <=> other).zero? + end + + def <(other) + (self <=> other).negative? + end + + def <=(other) + (self <=> other) <= 0 + end + + def >(other) + (self <=> other).positive? + end + + def >=(other) + (self <=> other) >= 0 + end end + + include JRubyCMP end end end diff --git a/lib/zip/entry.rb b/lib/zip/entry.rb index a67c6568..394f9190 100644 --- a/lib/zip/entry.rb +++ b/lib/zip/entry.rb @@ -1,21 +1,44 @@ +# frozen_string_literal: true + require 'pathname' + +require_relative 'dirtyable' + module Zip + # Zip::Entry represents an entry in a Zip archive. class Entry - STORED = 0 - DEFLATED = 8 + include Dirtyable + + # Constant used to specify that the entry is stored (i.e., not compressed). + STORED = ::Zip::COMPRESSION_METHOD_STORE + + # Constant used to specify that the entry is deflated (i.e., compressed). + DEFLATED = ::Zip::COMPRESSION_METHOD_DEFLATE + # Language encoding flag (EFS) bit - EFS = 0b100000000000 - - attr_accessor :comment, :compressed_size, :crc, :extra, :compression_method, - :name, :size, :local_header_offset, :zipfile, :fstype, :external_file_attributes, - :internal_file_attributes, - :gp_flags, :header_signature, :follow_symlinks, - :restore_times, :restore_permissions, :restore_ownership, - :unix_uid, :unix_gid, :unix_perms, - :dirty - attr_reader :ftype, :filepath # :nodoc: - - def set_default_vars_values + EFS = 0b100000000000 # :nodoc: + + # Compression level flags (used as part of the gp flags). + COMPRESSION_LEVEL_SUPERFAST_GPFLAG = 0b110 # :nodoc: + COMPRESSION_LEVEL_FAST_GPFLAG = 0b100 # :nodoc: + COMPRESSION_LEVEL_MAX_GPFLAG = 0b010 # :nodoc: + + attr_accessor :comment, :compressed_size, :follow_symlinks, :name, + :restore_ownership, :restore_permissions, :restore_times, + :unix_gid, :unix_perms, :unix_uid + + attr_accessor :crc, :external_file_attributes, :fstype, :gp_flags, + :internal_file_attributes, :local_header_offset # :nodoc: + + attr_reader :extra, :compression_level, :filepath # :nodoc: + + attr_writer :size # :nodoc: + + mark_dirty :comment=, :compressed_size=, :external_file_attributes=, + :fstype=, :gp_flags=, :name=, :size=, + :unix_gid=, :unix_perms=, :unix_uid= + + def set_default_vars_values # :nodoc: @local_header_offset = 0 @local_header_size = nil # not known until local entry is created or read @internal_file_attributes = 1 @@ -34,178 +57,252 @@ def set_default_vars_values end @follow_symlinks = false - @restore_times = false - @restore_permissions = false - @restore_ownership = false + @restore_times = DEFAULT_RESTORE_OPTIONS[:restore_times] + @restore_permissions = DEFAULT_RESTORE_OPTIONS[:restore_permissions] + @restore_ownership = DEFAULT_RESTORE_OPTIONS[:restore_ownership] # BUG: need an extra field to support uid/gid's @unix_uid = nil @unix_gid = nil @unix_perms = nil - # @posix_acl = nil - # @ntfs_acl = nil - @dirty = false end - def check_name(name) - return unless name.start_with?('/') - - raise ::Zip::EntryNameError, "Illegal ZipEntry name '#{name}', name must not start with /" + def check_name(name) # :nodoc: + raise EntryNameError, name if name.start_with?('/') + raise EntryNameError if name.length > 65_535 end - def initialize(*args) - name = args[1] || '' - check_name(name) + # Create a new Zip::Entry. + def initialize( + zipfile = '', name = '', + comment: '', size: nil, compressed_size: 0, crc: 0, + compression_method: DEFLATED, + compression_level: ::Zip.default_compression, + time: ::Zip::DOSTime.now, extra: ::Zip::ExtraField.new + ) + super() + @name = name + check_name(@name) set_default_vars_values @fstype = ::Zip::RUNNING_ON_WINDOWS ? ::Zip::FSTYPE_FAT : ::Zip::FSTYPE_UNIX - @zipfile = args[0] || '' - @name = name - @comment = args[2] || '' - @extra = args[3] || '' - @compressed_size = args[4] || 0 - @crc = args[5] || 0 - @compression_method = args[6] || ::Zip::Entry::DEFLATED - @size = args[7] || 0 - @time = args[8] || ::Zip::DOSTime.now - - @ftype = name_is_directory? ? :directory : :file - @extra = ::Zip::ExtraField.new(@extra.to_s) unless @extra.kind_of?(::Zip::ExtraField) - end - + @zipfile = zipfile + @comment = comment || '' + @compression_method = compression_method || DEFLATED + @compression_level = compression_level || ::Zip.default_compression + @compressed_size = compressed_size || 0 + @crc = crc || 0 + @size = size + @time = case time + when ::Zip::DOSTime + time + when Time + ::Zip::DOSTime.from_time(time) + else + ::Zip::DOSTime.now + end + @extra = + extra.kind_of?(ExtraField) ? extra : ExtraField.new(extra.to_s) + + set_compression_level_flags + end + + # Is this entry encrypted? def encrypted? gp_flags & 1 == 1 end - def incomplete? - gp_flags & 8 == 8 + def incomplete? # :nodoc: + (gp_flags & 8 == 8) && (crc == 0 || size == 0 || compressed_size == 0) end - def time - if @extra['UniversalTime'] - @extra['UniversalTime'].mtime - elsif @extra['NTFS'] - @extra['NTFS'].mtime - else - # Standard time field in central directory has local time - # under archive creator. Then, we can't get timezone. - @time - end + # The uncompressed size of the entry. + def size + @size || 0 + end + + # Get a timestamp component of this entry. + # + # Returns modification time by default. + def time(component: :mtime) + time = + if @extra['UniversalTime'] + @extra['UniversalTime'].send(component) + elsif @extra['NTFS'] + @extra['NTFS'].send(component) + end + + # Standard time field in central directory has local time + # under archive creator. Then, we can't get timezone. + time || (@time if component == :mtime) end alias mtime time - def time=(value) + # Get the last access time of this entry, if available. + def atime + time(component: :atime) + end + + # Get the creation time of this entry, if available. + def ctime + time(component: :ctime) + end + + # Set a timestamp component of this entry. + # + # Sets modification time by default. + def time=(value, component: :mtime) + @dirty = true unless @extra.member?('UniversalTime') || @extra.member?('NTFS') @extra.create('UniversalTime') end - (@extra['UniversalTime'] || @extra['NTFS']).mtime = value - @time = value + + value = DOSTime.from_time(value) + comp = "#{component}=" unless component.to_s.end_with?('=') + (@extra['UniversalTime'] || @extra['NTFS']).send(comp, value) + @time = value if component == :mtime end - def file_type_is?(type) - raise InternalError, "current filetype is unknown: #{inspect}" unless @ftype + alias mtime= time= - @ftype == type + # Set the last access time of this entry. + def atime=(value) + send(:time=, value, component: :atime) + end + + # Set the creation time of this entry. + def ctime=(value) + send(:time=, value, component: :ctime) + end + + # Does this entry return time fields with accurate timezone information? + def absolute_time? + @extra.member?('UniversalTime') || @extra.member?('NTFS') + end + + # Return the compression method for this entry. + # + # Returns STORED if the entry is a directory or if the compression + # level is 0. + def compression_method + return STORED if ftype == :directory || @compression_level == 0 + + @compression_method + end + + # Set the compression method for this entry. + def compression_method=(method) + @dirty = true + @compression_method = (ftype == :directory ? STORED : method) + end + + # Does this entry use the ZIP64 extensions? + def zip64? + !@extra['Zip64'].nil? + end + + def file_type_is?(type) # :nodoc: + ftype == type + end + + def ftype # :nodoc: + @ftype ||= name_is_directory? ? :directory : :file end # Dynamic checkers %w[directory file symlink].each do |k| - define_method "#{k}?" do + define_method :"#{k}?" do file_type_is?(k.to_sym) end end - def name_is_directory? #:nodoc:all + def name_is_directory? # :nodoc: @name.end_with?('/') end # Is the name a relative path, free of `..` patterns that could lead to # path traversal attacks? This does NOT handle symlinks; if the path # contains symlinks, this check is NOT enough to guarantee safety. - def name_safe? + def name_safe? # :nodoc: cleanpath = Pathname.new(@name).cleanpath return false unless cleanpath.relative? root = ::File::SEPARATOR - naive_expanded_path = ::File.join(root, cleanpath.to_s) - ::File.absolute_path(cleanpath.to_s, root) == naive_expanded_path + naive = ::File.join(root, cleanpath.to_s) + # Allow for Windows drive mappings at the root. + ::File.absolute_path(cleanpath.to_s, root).match?(/([A-Z]:)?#{naive}/i) end - def local_entry_offset #:nodoc:all + def local_entry_offset # :nodoc: local_header_offset + @local_header_size end - def name_size + def name_size # :nodoc: @name ? @name.bytesize : 0 end - def extra_size + def extra_size # :nodoc: @extra ? @extra.local_size : 0 end - def comment_size + def comment_size # :nodoc: @comment ? @comment.bytesize : 0 end - def calculate_local_header_size #:nodoc:all + def calculate_local_header_size # :nodoc: LOCAL_ENTRY_STATIC_HEADER_LENGTH + name_size + extra_size end # check before rewriting an entry (after file sizes are known) # that we didn't change the header size (and thus clobber file data or something) - def verify_local_header_size! + def verify_local_header_size! # :nodoc: return if @local_header_size.nil? new_size = calculate_local_header_size - raise Error, "local header size changed (#{@local_header_size} -> #{new_size})" if @local_header_size != new_size + return unless @local_header_size != new_size + + raise Error, + "Local header size changed (#{@local_header_size} -> #{new_size})" end - def cdir_header_size #:nodoc:all + def cdir_header_size # :nodoc: CDIR_ENTRY_STATIC_HEADER_LENGTH + name_size + (@extra ? @extra.c_dir_size : 0) + comment_size end - def next_header_offset #:nodoc:all - local_entry_offset + compressed_size + data_descriptor_size + def next_header_offset # :nodoc: + local_entry_offset + compressed_size end - # Extracts entry to file dest_path (defaults to @name). - # NB: The caller is responsible for making sure dest_path is safe, if it - # is passed. - def extract(dest_path = nil, &block) - if dest_path.nil? && !name_safe? - warn "WARNING: skipped '#{@name}' as unsafe." + # Extracts this entry to a file at `entry_path`, with + # `destination_directory` as the base location in the filesystem. + # + # NB: The caller is responsible for making sure `destination_directory` is + # safe, if it is passed. + def extract(entry_path = @name, destination_directory: '.', &block) + dest_dir = ::File.absolute_path(destination_directory || '.') + extract_path = ::File.absolute_path(::File.join(dest_dir, entry_path)) + + unless extract_path.start_with?(dest_dir) + warn "WARNING: skipped extracting '#{@name}' to '#{extract_path}' as unsafe." return self end - dest_path ||= @name block ||= proc { ::Zip.on_exists_proc } raise "unknown file type #{inspect}" unless directory? || file? || symlink? - __send__("create_#{@ftype}", dest_path, &block) + __send__(:"create_#{ftype}", extract_path, &block) self end - def to_s + def to_s # :nodoc: @name end class << self - def read_zip_short(io) # :nodoc: - io.read(2).unpack1('v') - end - - def read_zip_long(io) # :nodoc: - io.read(4).unpack1('V') - end - - def read_zip_64_long(io) # :nodoc: - io.read(8).unpack1('Q<') - end - - def read_c_dir_entry(io) #:nodoc:all + def read_c_dir_entry(io) # :nodoc: path = if io.respond_to?(:path) io.path else @@ -218,16 +315,18 @@ def read_c_dir_entry(io) #:nodoc:all nil end - def read_local_entry(io) + def read_local_entry(io) # :nodoc: entry = new(io) entry.read_local_entry(io) entry + rescue SplitArchiveError + raise rescue Error nil end end - def unpack_local_entry(buf) + def unpack_local_entry(buf) # :nodoc: @header_signature, @version, @fstype, @@ -242,62 +341,66 @@ def unpack_local_entry(buf) @extra_length = buf.unpack('VCCvvvvVVVvv') end - def read_local_entry(io) #:nodoc:all - @local_header_offset = io.tell + def read_local_entry(io) # :nodoc: + @dirty = false # No changes at this point. + current_offset = io.tell - static_sized_fields_buf = io.read(::Zip::LOCAL_ENTRY_STATIC_HEADER_LENGTH) || '' + read_local_header_fields(io) - unless static_sized_fields_buf.bytesize == ::Zip::LOCAL_ENTRY_STATIC_HEADER_LENGTH - raise Error, 'Premature end of file. Not enough data for zip entry local header' - end + if @header_signature == SPLIT_FILE_SIGNATURE + raise SplitArchiveError if current_offset.zero? - unpack_local_entry(static_sized_fields_buf) + # Rewind, skipping the data descriptor, then try to read the local header again. + current_offset += 16 + io.seek(current_offset) + read_local_header_fields(io) + end - unless @header_signature == ::Zip::LOCAL_ENTRY_SIGNATURE - raise ::Zip::Error, "Zip local header magic not found at location '#{local_header_offset}'" + unless @header_signature == LOCAL_ENTRY_SIGNATURE + raise Error, "Zip local header magic not found at location '#{current_offset}'" end + @local_header_offset = current_offset + set_time(@last_mod_date, @last_mod_time) @name = io.read(@name_length) - extra = io.read(@extra_length) - - @name.tr!('\\', '/') if ::Zip.force_entry_names_encoding @name.force_encoding(::Zip.force_entry_names_encoding) end + @name.tr!('\\', '/') # Normalise filepath separators after encoding set. + + # We need to do this here because `initialize` has so many side-effects. + # :-( + @ftype = name_is_directory? ? :directory : :file + extra = io.read(@extra_length) if extra && extra.bytesize != @extra_length raise ::Zip::Error, 'Truncated local zip entry header' end - if @extra.kind_of?(::Zip::ExtraField) - @extra.merge(extra) if extra - else - @extra = ::Zip::ExtraField.new(extra) - end - + read_extra_field(extra, local: true) parse_zip64_extra(true) @local_header_size = calculate_local_header_size end - def pack_local_entry + def pack_local_entry # :nodoc: zip64 = @extra['Zip64'] [::Zip::LOCAL_ENTRY_SIGNATURE, @version_needed_to_extract, # version needed to extract @gp_flags, # @gp_flags - @compression_method, + compression_method, @time.to_binary_dos_time, # @last_mod_time @time.to_binary_dos_date, # @last_mod_date @crc, zip64 && zip64.compressed_size ? 0xFFFFFFFF : @compressed_size, - zip64 && zip64.original_size ? 0xFFFFFFFF : @size, + zip64 && zip64.original_size ? 0xFFFFFFFF : (@size || 0), name_size, @extra ? @extra.local_size : 0].pack('VvvvvvVVVvv') end - def write_local_entry(io, rewrite = false) #:nodoc:all - prep_zip64_extra(true) + def write_local_entry(io, rewrite: false) # :nodoc: + prep_local_zip64_extra verify_local_header_size! if rewrite @local_header_offset = io.tell @@ -308,7 +411,7 @@ def write_local_entry(io, rewrite = false) #:nodoc:all @local_header_size = io.tell - @local_header_offset end - def unpack_c_dir_entry(buf) + def unpack_c_dir_entry(buf) # :nodoc: @header_signature, @version, # version of encoding software @fstype, # filesystem type @@ -332,7 +435,7 @@ def unpack_c_dir_entry(buf) @comment = buf.unpack('VCCvvvvvVVVvvvvvVV') end - def set_ftype_from_c_dir_entry + def set_ftype_from_c_dir_entry # :nodoc: @ftype = case @fstype when ::Zip::FSTYPE_UNIX @unix_perms = (@external_file_attributes >> 16) & 0o7777 @@ -344,8 +447,9 @@ def set_ftype_from_c_dir_entry when ::Zip::FILE_TYPE_SYMLINK :symlink else - # best case guess for whether it is a file or not - # Otherwise this would be set to unknown and that entry would never be able to extracted + # Best case guess for whether it is a file or not. + # Otherwise this would be set to unknown and that + # entry would never be able to be extracted. if name_is_directory? :directory else @@ -361,43 +465,47 @@ def set_ftype_from_c_dir_entry end end - def check_c_dir_entry_static_header_length(buf) - return if buf.bytesize == ::Zip::CDIR_ENTRY_STATIC_HEADER_LENGTH + def check_c_dir_entry_static_header_length(buf) # :nodoc: + return unless buf.nil? || buf.bytesize != ::Zip::CDIR_ENTRY_STATIC_HEADER_LENGTH raise Error, 'Premature end of file. Not enough data for zip cdir entry header' end - def check_c_dir_entry_signature - return if header_signature == ::Zip::CENTRAL_DIRECTORY_ENTRY_SIGNATURE + def check_c_dir_entry_signature # :nodoc: + return if @header_signature == ::Zip::CENTRAL_DIRECTORY_ENTRY_SIGNATURE raise Error, "Zip local header magic not found at location '#{local_header_offset}'" end - def check_c_dir_entry_comment_size + def check_c_dir_entry_comment_size # :nodoc: return if @comment && @comment.bytesize == @comment_length raise ::Zip::Error, 'Truncated cdir zip entry header' end - def read_c_dir_extra_field(io) + def read_extra_field(buf, local: false) # :nodoc: if @extra.kind_of?(::Zip::ExtraField) - @extra.merge(io.read(@extra_length)) + @extra.merge(buf, local: local) if buf else - @extra = ::Zip::ExtraField.new(io.read(@extra_length)) + @extra = ::Zip::ExtraField.new(buf, local: local) end end - def read_c_dir_entry(io) #:nodoc:all + def read_c_dir_entry(io) # :nodoc: + @dirty = false # No changes at this point. static_sized_fields_buf = io.read(::Zip::CDIR_ENTRY_STATIC_HEADER_LENGTH) check_c_dir_entry_static_header_length(static_sized_fields_buf) unpack_c_dir_entry(static_sized_fields_buf) check_c_dir_entry_signature set_time(@last_mod_date, @last_mod_time) + @name = io.read(@name_length) if ::Zip.force_entry_names_encoding @name.force_encoding(::Zip.force_entry_names_encoding) end - read_c_dir_extra_field(io) + @name.tr!('\\', '/') # Normalise filepath separators after encoding set. + + read_extra_field(io.read(@extra_length)) @comment = io.read(@comment_length) check_c_dir_entry_comment_size set_ftype_from_c_dir_entry @@ -413,27 +521,27 @@ def file_stat(path) # :nodoc: end def get_extra_attributes_from_path(path) # :nodoc: - return if Zip::RUNNING_ON_WINDOWS + stat = file_stat(path) + @time = DOSTime.from_time(stat.mtime) + return if ::Zip::RUNNING_ON_WINDOWS - stat = file_stat(path) @unix_uid = stat.uid @unix_gid = stat.gid @unix_perms = stat.mode & 0o7777 - @time = ::Zip::DOSTime.from_time(stat.mtime) end - def set_unix_attributes_on_path(dest_path) - # ignore setuid/setgid bits by default. honor if @restore_ownership - unix_perms_mask = 0o1777 - unix_perms_mask = 0o7777 if @restore_ownership - ::FileUtils.chmod(@unix_perms & unix_perms_mask, dest_path) if @restore_permissions && @unix_perms - ::FileUtils.chown(@unix_uid, @unix_gid, dest_path) if @restore_ownership && @unix_uid && @unix_gid && ::Process.egid == 0 - - # Restore the timestamp on a file. This will either have come from the - # original source file that was copied into the archive, or from the - # creation date of the archive if there was no original source file. - ::FileUtils.touch(dest_path, mtime: time) if @restore_times + # rubocop:disable Style/GuardClause + def set_unix_attributes_on_path(dest_path) # :nodoc: + # Ignore setuid/setgid bits by default. Honour if @restore_ownership. + unix_perms_mask = (@restore_ownership ? 0o7777 : 0o1777) + if @restore_permissions && @unix_perms + ::FileUtils.chmod(@unix_perms & unix_perms_mask, dest_path) + end + if @restore_ownership && @unix_uid && @unix_gid && ::Process.egid == 0 + ::FileUtils.chown(@unix_uid, @unix_gid, dest_path) + end end + # rubocop:enable Style/GuardClause def set_extra_attributes_on_path(dest_path) # :nodoc: return unless file? || directory? @@ -442,9 +550,14 @@ def set_extra_attributes_on_path(dest_path) # :nodoc: when ::Zip::FSTYPE_UNIX set_unix_attributes_on_path(dest_path) end + + # Restore the timestamp on a file. This will either have come from the + # original source file that was copied into the archive, or from the + # creation date of the archive if there was no original source file. + ::FileUtils.touch(dest_path, mtime: time) if @restore_times end - def pack_c_dir_entry + def pack_c_dir_entry # :nodoc: zip64 = @extra['Zip64'] [ @header_signature, @@ -452,12 +565,12 @@ def pack_c_dir_entry @fstype, # filesystem type @version_needed_to_extract, # @versionNeededToExtract @gp_flags, # @gp_flags - @compression_method, + compression_method, @time.to_binary_dos_time, # @last_mod_time @time.to_binary_dos_date, # @last_mod_date @crc, zip64 && zip64.compressed_size ? 0xFFFFFFFF : @compressed_size, - zip64 && zip64.original_size ? 0xFFFFFFFF : @size, + zip64 && zip64.original_size ? 0xFFFFFFFF : (@size || 0), name_size, @extra ? @extra.c_dir_size : 0, comment_size, @@ -471,11 +584,12 @@ def pack_c_dir_entry ].pack('VCCvvvvvVVVvvvvvVV') end - def write_c_dir_entry(io) #:nodoc:all - prep_zip64_extra(false) + def write_c_dir_entry(io) # :nodoc: + prep_cdir_zip64_extra + case @fstype when ::Zip::FSTYPE_UNIX - ft = case @ftype + ft = case ftype when :file @unix_perms ||= 0o644 ::Zip::FILE_TYPE_FILE @@ -488,7 +602,7 @@ def write_c_dir_entry(io) #:nodoc:all end unless ft.nil? - @external_file_attributes = (ft << 12 | (@unix_perms & 0o7777)) << 16 + @external_file_attributes = ((ft << 12) | (@unix_perms & 0o7777)) << 16 end end @@ -499,43 +613,42 @@ def write_c_dir_entry(io) #:nodoc:all io << @comment end - def ==(other) + def ==(other) # :nodoc: return false unless other.class == self.class # Compares contents of local entry and exposed fields - keys_equal = %w[compression_method crc compressed_size size name extra filepath].all? do |k| + %w[compression_method crc compressed_size size name extra filepath time].all? do |k| other.__send__(k.to_sym) == __send__(k.to_sym) end - keys_equal && time.dos_equals(other.time) end - def <=>(other) + def <=>(other) # :nodoc: to_s <=> other.to_s end # Returns an IO like object for the given ZipEntry. # Warning: may behave weird with symlinks. def get_input_stream(&block) - if @ftype == :directory - yield ::Zip::NullInputStream if block_given? + if ftype == :directory + yield ::Zip::NullInputStream if block ::Zip::NullInputStream elsif @filepath - case @ftype + case ftype when :file ::File.open(@filepath, 'rb', &block) when :symlink linkpath = ::File.readlink(@filepath) stringio = ::StringIO.new(linkpath) - yield(stringio) if block_given? + yield(stringio) if block stringio else - raise "unknown @file_type #{@ftype}" + raise "unknown @file_type #{ftype}" end else - zis = ::Zip::InputStream.new(@zipfile, local_header_offset) + zis = ::Zip::InputStream.new(@zipfile, offset: local_header_offset) zis.instance_variable_set(:@complete_entry, self) zis.get_next_entry - if block_given? + if block begin yield(zis) ensure @@ -554,7 +667,7 @@ def gather_fileinfo_from_srcpath(src_path) # :nodoc: if name_is_directory? raise ArgumentError, "entry name '#{newEntry}' indicates directory entry, but " \ - "'#{src_path}' is not a directory" + "'#{src_path}' is not a directory" end :file when 'directory' @@ -564,7 +677,7 @@ def gather_fileinfo_from_srcpath(src_path) # :nodoc: if name_is_directory? raise ArgumentError, "entry name '#{newEntry}' indicates directory entry, but " \ - "'#{src_path}' is not a directory" + "'#{src_path}' is not a directory" end :symlink else @@ -572,27 +685,30 @@ def gather_fileinfo_from_srcpath(src_path) # :nodoc: end @filepath = src_path + @size = stat.size get_extra_attributes_from_path(@filepath) end - def write_to_zip_output_stream(zip_output_stream) #:nodoc:all - if @ftype == :directory - zip_output_stream.put_next_entry(self, nil, nil, ::Zip::Entry::STORED) + def write_to_zip_output_stream(zip_output_stream) # :nodoc: + if ftype == :directory + zip_output_stream.put_next_entry(self) elsif @filepath - zip_output_stream.put_next_entry(self, nil, nil, compression_method || ::Zip::Entry::DEFLATED) - get_input_stream { |is| ::Zip::IOExtras.copy_stream(zip_output_stream, is) } + zip_output_stream.put_next_entry(self) + get_input_stream do |is| + ::Zip::IOExtras.copy_stream(zip_output_stream, is) + end else zip_output_stream.copy_raw_entry(self) end end - def parent_as_string + def parent_as_string # :nodoc: entry_name = name.chomp('/') slash_index = entry_name.rindex('/') slash_index ? entry_name.slice(0, slash_index + 1) : nil end - def get_raw_input_stream(&block) + def get_raw_input_stream(&block) # :nodoc: if @zipfile.respond_to?(:seek) && @zipfile.respond_to?(:read) yield @zipfile else @@ -600,12 +716,22 @@ def get_raw_input_stream(&block) end end - def clean_up - # By default, do nothing + def clean_up # :nodoc: + @dirty = false # Any changes are written at this point. end private + def read_local_header_fields(io) # :nodoc: + static_sized_fields_buf = io.read(::Zip::LOCAL_ENTRY_STATIC_HEADER_LENGTH) || '' + + unless static_sized_fields_buf.bytesize == ::Zip::LOCAL_ENTRY_STATIC_HEADER_LENGTH + raise Error, 'Premature end of file. Not enough data for zip entry local header' + end + + unpack_local_entry(static_sized_fields_buf) + end + def set_time(binary_dos_date, binary_dos_time) @time = ::Zip::DOSTime.parse_binary_dos_format(binary_dos_date, binary_dos_time) rescue ArgumentError @@ -614,9 +740,9 @@ def set_time(binary_dos_date, binary_dos_time) def create_file(dest_path, _continue_on_exists_proc = proc { Zip.continue_on_exists_proc }) if ::File.exist?(dest_path) && !yield(self, dest_path) - raise ::Zip::DestinationFileExistsError, - "Destination '#{dest_path}' already exists" + raise ::Zip::DestinationExistsError, dest_path end + ::File.open(dest_path, 'wb') do |os| get_input_stream do |is| bytes_written = 0 @@ -627,10 +753,10 @@ def create_file(dest_path, _continue_on_exists_proc = proc { Zip.continue_on_exi bytes_written += buf.bytesize next unless bytes_written > size && !warned - message = "entry '#{name}' should be #{size}B, but is larger when inflated." - raise ::Zip::EntrySizeError, message if ::Zip.validate_entry_sizes + error = ::Zip::EntrySizeError.new(self) + raise error if ::Zip.validate_entry_sizes - warn "WARNING: #{message}" + warn "WARNING: #{error.message}" warned = true end end @@ -643,14 +769,11 @@ def create_directory(dest_path) return if ::File.directory?(dest_path) if ::File.exist?(dest_path) - if block_given? && yield(self, dest_path) - ::FileUtils.rm_f dest_path - else - raise ::Zip::DestinationFileExistsError, - "Cannot create directory '#{dest_path}'. " \ - 'A file already exists with that name' - end + raise ::Zip::DestinationExistsError, dest_path unless block_given? && yield(self, dest_path) + + ::FileUtils.rm_f dest_path end + ::FileUtils.mkdir_p(dest_path) set_extra_attributes_on_path(dest_path) end @@ -664,53 +787,71 @@ def create_symlink(dest_path) # apply missing data from the zip64 extra information field, if present # (required when file sizes exceed 2**32, but can be used for all files) - def parse_zip64_extra(for_local_header) #:nodoc:all - return if @extra['Zip64'].nil? + def parse_zip64_extra(for_local_header) # :nodoc: + return unless zip64? if for_local_header @size, @compressed_size = @extra['Zip64'].parse(@size, @compressed_size) else - @size, @compressed_size, @local_header_offset = @extra['Zip64'].parse(@size, @compressed_size, @local_header_offset) + @size, @compressed_size, @local_header_offset = @extra['Zip64'].parse( + @size, @compressed_size, @local_header_offset + ) end end - def data_descriptor_size - (@gp_flags & 0x0008) > 0 ? 16 : 0 + # For DEFLATED compression *only*: set the general purpose flags 1 and 2 to + # indicate compression level. This seems to be mainly cosmetic but they are + # generally set by other tools - including in docx files. It is these flags + # that are used by commandline tools (and elsewhere) to give an indication + # of how compressed a file is. See the PKWARE APPNOTE for more information: + # https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT + # + # It's safe to simply OR these flags here as compression_level is read only. + def set_compression_level_flags + return unless compression_method == DEFLATED + + case @compression_level + when 1 + @gp_flags |= COMPRESSION_LEVEL_SUPERFAST_GPFLAG + when 2 + @gp_flags |= COMPRESSION_LEVEL_FAST_GPFLAG + when 8, 9 + @gp_flags |= COMPRESSION_LEVEL_MAX_GPFLAG + end end - # create a zip64 extra information field if we need one - def prep_zip64_extra(for_local_header) #:nodoc:all + # rubocop:disable Style/GuardClause + def prep_local_zip64_extra return unless ::Zip.write_zip64_support + return if (!zip64? && @size && @size < 0xFFFFFFFF) || !file? - need_zip64 = @size >= 0xFFFFFFFF || @compressed_size >= 0xFFFFFFFF - need_zip64 ||= @local_header_offset >= 0xFFFFFFFF unless for_local_header - if need_zip64 + # Might not know size here, so need ZIP64 just in case. + # If we already have a ZIP64 extra (placeholder) then we must fill it in. + if zip64? || @size.nil? || @size >= 0xFFFFFFFF || @compressed_size >= 0xFFFFFFFF @version_needed_to_extract = VERSION_NEEDED_TO_EXTRACT_ZIP64 - @extra.delete('Zip64Placeholder') - zip64 = @extra.create('Zip64') - if for_local_header - # local header always includes size and compressed size - zip64.original_size = @size - zip64.compressed_size = @compressed_size - else - # central directory entry entries include whichever fields are necessary - zip64.original_size = @size if @size >= 0xFFFFFFFF - zip64.compressed_size = @compressed_size if @compressed_size >= 0xFFFFFFFF - zip64.relative_header_offset = @local_header_offset if @local_header_offset >= 0xFFFFFFFF - end - else - @extra.delete('Zip64') + zip64 = @extra['Zip64'] || @extra.create('Zip64') - # if this is a local header entry, create a placeholder - # so we have room to write a zip64 extra field afterward - # (we won't know if it's needed until the file data is written) - if for_local_header - @extra.create('Zip64Placeholder') - else - @extra.delete('Zip64Placeholder') - end + # Local header always includes size and compressed size. + zip64.original_size = @size || 0 + zip64.compressed_size = @compressed_size + end + end + + def prep_cdir_zip64_extra + return unless ::Zip.write_zip64_support + + if (@size && @size >= 0xFFFFFFFF) || @compressed_size >= 0xFFFFFFFF || + @local_header_offset >= 0xFFFFFFFF + @version_needed_to_extract = VERSION_NEEDED_TO_EXTRACT_ZIP64 + zip64 = @extra['Zip64'] || @extra.create('Zip64') + + # Central directory entry entries include whichever fields are necessary. + zip64.original_size = @size if @size && @size >= 0xFFFFFFFF + zip64.compressed_size = @compressed_size if @compressed_size >= 0xFFFFFFFF + zip64.relative_header_offset = @local_header_offset if @local_header_offset >= 0xFFFFFFFF end end + # rubocop:enable Style/GuardClause end end diff --git a/lib/zip/entry_set.rb b/lib/zip/entry_set.rb index 9c503781..cb4700d6 100644 --- a/lib/zip/entry_set.rb +++ b/lib/zip/entry_set.rb @@ -1,7 +1,11 @@ +# frozen_string_literal: true + module Zip - class EntrySet #:nodoc:all + class EntrySet # :nodoc:all include Enumerable - attr_accessor :entry_set, :entry_order + + attr_reader :entry_set + protected :entry_set def initialize(an_enumerable = []) super() @@ -33,10 +37,8 @@ def delete(entry) entry if @entry_set.delete(to_key(entry)) end - def each - @entry_set = sorted_entries.dup.each do |_, value| - yield(value) - end + def each(&block) + entries.each(&block) end def entries @@ -59,18 +61,18 @@ def parent(entry) end def glob(pattern, flags = ::File::FNM_PATHNAME | ::File::FNM_DOTMATCH | ::File::FNM_EXTGLOB) - entries.map do |entry| + entries.filter_map do |entry| next nil unless ::File.fnmatch(pattern, entry.name.chomp('/'), flags) yield(entry) if block_given? entry - end.compact + end end protected def sorted_entries - ::Zip.sort_entries ? Hash[@entry_set.sort] : @entry_set + ::Zip.sort_entries ? @entry_set.sort.to_h : @entry_set end private diff --git a/lib/zip/errors.rb b/lib/zip/errors.rb index 0ff0e1e1..f172a77f 100644 --- a/lib/zip/errors.rb +++ b/lib/zip/errors.rb @@ -1,19 +1,139 @@ +# frozen_string_literal: true + module Zip + # The superclass for all rubyzip error types. Simply rescue this one if + # you don't need to know what sort of error has been raised. class Error < StandardError; end - class EntryExistsError < Error; end - class DestinationFileExistsError < Error; end - class CompressionMethodError < Error; end - class EntryNameError < Error; end - class EntrySizeError < Error; end - class InternalError < Error; end - class GPFBit3Error < Error; end - class DecompressionError < Error; end - - # Backwards compatibility with v1 (delete in v2) - ZipError = Error - ZipEntryExistsError = EntryExistsError - ZipDestinationFileExistsError = DestinationFileExistsError - ZipCompressionMethodError = CompressionMethodError - ZipEntryNameError = EntryNameError - ZipInternalError = InternalError + + # Error raised if an unsupported compression method is used. + class CompressionMethodError < Error + # The compression method that has caused this error. + attr_reader :compression_method + + # Create a new CompressionMethodError with the specified incorrect + # compression method. + def initialize(method) + super() + @compression_method = method + end + + # The message returned by this error. + def message + "Unsupported compression method: #{COMPRESSION_METHODS[@compression_method]}." + end + end + + # Error raised if there is a problem while decompressing an archive entry. + class DecompressionError < Error + # The error from the underlying Zlib library that caused this error. + attr_reader :zlib_error + + # Create a new DecompressionError with the specified underlying Zlib + # error. + def initialize(zlib_error) + super() + @zlib_error = zlib_error + end + + # The message returned by this error. + def message + "Zlib error ('#{@zlib_error.message}') while inflating." + end + end + + # Error raised when trying to extract an archive entry over an + # existing file. + class DestinationExistsError < Error + # Create a new DestinationExistsError with the clashing destination. + def initialize(destination) + super() + @destination = destination + end + + # The message returned by this error. + def message + "Cannot create file or directory '#{@destination}'. " \ + 'A file already exists with that name.' + end + end + + # Error raised when trying to add an entry to an archive where the + # entry name already exists. + class EntryExistsError < Error + # Create a new EntryExistsError with the specified source and name. + def initialize(source, name) + super() + @source = source + @name = name + end + + # The message returned by this error. + def message + "'#{@source}' failed. Entry #{@name} already exists." + end + end + + # Error raised when an entry name is invalid. + class EntryNameError < Error + # Create a new EntryNameError with the specified name. + def initialize(name = nil) + super() + @name = name + end + + # The message returned by this error. + def message + if @name.nil? + 'Illegal entry name. Names must have fewer than 65,536 characters.' + else + "Illegal entry name '#{@name}'. Names must not start with '/'." + end + end + end + + # Error raised if an entry is larger on extraction than it is advertised + # to be. + class EntrySizeError < Error + # The entry that has caused this error. + attr_reader :entry + + # Create a new EntrySizeError with the specified entry. + def initialize(entry) + super() + @entry = entry + end + + # The message returned by this error. + def message + "Entry '#{@entry.name}' should be #{@entry.size}B, but is larger when inflated." + end + end + + # Error raised if a split archive is read. Rubyzip does not support reading + # split archives. + class SplitArchiveError < Error + # The message returned by this error. + def message + 'Rubyzip cannot extract from split archives at this time.' + end + end + + # Error raised if there is not enough metadata for the entry to be streamed. + class StreamingError < Error + # The entry that has caused this error. + attr_reader :entry + + # Create a new StreamingError with the specified entry. + def initialize(entry) + super() + @entry = entry + end + + # The message returned by this error. + def message + "The local header of this entry ('#{@entry.name}') does not contain " \ + 'the correct metadata for `Zip::InputStream` to be able to ' \ + 'uncompress it. Please use `Zip::File` instead of `Zip::InputStream`.' + end + end end diff --git a/lib/zip/extra_field.rb b/lib/zip/extra_field.rb index aa3ef8a8..31d75d2c 100644 --- a/lib/zip/extra_field.rb +++ b/lib/zip/extra_field.rb @@ -1,9 +1,11 @@ +# frozen_string_literal: true + module Zip - class ExtraField < Hash + class ExtraField < Hash # :nodoc:all ID_MAP = {} - def initialize(binstr = nil) - merge(binstr) if binstr + def initialize(binstr = nil, local: false) + merge(binstr, local: local) if binstr end def extra_field_type_exist(binstr, id, len, index) @@ -16,25 +18,18 @@ def extra_field_type_exist(binstr, id, len, index) end end - def extra_field_type_unknown(binstr, len, index) - create_unknown_item unless self['Unknown'] - if !len || len + 4 > binstr[index..-1].bytesize - self['Unknown'] << binstr[index..-1] + def extra_field_type_unknown(binstr, len, index, local) + self['Unknown'] ||= Unknown.new + + if !len || len + 4 > binstr[index..].bytesize + self['Unknown'].merge(binstr[index..], local: local) return end - self['Unknown'] << binstr[index, len + 4] - end - def create_unknown_item - s = +'' - class << s - alias_method :to_c_dir_bin, :to_s - alias_method :to_local_bin, :to_s - end - self['Unknown'] = s + self['Unknown'].merge(binstr[index, len + 4], local: local) end - def merge(binstr) + def merge(binstr, local: false) return if binstr.empty? i = 0 @@ -44,8 +39,7 @@ def merge(binstr) if id && ID_MAP.member?(id) extra_field_type_exist(binstr, id, len, i) elsif id - create_unknown_item unless self['Unknown'] - break unless extra_field_type_unknown(binstr, len, i) + break unless extra_field_type_unknown(binstr, len, i, local) end i += len + 4 end @@ -59,8 +53,8 @@ def create(name) self[name] = field_class.new end - # place Unknown last, so "extra" data that is missing the proper signature/size - # does not prevent known fields from being read back in + # Place Unknown last, so "extra" data that is missing the proper + # signature/size does not prevent known fields from being read back in. def ordered_values result = [] each { |k, v| k == 'Unknown' ? result.push(v) : result.unshift(v) } @@ -90,12 +84,12 @@ def local_size end end +require 'zip/extra_field/unknown' require 'zip/extra_field/generic' require 'zip/extra_field/universal_time' require 'zip/extra_field/old_unix' require 'zip/extra_field/unix' require 'zip/extra_field/zip64' -require 'zip/extra_field/zip64_placeholder' require 'zip/extra_field/ntfs' # Copyright (C) 2002, 2003 Thomas Sondergaard diff --git a/lib/zip/extra_field/generic.rb b/lib/zip/extra_field/generic.rb index 5eb702d6..ddd8eed6 100644 --- a/lib/zip/extra_field/generic.rb +++ b/lib/zip/extra_field/generic.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + module Zip - class ExtraField::Generic + class ExtraField::Generic # :nodoc: def self.register_map return unless const_defined?(:HEADER_ID) @@ -19,26 +21,17 @@ def initial_parse(binstr) return false end - [binstr[2, 2].unpack1('v'), binstr[4..-1]] - end - - def ==(other) - return false if self.class != other.class - - each do |k, v| - return false if v != other[k] - end - true + [binstr[2, 2].unpack1('v'), binstr[4..]] end def to_local_bin s = pack_for_local - self.class.const_get(:HEADER_ID) + [s.bytesize].pack('v') << s + (self.class.const_get(:HEADER_ID) + [s.bytesize].pack('v')) << s end def to_c_dir_bin s = pack_for_c_dir - self.class.const_get(:HEADER_ID) + [s.bytesize].pack('v') << s + (self.class.const_get(:HEADER_ID) + [s.bytesize].pack('v')) << s end end end diff --git a/lib/zip/extra_field/ntfs.rb b/lib/zip/extra_field/ntfs.rb index f4f11b2d..eac99ad6 100644 --- a/lib/zip/extra_field/ntfs.rb +++ b/lib/zip/extra_field/ntfs.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + module Zip # PKWARE NTFS Extra Field (0x000a) # Only Tag 0x0001 is supported - class ExtraField::NTFS < ExtraField::Generic + class ExtraField::NTFS < ExtraField::Generic # :nodoc: HEADER_ID = [0x000A].pack('v') register_map @@ -23,7 +25,7 @@ def merge(binstr) size, content = initial_parse(binstr) (size && content) || return - content = content[4..-1] + content = content[4..] tags = parse_tags(content) tag1 = tags[1] @@ -51,7 +53,7 @@ def pack_for_c_dir # reserved 0 and tag 1 s = [0, 1].pack('Vv') - tag1 = ''.force_encoding(Encoding::BINARY) + tag1 = (+'').force_encoding(Encoding::BINARY) if @mtime tag1 << [to_ntfs_time(@mtime)].pack('Q<') if @atime @@ -84,7 +86,7 @@ def parse_tags(content) end def from_ntfs_time(ntfs_time) - ::Zip::DOSTime.at(ntfs_time / WINDOWS_TICK - SEC_TO_UNIX_EPOCH) + ::Zip::DOSTime.at((ntfs_time / WINDOWS_TICK) - SEC_TO_UNIX_EPOCH) end def to_ntfs_time(time) diff --git a/lib/zip/extra_field/old_unix.rb b/lib/zip/extra_field/old_unix.rb index dfd2ba56..0407d5da 100644 --- a/lib/zip/extra_field/old_unix.rb +++ b/lib/zip/extra_field/old_unix.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + module Zip # Olf Info-ZIP Extra for UNIX uid/gid and file timestampes - class ExtraField::OldUnix < ExtraField::Generic + class ExtraField::OldUnix < ExtraField::Generic # :nodoc: HEADER_ID = 'UX' register_map diff --git a/lib/zip/extra_field/universal_time.rb b/lib/zip/extra_field/universal_time.rb index 424c281d..b77b321b 100644 --- a/lib/zip/extra_field/universal_time.rb +++ b/lib/zip/extra_field/universal_time.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + module Zip # Info-ZIP Additional timestamp field - class ExtraField::UniversalTime < ExtraField::Generic + class ExtraField::UniversalTime < ExtraField::Generic # :nodoc: HEADER_ID = 'UT' register_map diff --git a/lib/zip/extra_field/unix.rb b/lib/zip/extra_field/unix.rb index 9a66c81d..59207a23 100644 --- a/lib/zip/extra_field/unix.rb +++ b/lib/zip/extra_field/unix.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + module Zip # Info-ZIP Extra for UNIX uid/gid - class ExtraField::IUnix < ExtraField::Generic + class ExtraField::IUnix < ExtraField::Generic # :nodoc: HEADER_ID = 'Ux' register_map @@ -20,8 +22,8 @@ def merge(binstr) return if !size || size == 0 uid, gid = content.unpack('vv') - @uid ||= uid - @gid ||= gid # rubocop:disable Naming/MemoizedInstanceVariableName + @uid = uid + @gid = gid end def ==(other) diff --git a/lib/zip/extra_field/unknown.rb b/lib/zip/extra_field/unknown.rb new file mode 100644 index 00000000..46f1f03d --- /dev/null +++ b/lib/zip/extra_field/unknown.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Zip + # A class to hold unknown extra fields so that they are preserved. + class ExtraField::Unknown # :nodoc: + def initialize + @local_bin = +'' + @cdir_bin = +'' + end + + def merge(binstr, local: false) + return if binstr.empty? + + if local + @local_bin << binstr + else + @cdir_bin << binstr + end + end + + def to_local_bin + @local_bin + end + + def to_c_dir_bin + @cdir_bin + end + + def ==(other) + @local_bin == other.to_local_bin && @cdir_bin == other.to_c_dir_bin + end + end +end diff --git a/lib/zip/extra_field/zip64.rb b/lib/zip/extra_field/zip64.rb index 9826c6cf..14778970 100644 --- a/lib/zip/extra_field/zip64.rb +++ b/lib/zip/extra_field/zip64.rb @@ -1,7 +1,11 @@ +# frozen_string_literal: true + module Zip # Info-ZIP Extra for Zip64 size - class ExtraField::Zip64 < ExtraField::Generic - attr_accessor :original_size, :compressed_size, :relative_header_offset, :disk_start_number + class ExtraField::Zip64 < ExtraField::Generic # :nodoc: + attr_accessor :compressed_size, :disk_start_number, + :original_size, :relative_header_offset + HEADER_ID = ['0100'].pack('H*') register_map @@ -36,7 +40,9 @@ def merge(binstr) def parse(original_size, compressed_size, relative_header_offset = nil, disk_start_number = nil) @original_size = extract(8, 'Q<') if original_size == 0xFFFFFFFF @compressed_size = extract(8, 'Q<') if compressed_size == 0xFFFFFFFF - @relative_header_offset = extract(8, 'Q<') if relative_header_offset && relative_header_offset == 0xFFFFFFFF + if relative_header_offset && relative_header_offset == 0xFFFFFFFF + @relative_header_offset = extract(8, 'Q<') + end @disk_start_number = extract(4, 'V') if disk_start_number && disk_start_number == 0xFFFF @content = nil [@original_size || original_size, @@ -51,7 +57,8 @@ def extract(size, format) private :extract def pack_for_local - # local header entries must contain original size and compressed size; other fields do not apply + # Local header entries must contain original size and compressed size; + # other fields do not apply. return '' unless @original_size && @compressed_size [@original_size, @compressed_size].pack('Qmy.zip + # The following example opens zip archive `my.zip` # (creating it if it doesn't exist) and adds an entry - # first.txt and a directory entry a_dir + # `first.txt` and a directory entry `a_dir` # to it. # - # require 'zip' + # ``` + # require 'zip' # - # Zip::File.open("my.zip", Zip::File::CREATE) { - # |zipfile| - # zipfile.get_output_stream("first.txt") { |f| f.puts "Hello from ZipFile" } - # zipfile.mkdir("a_dir") - # } + # Zip::File.open('my.zip', create: true) do |zipfile| + # zipfile.get_output_stream('first.txt') { |f| f.puts 'Hello from Zip::File' } + # zipfile.mkdir('a_dir') + # end + # ``` # - # The next example reopens my.zip writes the contents of - # first.txt to standard out and deletes the entry from + # The next example reopens `my.zip`, writes the contents of + # `first.txt` to standard out and deletes the entry from # the archive. # - # require 'zip' + # ``` + # require 'zip' # - # Zip::File.open("my.zip", Zip::File::CREATE) { - # |zipfile| - # puts zipfile.read("first.txt") - # zipfile.remove("first.txt") - # } + # Zip::File.open('my.zip', create: true) do |zipfile| + # puts zipfile.read('first.txt') + # zipfile.remove('first.txt') + # end # - # ZipFileSystem offers an alternative API that emulates ruby's - # interface for accessing the filesystem, ie. the File and Dir classes. - - class File < CentralDirectory - CREATE = true - SPLIT_SIGNATURE = 0x08074b50 - ZIP64_EOCD_SIGNATURE = 0x06064b50 - MAX_SEGMENT_SIZE = 3_221_225_472 - MIN_SEGMENT_SIZE = 65_536 - DATA_BUFFER_SIZE = 8192 - IO_METHODS = [:tell, :seek, :read, :eof, :close] - - DEFAULT_OPTIONS = { - restore_ownership: false, - restore_permissions: false, - restore_times: false - }.freeze + # Zip::FileSystem offers an alternative API that emulates ruby's + # interface for accessing the filesystem, ie. the ::File and ::Dir classes. + class File + extend Forwardable + extend FileSplit + IO_METHODS = [:tell, :seek, :read, :eof, :close].freeze # :nodoc: + + # The name of this zip archive. attr_reader :name # default -> false. attr_accessor :restore_ownership - # default -> false, but will be set to true in a future version. + # default -> true. attr_accessor :restore_permissions - # default -> false, but will be set to true in a future version. + # default -> true. attr_accessor :restore_times - # Returns the zip files comment, if it has one - attr_accessor :comment + def_delegators :@cdir, :comment, :comment=, :each, :entries, :glob, :size - # Opens a zip archive. Pass true as the second parameter to create + # Opens a zip archive. Pass create: true to create # a new archive if it doesn't exist already. - def initialize(path_or_io, create = false, buffer = false, options = {}) + def initialize(path_or_io, create: false, buffer: false, + restore_ownership: DEFAULT_RESTORE_OPTIONS[:restore_ownership], + restore_permissions: DEFAULT_RESTORE_OPTIONS[:restore_permissions], + restore_times: DEFAULT_RESTORE_OPTIONS[:restore_times], + compression_level: ::Zip.default_compression) super() - options = DEFAULT_OPTIONS.merge(options) + @name = path_or_io.respond_to?(:path) ? path_or_io.path : path_or_io - @comment = '' @create = create ? true : false # allow any truthy value to mean true - if ::File.size?(@name.to_s) - # There is a file, which exists, that is associated with this zip. - @create = false - @file_permissions = ::File.stat(@name).mode - - if buffer - read_from_stream(path_or_io) - else - ::File.open(@name, 'rb') do |f| - read_from_stream(f) - end - end - elsif buffer && path_or_io.size > 0 - # This zip is probably a non-empty StringIO. - read_from_stream(path_or_io) - elsif @create - # This zip is completely new/empty and is to be created. - @entry_set = EntrySet.new - elsif ::File.zero?(@name) - # A file exists, but it is empty. - raise Error, "File #{@name} has zero size. Did you mean to pass the create flag?" - else - # Everything is wrong. - raise Error, "File #{@name} not found" - end + initialize_cdir(path_or_io, buffer: buffer) - @stored_entries = @entry_set.dup - @stored_comment = @comment - @restore_ownership = options[:restore_ownership] - @restore_permissions = options[:restore_permissions] - @restore_times = options[:restore_times] + @restore_ownership = restore_ownership + @restore_permissions = restore_permissions + @restore_times = restore_times + @compression_level = compression_level end class << self # Similar to ::new. If a block is passed the Zip::File object is passed # to the block and is automatically closed afterwards, just as with # ruby's builtin File::open method. - def open(file_name, create = false, options = {}) - zf = ::Zip::File.new(file_name, create, false, options) + def open(file_name, create: false, + restore_ownership: DEFAULT_RESTORE_OPTIONS[:restore_ownership], + restore_permissions: DEFAULT_RESTORE_OPTIONS[:restore_permissions], + restore_times: DEFAULT_RESTORE_OPTIONS[:restore_times], + compression_level: ::Zip.default_compression) + + zf = ::Zip::File.new(file_name, create: create, + restore_ownership: restore_ownership, + restore_permissions: restore_permissions, + restore_times: restore_times, + compression_level: compression_level) + return zf unless block_given? begin @@ -128,29 +113,29 @@ def open(file_name, create = false, options = {}) end end - # Same as #open. But outputs data to a buffer instead of a file - def add_buffer - io = ::StringIO.new('') - zf = ::Zip::File.new(io, true, true) - yield zf - zf.write_buffer(io) - end - # Like #open, but reads zip archive contents from a String or open IO # stream, and outputs data to a buffer. # (This can be used to extract data from a # downloaded zip archive without first saving it to disk.) - def open_buffer(io, options = {}) + def open_buffer(io = ::StringIO.new, create: false, + restore_ownership: DEFAULT_RESTORE_OPTIONS[:restore_ownership], + restore_permissions: DEFAULT_RESTORE_OPTIONS[:restore_permissions], + restore_times: DEFAULT_RESTORE_OPTIONS[:restore_times], + compression_level: ::Zip.default_compression) + unless IO_METHODS.map { |method| io.respond_to?(method) }.all? || io.kind_of?(String) - raise "Zip::File.open_buffer expects a String or IO-like argument (responds to #{IO_METHODS.join(', ')}). Found: #{io.class}" + raise 'Zip::File.open_buffer expects a String or IO-like argument' \ + "(responds to #{IO_METHODS.join(', ')}). Found: #{io.class}" end io = ::StringIO.new(io) if io.kind_of?(::String) - # https://github.com/rubyzip/rubyzip/issues/119 - io.binmode if io.respond_to?(:binmode) + zf = ::Zip::File.new(io, create: create, buffer: true, + restore_ownership: restore_ownership, + restore_permissions: restore_permissions, + restore_times: restore_times, + compression_level: compression_level) - zf = ::Zip::File.new(io, true, true, options) return zf unless block_given? yield zf @@ -174,81 +159,18 @@ def foreach(zip_file_name, &block) end end - def get_segment_size_for_split(segment_size) - if MIN_SEGMENT_SIZE > segment_size - MIN_SEGMENT_SIZE - elsif MAX_SEGMENT_SIZE < segment_size - MAX_SEGMENT_SIZE - else - segment_size - end - end - - def get_partial_zip_file_name(zip_file_name, partial_zip_file_name) - unless partial_zip_file_name.nil? - partial_zip_file_name = zip_file_name.sub(/#{::File.basename(zip_file_name)}\z/, - partial_zip_file_name + ::File.extname(zip_file_name)) - end - partial_zip_file_name ||= zip_file_name - partial_zip_file_name - end - - def get_segment_count_for_split(zip_file_size, segment_size) - (zip_file_size / segment_size).to_i + (zip_file_size % segment_size == 0 ? 0 : 1) - end - - def put_split_signature(szip_file, segment_size) - signature_packed = [SPLIT_SIGNATURE].pack('V') - szip_file << signature_packed - segment_size - signature_packed.size - end + # Count the entries in a zip archive without reading the whole set of + # entry data into memory. + def count_entries(path_or_io) + cdir = ::Zip::CentralDirectory.new - # - # TODO: Make the code more understandable - # - def save_splited_part(zip_file, partial_zip_file_name, zip_file_size, szip_file_index, segment_size, segment_count) - ssegment_size = zip_file_size - zip_file.pos - ssegment_size = segment_size if ssegment_size > segment_size - szip_file_name = "#{partial_zip_file_name}.#{format('%03d', szip_file_index)}" - ::File.open(szip_file_name, 'wb') do |szip_file| - if szip_file_index == 1 - ssegment_size = put_split_signature(szip_file, segment_size) - end - chunk_bytes = 0 - until ssegment_size == chunk_bytes || zip_file.eof? - segment_bytes_left = ssegment_size - chunk_bytes - buffer_size = segment_bytes_left < DATA_BUFFER_SIZE ? segment_bytes_left : DATA_BUFFER_SIZE - chunk = zip_file.read(buffer_size) - chunk_bytes += buffer_size - szip_file << chunk - # Info for track splitting - yield segment_count, szip_file_index, chunk_bytes, ssegment_size if block_given? - end - end - end - - # Splits an archive into parts with segment size - def split(zip_file_name, segment_size = MAX_SEGMENT_SIZE, delete_zip_file = true, partial_zip_file_name = nil) - raise Error, "File #{zip_file_name} not found" unless ::File.exist?(zip_file_name) - raise Errno::ENOENT, zip_file_name unless ::File.readable?(zip_file_name) - - zip_file_size = ::File.size(zip_file_name) - segment_size = get_segment_size_for_split(segment_size) - return if zip_file_size <= segment_size - - segment_count = get_segment_count_for_split(zip_file_size, segment_size) - # Checking for correct zip structure - ::Zip::File.open(zip_file_name) {} - partial_zip_file_name = get_partial_zip_file_name(zip_file_name, partial_zip_file_name) - szip_file_index = 0 - ::File.open(zip_file_name, 'rb') do |zip_file| - until zip_file.eof? - szip_file_index += 1 - save_splited_part(zip_file, partial_zip_file_name, zip_file_size, szip_file_index, segment_size, segment_count) + if path_or_io.kind_of?(String) + ::File.open(path_or_io, 'rb') do |f| + cdir.count_entries(f) end + else + cdir.count_entries(path_or_io) end - ::File.delete(zip_file_name) if delete_zip_file - szip_file_index end end @@ -264,24 +186,29 @@ def get_input_stream(entry, &a_proc) # specified. If a block is passed the stream object is passed to the block and # the stream is automatically closed afterwards just as with ruby's builtin # File.open method. - def get_output_stream(entry, permission_int = nil, comment = nil, - extra = nil, compressed_size = nil, crc = nil, - compression_method = nil, size = nil, time = nil, - &a_proc) + def get_output_stream(entry, permissions: nil, comment: nil, + extra: nil, compressed_size: nil, crc: nil, + compression_method: nil, compression_level: nil, + size: nil, time: nil, &a_proc) new_entry = if entry.kind_of?(Entry) entry else - Entry.new(@name, entry.to_s, comment, extra, compressed_size, crc, compression_method, size, time) + Entry.new( + @name, entry.to_s, comment: comment, extra: extra, + compressed_size: compressed_size, crc: crc, size: size, + compression_method: compression_method, + compression_level: compression_level, time: time + ) end if new_entry.directory? raise ArgumentError, "cannot open stream to directory entry - '#{new_entry}'" end - new_entry.unix_perms = permission_int + new_entry.unix_perms = permissions zip_streamable_entry = StreamableStream.new(new_entry) - @entry_set << zip_streamable_entry + @cdir << zip_streamable_entry zip_streamable_entry.get_output_stream(&a_proc) end @@ -299,31 +226,39 @@ def read(entry) def add(entry, src_path, &continue_on_exists_proc) continue_on_exists_proc ||= proc { ::Zip.continue_on_exists_proc } check_entry_exists(entry, continue_on_exists_proc, 'add') - new_entry = entry.kind_of?(::Zip::Entry) ? entry : ::Zip::Entry.new(@name, entry.to_s) + new_entry = if entry.kind_of?(::Zip::Entry) + entry + else + ::Zip::Entry.new( + @name, entry.to_s, + compression_level: @compression_level + ) + end new_entry.gather_fileinfo_from_srcpath(src_path) - new_entry.dirty = true - @entry_set << new_entry + @cdir << new_entry end # Convenience method for adding the contents of a file to the archive # in Stored format (uncompressed) def add_stored(entry, src_path, &continue_on_exists_proc) - entry = ::Zip::Entry.new(@name, entry.to_s, nil, nil, nil, nil, ::Zip::Entry::STORED) + entry = ::Zip::Entry.new( + @name, entry.to_s, compression_method: ::Zip::Entry::STORED + ) add(entry, src_path, &continue_on_exists_proc) end # Removes the specified entry. def remove(entry) - @entry_set.delete(get_entry(entry)) + @cdir.delete(get_entry(entry)) end # Renames the specified entry. def rename(entry, new_name, &continue_on_exists_proc) found_entry = get_entry(entry) check_entry_exists(new_name, continue_on_exists_proc, 'rename') - @entry_set.delete(found_entry) + @cdir.delete(found_entry) found_entry.name = new_name - @entry_set << found_entry + @cdir << found_entry end # Replaces the specified entry with the contents of src_path (from @@ -334,11 +269,16 @@ def replace(entry, src_path) add(entry, src_path) end - # Extracts entry to file dest_path. - def extract(entry, dest_path, &block) + # Extracts `entry` to a file at `entry_path`, with `destination_directory` + # as the base location in the filesystem. + # + # NB: The caller is responsible for making sure `destination_directory` is + # safe, if it is passed. + def extract(entry, entry_path = nil, destination_directory: '.', &block) block ||= proc { ::Zip.on_exists_proc } found_entry = get_entry(entry) - found_entry.extract(dest_path, &block) + entry_path ||= found_entry.name + found_entry.extract(entry_path, destination_directory: destination_directory, &block) end # Commits changes that has been made since the previous commit to @@ -348,22 +288,23 @@ def commit on_success_replace do |tmp_file| ::Zip::OutputStream.open(tmp_file) do |zos| - @entry_set.each do |e| + @cdir.each do |e| e.write_to_zip_output_stream(zos) - e.dirty = false e.clean_up end zos.comment = comment end true end - initialize(name) + initialize_cdir(@name) end # Write buffer write changes to buffer and return - def write_buffer(io = ::StringIO.new('')) + def write_buffer(io = ::StringIO.new) + return io unless commit_required? + ::Zip::OutputStream.write_buffer(io) do |zos| - @entry_set.each { |e| e.write_to_zip_output_stream(zos) } + @cdir.each { |e| e.write_to_zip_output_stream(zos) } zos.comment = comment end end @@ -376,16 +317,19 @@ def close # Returns true if any changes has been made to this archive since # the previous commit def commit_required? - @entry_set.each do |e| - return true if e.dirty + return true if @create || @cdir.dirty? + + @cdir.each do |e| + return true if e.dirty? end - @comment != @stored_comment || @entry_set != @stored_entries || @create + + false end # Searches for entry with the specified name. Returns nil if # no entry is found. See also get_entry def find_entry(entry_name) - selected_entry = @entry_set.find_entry(entry_name) + selected_entry = @cdir.find_entry(entry_name) return if selected_entry.nil? selected_entry.restore_ownership = @restore_ownership @@ -394,11 +338,6 @@ def find_entry(entry_name) selected_entry end - # Searches for entries given a glob - def glob(*args, &block) - @entry_set.glob(*args, &block) - end - # Searches for an entry just as find_entry, but throws Errno::ENOENT # if no entry is found. def get_entry(entry) @@ -414,33 +353,50 @@ def mkdir(entry_name, permission = 0o755) entry_name = entry_name.dup.to_s entry_name << '/' unless entry_name.end_with?('/') - @entry_set << ::Zip::StreamableDirectory.new(@name, entry_name, nil, permission) + @cdir << ::Zip::StreamableDirectory.new(@name, entry_name, nil, permission) end private - def directory?(new_entry, src_path) - path_is_directory = ::File.directory?(src_path) - if new_entry.directory? && !path_is_directory - raise ArgumentError, - "entry name '#{new_entry}' indicates directory entry, but " \ - "'#{src_path}' is not a directory" - elsif !new_entry.directory? && path_is_directory - new_entry.name += '/' + def initialize_cdir(path_or_io, buffer: false) + @cdir = ::Zip::CentralDirectory.new + + if ::File.size?(@name.to_s) + # There is a file, which exists, that is associated with this zip. + @create = false + @file_permissions = ::File.stat(@name).mode + + if buffer + # https://github.com/rubyzip/rubyzip/issues/119 + path_or_io.binmode if path_or_io.respond_to?(:binmode) + @cdir.read_from_stream(path_or_io) + else + ::File.open(@name, 'rb') do |f| + @cdir.read_from_stream(f) + end + end + elsif buffer && path_or_io.size > 0 + # This zip is probably a non-empty StringIO. + @create = false + @cdir.read_from_stream(path_or_io) + elsif !@create && ::File.empty?(@name) + # A file exists, but it is empty, and we've said we're + # NOT creating a new zip. + raise Error, "File #{@name} has zero size. Did you mean to pass the create flag?" + elsif !@create + # If we get here, and we're not creating a new zip, then + # everything is wrong. + raise Error, "File #{@name} not found" end - new_entry.directory? && path_is_directory end def check_entry_exists(entry_name, continue_on_exists_proc, proc_name) + return unless @cdir.include?(entry_name) + continue_on_exists_proc ||= proc { Zip.continue_on_exists_proc } - return unless @entry_set.include?(entry_name) + raise ::Zip::EntryExistsError.new proc_name, entry_name unless continue_on_exists_proc.call - if continue_on_exists_proc.call - remove get_entry(entry_name) - else - raise ::Zip::EntryExistsError, - proc_name + " failed. Entry #{entry_name} already exists" - end + remove get_entry(entry_name) end def check_file(path) @@ -450,14 +406,12 @@ def check_file(path) def on_success_replace dirname, basename = ::File.split(name) ::Dir::Tmpname.create(basename, dirname) do |tmp_filename| - begin - if yield tmp_filename - ::File.rename(tmp_filename, name) - ::File.chmod(@file_permissions, name) unless @create - end - ensure - ::File.unlink(tmp_filename) if ::File.exist?(tmp_filename) + if yield tmp_filename + ::File.rename(tmp_filename, name) + ::File.chmod(@file_permissions, name) unless @create end + ensure + ::File.unlink(tmp_filename) if ::File.exist?(tmp_filename) end end end diff --git a/lib/zip/file_split.rb b/lib/zip/file_split.rb new file mode 100644 index 00000000..de5364a3 --- /dev/null +++ b/lib/zip/file_split.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module Zip + module FileSplit # :nodoc: + MAX_SEGMENT_SIZE = 3_221_225_472 + MIN_SEGMENT_SIZE = 65_536 + DATA_BUFFER_SIZE = 8192 + + def get_segment_size_for_split(segment_size) + segment_size.clamp(MIN_SEGMENT_SIZE, MAX_SEGMENT_SIZE) + end + + def get_partial_zip_file_name(zip_file_name, partial_zip_file_name) + unless partial_zip_file_name.nil? + partial_zip_file_name = zip_file_name.sub( + /#{::File.basename(zip_file_name)}\z/, + partial_zip_file_name + ::File.extname(zip_file_name) + ) + end + partial_zip_file_name ||= zip_file_name + partial_zip_file_name + end + + def get_segment_count_for_split(zip_file_size, segment_size) + (zip_file_size / segment_size).to_i + + ((zip_file_size % segment_size).zero? ? 0 : 1) + end + + def put_split_signature(szip_file, segment_size) + signature_packed = [SPLIT_FILE_SIGNATURE].pack('V') + szip_file << signature_packed + segment_size - signature_packed.size + end + + # + # TODO: Make the code more understandable + # + def save_splited_part( + zip_file, partial_zip_file_name, zip_file_size, + szip_file_index, segment_size, segment_count + ) + ssegment_size = zip_file_size - zip_file.pos + ssegment_size = segment_size if ssegment_size > segment_size + szip_file_name = "#{partial_zip_file_name}.#{format('%03d', szip_file_index)}" + ::File.open(szip_file_name, 'wb') do |szip_file| + if szip_file_index == 1 + ssegment_size = put_split_signature(szip_file, segment_size) + end + chunk_bytes = 0 + until ssegment_size == chunk_bytes || zip_file.eof? + segment_bytes_left = ssegment_size - chunk_bytes + buffer_size = [segment_bytes_left, DATA_BUFFER_SIZE].min + chunk = zip_file.read(buffer_size) + chunk_bytes += buffer_size + szip_file << chunk + # Info for track splitting + yield segment_count, szip_file_index, chunk_bytes, ssegment_size if block_given? + end + end + end + + # Splits an archive into parts with segment size + def split( + zip_file_name, segment_size: MAX_SEGMENT_SIZE, + delete_original: true, partial_zip_file_name: nil + ) + raise Error, "File #{zip_file_name} not found" unless ::File.exist?(zip_file_name) + raise Errno::ENOENT, zip_file_name unless ::File.readable?(zip_file_name) + + zip_file_size = ::File.size(zip_file_name) + segment_size = get_segment_size_for_split(segment_size) + return if zip_file_size <= segment_size + + segment_count = get_segment_count_for_split(zip_file_size, segment_size) + ::Zip::File.open(zip_file_name) {} # Check for correct zip structure. + partial_zip_file_name = get_partial_zip_file_name(zip_file_name, partial_zip_file_name) + szip_file_index = 0 + ::File.open(zip_file_name, 'rb') do |zip_file| + until zip_file.eof? + szip_file_index += 1 + save_splited_part( + zip_file, partial_zip_file_name, zip_file_size, + szip_file_index, segment_size, segment_count + ) + end + end + ::File.delete(zip_file_name) if delete_original + szip_file_index + end + end +end diff --git a/lib/zip/filesystem.rb b/lib/zip/filesystem.rb index d9928d4a..55369e92 100644 --- a/lib/zip/filesystem.rb +++ b/lib/zip/filesystem.rb @@ -1,4 +1,10 @@ +# frozen_string_literal: true + require 'zip' +require_relative 'filesystem/zip_file_name_mapper' +require_relative 'filesystem/directory_iterator' +require_relative 'filesystem/dir' +require_relative 'filesystem/file' module Zip # The ZipFileSystem API provides an API for accessing entries in @@ -13,627 +19,52 @@ module Zip # first.txt, a directory entry named mydir # and finally another normal entry named second.txt # - # require 'zip/filesystem' + # ``` + # require 'zip/filesystem' # - # Zip::File.open("my.zip", Zip::File::CREATE) { - # |zipfile| - # zipfile.file.open("first.txt", "w") { |f| f.puts "Hello world" } - # zipfile.dir.mkdir("mydir") - # zipfile.file.open("mydir/second.txt", "w") { |f| f.puts "Hello again" } - # } + # Zip::File.open('my.zip', create: true) do |zipfile| + # zipfile.file.open('first.txt', 'w') { |f| f.puts 'Hello world' } + # zipfile.dir.mkdir('mydir') + # zipfile.file.open('mydir/second.txt', 'w') { |f| f.puts 'Hello again' } + # end + # ``` # # Reading is as easy as writing, as the following example shows. The # example writes the contents of first.txt from zip archive # my.zip to standard out. # - # require 'zip/filesystem' + # ``` + # require 'zip/filesystem' # - # Zip::File.open("my.zip") { - # |zipfile| - # puts zipfile.file.read("first.txt") - # } - + # Zip::File.open('my.zip') do |zipfile| + # puts zipfile.file.read('first.txt') + # end + # ``` module FileSystem def initialize # :nodoc: mapped_zip = ZipFileNameMapper.new(self) - @zip_fs_dir = ZipFsDir.new(mapped_zip) - @zip_fs_file = ZipFsFile.new(mapped_zip) + @zip_fs_dir = Dir.new(mapped_zip) + @zip_fs_file = File.new(mapped_zip) @zip_fs_dir.file = @zip_fs_file @zip_fs_file.dir = @zip_fs_dir end - # Returns a ZipFsDir which is much like ruby's builtin Dir (class) - # object, except it works on the Zip::File on which this method is + # Returns a Zip::FileSystem::Dir which is much like ruby's builtin Dir + # (class) object, except it works on the Zip::File on which this method is # invoked def dir @zip_fs_dir end - # Returns a ZipFsFile which is much like ruby's builtin File (class) - # object, except it works on the Zip::File on which this method is + # Returns a Zip::FileSystem::File which is much like ruby's builtin File + # (class) object, except it works on the Zip::File on which this method is # invoked def file @zip_fs_file end - - # Instances of this class are normally accessed via the accessor - # Zip::File::file. An instance of ZipFsFile behaves like ruby's - # builtin File (class) object, except it works on Zip::File entries. - # - # The individual methods are not documented due to their - # similarity with the methods in File - class ZipFsFile - attr_writer :dir - # protected :dir - - class ZipFsStat - class << self - def delegate_to_fs_file(*methods) - methods.each do |method| - class_eval <<-END_EVAL, __FILE__, __LINE__ + 1 - def #{method} # def file? - @zip_fs_file.#{method}(@entry_name) # @zip_fs_file.file?(@entry_name) - end # end - END_EVAL - end - end - end - - def initialize(zip_fs_file, entry_name) - @zip_fs_file = zip_fs_file - @entry_name = entry_name - end - - def kind_of?(type) - super || type == ::File::Stat - end - - delegate_to_fs_file :file?, :directory?, :pipe?, :chardev?, :symlink?, - :socket?, :blockdev?, :readable?, :readable_real?, :writable?, :ctime, - :writable_real?, :executable?, :executable_real?, :sticky?, :owned?, - :grpowned?, :setuid?, :setgid?, :zero?, :size, :size?, :mtime, :atime - - def blocks - nil - end - - def get_entry - @zip_fs_file.__send__(:get_entry, @entry_name) - end - private :get_entry - - def gid - e = get_entry - if e.extra.member? 'IUnix' - e.extra['IUnix'].gid || 0 - else - 0 - end - end - - def uid - e = get_entry - if e.extra.member? 'IUnix' - e.extra['IUnix'].uid || 0 - else - 0 - end - end - - def ino - 0 - end - - def dev - 0 - end - - def rdev - 0 - end - - def rdev_major - 0 - end - - def rdev_minor - 0 - end - - def ftype - if file? - 'file' - elsif directory? - 'directory' - else - raise StandardError, 'Unknown file type' - end - end - - def nlink - 1 - end - - def blksize - nil - end - - def mode - e = get_entry - if e.fstype == 3 - e.external_file_attributes >> 16 - else - 33_206 # 33206 is equivalent to -rw-rw-rw- - end - end - end - - def initialize(mapped_zip) - @mapped_zip = mapped_zip - end - - def get_entry(filename) - unless exists?(filename) - raise Errno::ENOENT, "No such file or directory - #{filename}" - end - - @mapped_zip.find_entry(filename) - end - private :get_entry - - def unix_mode_cmp(filename, mode) - e = get_entry(filename) - e.fstype == 3 && ((e.external_file_attributes >> 16) & mode) != 0 - rescue Errno::ENOENT - false - end - private :unix_mode_cmp - - def exists?(filename) - expand_path(filename) == '/' || !@mapped_zip.find_entry(filename).nil? - end - alias exist? exists? - - # Permissions not implemented, so if the file exists it is accessible - alias owned? exists? - alias grpowned? exists? - - def readable?(filename) - unix_mode_cmp(filename, 0o444) - end - alias readable_real? readable? - - def writable?(filename) - unix_mode_cmp(filename, 0o222) - end - alias writable_real? writable? - - def executable?(filename) - unix_mode_cmp(filename, 0o111) - end - alias executable_real? executable? - - def setuid?(filename) - unix_mode_cmp(filename, 0o4000) - end - - def setgid?(filename) - unix_mode_cmp(filename, 0o2000) - end - - def sticky?(filename) - unix_mode_cmp(filename, 0o1000) - end - - def umask(*args) - ::File.umask(*args) - end - - def truncate(_filename, _len) - raise StandardError, 'truncate not supported' - end - - def directory?(filename) - entry = @mapped_zip.find_entry(filename) - expand_path(filename) == '/' || (!entry.nil? && entry.directory?) - end - - def open(filename, mode = 'r', permissions = 0o644, &block) - mode.delete!('b') # ignore b option - case mode - when 'r' - @mapped_zip.get_input_stream(filename, &block) - when 'w' - @mapped_zip.get_output_stream(filename, permissions, &block) - else - raise StandardError, "openmode '#{mode} not supported" unless mode == 'r' - end - end - - def new(filename, mode = 'r') - self.open(filename, mode) - end - - def size(filename) - @mapped_zip.get_entry(filename).size - end - - # Returns nil for not found and nil for directories - def size?(filename) - entry = @mapped_zip.find_entry(filename) - entry.nil? || entry.directory? ? nil : entry.size - end - - def chown(owner, group, *filenames) - filenames.each do |filename| - e = get_entry(filename) - e.extra.create('IUnix') unless e.extra.member?('IUnix') - e.extra['IUnix'].uid = owner - e.extra['IUnix'].gid = group - end - filenames.size - end - - def chmod(mode, *filenames) - filenames.each do |filename| - e = get_entry(filename) - e.fstype = 3 # force convertion filesystem type to unix - e.unix_perms = mode - e.external_file_attributes = mode << 16 - e.dirty = true - end - filenames.size - end - - def zero?(filename) - sz = size(filename) - sz.nil? || sz == 0 - rescue Errno::ENOENT - false - end - - def file?(filename) - entry = @mapped_zip.find_entry(filename) - !entry.nil? && entry.file? - end - - def dirname(filename) - ::File.dirname(filename) - end - - def basename(filename) - ::File.basename(filename) - end - - def split(filename) - ::File.split(filename) - end - - def join(*fragments) - ::File.join(*fragments) - end - - def utime(modified_time, *filenames) - filenames.each do |filename| - get_entry(filename).time = modified_time - end - end - - def mtime(filename) - @mapped_zip.get_entry(filename).mtime - end - - def atime(filename) - e = get_entry(filename) - if e.extra.member? 'UniversalTime' - e.extra['UniversalTime'].atime - elsif e.extra.member? 'NTFS' - e.extra['NTFS'].atime - end - end - - def ctime(filename) - e = get_entry(filename) - if e.extra.member? 'UniversalTime' - e.extra['UniversalTime'].ctime - elsif e.extra.member? 'NTFS' - e.extra['NTFS'].ctime - end - end - - def pipe?(_filename) - false - end - - def blockdev?(_filename) - false - end - - def chardev?(_filename) - false - end - - def symlink?(_filename) - false - end - - def socket?(_filename) - false - end - - def ftype(filename) - @mapped_zip.get_entry(filename).directory? ? 'directory' : 'file' - end - - def readlink(_filename) - raise NotImplementedError, 'The readlink() function is not implemented' - end - - def symlink(_filename, _symlink_name) - raise NotImplementedError, 'The symlink() function is not implemented' - end - - def link(_filename, _symlink_name) - raise NotImplementedError, 'The link() function is not implemented' - end - - def pipe - raise NotImplementedError, 'The pipe() function is not implemented' - end - - def stat(filename) - raise Errno::ENOENT, filename unless exists?(filename) - - ZipFsStat.new(self, filename) - end - - alias lstat stat - - def readlines(filename) - self.open(filename, &:readlines) - end - - def read(filename) - @mapped_zip.read(filename) - end - - def popen(*args, &a_proc) - ::File.popen(*args, &a_proc) - end - - def foreach(filename, sep = $INPUT_RECORD_SEPARATOR, &a_proc) - self.open(filename) { |is| is.each_line(sep, &a_proc) } - end - - def delete(*args) - args.each do |filename| - if directory?(filename) - raise Errno::EISDIR, "Is a directory - \"#{filename}\"" - end - - @mapped_zip.remove(filename) - end - end - - def rename(file_to_rename, new_name) - @mapped_zip.rename(file_to_rename, new_name) { true } - end - - alias unlink delete - - def expand_path(path) - @mapped_zip.expand_path(path) - end - end - - # Instances of this class are normally accessed via the accessor - # ZipFile::dir. An instance of ZipFsDir behaves like ruby's - # builtin Dir (class) object, except it works on ZipFile entries. - # - # The individual methods are not documented due to their - # similarity with the methods in Dir - class ZipFsDir - def initialize(mapped_zip) - @mapped_zip = mapped_zip - end - - attr_writer :file - - def new(directory_name) - ZipFsDirIterator.new(entries(directory_name)) - end - - def open(directory_name) - dir_iter = new(directory_name) - if block_given? - begin - yield(dir_iter) - return nil - ensure - dir_iter.close - end - end - dir_iter - end - - def pwd - @mapped_zip.pwd - end - alias getwd pwd - - def chdir(directory_name) - unless @file.stat(directory_name).directory? - raise Errno::EINVAL, "Invalid argument - #{directory_name}" - end - - @mapped_zip.pwd = @file.expand_path(directory_name) - end - - def entries(directory_name) - entries = [] - foreach(directory_name) { |e| entries << e } - entries - end - - def glob(*args, &block) - @mapped_zip.glob(*args, &block) - end - - def foreach(directory_name) - unless @file.stat(directory_name).directory? - raise Errno::ENOTDIR, directory_name - end - - path = @file.expand_path(directory_name) - path << '/' unless path.end_with?('/') - path = Regexp.escape(path) - subdir_entry_regex = Regexp.new("^#{path}([^/]+)$") - @mapped_zip.each do |filename| - match = subdir_entry_regex.match(filename) - yield(match[1]) unless match.nil? - end - end - - def delete(entry_name) - unless @file.stat(entry_name).directory? - raise Errno::EINVAL, "Invalid argument - #{entry_name}" - end - - @mapped_zip.remove(entry_name) - end - alias rmdir delete - alias unlink delete - - def mkdir(entry_name, permissions = 0o755) - @mapped_zip.mkdir(entry_name, permissions) - end - - def chroot(*_args) - raise NotImplementedError, 'The chroot() function is not implemented' - end - end - - class ZipFsDirIterator # :nodoc:all - include Enumerable - - def initialize(filenames) - @filenames = filenames - @index = 0 - end - - def close - @filenames = nil - end - - def each(&a_proc) - raise IOError, 'closed directory' if @filenames.nil? - - @filenames.each(&a_proc) - end - - def read - raise IOError, 'closed directory' if @filenames.nil? - - @filenames[(@index += 1) - 1] - end - - def rewind - raise IOError, 'closed directory' if @filenames.nil? - - @index = 0 - end - - def seek(position) - raise IOError, 'closed directory' if @filenames.nil? - - @index = position - end - - def tell - raise IOError, 'closed directory' if @filenames.nil? - - @index - end - end - - # All access to Zip::File from ZipFsFile and ZipFsDir goes through a - # ZipFileNameMapper, which has one responsibility: ensure - class ZipFileNameMapper # :nodoc:all - include Enumerable - - def initialize(zip_file) - @zip_file = zip_file - @pwd = '/' - end - - attr_accessor :pwd - - def find_entry(filename) - @zip_file.find_entry(expand_to_entry(filename)) - end - - def get_entry(filename) - @zip_file.get_entry(expand_to_entry(filename)) - end - - def get_input_stream(filename, &a_proc) - @zip_file.get_input_stream(expand_to_entry(filename), &a_proc) - end - - def get_output_stream(filename, permissions = nil, &a_proc) - @zip_file.get_output_stream( - expand_to_entry(filename), permissions, &a_proc - ) - end - - def glob(pattern, *flags, &block) - @zip_file.glob(expand_to_entry(pattern), *flags, &block) - end - - def read(filename) - @zip_file.read(expand_to_entry(filename)) - end - - def remove(filename) - @zip_file.remove(expand_to_entry(filename)) - end - - def rename(filename, new_name, &continue_on_exists_proc) - @zip_file.rename( - expand_to_entry(filename), - expand_to_entry(new_name), - &continue_on_exists_proc - ) - end - - def mkdir(filename, permissions = 0o755) - @zip_file.mkdir(expand_to_entry(filename), permissions) - end - - # Turns entries into strings and adds leading / - # and removes trailing slash on directories - def each - @zip_file.each do |e| - yield('/' + e.to_s.chomp('/')) - end - end - - def expand_path(path) - expanded = path.start_with?('/') ? path : ::File.join(@pwd, path) - expanded.gsub!(/\/\.(\/|$)/, '') - expanded.gsub!(/[^\/]+\/\.\.(\/|$)/, '') - expanded.empty? ? '/' : expanded - end - - private - - def expand_to_entry(path) - expand_path(path)[1..-1] - end - end end - class File + class File # :nodoc: include FileSystem end end diff --git a/lib/zip/filesystem/dir.rb b/lib/zip/filesystem/dir.rb new file mode 100644 index 00000000..5ae4ac45 --- /dev/null +++ b/lib/zip/filesystem/dir.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Zip + module FileSystem + class Dir # :nodoc:all + def initialize(mapped_zip) + @mapped_zip = mapped_zip + end + + attr_writer :file + + def new(directory_name) + DirectoryIterator.new(entries(directory_name)) + end + + def open(directory_name) + dir_iter = new(directory_name) + if block_given? + begin + yield(dir_iter) + return nil + ensure + dir_iter.close + end + end + dir_iter + end + + def pwd + @mapped_zip.pwd + end + alias getwd pwd + + def chdir(directory_name) + unless @file.stat(directory_name).directory? + raise Errno::EINVAL, "Invalid argument - #{directory_name}" + end + + @mapped_zip.pwd = @file.expand_path(directory_name) + end + + def entries(directory_name) + entries = [] + foreach(directory_name) { |e| entries << e } + entries + end + + def glob(...) + @mapped_zip.glob(...) + end + + def foreach(directory_name) + unless @file.stat(directory_name).directory? + raise Errno::ENOTDIR, directory_name + end + + path = @file.expand_path(directory_name) + path << '/' unless path.end_with?('/') + path = Regexp.escape(path) + subdir_entry_regex = Regexp.new("^#{path}([^/]+)$") + @mapped_zip.each do |filename| + match = subdir_entry_regex.match(filename) + yield(match[1]) unless match.nil? + end + end + + def delete(entry_name) + unless @file.stat(entry_name).directory? + raise Errno::EINVAL, "Invalid argument - #{entry_name}" + end + + @mapped_zip.remove(entry_name) + end + alias rmdir delete + alias unlink delete + + def mkdir(entry_name, permissions = 0o755) + @mapped_zip.mkdir(entry_name, permissions) + end + + def chroot(*_args) + raise NotImplementedError, 'The chroot() function is not implemented' + end + end + end +end diff --git a/lib/zip/filesystem/directory_iterator.rb b/lib/zip/filesystem/directory_iterator.rb new file mode 100644 index 00000000..91b1fc2e --- /dev/null +++ b/lib/zip/filesystem/directory_iterator.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Zip + module FileSystem + class DirectoryIterator # :nodoc:all + include Enumerable + + def initialize(filenames) + @filenames = filenames + @index = 0 + end + + def close + @filenames = nil + end + + def each(&a_proc) + raise IOError, 'closed directory' if @filenames.nil? + + @filenames.each(&a_proc) + end + + def read + raise IOError, 'closed directory' if @filenames.nil? + + @filenames[(@index += 1) - 1] + end + + def rewind + raise IOError, 'closed directory' if @filenames.nil? + + @index = 0 + end + + def seek(position) + raise IOError, 'closed directory' if @filenames.nil? + + @index = position + end + + def tell + raise IOError, 'closed directory' if @filenames.nil? + + @index + end + end + end +end diff --git a/lib/zip/filesystem/file.rb b/lib/zip/filesystem/file.rb new file mode 100644 index 00000000..cb094701 --- /dev/null +++ b/lib/zip/filesystem/file.rb @@ -0,0 +1,262 @@ +# frozen_string_literal: true + +require_relative 'file_stat' + +module Zip + module FileSystem + # Instances of this class are normally accessed via the accessor + # Zip::File::file. An instance of File behaves like ruby's + # builtin File (class) object, except it works on Zip::File entries. + # + # The individual methods are not documented due to their + # similarity with the methods in File + class File # :nodoc:all + attr_writer :dir + + def initialize(mapped_zip) + @mapped_zip = mapped_zip + end + + def find_entry(filename) + unless exists?(filename) + raise Errno::ENOENT, "No such file or directory - #{filename}" + end + + @mapped_zip.find_entry(filename) + end + + def unix_mode_cmp(filename, mode) + e = find_entry(filename) + e.fstype == FSTYPE_UNIX && ((e.external_file_attributes >> 16) & mode) != 0 + rescue Errno::ENOENT + false + end + private :unix_mode_cmp + + def exists?(filename) + expand_path(filename) == '/' || !@mapped_zip.find_entry(filename).nil? + end + alias exist? exists? + + # Permissions not implemented, so if the file exists it is accessible + alias owned? exists? + alias grpowned? exists? + + def readable?(filename) + unix_mode_cmp(filename, 0o444) + end + alias readable_real? readable? + + def writable?(filename) + unix_mode_cmp(filename, 0o222) + end + alias writable_real? writable? + + def executable?(filename) + unix_mode_cmp(filename, 0o111) + end + alias executable_real? executable? + + def setuid?(filename) + unix_mode_cmp(filename, 0o4000) + end + + def setgid?(filename) + unix_mode_cmp(filename, 0o2000) + end + + def sticky?(filename) + unix_mode_cmp(filename, 0o1000) + end + + def umask(*args) + ::File.umask(*args) + end + + def truncate(_filename, _len) + raise StandardError, 'truncate not supported' + end + + def directory?(filename) + entry = @mapped_zip.find_entry(filename) + expand_path(filename) == '/' || (!entry.nil? && entry.directory?) + end + + def open(filename, mode = 'r', permissions = 0o644, &block) + mode = mode.tr('b', '') # ignore b option + case mode + when 'r' + @mapped_zip.get_input_stream(filename, &block) + when 'w' + @mapped_zip.get_output_stream(filename, permissions, &block) + else + raise StandardError, "openmode '#{mode} not supported" unless mode == 'r' + end + end + + def new(filename, mode = 'r') + self.open(filename, mode) + end + + def size(filename) + @mapped_zip.get_entry(filename).size + end + + # Returns nil for not found and nil for directories + def size?(filename) + entry = @mapped_zip.find_entry(filename) + entry.nil? || entry.directory? ? nil : entry.size + end + + def chown(owner, group, *filenames) + filenames.each do |filename| + e = find_entry(filename) + e.extra.create('IUnix') unless e.extra.member?('IUnix') + e.extra['IUnix'].uid = owner + e.extra['IUnix'].gid = group + end + filenames.size + end + + def chmod(mode, *filenames) + filenames.each do |filename| + e = find_entry(filename) + e.fstype = FSTYPE_UNIX # Force conversion filesystem type to unix. + e.unix_perms = mode + e.external_file_attributes = mode << 16 + end + filenames.size + end + + def zero?(filename) + sz = size(filename) + sz.nil? || sz == 0 + rescue Errno::ENOENT + false + end + + def file?(filename) + entry = @mapped_zip.find_entry(filename) + !entry.nil? && entry.file? + end + + def dirname(filename) + ::File.dirname(filename) + end + + def basename(filename) + ::File.basename(filename) + end + + def split(filename) + ::File.split(filename) + end + + def join(*fragments) + ::File.join(*fragments) + end + + def utime(modified_time, *filenames) + filenames.each do |filename| + find_entry(filename).time = modified_time + end + end + + def mtime(filename) + @mapped_zip.get_entry(filename).mtime + end + + def atime(filename) + @mapped_zip.get_entry(filename).atime + end + + def ctime(filename) + @mapped_zip.get_entry(filename).ctime + end + + def pipe?(_filename) + false + end + + def blockdev?(_filename) + false + end + + def chardev?(_filename) + false + end + + def symlink?(filename) + @mapped_zip.get_entry(filename).symlink? + end + + def socket?(_filename) + false + end + + def ftype(filename) + @mapped_zip.get_entry(filename).directory? ? 'directory' : 'file' + end + + def readlink(_filename) + raise NotImplementedError, 'The readlink() function is not implemented' + end + + def symlink(_filename, _symlink_name) + raise NotImplementedError, 'The symlink() function is not implemented' + end + + def link(_filename, _symlink_name) + raise NotImplementedError, 'The link() function is not implemented' + end + + def pipe + raise NotImplementedError, 'The pipe() function is not implemented' + end + + def stat(filename) + raise Errno::ENOENT, filename unless exists?(filename) + + Stat.new(self, filename) + end + + alias lstat stat + + def readlines(filename) + self.open(filename, &:readlines) + end + + def read(filename) + @mapped_zip.read(filename) + end + + def popen(*args, &a_proc) + ::File.popen(*args, &a_proc) + end + + def foreach(filename, sep = $INPUT_RECORD_SEPARATOR, &a_proc) + self.open(filename) { |is| is.each_line(sep, &a_proc) } + end + + def delete(*args) + args.each do |filename| + if directory?(filename) + raise Errno::EISDIR, "Is a directory - \"#{filename}\"" + end + + @mapped_zip.remove(filename) + end + end + + def rename(file_to_rename, new_name) + @mapped_zip.rename(file_to_rename, new_name) { true } + end + + alias unlink delete + + def expand_path(path) + @mapped_zip.expand_path(path) + end + end + end +end diff --git a/lib/zip/filesystem/file_stat.rb b/lib/zip/filesystem/file_stat.rb new file mode 100644 index 00000000..6117bb7c --- /dev/null +++ b/lib/zip/filesystem/file_stat.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +module Zip + module FileSystem + class File # :nodoc:all + class Stat # :nodoc:all + class << self + def delegate_to_fs_file(*methods) + methods.each do |method| + class_exec do + define_method(method) do + @zip_fs_file.__send__(method, @entry_name) + end + end + end + end + end + + def initialize(zip_fs_file, entry_name) + @zip_fs_file = zip_fs_file + @entry_name = entry_name + end + + def kind_of?(type) + super || type == ::File::Stat + end + + delegate_to_fs_file :file?, :directory?, :pipe?, :chardev?, :symlink?, + :socket?, :blockdev?, :readable?, :readable_real?, :writable?, :ctime, + :writable_real?, :executable?, :executable_real?, :sticky?, :owned?, + :grpowned?, :setuid?, :setgid?, :zero?, :size, :size?, :mtime, :atime + + def blocks + nil + end + + def gid + e = find_entry + if e.extra.member? 'IUnix' + e.extra['IUnix'].gid || 0 + else + 0 + end + end + + def uid + e = find_entry + if e.extra.member? 'IUnix' + e.extra['IUnix'].uid || 0 + else + 0 + end + end + + def ino + 0 + end + + def dev + 0 + end + + def rdev + 0 + end + + def rdev_major + 0 + end + + def rdev_minor + 0 + end + + def ftype + if file? + 'file' + elsif directory? + 'directory' + else + raise StandardError, 'Unknown file type' + end + end + + def nlink + 1 + end + + def blksize + nil + end + + def mode + e = find_entry + if e.fstype == FSTYPE_UNIX + e.external_file_attributes >> 16 + else + 0o100_666 # Equivalent to -rw-rw-rw-. + end + end + + private + + def find_entry + @zip_fs_file.find_entry(@entry_name) + end + end + end + end +end diff --git a/lib/zip/filesystem/zip_file_name_mapper.rb b/lib/zip/filesystem/zip_file_name_mapper.rb new file mode 100644 index 00000000..77a4e691 --- /dev/null +++ b/lib/zip/filesystem/zip_file_name_mapper.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module Zip + module FileSystem + # All access to Zip::File from FileSystem::File and FileSystem::Dir + # goes through a ZipFileNameMapper, which has one responsibility: ensure + class ZipFileNameMapper # :nodoc:all + include Enumerable + + def initialize(zip_file) + @zip_file = zip_file + @pwd = '/' + end + + attr_accessor :pwd + + def find_entry(filename) + @zip_file.find_entry(expand_to_entry(filename)) + end + + def get_entry(filename) + @zip_file.get_entry(expand_to_entry(filename)) + end + + def get_input_stream(filename, &a_proc) + @zip_file.get_input_stream(expand_to_entry(filename), &a_proc) + end + + def get_output_stream(filename, permissions = nil, &a_proc) + @zip_file.get_output_stream( + expand_to_entry(filename), permissions: permissions, &a_proc + ) + end + + def glob(pattern, *flags, &block) + @zip_file.glob(expand_to_entry(pattern), *flags, &block) + end + + def read(filename) + @zip_file.read(expand_to_entry(filename)) + end + + def remove(filename) + @zip_file.remove(expand_to_entry(filename)) + end + + def rename(filename, new_name, &continue_on_exists_proc) + @zip_file.rename( + expand_to_entry(filename), + expand_to_entry(new_name), + &continue_on_exists_proc + ) + end + + def mkdir(filename, permissions = 0o755) + @zip_file.mkdir(expand_to_entry(filename), permissions) + end + + # Turns entries into strings and adds leading / + # and removes trailing slash on directories + def each + @zip_file.each do |e| + yield("/#{e.to_s.chomp('/')}") + end + end + + def expand_path(path) + expanded = path.start_with?('/') ? path.dup : ::File.join(@pwd, path) + expanded.gsub!(/\/\.(\/|$)/, '') + expanded.gsub!(/[^\/]+\/\.\.(\/|$)/, '') + expanded.empty? ? '/' : expanded + end + + private + + def expand_to_entry(path) + expand_path(path)[1..] + end + end + end +end diff --git a/lib/zip/inflater.rb b/lib/zip/inflater.rb index 530f98aa..40f285f1 100644 --- a/lib/zip/inflater.rb +++ b/lib/zip/inflater.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + module Zip - class Inflater < Decompressor #:nodoc:all + class Inflater < Decompressor # :nodoc:all def initialize(*args) super @@ -7,7 +9,7 @@ def initialize(*args) @zlib_inflater = ::Zlib::Inflate.new(-Zlib::MAX_WBITS) end - def read(length = nil, outbuf = '') + def read(length = nil, outbuf = +'') return (length.nil? || length.zero? ? '' : nil) if eof while length.nil? || (@buffer.bytesize < length) @@ -37,8 +39,8 @@ def produce_input retried += 1 retry end - rescue Zlib::Error - raise(::Zip::DecompressionError, 'zlib error while inflating') + rescue Zlib::Error => e + raise ::Zip::DecompressionError, e end def input_finished? diff --git a/lib/zip/input_stream.rb b/lib/zip/input_stream.rb index f942d190..defccabc 100644 --- a/lib/zip/input_stream.rb +++ b/lib/zip/input_stream.rb @@ -1,3 +1,6 @@ +# frozen_string_literal: true + +## module Zip # InputStream is the basic class for reading zip entries in a # zip file. It is possible to create a InputStream object directly, @@ -37,9 +40,8 @@ module Zip # # java.util.zip.ZipInputStream is the original inspiration for this # class. - class InputStream - CHUNK_SIZE = 32_768 + CHUNK_SIZE = 32_768 # :nodoc: include ::Zip::IOExtras::AbstractInputStream @@ -49,28 +51,35 @@ class InputStream # # @param context [String||IO||StringIO] file path or IO/StringIO object # @param offset [Integer] offset in the IO/StringIO - def initialize(context, offset = 0, decrypter = nil) + def initialize(context, offset: 0, decrypter: nil) super() - @archive_io = get_io(context, offset) - @decompressor = ::Zip::NullDecompressor - @decrypter = decrypter || ::Zip::NullDecrypter.new + @archive_io = get_io(context, offset) + @decompressor = ::Zip::NullDecompressor + @decrypter = decrypter || ::Zip::NullDecrypter.new @current_entry = nil + @complete_entry = nil end + # Close this InputStream. All further IO will raise an IOError. def close @archive_io.close end - # Returns a Entry object. It is necessary to call this - # method on a newly created InputStream before reading from - # the first entry in the archive. Returns nil when there are - # no more entries. + # Returns an Entry object and positions the stream at the beginning of + # the entry data. It is necessary to call this method on a newly created + # InputStream before reading from the first entry in the archive. + # Returns nil when there are no more entries. def get_next_entry - @archive_io.seek(@current_entry.next_header_offset, IO::SEEK_SET) if @current_entry + unless @current_entry.nil? + raise StreamingError, @current_entry if @current_entry.incomplete? + + @archive_io.seek(@current_entry.next_header_offset, IO::SEEK_SET) + end + open_entry end - # Rewinds the stream to the beginning of the current entry + # Rewinds the stream to the beginning of the current entry. def rewind return if @current_entry.nil? @@ -85,12 +94,19 @@ def sysread(length = nil, outbuf = '') @decompressor.read(length, outbuf) end + # Returns the size of the current entry, or `nil` if there isn't one. + def size + return if @current_entry.nil? + + @current_entry.size + end + class << self # Same as #initialize but if a block is passed the opened # stream is passed to the block and closed when the block # returns. - def open(filename_or_io, offset = 0, decrypter = nil) - zio = new(filename_or_io, offset, decrypter) + def open(filename_or_io, offset: 0, decrypter: nil) + zio = new(filename_or_io, offset: offset, decrypter: decrypter) return zio unless block_given? begin @@ -99,16 +115,11 @@ def open(filename_or_io, offset = 0, decrypter = nil) zio.close if zio end end - - def open_buffer(filename_or_io, offset = 0) - warn 'open_buffer is deprecated!!! Use open instead!' - ::Zip::InputStream.open(filename_or_io, offset) - end end protected - def get_io(io_or_file, offset = 0) + def get_io(io_or_file, offset = 0) # :nodoc: if io_or_file.respond_to?(:seek) io = io_or_file.dup io.seek(offset, ::IO::SEEK_SET) @@ -120,56 +131,58 @@ def get_io(io_or_file, offset = 0) end end - def open_entry + def open_entry # :nodoc: @current_entry = ::Zip::Entry.read_local_entry(@archive_io) - if @current_entry && @current_entry.encrypted? && @decrypter.kind_of?(NullEncrypter) - raise Error, 'password required to decode zip file' + return if @current_entry.nil? + + if @current_entry.encrypted? && @decrypter.kind_of?(NullDecrypter) + raise Error, + 'A password is required to decode this zip file' end - if @current_entry && @current_entry.incomplete? && @current_entry.crc == 0 \ - && @current_entry.compressed_size == 0 \ - && @current_entry.size == 0 && !@complete_entry - raise GPFBit3Error, - 'General purpose flag Bit 3 is set so not possible to get proper info from local header.' \ - 'Please use ::Zip::File instead of ::Zip::InputStream' + if @current_entry.incomplete? && @current_entry.compressed_size == 0 && !@complete_entry + raise StreamingError, @current_entry end + @decrypted_io = get_decrypted_io @decompressor = get_decompressor flush @current_entry end - def get_decrypted_io + def get_decrypted_io # :nodoc: header = @archive_io.read(@decrypter.header_bytesize) @decrypter.reset!(header) ::Zip::DecryptedIo.new(@archive_io, @decrypter) end - def get_decompressor + def get_decompressor # :nodoc: return ::Zip::NullDecompressor if @current_entry.nil? decompressed_size = - if @current_entry.incomplete? && @current_entry.crc == 0 && @current_entry.size == 0 && @complete_entry + if @current_entry.incomplete? && @current_entry.crc == 0 && + @current_entry.size == 0 && @complete_entry @complete_entry.size else @current_entry.size end - decompressor_class = ::Zip::Decompressor.find_by_compression_method(@current_entry.compression_method) + decompressor_class = ::Zip::Decompressor.find_by_compression_method( + @current_entry.compression_method + ) if decompressor_class.nil? - raise ::Zip::CompressionMethodError, - "Unsupported compression method #{@current_entry.compression_method}" + raise ::Zip::CompressionMethodError, @current_entry.compression_method end decompressor_class.new(@decrypted_io, decompressed_size) end - def produce_input + def produce_input # :nodoc: @decompressor.read(CHUNK_SIZE) end - def input_finished? + def input_finished? # :nodoc: @decompressor.eof end end diff --git a/lib/zip/ioextras.rb b/lib/zip/ioextras.rb index 63774d33..66688966 100644 --- a/lib/zip/ioextras.rb +++ b/lib/zip/ioextras.rb @@ -1,26 +1,26 @@ +# frozen_string_literal: true + module Zip - module IOExtras #:nodoc: + module IOExtras # :nodoc: CHUNK_SIZE = 131_072 - RANGE_ALL = 0..-1 - class << self def copy_stream(ostream, istream) - ostream.write(istream.read(CHUNK_SIZE, '')) until istream.eof? + ostream.write(istream.read(CHUNK_SIZE, +'')) until istream.eof? end def copy_stream_n(ostream, istream, nbytes) toread = nbytes while toread > 0 && !istream.eof? - tr = toread > CHUNK_SIZE ? CHUNK_SIZE : toread - ostream.write(istream.read(tr, '')) + tr = [toread, CHUNK_SIZE].min + ostream.write(istream.read(tr, +'')) toread -= tr end end end # Implements kind_of? in order to pretend to be an IO object - module FakeIO + module FakeIO # :nodoc: def kind_of?(object) object == IO || super end diff --git a/lib/zip/ioextras/abstract_input_stream.rb b/lib/zip/ioextras/abstract_input_stream.rb index 8392d240..ed95e63d 100644 --- a/lib/zip/ioextras/abstract_input_stream.rb +++ b/lib/zip/ioextras/abstract_input_stream.rb @@ -1,9 +1,11 @@ +# frozen_string_literal: true + module Zip - module IOExtras + module IOExtras # :nodoc: # Implements many of the convenience methods of IO # such as gets, getc, readline and readlines # depends on: input_finished?, produce_input and read - module AbstractInputStream + module AbstractInputStream # :nodoc: include Enumerable include FakeIO @@ -11,15 +13,15 @@ def initialize super @lineno = 0 @pos = 0 - @output_buffer = '' + @output_buffer = +'' end attr_accessor :lineno attr_reader :pos - def read(number_of_bytes = nil, buf = '') + def read(number_of_bytes = nil, buf = +'') tbuf = if @output_buffer.bytesize > 0 - if number_of_bytes <= @output_buffer.bytesize + if number_of_bytes && number_of_bytes <= @output_buffer.bytesize @output_buffer.slice!(0, number_of_bytes) else number_of_bytes -= @output_buffer.bytesize if number_of_bytes @@ -34,7 +36,7 @@ def read(number_of_bytes = nil, buf = '') end if tbuf.nil? || tbuf.empty? - return nil if number_of_bytes + return nil if number_of_bytes&.positive? return '' end @@ -74,15 +76,18 @@ def gets(a_sep_string = $INPUT_RECORD_SEPARATOR, number_of_bytes = nil) a_sep_string = "#{$INPUT_RECORD_SEPARATOR}#{$INPUT_RECORD_SEPARATOR}" if a_sep_string.empty? buffer_index = 0 - over_limit = (number_of_bytes && @output_buffer.bytesize >= number_of_bytes) + over_limit = number_of_bytes && @output_buffer.bytesize >= number_of_bytes while (match_index = @output_buffer.index(a_sep_string, buffer_index)).nil? && !over_limit buffer_index = [buffer_index, @output_buffer.bytesize - a_sep_string.bytesize].max return @output_buffer.empty? ? nil : flush if input_finished? @output_buffer << produce_input - over_limit = (number_of_bytes && @output_buffer.bytesize >= number_of_bytes) + over_limit = number_of_bytes && @output_buffer.bytesize >= number_of_bytes end - sep_index = [match_index + a_sep_string.bytesize, number_of_bytes || @output_buffer.bytesize].min + sep_index = [ + match_index + a_sep_string.bytesize, + number_of_bytes || @output_buffer.bytesize + ].min @pos += sep_index @output_buffer.slice!(0...sep_index) end @@ -93,7 +98,7 @@ def ungetc(byte) def flush ret_val = @output_buffer - @output_buffer = '' + @output_buffer = +'' ret_val end diff --git a/lib/zip/ioextras/abstract_output_stream.rb b/lib/zip/ioextras/abstract_output_stream.rb index b94c9d49..a71d24f6 100644 --- a/lib/zip/ioextras/abstract_output_stream.rb +++ b/lib/zip/ioextras/abstract_output_stream.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + module Zip - module IOExtras + module IOExtras # :nodoc: # Implements many of the output convenience methods of IO. # relies on << - module AbstractOutputStream + module AbstractOutputStream # :nodoc: include FakeIO def write(data) @@ -11,7 +13,7 @@ def write(data) end def print(*params) - self << params.join($OUTPUT_FIELD_SEPARATOR) << $OUTPUT_RECORD_SEPARATOR.to_s + self << params.join << $OUTPUT_RECORD_SEPARATOR.to_s end def printf(a_format_string, *params) diff --git a/lib/zip/null_compressor.rb b/lib/zip/null_compressor.rb index 70fd3294..c2ce899e 100644 --- a/lib/zip/null_compressor.rb +++ b/lib/zip/null_compressor.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + module Zip - class NullCompressor < Compressor #:nodoc:all + class NullCompressor < Compressor # :nodoc:all include Singleton def <<(_data) diff --git a/lib/zip/null_decompressor.rb b/lib/zip/null_decompressor.rb index 6534b161..8eb79e67 100644 --- a/lib/zip/null_decompressor.rb +++ b/lib/zip/null_decompressor.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + module Zip - module NullDecompressor #:nodoc:all + module NullDecompressor # :nodoc:all module_function def read(_length = nil, _outbuf = nil) diff --git a/lib/zip/null_input_stream.rb b/lib/zip/null_input_stream.rb index 2cd36616..5c2c7077 100644 --- a/lib/zip/null_input_stream.rb +++ b/lib/zip/null_input_stream.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + module Zip - module NullInputStream #:nodoc:all + module NullInputStream # :nodoc:all include ::Zip::NullDecompressor include ::Zip::IOExtras::AbstractInputStream end diff --git a/lib/zip/output_stream.rb b/lib/zip/output_stream.rb index 266083cd..0609f89f 100644 --- a/lib/zip/output_stream.rb +++ b/lib/zip/output_stream.rb @@ -1,3 +1,8 @@ +# frozen_string_literal: true + +require 'forwardable' + +## module Zip # ZipOutputStream is the basic class for writing zip files. It is # possible to create a ZipOutputStream object directly, passing @@ -16,50 +21,49 @@ module Zip # # java.util.zip.ZipOutputStream is the original inspiration for this # class. - class OutputStream + extend Forwardable include ::Zip::IOExtras::AbstractOutputStream - attr_accessor :comment + def_delegators :@cdir, :comment, :comment= # Opens the indicated zip file. If a file with that name already # exists it will be overwritten. - def initialize(file_name, stream = false, encrypter = nil) + def initialize(file_name, stream: false, encrypter: nil) super() @file_name = file_name @output_stream = if stream - iostream = @file_name.dup + iostream = Zip::RUNNING_ON_WINDOWS ? @file_name : @file_name.dup iostream.reopen(@file_name) iostream.rewind iostream else ::File.new(@file_name, 'wb') end - @entry_set = ::Zip::EntrySet.new + @cdir = ::Zip::CentralDirectory.new @compressor = ::Zip::NullCompressor.instance @encrypter = encrypter || ::Zip::NullEncrypter.new @closed = false @current_entry = nil - @comment = nil end - # Same as #initialize but if a block is passed the opened - # stream is passed to the block and closed when the block - # returns. class << self - def open(file_name, encrypter = nil) + # Same as #initialize but if a block is passed the opened + # stream is passed to the block and closed when the block + # returns. + def open(file_name, encrypter: nil) return new(file_name) unless block_given? - zos = new(file_name, false, encrypter) + zos = new(file_name, stream: false, encrypter: encrypter) yield zos ensure zos.close if zos end # Same as #open but writes to a filestream instead - def write_buffer(io = ::StringIO.new(''), encrypter = nil) + def write_buffer(io = ::StringIO.new, encrypter: nil) io.binmode if io.respond_to?(:binmode) - zos = new(io, true, encrypter) + zos = new(io, stream: true, encrypter: encrypter) yield zos zos.close_buffer end @@ -71,7 +75,7 @@ def close finalize_current_entry update_local_headers - write_central_directory + @cdir.write_to_stream(@output_stream) @output_stream.close @closed = true end @@ -82,37 +86,41 @@ def close_buffer finalize_current_entry update_local_headers - write_central_directory + @cdir.write_to_stream(@output_stream) @closed = true + @output_stream.flush @output_stream end # Closes the current entry and opens a new for writing. # +entry+ can be a ZipEntry object or a string. - def put_next_entry(entry_name, comment = nil, extra = nil, compression_method = Entry::DEFLATED, level = Zip.default_compression) + def put_next_entry( + entry_name, comment = '', extra = ExtraField.new, + compression_method = Entry::DEFLATED, level = Zip.default_compression + ) raise Error, 'zip stream is closed' if @closed - new_entry = if entry_name.kind_of?(Entry) - entry_name - else - Entry.new(@file_name, entry_name.to_s) - end - new_entry.comment = comment unless comment.nil? - unless extra.nil? - new_entry.extra = extra.kind_of?(ExtraField) ? extra : ExtraField.new(extra.to_s) - end - new_entry.compression_method = compression_method unless compression_method.nil? - init_next_entry(new_entry, level) + new_entry = + if entry_name.kind_of?(Entry) || entry_name.kind_of?(StreamableStream) + entry_name + else + Entry.new( + @file_name, entry_name.to_s, comment: comment, extra: extra, + compression_method: compression_method, compression_level: level + ) + end + + init_next_entry(new_entry) @current_entry = new_entry end - def copy_raw_entry(entry) + def copy_raw_entry(entry) # :nodoc: entry = entry.dup raise Error, 'zip stream is closed' if @closed raise Error, 'entry is not a ZipEntry' unless entry.kind_of?(Entry) finalize_current_entry - @entry_set << entry + @cdir << entry src_pos = entry.local_header_offset entry.write_local_entry(@output_stream) @compressor = NullCompressor.instance @@ -131,55 +139,53 @@ def finalize_current_entry return unless @current_entry finish - @current_entry.compressed_size = @output_stream.tell - \ - @current_entry.local_header_offset - \ + @current_entry.compressed_size = @output_stream.tell - + @current_entry.local_header_offset - @current_entry.calculate_local_header_size @current_entry.size = @compressor.size @current_entry.crc = @compressor.crc - @output_stream << @encrypter.data_descriptor(@current_entry.crc, @current_entry.compressed_size, @current_entry.size) + @output_stream << @encrypter.data_descriptor( + @current_entry.crc, + @current_entry.compressed_size, + @current_entry.size + ) @current_entry.gp_flags |= @encrypter.gp_flags @current_entry = nil @compressor = ::Zip::NullCompressor.instance end - def init_next_entry(entry, level = Zip.default_compression) + def init_next_entry(entry) finalize_current_entry - @entry_set << entry + @cdir << entry entry.write_local_entry(@output_stream) @encrypter.reset! @output_stream << @encrypter.header(entry.mtime) - @compressor = get_compressor(entry, level) + @compressor = get_compressor(entry) end - def get_compressor(entry, level) + def get_compressor(entry) case entry.compression_method when Entry::DEFLATED - ::Zip::Deflater.new(@output_stream, level, @encrypter) + ::Zip::Deflater.new(@output_stream, entry.compression_level, @encrypter) when Entry::STORED ::Zip::PassThruCompressor.new(@output_stream) else - raise ::Zip::CompressionMethodError, - "Invalid compression method: '#{entry.compression_method}'" + raise ::Zip::CompressionMethodError, entry.compression_method end end def update_local_headers pos = @output_stream.pos - @entry_set.each do |entry| + @cdir.each do |entry| @output_stream.pos = entry.local_header_offset - entry.write_local_entry(@output_stream, true) + entry.write_local_entry(@output_stream, rewrite: true) end @output_stream.pos = pos end - def write_central_directory - cdir = CentralDirectory.new(@entry_set, @comment) - cdir.write_to_stream(@output_stream) - end - protected - def finish + def finish # :nodoc: @compressor.finish end diff --git a/lib/zip/pass_thru_compressor.rb b/lib/zip/pass_thru_compressor.rb index 2dbaa273..9b5bdf3b 100644 --- a/lib/zip/pass_thru_compressor.rb +++ b/lib/zip/pass_thru_compressor.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + module Zip - class PassThruCompressor < Compressor #:nodoc:all + class PassThruCompressor < Compressor # :nodoc:all def initialize(output_stream) super() @output_stream = output_stream diff --git a/lib/zip/pass_thru_decompressor.rb b/lib/zip/pass_thru_decompressor.rb index e638540e..56e8bd75 100644 --- a/lib/zip/pass_thru_decompressor.rb +++ b/lib/zip/pass_thru_decompressor.rb @@ -1,11 +1,13 @@ +# frozen_string_literal: true + module Zip - class PassThruDecompressor < Decompressor #:nodoc:all + class PassThruDecompressor < Decompressor # :nodoc:all def initialize(*args) super @read_so_far = 0 end - def read(length = nil, outbuf = '') + def read(length = nil, outbuf = +'') return (length.nil? || length.zero? ? '' : nil) if eof if length.nil? || (@read_so_far + length) > decompressed_size diff --git a/lib/zip/streamable_directory.rb b/lib/zip/streamable_directory.rb index 3738ce2c..2c931190 100644 --- a/lib/zip/streamable_directory.rb +++ b/lib/zip/streamable_directory.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + module Zip - class StreamableDirectory < Entry + class StreamableDirectory < Entry # :nodoc: def initialize(zipfile, entry, src_path = nil, permission = nil) super(zipfile, entry) diff --git a/lib/zip/streamable_stream.rb b/lib/zip/streamable_stream.rb index 68f3e0e8..2fea80c7 100644 --- a/lib/zip/streamable_stream.rb +++ b/lib/zip/streamable_stream.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Zip class StreamableStream < DelegateClass(Entry) # :nodoc:all def initialize(entry) @@ -42,6 +44,7 @@ def write_to_zip_output_stream(output_stream) end def clean_up + super @temp_file.unlink end end diff --git a/lib/zip/version.rb b/lib/zip/version.rb index 0b20c214..7957e0de 100644 --- a/lib/zip/version.rb +++ b/lib/zip/version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Zip - VERSION = '2.3.0' + VERSION = '3.0.0.rc2' # :nodoc: end diff --git a/rubyzip.gemspec b/rubyzip.gemspec index 2e7cbf78..66ec3657 100644 --- a/rubyzip.gemspec +++ b/rubyzip.gemspec @@ -1,29 +1,39 @@ -lib = File.expand_path('lib', __dir__) -$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) -require 'zip/version' +# frozen_string_literal: true + +require_relative 'lib/zip/version' Gem::Specification.new do |s| - s.name = 'rubyzip' - s.version = ::Zip::VERSION - s.authors = ['Alexander Simonov'] - s.email = ['alex@simonov.me'] - s.homepage = 'http://github.com/rubyzip/rubyzip' - s.platform = Gem::Platform::RUBY - s.summary = 'rubyzip is a ruby module for reading and writing zip files' - s.files = Dir.glob('{samples,lib}/**/*.rb') + %w[README.md TODO Rakefile] - s.require_paths = ['lib'] - s.license = 'BSD 2-Clause' - s.metadata = { - 'bug_tracker_uri' => 'https://github.com/rubyzip/rubyzip/issues', - 'changelog_uri' => "https://github.com/rubyzip/rubyzip/blob/v#{s.version}/Changelog.md", - 'documentation_uri' => "https://www.rubydoc.info/gems/rubyzip/#{s.version}", - 'source_code_uri' => "https://github.com/rubyzip/rubyzip/tree/v#{s.version}", - 'wiki_uri' => 'https://github.com/rubyzip/rubyzip/wiki' + s.name = 'rubyzip' + s.version = Zip::VERSION + s.authors = ['Robert Haines', 'John Lees-Miller', 'Alexander Simonov'] + s.email = [ + 'hainesr@gmail.com', 'jdleesmiller@gmail.com', 'alex@simonov.me' + ] + s.homepage = 'http://github.com/rubyzip/rubyzip' + s.platform = Gem::Platform::RUBY + s.summary = 'rubyzip is a ruby module for reading and writing zip files' + s.files = Dir.glob('{samples,lib}/**/*.rb') + + %w[LICENSE.md README.md Changelog.md Rakefile rubyzip.gemspec] + s.require_paths = ['lib'] + s.license = 'BSD-2-Clause' + + s.metadata = { + 'bug_tracker_uri' => 'https://github.com/rubyzip/rubyzip/issues', + 'changelog_uri' => "https://github.com/rubyzip/rubyzip/blob/v#{s.version}/Changelog.md", + 'documentation_uri' => "https://www.rubydoc.info/gems/rubyzip/#{s.version}", + 'source_code_uri' => "https://github.com/rubyzip/rubyzip/tree/v#{s.version}", + 'wiki_uri' => 'https://github.com/rubyzip/rubyzip/wiki', + 'rubygems_mfa_required' => 'true' } - s.required_ruby_version = '>= 2.4' - s.add_development_dependency 'coveralls', '~> 0.7' - s.add_development_dependency 'minitest', '~> 5.4' - s.add_development_dependency 'pry', '~> 0.10' - s.add_development_dependency 'rake', '~> 12.3', '>= 12.3.3' - s.add_development_dependency 'rubocop', '~> 0.79' + + s.required_ruby_version = '>= 3.0' + + s.add_development_dependency 'minitest', '~> 5.25' + s.add_development_dependency 'rake', '~> 13.2' + s.add_development_dependency 'rdoc', '~> 6.11' + s.add_development_dependency 'rubocop', '~> 1.61.0' + s.add_development_dependency 'rubocop-performance', '~> 1.20.0' + s.add_development_dependency 'rubocop-rake', '~> 0.6.0' + s.add_development_dependency 'simplecov', '~> 0.22.0' + s.add_development_dependency 'simplecov-lcov', '~> 0.8' end diff --git a/samples/.cvsignore b/samples/.cvsignore deleted file mode 100644 index cf125d08..00000000 --- a/samples/.cvsignore +++ /dev/null @@ -1,4 +0,0 @@ -example.zip -exampleout.zip -filesystem.zip -zipdialogui.rb diff --git a/samples/example.rb b/samples/example.rb index 345e7e19..713729fb 100755 --- a/samples/example.rb +++ b/samples/example.rb @@ -1,4 +1,5 @@ #!/usr/bin/env ruby +# frozen_string_literal: true $LOAD_PATH << '../lib' system('zip example.zip example.rb gtk_ruby_zip.rb') @@ -20,7 +21,8 @@ zf = Zip::File.new('example.zip') zf.each_with_index do |entry, index| - puts "entry #{index} is #{entry.name}, size = #{entry.size}, compressed size = #{entry.compressed_size}" + puts "entry #{index} is #{entry.name}, size = #{entry.size}, " \ + "compressed size = #{entry.compressed_size}" # use zf.get_input_stream(entry) to get a ZipInputStream for the entry # entry can be the ZipEntry object or any object which has a to_s method that # returns the name of the entry. @@ -70,8 +72,11 @@ puts "Zip file splitted in #{part_zips_count} parts" # Track splitting an archive -Zip::File.split('large_zip_file.zip', 1_048_576, true, 'part_zip_file') do |part_count, part_index, chunk_bytes, segment_bytes| - puts "#{part_index} of #{part_count} part splitting: #{(chunk_bytes.to_f / segment_bytes * 100).to_i}%" +Zip::File.split( + 'large_zip_file.zip', 1_048_576, true, 'part_zip_file' +) do |part_count, part_index, chunk_bytes, segment_bytes| + puts "#{part_index} of #{part_count} part splitting: " \ + "#{(chunk_bytes.to_f / segment_bytes * 100).to_i}%" end # For other examples, look at zip.rb and ziptest.rb diff --git a/samples/example_filesystem.rb b/samples/example_filesystem.rb index 0d93ab6b..ec0075ee 100755 --- a/samples/example_filesystem.rb +++ b/samples/example_filesystem.rb @@ -1,4 +1,5 @@ #!/usr/bin/env ruby +# frozen_string_literal: true $LOAD_PATH << '../lib' @@ -6,9 +7,9 @@ EXAMPLE_ZIP = 'filesystem.zip' -File.delete(EXAMPLE_ZIP) if File.exist?(EXAMPLE_ZIP) +FileUtils.rm_f(EXAMPLE_ZIP) -Zip::File.open(EXAMPLE_ZIP, Zip::File::CREATE) do |zf| +Zip::File.open(EXAMPLE_ZIP, create: true) do |zf| zf.file.open('file1.txt', 'w') { |os| os.write 'first file1.txt' } zf.dir.mkdir('dir1') zf.dir.chdir('dir1') diff --git a/samples/example_recursive.rb b/samples/example_recursive.rb index 56a5cc7c..04b6f339 100644 --- a/samples/example_recursive.rb +++ b/samples/example_recursive.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'zip' # This is a simple example which uses rubyzip to @@ -21,7 +23,7 @@ def initialize(input_dir, output_file) def write entries = Dir.entries(@input_dir) - %w[. ..] - ::Zip::File.open(@output_file, ::Zip::File::CREATE) do |zipfile| + ::Zip::File.open(@output_file, create: true) do |zipfile| write_entries entries, '', zipfile end end diff --git a/samples/gtk_ruby_zip.rb b/samples/gtk_ruby_zip.rb index a86f0a9e..eeac2a0a 100755 --- a/samples/gtk_ruby_zip.rb +++ b/samples/gtk_ruby_zip.rb @@ -1,4 +1,5 @@ #!/usr/bin/env ruby +# frozen_string_literal: true $LOAD_PATH << '../lib' @@ -42,7 +43,8 @@ def initialize end class ButtonPanel < Gtk::HButtonBox - attr_reader :open_button, :extract_button + attr_reader :extract_button, :open_button + def initialize super set_layout(Gtk::BUTTONBOX_START) @@ -72,7 +74,7 @@ def open_zip(filename) @zipfile.each do |entry| @clist.append([entry.name, entry.size.to_s, - (100.0 * entry.compressedSize / entry.size).to_s + '%']) + "#{100.0 * entry.compressedSize / entry.size}%"]) end end end diff --git a/samples/qtzip.rb b/samples/qtzip.rb index 2c189ed6..917255fd 100755 --- a/samples/qtzip.rb +++ b/samples/qtzip.rb @@ -1,4 +1,5 @@ #!/usr/bin/env ruby +# frozen_string_literal: true $VERBOSE = true @@ -6,7 +7,7 @@ require 'Qt' system('rbuic -o zipdialogui.rb zipdialogui.ui') -require 'zipdialogui.rb' +require 'zipdialogui' require 'zip' a = Qt::Application.new(ARGV) @@ -65,14 +66,14 @@ def extract_files end puts "selected_items.size = #{selected_items.size}" puts "unselected_items.size = #{unselected_items.size}" - items = !selected_items.empty? ? selected_items : unselected_items + items = selected_items.empty? ? unselected_items : selected_items puts "items.size = #{items.size}" d = Qt::FileDialog.get_existing_directory(nil, self) - if !d - puts 'No directory chosen' - else + if d zipfile { |zf| items.each { |e| zf.extract(e, File.join(d, e)) } } + else + puts 'No directory chosen' end end diff --git a/samples/write_simple.rb b/samples/write_simple.rb index 8bb31bb3..84ac1387 100755 --- a/samples/write_simple.rb +++ b/samples/write_simple.rb @@ -1,10 +1,11 @@ #!/usr/bin/env ruby +# frozen_string_literal: true $LOAD_PATH << '../lib' require 'zip' -::Zip::OutputStream.open('simple.zip') do |zos| +Zip::OutputStream.open('simple.zip') do |zos| zos.put_next_entry 'entry.txt' zos.puts 'Hello world' end diff --git a/samples/zipfind.rb b/samples/zipfind.rb index 8f0dbf2e..c3888389 100755 --- a/samples/zipfind.rb +++ b/samples/zipfind.rb @@ -1,4 +1,5 @@ #!/usr/bin/env ruby +# frozen_string_literal: true $VERBOSE = true diff --git a/test/basic_zip_file_test.rb b/test/basic_zip_file_test.rb index 994728a3..160fd208 100644 --- a/test/basic_zip_file_test.rb +++ b/test/basic_zip_file_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class BasicZipFileTest < MiniTest::Test @@ -8,44 +10,35 @@ def setup end def test_entries - assert_equal(TestZipFile::TEST_ZIP2.entry_names.sort, - @zip_file.entries.entries.sort.map(&:name)) + expected_entry_names = TestZipFile::TEST_ZIP2.entry_names + actual_entry_names = @zip_file.entries.entries.map(&:name) + assert_equal(expected_entry_names.sort, actual_entry_names.sort) end def test_each - count = 0 - visited = {} - @zip_file.each do |entry| - assert(TestZipFile::TEST_ZIP2.entry_names.include?(entry.name)) - assert(!visited.include?(entry.name)) - visited[entry.name] = nil - count = count.succ - end - assert_equal(TestZipFile::TEST_ZIP2.entry_names.length, count) + expected_entry_names = TestZipFile::TEST_ZIP2.entry_names + actual_entry_names = [] + @zip_file.each { |entry| actual_entry_names << entry.name } + assert_equal(expected_entry_names.sort, actual_entry_names.sort) end def test_foreach - count = 0 - visited = {} - ::Zip::File.foreach(TestZipFile::TEST_ZIP2.zip_name) do |entry| - assert(TestZipFile::TEST_ZIP2.entry_names.include?(entry.name)) - assert(!visited.include?(entry.name)) - visited[entry.name] = nil - count = count.succ - end - assert_equal(TestZipFile::TEST_ZIP2.entry_names.length, count) + expected_entry_names = TestZipFile::TEST_ZIP2.entry_names + actual_entry_names = [] + ::Zip::File.foreach(TestZipFile::TEST_ZIP2.zip_name) { |entry| actual_entry_names << entry.name } + assert_equal(expected_entry_names.sort, actual_entry_names.sort) end def test_get_input_stream - count = 0 - visited = {} + expected_entry_names = TestZipFile::TEST_ZIP2.entry_names + actual_entry_names = [] + @zip_file.each do |entry| + actual_entry_names << entry.name assert_entry(entry.name, @zip_file.get_input_stream(entry), entry.name) - assert(!visited.include?(entry.name)) - visited[entry.name] = nil - count = count.succ end - assert_equal(TestZipFile::TEST_ZIP2.entry_names.length, count) + + assert_equal(expected_entry_names.sort, actual_entry_names.sort) end def test_get_input_stream_block diff --git a/test/bzip2_support_test.rb b/test/bzip2_support_test.rb index ab86b4e8..91c954a2 100644 --- a/test/bzip2_support_test.rb +++ b/test/bzip2_support_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class Bzip2SupportTest < MiniTest::Test @@ -5,7 +7,12 @@ class Bzip2SupportTest < MiniTest::Test def test_read Zip::InputStream.open(BZIP2_ZIP_TEST_FILE) do |zis| - assert_raises(Zip::CompressionMethodError) { zis.get_next_entry } + error = assert_raises(Zip::CompressionMethodError) do + zis.get_next_entry + end + + assert_equal(12, error.compression_method) + assert_match(/BZIP2/, error.message) end end end diff --git a/test/case_sensitivity_test.rb b/test/case_sensitivity_test.rb index 1c89551a..9a4d84f4 100644 --- a/test/case_sensitivity_test.rb +++ b/test/case_sensitivity_test.rb @@ -1,21 +1,25 @@ +# frozen_string_literal: true + require 'test_helper' class ZipCaseSensitivityTest < MiniTest::Test include CommonZipFileFixture - SRC_FILES = [['test/data/file1.txt', 'testfile.rb'], - ['test/data/file2.txt', 'testFILE.rb']] + SRC_FILES = [ + ['test/data/file1.txt', 'testfile.rb'], + ['test/data/file2.txt', 'testFILE.rb'] + ].freeze def teardown - ::Zip.case_insensitive_match = false + ::Zip.reset! end # Ensure that everything functions normally when +case_insensitive_match = false+ def test_add_case_sensitive ::Zip.case_insensitive_match = false - SRC_FILES.each { |fn, _en| assert(::File.exist?(fn)) } - zf = ::Zip::File.new(EMPTY_FILENAME, ::Zip::File::CREATE) + SRC_FILES.each { |(fn, _en)| assert(::File.exist?(fn)) } + zf = ::Zip::File.new(EMPTY_FILENAME, create: true) SRC_FILES.each { |fn, en| zf.add(en, fn) } zf.close @@ -33,20 +37,21 @@ def test_add_case_sensitive def test_add_case_insensitive ::Zip.case_insensitive_match = true - SRC_FILES.each { |fn, _en| assert(::File.exist?(fn)) } - zf = ::Zip::File.new(EMPTY_FILENAME, ::Zip::File::CREATE) + SRC_FILES.each { |(fn, _en)| assert(::File.exist?(fn)) } + zf = ::Zip::File.new(EMPTY_FILENAME, create: true) - assert_raises Zip::EntryExistsError do + error = assert_raises Zip::EntryExistsError do SRC_FILES.each { |fn, en| zf.add(en, fn) } end + assert_match(/'add'/, error.message) end # Ensure that names are treated case insensitively when reading files and +case_insensitive_match = true+ def test_add_case_sensitive_read_case_insensitive ::Zip.case_insensitive_match = false - SRC_FILES.each { |fn, _en| assert(::File.exist?(fn)) } - zf = ::Zip::File.new(EMPTY_FILENAME, ::Zip::File::CREATE) + SRC_FILES.each { |(fn, _en)| assert(::File.exist?(fn)) } + zf = ::Zip::File.new(EMPTY_FILENAME, create: true) SRC_FILES.each { |fn, en| zf.add(en, fn) } zf.close diff --git a/test/central_directory_entry_test.rb b/test/central_directory_entry_test.rb index c060a4d3..76d8b305 100644 --- a/test/central_directory_entry_test.rb +++ b/test/central_directory_entry_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class ZipCentralDirectoryEntryTest < MiniTest::Test @@ -57,13 +59,43 @@ def test_read_from_stream end end - def test_read_entry_from_truncated_zip_file - fragment = '' - File.open('test/data/testDirectory.bin') { |f| fragment = f.read(12) } # cdir entry header is at least 46 bytes - fragment.extend(IOizeString) - entry = ::Zip::Entry.new - entry.read_c_dir_entry(fragment) - raise 'ZipError expected' - rescue ::Zip::Error + def test_read_entry_from_truncated_zip_file_raises_error + File.open('test/data/testDirectory.bin') do |f| + # cdir entry header is at least 46 bytes, so just read a bit. + fragment = f.read(12) + assert_raises(::Zip::Error) do + entry = ::Zip::Entry.new + entry.read_c_dir_entry(StringIO.new(fragment)) + end + end + end + + def test_read_entry_from_truncated_zip_file_returns_nil + File.open('test/data/testDirectory.bin') do |f| + # cdir entry header is at least 46 bytes, so just read a bit. + fragment = f.read(12) + assert_nil(::Zip::Entry.read_c_dir_entry(StringIO.new(fragment))) + end + end + + def test_read_corrupted_entry_raises_error + fragment = File.binread('test/data/testDirectory.bin') + fragment.slice!(12) + io = StringIO.new(fragment) + assert_raises(::Zip::Error) do + entry = ::Zip::Entry.new + entry.read_c_dir_entry(io) + # First entry will be read but break later entries. + entry.read_c_dir_entry(io) + end + end + + def test_read_corrupted_entry_returns_nil + fragment = File.binread('test/data/testDirectory.bin') + fragment.slice!(12) + io = StringIO.new(fragment) + refute_nil(::Zip::Entry.read_c_dir_entry(io)) + # First entry will be read but break later entries. + assert_nil(::Zip::Entry.read_c_dir_entry(io)) end end diff --git a/test/central_directory_test.rb b/test/central_directory_test.rb index c4f7afa0..c36d2dc4 100644 --- a/test/central_directory_test.rb +++ b/test/central_directory_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class ZipCentralDirectoryTest < MiniTest::Test @@ -7,12 +9,11 @@ def teardown def test_read_from_stream ::File.open(TestZipFile::TEST_ZIP2.zip_name, 'rb') do |zip_file| - cdir = ::Zip::CentralDirectory.read_from_stream(zip_file) + cdir = ::Zip::CentralDirectory.new + cdir.read_from_stream(zip_file) assert_equal(TestZipFile::TEST_ZIP2.entry_names.size, cdir.size) - assert(cdir.entries.sort.compare_enumerables(TestZipFile::TEST_ZIP2.entry_names.sort) do |cdir_entry, test_entry_name| - cdir_entry.name == test_entry_name - end) + assert_equal(cdir.entries.map(&:name).sort, TestZipFile::TEST_ZIP2.entry_names.sort) assert_equal(TestZipFile::TEST_ZIP2.comment, cdir.comment) end end @@ -26,21 +27,52 @@ def test_read_from_invalid_stream rescue ::Zip::Error end - def test_read_from_truncated_zip_file - fragment = '' - File.open('test/data/testDirectory.bin', 'rb') { |f| fragment = f.read } - fragment.slice!(12) # removed part of first cdir entry. eocd structure still complete - fragment.extend(IOizeString) - entry = ::Zip::CentralDirectory.new - entry.read_from_stream(fragment) - raise 'ZipError expected' - rescue ::Zip::Error + def test_read_eocd_with_wrong_cdir_offset_from_file + ::File.open('test/data/testDirectory.bin', 'rb') do |f| + assert_raises(::Zip::Error) do + cdir = ::Zip::CentralDirectory.new + cdir.read_from_stream(f) + end + end + end + + def test_read_eocd_with_wrong_cdir_offset_from_buffer + ::File.open('test/data/testDirectory.bin', 'rb') do |f| + assert_raises(::Zip::Error) do + cdir = ::Zip::CentralDirectory.new + cdir.read_from_stream(StringIO.new(f.read)) + end + end + end + + def test_count_entries + [ + ['test/data/osx-archive.zip', 4], + ['test/data/zip64-sample.zip', 2], + ['test/data/max_length_file_comment.zip', 1], + ['test/data/100000-files.zip', 100_000] + ].each do |filename, num_entries| + cdir = ::Zip::CentralDirectory.new + + ::File.open(filename, 'rb') do |f| + assert_equal(num_entries, cdir.count_entries(f)) + + f.seek(0) + s = StringIO.new(f.read) + assert_equal(num_entries, cdir.count_entries(s)) + end + end end def test_write_to_stream - entries = [::Zip::Entry.new('file.zip', 'flimse', 'myComment', 'somethingExtra'), - ::Zip::Entry.new('file.zip', 'secondEntryName'), - ::Zip::Entry.new('file.zip', 'lastEntry.txt', 'Has a comment too')] + entries = [ + ::Zip::Entry.new( + 'file.zip', 'flimse', + comment: 'myComment', extra: 'somethingExtra' + ), + ::Zip::Entry.new('file.zip', 'secondEntryName'), + ::Zip::Entry.new('file.zip', 'lastEntry.txt', comment: 'Has a comment') + ] cdir = ::Zip::CentralDirectory.new(entries, 'my zip comment') File.open('test/data/generated/cdirtest.bin', 'wb') do |f| @@ -55,50 +87,60 @@ def test_write_to_stream assert_equal(cdir.entries.sort, cdir_readback.entries.sort) end - def test_write64_to_stream - ::Zip.write_zip64_support = true - entries = [::Zip::Entry.new('file.zip', 'file1-little', 'comment1', '', 200, 101, ::Zip::Entry::STORED, 200), - ::Zip::Entry.new('file.zip', 'file2-big', 'comment2', '', 18_000_000_000, 102, ::Zip::Entry::DEFLATED, 20_000_000_000), - ::Zip::Entry.new('file.zip', 'file3-alsobig', 'comment3', '', 15_000_000_000, 103, ::Zip::Entry::DEFLATED, 21_000_000_000), - ::Zip::Entry.new('file.zip', 'file4-little', 'comment4', '', 100, 104, ::Zip::Entry::DEFLATED, 121)] - [0, 250, 18_000_000_300, 33_000_000_350].each_with_index do |offset, index| - entries[index].local_header_offset = offset + def test_write64_to_stream_65536_entries + skip unless ENV['FULL_ZIP64_TEST'] + + entries = [] + 0x10000.times do |i| + entries << Zip::Entry.new('file.zip', "#{i}.txt") end - cdir = ::Zip::CentralDirectory.new(entries, 'zip comment') + cdir = Zip::CentralDirectory.new(entries) File.open('test/data/generated/cdir64test.bin', 'wb') do |f| cdir.write_to_stream(f) end - cdir_readback = ::Zip::CentralDirectory.new + cdir_readback = Zip::CentralDirectory.new File.open('test/data/generated/cdir64test.bin', 'rb') do |f| cdir_readback.read_from_stream(f) end - assert_equal(cdir.entries.sort, cdir_readback.entries.sort) - assert_equal(::Zip::VERSION_NEEDED_TO_EXTRACT_ZIP64, cdir_readback.instance_variable_get(:@version_needed_for_extract)) + assert_equal(0x10000, cdir_readback.size) + assert_equal(Zip::VERSION_NEEDED_TO_EXTRACT_ZIP64, cdir_readback.instance_variable_get(:@version_needed_for_extract)) end def test_equality - cdir1 = ::Zip::CentralDirectory.new([::Zip::Entry.new('file.zip', 'flimse', nil, - 'somethingExtra'), - ::Zip::Entry.new('file.zip', 'secondEntryName'), - ::Zip::Entry.new('file.zip', 'lastEntry.txt')], - 'my zip comment') - cdir2 = ::Zip::CentralDirectory.new([::Zip::Entry.new('file.zip', 'flimse', nil, - 'somethingExtra'), - ::Zip::Entry.new('file.zip', 'secondEntryName'), - ::Zip::Entry.new('file.zip', 'lastEntry.txt')], - 'my zip comment') - cdir3 = ::Zip::CentralDirectory.new([::Zip::Entry.new('file.zip', 'flimse', nil, - 'somethingExtra'), - ::Zip::Entry.new('file.zip', 'secondEntryName'), - ::Zip::Entry.new('file.zip', 'lastEntry.txt')], - 'comment?') - cdir4 = ::Zip::CentralDirectory.new([::Zip::Entry.new('file.zip', 'flimse', nil, - 'somethingExtra'), - ::Zip::Entry.new('file.zip', 'lastEntry.txt')], - 'comment?') + cdir1 = ::Zip::CentralDirectory.new( + [ + ::Zip::Entry.new('file.zip', 'flimse', extra: 'somethingExtra'), + ::Zip::Entry.new('file.zip', 'secondEntryName'), + ::Zip::Entry.new('file.zip', 'lastEntry.txt') + ], + 'my zip comment' + ) + cdir2 = ::Zip::CentralDirectory.new( + [ + ::Zip::Entry.new('file.zip', 'flimse', extra: 'somethingExtra'), + ::Zip::Entry.new('file.zip', 'secondEntryName'), + ::Zip::Entry.new('file.zip', 'lastEntry.txt') + ], + 'my zip comment' + ) + cdir3 = ::Zip::CentralDirectory.new( + [ + ::Zip::Entry.new('file.zip', 'flimse', extra: 'somethingExtra'), + ::Zip::Entry.new('file.zip', 'secondEntryName'), + ::Zip::Entry.new('file.zip', 'lastEntry.txt') + ], + 'comment?' + ) + cdir4 = ::Zip::CentralDirectory.new( + [ + ::Zip::Entry.new('file.zip', 'flimse', extra: 'somethingExtra'), + ::Zip::Entry.new('file.zip', 'lastEntry.txt') + ], + 'comment?' + ) assert_equal(cdir1, cdir1) assert_equal(cdir1, cdir2) diff --git a/test/constants_test.rb b/test/constants_test.rb index 8be01715..d31419f5 100644 --- a/test/constants_test.rb +++ b/test/constants_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class ConstantsTest < MiniTest::Test diff --git a/test/crypto/null_encryption_test.rb b/test/crypto/null_encryption_test.rb index ca039962..e6b6ef55 100644 --- a/test/crypto/null_encryption_test.rb +++ b/test/crypto/null_encryption_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class NullEncrypterTest < MiniTest::Test diff --git a/test/crypto/traditional_encryption_test.rb b/test/crypto/traditional_encryption_test.rb index 51f6cbb4..c3cc9fe0 100644 --- a/test/crypto/traditional_encryption_test.rb +++ b/test/crypto/traditional_encryption_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class TraditionalEncrypterTest < MiniTest::Test diff --git a/test/data/.gitattributes b/test/data/.gitattributes new file mode 100644 index 00000000..8c740099 --- /dev/null +++ b/test/data/.gitattributes @@ -0,0 +1,2 @@ +file1.txt eol=lf +file2.txt eol=lf diff --git a/test/data/100000-files.zip b/test/data/100000-files.zip new file mode 100644 index 00000000..ee3751ba Binary files /dev/null and b/test/data/100000-files.zip differ diff --git a/test/data/gpbit3stored.zip b/test/data/gpbit3stored.zip index 3c73eeb3..036424af 100644 Binary files a/test/data/gpbit3stored.zip and b/test/data/gpbit3stored.zip differ diff --git a/test/data/invalid-split.zip b/test/data/invalid-split.zip new file mode 100644 index 00000000..e6323d9e Binary files /dev/null and b/test/data/invalid-split.zip differ diff --git a/test/data/local_extra_field.zip b/test/data/local_extra_field.zip new file mode 100644 index 00000000..5a936e4c Binary files /dev/null and b/test/data/local_extra_field.zip differ diff --git a/test/data/max_length_file_comment.zip b/test/data/max_length_file_comment.zip new file mode 100644 index 00000000..dc39d04a Binary files /dev/null and b/test/data/max_length_file_comment.zip differ diff --git a/test/data/notzippedruby.rb b/test/data/notzippedruby.rb index 79f9cbb9..65b52b50 100755 --- a/test/data/notzippedruby.rb +++ b/test/data/notzippedruby.rb @@ -1,4 +1,5 @@ #!/usr/bin/env ruby +# frozen_string_literal: true class NotZippedRuby def return_true diff --git a/test/data/osx-archive.zip b/test/data/osx-archive.zip new file mode 100644 index 00000000..147a743f Binary files /dev/null and b/test/data/osx-archive.zip differ diff --git a/test/data/zip64_max_length_file_comment.zip b/test/data/zip64_max_length_file_comment.zip new file mode 100644 index 00000000..1b85108c Binary files /dev/null and b/test/data/zip64_max_length_file_comment.zip differ diff --git a/test/data/zipWithEncryption.zip b/test/data/zipWithEncryption.zip index e102b875..08215fcb 100644 Binary files a/test/data/zipWithEncryption.zip and b/test/data/zipWithEncryption.zip differ diff --git a/test/data/zipWithStoredCompressionAndEncryption.zip b/test/data/zipWithStoredCompressionAndEncryption.zip index 2fd545e9..f2d9c163 100644 Binary files a/test/data/zipWithStoredCompressionAndEncryption.zip and b/test/data/zipWithStoredCompressionAndEncryption.zip differ diff --git a/test/decompressor_test.rb b/test/decompressor_test.rb index d7ff2e73..9109a2e4 100644 --- a/test/decompressor_test.rb +++ b/test/decompressor_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class DecompressorTest < MiniTest::Test TEST_COMPRESSION_METHOD = 255 diff --git a/test/deflater_test.rb b/test/deflater_test.rb index 2506f920..9e6b1e2e 100644 --- a/test/deflater_test.rb +++ b/test/deflater_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class DeflaterTest < MiniTest::Test @@ -8,6 +10,10 @@ class DeflaterTest < MiniTest::Test DEFAULT_COMP_FILE = 'test/data/generated/compressiontest_default_compression.bin' NO_COMP_FILE = 'test/data/generated/compressiontest_no_compression.bin' + def teardown + Zip.reset! + end + def test_output_operator txt = load_file('test/data/file2.txt') deflate(txt, DEFLATER_TEST_FILE) @@ -43,7 +49,7 @@ def test_data_error private def load_file(filename) - File.open(filename, 'rb', &:read) + File.binread(filename) end def deflate(data, filename) diff --git a/test/dos_time_test.rb b/test/dos_time_test.rb new file mode 100644 index 00000000..70a38a9f --- /dev/null +++ b/test/dos_time_test.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'zip/dos_time' + +class DOSTimeTest < MiniTest::Test + def setup + @dos_time = Zip::DOSTime.new(2022, 1, 1, 12, 0, 0) + end + + def test_new + dos_time = Zip::DOSTime.new + assert(dos_time.absolute_time?) + + dos_time = Zip::DOSTime.new(2022, 1, 1, 12, 0, 0) + assert(dos_time.absolute_time?) + + dos_time = Zip::DOSTime.new(2022, 1, 1, 12, 0, 0, 0) + assert(dos_time.absolute_time?) + end + + def test_now + dos_time = Zip::DOSTime.now + assert(dos_time.absolute_time?) + end + + def test_utc + dos_time = Zip::DOSTime.utc(2022, 1, 1, 12, 0, 0) + assert(dos_time.absolute_time?) + end + + def test_gm + dos_time = Zip::DOSTime.gm(2022, 1, 1, 12, 0, 0) + assert(dos_time.absolute_time?) + end + + def test_mktime + dos_time = Zip::DOSTime.mktime(2022, 1, 1, 12, 0, 0) + assert(dos_time.absolute_time?) + end + + def test_from_time + time = Time.new(2022, 1, 1, 12, 0, 0) + dos_time = Zip::DOSTime.from_time(time) + assert_equal(@dos_time, dos_time) + assert(dos_time.absolute_time?) + end + + def test_parse_binary_dos_format + bin_dos_date = 0b101010000100001 + bin_dos_time = 0b110000000000000 + dos_time = Zip::DOSTime.parse_binary_dos_format(bin_dos_date, bin_dos_time) + assert_equal(@dos_time, dos_time) + refute(dos_time.absolute_time?) + end + + def test_at + time = Time.at(1_641_038_400) + dos_time = Zip::DOSTime.at(1_641_038_400) + assert_equal(time, dos_time) + assert(dos_time.absolute_time?) + end + + def test_local + dos_time = Zip::DOSTime.local(2022, 1, 1, 12, 0, 0) + assert(dos_time.absolute_time?) + end + + def test_comparison + time = Time.new(2022, 1, 1, 12, 0, 0) + assert_equal(0, @dos_time <=> time) + end + + def test_jruby_cmp + return unless defined? JRUBY_VERSION && Gem::Version.new(JRUBY_VERSION) < '9.2.18.0' + + time = Time.new(2022, 1, 1, 12, 0, 0) + assert(@dos_time == time) + assert(@dos_time <= time) + assert(@dos_time >= time) + + time = Time.new(2022, 1, 1, 12, 1, 1) + assert(time > @dos_time) + assert(@dos_time < time) + end +end diff --git a/test/encryption_test.rb b/test/encryption_test.rb index d3ed5ffb..05612817 100644 --- a/test/encryption_test.rb +++ b/test/encryption_test.rb @@ -1,42 +1,68 @@ +# frozen_string_literal: true + require 'test_helper' class EncryptionTest < MiniTest::Test ENCRYPT_ZIP_TEST_FILE = 'test/data/zipWithEncryption.zip' INPUT_FILE1 = 'test/data/file1.txt' + INPUT_FILE2 = 'test/data/file2.txt' def setup - @default_compression = Zip.default_compression Zip.default_compression = ::Zlib::DEFAULT_COMPRESSION end def teardown - Zip.default_compression = @default_compression + Zip.reset! end def test_encrypt - test_file = ::File.open(ENCRYPT_ZIP_TEST_FILE, 'rb').read - - @rand = [250, 143, 107, 13, 143, 22, 155, 75, 228, 150, 12] - @output = ::Zip::DOSTime.stub(:now, ::Zip::DOSTime.new(2014, 12, 17, 15, 56, 24)) do - Random.stub(:rand, ->(_range) { @rand.shift }) do - Zip::OutputStream.write_buffer(::StringIO.new(''), Zip::TraditionalEncrypter.new('password')) do |zos| - zos.put_next_entry('file1.txt') - zos.write ::File.open(INPUT_FILE1).read - end.string - end + content = File.read(INPUT_FILE1) + test_filename = 'top_secret_file.txt' + + password = 'swordfish' + + encrypted_zip = Zip::OutputStream.write_buffer( + ::StringIO.new, + encrypter: Zip::TraditionalEncrypter.new(password) + ) do |out| + out.put_next_entry(test_filename) + out.write content end - @output.unpack('C*').each_with_index do |c, i| - assert_equal test_file[i].ord, c + Zip::InputStream.open( + encrypted_zip, decrypter: Zip::TraditionalDecrypter.new(password) + ) do |zis| + entry = zis.get_next_entry + assert_equal test_filename, entry.name + assert_equal 1_327, entry.size + assert_equal content, zis.read end + + error = assert_raises(Zip::DecompressionError) do + Zip::InputStream.open( + encrypted_zip, + decrypter: Zip::TraditionalDecrypter.new("#{password}wrong") + ) do |zis| + zis.get_next_entry + assert_equal content, zis.read + end + end + assert_match(/Zlib error \('.+'\) while inflating\./, error.message) end def test_decrypt - Zip::InputStream.open(ENCRYPT_ZIP_TEST_FILE, 0, Zip::TraditionalDecrypter.new('password')) do |zis| + Zip::InputStream.open( + ENCRYPT_ZIP_TEST_FILE, + decrypter: Zip::TraditionalDecrypter.new('password') + ) do |zis| entry = zis.get_next_entry assert_equal 'file1.txt', entry.name - assert_equal 1327, entry.size - assert_equal ::File.open(INPUT_FILE1, 'r').read, zis.read + assert_equal 1_327, entry.size + assert_equal ::File.read(INPUT_FILE1), zis.read + entry = zis.get_next_entry + assert_equal 'file2.txt', entry.name + assert_equal 41_234, entry.size + assert_equal ::File.read(INPUT_FILE2), zis.read end end end diff --git a/test/entry_set_test.rb b/test/entry_set_test.rb index 4f137902..c5b27b73 100644 --- a/test/entry_set_test.rb +++ b/test/entry_set_test.rb @@ -1,14 +1,16 @@ +# frozen_string_literal: true + require 'test_helper' class ZipEntrySetTest < MiniTest::Test ZIP_ENTRIES = [ - ::Zip::Entry.new('zipfile.zip', 'name1', 'comment1'), - ::Zip::Entry.new('zipfile.zip', 'name3', 'comment1'), - ::Zip::Entry.new('zipfile.zip', 'name2', 'comment1'), - ::Zip::Entry.new('zipfile.zip', 'name4', 'comment1'), - ::Zip::Entry.new('zipfile.zip', 'name5', 'comment1'), - ::Zip::Entry.new('zipfile.zip', 'name6', 'comment1') - ] + ::Zip::Entry.new('zipfile.zip', 'name1', comment: 'comment1'), + ::Zip::Entry.new('zipfile.zip', 'name3', comment: 'comment1'), + ::Zip::Entry.new('zipfile.zip', 'name2', comment: 'comment1'), + ::Zip::Entry.new('zipfile.zip', 'name4', comment: 'comment1'), + ::Zip::Entry.new('zipfile.zip', 'name5', comment: 'comment1'), + ::Zip::Entry.new('zipfile.zip', 'name6', comment: 'comment1') + ].freeze def setup @zip_entry_set = ::Zip::EntrySet.new(ZIP_ENTRIES) @@ -20,13 +22,17 @@ def teardown def test_include assert(@zip_entry_set.include?(ZIP_ENTRIES.first)) - assert(!@zip_entry_set.include?(::Zip::Entry.new('different.zip', 'different', 'aComment'))) + assert( + !@zip_entry_set.include?( + ::Zip::Entry.new('different.zip', 'different', comment: 'aComment') + ) + ) end def test_size assert_equal(ZIP_ENTRIES.size, @zip_entry_set.size) assert_equal(ZIP_ENTRIES.size, @zip_entry_set.length) - @zip_entry_set << ::Zip::Entry.new('a', 'b', 'c') + @zip_entry_set << ::Zip::Entry.new('a', 'b', comment: 'c') assert_equal(ZIP_ENTRIES.size + 1, @zip_entry_set.length) end @@ -54,11 +60,19 @@ def test_delete def test_each # Used each instead each_with_index due the bug in jRuby count = 0 + new_size = 200 @zip_entry_set.each do |entry| assert(ZIP_ENTRIES.include?(entry)) + entry.clean_up # Start from a "saved" state. + entry.size = new_size # Check that entries can be changed in this block. count += 1 end + assert_equal(ZIP_ENTRIES.size, count) + @zip_entry_set.each do |entry| + assert_equal(new_size, entry.size) + assert(entry.dirty?) # Size was changed. + end end def test_entries @@ -66,7 +80,9 @@ def test_entries end def test_find_entry - entries = [::Zip::Entry.new('zipfile.zip', 'MiXeDcAsEnAmE', 'comment1')] + entries = [ + ::Zip::Entry.new('zipfile.zip', 'MiXeDcAsEnAmE', comment: 'comment1') + ] ::Zip.case_insensitive_match = true zip_entry_set = ::Zip::EntrySet.new(entries) @@ -93,10 +109,20 @@ def test_entries_sorted_in_each arr << entry end assert_equal(ZIP_ENTRIES.sort, arr) + + # Ensure `each` above hasn't permanently altered the ordering. + ::Zip.sort_entries = false + arr = [] + @zip_entry_set.each do |entry| + arr << entry + end + assert_equal(ZIP_ENTRIES, arr) end def test_compound - new_entry = ::Zip::Entry.new('zf.zip', 'new entry', "new entry's comment") + new_entry = ::Zip::Entry.new( + 'zf.zip', 'new entry', comment: "new entry's comment" + ) assert_equal(ZIP_ENTRIES.size, @zip_entry_set.size) @zip_entry_set << new_entry assert_equal(ZIP_ENTRIES.size + 1, @zip_entry_set.size) diff --git a/test/entry_test.rb b/test/entry_test.rb index 8daf7adc..b2d4c14e 100644 --- a/test/entry_test.rb +++ b/test/entry_test.rb @@ -1,18 +1,23 @@ +# frozen_string_literal: true + require 'test_helper' class ZipEntryTest < MiniTest::Test include ZipEntryData + def teardown + ::Zip.reset! + end + def test_constructor_and_getters - entry = ::Zip::Entry.new(TEST_ZIPFILE, - TEST_NAME, - TEST_COMMENT, - TEST_EXTRA, - TEST_COMPRESSED_SIZE, - TEST_CRC, - TEST_COMPRESSIONMETHOD, - TEST_SIZE, - TEST_TIME) + entry = ::Zip::Entry.new( + TEST_ZIPFILE, TEST_NAME, + comment: TEST_COMMENT, extra: TEST_EXTRA, + compressed_size: TEST_COMPRESSED_SIZE, + crc: TEST_CRC, size: TEST_SIZE, time: TEST_TIME, + compression_method: TEST_COMPRESSIONMETHOD, + compression_level: TEST_COMPRESSIONLEVEL + ) assert_equal(TEST_COMMENT, entry.comment) assert_equal(TEST_COMPRESSED_SIZE, entry.compressed_size) @@ -21,7 +26,10 @@ def test_constructor_and_getters assert_equal(TEST_COMPRESSIONMETHOD, entry.compression_method) assert_equal(TEST_NAME, entry.name) assert_equal(TEST_SIZE, entry.size) - assert_equal(TEST_TIME, entry.time) + + # Reverse times when testing because we need to use DOSTime#== for the + # comparison, not Time#==. + assert_equal(entry.time, TEST_TIME) end def test_is_directory_and_is_file @@ -39,30 +47,54 @@ def test_is_directory_and_is_file end def test_equality - entry1 = ::Zip::Entry.new('file.zip', 'name', 'isNotCompared', - 'something extra', 123, 1234, - ::Zip::Entry::DEFLATED, 10_000) - entry2 = ::Zip::Entry.new('file.zip', 'name', 'isNotComparedXXX', - 'something extra', 123, 1234, - ::Zip::Entry::DEFLATED, 10_000) - entry3 = ::Zip::Entry.new('file.zip', 'name2', 'isNotComparedXXX', - 'something extra', 123, 1234, - ::Zip::Entry::DEFLATED, 10_000) - entry4 = ::Zip::Entry.new('file.zip', 'name2', 'isNotComparedXXX', - 'something extraXX', 123, 1234, - ::Zip::Entry::DEFLATED, 10_000) - entry5 = ::Zip::Entry.new('file.zip', 'name2', 'isNotComparedXXX', - 'something extraXX', 12, 1234, - ::Zip::Entry::DEFLATED, 10_000) - entry6 = ::Zip::Entry.new('file.zip', 'name2', 'isNotComparedXXX', - 'something extraXX', 12, 123, - ::Zip::Entry::DEFLATED, 10_000) - entry7 = ::Zip::Entry.new('file.zip', 'name2', 'isNotComparedXXX', - 'something extraXX', 12, 123, - ::Zip::Entry::STORED, 10_000) - entry8 = ::Zip::Entry.new('file.zip', 'name2', 'isNotComparedXXX', - 'something extraXX', 12, 123, - ::Zip::Entry::STORED, 100_000) + entry1 = ::Zip::Entry.new( + 'file.zip', 'name', + comment: 'isNotCompared', extra: 'something extra', + compressed_size: 123, crc: 1234, size: 10_000 + ) + + entry2 = ::Zip::Entry.new( + 'file.zip', 'name', + comment: 'isNotComparedXXX', extra: 'something extra', + compressed_size: 123, crc: 1234, size: 10_000 + ) + + entry3 = ::Zip::Entry.new( + 'file.zip', 'name2', + comment: 'isNotComparedXXX', extra: 'something extra', + compressed_size: 123, crc: 1234, size: 10_000 + ) + + entry4 = ::Zip::Entry.new( + 'file.zip', 'name2', + comment: 'isNotComparedXXX', extra: 'something extraXX', + compressed_size: 123, crc: 1234, size: 10_000 + ) + + entry5 = ::Zip::Entry.new( + 'file.zip', 'name2', + comment: 'isNotComparedXXX', extra: 'something extraXX', + compressed_size: 12, crc: 1234, size: 10_000 + ) + + entry6 = ::Zip::Entry.new( + 'file.zip', 'name2', + comment: 'isNotComparedXXX', extra: 'something extraXX', + compressed_size: 12, crc: 123, size: 10_000 + ) + + entry7 = ::Zip::Entry.new( + 'file.zip', 'name2', comment: 'isNotComparedXXX', + extra: 'something extraXX', compressed_size: 12, crc: 123, size: 10_000, + compression_method: ::Zip::Entry::STORED + ) + + entry8 = ::Zip::Entry.new( + 'file.zip', 'name2', + comment: 'isNotComparedXXX', extra: 'something extraXX', + compressed_size: 12, crc: 123, size: 100_000, + compression_method: ::Zip::Entry::STORED + ) assert_equal(entry1, entry1) assert_equal(entry1, entry2) @@ -118,38 +150,52 @@ def test_parent_as_string end def test_entry_name_cannot_start_with_slash - assert_raises(::Zip::EntryNameError) { ::Zip::Entry.new('zf.zip', '/hej/der') } + error = assert_raises(::Zip::EntryNameError) do + ::Zip::Entry.new('zf.zip', '/hej/der') + end + assert_match(/'\/hej\/der'/, error.message) end - def test_store_file_without_compression - File.delete('/tmp/no_compress.zip') if File.exist?('/tmp/no_compress.zip') - files = Dir[File.join('test/data/globTest', '**', '**')] + def test_entry_name_cannot_be_too_long + name = 'a' * 65_535 + ::Zip::Entry.new('', name) # Should not raise anything. - Zip.setup do |z| - z.write_zip64_support = false + error = assert_raises(::Zip::EntryNameError) do + ::Zip::Entry.new('', "a#{name}") end + assert_match(/65,536/, error.message) + end - zipfile = Zip::File.open('/tmp/no_compress.zip', Zip::File::CREATE) - mimetype_entry = Zip::Entry.new(zipfile, # @zipfile - 'mimetype', # @name - '', # @comment - '', # @extra - 0, # @compressed_size - 0, # @crc - Zip::Entry::STORED) # @comppressed_method + def test_store_file_without_compression + Dir.mktmpdir do |tmp| + tmp_zip = File.join(tmp, 'no_compress.zip') - zipfile.add(mimetype_entry, 'test/data/mimetype') + Zip.setup do |z| + z.write_zip64_support = false + end - files.each do |file| - zipfile.add(file.sub('test/data/globTest/', ''), file) - end - zipfile.close + zipfile = Zip::File.open(tmp_zip, create: true) + + mimetype_entry = Zip::Entry.new( + zipfile, # @zipfile + 'mimetype', # @name + compression_method: Zip::Entry::STORED + ) + zipfile.add(mimetype_entry, 'test/data/mimetype') + + files = Dir[File.join('test/data/globTest', '**', '**')] + files.each do |file| + zipfile.add(file.sub('test/data/globTest/', ''), file) + end + + zipfile.close - f = File.open('/tmp/no_compress.zip', 'rb') - first_100_bytes = f.read(100) - f.close + f = File.open(tmp_zip, 'rb') + first_100_bytes = f.read(100) + f.close - assert_match(/mimetypeapplication\/epub\+zip/, first_100_bytes) + assert_match(/mimetypeapplication\/epub\+zip/, first_100_bytes) + end end def test_encrypted? @@ -169,4 +215,137 @@ def test_incomplete? entry.gp_flags = 0 assert_equal(false, entry.incomplete?) end + + def test_compression_level_flags + [ + [Zip.default_compression, 0], + [0, 0], + [1, 6], + [2, 4], + [3, 0], + [7, 0], + [8, 2], + [9, 2] + ].each do |level, flags| + # Check flags are set correctly when DEFLATED is (implicitly) specified. + e_def = Zip::Entry.new( + '', '', + compression_level: level + ) + assert_equal(flags, e_def.gp_flags & 0b110) + + # Check that flags are not set when STORED is specified. + e_sto = Zip::Entry.new( + '', '', + compression_method: Zip::Entry::STORED, + compression_level: level + ) + assert_equal(0, e_sto.gp_flags & 0b110) + end + + # Check that a directory entry's flags are not set, even if DEFLATED + # is specified. + e_dir = Zip::Entry.new( + '', 'd/', compression_method: Zip::Entry::DEFLATED, compression_level: 1 + ) + assert_equal(0, e_dir.gp_flags & 0b110) + end + + def test_compression_method_reader + [ + [Zip.default_compression, Zip::Entry::DEFLATED], + [0, Zip::Entry::STORED], + [1, Zip::Entry::DEFLATED], + [9, Zip::Entry::DEFLATED] + ].each do |level, method| + # Check that the correct method is returned when DEFLATED is specified. + entry = Zip::Entry.new(compression_level: level) + assert_equal(method, entry.compression_method) + end + + # Check that the correct method is returned when STORED is specified. + entry = Zip::Entry.new( + compression_method: Zip::Entry::STORED, compression_level: 1 + ) + assert_equal(Zip::Entry::STORED, entry.compression_method) + + # Check that directories are always STORED, whatever level is specified. + entry = Zip::Entry.new( + '', 'd/', compression_method: Zip::Entry::DEFLATED, compression_level: 1 + ) + assert_equal(Zip::Entry::STORED, entry.compression_method) + end + + def test_set_time_as_dos_time + entry = ::Zip::Entry.new + assert(entry.time.kind_of?(::Zip::DOSTime)) + entry.time = Time.now + assert(entry.time.kind_of?(::Zip::DOSTime)) + entry.time = ::Zip::DOSTime.now + assert(entry.time.kind_of?(::Zip::DOSTime)) + end + + def test_atime + entry = ::Zip::Entry.new + time = Time.new(1999, 12, 31, 23, 59, 59) + + entry.atime = time + assert(entry.dirty?) + assert_equal(::Zip::DOSTime.from_time(time), entry.atime) + refute_equal(entry.time, entry.atime) + assert(entry.atime.kind_of?(::Zip::DOSTime)) + assert_nil(entry.ctime) + end + + def test_ctime + entry = ::Zip::Entry.new + time = Time.new(1999, 12, 31, 23, 59, 59) + + entry.ctime = time + assert(entry.dirty?) + assert_equal(::Zip::DOSTime.from_time(time), entry.ctime) + refute_equal(entry.time, entry.ctime) + assert(entry.ctime.kind_of?(::Zip::DOSTime)) + assert_nil(entry.atime) + end + + def test_mtime + entry = ::Zip::Entry.new + time = Time.new(1999, 12, 31, 23, 59, 59) + + entry.mtime = time + assert(entry.dirty?) + assert_equal(::Zip::DOSTime.from_time(time), entry.mtime) + assert_equal(entry.time, entry.mtime) + assert(entry.mtime.kind_of?(::Zip::DOSTime)) + assert_nil(entry.atime) + assert_nil(entry.ctime) + end + + def test_time + entry = ::Zip::Entry.new + time = Time.new(1999, 12, 31, 23, 59, 59) + + entry.time = time + assert(entry.dirty?) + assert_equal(::Zip::DOSTime.from_time(time), entry.time) + assert_equal(entry.mtime, entry.time) + assert(entry.time.kind_of?(::Zip::DOSTime)) + assert_nil(entry.atime) + assert_nil(entry.ctime) + end + + def test_ensure_entry_time_set_to_file_mtime + entry = ::Zip::Entry.new + entry.gather_fileinfo_from_srcpath('test/data/mimetype') + assert_equal(entry.time, File.stat('test/data/mimetype').mtime) + end + + def test_absolute_time + entry = ::Zip::Entry.new + refute(entry.absolute_time?) + + entry.time = Time.now + assert(entry.absolute_time?) + end end diff --git a/test/errors_test.rb b/test/errors_test.rb deleted file mode 100644 index 5e6260f8..00000000 --- a/test/errors_test.rb +++ /dev/null @@ -1,33 +0,0 @@ -require 'test_helper' - -class ErrorsTest < MiniTest::Test - def test_rescue_legacy_zip_error - raise ::Zip::Error - rescue ::Zip::ZipError - end - - def test_rescue_legacy_zip_entry_exists_error - raise ::Zip::EntryExistsError - rescue ::Zip::ZipEntryExistsError - end - - def test_rescue_legacy_zip_destination_file_exists_error - raise ::Zip::DestinationFileExistsError - rescue ::Zip::ZipDestinationFileExistsError - end - - def test_rescue_legacy_zip_compression_method_error - raise ::Zip::CompressionMethodError - rescue ::Zip::ZipCompressionMethodError - end - - def test_rescue_legacy_zip_entry_name_error - raise ::Zip::EntryNameError - rescue ::Zip::ZipEntryNameError - end - - def test_rescue_legacy_zip_internal_error - raise ::Zip::InternalError - rescue ::Zip::ZipInternalError - end -end diff --git a/test/extra_field_test.rb b/test/extra_field_test.rb index fa6e212d..c91a7bc6 100644 --- a/test/extra_field_test.rb +++ b/test/extra_field_test.rb @@ -1,30 +1,56 @@ +# frozen_string_literal: true + require 'test_helper' class ZipExtraFieldTest < MiniTest::Test def test_new extra_pure = ::Zip::ExtraField.new('') extra_withstr = ::Zip::ExtraField.new('foo') + extra_withstr_local = ::Zip::ExtraField.new('foo', local: true) + assert_instance_of(::Zip::ExtraField, extra_pure) assert_instance_of(::Zip::ExtraField, extra_withstr) + assert_instance_of(::Zip::ExtraField, extra_withstr_local) + + assert_equal('foo', extra_withstr['Unknown'].to_c_dir_bin) + assert_equal('foo', extra_withstr_local['Unknown'].to_local_bin) end def test_unknownfield extra = ::Zip::ExtraField.new('foo') - assert_equal(extra['Unknown'], 'foo') + assert_equal('foo', extra['Unknown'].to_c_dir_bin) + extra.merge('a') - assert_equal(extra['Unknown'], 'fooa') + assert_equal('fooa', extra['Unknown'].to_c_dir_bin) + extra.merge('barbaz') - assert_equal(extra.to_s, 'fooabarbaz') + assert_equal('fooabarbaz', extra['Unknown'].to_c_dir_bin) + + extra.merge('bar', local: true) + assert_equal('bar', extra['Unknown'].to_local_bin) + assert_equal('fooabarbaz', extra['Unknown'].to_c_dir_bin) + end + + def test_bad_header_id + str = "ut\x5\0\x3\250$\r@" + ut = nil + assert_output('', /WARNING/) do + ut = ::Zip::ExtraField::UniversalTime.new(str) + end + assert_instance_of(::Zip::ExtraField::UniversalTime, ut) + assert_nil(ut.mtime) end def test_ntfs - str = "\x0A\x00 \x00\x00\x00\x00\x00\x01\x00\x18\x00\xC0\x81\x17\xE8B\xCE\xCF\x01\xC0\x81\x17\xE8B\xCE\xCF\x01\xC0\x81\x17\xE8B\xCE\xCF\x01" + str = +"\x0A\x00 \x00\x00\x00\x00\x00\x01\x00\x18\x00\xC0\x81\x17\xE8B\xCE\xCF\x01\xC0\x81\x17\xE8B\xCE\xCF\x01\xC0\x81\x17\xE8B\xCE\xCF\x01" extra = ::Zip::ExtraField.new(str) assert(extra.member?('NTFS')) t = ::Zip::DOSTime.at(1_410_496_497.405178) assert_equal(t, extra['NTFS'].mtime) assert_equal(t, extra['NTFS'].atime) assert_equal(t, extra['NTFS'].ctime) + + assert_equal(str.force_encoding('BINARY'), extra.to_local_bin) end def test_merge @@ -52,9 +78,9 @@ def test_to_s extra = ::Zip::ExtraField.new(str) assert_instance_of(String, extra.to_s) - s = extra.to_s - extra.merge('foo') - assert_equal(s.length + 3, extra.to_s.length) + extra_len = extra.to_s.length + extra.merge('foo', local: true) + assert_equal(extra_len + 3, extra.to_s.length) end def test_equality @@ -73,4 +99,26 @@ def test_equality extra1.create('IUnix') assert_equal(extra1, extra3) end + + def test_read_local_extra_field + ::Zip::File.open('test/data/local_extra_field.zip') do |zf| + ['file1.txt', 'file2.txt'].each do |file| + entry = zf.get_entry(file) + + assert_instance_of(::Zip::ExtraField, entry.extra) + assert_equal(1_000, entry.extra['IUnix'].uid) + assert_equal(1_000, entry.extra['IUnix'].gid) + end + end + end + + def test_load_unknown_extra_field + ::Zip::File.open('test/data/osx-archive.zip') do |zf| + zf.each do |entry| + # Check that there is only one occurance of the 'ux' extra field. + assert_equal(0, entry.extra['Unknown'].to_c_dir_bin.rindex('ux')) + assert_equal(0, entry.extra['Unknown'].to_local_bin.rindex('ux')) + end + end + end end diff --git a/test/extra_field_unknown_test.rb b/test/extra_field_unknown_test.rb new file mode 100644 index 00000000..4d24d928 --- /dev/null +++ b/test/extra_field_unknown_test.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ZipExtraFieldUnknownTest < MiniTest::Test + def test_new + extra = ::Zip::ExtraField::Unknown.new + assert_empty(extra.to_c_dir_bin) + assert_empty(extra.to_local_bin) + end + + def test_merge_cdir_then_local + extra = ::Zip::ExtraField::Unknown.new + field = "ux\v\x00\x01\x04\xF6\x01\x00\x00\x04\x14\x00\x00\x00" + + extra.merge(field) + assert_empty(extra.to_local_bin) + assert_equal(field, extra.to_c_dir_bin) + + extra.merge(field, local: true) + assert_equal(field, extra.to_local_bin) + assert_equal(field, extra.to_c_dir_bin) + end + + def test_merge_local_only + extra = ::Zip::ExtraField::Unknown.new + field = "ux\v\x00\x01\x04\xF6\x01\x00\x00\x04\x14\x00\x00\x00" + + extra.merge(field, local: true) + assert_equal(field, extra.to_local_bin) + assert_empty(extra.to_c_dir_bin) + end + + def test_equality + extra1 = ::Zip::ExtraField::Unknown.new + extra2 = ::Zip::ExtraField::Unknown.new + assert_equal(extra1, extra2) + + extra1.merge("ux\v\x00\x01\x04\xF6\x01\x00\x00\x04\x14\x00\x00\x00") + refute_equal(extra1, extra2) + + extra2.merge("ux\v\x00\x01\x04\xF6\x01\x00\x00\x04\x14\x00\x00\x00") + assert_equal(extra1, extra2) + + extra1.merge('foo', local: true) + refute_equal(extra1, extra2) + + extra2.merge('foo', local: true) + assert_equal(extra1, extra2) + end +end diff --git a/test/extra_field_ut_test.rb b/test/extra_field_ut_test.rb index 6b854978..9f16b616 100644 --- a/test/extra_field_ut_test.rb +++ b/test/extra_field_ut_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class ZipExtraFieldUTTest < MiniTest::Test @@ -9,7 +11,7 @@ class ZipExtraFieldUTTest < MiniTest::Test ["UT\x09\x00\x05PS>APS>A", 0b101, true, false, false], ["UT\x09\x00\x06PS>APS>A", 0b110, false, false, true], ["UT\x13\x00\x07PS>APS>APS>A", 0b111, false, false, false] - ] + ].freeze def test_parse PARSE_TESTS.each do |bin, flags, a, c, m| diff --git a/test/file_extract_directory_test.rb b/test/file_extract_directory_test.rb index 02a3fd0d..8c70d759 100644 --- a/test/file_extract_directory_test.rb +++ b/test/file_extract_directory_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class ZipFileExtractDirectoryTest < MiniTest::Test @@ -20,7 +22,7 @@ def setup super Dir.rmdir(TEST_OUT_NAME) if File.directory? TEST_OUT_NAME - File.delete(TEST_OUT_NAME) if File.exist? TEST_OUT_NAME + FileUtils.rm_f(TEST_OUT_NAME) end def test_extract_directory @@ -36,7 +38,9 @@ def test_extract_directory_exists_as_dir def test_extract_directory_exists_as_file File.open(TEST_OUT_NAME, 'w') { |f| f.puts 'something' } - assert_raises(::Zip::DestinationFileExistsError) { extract_test_dir } + assert_raises(::Zip::DestinationExistsError) do + extract_test_dir + end end def test_extract_directory_exists_as_file_overwrite @@ -44,7 +48,7 @@ def test_extract_directory_exists_as_file_overwrite called = false extract_test_dir do |entry, dest_path| called = true - assert_equal(TEST_OUT_NAME, dest_path) + assert_equal(File.absolute_path(TEST_OUT_NAME), dest_path) assert(entry.directory?) true end diff --git a/test/file_extract_test.rb b/test/file_extract_test.rb index 0e697187..9e353d1c 100644 --- a/test/file_extract_test.rb +++ b/test/file_extract_test.rb @@ -1,8 +1,11 @@ +# frozen_string_literal: true + require 'test_helper' class ZipFileExtractTest < MiniTest::Test include CommonZipFileFixture EXTRACTED_FILENAME = 'test/data/generated/extEntry' + EXTRACTED_FILENAME_ABS = ::File.absolute_path(EXTRACTED_FILENAME) ENTRY_TO_EXTRACT, *REMAINING_ENTRIES = TEST_ZIP.entry_names.reverse def setup @@ -35,13 +38,14 @@ def test_extract def test_extract_exists text = 'written text' - ::File.open(EXTRACTED_FILENAME, 'w') { |f| f.write(text) } + ::File.write(EXTRACTED_FILENAME, text) - assert_raises(::Zip::DestinationFileExistsError) do + assert_raises(::Zip::DestinationExistsError) do ::Zip::File.open(TEST_ZIP.zip_name) do |zf| zf.extract(zf.entries.first, EXTRACTED_FILENAME) end end + File.open(EXTRACTED_FILENAME, 'r') do |f| assert_equal(text, f.read) end @@ -49,13 +53,13 @@ def test_extract_exists def test_extract_exists_overwrite text = 'written text' - ::File.open(EXTRACTED_FILENAME, 'w') { |f| f.write(text) } + ::File.write(EXTRACTED_FILENAME, text) called_correctly = false ::Zip::File.open(TEST_ZIP.zip_name) do |zf| zf.extract(zf.entries.first, EXTRACTED_FILENAME) do |entry, extract_loc| called_correctly = zf.entries.first == entry && - extract_loc == EXTRACTED_FILENAME + extract_loc == EXTRACTED_FILENAME_ABS true end end @@ -73,7 +77,7 @@ def test_extract_non_entry zf.close if zf end - def test_extract_non_entry_2 + def test_extract_another_non_entry out_file = 'outfile' assert_raises(Errno::ENOENT) do zf = ::Zip::File.new(TEST_ZIP.zip_name) @@ -86,6 +90,8 @@ def test_extract_non_entry_2 end def test_extract_incorrect_size + Zip.write_zip64_support = false + # The uncompressed size fields in the zip file cannot be trusted. This makes # it harder for callers to validate the sizes of the files they are # extracting, which can lead to denial of service. See also @@ -97,7 +103,7 @@ def test_extract_incorrect_size true_size = 500_000 fake_size = 1 - ::Zip::File.open(real_zip, ::Zip::File::CREATE) do |zf| + ::Zip::File.open(real_zip, create: true) do |zf| zf.get_output_stream(file_name) do |os| os.write 'a' * true_size end @@ -110,24 +116,84 @@ def test_extract_incorrect_size assert_equal true_size, a_entry.size end - true_size_bytes = [compressed_size, true_size, file_name.size].pack('LLS') - fake_size_bytes = [compressed_size, fake_size, file_name.size].pack('LLS') + true_size_bytes = [compressed_size, true_size, file_name.size].pack('VVv') + fake_size_bytes = [compressed_size, fake_size, file_name.size].pack('VVv') data = File.binread(real_zip) assert data.include?(true_size_bytes) data.gsub! true_size_bytes, fake_size_bytes - File.open(fake_zip, 'wb') do |file| - file.write data + File.binwrite(fake_zip, data) + + Dir.chdir tmp do + ::Zip::File.open(fake_zip) do |zf| + a_entry = zf.find_entry(file_name) + assert_equal fake_size, a_entry.size + + ::Zip.validate_entry_sizes = false + assert_output('', /.+'a'.+1B.+/) do + a_entry.extract + end + assert_equal true_size, File.size(file_name) + FileUtils.rm file_name + + ::Zip.validate_entry_sizes = true + error = assert_raises ::Zip::EntrySizeError do + a_entry.extract + end + assert_equal( + "Entry 'a' should be 1B, but is larger when inflated.", + error.message + ) + end + end + end + end + + def test_extract_incorrect_size_zip64 + # The uncompressed size fields in the zip file cannot be trusted. This makes + # it harder for callers to validate the sizes of the files they are + # extracting, which can lead to denial of service. See also + # https://en.wikipedia.org/wiki/Zip_bomb + # + # This version of the test ensures that fraudulent sizes in the ZIP64 + # extensions are caught. + Dir.mktmpdir do |tmp| + real_zip = File.join(tmp, 'real.zip') + fake_zip = File.join(tmp, 'fake.zip') + file_name = 'a' + true_size = 500_000 + fake_size = 1 + + ::Zip::File.open(real_zip, create: true) do |zf| + zf.get_output_stream(file_name) do |os| + os.write 'a' * true_size + end + end + + compressed_size = nil + ::Zip::File.open(real_zip) do |zf| + a_entry = zf.find_entry(file_name) + compressed_size = a_entry.compressed_size + assert_equal true_size, a_entry.size end + true_size_bytes = [0x1, 16, true_size, compressed_size].pack('vvQ sizes[1]) + assert(sizes[1] > sizes[2]) + end + + def test_add_different_compression_as_default + src_file = 'test/data/file2.txt' + entry_name = 'newEntryName.rb' + files = [ + ['test/data/fast_comp.zip', Zlib::BEST_SPEED], + ['test/data/default_comp.zip', Zlib::DEFAULT_COMPRESSION], + ['test/data/best_comp.zip', Zlib::BEST_COMPRESSION] + ] + sizes = [] + + files.each do |name, comp| + ::Zip.default_compression = comp + zf = ::Zip::File.new(name, create: true) + + zf.add(entry_name, src_file) + zf.close + + zf_read = ::Zip::File.new(name) + entry = zf_read.entries.first + assert_equal(File.size(src_file), entry.size) + refute(entry.zip64?) # No ZIP64 extra as we know the entry size here. + AssertEntry.assert_contents( + src_file, zf_read.get_input_stream(entry.name, &:read) + ) + sizes << entry.compressed_size + zf_read.close + + ::File.delete(name) + end + + assert(sizes[0] > sizes[1]) + assert(sizes[1] > sizes[2]) end def test_add_stored src_file = 'test/data/file2.txt' entry_name = 'newEntryName.rb' assert(::File.exist?(src_file)) - zf = ::Zip::File.new(EMPTY_FILENAME, ::Zip::File::CREATE) + zf = ::Zip::File.new(EMPTY_FILENAME, create: true) zf.add_stored(entry_name, src_file) zf.close @@ -220,29 +369,35 @@ def test_add_stored assert_equal(File.size(src_file), entry.size) assert_equal(entry.size, entry.compressed_size) assert_equal(::Zip::Entry::STORED, entry.compression_method) + refute(entry.zip64?) # No ZIP64 extra as we know the entry size here. AssertEntry.assert_contents(src_file, zf_read.get_input_stream(entry_name, &:read)) end def test_recover_permissions_after_add_files_to_archive + # Windows NT does not support granular permissions + skip if Zip::RUNNING_ON_WINDOWS + src_zip = TEST_ZIP.zip_name - ::File.chmod(0o664, src_zip) - src_file = 'test/data/file2.txt' - entry_name = 'newEntryName.rb' - assert_equal(::File.stat(src_zip).mode, 0o100664) assert(::File.exist?(src_zip)) - zf = ::Zip::File.new(src_zip, ::Zip::File::CREATE) - zf.add(entry_name, src_file) + + ::File.chmod(0o664, src_zip) + assert_equal(0o100664, ::File.stat(src_zip).mode) + + zf = ::Zip::File.new(src_zip, create: true) + zf.add('newEntryName.rb', 'test/data/file2.txt') zf.close - assert_equal(::File.stat(src_zip).mode, 0o100664) + + assert_equal(0o100664, ::File.stat(src_zip).mode) end def test_add_existing_entry_name - assert_raises(::Zip::EntryExistsError) do + error = assert_raises(::Zip::EntryExistsError) do ::Zip::File.open(TEST_ZIP.zip_name) do |zf| zf.add(zf.entries.first.name, 'test/data/file2.txt') end end + assert_match(/'add'/, error.message) end def test_add_existing_entry_name_replace @@ -268,10 +423,50 @@ def test_add_directory ::Zip::File.open(TEST_ZIP.zip_name) do |zf| dir_entry = zf.entries.detect do |e| - e.name == TestFiles::EMPTY_TEST_DIR + '/' + e.name == "#{TestFiles::EMPTY_TEST_DIR}/" end assert(dir_entry.directory?) + refute(dir_entry.zip64?) # No ZIP64 extra as we know the entry size here. + end + end + + def test_mkdir + buffer = ::Zip::File.open_buffer(create: true) do |zf| + # Add a directory with no slash. + zf.mkdir('dir') + + # Add it again. + assert_raises(Errno::EEXIST) do + zf.mkdir('dir') + end + + # Add it with a slash. + assert_raises(Errno::EEXIST) do + zf.mkdir('dir/') + end + + # Add a directory with a slash. + zf.mkdir('folder/') + + # Add it again. + assert_raises(Errno::EEXIST) do + zf.mkdir('folder/') + end + + # Add it without a slash. + assert_raises(Errno::EEXIST) do + zf.mkdir('folder') + end + end + + ::Zip::File.open_buffer(buffer) do |zf| + ['dir/', 'dir', 'folder/', 'folder'].each do |dir| + entry = zf.find_entry(dir) + + assert(entry.directory?) + refute(entry.zip64?) + end end end @@ -321,7 +516,7 @@ def test_rename_with_each ::File.unlink(zf_name) if ::File.exist?(zf_name) arr = [] arr_renamed = [] - ::Zip::File.open(zf_name, ::Zip::File::CREATE) do |zf| + ::Zip::File.open(zf_name, create: true) do |zf| zf.mkdir('test') arr << 'test/' arr_renamed << 'Ztest/' @@ -334,7 +529,7 @@ def test_rename_with_each zf = ::Zip::File.open(zf_name) assert_equal(zf.entries.map(&:name), arr) zf.close - Zip::File.open(zf_name, 'wb') do |z| + Zip::File.open(zf_name) do |z| z.each do |f| z.rename(f, "Z#{f.name}") end @@ -349,11 +544,12 @@ def test_rename_to_existing_entry old_entries = nil ::Zip::File.open(TEST_ZIP.zip_name) { |zf| old_entries = zf.entries } - assert_raises(::Zip::EntryExistsError) do + error = assert_raises(::Zip::EntryExistsError) do ::Zip::File.open(TEST_ZIP.zip_name) do |zf| zf.rename(zf.entries[0], zf.entries[1].name) end end + assert_match(/'rename'/, error.message) ::Zip::File.open(TEST_ZIP.zip_name) do |zf| assert_equal(old_entries.sort.map(&:name), zf.entries.sort.map(&:name)) @@ -397,7 +593,10 @@ def test_rename_non_entry def test_rename_entry_to_existing_entry entry1, entry2, * = TEST_ZIP.entry_names zf = ::Zip::File.new(TEST_ZIP.zip_name) - assert_raises(::Zip::EntryExistsError) { zf.rename(entry1, entry2) } + error = assert_raises(::Zip::EntryExistsError) do + zf.rename(entry1, entry2) + end + assert_match(/'rename'/, error.message) ensure zf.close end @@ -455,14 +654,27 @@ def test_commit assert_equal(res, true) end + def test_commit_preserves_options + zip_file = 'test/data/generated/preserve_options.zip' + ::Zip::File.open(zip_file, create: true, compression_level: 8) do |zf| + assert(zf.commit_required?) + zf.commit + assert_equal(8, zf.instance_variable_get(:@compression_level)) + refute(zf.commit_required?) + end + end + def test_double_commit(filename = 'test/data/generated/double_commit_test.zip') ::FileUtils.touch('test/data/generated/test_double_commit1.txt') ::FileUtils.touch('test/data/generated/test_double_commit2.txt') - zf = ::Zip::File.open(filename, ::Zip::File::CREATE) + zf = ::Zip::File.open(filename, create: true) zf.add('test1.txt', 'test/data/generated/test_double_commit1.txt') zf.commit + refute(zf.commit_required?) zf.add('test2.txt', 'test/data/generated/test_double_commit2.txt') + assert(zf.commit_required?) zf.commit + refute(zf.commit_required?) zf.close zf2 = ::Zip::File.open(filename) refute_nil(zf2.entries.detect { |e| e.name == 'test1.txt' }) @@ -472,7 +684,6 @@ def test_double_commit(filename = 'test/data/generated/double_commit_test.zip') end def test_double_commit_zip64 - ::Zip.write_zip64_support = true test_double_commit('test/data/generated/double_commit_test64.zip') end @@ -481,9 +692,8 @@ def test_write_buffer zf = ::Zip::File.new(TEST_ZIP.zip_name) old_name = zf.entries.first zf.rename(old_name, new_name) - io = ::StringIO.new('') - buffer = zf.write_buffer(io) - File.open(TEST_ZIP.zip_name, 'wb') { |f| f.write buffer.string } + buffer = zf.write_buffer(::StringIO.new) + File.binwrite(TEST_ZIP.zip_name, buffer.string) zf_read = ::Zip::File.new(TEST_ZIP.zip_name) refute_nil(zf_read.entries.detect { |e| e.name == new_name }) assert_nil(zf_read.entries.detect { |e| e.name == old_name }) @@ -630,9 +840,9 @@ def test_preserve_file_order end def test_streaming - fname = ::File.join(::File.expand_path(::File.dirname(__FILE__)), '../README.md') + fname = ::File.join(__dir__, '..', 'README.md') zname = 'test/data/generated/README.zip' - Zip::File.open(zname, Zip::File::CREATE) do |zipfile| + Zip::File.open(zname, create: true) do |zipfile| zipfile.get_output_stream(File.basename(fname)) do |f| f.puts File.read(fname) end @@ -642,14 +852,14 @@ def test_streaming File.open(zname, 'rb') do |f| Zip::File.open_buffer(f) do |zipfile| zipfile.each do |entry| - next unless entry.name =~ /README.md/ + next unless entry.name.include?('README.md') data = zipfile.read(entry) end end end assert data - assert data =~ /Simonov/ + assert data.include?('Simonov') end def test_nonexistant_zip diff --git a/test/filesystem/directory_test.rb b/test/filesystem/dir_test.rb similarity index 98% rename from test/filesystem/directory_test.rb rename to test/filesystem/dir_test.rb index 8ad04d9e..2db69c22 100644 --- a/test/filesystem/directory_test.rb +++ b/test/filesystem/dir_test.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require 'test_helper' require 'zip/filesystem' -class ZipFsDirectoryTest < MiniTest::Test +class DirectoryTest < MiniTest::Test TEST_ZIP = 'test/data/generated/zipWithDirs_copy.zip' GLOB_TEST_ZIP = 'test/data/globTest.zip' diff --git a/test/filesystem/dir_iterator_test.rb b/test/filesystem/directory_iterator_test.rb similarity index 85% rename from test/filesystem/dir_iterator_test.rb rename to test/filesystem/directory_iterator_test.rb index e46da426..ba809dfd 100644 --- a/test/filesystem/dir_iterator_test.rb +++ b/test/filesystem/directory_iterator_test.rb @@ -1,11 +1,13 @@ +# frozen_string_literal: true + require 'test_helper' require 'zip/filesystem' -class ZipFsDirIteratorTest < MiniTest::Test - FILENAME_ARRAY = %w[f1 f2 f3 f4 f5 f6] +class DirectoryIteratorTest < MiniTest::Test + FILENAME_ARRAY = %w[f1 f2 f3 f4 f5 f6].freeze def setup - @dir_iter = ::Zip::FileSystem::ZipFsDirIterator.new(FILENAME_ARRAY) + @dir_iter = ::Zip::FileSystem::DirectoryIterator.new(FILENAME_ARRAY) end def test_close diff --git a/test/filesystem/file_mutating_test.rb b/test/filesystem/file_mutating_test.rb index ccba6e3d..6264f0f0 100644 --- a/test/filesystem/file_mutating_test.rb +++ b/test/filesystem/file_mutating_test.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require 'test_helper' require 'zip/filesystem' -class ZipFsFileMutatingTest < MiniTest::Test +class FileMutatingTest < MiniTest::Test TEST_ZIP = 'test/data/generated/zipWithDirs_copy.zip' def setup FileUtils.cp('test/data/zipWithDirs.zip', TEST_ZIP) diff --git a/test/filesystem/file_nonmutating_test.rb b/test/filesystem/file_nonmutating_test.rb index 346d5a76..46d700b0 100644 --- a/test/filesystem/file_nonmutating_test.rb +++ b/test/filesystem/file_nonmutating_test.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require 'test_helper' require 'zip/filesystem' -class ZipFsFileNonmutatingTest < MiniTest::Test +class FileNonmutatingTest < MiniTest::Test def setup @zipsha = Digest::SHA1.file('test/data/zipWithDirs.zip') @zip_file = ::Zip::File.new('test/data/zipWithDirs.zip') @@ -150,15 +152,6 @@ def test_join assert_equal('a/b/c/d', @zip_file.file.join('a', 'b', 'c', 'd')) end - def test_utime - t_now = ::Zip::DOSTime.now - t_bak = @zip_file.file.mtime('file1') - @zip_file.file.utime(t_now, 'file1') - assert_equal(t_now, @zip_file.file.mtime('file1')) - @zip_file.file.utime(t_bak, 'file1') - assert_equal(t_bak, @zip_file.file.mtime('file1')) - end - def assert_always_false(operation) assert(!@zip_file.file.send(operation, 'noSuchFile')) assert(!@zip_file.file.send(operation, 'file1')) @@ -184,7 +177,10 @@ def test_blockdev? end def test_symlink? - assert_always_false(:symlink?) + zip_file = ::Zip::File.new('test/data/path_traversal/tuzovakaoff/symlink.zip') + assert(zip_file.file.symlink?('path')) + assert(!zip_file.file.symlink?('path/file.txt')) + assert_e_n_o_e_n_t(:symlink?) end def test_socket? @@ -295,8 +291,10 @@ def test_ctime end def test_atime - assert_nil(@zip_file.file.atime('file1')) - assert_nil(@zip_file.file.stat('file1').atime) + assert_equal(::Zip::DOSTime.at(1_027_694_306), + @zip_file.file.atime('file1')) + assert_equal(::Zip::DOSTime.at(1_027_694_306), + @zip_file.file.stat('file1').atime) end def test_ntfs_time @@ -408,7 +406,7 @@ def test_foreach zf.file.foreach('test/data/file1.txt') do |l| # Ruby replaces \n with \r\n automatically on windows - newline = Zip::RUNNING_ON_WINDOWS ? l.gsub(/\r\n/, "\n") : l + newline = Zip::RUNNING_ON_WINDOWS ? l.gsub("\r\n", "\n") : l assert_equal(ref[index], newline) index = index.next end @@ -422,7 +420,7 @@ def test_foreach zf.file.foreach('test/data/file1.txt', ' ') do |l| # Ruby replaces \n with \r\n automatically on windows - newline = Zip::RUNNING_ON_WINDOWS ? l.gsub(/\r\n/, "\n") : l + newline = Zip::RUNNING_ON_WINDOWS ? l.gsub("\r\n", "\n") : l assert_equal(ref[index], newline) index = index.next end @@ -441,7 +439,7 @@ def test_glob '*/foo/**/*.txt' => ['globTest/foo/bar/baz/foo.txt'] }.each do |spec, expected_results| results = zf.glob(spec) - assert(results.all? { |entry| entry.kind_of? ::Zip::Entry }) + assert(results.all?(::Zip::Entry)) result_strings = results.map(&:to_s) missing_matches = expected_results - result_strings @@ -488,7 +486,7 @@ def test_readlines zip_file = zf.file.readlines('test/data/file1.txt') # Ruby replaces \n with \r\n automatically on windows - zip_file.each { |l| l.gsub!(/\r\n/, "\n") } if Zip::RUNNING_ON_WINDOWS + zip_file.each { |l| l.gsub!("\r\n", "\n") } if Zip::RUNNING_ON_WINDOWS assert_equal(orig_file, zip_file) end @@ -500,7 +498,7 @@ def test_read # Ruby replaces \n with \r\n automatically on windows zip_file = if Zip::RUNNING_ON_WINDOWS - zf.file.read('test/data/file1.txt').gsub(/\r\n/, "\n") + zf.file.read('test/data/file1.txt').gsub("\r\n", "\n") else zf.file.read('test/data/file1.txt') end diff --git a/test/filesystem/file_stat_test.rb b/test/filesystem/file_stat_test.rb index 05d7fff8..f10a5db4 100644 --- a/test/filesystem/file_stat_test.rb +++ b/test/filesystem/file_stat_test.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require 'test_helper' require 'zip/filesystem' -class ZipFsFileStatTest < MiniTest::Test +class FileStatTest < MiniTest::Test def setup @zip_file = ::Zip::File.new('test/data/zipWithDirs.zip') end @@ -19,11 +21,11 @@ def test_ino end def test_uid - assert_equal(0, @zip_file.file.stat('file1').uid) + assert_equal(500, @zip_file.file.stat('file1').uid) end def test_gid - assert_equal(0, @zip_file.file.stat('file1').gid) + assert_equal(500, @zip_file.file.stat('file1').gid) end def test_ftype diff --git a/test/gentestfiles.rb b/test/gentestfiles.rb index 503a0d00..75ce4d8f 100755 --- a/test/gentestfiles.rb +++ b/test/gentestfiles.rb @@ -1,4 +1,4 @@ -#!/usr/bin/env ruby +# frozen_string_literal: true $VERBOSE = true @@ -13,14 +13,14 @@ class TestFiles EMPTY_TEST_DIR = 'test/data/generated/emptytestdir' - ASCII_TEST_FILES = [RANDOM_ASCII_FILE1, RANDOM_ASCII_FILE2, RANDOM_ASCII_FILE3] - BINARY_TEST_FILES = [RANDOM_BINARY_FILE1, RANDOM_BINARY_FILE2] - TEST_DIRECTORIES = [EMPTY_TEST_DIR] - TEST_FILES = [ASCII_TEST_FILES, BINARY_TEST_FILES, EMPTY_TEST_DIR].flatten! + ASCII_TEST_FILES = [ + RANDOM_ASCII_FILE1, RANDOM_ASCII_FILE2, RANDOM_ASCII_FILE3 + ].freeze + BINARY_TEST_FILES = [RANDOM_BINARY_FILE1, RANDOM_BINARY_FILE2].freeze class << self def create_test_files - Dir.mkdir 'test/data/generated' unless Dir.exist?('test/data/generated') + FileUtils.mkdir_p 'test/data/generated' ASCII_TEST_FILES.each_with_index do |filename, index| create_random_ascii(filename, 1E4 * (index + 1)) @@ -39,13 +39,13 @@ def create_test_files def create_random_ascii(filename, size) File.open(filename, 'wb') do |file| - file << rand while file.tell < size + file << (0...size).map { rand(33..126).chr }.join end end def create_random_binary(filename, size) File.open(filename, 'wb') do |file| - file << [rand].pack('V') while file.tell < size + file << (0...size).map { rand(255) }.pack('C*') end end @@ -73,25 +73,25 @@ def initialize(zip_name, entry_names, comment = '') def self.create_test_zips raise "failed to create test zip '#{TEST_ZIP1.zip_name}'" \ - unless system("/usr/bin/zip -q #{TEST_ZIP1.zip_name} test/data/file2.txt") + unless system("zip -q #{TEST_ZIP1.zip_name} test/data/file2.txt") raise "failed to remove entry from '#{TEST_ZIP1.zip_name}'" \ unless system( - "/usr/bin/zip -q #{TEST_ZIP1.zip_name} -d test/data/file2.txt" + "zip -q #{TEST_ZIP1.zip_name} -d test/data/file2.txt" ) - File.open('test/data/generated/empty.txt', 'w') {} - File.open('test/data/generated/empty_chmod640.txt', 'w') {} + File.open('test/data/generated/empty.txt', 'wb') {} # Empty file. + File.open('test/data/generated/empty_chmod640.txt', 'wb') {} # Empty file. ::File.chmod(0o640, 'test/data/generated/empty_chmod640.txt') - File.open('test/data/generated/short.txt', 'w') { |file| file << 'ABCDEF' } + File.open('test/data/generated/short.txt', 'wb') { |file| file << 'ABCDEF' } test_text = '' - File.open('test/data/file2.txt') { |file| test_text = file.read } - File.open('test/data/generated/longAscii.txt', 'w') do |file| + File.open('test/data/file2.txt', 'rb') { |file| test_text = file.read } + File.open('test/data/generated/longAscii.txt', 'wb') do |file| file << test_text while file.tell < 1E5 end binary_pattern = '' - File.open('test/data/generated/empty.zip') do |file| + File.open('test/data/generated/empty.zip', 'rb') do |file| binary_pattern = file.read end binary_pattern *= 4 @@ -102,31 +102,31 @@ def self.create_test_zips raise "failed to create test zip '#{TEST_ZIP2.zip_name}'" \ unless system( - "/usr/bin/zip -q #{TEST_ZIP2.zip_name} #{TEST_ZIP2.entry_names.join(' ')}" + "zip -q #{TEST_ZIP2.zip_name} #{TEST_ZIP2.entry_names.join(' ')}" ) - if RUBY_PLATFORM =~ /mswin|mingw|cygwin/ + if RUBY_PLATFORM.match?(/mswin|mingw|cygwin/) raise "failed to add comment to test zip '#{TEST_ZIP2.zip_name}'" \ unless system( - "echo #{TEST_ZIP2.comment}| /usr/bin/zip -zq #{TEST_ZIP2.zip_name}\"" + "cmd /c \"d %04x', dec: 123, hex: 123) assert_equal('123 007b', @output_stream.buffer) end @@ -87,19 +84,19 @@ def test_puts @output_stream.puts('hello', 'world') assert_equal("\nhello\nworld\n", @output_stream.buffer) - @output_stream.buffer = '' + @output_stream.buffer = +'' @output_stream.puts("hello\n", "world\n") assert_equal("hello\nworld\n", @output_stream.buffer) - @output_stream.buffer = '' + @output_stream.buffer = +'' @output_stream.puts(%W[hello\n world\n]) assert_equal("hello\nworld\n", @output_stream.buffer) - @output_stream.buffer = '' + @output_stream.buffer = +'' @output_stream.puts(%W[hello\n world\n], 'bingo') assert_equal("hello\nworld\nbingo\n", @output_stream.buffer) - @output_stream.buffer = '' + @output_stream.buffer = +'' @output_stream.puts(16, 20, 50, 'hello') assert_equal("16\n20\n50\nhello\n", @output_stream.buffer) end diff --git a/test/ioextras/fake_io_test.rb b/test/ioextras/fake_io_test.rb index 612f442f..07b24b5a 100644 --- a/test/ioextras/fake_io_test.rb +++ b/test/ioextras/fake_io_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' require 'zip/ioextras' diff --git a/test/local_entry_test.rb b/test/local_entry_test.rb index 58bcda74..cba7c5f0 100644 --- a/test/local_entry_test.rb +++ b/test/local_entry_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class ZipLocalEntryTest < MiniTest::Test @@ -5,7 +7,7 @@ class ZipLocalEntryTest < MiniTest::Test LEH_FILE = 'test/data/generated/localEntryHeader.bin' def teardown - ::Zip.write_zip64_support = false + ::Zip.reset! end def test_read_local_entry_header_of_first_test_zip_entry @@ -40,50 +42,60 @@ def test_read_local_entry_from_non_zip_file end end - def test_read_local_entry_from_truncated_zip_file - fragment = '' - # local header is at least 30 bytes - ::File.open(TestZipFile::TEST_ZIP2.zip_name) { |f| fragment = f.read(12) } + def test_read_local_entry_from_truncated_zip_file_raises_error + ::File.open(TestZipFile::TEST_ZIP2.zip_name) do |f| + # Local header is at least 30 bytes, so don't read it all here. + fragment = f.read(12) + assert_raises(::Zip::Error) do + entry = ::Zip::Entry.new + entry.read_local_entry(StringIO.new(fragment)) + end + end + end - fragment.extend(IOizeString).reset - entry = ::Zip::Entry.new - entry.read_local_entry(fragment) - raise 'ZipError expected' - rescue ::Zip::Error + def test_read_local_entry_from_truncated_zip_file_returns_nil + ::File.open(TestZipFile::TEST_ZIP2.zip_name) do |f| + # Local header is at least 30 bytes, so don't read it all here. + fragment = f.read(12) + assert_nil(::Zip::Entry.read_local_entry(StringIO.new(fragment))) + end end def test_write_entry - entry = ::Zip::Entry.new('file.zip', 'entry_name', 'my little comment', - 'thisIsSomeExtraInformation', 100, 987_654, - ::Zip::Entry::DEFLATED, 400) + Zip.write_zip64_support = false + + entry = ::Zip::Entry.new( + 'file.zip', 'entry_name', comment: 'my little comment', size: 400, + extra: 'thisIsSomeExtraInformation', compressed_size: 100, crc: 987_654 + ) + write_to_file(LEH_FILE, CEH_FILE, entry) local_entry, central_entry = read_from_file(LEH_FILE, CEH_FILE) assert( - central_entry.extra['Zip64Placeholder'].nil?, - 'zip64 placeholder should not be used in central directory' + central_entry.extra['Zip64'].nil?, + 'zip64 should not be used in central directory at this point.' ) compare_local_entry_headers(entry, local_entry) compare_c_dir_entry_headers(entry, central_entry) end def test_write_entry_with_zip64 - ::Zip.write_zip64_support = true - entry = ::Zip::Entry.new('file.zip', 'entry_name', 'my little comment', - 'thisIsSomeExtraInformation', 100, 987_654, - ::Zip::Entry::DEFLATED, 400) + entry = ::Zip::Entry.new( + 'file.zip', 'entry_name', comment: 'my little comment', size: 400, + extra: 'thisIsSomeExtraInformation', compressed_size: 100, crc: 987_654 + ) + entry.extra.merge('thisIsSomeExtraInformation', local: true) write_to_file(LEH_FILE, CEH_FILE, entry) local_entry, central_entry = read_from_file(LEH_FILE, CEH_FILE) + assert( - local_entry.extra['Zip64Placeholder'], - 'zip64 placeholder should be used in local file header' + local_entry.extra['Zip64'].nil?, + 'zip64 should not be used in local file header at this point.' ) - - # This was removed when writing the c_dir_entry, so remove from compare. - local_entry.extra.delete('Zip64Placeholder') assert( - central_entry.extra['Zip64Placeholder'].nil?, - 'zip64 placeholder should not be used in central directory' + central_entry.extra['Zip64'].nil?, + 'zip64 should not be used in central directory at this point.' ) compare_local_entry_headers(entry, local_entry) @@ -91,11 +103,12 @@ def test_write_entry_with_zip64 end def test_write_64entry - ::Zip.write_zip64_support = true - entry = ::Zip::Entry.new('bigfile.zip', 'entry_name', 'my little equine', - 'malformed extra field because why not', - 0x7766554433221100, 0xDEADBEEF, ::Zip::Entry::DEFLATED, - 0x9988776655443322) + entry = ::Zip::Entry.new( + 'bigfile.zip', 'entry_name', comment: 'my little equine', + extra: 'malformed extra field because why not', size: 0x9988776655443322, + compressed_size: 0x7766554433221100, crc: 0xDEADBEEF + ) + write_to_file(LEH_FILE, CEH_FILE, entry) local_entry, central_entry = read_from_file(LEH_FILE, CEH_FILE) compare_local_entry_headers(entry, local_entry) @@ -103,17 +116,35 @@ def test_write_64entry end def test_rewrite_local_header64 - ::Zip.write_zip64_support = true buf1 = StringIO.new entry = ::Zip::Entry.new('file.zip', 'entry_name') entry.write_local_entry(buf1) - assert(entry.extra['Zip64'].nil?, 'zip64 extra is unnecessarily present') + # We don't know how long the entry will be at this point. + assert(entry.zip64?, 'zip64 extra should be present') buf2 = StringIO.new entry.size = 0x123456789ABCDEF0 entry.compressed_size = 0x0123456789ABCDEF - entry.write_local_entry(buf2, true) - refute_nil(entry.extra['Zip64']) + entry.write_local_entry(buf2, rewrite: true) + assert(entry.zip64?) + refute_equal(buf1.size, 0) + assert_equal(buf1.size, buf2.size) # it can't grow, or we'd clobber file data + end + + def test_rewrite_local_header + buf1 = StringIO.new + entry = ::Zip::Entry.new('file.zip', 'entry_name') + entry.write_local_entry(buf1) + # We don't know how long the entry will be at this point. + assert(entry.zip64?, 'zip64 extra should be present') + + buf2 = StringIO.new + entry.size = 0x256 + entry.compressed_size = 0x128 + entry.write_local_entry(buf2, rewrite: true) + # Zip64 should still be present, even with a small entry size. This + # is a rewrite, so header size can't change. + assert(entry.zip64?) refute_equal(buf1.size, 0) assert_equal(buf1.size, buf2.size) # it can't grow, or we'd clobber file data end @@ -128,7 +159,6 @@ def test_read_local_offset end def test_read64_local_offset - ::Zip.write_zip64_support = true entry = ::Zip::Entry.new('file.zip', 'entry_name') entry.local_header_offset = 0x0123456789ABCDEF ::File.open(CEH_FILE, 'wb') { |f| entry.write_c_dir_entry(f) } @@ -139,18 +169,23 @@ def test_read64_local_offset private - def compare_local_entry_headers(entry1, entry2) + def compare_common_entry_headers(entry1, entry2) assert_equal(entry1.compressed_size, entry2.compressed_size) assert_equal(entry1.crc, entry2.crc) - assert_equal(entry1.extra, entry2.extra) assert_equal(entry1.compression_method, entry2.compression_method) assert_equal(entry1.name, entry2.name) assert_equal(entry1.size, entry2.size) assert_equal(entry1.local_header_offset, entry2.local_header_offset) end + def compare_local_entry_headers(entry1, entry2) + compare_common_entry_headers(entry1, entry2) + assert_equal(entry1.extra.to_local_bin, entry2.extra.to_local_bin) + end + def compare_c_dir_entry_headers(entry1, entry2) - compare_local_entry_headers(entry1, entry2) + compare_common_entry_headers(entry1, entry2) + assert_equal(entry1.extra.to_c_dir_bin, entry2.extra.to_c_dir_bin) assert_equal(entry1.comment, entry2.comment) end diff --git a/test/output_stream_test.rb b/test/output_stream_test.rb index b2f64ab9..16254f13 100644 --- a/test/output_stream_test.rb +++ b/test/output_stream_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class ZipOutputStreamTest < MiniTest::Test @@ -23,18 +25,16 @@ def test_open end def test_write_buffer - io = ::StringIO.new('') - buffer = ::Zip::OutputStream.write_buffer(io) do |zos| + buffer = ::Zip::OutputStream.write_buffer(::StringIO.new) do |zos| zos.comment = TEST_ZIP.comment write_test_zip(zos) end - File.open(TEST_ZIP.zip_name, 'wb') { |f| f.write buffer.string } + File.binwrite(TEST_ZIP.zip_name, buffer.string) assert_test_zip_contents(TEST_ZIP) end def test_write_buffer_binmode - io = ::StringIO.new('') - buffer = ::Zip::OutputStream.write_buffer(io) do |zos| + buffer = ::Zip::OutputStream.write_buffer(::StringIO.new) do |zos| zos.comment = TEST_ZIP.comment write_test_zip(zos) end @@ -50,12 +50,35 @@ def test_write_buffer_with_temp_file end tmp_file.rewind - File.open(TEST_ZIP.zip_name, 'wb') { |f| f.write(tmp_file.read) } + File.binwrite(TEST_ZIP.zip_name, tmp_file.read) tmp_file.unlink assert_test_zip_contents(TEST_ZIP) end + def test_write_buffer_with_temp_file2 + tmp_file = ::File.join(Dir.tmpdir, 'zos.zip') + ::File.open(tmp_file, 'wb') do |f| + ::Zip::OutputStream.write_buffer(f) do |zos| + zos.comment = TEST_ZIP.comment + write_test_zip(zos) + end + end + + ::Zip::File.open(tmp_file) # Should open without error. + ensure + ::File.unlink(tmp_file) + end + + def test_write_buffer_with_default_io + buffer = ::Zip::OutputStream.write_buffer do |zos| + zos.comment = TEST_ZIP.comment + write_test_zip(zos) + end + File.binwrite(TEST_ZIP.zip_name, buffer.string) + assert_test_zip_contents(TEST_ZIP) + end + def test_writing_to_closed_stream assert_i_o_error_in_closed_stream { |zos| zos << 'hello world' } assert_i_o_error_in_closed_stream { |zos| zos.puts 'hello world' } @@ -83,7 +106,7 @@ def test_put_next_entry zos << stored_text end - assert(File.read(TEST_ZIP.zip_name)[stored_text]) + assert(File.read(TEST_ZIP.zip_name, mode: 'rb')[stored_text]) ::Zip::File.open(TEST_ZIP.zip_name) do |zf| assert_equal(stored_text, zf.read(entry_name)) end @@ -92,7 +115,9 @@ def test_put_next_entry def test_put_next_entry_using_zip_entry_creates_entries_with_correct_timestamps file = ::File.open('test/data/file2.txt', 'rb') ::Zip::OutputStream.open(TEST_ZIP.zip_name) do |zos| - zip_entry = ::Zip::Entry.new(zos, file.path, '', '', 0, 0, ::Zip::Entry::DEFLATED, 0, ::Zip::DOSTime.at(file.mtime)) + zip_entry = ::Zip::Entry.new( + zos, file.path, time: ::Zip::DOSTime.at(file.mtime) + ) zos.put_next_entry(zip_entry) zos << file.read end @@ -100,7 +125,7 @@ def test_put_next_entry_using_zip_entry_creates_entries_with_correct_timestamps ::Zip::InputStream.open(TEST_ZIP.zip_name) do |io| while (entry = io.get_next_entry) # Compare DOS Times, since they are stored with two seconds accuracy - assert(::Zip::DOSTime.at(file.mtime).dos_equals(::Zip::DOSTime.at(entry.mtime))) + assert(::Zip::DOSTime.at(file.mtime) == ::Zip::DOSTime.at(entry.mtime)) end end end diff --git a/test/pass_thru_compressor_test.rb b/test/pass_thru_compressor_test.rb index 334ba90c..455f800b 100644 --- a/test/pass_thru_compressor_test.rb +++ b/test/pass_thru_compressor_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class PassThruCompressorTest < MiniTest::Test diff --git a/test/pass_thru_decompressor_test.rb b/test/pass_thru_decompressor_test.rb index e0b66892..9c251a09 100644 --- a/test/pass_thru_decompressor_test.rb +++ b/test/pass_thru_decompressor_test.rb @@ -1,10 +1,12 @@ +# frozen_string_literal: true + require 'test_helper' class PassThruDecompressorTest < MiniTest::Test include DecompressorTests def setup super - @file = File.new(TEST_FILE) + @file = File.new(TEST_FILE, 'rb') @decompressor = ::Zip::PassThruDecompressor.new(@file, File.size(TEST_FILE)) end diff --git a/test/path_traversal_test.rb b/test/path_traversal_test.rb index 47c7e30f..e1ec9a74 100644 --- a/test/path_traversal_test.rb +++ b/test/path_traversal_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class PathTraversalTest < MiniTest::Test @@ -37,23 +39,31 @@ def in_tmpdir end def test_leading_slash - entries = { '/tmp/moo' => /WARNING: skipped \'\/tmp\/moo\'/ } - in_tmpdir do + entries = { '/tmp/moo' => '' } + in_tmpdir do |test_path| + Dir.mkdir('tmp') # Create 'tmp' dir within test directory. extract_paths(['jwilk', 'absolute1.zip'], entries) + + # Check that only the relative file is created. refute File.exist?('/tmp/moo') + assert File.exist?(File.join(test_path, 'tmp', 'moo')) end end def test_multiple_leading_slashes - entries = { '//tmp/moo' => /WARNING: skipped \'\/\/tmp\/moo\'/ } - in_tmpdir do + entries = { '//tmp/moo' => '' } + in_tmpdir do |test_path| + Dir.mkdir('tmp') # Create 'tmp' dir within test directory. extract_paths(['jwilk', 'absolute2.zip'], entries) + + # Check that only the relative file is created. refute File.exist?('/tmp/moo') + assert File.exist?(File.join(test_path, 'tmp', 'moo')) end end def test_leading_dot_dot - entries = { '../moo' => /WARNING: skipped \'\.\.\/moo\'/ } + entries = { '../moo' => /WARNING: skipped extracting '\.\.\/moo'/ } in_tmpdir do extract_paths(['jwilk', 'relative0.zip'], entries) refute File.exist?('../moo') @@ -63,7 +73,7 @@ def test_leading_dot_dot def test_non_leading_dot_dot_with_existing_folder entries = { 'tmp/' => '', - 'tmp/../../moo' => /WARNING: skipped \'tmp\/\.\.\/\.\.\/moo\'/ + 'tmp/../../moo' => /WARNING: skipped extracting 'tmp\/\.\.\/\.\.\/moo'/ } in_tmpdir do extract_paths('relative1.zip', entries) @@ -73,7 +83,7 @@ def test_non_leading_dot_dot_with_existing_folder end def test_non_leading_dot_dot_without_existing_folder - entries = { 'tmp/../../moo' => /WARNING: skipped \'tmp\/\.\.\/\.\.\/moo\'/ } + entries = { 'tmp/../../moo' => /WARNING: skipped extracting 'tmp\/\.\.\/\.\.\/moo'/ } in_tmpdir do extract_paths(['jwilk', 'relative2.zip'], entries) refute File.exist?('../moo') @@ -92,7 +102,7 @@ def test_file_symlink def test_directory_symlink # Can't create tmp/moo, because the tmp symlink is skipped. entries = { - 'tmp' => /WARNING: skipped symlink \'tmp\'/, + 'tmp' => /WARNING: skipped symlink '.*\/tmp'/, 'tmp/moo' => :error } in_tmpdir do @@ -104,8 +114,8 @@ def test_directory_symlink def test_two_directory_symlinks_a # Can't create par/moo because the symlinks are skipped. entries = { - 'cur' => /WARNING: skipped symlink \'cur\'/, - 'par' => /WARNING: skipped symlink \'par\'/, + 'cur' => /WARNING: skipped symlink '.*\/cur'/, + 'par' => /WARNING: skipped symlink '.*\/par'/, 'par/moo' => :error } in_tmpdir do @@ -119,8 +129,8 @@ def test_two_directory_symlinks_a def test_two_directory_symlinks_b # Can't create par/moo, because the symlinks are skipped. entries = { - 'cur' => /WARNING: skipped symlink \'cur\'/, - 'cur/par' => /WARNING: skipped symlink \'cur\/par\'/, + 'cur' => /WARNING: skipped symlink '.*\/cur'/, + 'cur/par' => /WARNING: skipped symlink '.*\/cur\/par'/, 'par/moo' => :error } in_tmpdir do @@ -130,14 +140,29 @@ def test_two_directory_symlinks_b end end - def test_entry_name_with_absolute_path_does_not_extract - entries = { - '/tmp/' => /WARNING: skipped \'\/tmp\/\'/, - '/tmp/file.txt' => /WARNING: skipped \'\/tmp\/file.txt\'/ - } - in_tmpdir do - extract_paths(['tuzovakaoff', 'absolutepath.zip'], entries) + def test_entry_name_with_absolute_path_does_not_extract_by_accident + in_tmpdir do |test_path| + zip_path = File.join(TEST_FILE_ROOT, 'tuzovakaoff', 'absolutepath.zip') + Zip::File.open(zip_path) do |zip_file| + zip_file.each do |entry| + entry.extract(entry.name, destination_directory: nil) + assert File.exist?(File.join(test_path, entry.name)) + refute File.exist?(entry.name) unless entry.name == '/tmp/' + end + end + end + end + + def test_entry_name_with_absolute_path_extracts_to_cwd_by_default + in_tmpdir do |test_path| + zip_path = File.join(TEST_FILE_ROOT, 'tuzovakaoff', 'absolutepath.zip') + Zip::File.open(zip_path) do |zip_file| + zip_file.each(&:extract) + end + + # Check that only the relative file is created. refute File.exist?('/tmp/file.txt') + assert File.exist?(File.join(test_path, 'tmp', 'file.txt')) end end @@ -146,17 +171,20 @@ def test_entry_name_with_absolute_path_extract_when_given_different_path zip_path = File.join(TEST_FILE_ROOT, 'tuzovakaoff', 'absolutepath.zip') Zip::File.open(zip_path) do |zip_file| zip_file.each do |entry| - entry.extract(File.join(test_path, entry.name)) + entry.extract(destination_directory: test_path) end end + + # Check that only the relative file is created. refute File.exist?('/tmp/file.txt') + assert File.exist?(File.join(test_path, 'tmp', 'file.txt')) end end def test_entry_name_with_relative_symlink # Doesn't create the symlink path, so can't create path/file.txt. entries = { - 'path' => /WARNING: skipped symlink \'path\'/, + 'path' => /WARNING: skipped symlink '.*\/path'/, 'path/file.txt' => :error } in_tmpdir do diff --git a/test/samples/example_recursive_test.rb b/test/samples/example_recursive_test.rb index 4fb14883..a2af3ec7 100644 --- a/test/samples/example_recursive_test.rb +++ b/test/samples/example_recursive_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' require 'fileutils' require_relative '../../samples/example_recursive' diff --git a/test/settings_test.rb b/test/settings_test.rb index 0510a6fc..9e280aab 100644 --- a/test/settings_test.rb +++ b/test/settings_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class ZipSettingsTest < MiniTest::Test @@ -10,7 +12,7 @@ def setup super Dir.rmdir(TEST_OUT_NAME) if File.directory? TEST_OUT_NAME - File.delete(TEST_OUT_NAME) if File.exist? TEST_OUT_NAME + FileUtils.rm_f(TEST_OUT_NAME) end def teardown @@ -38,17 +40,20 @@ def test_true_on_exists_proc def test_false_on_exists_proc Zip.on_exists_proc = false File.open(TEST_OUT_NAME, 'w') { |f| f.puts 'something' } - assert_raises(Zip::DestinationFileExistsError) { extract_test_dir } + assert_raises(Zip::DestinationExistsError) do + extract_test_dir + end end def test_false_continue_on_exists_proc Zip.continue_on_exists_proc = false - assert_raises(::Zip::EntryExistsError) do + error = assert_raises(::Zip::EntryExistsError) do ::Zip::File.open(TEST_ZIP.zip_name) do |zf| zf.add(zf.entries.first.name, 'test/data/file2.txt') end end + assert_match(/'add'/, error.message) end def test_true_continue_on_exists_proc @@ -71,8 +76,7 @@ def test_false_warn_invalid_date Zip.warn_invalid_date = false assert_output('', '') do - ::Zip::File.open(test_file) do |_zf| - end + ::Zip::File.open(test_file) {} # Do nothing with the open file. end end @@ -81,8 +85,7 @@ def test_true_warn_invalid_date Zip.warn_invalid_date = true assert_output('', /invalid date\/time in zip entry/) do - ::Zip::File.open(test_file) do |_zf| - end + ::Zip::File.open(test_file) {} # Do nothing with the open file. end end diff --git a/test/stored_support_test.rb b/test/stored_support_test.rb index 28836b9e..e1cd1813 100644 --- a/test/stored_support_test.rb +++ b/test/stored_support_test.rb @@ -1,8 +1,11 @@ +# frozen_string_literal: true + require 'test_helper' class StoredSupportTest < MiniTest::Test STORED_ZIP_TEST_FILE = 'test/data/zipWithStoredCompression.zip' - ENCRYPTED_STORED_ZIP_TEST_FILE = 'test/data/zipWithStoredCompressionAndEncryption.zip' + ENCRYPTED_STORED_ZIP_TEST_FILE = + 'test/data/zipWithStoredCompressionAndEncryption.zip' INPUT_FILE1 = 'test/data/file1.txt' INPUT_FILE2 = 'test/data/file2.txt' @@ -11,24 +14,26 @@ def test_read entry = zis.get_next_entry assert_equal 'file1.txt', entry.name assert_equal 1_327, entry.size - assert_equal ::File.open(INPUT_FILE1, 'r').read, zis.read + assert_equal ::File.read(INPUT_FILE1), zis.read entry = zis.get_next_entry assert_equal 'file2.txt', entry.name assert_equal 41_234, entry.size - assert_equal ::File.open(INPUT_FILE2, 'r').read, zis.read + assert_equal ::File.read(INPUT_FILE2), zis.read end end def test_encrypted_read - Zip::InputStream.open(ENCRYPTED_STORED_ZIP_TEST_FILE, 0, Zip::TraditionalDecrypter.new('password')) do |zis| + Zip::InputStream.open( + ENCRYPTED_STORED_ZIP_TEST_FILE, decrypter: Zip::TraditionalDecrypter.new('password') + ) do |zis| entry = zis.get_next_entry assert_equal 'file1.txt', entry.name assert_equal 1_327, entry.size - assert_equal ::File.open(INPUT_FILE1, 'r').read, zis.read + assert_equal ::File.read(INPUT_FILE1), zis.read entry = zis.get_next_entry assert_equal 'file2.txt', entry.name assert_equal 41_234, entry.size - assert_equal ::File.open(INPUT_FILE2, 'r').read, zis.read + assert_equal ::File.read(INPUT_FILE2), zis.read end end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 598736e6..8f665b1c 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'simplecov' require 'minitest/autorun' require 'minitest/unit' @@ -10,49 +12,10 @@ TestFiles.create_test_files TestZipFile.create_test_zips -if defined? JRUBY_VERSION - require 'jruby' - JRuby.objectspace = true -end - -::MiniTest.after_run do +MiniTest.after_run do FileUtils.rm_rf('test/data/generated') end -module IOizeString - attr_reader :tell - - def read(count = nil) - @tell ||= 0 - count ||= size - ret_val = slice(@tell, count) - @tell += count - ret_val - end - - def seek(index, offset) - @tell ||= 0 - case offset - when IO::SEEK_END - pos = size + index - when IO::SEEK_SET - pos = index - when IO::SEEK_CUR - pos = @tell + index - else - raise 'Error in test method IOizeString::seek' - end - - raise Errno::EINVAL if pos < 0 || pos >= size - - @tell = pos - end - - def reset - @tell = 0 - end -end - module DecompressorTests # expects @ref_text, @ref_lines and @decompressor @@ -60,7 +23,7 @@ module DecompressorTests def setup @ref_text = '' - File.open(TEST_FILE) { |f| @ref_text = f.read } + File.open(TEST_FILE, 'rb') { |f| @ref_text = f.read } @ref_lines = @ref_text.split($INPUT_RECORD_SEPARATOR) end @@ -93,7 +56,7 @@ def assert_entry_contents_for_stream(filename, zis, entry_name) actual = zis.read if expected != actual if (expected && actual) && (expected.length > 400 || actual.length > 400) - entry_filename = entry_name + '.zipEntry' + entry_filename = "#{entry_name}.zipEntry" File.open(entry_filename, 'wb') { |entryfile| entryfile << actual } raise("File '#{filename}' is different from '#{entry_filename}'") else @@ -109,7 +72,7 @@ def self.assert_contents(filename, string) return unless contents != string if contents.length > 400 || string.length > 400 - string_file = filename + '.other' + string_file = "#{filename}.other" File.open(string_file, 'wb') { |f| f << string } raise("File '#{filename}' is different from contents of string stored in '#{string_file}'") else @@ -146,7 +109,7 @@ class TestOutputStream attr_accessor :buffer def initialize - @buffer = '' + @buffer = +'' end def <<(data) @@ -165,16 +128,6 @@ def run_crc_test(compressor_class) end end -module Enumerable - def compare_enumerables(enumerable) - array = enumerable.to_a - each_with_index do |element, index| - return false unless yield(element, array[index]) - end - size == array.size - end -end - module CommonZipFileFixture include AssertEntry @@ -184,7 +137,7 @@ module CommonZipFileFixture TEST_ZIP.zip_name = 'test/data/generated/5entry_copy.zip' def setup - File.delete(EMPTY_FILENAME) if File.exist?(EMPTY_FILENAME) + FileUtils.rm_f(EMPTY_FILENAME) FileUtils.cp(TestZipFile::TEST_ZIP2.zip_name, TEST_ZIP.zip_name) end end @@ -192,22 +145,21 @@ def setup module ExtraAssertions def assert_forwarded(object, method, ret_val, *expected_args) call_args = nil - call_args_proc = proc { |args| call_args = args } - object.instance_eval <<-END_EVAL, __FILE__, __LINE__ + 1 - alias #{method}_org #{method} - def #{method}(*args) - ObjectSpace._id2ref(#{call_args_proc.object_id}).call(args) - ObjectSpace._id2ref(#{ret_val.object_id}) - end - END_EVAL + object.singleton_class.class_exec do + alias_method :"#{method}_org", method + define_method(method) do |*args| + call_args = args + ret_val + end + end assert_equal(ret_val, yield) # Invoke test assert_equal(expected_args, call_args) ensure - object.instance_eval <<-END_EVAL, __FILE__, __LINE__ + 1 - undef #{method} - alias #{method} #{method}_org - END_EVAL + object.singleton_class.class_exec do + remove_method method + alias_method method, :"#{method}_org" + end end end @@ -218,6 +170,7 @@ module ZipEntryData TEST_CRC = 325_324 TEST_EXTRA = 'Some data here' TEST_COMPRESSIONMETHOD = ::Zip::Entry::DEFLATED + TEST_COMPRESSIONLEVEL = ::Zip.default_compression TEST_NAME = 'entry name' TEST_SIZE = 8432 TEST_ISDIRECTORY = false diff --git a/test/unicode_file_names_and_comments_test.rb b/test/unicode_file_names_and_comments_test.rb index 4d2fc20f..bc843321 100644 --- a/test/unicode_file_names_and_comments_test.rb +++ b/test/unicode_file_names_and_comments_test.rb @@ -1,8 +1,14 @@ +# frozen_string_literal: true + require 'test_helper' class ZipUnicodeFileNamesAndComments < MiniTest::Test FILENAME = File.join(File.dirname(__FILE__), 'test1.zip') + def teardown + ::Zip.reset! + end + def test_unicode_file_name file_entrys = ['текстовыйфайл.txt', 'Résumé.txt', '슬레이어스휘.txt'] directory_entrys = ['папка/текстовыйфайл.txt', 'Résumé/Résumé.txt', '슬레이어스휘/슬레이어스휘.txt'] @@ -41,14 +47,13 @@ def test_unicode_file_name refute_nil(zip.find_entry(filepath)) end end - ::Zip.force_entry_names_encoding = nil ::File.unlink(FILENAME) end def test_unicode_comment str = '渠道升级' - ::Zip::File.open(FILENAME, Zip::File::CREATE) do |z| + ::Zip::File.open(FILENAME, create: true) do |z| z.comment = str end diff --git a/test/version_test.rb b/test/version_test.rb new file mode 100644 index 00000000..3df7662b --- /dev/null +++ b/test/version_test.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'zip/version' + +class VersionTest < MiniTest::Test + def test_version + # Ensure all our versions numbers have at least MAJOR.MINOR.PATCH + # elements separated by dots, to comply with Semantic Versioning. + assert_match(/^\d+\.\d+\.\d+/, Zip::VERSION) + end +end diff --git a/test/zip64_full_test.rb b/test/zip64_full_test.rb index ed11ed65..f287a534 100644 --- a/test/zip64_full_test.rb +++ b/test/zip64_full_test.rb @@ -1,51 +1,60 @@ -if ENV['FULL_ZIP64_TEST'] - require 'minitest/autorun' - require 'minitest/unit' - require 'fileutils' - require 'zip' - - # test zip64 support for real, by actually exceeding the 32-bit size/offset limits - # this test does not, of course, run with the normal unit tests! ;) - - class Zip64FullTest < MiniTest::Test - def teardown - ::Zip.reset! - end +# frozen_string_literal: true - def prepare_test_file(test_filename) - ::File.delete(test_filename) if ::File.exist?(test_filename) - test_filename - end +require 'test_helper' + +# Test zip64 support for real by actually exceeding the 32-bit +# size/offset limits. This test does not, of course, run with the +# normal unit tests! ;) +class Zip64FullTest < MiniTest::Test + HUGE_ZIP = 'huge.zip' + + def teardown + ::Zip.reset! + ::FileUtils.rm_f HUGE_ZIP + end + + def test_large_zip_file + skip unless ENV['FULL_ZIP64_TEST'] && !Zip::RUNNING_ON_WINDOWS + + first_text = 'starting out small' + last_text = 'this tests files starting after 4GB in the archive' + comment_text = 'this is a file comment in a zip64 archive' + + ::Zip::File.open(HUGE_ZIP, create: true) do |zf| + zf.comment = comment_text - def test_large_zip_file - ::Zip.write_zip64_support = true - first_text = 'starting out small' - last_text = 'this tests files starting after 4GB in the archive' - test_filename = prepare_test_file('huge.zip') - ::Zip::OutputStream.open(test_filename) do |io| - io.put_next_entry('first_file.txt') + zf.get_output_stream('first_file.txt') do |io| io.write(first_text) + end - # write just over 4GB (stored, so the zip file exceeds 4GB) - buf = 'blah' * 16_384 - io.put_next_entry('huge_file', nil, nil, ::Zip::Entry::STORED) + # Write just over 4GB (stored, so the zip file exceeds 4GB). + buf = 'blah' * 16_384 + zf.get_output_stream( + 'huge_file', compression_method: ::Zip::COMPRESSION_METHOD_STORE + ) do |io| 65_537.times { io.write(buf) } - - io.put_next_entry('last_file.txt') - io.write(last_text) end - ::Zip::File.open(test_filename) do |zf| - assert_equal %w[first_file.txt huge_file last_file.txt], zf.entries.map(&:name) - assert_equal first_text, zf.read('first_file.txt') - assert_equal last_text, zf.read('last_file.txt') + zf.get_output_stream('last_file.txt') do |io| + io.write(last_text) end + end - # note: if this fails, be sure you have UnZip version 6.0 or newer - # as this is the first version to support zip64 extensions - # but some OSes (*cough* OSX) still bundle a 5.xx release - assert system("unzip -tqq #{test_filename}"), 'third-party zip validation failed' + ::Zip::File.open(HUGE_ZIP) do |zf| + assert_equal( + %w[first_file.txt huge_file last_file.txt], zf.entries.map(&:name) + ) + assert_equal(first_text, zf.read('first_file.txt')) + assert_equal(last_text, zf.read('last_file.txt')) + assert_equal(comment_text, zf.comment) end - end + # NOTE: if this fails, be sure you have UnZip version 6.0 or newer + # as this is the first version to support zip64 extensions + # but some OSes (*cough* OSX) still bundle a 5.xx release + assert( + system("unzip -tqq #{HUGE_ZIP}"), + 'third-party zip validation failed' + ) + end end diff --git a/test/zip64_support_test.rb b/test/zip64_support_test.rb index 3e4154a8..0e96a9c6 100644 --- a/test/zip64_support_test.rb +++ b/test/zip64_support_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class Zip64SupportTest < MiniTest::Test