omnizip 0.3.41 → 0.3.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 919a322870094f270bd574530af4c463b6446586fdf869026b79ad017834bc1e
4
- data.tar.gz: 55507eb0b2da4109cb094e711cda2d39df10773c9774c4a01614edc0d8548d9a
3
+ metadata.gz: 436437db6697f7363305eda4e6a3e2b78b922aa12b3c43e29756efe187310b60
4
+ data.tar.gz: 6e0b17e143c50beae378cdb5b7902e773d888bca14183fcf83e61dfeabe56a5e
5
5
  SHA512:
6
- metadata.gz: 73b03468e2bb51d227059f316ab4fce911ce19ee6e0c175559de7b1df58a69536d9284d2b5ee2a0a19366ef0c53d448d02e4b95f397042c69c569630aacea86f
7
- data.tar.gz: dad012852021f331f77a36bf86842274f612d215e32829965bf4bbaf5ad8b9b17539eb7e531a55e73c93d206c73a66760ccde6dfcf234b49e371f17f79451df8
6
+ metadata.gz: f1e20ae301093cacb8c582e1b554a678c72cefa6bf21095d129bebc14b8c4095a68a87da653ef5ea16cb65c00de9edd6ae7b0d92191e49c25e05438ceba264ab
7
+ data.tar.gz: a6ad938ab6bdfd75586bcef013ba056cc523681ef34449d03bc44daf18a591e6485b292b7c62bd17c72c13c20c7ff8c45caf4faf6735d5cff79a0c2f4a5a010b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.42] - 2026-08-29
11
+
12
+ ### Changed
13
+ - `Formats::Zip::Reader` is usable immediately: entries parse on
14
+ first use, so `extract_all`/listing no longer silently do nothing
15
+ when `#read` was not called first. `#read` remains for eager
16
+ loading.
17
+ - `Temp::ArchiveHelper` writes zips through the native
18
+ `Formats::Zip::Writer` instead of the rubyzip-compat
19
+ `Omnizip::Zip::File` seam (the Metadata subsystem remains on
20
+ compat deliberately as an in-place central-directory editor).
21
+ - The RAR extraction spill cache installs an `at_exit` sweeper on
22
+ first use, reclaiming spill directories at normal process exit.
23
+ - Module docs tell the truth: `Formats::Rar` claimed read-only with
24
+ external tools required; `Formats::Iso` claimed read-only while
25
+ carrying the verified writer. New `CONTEXT.md` records the domain
26
+ glossary (primary parser vs adapters, native vs external decode,
27
+ interop-verified vs Omnizip-internal codecs, handler routes, the
28
+ compat seam, bridges).
29
+
30
+ ## [0.3.41] - 2026-08-29
31
+
32
+ ### Changed
33
+ - One RAR parser. `Formats::Rar3::Reader` and `Formats::Rar5::Reader`
34
+ are adapters over the primary `Formats::Rar::Reader` — the only
35
+ parser verified against real WinRAR archives — replacing ~600
36
+ lines of duplicate parsing. Both legacy interfaces gain real-
37
+ archive fidelity for free (exact mtimes, backslash normalization,
38
+ directory detection, CRC fields). The legacy RAR5 writer — whose
39
+ output not even unrar could list (garbage names, 2038 dates) —
40
+ now bridges onto the spec-conformant primary writer; its archives
41
+ pass `unrar t`. The primary Header learned minimal archives (file
42
+ block first, no main header) for RAR4 and RAR5, restoring a legacy
43
+ capability in one place.
44
+ - Direct-seek extraction: the parser records each entry's
45
+ data_offset, so native extraction is one seek + read instead of
46
+ re-parsing the archive per entry. unrar-backed extraction spills
47
+ the archive once per archive path instead of extracting the whole
48
+ archive per entry (O(n^2) -> O(n)). Fixed a latent off-by-two in
49
+ the fallback block walk.
50
+ - Converter symmetry: zip -> 7z uses the native reader with the
51
+ same extract-to-temp/write-from-tree shape as 7z -> zip; internal
52
+ code no longer calls the rubyzip-compat `Omnizip::Zip::File` seam.
53
+ - `omnizip archive create` passes one flat options hash;
54
+ `Formats::Rar.create` applies the RAR4/RAR5-specific semantics
55
+ (the RAR4 writer's recovery guard no longer trips on explicit
56
+ false).
57
+
58
+ ### Fixed
59
+ - Native-decode CRC guard referenced `Compression::DecompressionError`
60
+ which does not exist (the classes live under
61
+ `Compression::Dispatcher`); the NameError was silently masked by
62
+ the fallback rescue.
63
+ - Unicode-flagged RAR4 names can carry non-UTF-8 bytes;
64
+ String#tr raised and aborted the block walk under non-UTF-8
65
+ locales (CI runners). Names are scrubbed before normalization.
66
+ - Corrupt size fields drove unbounded reads: a garbage pack_size
67
+ attempted a multi-gigabyte read (NoMemoryError on CI) or an
68
+ overflowing seek (EINVAL on Linux, silently clamped on macOS).
69
+ Packed data is now seeked past — never allocated to skip — with
70
+ sizes bounds-checked against the archive length and corrupt skips
71
+ clamped to EOF so entries still list.
72
+
10
73
  ## [0.3.40] - 2026-08-29
