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 75dce5fd..49bed3dd 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,2 +1,114 @@ -inherit_from: - - .rubocop_rubyzip.yml \ No newline at end of file +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: + 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 12aac095..00000000 --- a/.travis.yml +++ /dev/null @@ -1,32 +0,0 @@ -language: ruby -sudo: false -cache: bundler -rvm: - - 2.0.0 - - 2.1.10 - - 2.2.6 - - 2.3.3 - - 2.4.0 - - ruby-head - - rbx-2 -matrix: - include: - - rvm: jruby-9.1.4.0 - jdk: oraclejdk7 - - rvm: jruby-9.1.4.0 - jdk: oraclejdk8 - - rvm: jruby-9.1.4.0 - jdk: openjdk7 - - rvm: jruby-head - jdk: oraclejdk8 - allow_failures: - - rvm: ruby-head - - rvm: rbx-2 - - rvm: jruby-head -before_install: - - gem update --system - - gem install bundler - - gem --version -before_script: - - echo `whereis zip` - - echo `whereis unzip` diff --git a/Changelog.md b/Changelog.md index 7318fd10..7e0b9052 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,281 +1,421 @@ -1.2.1 -===== - -* Add accessor to @internal_file_attributes #304 -* Extended globbing #303 -* README updates #283, #289 -* Cleanup after tests #298, #306 -* Fix permissions on new zip files #294, #300 -* Fix examples #297 -* Support cp932 encoding #308 -* Fix Directory traversal vulnerability #315 -* Allow open_buffer to work without a given block #314 +# 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) -1.2.0 -===== +# 2.4.1 (2025-01-05) -* Don't enable JRuby objectspace #252 -* Fixes an exception thrown when decoding some weird .zip files #248 -* Use duck typing with IO methods #244 -* Added error for empty (zero bit) zip file #242 -* Accept StringIO in Zip.open_buffer #238 -* Do something more expected with new file permissions #237 -* Case insensitivity option for #find_entry #222 -* Fixes in documentation and examples +*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.* -1.1.7 -===== +Tooling: -* Fix UTF-8 support for comments -* `Zip.sort_entries` working for zip output -* Prevent tempfile path from being unlinked by garbage collection -* NTFS Extra Field (0x000a) support -* Use String#tr instead of String#gsub -* Ability to not show warning about incorrect date -* Be smarter about handling buffer file modes. -* Support for Traditional Encryption (ZipCrypto) +- Opt-in for MFA requirement explicitly on 2.4 branch. -1.1.6 -===== +# 2.4 (2025-01-04) - Yanked -* Revert "Return created zip file from Zip::File.open when supplied a block" +*Yanked due to incorrect version number format (2.4 vs 2.4.0).* -1.1.5 -===== +- 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. -* Treat empty file as non-exists (@layerssss) -* Revert regression commit -* Return created zip file from Zip::File.open when supplied a block (@tpickett66) -* Zip::Entry::DEFLATED is forced on every file (@mehmetc) -* Add InputStream#ungetc (@zacstewart) -* Alias for legacy error names (@orien) +Tooling: -1.1.4 -===== +- Switch to using GitHub Actions (from Travis). +- Update Rubocop versions and configuration. +- Update actions with latest rubies. -* Don't send empty string to stream (@mrloop) -* Zip::Entry::DEFLATED was forced on every file (@mehmetc) -* Alias for legacy error names (@orien) +# 2.3.2 (2021-07-05) -1.1.3 -===== +- 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`. -* Fix compatibility of ::OutputStream::write_buffer (@orien) -* Clean up tempfiles from output stream (@iangreenleaf) +# 2.3.1 (2021-07-03) -1.1.2 -===== +- A "dummy" release to warn about breaking changes coming in version 3.0. -* Fix compatibility of ::Zip::File.write_buffer +# 2.3.0 (2020-03-14) -1.1.1 -===== +- 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) -* Speedup deflater (@loadhigh) -* Less Arrays and Strings allocations (@srawlins) -* Fix Zip64 writting support (@mrjamesriley) -* Fix StringIO support (@simonoff) -* Posibility to change default compression level -* Make Zip64 write support optional via configuration +Tooling: -1.1.0 -===== +- 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. -* StringIO Support -* Zip64 Support -* Better jRuby Support -* Order of files in the archive can be sorted -* Other small fixes +# 2.2.0 (2020-02-01) -1.0.0 -===== +- Add support for decompression plugin gems [#427](https://github.com/rubyzip/rubyzip/pull/427) -* Removed support for Ruby 1.8 -* Changed the API for gem. Now it can be used without require param in Gemfile. -* Added read-only support for Zip64 files. -* Added support for setting Unicode file names. +# 2.1.0 (2020-01-25) -0.9.9 -===== +- 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`. +- 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) +- Drop unused `tmpdir` requirement [#411](https://github.com/rubyzip/rubyzip/pull/411) -* Added support for backslashes in zip files (generated by the default Windows zip packer for example) and comment sections with the comment length set to zero even though there is actually a comment. +Tooling -0.9.8 -===== +- Move CI to xenial and include jruby on JDK11 [#419](https://github.com/rubyzip/rubyzip/pull/419/files) -* Fixed: "Unitialized constant NullInputStream" error +# 2.0.0 (2019-09-25) -0.9.5 -===== +Security -* Removed support for loading ruby in zip files (ziprequire.rb). +- 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`. -0.9.4 -===== +Tooling / Documentation -* Changed ZipOutputStream.put_next_entry signature (API CHANGE!). Now allows comment, extra field and compression method to be specified. +- Remove test files from the gem to avoid problems with antivirus detections on the test files [#405](https://github.com/rubyzip/rubyzip/pull/405) / [#384](https://github.com/rubyzip/rubyzip/issues/384) +- Drop support for unsupported ruby versions [#406](https://github.com/rubyzip/rubyzip/pull/406) -0.9.3 -===== +# 1.3.0 (2019-09-25) -* Fixed: Added ZipEntry::name_encoding which retrieves the character -encoding of the name and comment of the entry. -* Added convenience methods ZipEntry::name_in(enc) and ZipEntry::comment_in(enc) for -getting zip entry names and comments in a specified character -encoding. +Security -0.9.2 -===== +- 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. -* Fixed: Renaming an entry failed if the entry's new name was a different length than its old name. (Diego Barros) +New Feature -0.9.1 -===== +- Add `add_stored` method to simplify adding entries without compression [#366](https://github.com/rubyzip/rubyzip/pull/366) -* Added symlink support and support for unix file permissions. Reduced memory usage during decompression. -* New methods ZipFile::[follow_symlinks, restore_times, restore_permissions, restore_ownership]. -* New methods ZipEntry::unix_perms, ZipInputStream::eof?. -* Added documentation and test for new ZipFile::extract. -* Added some of the API suggestions from sf.net #1281314. -* Applied patch for sf.net bug #1446926. -* Applied patch for sf.net bug #1459902. -* Rework ZipEntry and delegate classes. +Tooling / Documentation -0.5.12 -====== +- Add more gem metadata links [#402](https://github.com/rubyzip/rubyzip/pull/402) -* Fixed problem with writing binary content to a ZipFile in MS Windows. +# 1.2.4 (2019-09-06) -0.5.11 -====== +- Do not rewrite zip files opened with `open_buffer` that have not changed [#360](https://github.com/rubyzip/rubyzip/pull/360) -* Fixed name clash file method copy_stream from fileutils.rb. Fixed problem with references to constant CHUNK_SIZE. -* ZipInputStream/AbstractInputStream read is now buffered like ruby IO's read method, which means that read and gets etc can be mixed. The - unbuffered read method has been renamed to sysread. +Tooling / Documentation -0.5.10 -====== +- Update `example_recursive.rb` in README [#397](https://github.com/rubyzip/rubyzip/pull/397) +- Hold CI at `trusty` for now, automatically pick the latest ruby patch version, use rbx-4 and hold jruby at 9.1 [#399](https://github.com/rubyzip/rubyzip/pull/399) -* Fixed method name resolution problem with FileUtils::copy_stream and IOExtras::copy_stream. +# 1.2.3 -0.5.9 -===== +- Allow tilde in zip entry names [#391](https://github.com/rubyzip/rubyzip/pull/391) (fixes regression in 1.2.2 from [#376](https://github.com/rubyzip/rubyzip/pull/376)) +- Support frozen string literals in more files [#390](https://github.com/rubyzip/rubyzip/pull/390) +- Require `pathname` explicitly [#388](https://github.com/rubyzip/rubyzip/pull/388) (fixes regression in 1.2.2 from [#376](https://github.com/rubyzip/rubyzip/pull/376)) -* Fixed serious memory consumption issue +Tooling / Documentation: -0.5.8 -===== +- CI updates [#392](https://github.com/rubyzip/rubyzip/pull/392), [#394](https://github.com/rubyzip/rubyzip/pull/394) + - Bump supported ruby versions and add 2.6 + - JRuby failures are no longer ignored (reverts [#375](https://github.com/rubyzip/rubyzip/pull/375) / part of [#371](https://github.com/rubyzip/rubyzip/pull/371)) +- Add changelog entry that was missing for last release [#387](https://github.com/rubyzip/rubyzip/pull/387) +- Comment cleanup [#385](https://github.com/rubyzip/rubyzip/pull/385) -* Fixed install script. +# 1.2.2 -0.5.7 -===== -* install.rb no longer assumes it is being run from the toplevel source -dir. Directory structure changed to reflect common ruby library -project structure. Migrated from RubyUnit to Test::Unit format. Now -uses Rake to build source packages and gems and run unit tests. +NB: This release drops support for extracting symlinks, because there was no clear way to support this securely. See https://github.com/rubyzip/rubyzip/pull/376#issue-210954555 for details. -0.5.6 -===== -* Fix for FreeBSD 4.9 which returns Errno::EFBIG instead of -Errno::EINVAL for some invalid seeks. Fixed 'version needed to -extract'-field incorrect in local headers. +- Fix CVE-2018-1000544 [#376](https://github.com/rubyzip/rubyzip/pull/376) / [#371](https://github.com/rubyzip/rubyzip/pull/371) +- Fix NoMethodError: undefined method `glob' [#363](https://github.com/rubyzip/rubyzip/pull/363) +- Fix handling of stored files (i.e. files not using compression) with general purpose bit 3 set [#358](https://github.com/rubyzip/rubyzip/pull/358) +- Fix `close` on StringIO-backed zip file [#353](https://github.com/rubyzip/rubyzip/pull/353) +- Add `Zip.force_entry_names_encoding` option [#340](https://github.com/rubyzip/rubyzip/pull/340) +- Update rubocop, apply auto-fixes, and fix regressions caused by said auto-fixes [#332](https://github.com/rubyzip/rubyzip/pull/332), [#355](https://github.com/rubyzip/rubyzip/pull/355) +- Save temporary files to temporary directory (rather than current directory) [#325](https://github.com/rubyzip/rubyzip/pull/325) -0.5.5 -===== +Tooling / Documentation: -* Fix for a problem with writing zip files that concerns only ruby 1.8.1. +- Turn off all terminal output in all tests [#361](https://github.com/rubyzip/rubyzip/pull/361) +- Several CI updates [#346](https://github.com/rubyzip/rubyzip/pull/346), [#347](https://github.com/rubyzip/rubyzip/pull/347), [#350](https://github.com/rubyzip/rubyzip/pull/350), [#352](https://github.com/rubyzip/rubyzip/pull/352) +- Several README improvements [#345](https://github.com/rubyzip/rubyzip/pull/345), [#326](https://github.com/rubyzip/rubyzip/pull/326), [#321](https://github.com/rubyzip/rubyzip/pull/321) -0.5.4 -===== +# 1.2.1 -* Significantly reduced memory footprint when modifying zip files. +- Add accessor to @internal_file_attributes #304 +- Extended globbing #303 +- README updates #283, #289 +- Cleanup after tests #298, #306 +- Fix permissions on new zip files #294, #300 +- Fix examples #297 +- Support cp932 encoding #308 +- Fix Directory traversal vulnerability #315 +- Allow open_buffer to work without a given block #314 -0.5.3 -===== -* Added optimization to avoid decompressing and recompressing individual -entries when modifying a zip archive. +# 1.2.0 -0.5.2 -===== -* Fixed ZipFile corruption bug in ZipFile class. Added basic unix -extra-field support. +- Don't enable JRuby objectspace #252 +- Fixes an exception thrown when decoding some weird .zip files #248 +- Use duck typing with IO methods #244 +- Added error for empty (zero bit) zip file #242 +- Accept StringIO in Zip.open_buffer #238 +- Do something more expected with new file permissions #237 +- Case insensitivity option for #find_entry #222 +- Fixes in documentation and examples -0.5.1 -===== +# 1.1.7 -* Fixed ZipFile.get_output_stream bug. +- Fix UTF-8 support for comments +- `Zip.sort_entries` working for zip output +- Prevent tempfile path from being unlinked by garbage collection +- NTFS Extra Field (0x000a) support +- Use String#tr instead of String#gsub +- Ability to not show warning about incorrect date +- Be smarter about handling buffer file modes. +- Support for Traditional Encryption (ZipCrypto) -0.5.0 -===== +# 1.1.6 -* Ruby 1.8.0 and ruby-zlib 0.6.0 compatibility -* Changed method names from camelCase to rubys underscore style. -* Installs to zip/ subdir instead of directly to site_ruby -* Added ZipFile.directory and ZipFile.file - each method return an -object that can be used like Dir and File only for the contents of the -zip file. -* Added sample application zipfind which works like Find.find, only -Zip::ZipFind.find traverses into zip archives too. -* FIX: AbstractInputStream.each_line with non-default separator +- Revert "Return created zip file from Zip::File.open when supplied a block" +# 1.1.5 -0.5.0a -====== -Source reorganized. Added ziprequire, which can be used to load ruby -modules from a zip file, in a fashion similar to jar files in -Java. Added gtk_ruby_zip, another sample application. Implemented -ZipInputStream.lineno and ZipInputStream.rewind +- Treat empty file as non-exists (@layerssss) +- Revert regression commit +- Return created zip file from Zip::File.open when supplied a block (@tpickett66) +- Zip::Entry::DEFLATED is forced on every file (@mehmetc) +- Add InputStream#ungetc (@zacstewart) +- Alias for legacy error names (@orien) -Bug fixes: +# 1.1.4 + +- Don't send empty string to stream (@mrloop) +- Zip::Entry::DEFLATED was forced on every file (@mehmetc) +- Alias for legacy error names (@orien) + +# 1.1.3 + +- Fix compatibility of ::OutputStream::write_buffer (@orien) +- Clean up tempfiles from output stream (@iangreenleaf) + +# 1.1.2 + +- Fix compatibility of ::Zip::File.write_buffer + +# 1.1.1 + +- Speedup deflater (@loadhigh) +- Less Arrays and Strings allocations (@srawlins) +- Fix Zip64 writing support (@mrjamesriley) +- Fix StringIO support (@simonoff) +- Possibility to change default compression level +- Make Zip64 write support optional via configuration + +# 1.1.0 + +- StringIO Support +- Zip64 Support +- Better jRuby Support +- Order of files in the archive can be sorted +- Other small fixes + +# 1.0.0 + +- Removed support for Ruby 1.8 +- Changed the API for gem. Now it can be used without require param in Gemfile. +- Added read-only support for Zip64 files. +- Added support for setting Unicode file names. + +# 0.9.9 + +- Added support for backslashes in zip files (generated by the default Windows zip packer for example) and comment sections with the comment length set to zero even though there is actually a comment. + +# 0.9.8 + +- Fixed: "Unitialized constant NullInputStream" error + +# 0.9.5 + +- Removed support for loading ruby in zip files (ziprequire.rb). + +# 0.9.4 + +- Changed ZipOutputStream.put_next_entry signature (API CHANGE!). Now allows comment, extra field and compression method to be specified. + +# 0.9.3 + +- Fixed: Added ZipEntry::name_encoding which retrieves the character encoding of the name and comment of the entry. +- Added convenience methods ZipEntry::name_in(enc) and ZipEntry::comment_in(enc) for getting zip entry names and comments in a specified character encoding. + +# 0.9.2 + +- Fixed: Renaming an entry failed if the entry's new name was a different length than its old name. (Diego Barros) + +# 0.9.1 + +- Added symlink support and support for unix file permissions. Reduced memory usage during decompression. +- New methods ZipFile::[follow_symlinks, restore_times, restore_permissions, restore_ownership]. +- New methods ZipEntry::unix_perms, ZipInputStream::eof?. +- Added documentation and test for new ZipFile::extract. +- Added some of the API suggestions from sf.net #1281314. +- Applied patch for sf.net bug #1446926. +- Applied patch for sf.net bug #1459902. +- Rework ZipEntry and delegate classes. + +# 0.5.12 -* Read and write date and time information correctly for zip entries. -* Fixed read() using separate buffer, causing mix of gets/readline/read to -cause problems. +- Fixed problem with writing binary content to a ZipFile in MS Windows. -0.4.2 -===== +# 0.5.11 -* Performance optimizations. Test suite runs in half the time. +- Fixed name clash file method copy_stream from fileutils.rb. Fixed problem with references to constant CHUNK_SIZE. +- ZipInputStream/AbstractInputStream read is now buffered like ruby IO's read method, which means that read and gets etc can be mixed. The unbuffered read method has been renamed to sysread. + +# 0.5.10 + +- Fixed method name resolution problem with FileUtils::copy_stream and IOExtras::copy_stream. + +# 0.5.9 + +- Fixed serious memory consumption issue + +# 0.5.8 + +- Fixed install script. + +# 0.5.7 + +- install.rb no longer assumes it is being run from the toplevel source dir. Directory structure changed to reflect common ruby library project structure. Migrated from RubyUnit to Test::Unit format. Now uses Rake to build source packages and gems and run unit tests. + +# 0.5.6 + +- Fix for FreeBSD 4.9 which returns Errno::EFBIG instead of Errno::EINVAL for some invalid seeks. Fixed 'version needed to extract'-field incorrect in local headers. + +# 0.5.5 + +- Fix for a problem with writing zip files that concerns only ruby 1.8.1. + +# 0.5.4 + +- Significantly reduced memory footprint when modifying zip files. + +# 0.5.3 + +- Added optimization to avoid decompressing and recompressing individual entries when modifying a zip archive. + +# 0.5.2 + +- Fixed ZipFile corruption bug in ZipFile class. Added basic unix extra-field support. + +# 0.5.1 + +- Fixed ZipFile.get_output_stream bug. + +# 0.5.0 + +- Ruby 1.8.0 and ruby-zlib 0.6.0 compatibility +- Changed method names from camelCase to rubys underscore style. +- Installs to zip/ subdir instead of directly to site_ruby +- Added ZipFile.directory and ZipFile.file - each method return an + object that can be used like Dir and File only for the contents of the + zip file. +- Added sample application zipfind which works like Find.find, only + Zip::ZipFind.find traverses into zip archives too. +- FIX: AbstractInputStream.each_line with non-default separator + +# 0.5.0a + +Source reorganized. Added ziprequire, which can be used to load ruby modules from a zip file, in a fashion similar to jar files in Java. Added gtk_ruby_zip, another sample application. Implemented ZipInputStream.lineno and ZipInputStream.rewind + +Bug fixes: -0.4.1 -===== +- Read and write date and time information correctly for zip entries. +- Fixed read() using separate buffer, causing mix of gets/readline/read to cause problems. -* Windows compatibility fixes. +# 0.4.2 -0.4.0 -===== +- Performance optimizations. Test suite runs in half the time. -* Zip::ZipFile is now mutable and provides a more convenient way of -modifying zip archives than Zip::ZipOutputStream. Operations for -adding, extracting, renaming, replacing and removing entries to zip -archives are now available. +# 0.4.1 -* Runs without warnings with -w switch. +- Windows compatibility fixes. -* Install script install.rb added. +# 0.4.0 -0.3.1 -===== +- Zip::ZipFile is now mutable and provides a more convenient way of modifying zip archives than Zip::ZipOutputStream. Operations for adding, extracting, renaming, replacing and removing entries to zip archives are now available. +- Runs without warnings with -w switch. +- Install script install.rb added. -* Rudimentary support for writing zip archives. +# 0.3.1 -0.2.2 -===== +- Rudimentary support for writing zip archives. -* Fixed and extended unit test suite. Updated to work with ruby/zlib -0.5. It doesn't work with earlier versions of ruby/zlib. +# 0.2.2 -0.2.0 -===== +- Fixed and extended unit test suite. Updated to work with ruby/zlib 0.5. It doesn't work with earlier versions of ruby/zlib. -* Class ZipFile added. Where ZipInputStream is used to read the -individual entries in a zip file, ZipFile reads the central directory -in the zip archive, so you can get to any entry in the zip archive -without having to skipping through all the preceeding entries. +# 0.2.0 +- Class ZipFile added. Where ZipInputStream is used to read the individual entries in a zip file, ZipFile reads the central directory in the zip archive, so you can get to any entry in the zip archive without having to skipping through all the preceeding entries. -0.1.0 -===== +# 0.1.0 -* First working version of ZipInputStream. +- First working version of ZipInputStream. 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 8979d06f..dcf01eda 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,35 @@ # 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 1.9.2 or greater +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 + Rubyzip is available on RubyGems: ``` @@ -47,19 +55,20 @@ 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 # - The original file, including the path to find it - zipfile.add(filename, folder + '/' + filename) + zipfile.add(filename, File.join(folder, filename)) end - zipfile.get_output_stream("myFile") { |os| os.write "myFile contains just this" } + zipfile.get_output_stream("myFile") { |f| f.write "myFile contains just this" } end ``` ### Zipping a directory recursively -Copy from [here](https://github.com/rubyzip/rubyzip/blob/05916bf89181e1955118fd3ea059f18acac28cc8/samples/example_recursive.rb ) + +Copy from [here](https://github.com/rubyzip/rubyzip/blob/9d891f7353e66052283562d3e252fe380bb4b199/samples/example_recursive.rb) ```ruby require 'zip' @@ -83,47 +92,44 @@ class ZipFileGenerator # Zip the input directory. def write - entries = Dir.entries(@input_dir) - %w(. ..) + entries = Dir.entries(@input_dir) - %w[. ..] - ::Zip::File.open(@output_file, ::Zip::File::CREATE) do |io| - write_entries entries, '', io + ::Zip::File.open(@output_file, create: true) do |zipfile| + write_entries entries, '', zipfile end end private # A helper method to make the recursion work. - def write_entries(entries, path, io) + def write_entries(entries, path, zipfile) entries.each do |e| - zip_file_path = path == '' ? e : File.join(path, e) - disk_file_path = File.join(@input_dir, zip_file_path) - puts "Deflating #{disk_file_path}" + zipfile_path = path == '' ? e : File.join(path, e) + disk_file_path = File.join(@input_dir, zipfile_path) if File.directory? disk_file_path - recursively_deflate_directory(disk_file_path, io, zip_file_path) + recursively_deflate_directory(disk_file_path, zipfile, zipfile_path) else - put_into_archive(disk_file_path, io, zip_file_path) + put_into_archive(disk_file_path, zipfile, zipfile_path) end end end - def recursively_deflate_directory(disk_file_path, io, zip_file_path) - io.mkdir zip_file_path - subdir = Dir.entries(disk_file_path) - %w(. ..) - write_entries subdir, zip_file_path, io + def recursively_deflate_directory(disk_file_path, zipfile, zipfile_path) + zipfile.mkdir zipfile_path + subdir = Dir.entries(disk_file_path) - %w[. ..] + write_entries subdir, zipfile_path, zipfile end - def put_into_archive(disk_file_path, io, zip_file_path) - io.get_output_stream(zip_file_path) do |f| - f.write(File.open(disk_file_path, 'rb').read) - end + def put_into_archive(disk_file_path, zipfile, zipfile_path) + zipfile.add(zipfile_path, disk_file_path) end 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 @@ -152,12 +158,15 @@ When modifying a zip archive the file permissions of the archive are preserved. ### Reading a Zip file ```ruby +MAX_SIZE = 1024**2 # 1MiB (but of course you can increase this) Zip::File.open('foo.zip') do |zip_file| # Handle entries one by one zip_file.each do |entry| - # Extract to file/directory/symlink puts "Extracting #{entry.name}" - entry.extract(dest_file) + raise 'File too large when extracted' if entry.size > MAX_SIZE + + # Extract to file or directory based on name in the archive + entry.extract # Read into memory content = entry.get_input_stream.read @@ -165,34 +174,76 @@ Zip::File.open('foo.zip') do |zip_file| # Find specific entry entry = zip_file.glob('*.csv').first + raise 'File too large when extracted' if entry.size > MAX_SIZE puts entry.get_input_stream.read 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 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 ``` -If `::Zip::InputStream` finds such entry in the zip archive it will raise an exception. +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 +# 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 -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(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 @@ -206,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) @@ -221,7 +272,9 @@ File.open(new_path, "wb") {|f| f.write(buffer.string) } ## Configuration -By default, rubyzip will not overwrite files if they already exist inside of the extracted path. To change this behavior, you may specify a configuration option like so: +### Existing Files + +By default, rubyzip will not overwrite files if they already exist inside of the extracted path. To change this behavior, you may specify a configuration option like so: ```ruby Zip.on_exists_proc = true @@ -235,24 +288,88 @@ Additionally, if you want to configure rubyzip to overwrite existing files while Zip.continue_on_exists_proc = true ``` +### Non-ASCII Names + If you want to store non-english names and want to open them on Windows(pre 7) you need to set this option: ```ruby Zip.unicode_names = true ``` +Sometimes file names inside zip contain non-ASCII characters. If you can assume which encoding was used for such names and want to be able to find such entries using `find_entry` then you can force assumed encoding like so: + +```ruby +Zip.force_entry_names_encoding = 'UTF-8' +``` + +Allowed encoding names are the same as accepted by `String#force_encoding` + +### Date Validation + Some zip files might have an invalid date format, which will raise a warning. You can hide this warning with the following setting: ```ruby Zip.warn_invalid_date = false ``` -You can set the default compression level like so: +### Size Validation + +By default (in rubyzip >= 2.0), rubyzip's `extract` method checks that an entry's reported uncompressed size is not (significantly) smaller than its actual size. This is to help you protect your application against [zip bombs](https://en.wikipedia.org/wiki/Zip_bomb). Before `extract`ing an entry, you should check that its size is in the range you expect. For example, if your application supports processing up to 100 files at once, each up to 10MiB, your zip extraction code might look like: + +```ruby +MAX_FILE_SIZE = 10 * 1024**2 # 10MiB +MAX_FILES = 100 +Zip::File.open('foo.zip') do |zip_file| + num_files = 0 + zip_file.each do |entry| + num_files += 1 if entry.file? + raise 'Too many extracted files' if num_files > MAX_FILES + raise 'File too large when extracted' if entry.size > MAX_FILE_SIZE + entry.extract + end +end +``` + +If you need to extract zip files that report incorrect uncompressed sizes and you really trust them not too be too large, you can disable this setting with +```ruby +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. + +### 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 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 + +Since version 3.0, Zip64 support is enabled for writing by default. To disable it do this: + +```ruby +Zip.write_zip64_support = false +``` + +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 You can set multiple settings at the same time by using a block: @@ -265,23 +382,50 @@ You can set multiple settings at the same time by using a block: end ``` -By default, Zip64 support is disabled for writing. To enable it do this: +## Compatibility -```ruby -Zip.write_zip64_support = true -``` +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). -_NOTE_: If you will enable Zip64 writing then you will need zip extractor with Zip64 support to extract archive. +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 @@ -290,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 bb44361a..f85e7fb0 100644 --- a/lib/zip.rb +++ b/lib/zip.rb @@ -1,10 +1,13 @@ +# frozen_string_literal: true + +require 'English' require 'delegate' require 'singleton' require 'tempfile' -require 'tmpdir' require 'fileutils' require 'stringio' require 'zlib' +require 'zip/constants' require 'zip/dos_time' require 'zip/ioextras' require 'rbconfig' @@ -22,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' @@ -29,25 +33,47 @@ 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, :on_exists_proc, :continue_on_exists_proc, :sort_entries, :default_compression, :write_zip64_support, :warn_invalid_date, :case_insensitive_match + attr_accessor :unicode_names, + :on_exists_proc, + :continue_on_exists_proc, + :sort_entries, + :default_compression, + :write_zip64_support, + :warn_invalid_date, + :case_insensitive_match, + :force_entry_names_encoding, + :validate_entry_sizes + + DEFAULT_RESTORE_OPTIONS = { + restore_ownership: false, + restore_permissions: true, + restore_times: true + }.freeze # :nodoc: - def reset! + 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 cb7e2da7..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 62d47f0e..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,23 +22,21 @@ 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 + def reset!; end end - class NullDecrypter < Decrypter + class NullDecrypter < Decrypter # :nodoc: include NullEncryption def decrypt(data) data end - def reset!(_header) - end + def reset!(_header); end end end 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 cd0fb054..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 fd9353c6..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) + @@ -19,26 +31,65 @@ def to_binary_dos_time end def to_binary_dos_date - (day) + + day + (month << 5) + ((year - 1980) << 9) end - # Dos time is only stored with two seconds accuracy def dos_equals(other) - to_i / 2 == other.to_i / 2 - 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) + 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. + 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(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 0aba0eb8..394f9190 100644 --- a/lib/zip/entry.rb +++ b/lib/zip/entry.rb @@ -1,20 +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 @@ -33,155 +57,252 @@ def set_default_vars_values end @follow_symlinks = false - @restore_times = true - @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 - def file_type_is?(type) - raise InternalError, "current filetype is unknown: #{inspect}" unless @ftype - @ftype == type + # Set the creation time of this entry. + def ctime=(value) + send(:time=, value, component: :ctime) + end + + # Does this entry return time fields with accurate timezone information? + def absolute_time? + @extra.member?('UniversalTime') || @extra.member?('NTFS') + end + + # Return the compression method for this entry. + # + # Returns STORED if the entry is a directory or if the compression + # level is 0. + def compression_method + return STORED if ftype == :directory || @compression_level == 0 + + @compression_method + end + + # Set the compression method for this entry. + def compression_method=(method) + @dirty = true + @compression_method = (ftype == :directory ? STORED : method) + end + + # Does this entry use the ZIP64 extensions? + def zip64? + !@extra['Zip64'].nil? + end + + def file_type_is?(type) # :nodoc: + ftype == type + end + + def ftype # :nodoc: + @ftype ||= name_is_directory? ? :directory : :file end # Dynamic checkers - %w(directory file symlink).each do |k| - define_method "#{k}?" do + %w[directory file symlink].each do |k| + 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 - def local_entry_offset #:nodoc:all + # 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? # :nodoc: + cleanpath = Pathname.new(@name).cleanpath + return false unless cleanpath.relative? + + root = ::File::SEPARATOR + 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: 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). - def extract(dest_path = @name, &block) - block ||= proc { ::Zip.on_exists_proc } + # 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)) - if @name.squeeze('/') =~ /\.{2}(?:\/|\z)/ - puts "WARNING: skipped \"../\" path component(s) in #{@name}" + unless extract_path.start_with?(dest_dir) + warn "WARNING: skipped extracting '#{@name}' to '#{extract_path}' as unsafe." return self end - if directory? || file? || symlink? - __send__("create_#{@ftype}", dest_path, &block) - else - raise "unknown file type #{inspect}" - end + block ||= proc { ::Zip.on_exists_proc } + 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 @@ -194,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, @@ -220,57 +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) + if ::Zip.force_entry_names_encoding + @name.force_encoding(::Zip.force_entry_names_encoding) + end + @name.tr!('\\', '/') # Normalise filepath separators after encoding set. - @name.gsub!('\\', '/') + # 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, - @time.to_binary_dos_time, # @last_mod_time , - @time.to_binary_dos_date, # @last_mod_date , + @gp_flags, # @gp_flags + 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.compressed_size ? 0xFFFFFFFF : @compressed_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 @@ -281,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 @@ -305,10 +435,10 @@ 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) & 07777 + @unix_perms = (@external_file_attributes >> 16) & 0o7777 case (@external_file_attributes >> 28) when ::Zip::FILE_TYPE_DIR :directory @@ -317,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 @@ -334,37 +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) - read_c_dir_extra_field(io) + if ::Zip.force_entry_names_encoding + @name.force_encoding(::Zip.force_entry_names_encoding) + end + @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 @@ -380,77 +521,88 @@ 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 & 07777 + @unix_perms = stat.mode & 0o7777 end - def set_unix_permissions_on_path(dest_path) - # BUG: does not update timestamps into account - # ignore setuid/setgid bits by default. honor if @restore_ownership - unix_perms_mask = 01777 - unix_perms_mask = 07777 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 - # File::utimes() + # 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? case @fstype when ::Zip::FSTYPE_UNIX - set_unix_permissions_on_path(dest_path) + 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, @version, # version of encoding software @fstype, # filesystem type - @version_needed_to_extract, # @versionNeededToExtract , - @gp_flags, # @gp_flags , - @compression_method, - @time.to_binary_dos_time, # @last_mod_time , - @time.to_binary_dos_date, # @last_mod_date , + @version_needed_to_extract, # @versionNeededToExtract + @gp_flags, # @gp_flags + 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.compressed_size ? 0xFFFFFFFF : @compressed_size, + zip64 && zip64.original_size ? 0xFFFFFFFF : (@size || 0), name_size, @extra ? @extra.c_dir_size : 0, comment_size, - (zip64 && zip64.disk_start_number) ? 0xFFFF : 0, # disk number start + zip64 && zip64.disk_start_number ? 0xFFFF : 0, # disk number start @internal_file_attributes, # file type (binary=0, text=1) @external_file_attributes, # native filesystem attributes - (zip64 && zip64.relative_header_offset) ? 0xFFFFFFFF : @local_header_offset, + zip64 && zip64.relative_header_offset ? 0xFFFFFFFF : @local_header_offset, @name, @extra, @comment ].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 ||= 0644 + @unix_perms ||= 0o644 ::Zip::FILE_TYPE_FILE when :directory - @unix_perms ||= 0755 + @unix_perms ||= 0o755 ::Zip::FILE_TYPE_DIR when :symlink - @unix_perms ||= 0755 + @unix_perms ||= 0o755 ::Zip::FILE_TYPE_SYMLINK end unless ft.nil? - @external_file_attributes = (ft << 12 | (@unix_perms & 07777)) << 16 + @external_file_attributes = ((ft << 12) | (@unix_perms & 0o7777)) << 16 end end @@ -461,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.instance_variable_set(:@internal, true) + 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 @@ -515,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' @@ -525,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 @@ -533,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 @@ -561,127 +716,142 @@ 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 - warn 'Invalid date/time in zip entry' if ::Zip.warn_invalid_date + warn 'WARNING: invalid date/time in zip entry.' if ::Zip.warn_invalid_date end 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| - set_extra_attributes_on_path(dest_path) - - buf = '' + bytes_written = 0 + warned = false + buf = +'' while (buf = is.sysread(::Zip::Decompressor::CHUNK_SIZE, buf)) os << buf + bytes_written += buf.bytesize + 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 + + set_extra_attributes_on_path(dest_path) end 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 # BUG: create_symlink() does not use &block def create_symlink(dest_path) - stat = nil - begin - stat = ::File.lstat(dest_path) - rescue Errno::ENOENT - end - - io = get_input_stream - linkto = io.read - - if stat - if stat.symlink? - if ::File.readlink(dest_path) == linkto - return - else - raise ::Zip::DestinationFileExistsError, - "Cannot create symlink '#{dest_path}'. " \ - 'A symlink already exists with that name' - end - else - raise ::Zip::DestinationFileExistsError, - "Cannot create symlink '#{dest_path}'. " \ - 'A file already exists with that name' - end - end - - ::File.symlink(linkto, dest_path) + # TODO: Symlinks pose security challenges. Symlink support temporarily + # removed in view of https://github.com/rubyzip/rubyzip/issues/369 . + warn "WARNING: skipped symlink '#{dest_path}'." end # 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 21bfd381..cb4700d6 100644 --- a/lib/zip/entry_set.rb +++ b/lib/zip/entry_set.rb @@ -1,11 +1,15 @@ +# 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() - @entry_set = {} + @entry_set = {} an_enumerable.each { |o| push(o) } end @@ -34,9 +38,7 @@ def delete(entry) end def each(&block) - @entry_set = sorted_entries.dup.each do |_, value| - block.call(value) - end + 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 b2bcccd2..f172a77f 100644 --- a/lib/zip/errors.rb +++ b/lib/zip/errors.rb @@ -1,17 +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 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 225abeb5..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 self.member?(field_name) - self[field_name].merge(binstr[i, len + 4]) + if member?(field_name) + 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 = '' - 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 7b60bebb..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 self.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) - $stderr.puts 'Warning: weired extra feild header ID. skip parsing' + 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] + # 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 + # default -> false. attr_accessor :restore_ownership - # default -> false + + # default -> true. attr_accessor :restore_permissions - # default -> true + + # default -> true. attr_accessor :restore_times - # Returns the zip files comment, if it has one - attr_accessor :comment - # Opens a zip archive. Pass true as the second parameter to create + def_delegators :@cdir, :comment, :comment=, :each, :entries, :glob, :size + + # Opens a zip archive. Pass create: true to create # a new archive if it doesn't exist already. - def initialize(file_name, 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() - @name = file_name - @comment = '' + + @name = path_or_io.respond_to?(:path) ? path_or_io.path : path_or_io @create = create ? true : false # allow any truthy value to mean true - case - when !buffer && ::File.size?(file_name) - @create = false - @file_permissions = ::File.stat(file_name).mode - ::File.open(name, 'rb') do |f| - read_from_stream(f) - end - when @create - @entry_set = EntrySet.new - when ::File.zero?(file_name) - raise Error, "File #{file_name} has zero size. Did you mean to pass the create flag?" - else - raise Error, "File #{file_name} not found" - end - @stored_entries = @entry_set.dup - @stored_comment = @comment - @restore_ownership = options[:restore_ownership] || false - @restore_permissions = options[:restore_permissions] || true - @restore_times = options[:restore_times] || true + + initialize_cdir(path_or_io, buffer: buffer) + + @restore_ownership = restore_ownership + @restore_permissions = restore_permissions + @restore_times = restore_times + @compression_level = compression_level end class << self - # Same as #new. If a block is passed the ZipFile 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) - zf = ::Zip::File.new(file_name, create) + # 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, + 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 @@ -104,33 +113,33 @@ def open(file_name, create = false) 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 - if io.is_a?(::String) - require 'stringio' - io = ::StringIO.new(io) - elsif io.respond_to?(:binmode) - # https://github.com/rubyzip/rubyzip/issues/119 - io.binmode - end - zf = ::Zip::File.new(io, true, true, options) - zf.read_from_stream(io) + + io = ::StringIO.new(io) if io.kind_of?(::String) + + 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) + return zf unless block_given? + yield zf + begin zf.write_buffer(io) rescue IOError => e @@ -144,92 +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) - case - when MIN_SEGMENT_SIZE > segment_size - MIN_SEGMENT_SIZE - when MAX_SEGMENT_SIZE < segment_size - MAX_SEGMENT_SIZE - else - segment_size + def foreach(zip_file_name, &block) + ::Zip::File.open(zip_file_name) do |zip_file| + zip_file.each(&block) end end - def get_partial_zip_file_name(zip_file_name, partial_zip_file_name) - partial_zip_file_name = zip_file_name.sub(/#{::File.basename(zip_file_name)}\z/, - partial_zip_file_name + ::File.extname(zip_file_name)) unless partial_zip_file_name.nil? - 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 @@ -237,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 @@ -261,70 +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, 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 unless 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 @@ -337,65 +317,86 @@ 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) - @entry_set.find_entry(entry_name) - end + selected_entry = @cdir.find_entry(entry_name) + return if selected_entry.nil? - # Searches for entries given a glob - def glob(*args, &block) - @entry_set.glob(*args, &block) + selected_entry.restore_ownership = @restore_ownership + selected_entry.restore_permissions = @restore_permissions + selected_entry.restore_times = @restore_times + selected_entry end # Searches for an entry just as find_entry, but throws Errno::ENOENT # if no entry is found. def get_entry(entry) selected_entry = find_entry(entry) - raise Errno::ENOENT, entry unless selected_entry - selected_entry.restore_ownership = @restore_ownership - selected_entry.restore_permissions = @restore_permissions - selected_entry.restore_times = @restore_times + raise Errno::ENOENT, entry if selected_entry.nil? + selected_entry end # Creates a directory - def mkdir(entryName, permissionInt = 0755) - 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) @@ -405,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 c77cdf4d..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,607 +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? - return 'file' - elsif directory? - return '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, 0444) - end - alias readable_real? readable? - - def writable?(fileName) - unix_mode_cmp(fileName, 0222) - end - alias writable_real? writable? - - def executable?(fileName) - unix_mode_cmp(fileName, 0111) - end - alias executable_real? executable? - - def setuid?(fileName) - unix_mode_cmp(fileName, 04000) - end - - def setgid?(fileName) - unix_mode_cmp(fileName, 02000) - end - - def sticky?(fileName) - unix_mode_cmp(fileName, 01000) - 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 = 0644, &block) - openMode.gsub!('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 = 0755) - @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 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 = 0755) - @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 0e2b97e1..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 = '' - @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 f8e78868..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) - puts '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,45 +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 && !@internal - 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 - @decompressor = get_decompressor + + @decrypted_io = get_decrypted_io + @decompressor = get_decompressor flush @current_entry end - def get_decompressor - case - when @current_entry.nil? - ::Zip::NullDecompressor - when @current_entry.compression_method == ::Zip::Entry::STORED - ::Zip::PassThruDecompressor.new(@archive_io, @current_entry.size) - when @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}" + 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 + @current_entry.size + end + + 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 5db051e1..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 @@ -33,8 +35,9 @@ def read(number_of_bytes = nil, buf = '') sysread(number_of_bytes, buf) end - if tbuf.nil? || tbuf.length == 0 - return nil if number_of_bytes + if tbuf.nil? || tbuf.empty? + 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 c1246f97..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,11 +13,11 @@ 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) - self << sprintf(a_format_string, *params) + self << format(a_format_string, *params) end def putc(an_object) 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 693678be..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 - if entry_name.kind_of?(Entry) - new_entry = entry_name - else - new_entry = 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 ca30f5d4..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 fc1c9a2d..2fea80c7 100644 --- a/lib/zip/streamable_stream.rb +++ b/lib/zip/streamable_stream.rb @@ -1,13 +1,10 @@ +# frozen_string_literal: true + module Zip - class StreamableStream < DelegateClass(Entry) # nodoc:all + class StreamableStream < DelegateClass(Entry) # :nodoc:all def initialize(entry) super(entry) - dirname = if zipfile.is_a?(::String) - ::File.dirname(zipfile) - else - '.' - end - @temp_file = Tempfile.new(::File.basename(name), dirname) + @temp_file = Tempfile.new(::File.basename(name)) @temp_file.binmode end @@ -27,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? @@ -40,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 f1dc85f4..7957e0de 100644 --- a/lib/zip/version.rb +++ b/lib/zip/version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Zip - VERSION = '1.2.1' + VERSION = '3.0.0.rc2' # :nodoc: end diff --git a/rubyzip.gemspec b/rubyzip.gemspec index ccd9ba56..66ec3657 100644 --- a/rubyzip.gemspec +++ b/rubyzip.gemspec @@ -1,23 +1,39 @@ -#-*- encoding: utf-8 -*- -lib = File.expand_path('../lib', __FILE__) -$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) -require 'zip/version' +# frozen_string_literal: true + +require_relative 'lib/zip/version' Gem::Specification.new do |s| - s.name = 'rubyzip' - s.version = ::Zip::VERSION - s.authors = ['Alexander Simonov'] - s.email = ['alex@simonov.me'] - s.homepage = 'http://github.com/rubyzip/rubyzip' - s.platform = Gem::Platform::RUBY - s.summary = 'rubyzip is a ruby module for reading and writing zip files' - s.files = Dir.glob('{samples,lib}/**/*.rb') + %w(README.md TODO Rakefile) - s.test_files = Dir.glob('test/**/*') - s.require_paths = ['lib'] - s.license = 'BSD 2-Clause' - s.required_ruby_version = '>= 1.9.2' - 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.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 = '>= 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 c0680b72..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 @@ -19,37 +21,36 @@ def initialize(input_dir, output_file) # Zip the input directory. def write - entries = Dir.entries(@input_dir) - %w(. ..) + entries = Dir.entries(@input_dir) - %w[. ..] - ::Zip::File.open(@output_file, ::Zip::File::CREATE) do |io| - write_entries entries, '', io + ::Zip::File.open(@output_file, create: true) do |zipfile| + write_entries entries, '', zipfile end end private # A helper method to make the recursion work. - def write_entries(entries, path, io) + def write_entries(entries, path, zipfile) entries.each do |e| - zip_file_path = path == '' ? e : File.join(path, e) - disk_file_path = File.join(@input_dir, zip_file_path) - puts "Deflating #{disk_file_path}" + zipfile_path = path == '' ? e : File.join(path, e) + disk_file_path = File.join(@input_dir, zipfile_path) if File.directory? disk_file_path - recursively_deflate_directory(disk_file_path, io, zip_file_path) + recursively_deflate_directory(disk_file_path, zipfile, zipfile_path) else - put_into_archive(disk_file_path, io, zip_file_path) + put_into_archive(disk_file_path, zipfile, zipfile_path) end end end - def recursively_deflate_directory(disk_file_path, io, zip_file_path) - io.mkdir zip_file_path - subdir = Dir.entries(disk_file_path) - %w(. ..) - write_entries subdir, zip_file_path, io + def recursively_deflate_directory(disk_file_path, zipfile, zipfile_path) + zipfile.mkdir zipfile_path + subdir = Dir.entries(disk_file_path) - %w[. ..] + write_entries subdir, zipfile_path, zipfile end - def put_into_archive(disk_file_path, io, zip_file_path) - io.add(zip_file_path, disk_file_path) + def put_into_archive(disk_file_path, zipfile, zipfile_path) + zipfile.add(zipfile_path, disk_file_path) end end diff --git a/samples/gtk_ruby_zip.rb b/samples/gtk_ruby_zip.rb index 2b5a2883..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,20 +19,20 @@ 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) box.pack_start(sw, true, true, 0) - @clist = Gtk::CList.new(%w(Name Size Compression)) + @clist = Gtk::CList.new(%w[Name Size Compression]) @clist.set_selection_mode(Gtk::SELECTION_BROWSE) @clist.set_column_width(0, 120) @clist.set_column_width(1, 120) @@ -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 c47fc97c..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.size > 0 ? 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 fa2aa4e6..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 __FILE__ == $0 +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 35a16cde..76d8b305 100644 --- a/test/central_directory_entry_test.rb +++ b/test/central_directory_entry_test.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + require 'test_helper' class ZipCentralDirectoryEntryTest < MiniTest::Test def test_read_from_stream - File.open('test/data/testDirectory.bin', 'rb') do |file| + File.open('test/data/testDirectory.bin', 'rb') do |file| entry = ::Zip::Entry.read_c_dir_entry(file) assert_equal('longAscii.txt', entry.name) @@ -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 new file mode 100644 index 00000000..036424af Binary files /dev/null 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/path_traversal/Makefile b/test/data/path_traversal/Makefile new file mode 100644 index 00000000..9ff4d816 --- /dev/null +++ b/test/data/path_traversal/Makefile @@ -0,0 +1,10 @@ +# Based on 'relative2' in https://github.com/jwilk/path-traversal-samples, +# but create the local `tmp` folder before adding the symlink. Otherwise +# we may bail out before we get to trying to create the file. +all: relative1.zip +relative1.zip: + rm -f $(@) + mkdir -p -m 755 tmp/tmp + umask 022 && echo moo > moo + cd tmp && zip -X ../$(@) tmp tmp/../../moo + rm -rf tmp moo diff --git a/test/data/path_traversal/jwilk/README.md b/test/data/path_traversal/jwilk/README.md new file mode 100644 index 00000000..2ecceb23 --- /dev/null +++ b/test/data/path_traversal/jwilk/README.md @@ -0,0 +1,5 @@ +# Path Traversal Samples + +Copied from https://github.com/jwilk/path-traversal-samples on 2018-08-26. + +License: MIT diff --git a/test/data/path_traversal/jwilk/absolute1.zip b/test/data/path_traversal/jwilk/absolute1.zip new file mode 100644 index 00000000..27c615d9 Binary files /dev/null and b/test/data/path_traversal/jwilk/absolute1.zip differ diff --git a/test/data/path_traversal/jwilk/absolute2.zip b/test/data/path_traversal/jwilk/absolute2.zip new file mode 100644 index 00000000..c82c14ea Binary files /dev/null and b/test/data/path_traversal/jwilk/absolute2.zip differ diff --git a/test/data/path_traversal/jwilk/dirsymlink.zip b/test/data/path_traversal/jwilk/dirsymlink.zip new file mode 100644 index 00000000..978b5d8a Binary files /dev/null and b/test/data/path_traversal/jwilk/dirsymlink.zip differ diff --git a/test/data/path_traversal/jwilk/dirsymlink2a.zip b/test/data/path_traversal/jwilk/dirsymlink2a.zip new file mode 100644 index 00000000..443deede Binary files /dev/null and b/test/data/path_traversal/jwilk/dirsymlink2a.zip differ diff --git a/test/data/path_traversal/jwilk/dirsymlink2b.zip b/test/data/path_traversal/jwilk/dirsymlink2b.zip new file mode 100644 index 00000000..5a5a12b4 Binary files /dev/null and b/test/data/path_traversal/jwilk/dirsymlink2b.zip differ diff --git a/test/data/path_traversal/jwilk/relative0.zip b/test/data/path_traversal/jwilk/relative0.zip new file mode 100644 index 00000000..d27a0d08 Binary files /dev/null and b/test/data/path_traversal/jwilk/relative0.zip differ diff --git a/test/data/path_traversal/jwilk/relative2.zip b/test/data/path_traversal/jwilk/relative2.zip new file mode 100644 index 00000000..8957028d Binary files /dev/null and b/test/data/path_traversal/jwilk/relative2.zip differ diff --git a/test/data/path_traversal/jwilk/symlink.zip b/test/data/path_traversal/jwilk/symlink.zip new file mode 100644 index 00000000..edaa7526 Binary files /dev/null and b/test/data/path_traversal/jwilk/symlink.zip differ diff --git a/test/data/path_traversal/relative1.zip b/test/data/path_traversal/relative1.zip new file mode 100644 index 00000000..bfcb9def Binary files /dev/null and b/test/data/path_traversal/relative1.zip differ diff --git a/test/data/path_traversal/tilde.zip b/test/data/path_traversal/tilde.zip new file mode 100644 index 00000000..0442ab93 Binary files /dev/null and b/test/data/path_traversal/tilde.zip differ diff --git a/test/data/path_traversal/tuzovakaoff/README.md b/test/data/path_traversal/tuzovakaoff/README.md new file mode 100644 index 00000000..f599810e --- /dev/null +++ b/test/data/path_traversal/tuzovakaoff/README.md @@ -0,0 +1,3 @@ +# Path Traversal Samples + +Copied from https://github.com/tuzovakaoff/zip_path_traversal on 2018-08-25. diff --git a/test/data/path_traversal/tuzovakaoff/absolutepath.zip b/test/data/path_traversal/tuzovakaoff/absolutepath.zip new file mode 100644 index 00000000..59fceed7 Binary files /dev/null and b/test/data/path_traversal/tuzovakaoff/absolutepath.zip differ diff --git a/test/data/path_traversal/tuzovakaoff/symlink.zip b/test/data/path_traversal/tuzovakaoff/symlink.zip new file mode 100644 index 00000000..e74ee19a Binary files /dev/null and b/test/data/path_traversal/tuzovakaoff/symlink.zip differ diff --git a/test/data/rubycode.zip b/test/data/rubycode.zip index 8a68560e..06134bbc 100644 Binary files a/test/data/rubycode.zip and b/test/data/rubycode.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 a65a3afa..00000000 --- a/test/errors_test.rb +++ /dev/null @@ -1,34 +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 new file mode 100644 index 00000000..9f16b616 --- /dev/null +++ b/test/extra_field_ut_test.rb @@ -0,0 +1,98 @@ +# 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], + ["UT\x05\x00\x04PS>A", 0b100, true, false, true], + ["UT\x09\x00\x03PS>APS>A", 0b011, false, true, false], + ["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| + ut = ::Zip::ExtraField::UniversalTime.new(bin) + assert_equal(flags, ut.flag) + assert(ut.atime.nil? == a) + assert(ut.ctime.nil? == c) + assert(ut.mtime.nil? == m) + end + end + + def test_parse_size_zero + ut = ::Zip::ExtraField::UniversalTime.new("UT\x00") + assert_equal(0b000, ut.flag) + assert_nil(ut.atime) + assert_nil(ut.ctime) + assert_nil(ut.mtime) + end + + def test_parse_size_nil + ut = ::Zip::ExtraField::UniversalTime.new('UT') + assert_equal(0b000, ut.flag) + assert_nil(ut.atime) + assert_nil(ut.ctime) + assert_nil(ut.mtime) + end + + def test_parse_nil + ut = ::Zip::ExtraField::UniversalTime.new + assert_equal(0b000, ut.flag) + assert_nil(ut.atime) + assert_nil(ut.ctime) + assert_nil(ut.mtime) + end + + def test_set_clear_times + time = ::Zip::DOSTime.now + ut = ::Zip::ExtraField::UniversalTime.new + assert_equal(0b000, ut.flag) + + ut.mtime = time + assert_equal(0b001, ut.flag) + assert_equal(time, ut.mtime) + + ut.ctime = time + assert_equal(0b101, ut.flag) + assert_equal(time, ut.ctime) + + ut.atime = time + assert_equal(0b111, ut.flag) + assert_equal(time, ut.atime) + + ut.ctime = nil + assert_equal(0b011, ut.flag) + assert_nil ut.ctime + + ut.mtime = nil + assert_equal(0b010, ut.flag) + assert_nil ut.mtime + + ut.atime = nil + assert_equal(0b000, ut.flag) + assert_nil ut.atime + end + + def test_pack + time = ::Zip::DOSTime.at('PS>A'.unpack1('l<')) + ut = ::Zip::ExtraField::UniversalTime.new + assert_equal("\x00", ut.pack_for_local) + assert_equal("\x00", ut.pack_for_c_dir) + + ut.mtime = time + assert_equal("\x01PS>A", ut.pack_for_local) + assert_equal("\x01PS>A", ut.pack_for_c_dir) + + ut.atime = time + assert_equal("\x03PS>APS>A", ut.pack_for_local) + assert_equal("\x03PS>A", ut.pack_for_c_dir) + + ut.ctime = time + assert_equal("\x07PS>APS>APS>A", ut.pack_for_local) + assert_equal("\x07PS>A", ut.pack_for_c_dir) + end +end 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 57833fcb..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 @@ -10,13 +13,17 @@ def setup ::File.delete(EXTRACTED_FILENAME) if ::File.exist?(EXTRACTED_FILENAME) end + def teardown + ::Zip.reset! + end + def test_extract ::Zip::File.open(TEST_ZIP.zip_name) do |zf| zf.extract(ENTRY_TO_EXTRACT, EXTRACTED_FILENAME) 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) @@ -25,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 @@ -69,15 +77,138 @@ 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 + # https://en.wikipedia.org/wiki/Zip_bomb + 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 = [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.binwrite(fake_zip, data) + + Dir.chdir tmp do + ::Zip::File.open(fake_zip) do |zf| + a_entry = zf.find_entry(file_name) + assert_equal fake_size, a_entry.size + + ::Zip.validate_entry_sizes = false + assert_output('', /.+'a'.+1B.+/) do + a_entry.extract + end + assert_equal true_size, File.size(file_name) + FileUtils.rm file_name + + ::Zip.validate_entry_sizes = true + error = assert_raises ::Zip::EntrySizeError do + a_entry.extract + end + assert_equal( + "Entry 'a' should be 1B, but is larger when inflated.", + error.message + ) + end + end + end + end + + def test_extract_incorrect_size_zip64 + # The uncompressed size fields in the zip file cannot be trusted. This makes + # it harder for callers to validate the sizes of the files they are + # extracting, which can lead to denial of service. See also + # https://en.wikipedia.org/wiki/Zip_bomb + # + # This version of the test ensures that fraudulent sizes in the ZIP64 + # extensions are caught. + Dir.mktmpdir do |tmp| + real_zip = File.join(tmp, 'real.zip') + fake_zip = File.join(tmp, 'fake.zip') + file_name = 'a' + true_size = 500_000 + fake_size = 1 + + ::Zip::File.open(real_zip, create: true) do |zf| + zf.get_output_stream(file_name) do |os| + os.write 'a' * true_size + end + end + + compressed_size = nil + ::Zip::File.open(real_zip) do |zf| + a_entry = zf.find_entry(file_name) + compressed_size = a_entry.compressed_size + assert_equal true_size, a_entry.size + end + + true_size_bytes = [0x1, 16, true_size, compressed_size].pack('vvQ sizes[1]) + assert(sizes[1] > sizes[2]) + end + + def test_add_different_compression_as_default + src_file = 'test/data/file2.txt' + entry_name = 'newEntryName.rb' + files = [ + ['test/data/fast_comp.zip', Zlib::BEST_SPEED], + ['test/data/default_comp.zip', Zlib::DEFAULT_COMPRESSION], + ['test/data/best_comp.zip', Zlib::BEST_COMPRESSION] + ] + sizes = [] + + files.each do |name, comp| + ::Zip.default_compression = comp + zf = ::Zip::File.new(name, create: true) + + zf.add(entry_name, src_file) + zf.close + + zf_read = ::Zip::File.new(name) + entry = zf_read.entries.first + assert_equal(File.size(src_file), entry.size) + refute(entry.zip64?) # No ZIP64 extra as we know the entry size here. + AssertEntry.assert_contents( + src_file, zf_read.get_input_stream(entry.name, &:read) + ) + sizes << entry.compressed_size + zf_read.close + + ::File.delete(name) + end + + assert(sizes[0] > sizes[1]) + assert(sizes[1] > sizes[2]) + end + + def test_add_stored + src_file = 'test/data/file2.txt' + entry_name = 'newEntryName.rb' + assert(::File.exist?(src_file)) + zf = ::Zip::File.new(EMPTY_FILENAME, create: true) + zf.add_stored(entry_name, src_file) zf.close - zfRead = ::Zip::File.new(EMPTY_FILENAME) - assert_equal('', zfRead.comment) - assert_equal(1, zfRead.entries.length) - assert_equal(entryName, zfRead.entries.first.name) - AssertEntry.assert_contents(srcFile, - zfRead.get_input_stream(entryName) { |zis| zis.read }) + 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) + 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(0664, srcZip) - srcFile = 'test/data/file2.txt' - entryName = 'newEntryName.rb' - assert_equal(::File.stat(srcZip).mode, 0100664) - 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, 0100664) + + 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 @@ -178,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 @@ -230,11 +516,11 @@ 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/' - %w(a b c d).each do |f| + %w[a b c d].each do |f| zf.get_output_stream("test/#{f}") { |file| file.puts 'aaaa' } arr << "test/#{f}" arr_renamed << "Ztest/#{f}" @@ -243,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 @@ -255,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 @@ -303,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' - ::Zip::File.open(TEST_ZIP.zip_name) do |zf| - assert_raises(Errno::ENOENT) { zf.replace(entryToReplace, 'test/data/file2.txt') } + replace_entry = 'nonExistingEntryname' + ::Zip::File.open(TEST_ZIP.zip_name) do |zf| + 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 -t #{TEST_ZIP.zip_name}") + 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) - res = system("unzip -t #{filename}") + 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 @@ -410,53 +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 @@ -466,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 @@ -492,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 @@ -533,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 @@ -558,7 +878,7 @@ def test_odd_extra_field entry_count = 0 File.open 'test/data/oddExtraField.zip', 'rb' do |zip_io| Zip::File.open_buffer zip_io.read do |zip| - zip.each do |zip_entry| + zip.each do |_zip_entry| entry_count += 1 end end @@ -570,14 +890,35 @@ def test_open_xls_does_not_raise_type_error ::Zip::File.open('test/data/test.xls') end + def test_find_get_entry + ::Zip::File.open(TEST_ZIP.zip_name) do |zf| + assert_nil zf.find_entry('not_in_here.txt') + + refute_nil zf.find_entry('test/data/generated/empty.txt') + + assert_raises(Errno::ENOENT) do + zf.get_entry('not_in_here.txt') + end + + # Should not raise anything. + zf.get_entry('test/data/generated/empty.txt') + 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 - 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 c2cf00ac..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 68% rename from test/filesystem/directory_test.rb rename to test/filesystem/dir_test.rb index d6c029f3..2db69c22 100644 --- a/test/filesystem/directory_test.rb +++ b/test/filesystem/dir_test.rb @@ -1,8 +1,11 @@ +# 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' def setup FileUtils.cp('test/data/zipWithDirs.zip', TEST_ZIP) @@ -51,10 +54,10 @@ def test_pwd_chdir_entries zf.dir.chdir 'file1' end - assert_equal(%w(dir1 dir2 file1).sort, zf.dir.entries('.').sort) + assert_equal(%w[dir1 dir2 file1].sort, zf.dir.entries('.').sort) zf.dir.chdir 'dir1' assert_equal('/dir1', zf.dir.pwd) - assert_equal(%w(dir11 file11 file12), zf.dir.entries('.').sort) + assert_equal(%w[dir11 file11 file12], zf.dir.entries('.').sort) zf.dir.chdir '../dir2/dir21' assert_equal('/dir2/dir21', zf.dir.pwd) @@ -64,24 +67,24 @@ 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 } - assert_equal(%w(dir1 dir2 file1).sort, entries.sort) + assert_equal(%w[dir1 dir2 file1].sort, entries.sort) entries = [] zf.dir.foreach('dir1') { |e| entries << e } - assert_equal(%w(dir11 file11 file12), entries.sort) + assert_equal(%w[dir11 file11 file12], entries.sort) end end @@ -93,11 +96,28 @@ def test_chroot end end - # Globbing not supported yet - # def test_glob - # # test alias []-operator too - # fail "implement test" - # end + def test_glob + globbed_files = [ + 'globTest/foo/bar/baz/foo.txt', + 'globTest/foo.txt', + 'globTest/food.txt' + ] + + ::Zip::File.open(GLOB_TEST_ZIP) do |zf| + zf.dir.glob('**/*.txt') do |f| + assert globbed_files.include?(f.name) + end + + zf.dir.glob('globTest/foo/**/*.txt') do |f| + assert_equal globbed_files[0], f.name + end + + zf.dir.chdir('globTest/foo') + zf.dir.glob('**/*.txt') do |f| + assert_equal globbed_files[0], f.name + end + end + end def test_open_new ::Zip::File.open(TEST_ZIP) do |zf| @@ -110,11 +130,11 @@ def test_open_new end d = zf.dir.new('.') - assert_equal(%w(file1 dir1 dir2).sort, d.entries.sort) + assert_equal(%w[file1 dir1 dir2].sort, d.entries.sort) d.close zf.dir.open('dir1') do |dir| - assert_equal(%w(dir11 file11 file12).sort, dir.entries.sort) + assert_equal(%w[dir11 file11 file12].sort, dir.entries.sort) end end end 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 e398060a..6264f0f0 100644 --- a/test/filesystem/file_mutating_test.rb +++ b/test/filesystem/file_mutating_test.rb @@ -1,14 +1,15 @@ +# 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) end - def teardown - end + def teardown; end def test_delete do_test_delete_or_unlink(:delete) @@ -51,11 +52,11 @@ def test_rename def test_chmod ::Zip::File.open(TEST_ZIP) do |zf| - zf.file.chmod(0765, 'file1') + zf.file.chmod(0o765, 'file1') end ::Zip::File.open(TEST_ZIP) do |zf| - assert_equal(0100765, zf.file.stat('file1').mode) + assert_equal(0o100765, zf.file.stat('file1').mode) end end diff --git a/test/filesystem/file_nonmutating_test.rb b/test/filesystem/file_nonmutating_test.rb index e8242258..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') @@ -14,11 +16,11 @@ def teardown def test_umask assert_equal(::File.umask, @zip_file.file.umask) - @zip_file.file.umask(0006) + @zip_file.file.umask(0o006) end def test_exists? - assert(! @zip_file.file.exists?('notAFile')) + assert(!@zip_file.file.exists?('notAFile')) assert(@zip_file.file.exists?('file1')) assert(@zip_file.file.exists?('dir1')) assert(@zip_file.file.exists?('dir1/')) @@ -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 @@ -114,31 +116,31 @@ def test_size? def test_file? assert(@zip_file.file.file?('file1')) assert(@zip_file.file.file?('dir2/file21')) - assert(! @zip_file.file.file?('dir1')) - assert(! @zip_file.file.file?('dir1/dir11')) + assert(!@zip_file.file.file?('dir1')) + assert(!@zip_file.file.file?('dir1/dir11')) assert(@zip_file.file.stat('file1').file?) assert(@zip_file.file.stat('dir2/file21').file?) - assert(! @zip_file.file.stat('dir1').file?) - assert(! @zip_file.file.stat('dir1/dir11').file?) + assert(!@zip_file.file.stat('dir1').file?) + assert(!@zip_file.file.stat('dir1/dir11').file?) end 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,25 +152,16 @@ 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')) - assert(! @zip_file.file.send(operation, 'dir1')) - assert(! @zip_file.file.stat('file1').send(operation)) - assert(! @zip_file.file.stat('dir1').send(operation)) + assert(!@zip_file.file.send(operation, 'noSuchFile')) + assert(!@zip_file.file.send(operation, 'file1')) + assert(!@zip_file.file.send(operation, 'dir1')) + assert(!@zip_file.file.stat('file1').send(operation)) + assert(!@zip_file.file.stat('dir1').send(operation)) end def assert_true_if_entry_exists(operation) - assert(! @zip_file.file.send(operation, 'noSuchFile')) + assert(!@zip_file.file.send(operation, 'noSuchFile')) assert(@zip_file.file.send(operation, 'file1')) assert(@zip_file.file.send(operation, 'dir1')) assert(@zip_file.file.stat('file1').send(operation)) @@ -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? @@ -221,15 +217,15 @@ def test_link end def test_directory? - assert(! @zip_file.file.directory?('notAFile')) - assert(! @zip_file.file.directory?('file1')) - assert(! @zip_file.file.directory?('dir1/file11')) + assert(!@zip_file.file.directory?('notAFile')) + assert(!@zip_file.file.directory?('file1')) + assert(!@zip_file.file.directory?('dir1/file11')) assert(@zip_file.file.directory?('dir1')) assert(@zip_file.file.directory?('dir1/')) assert(@zip_file.file.directory?('dir2/dir21')) - assert(! @zip_file.file.stat('file1').directory?) - assert(! @zip_file.file.stat('dir1/file11').directory?) + assert(!@zip_file.file.stat('file1').directory?) + assert(!@zip_file.file.stat('dir1/file11').directory?) assert(@zip_file.file.stat('dir1').directory?) assert(@zip_file.file.stat('dir1/').directory?) assert(@zip_file.file.stat('dir2/dir21').directory?) @@ -243,24 +239,24 @@ def test_chown end def test_zero? - assert(! @zip_file.file.zero?('notAFile')) - assert(! @zip_file.file.zero?('file1')) + 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('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 @@ -309,7 +307,7 @@ def test_ntfs_time end def test_readable? - assert(! @zip_file.file.readable?('noSuchFile')) + assert(!@zip_file.file.readable?('noSuchFile')) assert(@zip_file.file.readable?('file1')) assert(@zip_file.file.readable?('dir1')) assert(@zip_file.file.stat('file1').readable?) @@ -317,7 +315,7 @@ def test_readable? end def test_readable_real? - assert(! @zip_file.file.readable_real?('noSuchFile')) + assert(!@zip_file.file.readable_real?('noSuchFile')) assert(@zip_file.file.readable_real?('file1')) assert(@zip_file.file.readable_real?('dir1')) assert(@zip_file.file.stat('file1').readable_real?) @@ -325,7 +323,7 @@ def test_readable_real? end def test_writable? - assert(! @zip_file.file.writable?('noSuchFile')) + assert(!@zip_file.file.writable?('noSuchFile')) assert(@zip_file.file.writable?('file1')) assert(@zip_file.file.writable?('dir1')) assert(@zip_file.file.stat('file1').writable?) @@ -333,7 +331,7 @@ def test_writable? end def test_writable_real? - assert(! @zip_file.file.writable_real?('noSuchFile')) + assert(!@zip_file.file.writable_real?('noSuchFile')) assert(@zip_file.file.writable_real?('file1')) assert(@zip_file.file.writable_real?('dir1')) assert(@zip_file.file.stat('file1').writable_real?) @@ -341,18 +339,18 @@ def test_writable_real? end def test_executable? - assert(! @zip_file.file.executable?('noSuchFile')) - assert(! @zip_file.file.executable?('file1')) + assert(!@zip_file.file.executable?('noSuchFile')) + assert(!@zip_file.file.executable?('file1')) assert(@zip_file.file.executable?('dir1')) - assert(! @zip_file.file.stat('file1').executable?) + assert(!@zip_file.file.stat('file1').executable?) assert(@zip_file.file.stat('dir1').executable?) end def test_executable_real? - assert(! @zip_file.file.executable_real?('noSuchFile')) - assert(! @zip_file.file.executable_real?('file1')) + assert(!@zip_file.file.executable_real?('noSuchFile')) + assert(!@zip_file.file.executable_real?('file1')) assert(@zip_file.file.executable_real?('dir1')) - assert(! @zip_file.file.stat('file1').executable_real?) + assert(!@zip_file.file.stat('file1').executable_real?) assert(@zip_file.file.stat('dir1').executable_real?) end @@ -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 @@ -455,7 +455,7 @@ def test_glob zf.glob('**/foo.txt') do |match| results << "<#{match.class.name}: #{match}>" end - assert((!results.empty?), 'block not run, or run out of context') + assert(!results.empty?, 'block not run, or run out of context') assert_equal 2, results.size assert_operator results, :include?, '' assert_operator results, :include?, '' @@ -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 51e60d9c..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 @@ -32,10 +34,10 @@ def test_ftype end def test_mode - assert_equal(0600, @zip_file.file.stat('file1').mode & 0777) - assert_equal(0600, @zip_file.file.stat('file1').mode & 0777) - assert_equal(0755, @zip_file.file.stat('dir1').mode & 0777) - assert_equal(0755, @zip_file.file.stat('dir1').mode & 0777) + assert_equal(0o600, @zip_file.file.stat('file1').mode & 0o777) + assert_equal(0o600, @zip_file.file.stat('file1').mode & 0o777) + assert_equal(0o755, @zip_file.file.stat('dir1').mode & 0o777) + assert_equal(0o755, @zip_file.file.stat('dir1').mode & 0o777) end def test_dev diff --git a/test/gentestfiles.rb b/test/gentestfiles.rb index 88ffd385..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 #{TEST_ZIP1.zip_name} test/data/file2.txt") - raise "failed to remove entry from '#{TEST_ZIP1.zip_name}'" unless system("/usr/bin/zip #{TEST_ZIP1.zip_name} -d test/data/file2.txt") - - File.open('test/data/generated/empty.txt', 'w') {} - File.open('test/data/generated/empty_chmod640.txt', 'w') {} - ::File.chmod(0640, '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 + 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', '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 #{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 -z #{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 \" 0 + assert !zis.gets.empty? assert_equal(false, zis.eof?) entry = zis.get_next_entry # empty.txt assert_equal(TestZipFile::TEST_ZIP2.entry_names[1], entry.name) @@ -84,20 +145,20 @@ def test_incomplete_reads assert_equal(true, zis.eof?) entry = zis.get_next_entry # short.txt assert_equal(TestZipFile::TEST_ZIP2.entry_names[3], entry.name) - assert zis.gets.length > 0 + assert !zis.gets.empty? entry = zis.get_next_entry # longBinary.bin assert_equal(TestZipFile::TEST_ZIP2.entry_names[4], entry.name) - assert zis.gets.length > 0 + assert !zis.gets.empty? end end def test_incomplete_reads_from_string_io - string_io = ::StringIO.new(::File.read(TestZipFile::TEST_ZIP2.zip_name)) + string_io = ::StringIO.new(::File.read(TestZipFile::TEST_ZIP2.zip_name, mode: 'rb')) ::Zip::InputStream.open(string_io) do |zis| entry = zis.get_next_entry # longAscii.txt assert_equal(false, zis.eof?) assert_equal(TestZipFile::TEST_ZIP2.entry_names[0], entry.name) - assert zis.gets.length > 0 + assert !zis.gets.empty? assert_equal(false, zis.eof?) entry = zis.get_next_entry # empty.txt assert_equal(TestZipFile::TEST_ZIP2.entry_names[1], entry.name) @@ -111,10 +172,10 @@ def test_incomplete_reads_from_string_io assert_equal(true, zis.eof?) entry = zis.get_next_entry # short.txt assert_equal(TestZipFile::TEST_ZIP2.entry_names[3], entry.name) - assert zis.gets.length > 0 + assert !zis.gets.empty? entry = zis.get_next_entry # longBinary.bin assert_equal(TestZipFile::TEST_ZIP2.entry_names[4], entry.name) - assert zis.gets.length > 0 + assert !zis.gets.empty? end end @@ -128,13 +189,19 @@ def test_read_with_number_of_bytes_returns_nil_at_eof end end + def test_read_with_zero_returns_empty_string + ::Zip::InputStream.open(TestZipFile::TEST_ZIP2.zip_name) do |zis| + assert_equal('', zis.read(0)) + end + end + def test_rewind ::Zip::InputStream.open(TestZipFile::TEST_ZIP2.zip_name) do |zis| e = zis.get_next_entry assert_equal(TestZipFile::TEST_ZIP2.entry_names[0], e.name) # Do a little reading - buf = '' + buf = +'' buf << zis.read(100) assert_equal(100, zis.pos) buf << (zis.gets || '') @@ -143,7 +210,7 @@ def test_rewind zis.rewind - buf2 = '' + buf2 = +'' buf2 << zis.read(100) buf2 << (zis.gets || '') buf2 << (zis.gets || '') @@ -179,4 +246,14 @@ def test_ungetc assert_equal('$VERBOSE =', zis.read(10)) end end + + def test_readline_then_read + ::Zip::InputStream.open(TestZipFile::TEST_ZIP2.zip_name) do |zis| + zis.get_next_entry + assert_equal("#!/usr/bin/env ruby\n", zis.readline) + refute(zis.eof?) + refute_empty(zis.read) # Also should not raise an error. + assert(zis.eof?) + end + end end diff --git a/test/ioextras/abstract_input_stream_test.rb b/test/ioextras/abstract_input_stream_test.rb index 3ae005d1..2ef849c8 100644 --- a/test/ioextras/abstract_input_stream_test.rb +++ b/test/ioextras/abstract_input_stream_test.rb @@ -1,26 +1,31 @@ +# frozen_string_literal: true + require 'test_helper' require 'zip/ioextras' class AbstractInputStreamTest < MiniTest::Test # AbstractInputStream subclass that provides a read method - TEST_LINES = ["Hello world#{$/}", - "this is the second line#{$/}", - 'this is the last line'] + TEST_LINES = [ + "Hello world#{$INPUT_RECORD_SEPARATOR}", + "this is the second line#{$INPUT_RECORD_SEPARATOR}", + 'this is the last line' + ].freeze TEST_STRING = TEST_LINES.join + class TestAbstractInputStream include ::Zip::IOExtras::AbstractInputStream - def initialize(aString) + def initialize(string) super() - @contents = aString - @readPointer = 0 + @contents = string + @read_ptr = 0 end - def sysread(charsToRead, _buf = nil) - retVal = @contents[@readPointer, charsToRead] - @readPointer += charsToRead - retVal + def sysread(chars_to_read, _buf = nil) + ret_val = @contents[@read_ptr, chars_to_read] + @read_ptr += chars_to_read + ret_val end def produce_input @@ -28,7 +33,7 @@ def produce_input end def input_finished? - @contents[@readPointer].nil? + @contents[@read_ptr].nil? end end @@ -50,14 +55,14 @@ def test_gets def test_gets_multi_char_seperator assert_equal('Hell', @io.gets('ll')) - assert_equal("o world#{$/}this is the second l", @io.gets('d l')) + assert_equal("o world#{$INPUT_RECORD_SEPARATOR}this is the second l", @io.gets('d l')) end LONG_LINES = [ - 'x' * 48 + "\r\n", - 'y' * 49 + "\r\n", + "#{'x' * 48}\r\n", + "#{'y' * 49}\r\n", 'rest' - ] + ].freeze def test_gets_mulit_char_seperator_split io = TestAbstractInputStream.new(LONG_LINES.join) @@ -69,7 +74,7 @@ def test_gets_mulit_char_seperator_split def test_gets_with_sep_and_index io = TestAbstractInputStream.new(LONG_LINES.join) assert_equal('x', io.gets("\r\n", 1)) - assert_equal('x' * 47 + "\r", io.gets("\r\n", 48)) + assert_equal("#{'x' * 47}\r", io.gets("\r\n", 48)) assert_equal("\n", io.gets(nil, 1)) assert_equal('yy', io.gets(nil, 2)) end @@ -80,10 +85,10 @@ def test_gets_with_index end def test_each_line - lineNumber = 0 + line_num = 0 @io.each_line do |line| - assert_equal(TEST_LINES[lineNumber], line) - lineNumber += 1 + assert_equal(TEST_LINES[line_num], line) + line_num += 1 end end @@ -95,7 +100,7 @@ def test_readline test_gets begin @io.readline - fail 'EOFError expected' + raise 'EOFError expected' rescue EOFError end end diff --git a/test/ioextras/abstract_output_stream_test.rb b/test/ioextras/abstract_output_stream_test.rb index 3c2cefa0..6c7166ea 100644 --- a/test/ioextras/abstract_output_stream_test.rb +++ b/test/ioextras/abstract_output_stream_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' require 'zip/ioextras' @@ -8,7 +10,7 @@ class TestOutputStream attr_accessor :buffer def initialize - @buffer = '' + @buffer = +'' end def <<(data) @@ -20,27 +22,27 @@ def <<(data) def setup @output_stream = TestOutputStream.new - @origCommaSep = $, - @origOutputSep = $\ + @save_output_sep = $OUTPUT_RECORD_SEPARATOR end def teardown - $, = @origCommaSep - $\ = @origOutputSep + $OUTPUT_RECORD_SEPARATOR = @save_output_sep end def test_write - count = @output_stream.write('a little string') - assert_equal('a little string', @output_stream.buffer) - assert_equal('a little string'.length, count) - - count = @output_stream.write('. a little more') - assert_equal('a little string. a little more', @output_stream.buffer) - assert_equal('. a little more'.length, count) + str1 = 'a little string' + count = @output_stream.write(str1) + assert_equal(str1, @output_stream.buffer) + assert_equal(str1.length, count) + + str2 = '. a little more' + count = @output_stream.write(str2) + assert_equal(str1 + str2, @output_stream.buffer) + assert_equal(str2.length, count) end def test_print - $\ = nil # record separator set to nil + $OUTPUT_RECORD_SEPARATOR = nil # record separator set to nil @output_stream.print('hello') assert_equal('hello', @output_stream.buffer) @@ -50,26 +52,21 @@ def test_print @output_stream.print(' You ok ', 'out ', 'there?') assert_equal('hello world. You ok out there?', @output_stream.buffer) - $\ = "\n" + $OUTPUT_RECORD_SEPARATOR = "\n" @output_stream.print assert_equal("hello world. You ok out there?\n", @output_stream.buffer) @output_stream.print('I sure hope so!') assert_equal("hello world. You ok out there?\nI sure hope so!\n", @output_stream.buffer) - $, = 'X' - @output_stream.buffer = '' - @output_stream.print('monkey', 'duck', 'zebra') - assert_equal("monkeyXduckXzebra\n", @output_stream.buffer) - - $\ = nil - @output_stream.buffer = '' + $OUTPUT_RECORD_SEPARATOR = nil + @output_stream.buffer = +'' @output_stream.print(20) assert_equal('20', @output_stream.buffer) end def test_printf - @output_stream.printf('%d %04x', 123, 123) + @output_stream.printf('%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.puts(["hello\n", "world\n"]) + @output_stream.buffer = +'' + @output_stream.puts(%W[hello\n world\n]) assert_equal("hello\nworld\n", @output_stream.buffer) - @output_stream.buffer = '' - @output_stream.puts(["hello\n", "world\n"], 'bingo') + @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 new file mode 100644 index 00000000..e1ec9a74 --- /dev/null +++ b/test/path_traversal_test.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PathTraversalTest < MiniTest::Test + TEST_FILE_ROOT = File.absolute_path('test/data/path_traversal') + + def setup + # With apologies to anyone using these files... but they are the files in + # the sample zips, so we don't have much choice here. + FileUtils.rm_f '/tmp/moo' + FileUtils.rm_f '/tmp/file.txt' + end + + def extract_paths(zip_path, entries) + ::Zip::File.open(::File.join(TEST_FILE_ROOT, zip_path)) do |zip| + entries.each do |entry, test| + if test == :error + assert_raises(Errno::ENOENT) do + zip.find_entry(entry).extract + end + else + assert_output('', test) do + zip.find_entry(entry).extract + end + end + end + end + end + + def in_tmpdir + Dir.mktmpdir do |tmp| + test_path = File.join(tmp, 'test') + Dir.mkdir test_path + Dir.chdir test_path do + yield test_path + end + end + end + + def test_leading_slash + 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' => '' } + 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 extracting '\.\.\/moo'/ } + in_tmpdir do + extract_paths(['jwilk', 'relative0.zip'], entries) + refute File.exist?('../moo') + end + end + + def test_non_leading_dot_dot_with_existing_folder + entries = { + 'tmp/' => '', + 'tmp/../../moo' => /WARNING: skipped extracting 'tmp\/\.\.\/\.\.\/moo'/ + } + in_tmpdir do + extract_paths('relative1.zip', entries) + assert Dir.exist?('tmp') + refute File.exist?('../moo') + end + end + + def test_non_leading_dot_dot_without_existing_folder + entries = { 'tmp/../../moo' => /WARNING: skipped extracting 'tmp\/\.\.\/\.\.\/moo'/ } + in_tmpdir do + extract_paths(['jwilk', 'relative2.zip'], entries) + refute File.exist?('../moo') + end + end + + def test_file_symlink + entries = { 'moo' => '' } + in_tmpdir do + extract_paths(['jwilk', 'symlink.zip'], entries) + assert File.exist?('moo') + refute File.exist?('/tmp/moo') + end + end + + def test_directory_symlink + # Can't create tmp/moo, because the tmp symlink is skipped. + entries = { + 'tmp' => /WARNING: skipped symlink '.*\/tmp'/, + 'tmp/moo' => :error + } + in_tmpdir do + extract_paths(['jwilk', 'dirsymlink.zip'], entries) + refute File.exist?('/tmp/moo') + end + end + + 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'/, + 'par/moo' => :error + } + in_tmpdir do + extract_paths(['jwilk', 'dirsymlink2a.zip'], entries) + refute File.exist?('cur') + refute File.exist?('par') + refute File.exist?('par/moo') + end + end + + 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'/, + 'par/moo' => :error + } + in_tmpdir do + extract_paths(['jwilk', 'dirsymlink2b.zip'], entries) + refute File.exist?('cur') + refute File.exist?('../moo') + end + end + + 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 + + def test_entry_name_with_absolute_path_extract_when_given_different_path + 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(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/file.txt' => :error + } + in_tmpdir do + extract_paths(['tuzovakaoff', 'symlink.zip'], entries) + refute File.exist?('/tmp/file.txt') + end + end + + def test_entry_name_with_tilde + in_tmpdir do + extract_paths('tilde.zip', '~tilde~' => '') + assert File.exist?('~tilde~') + end + end +end 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 c2c9cce1..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 @@ -80,16 +84,18 @@ def test_true_warn_invalid_date test_file = File.join(File.dirname(__FILE__), 'data', 'WarnInvalidDate.zip') Zip.warn_invalid_date = true - assert_output('', /Invalid date\/time in zip entry/) do - ::Zip::File.open(test_file) do |_zf| - end + assert_output('', /invalid date\/time in zip entry/) do + ::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 c7cbfb95..8f665b1c 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,7 +1,10 @@ +# frozen_string_literal: true + require 'simplecov' require 'minitest/autorun' require 'minitest/unit' require 'fileutils' +require 'tmpdir' require 'digest/sha1' require 'zip' require 'gentestfiles' @@ -9,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 @@ -94,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 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 @@ -115,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 @@ -158,7 +109,7 @@ class TestOutputStream attr_accessor :buffer def initialize - @buffer = '' + @buffer = +'' end def <<(data) @@ -167,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 @@ -196,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 @@ -227,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 b9b1967a..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'] @@ -33,12 +37,23 @@ def test_unicode_file_name assert(filepath == entry_name) end end + + ::Zip.force_entry_names_encoding = 'UTF-8' + ::Zip::File.open(FILENAME) do |zip| + file_entrys.each do |filename| + refute_nil(zip.find_entry(filename)) + end + directory_entrys.each do |filepath| + refute_nil(zip.find_entry(filepath)) + end + end + ::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 d7fccbb4..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 -t #{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