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 a408fa0d..49bed3dd 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,6 +1,114 @@ -inherit_from: - - .rubocop_rubyzip.yml +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: 1.9 -Style/MutableConstant: - Enabled: false # Because some existent code relies on mutable constant + 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 + EnforcedColonStyle: table + +# Set a workable line length, given the current state of the code, +# and turn off for the tests. +Layout/LineLength: + 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: + AllowComments: true + Exclude: + - 'test/**/*.rb' + +# Allow this "useless" test, as we are testing <=> here. +Lint/BinaryOperatorWithIdenticalOperands: + Exclude: + - 'test/entry_test.rb' + +# Turn off ABC metrics for the tests and set a workable max given +# the current state of the code. +Metrics/AbcSize: + Max: 37 + Exclude: + - 'test/**/*.rb' + +# 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. +Metrics/ClassLength: + Exclude: + - 'test/**/*.rb' + +# Turn method length metrics off for the tests. +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: + Exclude: + - 'lib/zip/crypto/traditional_encryption.rb' + +# Allow inner slashes when using // for regex literals. Allow the +# Guardfile to use a syntax that is more consistent with its own style. +Style/RegexpLiteral: + AllowInnerSlashes: true + Exclude: + - 'Guardfile' + +Style/SymbolArray: + EnforcedStyle: brackets + +# Turn this cop off for these files as it fires for objects without +# an empty? method. +Style/ZeroLengthPredicate: + Exclude: + - 'lib/zip/file.rb' + - 'lib/zip/input_stream.rb' diff --git a/.rubocop_rubyzip.yml b/.rubocop_rubyzip.yml deleted file mode 100644 index 3030f8a0..00000000 --- a/.rubocop_rubyzip.yml +++ /dev/null @@ -1,137 +0,0 @@ -# This configuration was generated by `rubocop --auto-gen-config` -# on 2015-06-08 10:22:52 +0300 using RuboCop version 0.32.0. -# 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: 13 -Lint/HandleExceptions: - Enabled: false - -# Offense count: 1 -Lint/LiteralInCondition: - Enabled: false - -# Offense count: 1 -Lint/RescueException: - Enabled: false - -# Offense count: 1 -Lint/UselessComparison: - Enabled: false - -# Offense count: 115 -Metrics/AbcSize: - Max: 62 - -# Offense count: 12 -# Configuration parameters: CountComments. -Metrics/ClassLength: - Max: 562 - -# Offense count: 21 -Metrics/CyclomaticComplexity: - Max: 14 - -# Offense count: 237 -# Configuration parameters: AllowURI, URISchemes. -Metrics/LineLength: - Max: 236 - -# Offense count: 108 -# Configuration parameters: CountComments. -Metrics/MethodLength: - Max: 35 - -# Offense count: 2 -# Configuration parameters: CountKeywordArgs. -Metrics/ParameterLists: - Max: 10 - -# Offense count: 15 -Metrics/PerceivedComplexity: - Max: 15 - -# Offense count: 8 -Style/AccessorMethodName: - Enabled: false - -# Offense count: 23 -# Cop supports --auto-correct. -Style/Alias: - Enabled: false - -# Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles, ProceduralMethods, FunctionalMethods, IgnoredMethods. -Style/BlockDelimiters: - Enabled: false - -# Offense count: 7 -# Configuration parameters: EnforcedStyle, SupportedStyles. -Style/ClassAndModuleChildren: - Enabled: false - -# Offense count: 15 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. -Style/ClassCheck: - Enabled: false - -# Offense count: 77 -Style/Documentation: - Enabled: false - -# Offense count: 1 -# Cop supports --auto-correct. -Style/InfiniteLoop: - Enabled: false - -# Offense count: 1 -Style/ModuleFunction: - Enabled: false - -# Offense count: 1 -Style/MultilineBlockChain: - Enabled: false - -# Offense count: 3 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles, AllowInnerSlashes. -Style/RegexpLiteral: - Enabled: false - -# Offense count: 2 -# Cop supports --auto-correct. -# Configuration parameters: AllowAsExpressionSeparator. -Style/Semicolon: - Enabled: false - -# Offense count: 79 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. -Style/SignalException: - Enabled: false - -# Offense count: 9 -# Cop supports --auto-correct. -# Configuration parameters: MultiSpaceAllowedForOperators. -Style/SpaceAroundOperators: - Enabled: false - -# Offense count: 30 -# Cop supports --auto-correct. -Style/SpecialGlobalVars: - Enabled: false - -# Offense count: 22 -# Cop supports --auto-correct. -# Configuration parameters: IgnoredMethods. -Style/SymbolProc: - Enabled: false - -# Offense count: 151 -# Configuration parameters: EnforcedStyle, SupportedStyles. -Style/VariableName: - Enabled: false diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml new file mode 100644 index 00000000..ff5238cd --- /dev/null +++ b/.rubocop_todo.yml @@ -0,0 +1,123 @@ +# This configuration was generated by +# `rubocop --auto-gen-config` +# 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. + +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: 650 + +# Offense count: 21 +# Configuration parameters: IgnoredMethods. +Metrics/CyclomaticComplexity: + Max: 14 + +# Offense count: 47 +# Configuration parameters: CountComments, CountAsOne, ExcludedMethods, IgnoredMethods. +Metrics/MethodLength: + Max: 34 + +# Offense count: 5 +# Configuration parameters: CountKeywordArgs. +Metrics/ParameterLists: + Max: 11 + MaxOptionalParameters: 9 + +# Offense count: 14 +# Configuration parameters: IgnoredMethods. +Metrics/PerceivedComplexity: + Max: 15 + +# Offense count: 7 +Naming/AccessorMethodName: + Exclude: + - 'lib/zip/entry.rb' + - 'lib/zip/input_stream.rb' + - 'lib/zip/streamable_stream.rb' + +# Offense count: 7 +# Cop supports --auto-correct. +# Configuration parameters: EnforcedStyle. +# SupportedStyles: nested, compact +Style/ClassAndModuleChildren: + Exclude: + - 'lib/zip/extra_field/generic.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/unknown.rb' + - 'lib/zip/extra_field/zip64.rb' + - 'lib/zip/extra_field/zip64_placeholder.rb' + +# Offense count: 22 +# Configuration parameters: AllowedConstants. +Style/Documentation: + Enabled: false + +# Offense count: 13 +# Cop supports --auto-correct. +Style/IfUnlessModifier: + Exclude: + - 'lib/zip/entry.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. +# SupportedStyles: literals, strict +Style/MutableConstant: + Exclude: + - 'lib/zip/extra_field.rb' + +# Offense count: 21 +# Cop supports --auto-correct. +# Configuration parameters: EnforcedStyle, IgnoredMethods. +# SupportedStyles: predicate, comparison +Style/NumericPredicate: + Exclude: + - '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/file.rb' + - 'lib/zip/input_stream.rb' + - 'lib/zip/ioextras.rb' + - 'lib/zip/ioextras/abstract_input_stream.rb' + +# Offense count: 17 +# Cop supports --auto-correct. +# Configuration parameters: ConvertCodeThatCanStartToReturnNil, AllowedMethods. +# AllowedMethods: present?, blank?, presence, try, try! +Style/SafeNavigation: + Exclude: + - 'lib/zip/entry.rb' + - 'lib/zip/input_stream.rb' + - 'lib/zip/output_stream.rb' + - 'test/file_extract_test.rb' + - 'test/filesystem/file_nonmutating_test.rb' + - 'test/filesystem/file_stat_test.rb' + - 'test/test_helper.rb' 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 21a4c64f..00000000 --- a/.travis.yml +++ /dev/null @@ -1,33 +0,0 @@ -language: ruby -dist: xenial -cache: bundler -rvm: - - 2.4 - - 2.5 - - 2.6 - - 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 0d12fb3b..7e0b9052 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,12 +1,126 @@ -# 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) + +- Fix frozen string literal error [#431](https://github.com/rubyzip/rubyzip/pull/431) +- Set `OutputStream.write_buffer`'s buffer to binmode [#439](https://github.com/rubyzip/rubyzip/pull/439) +- Upgrade rubocop and fix various linting complaints [#437](https://github.com/rubyzip/rubyzip/pull/437) [#440](https://github.com/rubyzip/rubyzip/pull/440) + +Tooling: + +- Add a `bin/console` script for development [#420](https://github.com/rubyzip/rubyzip/pull/420) +- Update rake requirement (development dependency only) to fix a security alert. + +# 2.2.0 (2020-02-01) + +- Add support for decompression plugin gems [#427](https://github.com/rubyzip/rubyzip/pull/427) # 2.1.0 (2020-01-25) - Fix (at least partially) the `restore_times` and `restore_permissions` options to `Zip::File.new` [#413](https://github.com/rubyzip/rubyzip/pull/413) - - Previously, neither option did anything, regardless of what it was set to. We have therefore defaulted them to `false` to preserve the current behavior, for the time being. If you have explicitly set either to `true`, it will now have an effect. - - Fix handling of UniversalTime (`mtime`, `atime`, `ctime`) fields. [#421](https://github.com/rubyzip/rubyzip/pull/421) - - Previously, `Zip::File` did not pass the options to `Zip::Entry` in some cases. [#423](https://github.com/rubyzip/rubyzip/pull/423) - - Note that `restore_times` in this release does nothing on Windows and only restores `mtime`, not `atime` or `ctime`. + - Previously, neither option did anything, regardless of what it was set to. We have therefore defaulted them to `false` to preserve the current behavior, for the time being. If you have explicitly set either to `true`, it will now have an effect. + - Fix handling of UniversalTime (`mtime`, `atime`, `ctime`) fields. [#421](https://github.com/rubyzip/rubyzip/pull/421) + - Previously, `Zip::File` did not pass the options to `Zip::Entry` in some cases. [#423](https://github.com/rubyzip/rubyzip/pull/423) + - Note that `restore_times` in this release does nothing on Windows and only restores `mtime`, not `atime` or `ctime`. - Allow `Zip::File.open` to take an options hash like `Zip::File.new` [#418](https://github.com/rubyzip/rubyzip/pull/418) - Always print warnings with `warn`, instead of a mix of `puts` and `warn` [#416](https://github.com/rubyzip/rubyzip/pull/416) - Create temporary files in the system temporary directory instead of the directory of the zip file [#411](https://github.com/rubyzip/rubyzip/pull/411) @@ -21,7 +135,7 @@ Tooling Security - Default the `validate_entry_sizes` option to `true`, so that callers can trust an entry's reported size when using `extract` [#403](https://github.com/rubyzip/rubyzip/pull/403) - - This option defaulted to `false` in 1.3.0 for backward compatibility, but it now defaults to `true`. If you are using an older version of ruby and can't yet upgrade to 2.x, you can still use 1.3.0 and set the option to `true`. + - This option defaulted to `false` in 1.3.0 for backward compatibility, but it now defaults to `true`. If you are using an older version of ruby and can't yet upgrade to 2.x, you can still use 1.3.0 and set the option to `true`. Tooling / Documentation @@ -33,7 +147,7 @@ Tooling / Documentation Security - Add `validate_entry_sizes` option so that callers can trust an entry's reported size when using `extract` [#403](https://github.com/rubyzip/rubyzip/pull/403) - - This option defaults to `false` for backward compatibility in this release, but you are strongly encouraged to set it to `true`. It will default to `true` in rubyzip 2.0. + - This option defaults to `false` for backward compatibility in this release, but you are strongly encouraged to set it to `true`. It will default to `true` in rubyzip 2.0. New Feature 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 44a9b287..f1e81470 100644 --- a/Rakefile +++ b/Rakefile @@ -1,5 +1,9 @@ +# frozen_string_literal: true + require 'bundler/gem_tasks' require 'rake/testtask' +require 'rdoc/task' +require 'rubocop/rake_task' task default: :test @@ -10,9 +14,12 @@ Rake::TestTask.new(:test) do |test| test.verbose = true 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 +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 + +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 new file mode 100755 index 00000000..15e7b977 --- /dev/null +++ b/bin/console @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'bundler/setup' +require 'zip' + +require 'irb' +IRB.start(__FILE__) diff --git a/lib/zip.rb b/lib/zip.rb index fa382376..f85e7fb0 100644 --- a/lib/zip.rb +++ b/lib/zip.rb @@ -1,9 +1,13 @@ +# frozen_string_literal: true + +require 'English' require 'delegate' require 'singleton' require 'tempfile' require 'fileutils' require 'stringio' require 'zlib' +require 'zip/constants' require 'zip/dos_time' require 'zip/ioextras' require 'rbconfig' @@ -21,6 +25,7 @@ require 'zip/null_input_stream' require 'zip/pass_thru_compressor' require 'zip/pass_thru_decompressor' +require 'zip/crypto/decrypted_io' require 'zip/crypto/encryption' require 'zip/crypto/null_encryption' require 'zip/crypto/traditional_encryption' @@ -28,9 +33,13 @@ require 'zip/deflater' require 'zip/streamable_stream' require 'zip/streamable_directory' -require 'zip/constants' 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, @@ -44,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 0b6874ef..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, @@ -65,16 +95,14 @@ def write_64_e_o_c_d(io, offset, cdir_size) #:nodoc: @entry_set ? @entry_set.size : 0, # number of entries on this disk @entry_set ? @entry_set.size : 0, # number of entries total cdir_size, # size of central directory - offset, # offset of start of central directory in its disk + offset # offset of start of central directory in its disk ] 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 + COMPRESSION_METHOD_SHRINK = 1 + COMPRESSION_METHOD_REDUCE_1 = 2 + COMPRESSION_METHOD_REDUCE_2 = 3 + COMPRESSION_METHOD_REDUCE_3 = 4 + COMPRESSION_METHOD_REDUCE_4 = 5 + COMPRESSION_METHOD_IMPLODE = 6 + # RESERVED = 7 + COMPRESSION_METHOD_DEFLATE = 8 + COMPRESSION_METHOD_DEFLATE_64 = 9 + COMPRESSION_METHOD_PKWARE_DCLI = 10 + # RESERVED = 11 + COMPRESSION_METHOD_BZIP2 = 12 + # RESERVED = 13 + COMPRESSION_METHOD_LZMA = 14 + # RESERVED = 15 + COMPRESSION_METHOD_IBM_CMPSC = 16 + # RESERVED = 17 + COMPRESSION_METHOD_IBM_TERSE = 18 + COMPRESSION_METHOD_IBM_LZ77 = 19 + COMPRESSION_METHOD_JPEG = 96 + COMPRESSION_METHOD_WAVPACK = 97 + COMPRESSION_METHOD_PPMD = 98 + COMPRESSION_METHOD_AES = 99 + + COMPRESSION_METHODS = { + COMPRESSION_METHOD_STORE => 'Store (no compression)', + COMPRESSION_METHOD_SHRINK => 'Shrink', + COMPRESSION_METHOD_REDUCE_1 => 'Reduce with compression factor 1', + COMPRESSION_METHOD_REDUCE_2 => 'Reduce with compression factor 2', + COMPRESSION_METHOD_REDUCE_3 => 'Reduce with compression factor 3', + COMPRESSION_METHOD_REDUCE_4 => 'Reduce with compression factor 4', + COMPRESSION_METHOD_IMPLODE => 'Implode', + # RESERVED = 7 + COMPRESSION_METHOD_DEFLATE => 'Deflate', + COMPRESSION_METHOD_DEFLATE_64 => 'Deflate64(tm)', + COMPRESSION_METHOD_PKWARE_DCLI => 'PKWARE Data Compression Library Imploding (old IBM TERSE)', + # RESERVED = 11 + COMPRESSION_METHOD_BZIP2 => 'BZIP2', + # RESERVED = 13 + COMPRESSION_METHOD_LZMA => 'LZMA', + # RESERVED = 15 + COMPRESSION_METHOD_IBM_CMPSC => 'IBM z/OS CMPSC Compression', + # RESERVED = 17 + COMPRESSION_METHOD_IBM_TERSE => 'IBM TERSE (new)', + COMPRESSION_METHOD_IBM_LZ77 => 'IBM LZ77 z Architecture (PFS)', + COMPRESSION_METHOD_JPEG => 'JPEG variant', + COMPRESSION_METHOD_WAVPACK => 'WavPack compressed data', + 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 new file mode 100644 index 00000000..a38004fa --- /dev/null +++ b/lib/zip/crypto/decrypted_io.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Zip + class DecryptedIo # :nodoc:all + CHUNK_SIZE = 32_768 + + def initialize(io, decrypter) + @io = io + @decrypter = decrypter + end + + def read(length = nil, outbuf = +'') + return (length.nil? || length.zero? ? '' : nil) if eof + + while length.nil? || (buffer.bytesize < length) + break if input_finished? + + buffer << produce_input + end + + outbuf.replace(buffer.slice!(0...(length || buffer.bytesize))) + end + + private + + def eof + buffer.empty? && input_finished? + end + + def buffer + @buffer ||= +'' + end + + def input_finished? + @io.eof + end + + def produce_input + @decrypter.decrypt(@io.read(CHUNK_SIZE)) + end + end +end 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 91e6ce16..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! @@ -24,9 +26,9 @@ def reset_keys! end end - def update_keys(n) - @key0 = ~Zlib.crc32(n, ~@key0) - @key1 = ((@key1 + (@key0 & 0xff)) * 134_775_813 + 1) & 0xffffffff + def update_keys(num) + @key0 = ~Zlib.crc32(num, ~@key0) + @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! @@ -63,14 +65,14 @@ def reset! private - def encode(n) + def encode(num) t = decrypt_byte - update_keys(n.chr) - t ^ n + update_keys(num.chr) + t ^ num end end - class TraditionalDecrypter < Decrypter + class TraditionalDecrypter < Decrypter # :nodoc: include TraditionalEncryption def decrypt(data) @@ -86,10 +88,10 @@ def reset!(header) private - def decode(n) - n ^= decrypt_byte - update_keys(n.chr) - n + def decode(num) + num ^= decrypt_byte + update_keys(num.chr) + num end end end diff --git a/lib/zip/decompressor.rb b/lib/zip/decompressor.rb index 047ed5e7..e75aba92 100644 --- a/lib/zip/decompressor.rb +++ b/lib/zip/decompressor.rb @@ -1,9 +1,28 @@ +# frozen_string_literal: true + module Zip - class Decompressor #:nodoc:all + class Decompressor # :nodoc:all CHUNK_SIZE = 32_768 - def initialize(input_stream) + + def self.decompressor_classes + @decompressor_classes ||= {} + end + + def self.register(compression_method, decompressor_class) + decompressor_classes[compression_method] = decompressor_class + end + + def self.find_by_compression_method(compression_method) + decompressor_classes[compression_method] + end + + attr_reader :decompressed_size, :input_stream + + def initialize(input_stream, decompressed_size = nil) super() + @input_stream = input_stream + @decompressed_size = decompressed_size end end end 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 c912b773..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. @@ -34,16 +53,43 @@ def self.from_time(time) local(time.year, time.month, time.day, time.hour, time.min, time.sec) end - def self.parse_binary_dos_format(binaryDosDate, binaryDosTime) - second = 2 * (0b11111 & binaryDosTime) - minute = (0b11111100000 & binaryDosTime) >> 5 - hour = (0b1111100000000000 & binaryDosTime) >> 11 - day = (0b11111 & binaryDosDate) - month = (0b111100000 & binaryDosDate) >> 5 - year = ((0b1111111000000000 & binaryDosDate) >> 9) + 1980 - begin - local(year, month, day, hour, minute, second) + def self.parse_binary_dos_format(bin_dos_date, bin_dos_time) + second = 2 * (0b11111 & bin_dos_time) + minute = (0b11111100000 & bin_dos_time) >> 5 + hour = (0b1111100000000000 & bin_dos_time) >> 11 + day = (0b11111 & bin_dos_date) + month = (0b111100000 & bin_dos_date) >> 5 + year = ((0b1111111000000000 & bin_dos_date) >> 9) + 1980 + + 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 f6d5cb5e..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,169 +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 + @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? # :nodoc: + (gp_flags & 8 == 8) && (crc == 0 || size == 0 || compressed_size == 0) + 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 - @ftype = name_is_directory? ? :directory : :file - @extra = ::Zip::ExtraField.new(@extra.to_s) unless @extra.is_a?(::Zip::ExtraField) + # Standard time field in central directory has local time + # under archive creator. Then, we can't get timezone. + time || (@time if component == :mtime) 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 + alias mtime time + + # Get the last access time of this entry, if available. + def atime + time(component: :atime) end - alias mtime time + # Get the creation time of this entry, if available. + def ctime + time(component: :ctime) + end - def time=(value) + # 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 + + alias mtime= time= + + # 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 file_type_is?(type) - raise InternalError, "current filetype is unknown: #{inspect}" unless @ftype - @ftype == type + 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 } - if directory? || file? || symlink? - __send__("create_#{@ftype}", dest_path, &block) - else - raise "unknown file type #{inspect}" - end + raise "unknown file type #{inspect}" unless directory? || file? || symlink? + __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).unpack('v')[0] - end - - def read_zip_long(io) # :nodoc: - io.read(4).unpack('V')[0] - end - - def read_zip_64_long(io) # :nodoc: - io.read(8).unpack('Q<')[0] - 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 @@ -209,18 +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 - public - - def unpack_local_entry(buf) + def unpack_local_entry(buf) # :nodoc: @header_signature, @version, @fstype, @@ -235,60 +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' - else - if @extra.is_a?(::Zip::ExtraField) - @extra.merge(extra) if extra - else - @extra = ::Zip::ExtraField.new(extra) - end 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 @@ -299,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 @@ -323,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 @@ -335,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 @@ -352,40 +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) - if @extra.is_a?(::Zip::ExtraField) - @extra.merge(io.read(@extra_length)) + def read_extra_field(buf, local: false) # :nodoc: + if @extra.kind_of?(::Zip::ExtraField) + @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 @@ -401,26 +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) + stat = file_stat(path) + @time = DOSTime.from_time(stat.mtime) + return if ::Zip::RUNNING_ON_WINDOWS + @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? @@ -429,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, @@ -439,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, @@ -458,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 @@ -475,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 @@ -486,42 +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 @@ -540,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' @@ -550,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 @@ -558,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 @@ -586,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 @@ -600,26 +740,24 @@ 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 warned = false - buf = ''.dup + buf = +'' while (buf = is.sysread(::Zip::Decompressor::CHUNK_SIZE, buf)) os << buf bytes_written += buf.bytesize - if bytes_written > size && !warned - message = "entry '#{name}' should be #{size}B, but is larger when inflated." - if ::Zip.validate_entry_sizes - raise ::Zip::EntrySizeError, message - else - warn "WARNING: #{message}" - warned = true - end - end + next unless bytes_written > size && !warned + + error = ::Zip::EntrySizeError.new(self) + raise error if ::Zip.validate_entry_sizes + + warn "WARNING: #{error.message}" + warned = true end end end @@ -629,15 +767,13 @@ def create_file(dest_path, _continue_on_exists_proc = proc { Zip.continue_on_exi 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 @@ -651,51 +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 - need_zip64 = @size >= 0xFFFFFFFF || @compressed_size >= 0xFFFFFFFF - need_zip64 ||= @local_header_offset >= 0xFFFFFFFF unless for_local_header - if need_zip64 + return if (!zip64? && @size && @size < 0xFFFFFFFF) || !file? + + # 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 3272b2a4..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 @@ -50,6 +52,7 @@ def dup def ==(other) return false unless other.kind_of?(EntrySet) + @entry_set.values == other.entry_set.values end @@ -58,17 +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 364c6eee..f172a77f 100644 --- a/lib/zip/errors.rb +++ b/lib/zip/errors.rb @@ -1,18 +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 - - # 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 72c36764..31d75d2c 100644 --- a/lib/zip/extra_field.rb +++ b/lib/zip/extra_field.rb @@ -1,50 +1,45 @@ +# 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, i) + def extra_field_type_exist(binstr, id, len, index) field_name = ID_MAP[id].name if member?(field_name) - self[field_name].merge(binstr[i, len + 4]) + self[field_name].merge(binstr[index, len + 4]) else - field_obj = ID_MAP[id].new(binstr[i, len + 4]) + field_obj = ID_MAP[id].new(binstr[index, len + 4]) self[field_name] = field_obj end end - def extra_field_type_unknown(binstr, len, i) - create_unknown_item unless self['Unknown'] - if !len || len + 4 > binstr[i..-1].bytesize - self['Unknown'] << binstr[i..-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[i, len + 4] - end - def create_unknown_item - s = ''.dup - 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 while i < binstr.bytesize id = binstr[i, 2] - len = binstr[i + 2, 2].to_s.unpack('v').first + len = binstr[i + 2, 2].to_s.unpack1('v') 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 @@ -54,11 +49,12 @@ def create(name) unless (field_class = ID_MAP.values.find { |k| k.name == name }) raise Error, "Unknown extra field '#{name}'" end + 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) } @@ -88,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 d61137fe..ddd8eed6 100644 --- a/lib/zip/extra_field/generic.rb +++ b/lib/zip/extra_field/generic.rb @@ -1,9 +1,11 @@ +# frozen_string_literal: true + module Zip - class ExtraField::Generic + class ExtraField::Generic # :nodoc: def self.register_map - if const_defined?(:HEADER_ID) - ::Zip::ExtraField::ID_MAP[const_get(:HEADER_ID)] = self - end + return unless const_defined?(:HEADER_ID) + + ::Zip::ExtraField::ID_MAP[const_get(:HEADER_ID)] = self end def self.name @@ -12,32 +14,24 @@ def self.name # return field [size, content] or false def initial_parse(binstr) - if !binstr - # If nil, start with empty. - return false - elsif binstr[0, 2] != self.class.const_get(:HEADER_ID) + return false unless binstr + + if binstr[0, 2] != self.class.const_get(:HEADER_ID) warn 'WARNING: weird extra field header ID. Skip parsing it.' return false end - [binstr[2, 2].unpack('v')[0], 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 687704d8..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 @@ -19,14 +21,16 @@ def initialize(binstr = nil) def merge(binstr) return if binstr.empty? + size, content = initial_parse(binstr) (size && content) || return - content = content[4..-1] + content = content[4..] tags = parse_tags(content) tag1 = tags[1] return unless tag1 + ntfs_mtime, ntfs_atime, ntfs_ctime = tag1.unpack('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, :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 + initialize_cdir(path_or_io, buffer: buffer) - 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 - - @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 yield zf ensure @@ -127,30 +113,31 @@ 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 = {}) - unless IO_METHODS.map { |method| io.respond_to?(method) }.all? || io.is_a?(String) - raise "Zip::File.open_buffer expects a String or IO-like argument (responds to #{IO_METHODS.join(', ')}). Found: #{io.class}" + 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}" end - io = ::StringIO.new(io) if io.is_a?(::String) + 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 begin @@ -166,93 +153,32 @@ def open_buffer(io, options = {}) # whereas ZipInputStream jumps through the entire archive accessing the # local entry headers (which contain the same information as the # central directory). - def foreach(aZipFileName, &block) - open(aZipFileName) do |zipFile| - zipFile.each(&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)) + def foreach(zip_file_name, &block) + ::Zip::File.open(zip_file_name) do |zip_file| + zip_file.each(&block) 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 - # - # 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 + # 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 - # 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 - 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 # Returns an input stream to the specified entry. 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_input_stream(entry, &aProc) - get_entry(entry).get_input_stream(&aProc) + def get_input_stream(entry, &a_proc) + get_entry(entry).get_input_stream(&a_proc) end # Returns an output stream to the specified entry. If entry is not an instance @@ -260,21 +186,30 @@ def get_input_stream(entry, &aProc) # 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, &aProc) + 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 - zip_streamable_entry.get_output_stream(&aProc) + @cdir << zip_streamable_entry + zip_streamable_entry.get_output_stream(&a_proc) end # Returns the name of the zip archive @@ -284,77 +219,92 @@ def to_s # Returns a string containing the contents of the specified entry def read(entry) - get_input_stream(entry) { |is| is.read } + get_input_stream(entry, &:read) end # Convenience method for adding the contents of a file to the archive 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) - foundEntry = get_entry(entry) + found_entry = get_entry(entry) check_entry_exists(new_name, continue_on_exists_proc, 'rename') - @entry_set.delete(foundEntry) - foundEntry.name = new_name - @entry_set << foundEntry + @cdir.delete(found_entry) + found_entry.name = new_name + @cdir << found_entry end - # Replaces the specified entry with the contents of srcPath (from + # Replaces the specified entry with the contents of src_path (from # the file system). - def replace(entry, srcPath) - check_file(srcPath) + def replace(entry, src_path) + check_file(src_path) remove(entry) - add(entry, srcPath) + 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 # the zip archive. def commit - return if name.is_a?(StringIO) || !commit_required? + return if name.kind_of?(StringIO) || !commit_required? + 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 @@ -367,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 @@ -385,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) @@ -400,36 +348,55 @@ def get_entry(entry) end # Creates a directory - def mkdir(entryName, permissionInt = 0o755) - raise Errno::EEXIST, "File exists - #{entryName}" if find_entry(entryName) - entryName = entryName.dup.to_s - entryName << '/' unless entryName.end_with?('/') - @entry_set << ::Zip::StreamableDirectory.new(@name, entryName, nil, permissionInt) + def mkdir(entry_name, permission = 0o755) + raise Errno::EEXIST, "File exists - #{entry_name}" if find_entry(entry_name) + + entry_name = entry_name.dup.to_s + entry_name << '/' unless entry_name.end_with?('/') + @cdir << ::Zip::StreamableDirectory.new(@name, entry_name, nil, permission) end private - def directory?(newEntry, srcPath) - srcPathIsDirectory = ::File.directory?(srcPath) - if newEntry.directory? && !srcPathIsDirectory - raise ArgumentError, - "entry name '#{newEntry}' indicates directory entry, but " \ - "'#{srcPath}' is not a directory" - elsif !newEntry.directory? && srcPathIsDirectory - newEntry.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 - newEntry.directory? && srcPathIsDirectory end - def check_entry_exists(entryName, continue_on_exists_proc, procedureName) + 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?(entryName) - if continue_on_exists_proc.call - remove get_entry(entryName) - else - raise ::Zip::EntryExistsError, - procedureName + " failed. Entry #{entryName} already exists" - end + raise ::Zip::EntryExistsError.new proc_name, entry_name unless continue_on_exists_proc.call + + remove get_entry(entry_name) end def check_file(path) @@ -439,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 81ad1a18..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,611 +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: - mappedZip = ZipFileNameMapper.new(self) - @zipFsDir = ZipFsDir.new(mappedZip) - @zipFsFile = ZipFsFile.new(mappedZip) - @zipFsDir.file = @zipFsFile - @zipFsFile.dir = @zipFsDir + mapped_zip = ZipFileNameMapper.new(self) + @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 - @zipFsDir + @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 - @zipFsFile - 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? - @zipFsFile.#{method}(@entryName) # @zipFsFile.file?(@entryName) - end # end - end_eval - end - end - end - - def initialize(zipFsFile, entryName) - @zipFsFile = zipFsFile - @entryName = entryName - end - - def kind_of?(t) - super || t == ::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 - @zipFsFile.__send__(:get_entry, @entryName) - 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(mappedZip) - @mappedZip = mappedZip - end - - def get_entry(fileName) - unless exists?(fileName) - raise Errno::ENOENT, "No such file or directory - #{fileName}" - end - @mappedZip.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) == '/' || !@mappedZip.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 = @mappedZip.find_entry(fileName) - expand_path(fileName) == '/' || (!entry.nil? && entry.directory?) - end - - def open(fileName, openMode = 'r', permissionInt = 0o644, &block) - openMode.delete!('b') # ignore b option - case openMode - when 'r' - @mappedZip.get_input_stream(fileName, &block) - when 'w' - @mappedZip.get_output_stream(fileName, permissionInt, &block) - else - raise StandardError, "openmode '#{openMode} not supported" unless openMode == 'r' - end - end - - def new(fileName, openMode = 'r') - open(fileName, openMode) - end - - def size(fileName) - @mappedZip.get_entry(fileName).size - end - - # Returns nil for not found and nil for directories - def size?(fileName) - entry = @mappedZip.find_entry(fileName) - entry.nil? || entry.directory? ? nil : entry.size - end - - def chown(ownerInt, groupInt, *filenames) - filenames.each do |fileName| - e = get_entry(fileName) - e.extra.create('IUnix') unless e.extra.member?('IUnix') - e.extra['IUnix'].uid = ownerInt - e.extra['IUnix'].gid = groupInt - end - filenames.size - end - - def chmod(modeInt, *filenames) - filenames.each do |fileName| - e = get_entry(fileName) - e.fstype = 3 # force convertion filesystem type to unix - e.unix_perms = modeInt - e.external_file_attributes = modeInt << 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 = @mappedZip.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(modifiedTime, *fileNames) - fileNames.each do |fileName| - get_entry(fileName).time = modifiedTime - end - end - - def mtime(fileName) - @mappedZip.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) - @mappedZip.get_entry(fileName).directory? ? 'directory' : 'file' - end - - def readlink(_fileName) - raise NotImplementedError, 'The readlink() function is not implemented' - end - - def symlink(_fileName, _symlinkName) - raise NotImplementedError, 'The symlink() function is not implemented' - end - - def link(_fileName, _symlinkName) - 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) - open(fileName) { |is| is.readlines } - end - - def read(fileName) - @mappedZip.read(fileName) - end - - def popen(*args, &aProc) - ::File.popen(*args, &aProc) - end - - def foreach(fileName, aSep = $/, &aProc) - open(fileName) { |is| is.each_line(aSep, &aProc) } - end - - def delete(*args) - args.each do |fileName| - if directory?(fileName) - raise Errno::EISDIR, "Is a directory - \"#{fileName}\"" - end - @mappedZip.remove(fileName) - end - end - - def rename(fileToRename, newName) - @mappedZip.rename(fileToRename, newName) { true } - end - - alias unlink delete - - def expand_path(aPath) - @mappedZip.expand_path(aPath) - 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(mappedZip) - @mappedZip = mappedZip - end - - attr_writer :file - - def new(aDirectoryName) - ZipFsDirIterator.new(entries(aDirectoryName)) - end - - def open(aDirectoryName) - dirIt = new(aDirectoryName) - if block_given? - begin - yield(dirIt) - return nil - ensure - dirIt.close - end - end - dirIt - end - - def pwd - @mappedZip.pwd - end - alias getwd pwd - - def chdir(aDirectoryName) - unless @file.stat(aDirectoryName).directory? - raise Errno::EINVAL, "Invalid argument - #{aDirectoryName}" - end - @mappedZip.pwd = @file.expand_path(aDirectoryName) - end - - def entries(aDirectoryName) - entries = [] - foreach(aDirectoryName) { |e| entries << e } - entries - end - - def glob(*args, &block) - @mappedZip.glob(*args, &block) - end - - def foreach(aDirectoryName) - unless @file.stat(aDirectoryName).directory? - raise Errno::ENOTDIR, aDirectoryName - end - path = @file.expand_path(aDirectoryName) - path << '/' unless path.end_with?('/') - path = Regexp.escape(path) - subDirEntriesRegex = Regexp.new("^#{path}([^/]+)$") - @mappedZip.each do |fileName| - match = subDirEntriesRegex.match(fileName) - yield(match[1]) unless match.nil? - end - end - - def delete(entryName) - unless @file.stat(entryName).directory? - raise Errno::EINVAL, "Invalid argument - #{entryName}" - end - @mappedZip.remove(entryName) - end - alias rmdir delete - alias unlink delete - - def mkdir(entryName, permissionInt = 0o755) - @mappedZip.mkdir(entryName, permissionInt) - end - - def chroot(*_args) - raise NotImplementedError, 'The chroot() function is not implemented' - end - end - - class ZipFsDirIterator # :nodoc:all - include Enumerable - - def initialize(arrayOfFileNames) - @fileNames = arrayOfFileNames - @index = 0 - end - - def close - @fileNames = nil - end - - def each(&aProc) - raise IOError, 'closed directory' if @fileNames.nil? - @fileNames.each(&aProc) - 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(anIntegerPosition) - raise IOError, 'closed directory' if @fileNames.nil? - @index = anIntegerPosition - 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(zipFile) - @zipFile = zipFile - @pwd = '/' - end - - attr_accessor :pwd - - def find_entry(fileName) - @zipFile.find_entry(expand_to_entry(fileName)) - end - - def get_entry(fileName) - @zipFile.get_entry(expand_to_entry(fileName)) - end - - def get_input_stream(fileName, &aProc) - @zipFile.get_input_stream(expand_to_entry(fileName), &aProc) - end - - def get_output_stream(fileName, permissionInt = nil, &aProc) - @zipFile.get_output_stream(expand_to_entry(fileName), permissionInt, &aProc) - end - - def glob(pattern, *flags, &block) - @zipFile.glob(expand_to_entry(pattern), *flags, &block) - end - - def read(fileName) - @zipFile.read(expand_to_entry(fileName)) - end - - def remove(fileName) - @zipFile.remove(expand_to_entry(fileName)) - end - - def rename(fileName, newName, &continueOnExistsProc) - @zipFile.rename(expand_to_entry(fileName), expand_to_entry(newName), - &continueOnExistsProc) - end - - def mkdir(fileName, permissionInt = 0o755) - @zipFile.mkdir(expand_to_entry(fileName), permissionInt) - end - - # Turns entries into strings and adds leading / - # and removes trailing slash on directories - def each - @zipFile.each do |e| - yield('/' + e.to_s.chomp('/')) - end - end - - def expand_path(aPath) - expanded = aPath.start_with?('/') ? aPath : ::File.join(@pwd, aPath) - expanded.gsub!(/\/\.(\/|$)/, '') - expanded.gsub!(/[^\/]+\/\.\.(\/|$)/, '') - expanded.empty? ? '/' : expanded - end - - private - - def expand_to_entry(aPath) - expand_path(aPath)[1..-1] - end + @zip_fs_file 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 f1b26d45..40f285f1 100644 --- a/lib/zip/inflater.rb +++ b/lib/zip/inflater.rb @@ -1,64 +1,54 @@ +# frozen_string_literal: true + module Zip - class Inflater < Decompressor #:nodoc:all - def initialize(input_stream, decrypter = NullDecrypter.new) - super(input_stream) - @zlib_inflater = ::Zlib::Inflate.new(-Zlib::MAX_WBITS) - @output_buffer = ''.dup - @has_returned_empty_string = false - @decrypter = decrypter - end + class Inflater < Decompressor # :nodoc:all + def initialize(*args) + super - def sysread(number_of_bytes = nil, buf = '') - readEverything = number_of_bytes.nil? - while readEverything || @output_buffer.bytesize < number_of_bytes - break if internal_input_finished? - @output_buffer << internal_produce_input(buf) - end - return value_when_finished if @output_buffer.bytesize == 0 && input_finished? - end_index = number_of_bytes.nil? ? @output_buffer.bytesize : number_of_bytes - @output_buffer.slice!(0...end_index) + @buffer = +'' + @zlib_inflater = ::Zlib::Inflate.new(-Zlib::MAX_WBITS) end - def produce_input - if @output_buffer.empty? - internal_produce_input - else - @output_buffer.slice!(0...(@output_buffer.length)) + def read(length = nil, outbuf = +'') + return (length.nil? || length.zero? ? '' : nil) if eof + + while length.nil? || (@buffer.bytesize < length) + break if input_finished? + + @buffer << produce_input end + + outbuf.replace(@buffer.slice!(0...(length || @buffer.bytesize))) end - # to be used with produce_input, not read (as read may still have more data cached) - # is data cached anywhere other than @outputBuffer? the comment above may be wrong - def input_finished? - @output_buffer.empty? && internal_input_finished? + def eof + @buffer.empty? && input_finished? end - alias :eof input_finished? - alias :eof? input_finished? + alias eof? eof private - def internal_produce_input(buf = '') + def produce_input retried = 0 begin - @zlib_inflater.inflate(@decrypter.decrypt(@input_stream.read(Decompressor::CHUNK_SIZE, buf))) + @zlib_inflater.inflate(input_stream.read(Decompressor::CHUNK_SIZE)) rescue Zlib::BufError raise if retried >= 5 # how many times should we retry? + retried += 1 retry end + rescue Zlib::Error => e + raise ::Zip::DecompressionError, e end - def internal_input_finished? + def input_finished? @zlib_inflater.finished? end - - def value_when_finished # mimic behaviour of ruby File object. - return if @has_returned_empty_string - @has_returned_empty_string = true - '' - end end + + ::Zip::Decompressor.register(::Zip::COMPRESSION_METHOD_DEFLATE, ::Zip::Inflater) end # Copyright (C) 2002, 2003 Thomas Sondergaard diff --git a/lib/zip/input_stream.rb b/lib/zip/input_stream.rb index b9c35111..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,8 +40,9 @@ module Zip # # java.util.zip.ZipInputStream is the original inspiration for this # class. - class InputStream + CHUNK_SIZE = 32_768 # :nodoc: + include ::Zip::IOExtras::AbstractInputStream # Opens the indicated zip file. An exception is thrown @@ -47,30 +51,38 @@ 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 + @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? + @lineno = 0 @pos = 0 @archive_io.seek(@current_entry.local_header_offset, IO::SEEK_SET) @@ -78,39 +90,36 @@ def rewind end # Modeled after IO.sysread - def sysread(number_of_bytes = nil, buf = nil) - @decompressor.sysread(number_of_bytes, buf) + def sysread(length = nil, outbuf = '') + @decompressor.read(length, outbuf) end - def eof - @output_buffer.empty? && @decompressor.eof - end + # Returns the size of the current entry, or `nil` if there isn't one. + def size + return if @current_entry.nil? - alias :eof? eof + @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 yield zio ensure zio.close if zio end end - - def open_buffer(filename_or_io, offset = 0) - warn 'open_buffer is deprecated!!! Use open instead!' - 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) @@ -122,48 +131,59 @@ 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.gp_flags & 1 == 1 && @decrypter.is_a?(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.gp_flags & 8 == 8 && @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_decompressor - if @current_entry.nil? - ::Zip::NullDecompressor - elsif @current_entry.compression_method == ::Zip::Entry::STORED - if @current_entry.gp_flags & 8 == 8 && @current_entry.crc == 0 && @current_entry.size == 0 && @complete_entry - ::Zip::PassThruDecompressor.new(@archive_io, @complete_entry.size) + 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 # :nodoc: + return ::Zip::NullDecompressor if @current_entry.nil? + + decompressed_size = + if @current_entry.incomplete? && @current_entry.crc == 0 && + @current_entry.size == 0 && @complete_entry + @complete_entry.size else - ::Zip::PassThruDecompressor.new(@archive_io, @current_entry.size) + @current_entry.size end - elsif @current_entry.compression_method == ::Zip::Entry::DEFLATED - header = @archive_io.read(@decrypter.header_bytesize) - @decrypter.reset!(header) - ::Zip::Inflater.new(@archive_io, @decrypter) - else - raise ::Zip::CompressionMethodError, - "Unsupported 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, @current_entry.compression_method end + + decompressor_class.new(@decrypted_io, decompressed_size) end - def produce_input - @decompressor.produce_input + def produce_input # :nodoc: + @decompressor.read(CHUNK_SIZE) end - def input_finished? - @decompressor.input_finished? + def input_finished? # :nodoc: + @decompressor.eof end end end diff --git a/lib/zip/ioextras.rb b/lib/zip/ioextras.rb index 2412480b..66688966 100644 --- a/lib/zip/ioextras.rb +++ b/lib/zip/ioextras.rb @@ -1,31 +1,31 @@ +# 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 end - end # IOExtras namespace module + end end require 'zip/ioextras/abstract_input_stream' diff --git a/lib/zip/ioextras/abstract_input_stream.rb b/lib/zip/ioextras/abstract_input_stream.rb index 7b7fd61d..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,8 @@ 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 @@ -48,13 +51,13 @@ def read(number_of_bytes = nil, buf = '') buf end - def readlines(a_sep_string = $/) + def readlines(a_sep_string = $INPUT_RECORD_SEPARATOR) ret_val = [] each_line(a_sep_string) { |line| ret_val << line } ret_val end - def gets(a_sep_string = $/, number_of_bytes = nil) + def gets(a_sep_string = $INPUT_RECORD_SEPARATOR, number_of_bytes = nil) @lineno = @lineno.next if number_of_bytes.respond_to?(:to_int) @@ -62,24 +65,29 @@ def gets(a_sep_string = $/, number_of_bytes = nil) a_sep_string = a_sep_string.to_str if a_sep_string elsif a_sep_string.respond_to?(:to_int) number_of_bytes = a_sep_string.to_int - a_sep_string = $/ + a_sep_string = $INPUT_RECORD_SEPARATOR else number_of_bytes = nil a_sep_string = a_sep_string.to_str if a_sep_string end return read(number_of_bytes) if a_sep_string.nil? - a_sep_string = "#{$/}#{$/}" if a_sep_string.empty? + + 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 @@ -90,22 +98,30 @@ def ungetc(byte) def flush ret_val = @output_buffer - @output_buffer = '' + @output_buffer = +'' ret_val end - def readline(a_sep_string = $/) + def readline(a_sep_string = $INPUT_RECORD_SEPARATOR) ret_val = gets(a_sep_string) raise EOFError unless ret_val + ret_val end - def each_line(a_sep_string = $/) - yield readline(a_sep_string) while true + def each_line(a_sep_string = $INPUT_RECORD_SEPARATOR) + loop { yield readline(a_sep_string) } rescue EOFError + # We just need to catch this; we don't need to handle it. + end + + alias each each_line + + def eof + @output_buffer.empty? && input_finished? end - alias_method :each, :each_line + alias eof? eof end end end diff --git a/lib/zip/ioextras/abstract_output_stream.rb b/lib/zip/ioextras/abstract_output_stream.rb index 69d0cc7c..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($,) << $\.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 1560ef14..8eb79e67 100644 --- a/lib/zip/null_decompressor.rb +++ b/lib/zip/null_decompressor.rb @@ -1,19 +1,13 @@ +# frozen_string_literal: true + module Zip - module NullDecompressor #:nodoc:all + module NullDecompressor # :nodoc:all module_function - def sysread(_numberOfBytes = nil, _buf = nil) - nil - end - - def produce_input + def read(_length = nil, _outbuf = nil) nil end - def input_finished? - true - end - def eof true end 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 d9bbc4df..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,48 +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) - zos = new(io, true, encrypter) + def write_buffer(io = ::StringIO.new, encrypter: nil) + io.binmode if io.respond_to?(:binmode) + zos = new(io, stream: true, encrypter: encrypter) yield zos zos.close_buffer end @@ -66,9 +72,10 @@ def write_buffer(io = ::StringIO.new(''), encrypter = nil) # Closes the stream and writes the central directory to the zip file def close return if @closed + finalize_current_entry update_local_headers - write_central_directory + @cdir.write_to_stream(@output_stream) @output_stream.close @closed = true end @@ -76,37 +83,44 @@ def close # Closes the stream and writes the central directory to the zip file def close_buffer return @output_stream if @closed + 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.is_a?(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.is_a?(Entry) + 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 @@ -123,54 +137,55 @@ def copy_raw_entry(entry) def finalize_current_entry return unless @current_entry + finish - @current_entry.compressed_size = @output_stream.tell - @current_entry.local_header_offset - @current_entry.calculate_local_header_size + @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 then - ::Zip::Deflater.new(@output_stream, level, @encrypter) - when Entry::STORED then + when Entry::DEFLATED + ::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 fdca2481..9b5bdf3b 100644 --- a/lib/zip/pass_thru_compressor.rb +++ b/lib/zip/pass_thru_compressor.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + module Zip - class PassThruCompressor < Compressor #:nodoc:all - def initialize(outputStream) + class PassThruCompressor < Compressor # :nodoc:all + def initialize(output_stream) super() - @output_stream = outputStream + @output_stream = output_stream @crc = Zlib.crc32 @size = 0 end diff --git a/lib/zip/pass_thru_decompressor.rb b/lib/zip/pass_thru_decompressor.rb index 485462c5..56e8bd75 100644 --- a/lib/zip/pass_thru_decompressor.rb +++ b/lib/zip/pass_thru_decompressor.rb @@ -1,38 +1,31 @@ +# frozen_string_literal: true + module Zip - class PassThruDecompressor < Decompressor #:nodoc:all - def initialize(input_stream, chars_to_read) - super(input_stream) - @chars_to_read = chars_to_read + class PassThruDecompressor < Decompressor # :nodoc:all + def initialize(*args) + super @read_so_far = 0 - @has_returned_empty_string = false end - def sysread(number_of_bytes = nil, buf = '') - if input_finished? - has_returned_empty_string_val = @has_returned_empty_string - @has_returned_empty_string = true - return '' unless has_returned_empty_string_val - return - end + def read(length = nil, outbuf = +'') + return (length.nil? || length.zero? ? '' : nil) if eof - if number_of_bytes.nil? || @read_so_far + number_of_bytes > @chars_to_read - number_of_bytes = @chars_to_read - @read_so_far + if length.nil? || (@read_so_far + length) > decompressed_size + length = decompressed_size - @read_so_far end - @read_so_far += number_of_bytes - @input_stream.read(number_of_bytes, buf) - end - def produce_input - sysread(::Zip::Decompressor::CHUNK_SIZE) + @read_so_far += length + input_stream.read(length, outbuf) end - def input_finished? - @read_so_far >= @chars_to_read + def eof + @read_so_far >= decompressed_size end - alias eof input_finished? - alias eof? input_finished? + alias eof? eof end + + ::Zip::Decompressor.register(::Zip::COMPRESSION_METHOD_STORE, ::Zip::PassThruDecompressor) end # Copyright (C) 2002, 2003 Thomas Sondergaard diff --git a/lib/zip/streamable_directory.rb b/lib/zip/streamable_directory.rb index 4560663c..2c931190 100644 --- a/lib/zip/streamable_directory.rb +++ b/lib/zip/streamable_directory.rb @@ -1,11 +1,13 @@ +# frozen_string_literal: true + module Zip - class StreamableDirectory < Entry - def initialize(zipfile, entry, srcPath = nil, permissionInt = nil) + class StreamableDirectory < Entry # :nodoc: + def initialize(zipfile, entry, src_path = nil, permission = nil) super(zipfile, entry) @ftype = :directory - entry.get_extra_attributes_from_path(srcPath) if srcPath - @unix_perms = permissionInt if permissionInt + entry.get_extra_attributes_from_path(src_path) if src_path + @unix_perms = permission if permission end end end diff --git a/lib/zip/streamable_stream.rb b/lib/zip/streamable_stream.rb index 642ddae2..2fea80c7 100644 --- a/lib/zip/streamable_stream.rb +++ b/lib/zip/streamable_stream.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + module Zip - class StreamableStream < DelegateClass(Entry) # nodoc:all + class StreamableStream < DelegateClass(Entry) # :nodoc:all def initialize(entry) super(entry) @temp_file = Tempfile.new(::File.basename(name)) @@ -22,6 +24,7 @@ def get_input_stream unless @temp_file.closed? raise StandardError, "cannot open entry for reading while its open for writing - #{name}" end + @temp_file.open # reopens tempfile from top @temp_file.binmode if block_given? @@ -35,12 +38,13 @@ def get_input_stream end end - def write_to_zip_output_stream(aZipOutputStream) - aZipOutputStream.put_next_entry(self) - get_input_stream { |is| ::Zip::IOExtras.copy_stream(aZipOutputStream, is) } + def write_to_zip_output_stream(output_stream) + output_stream.put_next_entry(self) + get_input_stream { |is| ::Zip::IOExtras.copy_stream(output_stream, is) } end def clean_up + super @temp_file.unlink end end diff --git a/lib/zip/version.rb b/lib/zip/version.rb index 2af7a65c..7957e0de 100644 --- a/lib/zip/version.rb +++ b/lib/zip/version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Zip - VERSION = '2.1.0' + VERSION = '3.0.0.rc2' # :nodoc: end diff --git a/rubyzip.gemspec b/rubyzip.gemspec index f8c59a18..66ec3657 100644 --- a/rubyzip.gemspec +++ b/rubyzip.gemspec @@ -1,31 +1,39 @@ -#-*- encoding: utf-8 -*- +# frozen_string_literal: true -lib = File.expand_path('../lib', __FILE__) -$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) -require 'zip/version' +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 'rake', '~> 10.3' - s.add_development_dependency 'pry', '~> 0.10' - s.add_development_dependency 'minitest', '~> 5.4' - s.add_development_dependency 'coveralls', '~> 0.7' - s.add_development_dependency 'rubocop', '~> 0.49.1' + + 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 224d4f1c..713729fb 100755 --- a/samples/example.rb +++ b/samples/example.rb @@ -1,6 +1,7 @@ #!/usr/bin/env ruby +# frozen_string_literal: true -$: << '../lib' +$LOAD_PATH << '../lib' system('zip example.zip example.rb gtk_ruby_zip.rb') require 'zip' @@ -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.to_f * 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 f253a5e5..ec0075ee 100755 --- a/samples/example_filesystem.rb +++ b/samples/example_filesystem.rb @@ -1,14 +1,15 @@ #!/usr/bin/env ruby +# frozen_string_literal: true -$: << '../lib' +$LOAD_PATH << '../lib' require 'zip/filesystem' 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 62f005a5..eeac2a0a 100755 --- a/samples/gtk_ruby_zip.rb +++ b/samples/gtk_ruby_zip.rb @@ -1,6 +1,7 @@ #!/usr/bin/env ruby +# frozen_string_literal: true -$: << '../lib' +$LOAD_PATH << '../lib' $VERBOSE = true @@ -18,14 +19,14 @@ def initialize add(box) @zipfile = nil - @buttonPanel = ButtonPanel.new - @buttonPanel.openButton.signal_connect(Gtk::Button::SIGNAL_CLICKED) do + @button_panel = ButtonPanel.new + @button_panel.open_button.signal_connect(Gtk::Button::SIGNAL_CLICKED) do show_file_selector end - @buttonPanel.extractButton.signal_connect(Gtk::Button::SIGNAL_CLICKED) do + @button_panel.extract_button.signal_connect(Gtk::Button::SIGNAL_CLICKED) do puts 'Not implemented!' end - box.pack_start(@buttonPanel, false, false, 0) + box.pack_start(@button_panel, false, false, 0) sw = Gtk::ScrolledWindow.new sw.set_policy(Gtk::POLICY_AUTOMATIC, Gtk::POLICY_AUTOMATIC) @@ -42,27 +43,28 @@ def initialize end class ButtonPanel < Gtk::HButtonBox - attr_reader :openButton, :extractButton + attr_reader :extract_button, :open_button + def initialize super set_layout(Gtk::BUTTONBOX_START) set_spacing(0) - @openButton = Gtk::Button.new('Open archive') - @extractButton = Gtk::Button.new('Extract entry') - pack_start(@openButton) - pack_start(@extractButton) + @open_button = Gtk::Button.new('Open archive') + @extract_button = Gtk::Button.new('Extract entry') + pack_start(@open_button) + pack_start(@extract_button) end end def show_file_selector - @fileSelector = Gtk::FileSelection.new('Open zip file') - @fileSelector.show - @fileSelector.ok_button.signal_connect(Gtk::Button::SIGNAL_CLICKED) do - open_zip(@fileSelector.filename) - @fileSelector.destroy + @file_selector = Gtk::FileSelection.new('Open zip file') + @file_selector.show + @file_selector.ok_button.signal_connect(Gtk::Button::SIGNAL_CLICKED) do + open_zip(@file_selector.filename) + @file_selector.destroy end - @fileSelector.cancel_button.signal_connect(Gtk::Button::SIGNAL_CLICKED) do - @fileSelector.destroy + @file_selector.cancel_button.signal_connect(Gtk::Button::SIGNAL_CLICKED) do + @file_selector.destroy end end @@ -72,13 +74,13 @@ 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 -mainApp = MainApp.new +main_app = MainApp.new -mainApp.show_all +main_app.show_all Gtk.main diff --git a/samples/qtzip.rb b/samples/qtzip.rb index 1d450a78..917255fd 100755 --- a/samples/qtzip.rb +++ b/samples/qtzip.rb @@ -1,12 +1,13 @@ #!/usr/bin/env ruby +# frozen_string_literal: true $VERBOSE = true -$: << '../lib' +$LOAD_PATH << '../lib' require 'Qt' system('rbuic -o zipdialogui.rb zipdialogui.ui') -require 'zipdialogui.rb' +require 'zipdialogui' require 'zip' a = Qt::Application.new(ARGV) @@ -20,12 +21,12 @@ def initialize self, SLOT('extract_files()')) end - def zipfile(&proc) - Zip::File.open(@zip_filename, &proc) + def zipfile(&a_proc) + Zip::File.open(@zip_filename, &a_proc) end - def each(&proc) - Zip::File.foreach(@zip_filename, &proc) + def each(&a_proc) + Zip::File.foreach(@zip_filename, &a_proc) end def refresh @@ -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 @@ -80,7 +81,7 @@ def extract_files end unless ARGV[0] - puts "usage: #{$0} zipname" + puts "usage: #{$PROGRAM_NAME} zipname" exit end diff --git a/samples/write_simple.rb b/samples/write_simple.rb index be2a9704..84ac1387 100755 --- a/samples/write_simple.rb +++ b/samples/write_simple.rb @@ -1,12 +1,11 @@ #!/usr/bin/env ruby +# frozen_string_literal: true -$: << '../lib' +$LOAD_PATH << '../lib' require 'zip' -include 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 400e0a69..c3888389 100755 --- a/samples/zipfind.rb +++ b/samples/zipfind.rb @@ -1,37 +1,39 @@ #!/usr/bin/env ruby +# frozen_string_literal: true $VERBOSE = true -$: << '../lib' +$LOAD_PATH << '../lib' require 'zip' require 'find' module Zip module ZipFind - def self.find(path, zipFilePattern = /\.zip$/i) - Find.find(path) do |fileName| - yield(fileName) - next unless zipFilePattern.match(fileName) && File.file?(fileName) + def self.find(path, zip_file_pattern = /\.zip$/i) + Find.find(path) do |filename| + yield(filename) + next unless zip_file_pattern.match(filename) && File.file?(filename) + begin - Zip::File.foreach(fileName) do |zipEntry| - yield(fileName + File::SEPARATOR + zipEntry.to_s) + Zip::File.foreach(filename) do |entry| + yield(filename + File::SEPARATOR + entry.to_s) end - rescue Errno::EACCES => ex - puts ex + rescue Errno::EACCES => e + puts e end end end - def self.find_file(path, fileNamePattern, zipFilePattern = /\.zip$/i) - find(path, zipFilePattern) do |fileName| - yield(fileName) if fileNamePattern.match(fileName) + def self.find_file(path, filename_pattern, zip_file_pattern = /\.zip$/i) + find(path, zip_file_pattern) do |filename| + yield(filename) if filename_pattern.match(filename) end end end end -if $0 == __FILE__ +if $PROGRAM_NAME == __FILE__ module ZipFindConsoleRunner PATH_ARG_INDEX = 0 FILENAME_PATTERN_ARG_INDEX = 1 @@ -41,24 +43,24 @@ def self.run(args) check_args(args) Zip::ZipFind.find_file(args[PATH_ARG_INDEX], args[FILENAME_PATTERN_ARG_INDEX], - args[ZIPFILE_PATTERN_ARG_INDEX]) do |fileName| - report_entry_found fileName + args[ZIPFILE_PATTERN_ARG_INDEX]) do |filename| + report_entry_found filename end end def self.check_args(args) - if args.size != 3 - usage - exit - end + return if args.size == 3 + + usage + exit end def self.usage - puts "Usage: #{$0} PATH ZIPFILENAME_PATTERN FILNAME_PATTERN" + puts "Usage: #{$PROGRAM_NAME} PATH ZIPFILENAME_PATTERN FILNAME_PATTERN" end - def self.report_entry_found(fileName) - puts fileName + def self.report_entry_found(filename) + puts filename end end diff --git a/test/basic_zip_file_test.rb b/test/basic_zip_file_test.rb index 9e490b4a..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 @@ -5,56 +7,44 @@ class BasicZipFileTest < MiniTest::Test def setup @zip_file = ::Zip::File.new(TestZipFile::TEST_ZIP2.zip_name) - @testEntryNameIndex = 0 end def test_entries - assert_equal(TestZipFile::TEST_ZIP2.entry_names.sort, - @zip_file.entries.entries.sort.map { |e| e.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 - fileAndEntryName = @zip_file.entries.first.name - @zip_file.get_input_stream(fileAndEntryName) do |zis| - assert_entry_contents_for_stream(fileAndEntryName, - zis, - fileAndEntryName) + name = @zip_file.entries.first.name + @zip_file.get_input_stream(name) do |zis| + assert_entry_contents_for_stream(name, zis, name) end end end diff --git a/test/bzip2_support_test.rb b/test/bzip2_support_test.rb new file mode 100644 index 00000000..91c954a2 --- /dev/null +++ b/test/bzip2_support_test.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'test_helper' + +class Bzip2SupportTest < MiniTest::Test + BZIP2_ZIP_TEST_FILE = 'test/data/zipWithBzip2Compression.zip' + + def test_read + Zip::InputStream.open(BZIP2_ZIP_TEST_FILE) do |zis| + 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 4aab1667..9a4d84f4 100644 --- a/test/case_sensitivity_test.rb +++ b/test/case_sensitivity_test.rb @@ -1,69 +1,78 @@ +# 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 - zfRead = ::Zip::File.new(EMPTY_FILENAME) - assert_equal(SRC_FILES.size, zfRead.entries.length) - SRC_FILES.each_with_index { |a, i| - assert_equal(a.last, zfRead.entries[i].name) + zf_read = ::Zip::File.new(EMPTY_FILENAME) + assert_equal(SRC_FILES.size, zf_read.entries.length) + SRC_FILES.each_with_index do |a, i| + assert_equal(a.last, zf_read.entries[i].name) AssertEntry.assert_contents(a.first, - zfRead.get_input_stream(a.last) { |zis| zis.read }) - } + zf_read.get_input_stream(a.last, &:read)) + end end # Ensure that names are treated case insensitively when adding files and +case_insensitive_match = false+ 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 ::Zip.case_insensitive_match = true - zfRead = ::Zip::File.new(EMPTY_FILENAME) - assert_equal(SRC_FILES.collect { |_fn, en| en.downcase }.uniq.size, zfRead.entries.length) - assert_equal(SRC_FILES.last.last.downcase, zfRead.entries.first.name.downcase) - AssertEntry.assert_contents(SRC_FILES.last.first, - zfRead.get_input_stream(SRC_FILES.last.last) { |zis| zis.read }) + zf_read = ::Zip::File.new(EMPTY_FILENAME) + assert_equal(SRC_FILES.collect { |_fn, en| en.downcase }.uniq.size, zf_read.entries.length) + assert_equal(SRC_FILES.last.last.downcase, zf_read.entries.first.name.downcase) + AssertEntry.assert_contents( + SRC_FILES.last.first, zf_read.get_input_stream(SRC_FILES.last.last, &:read) + ) end private - def assert_contains(zf, entryName, filename = entryName) - assert(zf.entries.detect { |e| e.name == entryName } != nil, "entry #{entryName} not in #{zf.entries.join(', ')} in zip file #{zf}") - assert_entry_contents(zf, entryName, filename) if File.exist?(filename) + def assert_contains(zip_file, entry_name, filename = entry_name) + refute_nil( + zip_file.entries.detect { |e| e.name == entry_name }, + "entry #{entry_name} not in #{zip_file.entries.join(', ')} in zip file #{zip_file}" + ) + assert_entry_contents(zip_file, entry_name, filename) if File.exist?(filename) end end diff --git a/test/central_directory_entry_test.rb b/test/central_directory_entry_test.rb index fa0d8065..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) - fail '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 26be6424..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 @@ -6,87 +8,139 @@ def teardown end def test_read_from_stream - ::File.open(TestZipFile::TEST_ZIP2.zip_name, 'rb') do |zipFile| - cdir = ::Zip::CentralDirectory.read_from_stream(zipFile) + ::File.open(TestZipFile::TEST_ZIP2.zip_name, 'rb') do |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 |cdirEntry, testEntryName| - cdirEntry.name == testEntryName - end) + assert_equal(cdir.entries.map(&:name).sort, TestZipFile::TEST_ZIP2.entry_names.sort) assert_equal(TestZipFile::TEST_ZIP2.comment, cdir.comment) end end def test_read_from_invalid_stream - File.open('test/data/file2.txt', 'rb') do |zipFile| + File.open('test/data/file2.txt', 'rb') do |zip_file| cdir = ::Zip::CentralDirectory.new - cdir.read_from_stream(zipFile) + cdir.read_from_stream(zip_file) end - fail 'ZipError expected!' + raise 'ZipError expected!' 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) - fail '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') { |f| cdir.write_to_stream(f) } - cdirReadback = ::Zip::CentralDirectory.new - File.open('test/data/generated/cdirtest.bin', 'rb') { |f| cdirReadback.read_from_stream(f) } + File.open('test/data/generated/cdirtest.bin', 'wb') do |f| + cdir.write_to_stream(f) + end + + cdir_readback = ::Zip::CentralDirectory.new + File.open('test/data/generated/cdirtest.bin', 'rb') do |f| + cdir_readback.read_from_stream(f) + end - assert_equal(cdir.entries.sort, cdirReadback.entries.sort) + 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) + File.open('test/data/generated/cdir64test.bin', 'wb') do |f| + cdir.write_to_stream(f) + end + + cdir_readback = Zip::CentralDirectory.new + File.open('test/data/generated/cdir64test.bin', 'rb') do |f| + cdir_readback.read_from_stream(f) end - cdir = ::Zip::CentralDirectory.new(entries, 'zip comment') - File.open('test/data/generated/cdir64test.bin', 'wb') { |f| cdir.write_to_stream(f) } - cdirReadback = ::Zip::CentralDirectory.new - File.open('test/data/generated/cdir64test.bin', 'rb') { |f| cdirReadback.read_from_stream(f) } - assert_equal(cdir.entries.sort, cdirReadback.entries.sort) - assert_equal(::Zip::VERSION_NEEDED_TO_EXTRACT_ZIP64, cdirReadback.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 new file mode 100644 index 00000000..d31419f5 --- /dev/null +++ b/test/constants_test.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ConstantsTest < MiniTest::Test + def test_compression_methods + assert_equal(0, Zip::COMPRESSION_METHOD_STORE) + assert_equal(1, Zip::COMPRESSION_METHOD_SHRINK) + assert_equal(2, Zip::COMPRESSION_METHOD_REDUCE_1) + assert_equal(3, Zip::COMPRESSION_METHOD_REDUCE_2) + assert_equal(4, Zip::COMPRESSION_METHOD_REDUCE_3) + assert_equal(5, Zip::COMPRESSION_METHOD_REDUCE_4) + assert_equal(6, Zip::COMPRESSION_METHOD_IMPLODE) + assert_equal(8, Zip::COMPRESSION_METHOD_DEFLATE) + assert_equal(9, Zip::COMPRESSION_METHOD_DEFLATE_64) + assert_equal(10, Zip::COMPRESSION_METHOD_PKWARE_DCLI) + assert_equal(12, Zip::COMPRESSION_METHOD_BZIP2) + assert_equal(14, Zip::COMPRESSION_METHOD_LZMA) + assert_equal(16, Zip::COMPRESSION_METHOD_IBM_CMPSC) + assert_equal(18, Zip::COMPRESSION_METHOD_IBM_TERSE) + assert_equal(19, Zip::COMPRESSION_METHOD_IBM_LZ77) + assert_equal(96, Zip::COMPRESSION_METHOD_JPEG) + assert_equal(97, Zip::COMPRESSION_METHOD_WAVPACK) + assert_equal(98, Zip::COMPRESSION_METHOD_PPMD) + assert_equal(99, Zip::COMPRESSION_METHOD_AES) + + assert(Zip::COMPRESSION_METHODS[Zip::COMPRESSION_METHOD_STORE]) + assert(Zip::COMPRESSION_METHODS[Zip::COMPRESSION_METHOD_SHRINK]) + assert(Zip::COMPRESSION_METHODS[Zip::COMPRESSION_METHOD_REDUCE_1]) + assert(Zip::COMPRESSION_METHODS[Zip::COMPRESSION_METHOD_REDUCE_2]) + assert(Zip::COMPRESSION_METHODS[Zip::COMPRESSION_METHOD_REDUCE_3]) + assert(Zip::COMPRESSION_METHODS[Zip::COMPRESSION_METHOD_REDUCE_4]) + assert(Zip::COMPRESSION_METHODS[Zip::COMPRESSION_METHOD_IMPLODE]) + assert(Zip::COMPRESSION_METHODS[Zip::COMPRESSION_METHOD_DEFLATE]) + assert(Zip::COMPRESSION_METHODS[Zip::COMPRESSION_METHOD_DEFLATE_64]) + assert(Zip::COMPRESSION_METHODS[Zip::COMPRESSION_METHOD_PKWARE_DCLI]) + assert(Zip::COMPRESSION_METHODS[Zip::COMPRESSION_METHOD_BZIP2]) + assert(Zip::COMPRESSION_METHODS[Zip::COMPRESSION_METHOD_LZMA]) + assert(Zip::COMPRESSION_METHODS[Zip::COMPRESSION_METHOD_IBM_CMPSC]) + assert(Zip::COMPRESSION_METHODS[Zip::COMPRESSION_METHOD_IBM_TERSE]) + assert(Zip::COMPRESSION_METHODS[Zip::COMPRESSION_METHOD_IBM_LZ77]) + assert(Zip::COMPRESSION_METHODS[Zip::COMPRESSION_METHOD_JPEG]) + assert(Zip::COMPRESSION_METHODS[Zip::COMPRESSION_METHOD_WAVPACK]) + assert(Zip::COMPRESSION_METHODS[Zip::COMPRESSION_METHOD_PPMD]) + assert(Zip::COMPRESSION_METHODS[Zip::COMPRESSION_METHOD_AES]) + end +end 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/file1.txt.corrupt.deflatedData b/test/data/file1.txt.corrupt.deflatedData new file mode 100644 index 00000000..95fe8720 Binary files /dev/null and b/test/data/file1.txt.corrupt.deflatedData 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/zipWithBzip2Compression.zip b/test/data/zipWithBzip2Compression.zip new file mode 100644 index 00000000..1cd268b3 Binary files /dev/null and b/test/data/zipWithBzip2Compression.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/zipWithStoredCompression.zip b/test/data/zipWithStoredCompression.zip new file mode 100644 index 00000000..045ab9d4 Binary files /dev/null and b/test/data/zipWithStoredCompression.zip differ diff --git a/test/data/zipWithStoredCompressionAndEncryption.zip b/test/data/zipWithStoredCompressionAndEncryption.zip new file mode 100644 index 00000000..f2d9c163 Binary files /dev/null and b/test/data/zipWithStoredCompressionAndEncryption.zip differ diff --git a/test/decompressor_test.rb b/test/decompressor_test.rb new file mode 100644 index 00000000..9109a2e4 --- /dev/null +++ b/test/decompressor_test.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'test_helper' +class DecompressorTest < MiniTest::Test + TEST_COMPRESSION_METHOD = 255 + + class TestCompressionClass + end + + def test_decompressor_registration + assert_nil(::Zip::Decompressor.find_by_compression_method(TEST_COMPRESSION_METHOD)) + + ::Zip::Decompressor.register(TEST_COMPRESSION_METHOD, TestCompressionClass) + + assert_equal(TestCompressionClass, ::Zip::Decompressor.find_by_compression_method(TEST_COMPRESSION_METHOD)) + end +end diff --git a/test/deflater_test.rb b/test/deflater_test.rb index e4f552ef..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,11 +10,15 @@ 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) - inflatedTxt = inflate(DEFLATER_TEST_FILE) - assert_equal(txt, inflatedTxt) + inflated_txt = inflate(DEFLATER_TEST_FILE) + assert_equal(txt, inflated_txt) end def test_default_compression @@ -34,15 +40,20 @@ def test_default_compression assert(default < no) end + def test_data_error + assert_raises(::Zip::DecompressionError) do + inflate('test/data/file1.txt.corrupt.deflatedData') + end + end + private - def load_file(fileName) - txt = nil - File.open(fileName, 'rb') { |f| txt = f.read } + def load_file(filename) + File.binread(filename) end - def deflate(data, fileName) - File.open(fileName, 'wb') do |file| + def deflate(data, filename) + File.open(filename, 'wb') do |file| deflater = ::Zip::Deflater.new(file) deflater << data deflater.finish @@ -51,11 +62,10 @@ def deflate(data, fileName) end end - def inflate(fileName) - txt = nil - File.open(fileName, 'rb') do |file| + def inflate(filename) + File.open(filename, 'rb') do |file| inflater = ::Zip::Inflater.new(file) - txt = inflater.sysread + inflater.read end end 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 46770a17..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 = 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 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 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 6501ab86..c5b27b73 100644 --- a/test/entry_set_test.rb +++ b/test/entry_set_test.rb @@ -1,17 +1,19 @@ +# 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 - @zipEntrySet = ::Zip::EntrySet.new(ZIP_ENTRIES) + @zip_entry_set = ::Zip::EntrySet.new(ZIP_ENTRIES) end def teardown @@ -19,15 +21,19 @@ def teardown end def test_include - assert(@zipEntrySet.include?(ZIP_ENTRIES.first)) - assert(!@zipEntrySet.include?(::Zip::Entry.new('different.zip', 'different', 'aComment'))) + assert(@zip_entry_set.include?(ZIP_ENTRIES.first)) + assert( + !@zip_entry_set.include?( + ::Zip::Entry.new('different.zip', 'different', comment: 'aComment') + ) + ) end def test_size - assert_equal(ZIP_ENTRIES.size, @zipEntrySet.size) - assert_equal(ZIP_ENTRIES.size, @zipEntrySet.length) - @zipEntrySet << ::Zip::Entry.new('a', 'b', 'c') - assert_equal(ZIP_ENTRIES.size + 1, @zipEntrySet.length) + 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', comment: 'c') + assert_equal(ZIP_ENTRIES.size + 1, @zip_entry_set.length) end def test_add @@ -41,78 +47,98 @@ def test_add end def test_delete - assert_equal(ZIP_ENTRIES.size, @zipEntrySet.size) - entry = @zipEntrySet.delete(ZIP_ENTRIES.first) - assert_equal(ZIP_ENTRIES.size - 1, @zipEntrySet.size) + assert_equal(ZIP_ENTRIES.size, @zip_entry_set.size) + entry = @zip_entry_set.delete(ZIP_ENTRIES.first) + assert_equal(ZIP_ENTRIES.size - 1, @zip_entry_set.size) assert_equal(ZIP_ENTRIES.first, entry) - entry = @zipEntrySet.delete(ZIP_ENTRIES.first) - assert_equal(ZIP_ENTRIES.size - 1, @zipEntrySet.size) + entry = @zip_entry_set.delete(ZIP_ENTRIES.first) + assert_equal(ZIP_ENTRIES.size - 1, @zip_entry_set.size) assert_nil(entry) end def test_each # Used each instead each_with_index due the bug in jRuby count = 0 - @zipEntrySet.each do |entry| + 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 - assert_equal(ZIP_ENTRIES, @zipEntrySet.entries) + assert_equal(ZIP_ENTRIES, @zip_entry_set.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 - zipEntrySet = ::Zip::EntrySet.new(entries) - assert_equal(entries[0], zipEntrySet.find_entry('MiXeDcAsEnAmE')) - assert_equal(entries[0], zipEntrySet.find_entry('mixedcasename')) + zip_entry_set = ::Zip::EntrySet.new(entries) + assert_equal(entries[0], zip_entry_set.find_entry('MiXeDcAsEnAmE')) + assert_equal(entries[0], zip_entry_set.find_entry('mixedcasename')) ::Zip.case_insensitive_match = false - zipEntrySet = ::Zip::EntrySet.new(entries) - assert_equal(entries[0], zipEntrySet.find_entry('MiXeDcAsEnAmE')) - assert_nil(zipEntrySet.find_entry('mixedcasename')) + zip_entry_set = ::Zip::EntrySet.new(entries) + assert_equal(entries[0], zip_entry_set.find_entry('MiXeDcAsEnAmE')) + assert_nil(zip_entry_set.find_entry('mixedcasename')) end def test_entries_with_sort ::Zip.sort_entries = true - assert_equal(ZIP_ENTRIES.sort, @zipEntrySet.entries) + assert_equal(ZIP_ENTRIES.sort, @zip_entry_set.entries) ::Zip.sort_entries = false - assert_equal(ZIP_ENTRIES, @zipEntrySet.entries) + assert_equal(ZIP_ENTRIES, @zip_entry_set.entries) end def test_entries_sorted_in_each ::Zip.sort_entries = true arr = [] - @zipEntrySet.each do |entry| + @zip_entry_set.each do |entry| 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 - newEntry = ::Zip::Entry.new('zf.zip', 'new entry', "new entry's comment") - assert_equal(ZIP_ENTRIES.size, @zipEntrySet.size) - @zipEntrySet << newEntry - assert_equal(ZIP_ENTRIES.size + 1, @zipEntrySet.size) - assert(@zipEntrySet.include?(newEntry)) + 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) + assert(@zip_entry_set.include?(new_entry)) - @zipEntrySet.delete(newEntry) - assert_equal(ZIP_ENTRIES.size, @zipEntrySet.size) + @zip_entry_set.delete(new_entry) + assert_equal(ZIP_ENTRIES.size, @zip_entry_set.size) end def test_dup - copy = @zipEntrySet.dup - assert_equal(@zipEntrySet, copy) + copy = @zip_entry_set.dup + assert_equal(@zip_entry_set, copy) # demonstrate that this is a deep copy copy.entries[0].name = 'a totally different name' - assert(@zipEntrySet != copy) + assert(@zip_entry_set != copy) end def test_parent @@ -121,15 +147,15 @@ def test_parent ::Zip::Entry.new('zf.zip', 'a/b/'), ::Zip::Entry.new('zf.zip', 'a/b/c/') ] - entrySet = ::Zip::EntrySet.new(entries) + entry_set = ::Zip::EntrySet.new(entries) - assert_nil(entrySet.parent(entries[0])) - assert_equal(entries[0], entrySet.parent(entries[1])) - assert_equal(entries[1], entrySet.parent(entries[2])) + assert_nil(entry_set.parent(entries[0])) + assert_equal(entries[0], entry_set.parent(entries[1])) + assert_equal(entries[1], entry_set.parent(entries[2])) end def test_glob - res = @zipEntrySet.glob('name[2-4]') + res = @zip_entry_set.glob('name[2-4]') assert_equal(3, res.size) assert_equal(ZIP_ENTRIES[1, 3].sort, res.sort) end @@ -141,13 +167,13 @@ def test_glob2 ::Zip::Entry.new('zf.zip', 'a/b/c/'), ::Zip::Entry.new('zf.zip', 'a/b/c/c1') ] - entrySet = ::Zip::EntrySet.new(entries) + entry_set = ::Zip::EntrySet.new(entries) - assert_equal(entries[0, 1], entrySet.glob('*')) - # assert_equal(entries[FIXME], entrySet.glob("**")) - # res = entrySet.glob('a*') + assert_equal(entries[0, 1], entry_set.glob('*')) + # assert_equal(entries[FIXME], entry_set.glob("**")) + # res = entry_set.glob('a*') # assert_equal(entries.size, res.size) - # assert_equal(entrySet.map { |e| e.name }, res.map { |e| e.name }) + # assert_equal(entry_set.map { |e| e.name }, res.map { |e| e.name }) end def test_glob3 @@ -156,8 +182,8 @@ def test_glob3 ::Zip::Entry.new('zf.zip', 'a/b'), ::Zip::Entry.new('zf.zip', 'a/c') ] - entrySet = ::Zip::EntrySet.new(entries) + entry_set = ::Zip::EntrySet.new(entries) - assert_equal(entries[0, 2].sort, entrySet.glob('a/{a,b}').sort) + assert_equal(entries[0, 2].sort, entry_set.glob('a/{a,b}').sort) end end diff --git a/test/entry_test.rb b/test/entry_test.rb index b49783d3..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,37 +150,202 @@ 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_entry_name_cannot_be_too_long + name = 'a' * 65_535 + ::Zip::Entry.new('', name) # Should not raise anything. + + error = assert_raises(::Zip::EntryNameError) do + ::Zip::Entry.new('', "a#{name}") + end + assert_match(/65,536/, 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', '**', '**')] + Dir.mktmpdir do |tmp| + tmp_zip = File.join(tmp, 'no_compress.zip') + + Zip.setup do |z| + z.write_zip64_support = false + end + + 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 - Zip.setup do |z| - z.write_zip64_support = false + zipfile.close + + f = File.open(tmp_zip, 'rb') + first_100_bytes = f.read(100) + f.close + + assert_match(/mimetypeapplication\/epub\+zip/, first_100_bytes) end + 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_encrypted? + entry = Zip::Entry.new + entry.gp_flags = 1 + assert_equal(true, entry.encrypted?) - zipfile.add(mimetype_entry, 'test/data/mimetype') + entry.gp_flags = 0 + assert_equal(false, entry.encrypted?) + end + + def test_incomplete? + entry = Zip::Entry.new + entry.gp_flags = 8 + assert_equal(true, entry.incomplete?) - files.each do |file| - zipfile.add(file.sub('test/data/globTest/', ''), file) + 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 - zipfile.close - f = File.open('/tmp/no_compress.zip', 'rb') - first_100_bytes = f.read(100) - f.close + # 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?) - assert_match(/mimetypeapplication\/epub\+zip/, first_100_bytes) + 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 2c6adb2f..00000000 --- a/test/errors_test.rb +++ /dev/null @@ -1,35 +0,0 @@ -# encoding: utf-8 - -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 ad2ab7a6..9f16b616 100644 --- a/test/extra_field_ut_test.rb +++ b/test/extra_field_ut_test.rb @@ -1,7 +1,8 @@ +# frozen_string_literal: true + require 'test_helper' class ZipExtraFieldUTTest < MiniTest::Test - PARSE_TESTS = [ ["UT\x05\x00\x01PS>A", 0b001, true, true, false], ["UT\x05\x00\x02PS>A", 0b010, false, true, true], @@ -10,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 f14f7870..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 @@ -5,14 +7,14 @@ class ZipFileExtractDirectoryTest < MiniTest::Test TEST_OUT_NAME = 'test/data/generated/emptyOutDir' - def open_zip(&aProc) - assert(!aProc.nil?) - ::Zip::File.open(TestZipFile::TEST_ZIP4.zip_name, &aProc) + def open_zip(&a_proc) + assert(!a_proc.nil?) + ::Zip::File.open(TestZipFile::TEST_ZIP4.zip_name, &a_proc) end - def extract_test_dir(&aProc) + def extract_test_dir(&a_proc) open_zip do |zf| - zf.extract(TestFiles::EMPTY_TEST_DIR, TEST_OUT_NAME, &aProc) + zf.extract(TestFiles::EMPTY_TEST_DIR, TEST_OUT_NAME, &a_proc) end end @@ -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,19 +38,21 @@ 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 File.open(TEST_OUT_NAME, 'w') { |f| f.puts 'something' } - gotCalled = false - extract_test_dir do |entry, destPath| - gotCalled = true - assert_equal(TEST_OUT_NAME, destPath) + called = false + extract_test_dir do |entry, dest_path| + called = true + assert_equal(File.absolute_path(TEST_OUT_NAME), dest_path) assert(entry.directory?) true end - assert(gotCalled) + assert(called) assert(File.directory?(TEST_OUT_NAME)) end end diff --git a/test/file_extract_test.rb b/test/file_extract_test.rb index a494f781..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 @@ -20,7 +23,7 @@ def test_extract assert(File.exist?(EXTRACTED_FILENAME)) AssertEntry.assert_contents(EXTRACTED_FILENAME, - zf.get_input_stream(ENTRY_TO_EXTRACT) { |is| is.read }) + zf.get_input_stream(ENTRY_TO_EXTRACT, &:read)) ::File.unlink(EXTRACTED_FILENAME) @@ -29,40 +32,41 @@ def test_extract assert(File.exist?(EXTRACTED_FILENAME)) AssertEntry.assert_contents(EXTRACTED_FILENAME, - entry.get_input_stream { |is| is.read }) + entry.get_input_stream(&:read)) end end def test_extract_exists - writtenText = 'written text' - ::File.open(EXTRACTED_FILENAME, 'w') { |f| f.write(writtenText) } + text = 'written 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(writtenText, f.read) + assert_equal(text, f.read) end end def test_extract_exists_overwrite - writtenText = 'written text' - ::File.open(EXTRACTED_FILENAME, 'w') { |f| f.write(writtenText) } + text = 'written text' + ::File.write(EXTRACTED_FILENAME, text) - gotCalledCorrectly = false + called_correctly = false ::Zip::File.open(TEST_ZIP.zip_name) do |zf| - zf.extract(zf.entries.first, EXTRACTED_FILENAME) do |entry, extractLoc| - gotCalledCorrectly = zf.entries.first == entry && - extractLoc == EXTRACTED_FILENAME + zf.extract(zf.entries.first, EXTRACTED_FILENAME) do |entry, extract_loc| + called_correctly = zf.entries.first == entry && + extract_loc == EXTRACTED_FILENAME_ABS true end end - assert(gotCalledCorrectly) + assert(called_correctly) ::File.open(EXTRACTED_FILENAME, 'r') do |f| - assert(writtenText != f.read) + assert(text != f.read) end end @@ -73,19 +77,21 @@ def test_extract_non_entry zf.close if zf end - def test_extract_non_entry_2 - outFile = 'outfile' + def test_extract_another_non_entry + out_file = 'outfile' assert_raises(Errno::ENOENT) do zf = ::Zip::File.new(TEST_ZIP.zip_name) - nonEntry = 'hotdog-diddelidoo' - assert(!zf.entries.include?(nonEntry)) - zf.extract(nonEntry, outFile) + non_entry = 'hotdog-diddelidoo' + assert(!zf.entries.include?(non_entry)) + zf.extract(non_entry, out_file) zf.close end - assert(!File.exist?(outFile)) + assert(!File.exist?(out_file)) 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 - srcFile = 'test/data/file2.txt' - entryName = 'newEntryName.rb' - assert(::File.exist?(srcFile)) - zf = ::Zip::File.new(EMPTY_FILENAME, ::Zip::File::CREATE) - zf.add_stored(entryName, srcFile) + src_file = 'test/data/file2.txt' + entry_name = 'newEntryName.rb' + assert(::File.exist?(src_file)) + zf = ::Zip::File.new(EMPTY_FILENAME, create: true) + zf.add_stored(entry_name, src_file) zf.close - zfRead = ::Zip::File.new(EMPTY_FILENAME) - entry = zfRead.entries.first - assert_equal('', zfRead.comment) - assert_equal(1, zfRead.entries.length) - assert_equal(entryName, entry.name) - assert_equal(File.size(srcFile), entry.size) + zf_read = ::Zip::File.new(EMPTY_FILENAME) + entry = zf_read.entries.first + assert_equal('', zf_read.comment) + assert_equal(1, zf_read.entries.length) + assert_equal(entry_name, entry.name) + assert_equal(File.size(src_file), entry.size) assert_equal(entry.size, entry.compressed_size) assert_equal(::Zip::Entry::STORED, entry.compression_method) - AssertEntry.assert_contents(srcFile, - zfRead.get_input_stream(entryName) { |zis| zis.read }) + 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 - srcZip = TEST_ZIP.zip_name - ::File.chmod(0o664, srcZip) - srcFile = 'test/data/file2.txt' - entryName = 'newEntryName.rb' - assert_equal(::File.stat(srcZip).mode, 0o100664) - assert(::File.exist?(srcZip)) - zf = ::Zip::File.new(srcZip, ::Zip::File::CREATE) - zf.add(entryName, srcFile) + # Windows NT does not support granular permissions + skip if Zip::RUNNING_ON_WINDOWS + + src_zip = TEST_ZIP.zip_name + assert(::File.exist?(src_zip)) + + ::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(srcZip).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 - gotCalled = false - replacedEntry = nil + called = false + replaced_entry = nil ::Zip::File.open(TEST_ZIP.zip_name) do |zf| - replacedEntry = zf.entries.first.name - zf.add(replacedEntry, 'test/data/file2.txt') { gotCalled = true; true } + replaced_entry = zf.entries.first.name + zf.add(replaced_entry, 'test/data/file2.txt') do + called = true + true + end end - assert(gotCalled) + assert(called) ::Zip::File.open(TEST_ZIP.zip_name) do |zf| - assert_contains(zf, replacedEntry, 'test/data/file2.txt') + assert_contains(zf, replaced_entry, 'test/data/file2.txt') end end @@ -262,51 +420,95 @@ def test_add_directory ::Zip::File.open(TEST_ZIP.zip_name) do |zf| zf.add(TestFiles::EMPTY_TEST_DIR, TestFiles::EMPTY_TEST_DIR) end + ::Zip::File.open(TEST_ZIP.zip_name) do |zf| - dirEntry = zf.entries.detect { |e| e.name == TestFiles::EMPTY_TEST_DIR + '/' } - assert(dirEntry.directory?) + dir_entry = zf.entries.detect do |e| + 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 def test_remove - entryToRemove, *remainingEntries = TEST_ZIP.entry_names + entry, *remaining = TEST_ZIP.entry_names FileUtils.cp(TestZipFile::TEST_ZIP2.zip_name, TEST_ZIP.zip_name) zf = ::Zip::File.new(TEST_ZIP.zip_name) - assert(zf.entries.map { |e| e.name }.include?(entryToRemove)) - zf.remove(entryToRemove) - assert(!zf.entries.map { |e| e.name }.include?(entryToRemove)) - assert_equal(zf.entries.map { |x| x.name }.sort, remainingEntries.sort) + assert(zf.entries.map(&:name).include?(entry)) + zf.remove(entry) + assert(!zf.entries.map(&:name).include?(entry)) + assert_equal(zf.entries.map(&:name).sort, remaining.sort) zf.close - zfRead = ::Zip::File.new(TEST_ZIP.zip_name) - assert(!zfRead.entries.map { |e| e.name }.include?(entryToRemove)) - assert_equal(zfRead.entries.map { |x| x.name }.sort, remainingEntries.sort) - zfRead.close + zf_read = ::Zip::File.new(TEST_ZIP.zip_name) + assert(!zf_read.entries.map(&:name).include?(entry)) + assert_equal(zf_read.entries.map(&:name).sort, remaining.sort) + zf_read.close end def test_rename - entryToRename, * = TEST_ZIP.entry_names + entry, * = TEST_ZIP.entry_names zf = ::Zip::File.new(TEST_ZIP.zip_name) - assert(zf.entries.map { |e| e.name }.include?(entryToRename)) + assert(zf.entries.map(&:name).include?(entry)) - contents = zf.read(entryToRename) - newName = 'changed entry name' - assert(!zf.entries.map { |e| e.name }.include?(newName)) + contents = zf.read(entry) + new_name = 'changed entry name' + assert(!zf.entries.map(&:name).include?(new_name)) - zf.rename(entryToRename, newName) - assert(zf.entries.map { |e| e.name }.include?(newName)) + zf.rename(entry, new_name) + assert(zf.entries.map(&:name).include?(new_name)) - assert_equal(contents, zf.read(newName)) + assert_equal(contents, zf.read(new_name)) zf.close - zfRead = ::Zip::File.new(TEST_ZIP.zip_name) - assert(zfRead.entries.map { |e| e.name }.include?(newName)) - assert_equal(contents, zfRead.read(newName)) - zfRead.close + zf_read = ::Zip::File.new(TEST_ZIP.zip_name) + assert(zf_read.entries.map(&:name).include?(new_name)) + assert_equal(contents, zf_read.read(new_name)) + zf_read.close end def test_rename_with_each @@ -314,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/' @@ -327,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 @@ -339,45 +541,49 @@ def test_rename_with_each end def test_rename_to_existing_entry - oldEntries = nil - ::Zip::File.open(TEST_ZIP.zip_name) { |zf| oldEntries = zf.entries } + 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(oldEntries.sort.map { |e| e.name }, zf.entries.sort.map { |e| e.name }) + assert_equal(old_entries.sort.map(&:name), zf.entries.sort.map(&:name)) end end def test_rename_to_existing_entry_overwrite - oldEntries = nil - ::Zip::File.open(TEST_ZIP.zip_name) { |zf| oldEntries = zf.entries } + old_entries = nil + ::Zip::File.open(TEST_ZIP.zip_name) { |zf| old_entries = zf.entries } - gotCalled = false - renamedEntryName = nil + called = false + new_entry_name = nil ::Zip::File.open(TEST_ZIP.zip_name) do |zf| - renamedEntryName = zf.entries[0].name - zf.rename(zf.entries[0], zf.entries[1].name) { gotCalled = true; true } + new_entry_name = zf.entries[0].name + zf.rename(zf.entries[0], zf.entries[1].name) do + called = true + true + end end - assert(gotCalled) - oldEntries.delete_if { |e| e.name == renamedEntryName } + assert(called) + old_entries.delete_if { |e| e.name == new_entry_name } ::Zip::File.open(TEST_ZIP.zip_name) do |zf| - assert_equal(oldEntries.sort.map { |e| e.name }, - zf.entries.sort.map { |e| e.name }) + assert_equal(old_entries.sort.map(&:name), + zf.entries.sort.map(&:name)) end end def test_rename_non_entry - nonEntry = 'bogusEntry' + non_entry = 'bogusEntry' target_entry = 'target_entryName' zf = ::Zip::File.new(TEST_ZIP.zip_name) - assert(!zf.entries.include?(nonEntry)) - assert_raises(Errno::ENOENT) { zf.rename(nonEntry, target_entry) } + assert(!zf.entries.include?(non_entry)) + assert_raises(Errno::ENOENT) { zf.rename(non_entry, target_entry) } zf.commit assert(!zf.entries.include?(target_entry)) ensure @@ -387,87 +593,111 @@ 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 def test_replace - entryToReplace = TEST_ZIP.entry_names[2] - newEntrySrcFilename = 'test/data/file2.txt' + replace_entry = TEST_ZIP.entry_names[2] + replace_src = 'test/data/file2.txt' zf = ::Zip::File.new(TEST_ZIP.zip_name) - zf.replace(entryToReplace, newEntrySrcFilename) + zf.replace(replace_entry, replace_src) zf.close - zfRead = ::Zip::File.new(TEST_ZIP.zip_name) - AssertEntry.assert_contents(newEntrySrcFilename, - zfRead.get_input_stream(entryToReplace) { |is| is.read }) - AssertEntry.assert_contents(TEST_ZIP.entry_names[0], - zfRead.get_input_stream(TEST_ZIP.entry_names[0]) { |is| is.read }) - AssertEntry.assert_contents(TEST_ZIP.entry_names[1], - zfRead.get_input_stream(TEST_ZIP.entry_names[1]) { |is| is.read }) - AssertEntry.assert_contents(TEST_ZIP.entry_names[3], - zfRead.get_input_stream(TEST_ZIP.entry_names[3]) { |is| is.read }) - zfRead.close + zf_read = ::Zip::File.new(TEST_ZIP.zip_name) + AssertEntry.assert_contents( + replace_src, + zf_read.get_input_stream(replace_entry, &:read) + ) + AssertEntry.assert_contents( + TEST_ZIP.entry_names[0], + zf_read.get_input_stream(TEST_ZIP.entry_names[0], &:read) + ) + AssertEntry.assert_contents( + TEST_ZIP.entry_names[1], + zf_read.get_input_stream(TEST_ZIP.entry_names[1], &:read) + ) + AssertEntry.assert_contents( + TEST_ZIP.entry_names[3], + zf_read.get_input_stream(TEST_ZIP.entry_names[3], &:read) + ) + zf_read.close end def test_replace_non_entry - entryToReplace = 'nonExistingEntryname' + replace_entry = 'nonExistingEntryname' ::Zip::File.open(TEST_ZIP.zip_name) do |zf| - assert_raises(Errno::ENOENT) { zf.replace(entryToReplace, 'test/data/file2.txt') } + assert_raises(Errno::ENOENT) do + zf.replace(replace_entry, 'test/data/file2.txt') + end end end def test_commit - newName = 'renamedFirst' + new_name = 'renamedFirst' zf = ::Zip::File.new(TEST_ZIP.zip_name) - oldName = zf.entries.first - zf.rename(oldName, newName) + old_name = zf.entries.first + zf.rename(old_name, new_name) zf.commit - zfRead = ::Zip::File.new(TEST_ZIP.zip_name) - assert(zfRead.entries.detect { |e| e.name == newName } != nil) - assert(zfRead.entries.detect { |e| e.name == oldName }.nil?) - zfRead.close + 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 }) + zf_read.close zf.close res = system("unzip -tqq #{TEST_ZIP.zip_name}") 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) - assert(zf2.entries.detect { |e| e.name == 'test1.txt' } != nil) - assert(zf2.entries.detect { |e| e.name == 'test2.txt' } != nil) + refute_nil(zf2.entries.detect { |e| e.name == 'test1.txt' }) + refute_nil(zf2.entries.detect { |e| e.name == 'test2.txt' }) res = system("unzip -tqq #{filename}") assert_equal(res, true) end def test_double_commit_zip64 - ::Zip.write_zip64_support = true test_double_commit('test/data/generated/double_commit_test64.zip') end def test_write_buffer - newName = 'renamedFirst' + new_name = 'renamedFirst' zf = ::Zip::File.new(TEST_ZIP.zip_name) - oldName = zf.entries.first - zf.rename(oldName, newName) - io = ::StringIO.new('') - buffer = zf.write_buffer(io) - File.open(TEST_ZIP.zip_name, 'wb') { |f| f.write buffer.string } - zfRead = ::Zip::File.new(TEST_ZIP.zip_name) - assert(zfRead.entries.detect { |e| e.name == newName } != nil) - assert(zfRead.entries.detect { |e| e.name == oldName }.nil?) - zfRead.close + old_name = zf.entries.first + zf.rename(old_name, new_name) + 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 }) + zf_read.close zf.close end @@ -494,52 +724,58 @@ def test_commit_use_zip_entry # end def test_compound1 - renamedName = 'renamedName' + renamed_name = 'renamed_name' filename_to_remove = '' + begin zf = ::Zip::File.new(TEST_ZIP.zip_name) - originalEntries = zf.entries.dup + orig_entries = zf.entries.dup assert_not_contains(zf, TestFiles::RANDOM_ASCII_FILE1) zf.add(TestFiles::RANDOM_ASCII_FILE1, TestFiles::RANDOM_ASCII_FILE1) assert_contains(zf, TestFiles::RANDOM_ASCII_FILE1) - entry_to_rename = zf.entries.find { |entry| entry.name.match('longAscii') } - zf.rename(entry_to_rename, renamedName) - assert_contains(zf, renamedName) + entry_to_rename = zf.entries.find do |entry| + entry.name.match('longAscii') + end + zf.rename(entry_to_rename, renamed_name) + assert_contains(zf, renamed_name) TestFiles::BINARY_TEST_FILES.each do |filename| zf.add(filename, filename) assert_contains(zf, filename) end - assert_contains(zf, originalEntries.last.to_s) - filename_to_remove = originalEntries.map(&:to_s).find { |name| name.match('longBinary') } + assert_contains(zf, orig_entries.last.to_s) + filename_to_remove = orig_entries.map(&:to_s).find do |name| + name.match('longBinary') + end zf.remove(filename_to_remove) assert_not_contains(zf, filename_to_remove) ensure zf.close end + begin - zfRead = ::Zip::File.new(TEST_ZIP.zip_name) - assert_contains(zfRead, TestFiles::RANDOM_ASCII_FILE1) - assert_contains(zfRead, renamedName) + zf_read = ::Zip::File.new(TEST_ZIP.zip_name) + assert_contains(zf_read, TestFiles::RANDOM_ASCII_FILE1) + assert_contains(zf_read, renamed_name) TestFiles::BINARY_TEST_FILES.each do |filename| - assert_contains(zfRead, filename) + assert_contains(zf_read, filename) end - assert_not_contains(zfRead, filename_to_remove) + assert_not_contains(zf_read, filename_to_remove) ensure - zfRead.close + zf_read.close end end def test_compound2 begin zf = ::Zip::File.new(TEST_ZIP.zip_name) - originalEntries = zf.entries.dup + orig_entries = zf.entries.dup - originalEntries.each do |entry| + orig_entries.each do |entry| zf.remove(entry) assert_not_contains(zf, entry) end @@ -549,25 +785,25 @@ def test_compound2 zf.add(filename, filename) assert_contains(zf, filename) end - assert_equal(zf.entries.sort.map { |e| e.name }, TestFiles::ASCII_TEST_FILES) + assert_equal(zf.entries.sort.map(&:name), TestFiles::ASCII_TEST_FILES) - zf.rename(TestFiles::ASCII_TEST_FILES[0], 'newName') + zf.rename(TestFiles::ASCII_TEST_FILES[0], 'new_name') assert_not_contains(zf, TestFiles::ASCII_TEST_FILES[0]) - assert_contains(zf, 'newName') + assert_contains(zf, 'new_name') ensure zf.close end begin - zfRead = ::Zip::File.new(TEST_ZIP.zip_name) - asciiTestFiles = TestFiles::ASCII_TEST_FILES.dup - asciiTestFiles.shift - asciiTestFiles.each do |filename| + zf_read = ::Zip::File.new(TEST_ZIP.zip_name) + ascii_files = TestFiles::ASCII_TEST_FILES.dup + ascii_files.shift + ascii_files.each do |filename| assert_contains(zf, filename) end - assert_contains(zf, 'newName') + assert_contains(zf, 'new_name') ensure - zfRead.close + zf_read.close end end @@ -575,38 +811,38 @@ def test_change_comment ::Zip::File.open(TEST_ZIP.zip_name) do |zf| zf.comment = 'my changed comment' end - zfRead = ::Zip::File.open(TEST_ZIP.zip_name) - assert_equal('my changed comment', zfRead.comment) + zf_read = ::Zip::File.open(TEST_ZIP.zip_name) + assert_equal('my changed comment', zf_read.comment) end def test_preserve_file_order - entryNames = nil + entry_names = nil ::Zip::File.open(TEST_ZIP.zip_name) do |zf| - entryNames = zf.entries.map { |e| e.to_s } + entry_names = zf.entries.map(&:to_s) zf.get_output_stream('a.txt') { |os| os.write 'this is a.txt' } zf.get_output_stream('z.txt') { |os| os.write 'this is z.txt' } zf.get_output_stream('k.txt') { |os| os.write 'this is k.txt' } - entryNames << 'a.txt' << 'z.txt' << 'k.txt' + entry_names << 'a.txt' << 'z.txt' << 'k.txt' end ::Zip::File.open(TEST_ZIP.zip_name) do |zf| - assert_equal(entryNames, zf.entries.map { |e| e.to_s }) - entries = zf.entries.sort_by { |e| e.name }.reverse + assert_equal(entry_names, zf.entries.map(&:to_s)) + entries = zf.entries.sort_by(&:name).reverse entries.each do |e| zf.remove e zf.get_output_stream(e) { |os| os.write 'foo' } end - entryNames = entries.map { |e| e.to_s } + entry_names = entries.map(&:to_s) end ::Zip::File.open(TEST_ZIP.zip_name) do |zf| - assert_equal(entryNames, zf.entries.map { |e| e.to_s }) + assert_equal(entry_names, zf.entries.map(&:to_s)) end 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 @@ -616,13 +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 @@ -670,12 +907,18 @@ def test_find_get_entry private - def assert_contains(zf, entryName, filename = entryName) - assert(zf.entries.detect { |e| e.name == entryName } != nil, "entry #{entryName} not in #{zf.entries.join(', ')} in zip file #{zf}") - assert_entry_contents(zf, entryName, filename) if File.exist?(filename) + def assert_contains(zip_file, entry_name, filename = entry_name) + refute_nil( + zip_file.entries.detect { |e| e.name == entry_name }, + "entry #{entry_name} not in #{zip_file.entries.join(', ')} in zip file #{zip_file}" + ) + assert_entry_contents(zip_file, entry_name, filename) if File.exist?(filename) end - def assert_not_contains(zf, entryName) - assert(zf.entries.detect { |e| e.name == entryName }.nil?, "entry #{entryName} in #{zf.entries.join(', ')} in zip file #{zf}") + def assert_not_contains(zip_file, entry_name) + assert_nil( + zip_file.entries.detect { |e| e.name == entry_name }, + "entry #{entry_name} in #{zip_file.entries.join(', ')} in zip file #{zip_file}" + ) end end diff --git a/test/filesystem/dir_iterator_test.rb b/test/filesystem/dir_iterator_test.rb deleted file mode 100644 index 8d12ce27..00000000 --- a/test/filesystem/dir_iterator_test.rb +++ /dev/null @@ -1,58 +0,0 @@ -require 'test_helper' -require 'zip/filesystem' - -class ZipFsDirIteratorTest < MiniTest::Test - FILENAME_ARRAY = %w[f1 f2 f3 f4 f5 f6] - - def setup - @dirIt = ::Zip::FileSystem::ZipFsDirIterator.new(FILENAME_ARRAY) - end - - def test_close - @dirIt.close - assert_raises(IOError, 'closed directory') do - @dirIt.each { |e| p e } - end - assert_raises(IOError, 'closed directory') do - @dirIt.read - end - assert_raises(IOError, 'closed directory') do - @dirIt.rewind - end - assert_raises(IOError, 'closed directory') do - @dirIt.seek(0) - end - assert_raises(IOError, 'closed directory') do - @dirIt.tell - end - end - - def test_each - # Tested through Enumerable.entries - assert_equal(FILENAME_ARRAY, @dirIt.entries) - end - - def test_read - FILENAME_ARRAY.size.times do |i| - assert_equal(FILENAME_ARRAY[i], @dirIt.read) - end - end - - def test_rewind - @dirIt.read - @dirIt.read - assert_equal(FILENAME_ARRAY[2], @dirIt.read) - @dirIt.rewind - assert_equal(FILENAME_ARRAY[0], @dirIt.read) - end - - def test_tell_seek - @dirIt.read - @dirIt.read - pos = @dirIt.tell - valAtPos = @dirIt.read - @dirIt.read - @dirIt.seek(pos) - assert_equal(valAtPos, @dirIt.read) - end -end diff --git a/test/filesystem/directory_test.rb b/test/filesystem/dir_test.rb similarity index 92% rename from test/filesystem/directory_test.rb rename to test/filesystem/dir_test.rb index f36ede53..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' @@ -65,16 +67,16 @@ def test_pwd_chdir_entries def test_foreach ::Zip::File.open(TEST_ZIP) do |zf| - blockCalled = false + block_called = false assert_raises(Errno::ENOENT, 'No such file or directory - noSuchDir') do - zf.dir.foreach('noSuchDir') { |_e| blockCalled = true } + zf.dir.foreach('noSuchDir') { |_e| block_called = true } end - assert(!blockCalled) + assert(!block_called) assert_raises(Errno::ENOTDIR, 'Not a directory - file1') do - zf.dir.foreach('file1') { |_e| blockCalled = true } + zf.dir.foreach('file1') { |_e| block_called = true } end - assert(!blockCalled) + assert(!block_called) entries = [] zf.dir.foreach('.') { |e| entries << e } diff --git a/test/filesystem/directory_iterator_test.rb b/test/filesystem/directory_iterator_test.rb new file mode 100644 index 00000000..ba809dfd --- /dev/null +++ b/test/filesystem/directory_iterator_test.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'zip/filesystem' + +class DirectoryIteratorTest < MiniTest::Test + FILENAME_ARRAY = %w[f1 f2 f3 f4 f5 f6].freeze + + def setup + @dir_iter = ::Zip::FileSystem::DirectoryIterator.new(FILENAME_ARRAY) + end + + def test_close + @dir_iter.close + assert_raises(IOError, 'closed directory') do + @dir_iter.each { |e| p e } + end + assert_raises(IOError, 'closed directory') do + @dir_iter.read + end + assert_raises(IOError, 'closed directory') do + @dir_iter.rewind + end + assert_raises(IOError, 'closed directory') do + @dir_iter.seek(0) + end + assert_raises(IOError, 'closed directory') do + @dir_iter.tell + end + end + + def test_each + # Tested through Enumerable.entries + assert_equal(FILENAME_ARRAY, @dir_iter.entries) + end + + def test_read + FILENAME_ARRAY.size.times do |i| + assert_equal(FILENAME_ARRAY[i], @dir_iter.read) + end + end + + def test_rewind + @dir_iter.read + @dir_iter.read + assert_equal(FILENAME_ARRAY[2], @dir_iter.read) + @dir_iter.rewind + assert_equal(FILENAME_ARRAY[0], @dir_iter.read) + end + + def test_tell_seek + @dir_iter.read + @dir_iter.read + pos = @dir_iter.tell + value = @dir_iter.read + @dir_iter.read + @dir_iter.seek(pos) + assert_equal(value, @dir_iter.read) + end +end 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 62486666..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') @@ -31,30 +33,30 @@ def test_exists? end def test_open_read - blockCalled = false + block_called = false @zip_file.file.open('file1', 'r') do |f| - blockCalled = true + block_called = true assert_equal("this is the entry 'file1' in my test archive!", f.readline.chomp) end - assert(blockCalled) + assert(block_called) - blockCalled = false + block_called = false @zip_file.file.open('file1', 'rb') do |f| # test binary flag is ignored - blockCalled = true + block_called = true assert_equal("this is the entry 'file1' in my test archive!", f.readline.chomp) end - assert(blockCalled) + assert(block_called) - blockCalled = false + block_called = false @zip_file.dir.chdir 'dir2' @zip_file.file.open('file21', 'r') do |f| - blockCalled = true + block_called = true assert_equal("this is the entry 'dir2/file21' in my test archive!", f.readline.chomp) end - assert(blockCalled) + assert(block_called) @zip_file.dir.chdir '/' assert_raises(Errno::ENOENT) do @@ -80,7 +82,7 @@ def test_new end begin is = @zip_file.file.new('file1') do - fail 'should not call block' + raise 'should not call block' end ensure is.close if is @@ -126,19 +128,19 @@ def test_file? include ExtraAssertions def test_dirname - assert_forwarded(File, :dirname, 'retVal', 'a/b/c/d') do + assert_forwarded(File, :dirname, 'ret_val', 'a/b/c/d') do @zip_file.file.dirname('a/b/c/d') end end def test_basename - assert_forwarded(File, :basename, 'retVal', 'a/b/c/d') do + assert_forwarded(File, :basename, 'ret_val', 'a/b/c/d') do @zip_file.file.basename('a/b/c/d') end end def test_split - assert_forwarded(File, :split, 'retVal', 'a/b/c/d') do + assert_forwarded(File, :split, 'ret_val', 'a/b/c/d') do @zip_file.file.split('a/b/c/d') end end @@ -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? @@ -246,21 +242,21 @@ def test_zero? assert(!@zip_file.file.zero?('notAFile')) assert(!@zip_file.file.zero?('file1')) assert(@zip_file.file.zero?('dir1')) - blockCalled = false + block_called = false ::Zip::File.open('test/data/generated/5entry.zip') do |zf| - blockCalled = true + block_called = true assert(zf.file.zero?('test/data/generated/empty.txt')) end - assert(blockCalled) + assert(block_called) assert(!@zip_file.file.stat('file1').zero?) assert(@zip_file.file.stat('dir1').zero?) - blockCalled = false + block_called = false ::Zip::File.open('test/data/generated/5entry.zip') do |zf| - blockCalled = true + block_called = true assert(zf.file.stat('test/data/generated/empty.txt').zero?) end - assert(blockCalled) + assert(block_called) end def test_expand_path @@ -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 @@ -434,12 +432,14 @@ def test_glob ::Zip::File.open('test/data/globTest.zip') do |zf| { 'globTest/foo.txt' => ['globTest/foo.txt'], - '*/foo.txt' => ['globTest/foo.txt'], - '**/foo.txt' => ['globTest/foo.txt', 'globTest/foo/bar/baz/foo.txt'], - '*/foo/**/*.txt' => ['globTest/foo/bar/baz/foo.txt'] + '*/foo.txt' => ['globTest/foo.txt'], + '**/foo.txt' => [ + 'globTest/foo.txt', 'globTest/foo/bar/baz/foo.txt' + ], + '*/foo/**/*.txt' => ['globTest/foo/bar/baz/foo.txt'] }.each do |spec, expected_results| results = zf.glob(spec) - assert results.all? { |entry| entry.is_a? ::Zip::Entry } + assert(results.all?(::Zip::Entry)) result_strings = results.map(&:to_s) missing_matches = expected_results - result_strings @@ -466,12 +466,12 @@ def test_popen if Zip::RUNNING_ON_WINDOWS # This is pretty much projectile vomit but it allows the test to be # run on windows also - system_dir = ::File.popen('dir') { |f| f.read }.gsub(/Dir\(s\).*$/, '') - zipfile_dir = @zip_file.file.popen('dir') { |f| f.read }.gsub(/Dir\(s\).*$/, '') + system_dir = ::File.popen('dir', &:read).gsub(/Dir\(s\).*$/, '') + zipfile_dir = @zip_file.file.popen('dir', &:read).gsub(/Dir\(s\).*$/, '') assert_equal(system_dir, zipfile_dir) else - assert_equal(::File.popen('ls') { |f| f.read }, - @zip_file.file.popen('ls') { |f| f.read }) + assert_equal(::File.popen('ls', &:read), + @zip_file.file.popen('ls', &:read)) end end @@ -486,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 @@ -498,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 3e76e7d0..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,19 +39,20 @@ 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 def ensure_dir(name) if File.exist?(name) return if File.stat(name).directory? + File.delete(name) end Dir.mkdir(name) @@ -71,56 +72,90 @@ def initialize(zip_name, entry_names, comment = '') end 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") - 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") - - File.open('test/data/generated/empty.txt', 'w') {} - File.open('test/data/generated/empty_chmod640.txt', 'w') {} + raise "failed to create test zip '#{TEST_ZIP1.zip_name}'" \ + unless system("zip -q #{TEST_ZIP1.zip_name} test/data/file2.txt") + raise "failed to remove entry from '#{TEST_ZIP1.zip_name}'" \ + unless system( + "zip -q #{TEST_ZIP1.zip_name} -d test/data/file2.txt" + ) + + 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' } - ziptestTxt = '' - File.open('test/data/file2.txt') { |file| ziptestTxt = file.read } - File.open('test/data/generated/longAscii.txt', 'w') do |file| - file << ziptestTxt while file.tell < 1E5 + File.open('test/data/generated/short.txt', 'wb') { |file| file << 'ABCDEF' } + test_text = '' + 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 - testBinaryPattern = '' - File.open('test/data/generated/empty.zip') { |file| testBinaryPattern = file.read } - testBinaryPattern *= 4 + binary_pattern = '' + File.open('test/data/generated/empty.zip', 'rb') do |file| + binary_pattern = file.read + end + binary_pattern *= 4 File.open('test/data/generated/longBinary.bin', 'wb') do |file| - file << testBinaryPattern << rand << "\0" while file.tell < 6E5 + file << binary_pattern << rand << "\0" while file.tell < 6E5 end - 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(' ')}") + raise "failed to create test zip '#{TEST_ZIP2.zip_name}'" \ + unless system( + "zip -q #{TEST_ZIP2.zip_name} #{TEST_ZIP2.entry_names.join(' ')}" + ) - if RUBY_PLATFORM =~ /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}\"") + if RUBY_PLATFORM.match?(/mswin|mingw|cygwin/) + raise "failed to add comment to test zip '#{TEST_ZIP2.zip_name}'" \ + unless system( + "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 666a63a0..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,71 +42,115 @@ def test_read_local_entry_from_non_zip_file end end - def test_read_local_entry_from_truncated_zip_file - zipFragment = '' - ::File.open(TestZipFile::TEST_ZIP2.zip_name) { |f| zipFragment = f.read(12) } # local header is at least 30 bytes - zipFragment.extend(IOizeString).reset - entry = ::Zip::Entry.new - entry.read_local_entry(zipFragment) - fail 'ZipError expected' - rescue ::Zip::Error + 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 + + 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', 'entryName', '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) - entryReadLocal, entryReadCentral = read_from_file(LEH_FILE, CEH_FILE) - assert(entryReadCentral.extra['Zip64Placeholder'].nil?, 'zip64 placeholder should not be used in central directory') - compare_local_entry_headers(entry, entryReadLocal) - compare_c_dir_entry_headers(entry, entryReadCentral) + local_entry, central_entry = read_from_file(LEH_FILE, CEH_FILE) + assert( + 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', 'entryName', '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) - entryReadLocal, entryReadCentral = read_from_file(LEH_FILE, CEH_FILE) - assert(entryReadLocal.extra['Zip64Placeholder'], 'zip64 placeholder should be used in local file header') - entryReadLocal.extra.delete('Zip64Placeholder') # it was removed when writing the c_dir_entry, so remove from compare - assert(entryReadCentral.extra['Zip64Placeholder'].nil?, 'zip64 placeholder should not be used in central directory') - compare_local_entry_headers(entry, entryReadLocal) - compare_c_dir_entry_headers(entry, entryReadCentral) + local_entry, central_entry = read_from_file(LEH_FILE, CEH_FILE) + + assert( + local_entry.extra['Zip64'].nil?, + 'zip64 should not be used in local file header at this point.' + ) + assert( + 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_64entry - ::Zip.write_zip64_support = true - entry = ::Zip::Entry.new('bigfile.zip', 'entryName', '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) - entryReadLocal, entryReadCentral = read_from_file(LEH_FILE, CEH_FILE) - compare_local_entry_headers(entry, entryReadLocal) - compare_c_dir_entry_headers(entry, entryReadCentral) + local_entry, central_entry = read_from_file(LEH_FILE, CEH_FILE) + compare_local_entry_headers(entry, local_entry) + compare_c_dir_entry_headers(entry, central_entry) end def test_rewrite_local_header64 - ::Zip.write_zip64_support = true buf1 = StringIO.new - entry = ::Zip::Entry.new('file.zip', 'entryName') + 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 def test_read_local_offset - entry = ::Zip::Entry.new('file.zip', 'entryName') + entry = ::Zip::Entry.new('file.zip', 'entry_name') entry.local_header_offset = 12_345 ::File.open(CEH_FILE, 'wb') { |f| entry.write_c_dir_entry(f) } read_entry = nil @@ -113,8 +159,7 @@ def test_read_local_offset end def test_read64_local_offset - ::Zip.write_zip64_support = true - entry = ::Zip::Entry.new('file.zip', 'entryName') + 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) } read_entry = nil @@ -124,31 +169,43 @@ 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 - def write_to_file(localFileName, centralFileName, entry) - ::File.open(localFileName, 'wb') { |f| entry.write_local_entry(f) } - ::File.open(centralFileName, 'wb') { |f| entry.write_c_dir_entry(f) } + def write_to_file(local_filename, central_filename, entry) + ::File.open(local_filename, 'wb') { |f| entry.write_local_entry(f) } + ::File.open(central_filename, 'wb') { |f| entry.write_c_dir_entry(f) } end - def read_from_file(localFileName, centralFileName) - localEntry = nil - cdirEntry = nil - ::File.open(localFileName, 'rb') { |f| localEntry = ::Zip::Entry.read_local_entry(f) } - ::File.open(centralFileName, 'rb') { |f| cdirEntry = ::Zip::Entry.read_c_dir_entry(f) } - [localEntry, cdirEntry] + def read_from_file(local_filename, central_filename) + local_entry = nil + cdir_entry = nil + + ::File.open(local_filename, 'rb') do |f| + local_entry = ::Zip::Entry.read_local_entry(f) + end + + ::File.open(central_filename, 'rb') do |f| + cdir_entry = ::Zip::Entry.read_c_dir_entry(f) + end + + [local_entry, cdir_entry] end end diff --git a/test/output_stream_test.rb b/test/output_stream_test.rb index a7725e22..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,15 +25,22 @@ 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 + buffer = ::Zip::OutputStream.write_buffer(::StringIO.new) do |zos| + zos.comment = TEST_ZIP.comment + write_test_zip(zos) + end + assert_equal Encoding::ASCII_8BIT, buffer.external_encoding + end + def test_write_buffer_with_temp_file tmp_file = Tempfile.new('') @@ -41,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' } @@ -57,11 +89,11 @@ def test_cannot_open_file name = TestFiles::EMPTY_TEST_DIR begin ::Zip::OutputStream.open(name) - rescue Exception - assert($!.kind_of?(Errno::EISDIR) || # Linux - $!.kind_of?(Errno::EEXIST) || # Windows/cygwin - $!.kind_of?(Errno::EACCES), # Windows - "Expected Errno::EISDIR (or on win/cygwin: Errno::EEXIST), but was: #{$!.class}") + rescue SystemCallError + assert($ERROR_INFO.kind_of?(Errno::EISDIR) || # Linux + $ERROR_INFO.kind_of?(Errno::EEXIST) || # Windows/cygwin + $ERROR_INFO.kind_of?(Errno::EACCES), # Windows + "Expected Errno::EISDIR (or on win/cygwin: Errno::EEXIST), but was: #{$ERROR_INFO.class}") end end @@ -74,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 @@ -83,14 +115,17 @@ 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 ::Zip::InputStream.open(TEST_ZIP.zip_name) do |io| while (entry = io.get_next_entry) - assert(::Zip::DOSTime.at(file.mtime).dos_equals(::Zip::DOSTime.at(entry.mtime))) # Compare DOS Times, since they are stored with two seconds accuracy + # Compare DOS Times, since they are stored with two seconds accuracy + assert(::Zip::DOSTime.at(file.mtime) == ::Zip::DOSTime.at(entry.mtime)) end end end @@ -120,9 +155,9 @@ def assert_i_o_error_in_closed_stream end def write_test_zip(zos) - TEST_ZIP.entry_names.each do |entryName| - zos.put_next_entry(entryName) - File.open(entryName, 'rb') { |f| zos.write(f.read) } + TEST_ZIP.entry_names.each do |entry_name| + zos.put_next_entry(entry_name) + File.open(entry_name, 'rb') { |f| zos.write(f.read) } 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 8b6f67d5..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') @@ -62,9 +72,9 @@ def test_leading_dot_dot def test_non_leading_dot_dot_with_existing_folder entries = { - 'tmp/' => '', - 'tmp/../../moo' => /WARNING: skipped \'tmp\/\.\.\/\.\.\/moo\'/ - } + 'tmp/' => '', + 'tmp/../../moo' => /WARNING: skipped extracting 'tmp\/\.\.\/\.\.\/moo'/ + } in_tmpdir do extract_paths('relative1.zip', entries) assert Dir.exist?('tmp') @@ -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 7c1331a6..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,21 +12,21 @@ 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 ::Zip.reset! end - def open_zip(&aProc) - assert(!aProc.nil?) - ::Zip::File.open(TestZipFile::TEST_ZIP4.zip_name, &aProc) + def open_zip(&a_proc) + refute_nil(a_proc) + ::Zip::File.open(TestZipFile::TEST_ZIP4.zip_name, &a_proc) end - def extract_test_dir(&aProc) + def extract_test_dir(&a_proc) open_zip do |zf| - zf.extract(TestFiles::EMPTY_TEST_DIR, TEST_OUT_NAME, &aProc) + zf.extract(TestFiles::EMPTY_TEST_DIR, TEST_OUT_NAME, &a_proc) end end @@ -38,31 +40,34 @@ 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 Zip.continue_on_exists_proc = true - replacedEntry = nil + replaced_entry = nil ::Zip::File.open(TEST_ZIP.zip_name) do |zf| - replacedEntry = zf.entries.first.name - zf.add(replacedEntry, 'test/data/file2.txt') + replaced_entry = zf.entries.first.name + zf.add(replaced_entry, 'test/data/file2.txt') end ::Zip::File.open(TEST_ZIP.zip_name) do |zf| - assert_contains(zf, replacedEntry, 'test/data/file2.txt') + assert_contains(zf, replaced_entry, 'test/data/file2.txt') end end @@ -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,15 +85,17 @@ 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 private - def assert_contains(zf, entryName, filename = entryName) - assert(zf.entries.detect { |e| e.name == entryName } != nil, "entry #{entryName} not in #{zf.entries.join(', ')} in zip file #{zf}") - assert_entry_contents(zf, entryName, filename) if File.exist?(filename) + def assert_contains(zip_file, entry_name, filename = entry_name) + refute_nil( + zip_file.entries.detect { |e| e.name == entry_name }, + "entry #{entry_name} not in #{zip_file.entries.join(', ')} in zip file #{zip_file}" + ) + assert_entry_contents(zip_file, entry_name, filename) if File.exist?(filename) end end diff --git a/test/stored_support_test.rb b/test/stored_support_test.rb new file mode 100644 index 00000000..e1cd1813 --- /dev/null +++ b/test/stored_support_test.rb @@ -0,0 +1,39 @@ +# 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' + INPUT_FILE1 = 'test/data/file1.txt' + INPUT_FILE2 = 'test/data/file2.txt' + + def test_read + Zip::InputStream.open(STORED_ZIP_TEST_FILE) do |zis| + entry = zis.get_next_entry + assert_equal 'file1.txt', entry.name + 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 + + def test_encrypted_read + 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.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/test_helper.rb b/test/test_helper.rb index 6d11af6c..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,83 +12,31 @@ 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 unless count - retVal = slice(@tell, count) - @tell += count - retVal - end - - def seek(index, offset) - @tell ||= 0 - case offset - when IO::SEEK_END - newPos = size + index - when IO::SEEK_SET - newPos = index - when IO::SEEK_CUR - newPos = @tell + index - else - raise 'Error in test method IOizeString::seek' - end - if newPos < 0 || newPos >= size - raise Errno::EINVAL - else - @tell = newPos - end - end - - def reset - @tell = 0 - end -end - module DecompressorTests - # expects @refText, @refLines and @decompressor + # expects @ref_text, @ref_lines and @decompressor TEST_FILE = 'test/data/file1.txt' def setup - @refText = '' - File.open(TEST_FILE) { |f| @refText = f.read } - @refLines = @refText.split($/) + @ref_text = '' + File.open(TEST_FILE, 'rb') { |f| @ref_text = f.read } + @ref_lines = @ref_text.split($INPUT_RECORD_SEPARATOR) end def test_read_everything - assert_equal(@refText, @decompressor.sysread) + assert_equal(@ref_text, @decompressor.read) end def test_read_in_chunks - chunkSize = 5 - while (decompressedChunk = @decompressor.sysread(chunkSize)) - assert_equal(@refText.slice!(0, chunkSize), decompressedChunk) + size = 5 + while (chunk = @decompressor.read(size)) + assert_equal(@ref_text.slice!(0, size), chunk) end - assert_equal(0, @refText.size) - end - - def test_mixing_reads_and_produce_input - # Just some preconditions to make sure we have enough data for this test - assert(@refText.length > 1000) - assert(@refLines.length > 40) - - assert_equal(@refText[0...100], @decompressor.sysread(100)) - - assert(!@decompressor.input_finished?) - buf = @decompressor.produce_input - assert_equal(@refText[100...(100 + buf.length)], buf) + assert_equal(0, @ref_text.size) end end @@ -95,20 +45,20 @@ def assert_next_entry(filename, zis) assert_entry(filename, zis, zis.get_next_entry.name) end - def assert_entry(filename, zis, entryName) - assert_equal(filename, entryName) - assert_entry_contents_for_stream(filename, zis, entryName) + def assert_entry(filename, zis, entry_name) + assert_equal(filename, entry_name) + assert_entry_contents_for_stream(filename, zis, entry_name) end - def assert_entry_contents_for_stream(filename, zis, entryName) + def assert_entry_contents_for_stream(filename, zis, entry_name) File.open(filename, 'rb') do |file| expected = file.read actual = zis.read if expected != actual if (expected && actual) && (expected.length > 400 || actual.length > 400) - zipEntryFilename = entryName + '.zipEntry' - File.open(zipEntryFilename, 'wb') { |entryfile| entryfile << actual } - fail("File '#{filename}' is different from '#{zipEntryFilename}'") + entry_filename = "#{entry_name}.zipEntry" + File.open(entry_filename, 'wb') { |entryfile| entryfile << actual } + raise("File '#{filename}' is different from '#{entry_filename}'") else assert_equal(expected, actual) end @@ -116,37 +66,37 @@ def assert_entry_contents_for_stream(filename, zis, entryName) end end - def self.assert_contents(filename, aString) - fileContents = '' - File.open(filename, 'rb') { |f| fileContents = f.read } - if fileContents != aString - if fileContents.length > 400 || aString.length > 400 - stringFile = filename + '.other' - File.open(stringFile, 'wb') { |f| f << aString } - fail("File '#{filename}' is different from contents of string stored in '#{stringFile}'") - else - assert_equal(fileContents, aString) - end + def self.assert_contents(filename, string) + contents = '' + File.open(filename, 'rb') { |f| contents = f.read } + return unless contents != string + + if contents.length > 400 || string.length > 400 + 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 + assert_equal(contents, string) end end - def assert_stream_contents(zis, testZipFile) + def assert_stream_contents(zis, zip_file) assert(!zis.nil?) - testZipFile.entry_names.each do |entryName| - assert_next_entry(entryName, zis) + zip_file.entry_names.each do |entry_name| + assert_next_entry(entry_name, zis) end assert_nil(zis.get_next_entry) end - def assert_test_zip_contents(testZipFile) - ::Zip::InputStream.open(testZipFile.zip_name) do |zis| - assert_stream_contents(zis, testZipFile) + def assert_test_zip_contents(zip_file) + ::Zip::InputStream.open(zip_file.zip_name) do |zis| + assert_stream_contents(zis, zip_file) end end - def assert_entry_contents(zipFile, entryName, filename = entryName.to_s) - zis = zipFile.get_input_stream(entryName) - assert_entry_contents_for_stream(filename, zis, entryName) + def assert_entry_contents(zip_file, entry_name, filename = entry_name.to_s) + zis = zip_file.get_input_stream(entry_name) + assert_entry_contents_for_stream(filename, zis, entry_name) ensure zis.close if zis end @@ -159,7 +109,7 @@ class TestOutputStream attr_accessor :buffer def initialize - @buffer = '' + @buffer = +'' end def <<(data) @@ -168,26 +118,16 @@ def <<(data) end end - def run_crc_test(compressorClass) + def run_crc_test(compressor_class) str = "Here's a nice little text to compute the crc for! Ho hum, it is nice nice nice nice indeed." - fakeOut = TestOutputStream.new + fake_out = TestOutputStream.new - deflater = compressorClass.new(fakeOut) + deflater = compressor_class.new(fake_out) deflater << str assert_equal(0x919920fc, deflater.crc) end end -module Enumerable - def compare_enumerables(otherEnumerable) - otherAsArray = otherEnumerable.to_a - each_with_index do |element, index| - return false unless yield(element, otherAsArray[index]) - end - size == otherAsArray.size - end -end - module CommonZipFileFixture include AssertEntry @@ -197,27 +137,29 @@ 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 module ExtraAssertions - def assert_forwarded(anObject, method, retVal, *expectedArgs) - callArgs = nil - setCallArgsProc = proc { |args| callArgs = args } - anObject.instance_eval <<-"end_eval" - alias #{method}_org #{method} - def #{method}(*args) - ObjectSpace._id2ref(#{setCallArgsProc.object_id}).call(args) - ObjectSpace._id2ref(#{retVal.object_id}) - end - end_eval + def assert_forwarded(object, method, ret_val, *expected_args) + call_args = nil + 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(retVal, yield) # Invoke test - assert_equal(expectedArgs, callArgs) + assert_equal(ret_val, yield) # Invoke test + assert_equal(expected_args, call_args) ensure - anObject.instance_eval "undef #{method}; alias #{method} #{method}_org" + object.singleton_class.class_exec do + remove_method method + alias_method method, :"#{method}_org" + end end end @@ -228,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 aac3e256..bc843321 100644 --- a/test/unicode_file_names_and_comments_test.rb +++ b/test/unicode_file_names_and_comments_test.rb @@ -1,10 +1,14 @@ -# encoding: utf-8 +# 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'] @@ -43,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