11
74
 
12
75
  ### Fixed
data/CONTEXT.md ADDED
@@ -0,0 +1,67 @@
1
+ # Domain Glossary
2
+
3
+ Names for the concepts that keep coming up in omnizip. Use these
4
+ words in code, docs, and reviews; when a term sharpens, update it
5
+ here.
6
+
7
+ ## Archive access
8
+
9
+ - **Archive facade** — `Omnizip::Archive`: the format-neutral entry
10
+ point. `create` yields a Builder (add_file/add_directory/add_data),
11
+ `open` yields a ReaderSession (entries/read/extract/extract_all).
12
+ - **ArchiveHandler** — per-format adapter registered in the handler
13
+ registry (create/extract_to/list/read_entry) plus its extension
14
+ route. The facade and the convenience layer dispatch through
15
+ handlers, never through format classes directly.
16
+ - **Extension route** — mapping from a file extension to a format
17
+ symbol. Writable routes (`ARCHIVE_FORMAT_EXTENSIONS`) may create
18
+ archives; read routes (`READ_ARCHIVE_FORMAT_EXTENSIONS`) only
19
+ extract/list/read. `.rar`, `.cpio`, `.iso` are read-only routes;
20
+ creating them raises truthfully.
21
+
22
+ ## Reading and decode
23
+
24
+ - **Reader invariant — always usable** — every Reader
25
+ (`Formats::Zip`, `Formats::SevenZip`, `Formats::Rar`) parses on
26
+ first use; calling `#open` first is never required, only eager.
27
+
28
+ - **Native decode** — decompression implemented in Ruby inside
29
+ omnizip. RAR native decode is **CRC-gated**: the stored header CRC
30
+ must match the decoded bytes or the result is discarded.
31
+ - **External fallback** — delegating to an external tool (the unrar
32
+ command) when native decode cannot be trusted. RAR extraction
33
+ spills the archive once per archive path (the **extraction spill
34
+ cache**) and copies entries out of it.
35
+
36
+ ## Writing and interop
37
+
38
+ - **Interop-verified** — output accepted by the official tool for
39
+ the format (unrar for RAR, 7zz for 7z/ISO, unzip for ZIP), checked
40
+ with `test` plus a byte-identical extraction round-trip.
41
+ - **Omnizip-internal codec** — a compression stream only omnizip can
42
+ decode (RAR methods above :store). Archives carrying them must be
43
+ labeled honestly; official tools fail loudly, never silently.
44
+ - **STORE** — the uncompressed method; the interoperability floor
45
+ for every archive writer in this library.
46
+
47
+ ## Seams
48
+
49
+ - **Compat seam** — `Omnizip::Zip` (aliased into the global `Zip`
50
+ namespace by `omnizip/rubyzip_compat`): a rubyzip-compatible
51
+ layer for library consumers migrating off rubyzip. Internal code
52
+ must use the native `Formats::Zip` tree instead; the Metadata
53
+ subsystem is its one deliberate in-house consumer (in-place
54
+ central-directory editing).
55
+ - **Bridge** — an adapter that carries a file-based implementation
56
+ across to a non-file interface, spilling through a temporary file
57
+ (the RAR3/RAR5 legacy readers, `Buffer::SevenZipBridge`).
58
+ - **Primary parser** — the one parser per format family verified
59
+ against real archives (e.g. `Formats::Rar::Reader`). Legacy
60
+ interfaces are adapters over primary parsers, never parallel
61
+ implementations.
62
+
63
+ ## In memory
64
+
65
+ - **Buffer** — `Omnizip::Buffer`: in-memory archive create/read for
66
+ ZIP (stream-based) and 7z (through the bridge). Format detection
67
+ compares binary magic literals.
@@ -3,14 +3,13 @@
3
3
  module Omnizip
4
4
  module Formats
5
5
  # ISO 9660 CD-ROM filesystem format support
6
- # Provides read-only access to ISO images
7
6
  #
8
- # ISO 9660 is the standard filesystem for CD-ROMs and DVD-ROMs.
9
- # This implementation supports:
10
- # - Primary Volume Descriptor parsing
11
- # - Directory structure traversal
12
- # - File extraction
13
- # - Rock Ridge extensions (basic)
7
+ # Reads and writes ISO 9660 images: Primary Volume Descriptor
8
+ # parsing, directory traversal, file extraction, and image
9
+ # creation whose directory records point at allocated extents
10
+ # (verified against 7-Zip). Rock Ridge and Joliet are NOT
11
+ # implemented the writer's options stay false rather than
12
+ # advertising extensions readers would misparse.
14
13
  module Iso
15
14
  # Nested classes - autoloaded
16
15
  autoload :Reader, "omnizip/formats/iso/reader"
@@ -258,6 +258,7 @@ module Omnizip
258
258
  raise "Command entry extraction failed: #{entry_name}"
259
259
  end
260
260
  extract_cache[cache_key] = cached_dir
261
+ install_exit_sweeper
261
262
  end
262
263
 
263
264
  source = File.join(cached_dir, entry_name)
@@ -265,8 +266,8 @@ module Omnizip
265
266
  end
266
267
 
267
268
  # Extracted-archive cache: archive path => spill directory.
268
- # Cleared by .clear_extract_cache! (tests); directories live
269
- # in the system temp dir until process exit.
269
+ # Cleared by .clear_extract_cache!; an at_exit sweeper
270
+ # reclaims everything at normal process termination.
270
271
  def extract_cache
271
272
  @extract_cache ||= {}
272
273
  end
@@ -277,6 +278,14 @@ module Omnizip
277
278
  extract_cache.clear
278
279
  end
279
280
 
281
+ # Reclaim spill directories when the process exits normally
282
+ def install_exit_sweeper
283
+ return if @exit_sweeper_installed
284
+
285
+ at_exit { clear_extract_cache! }
286
+ @exit_sweeper_installed = true
287
+ end
288
+
280
289
  # Build extract command
281
290
  def build_extract_command(archive_path, output_dir, password)
282
291
  # Array form: no shell involved, so paths and passwords
@@ -12,7 +12,7 @@ module Omnizip
12
12
  class Reader
13
13
  include Omnizip::Formats::Rar::Constants
14
14
 
15
- attr_reader :file_path, :header, :entries, :archive_info,
15
+ attr_reader :file_path, :header, :archive_info,
16
16
  :volume_manager
17
17
 
18
18
  # Initialize reader with file path
@@ -25,6 +25,7 @@ module Omnizip
25
25
  @archive_info = Models::RarArchive.new(file_path)
26
26
  @volume_manager = VolumeManager.new(file_path)
27
27
  @use_native = true # Prefer native decompression
28
+ @opened = false
28
29
  end
29
30
 
30
31
  # Open and parse RAR archive
@@ -34,14 +35,24 @@ module Omnizip
34
35
  File.open(@file_path, "rb") do |io|
35
36
  parse_archive(io)
36
37
  end
38
+ @opened = true
37
39
  self
38
40
  end
39
41
 
42
+ # All entries, parsing on first use — a Reader is always
43
+ # usable; #open remains for eager loading
44
+ #
45
+ # @return [Array<Models::RarEntry>] File entries
46
+ def entries
47
+ ensure_open
48
+ @entries
49
+ end
50
+
40
51
  # List all files in archive
41
52
  #
42
53
  # @return [Array<Models::RarEntry>] File entries
43
54
  def list_files
44
- @entries
55
+ entries
45
56
  end
46
57
 
47
58
  # Extract file to output path
@@ -51,6 +62,7 @@ module Omnizip
51
62
  # @param password [String, nil] Optional password
52
63
  # @raise [RuntimeError] if entry not found or extraction fails
53
64
  def extract_entry(entry_name, output_path, password: nil)
65
+ ensure_open
54
66
  entry = @entries.find { |e| e.name == entry_name }
55
67
  raise "Entry not found: #{entry_name}" unless entry
56
68
 
@@ -79,6 +91,7 @@ module Omnizip
79
91
  # @param password [String, nil] Optional password
80
92
  # @raise [RuntimeError] on extraction error
81
93
  def extract_all(output_dir, password: nil)
94
+ ensure_open
82
95
  FileUtils.mkdir_p(output_dir)
83
96
 
84
97
  # Use decompressor to extract all
@@ -126,6 +139,11 @@ module Omnizip
126
139
 
127
140
  private
128
141
 
142
+ # Parse the archive once, on demand
143
+ def ensure_open
144
+ open unless @opened
145
+ end
146
+
129
147
  # Parse RAR archive structure
130
148
  #
131
149
  # @param io [IO] Input stream
@@ -3,17 +3,18 @@
3
3
  module Omnizip
4
4
  module Formats
5
5
  # RAR archive format support
6
- # Provides read-only access to RAR archives (single and multi-volume)
7
6
  #
8
- # This module implements RAR archive format support:
9
- # - Format signature validation (RAR4 and RAR5)
10
- # - Archive structure parsing
11
- # - File listing
12
- # - File extraction (requires unrar gem or system command)
13
- # - Multi-volume archive support
14
- #
15
- # Note: RAR compression is proprietary, so this implementation
16
- # is read-only and requires external decompression tools.
7
+ # - Formats::Rar::Reader the primary parser, verified against
8
+ # real WinRAR archives: listing, directories, mtimes, and
9
+ # extraction (native STORE decode with stored-CRC verification;
10
+ # foreign compressed streams fall back to the unrar command
11
+ # after the CRC check rejects them)
12
+ # - Formats::Rar::Writer (RAR4) and Formats::Rar::Rar5::Writer —
13
+ # STORE output is unrar-verified; compressed methods produce
14
+ # Omnizip-internal streams (see the README interop table)
15
+ # - Formats::Rar3 / Formats::Rar5 legacy interfaces, thin
16
+ # adapters over the primary reader and the RAR5 writer
17
+ # - Multi-volume archives, encryption (RAR5), recovery records
17
18
  module Rar
18
19
  # Nested classes - autoloaded
19
20
  autoload :Constants, "omnizip/formats/rar/constants"
@@ -28,6 +28,7 @@ module Omnizip
28
28
  @offset = options[:offset] || 0
29
29
  @solid_cache = {}
30
30
  @solid_cache_extracted = {}
31
+ @opened = false
31
32
  end
32
33
 
33
34
  # Open and parse .7z archive
@@ -79,10 +80,12 @@ module Omnizip
79
80
  @split_reader&.volumes || [@file_path]
80
81
  end
81
82
 
82
- # List all files in archive
83
+ # List all files in archive, parsing on first use — a Reader
84
+ # is always usable; #open remains for eager loading
83
85
  #
84
86
  # @return [Array<Models::FileEntry>] File entries
85
87
  def list_files
88
+ ensure_open
86
89
  @entries.reject { |e| e.name.nil? || e.name.empty? }
87
90
  end
88
91
 
@@ -92,6 +95,7 @@ module Omnizip
92
95
  # @param output_path [String] Destination path
93
96
  # @raise [RuntimeError] if entry not found or extraction fails
94
97
  def extract_entry(entry_name, output_path)
98
+ ensure_open
95
99
  # Delegate to split reader if available
96
100
  if @split_reader
97
101
  @split_reader.extract_entry(entry_name, output_path)
@@ -132,6 +136,7 @@ module Omnizip
132
136
  # @param output_dir [String] Destination directory
133
137
  # @raise [RuntimeError] on extraction error
134
138
  def extract_all(output_dir)
139
+ ensure_open
135
140
  FileUtils.mkdir_p(output_dir)
136
141
 
137
142
  real_out = File.realpath(output_dir)
@@ -187,6 +192,11 @@ module Omnizip
187
192
 
188
193
  private
189
194
 
195
+ # Parse the archive once, on demand
196
+ def ensure_open
197
+ open unless @opened
198
+ end
199
+
190
200
  # Parse .7z archive structure
191
201
  #
192
202
  # @param io [io] Input stream
@@ -236,7 +246,16 @@ module Omnizip
236
246
 
237
247
  # Parse metadata
238
248
  parser = Parser.new(next_header_data)
239
- @stream_info, @entries = parse_metadata(parser)
249
+ begin
250
+ @stream_info, @entries = parse_metadata(parser)
251
+ rescue StandardError => e
252
+ if @headers_decrypted
253
+ raise "Failed to decrypt headers: incorrect password " \
254
+ "or corrupted data (#{e.message})"
255
+ end
256
+
257
+ raise
258
+ end
240
259
 
241
260
  # Map entries to their folders/streams
242
261
  map_entries_to_streams
@@ -257,11 +276,16 @@ module Omnizip
257
276
 
258
277
  # Decrypt using password
259
278
  encryptor = HeaderEncryptor.new(@password)
260
- encryptor.decrypt(
279
+ decrypted = encryptor.decrypt(
261
280
  @encrypted_header.encrypted_data,
262
281
  @encrypted_header.salt,
263
282
  @encrypted_header.iv,
264
283
  )
284
+ # AES-CBC 'succeeds' with any key and yields garbage; wrong
285
+ # passwords surface as later header-parse failures, so mark
286
+ # the archive and translate those into the password error
287
+ @headers_decrypted = true
288
+ decrypted
265
289
  rescue OpenSSL::Cipher::CipherError => e
266
290
  raise "Failed to decrypt headers: incorrect password (#{e.message})"
267
291
  end
@@ -393,6 +417,20 @@ module Omnizip
393
417
  entry.folder_index = folder_idx
394
418
  entry.file_index = i
395
419
  entry.size = @stream_info.unpack_sizes[stream_idx] if @stream_info.unpack_sizes[stream_idx]
420
+
421
+ # Single-stream folders own their packed bytes outright,
422
+ # so the entry's compressed size is exact. Solid folders
423
+ # share packed bytes across streams — leave nil rather
424
+ # than fabricate a split.
425
+ num_in_folder = @stream_info.num_unpack_streams_in_folders[folder_idx]
426
+ if num_in_folder == 1
427
+ folder = @stream_info.folders[folder_idx]
428
+ packed = folder.pack_stream_indices.sum do |pi|
429
+ @stream_info.pack_sizes[pi] || 0
430
+ end
431
+ entry.compressed_size = packed
432
+ end
433
+
396
434
  stream_idx += 1
397
435
  end
398
436
  end
@@ -9,7 +9,7 @@ module Omnizip
9
9
  class Reader
10
10
  include Omnizip::Formats::Zip::Constants
11
11
 
12
- attr_reader :file_path, :entries
12
+ attr_reader :file_path
13
13
 
14
14
  def initialize(file_path)
15
15
  @file_path = file_path
@@ -36,6 +36,13 @@ module Omnizip
36
36
  self
37
37
  end
38
38
 
39
+ # All entries, parsing on first use — a Reader is usable
40
+ # immediately; #read remains for explicit eager loading
41
+ def entries
42
+ ensure_read
43
+ @entries
44
+ end
45
+
39
46
  # Extract all files to a directory
40
47
  def extract_all(output_dir, preserve_links: true,
41
48
  dereference_links: false)
@@ -163,6 +170,11 @@ dereference_links: false)
163
170
 
164
171
  private
165
172
 
173
+ # Parse the central directory once, on demand
174
+ def ensure_read
175
+ read if @entries.empty? && @central_directory.empty?
176
+ end
177
+
166
178
  # Read central directory entries
167
179
  def read_central_directory(io, eocd)
168
180
  io.seek(eocd.central_directory_offset, ::IO::SEEK_SET)
data/lib/omnizip/temp.rb CHANGED
@@ -129,9 +129,12 @@ module Omnizip
129
129
 
130
130
  case format
131
131
  when :zip
132
- Omnizip::Zip::File.create(path) do |zip|
133
- zip.add(name) { data }
134
- end
132
+ # Native writer — the rubyzip-compat Omnizip::Zip seam is
133
+ # for library consumers migrating from rubyzip, not for
134
+ # internal use
135
+ writer = Omnizip::Formats::Zip::Writer.new(path)
136
+ writer.add_data(name, data.to_s)
137
+ writer.write
135
138
  end
136
139
  end
137
140
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Omnizip
4
- VERSION = "0.3.41"
4
+ VERSION = "0.3.43"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omnizip
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.41
4
+ version: 0.3.43
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -115,6 +115,7 @@ files:
115
115
  - ".rubocop.yml"
116
116
  - ".rubocop_todo.yml"
117
117
  - CHANGELOG.md
118
+ - CONTEXT.md
118
119
  - COPYING
119
120
  - Gemfile
120
121
  - LICENSE