omnizip 0.3.42 → 0.3.44

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: 060755f6c69bca46bcb999d0d36471045ae3d13e260cfa3c73601a8d440b1634
4
- data.tar.gz: b524b77c579c824009821ca5d1bdb47327cfc55f3ebd1dab4ea8574d1ec3c631
3
+ metadata.gz: 358c03758aa086cbb4f3a22845af9f309542df3c272fc640faaaf807d3b2adb0
4
+ data.tar.gz: 96c8735854c4413b2cb802611eb7acd2d4480d1983b5dd4e4e4a45ebf35d3725
5
5
  SHA512:
6
- metadata.gz: 237f14e0c479844a473d1830e956882a0d250f6c733522bcd9f3d36b7ba10b474b4564902791aecd52948ad330b82bb0bb7e61dd97db607bf3dcd85ab3dc847b
7
- data.tar.gz: cfe4637c8ad1223ccb8b9735fc1494d61937b636f3d79dd7fbd2c1f59f6f35eb38686152ee56732f0cb29cfbf3c12f7ddfb6dae5bd6c146b8d833fca5315a00a
6
+ metadata.gz: 67ab99068276c2eef687be2d7497c911cdb96f920dea552b9392fe860cbc805345c8bf87f5f6640934b09d46b7fc3c598c8bb140a296f37395d2020ff9e0bbd6
7
+ data.tar.gz: 256599715a8f9aa94ee2e94376122a6571b8a7eb0e80548b74e57f14d2d2522a12fc6f3b5566f08c3b6a63692b06e3845a3d8948dfdf24fc53f22d79eebd434d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.43] - 2026-08-29
11
+
12
+ ### Changed
13
+ - Reader invariant, library-wide: `Formats::SevenZip::Reader` and
14
+ `Formats::Rar::Reader` parse on first use, so `list_files`/
15
+ `extract_entry`/`extract_all` no longer silently operate on empty
16
+ entries when `#open` was not called first. "A reader is always
17
+ usable" is recorded in `CONTEXT.md`; `#open` remains for eager
18
+ loading. New specs pin the behavior for both readers.
19
+ - 7z entries in single-stream folders report their exact
20
+ `compressed_size` (folder pack sizes were parsed but never mapped
21
+ onto entries); solid folders share packed bytes across streams, so
22
+ those entries keep nil rather than a fabricated split.
23
+
24
+ ## [0.3.42] - 2026-08-29
25
+
26
+ ### Changed
27
+ - `Formats::Zip::Reader` is usable immediately: entries parse on
28
+ first use, so `extract_all`/listing no longer silently do nothing
29
+ when `#read` was not called first. `#read` remains for eager
30
+ loading.
31
+ - `Temp::ArchiveHelper` writes zips through the native
32
+ `Formats::Zip::Writer` instead of the rubyzip-compat
33
+ `Omnizip::Zip::File` seam (the Metadata subsystem remains on
34
+ compat deliberately as an in-place central-directory editor).
35
+ - The RAR extraction spill cache installs an `at_exit` sweeper on
36
+ first use, reclaiming spill directories at normal process exit.
37
+ - Module docs tell the truth: `Formats::Rar` claimed read-only with
38
+ external tools required; `Formats::Iso` claimed read-only while
39
+ carrying the verified writer. New `CONTEXT.md` records the domain
40
+ glossary (primary parser vs adapters, native vs external decode,
41
+ interop-verified vs Omnizip-internal codecs, handler routes, the
42
+ compat seam, bridges).
43
+
10
44
  ## [0.3.41] - 2026-08-29
11
45
 
12
46
  ### Changed
data/CONTEXT.md CHANGED
@@ -19,7 +19,11 @@ here.
19
19
  extract/list/read. `.rar`, `.cpio`, `.iso` are read-only routes;
20
20
  creating them raises truthfully.
21
21
 
22
- ## Reading and decoding
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.
23
27
 
24
28
  - **Native decode** — decompression implemented in Ruby inside
25
29
  omnizip. RAR native decode is **CRC-gated**: the stored header CRC
data/lib/omnizip/cli.rb CHANGED
@@ -344,6 +344,17 @@ module Omnizip
344
344
  rescue StandardError => e
345
345
  handle_error(e)
346
346
  end
347
+
348
+ desc "verify ARCHIVE", "Verify archive integrity"
349
+ long_desc <<~DESC
350
+ Verify the integrity of an archive (currently RAR archives via
351
+ header and recovery-record checks).
352
+ DESC
353
+ def verify(archive)
354
+ Omnizip::Commands::ArchiveVerifyCommand.new(options).run(archive)
355
+ rescue StandardError => e
356
+ handle_error(e)
357
+ end
347
358
  end
348
359
 
349
360
  # Command-line interface for Omnizip.
@@ -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
@@ -165,8 +165,8 @@ module Omnizip
165
165
  #
166
166
  # @param archive_path [String] Path to RAR archive
167
167
  # @return [ArchiveVerifier::VerificationResult] Verification result
168
- def verify(archive_path)
169
- ArchiveVerifier.new(archive_path).verify
168
+ def verify(archive_path, use_recovery: true)
169
+ ArchiveVerifier.new(archive_path).verify(use_recovery: use_recovery)
170
170
  end
171
171
 
172
172
  # Repair corrupted archive
@@ -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
@@ -420,6 +458,12 @@ module Omnizip
420
458
  pack_idx += num_streams
421
459
  end
422
460
 
461
+ # Pack streams are laid out sequentially; skip past every
462
+ # stream owned by earlier folders to reach this folder's
463
+ # bytes (with stored archives pack sizes equal unpack
464
+ # sizes, which masked this offset in the past)
465
+ pack_idx.times { |i| pack_pos += @stream_info.pack_sizes[i] || 0 }
466
+
423
467
  # Check if this is a BCJ2 multi-stream folder
424
468
  if Bcj2StreamDecompressor.bcj2_folder?(folder)
425
469
  extract_bcj2_entry(io, entry, folder, pack_pos, pack_idx)
@@ -169,15 +169,20 @@ module Omnizip
169
169
  [lzma2_chunk, [compressed_size]]
170
170
  end
171
171
 
172
- # Build packed data for non-solid mode (COPY - no compression)
172
+ # Build packed data for non-solid mode: every file is its own
173
+ # LZMA2 stream. Empty files keep a zero-size pack stream.
173
174
  def build_non_solid_packed_data(file_data)
174
- # For non-solid mode, concatenate raw file data without compression
175
- packed_data = String.new(encoding: "BINARY")
175
+ packed_data = String.new(encoding: Encoding::BINARY)
176
176
  packed_sizes = []
177
177
 
178
178
  file_data[:streams].each do |stream|
179
- packed_data << stream[:data]
180
- packed_sizes << stream[:size]
179
+ if stream[:size].positive?
180
+ compressed = compress_with_lzma2(stream[:data])
181
+ packed_data << compressed
182
+ packed_sizes << compressed.bytesize
183
+ else
184
+ packed_sizes << 0
185
+ end
181
186
  end
182
187
 
183
188
  [packed_data, packed_sizes]
@@ -255,7 +260,8 @@ module Omnizip
255
260
  num_files)
256
261
  else
257
262
  # Non-solid mode: one pack stream per file, one folder per file
258
- build_non_solid_streams_info(metadata, file_data[:streams])
263
+ build_non_solid_streams_info(metadata, file_data[:streams],
264
+ packed_sizes)
259
265
  end
260
266
 
261
267
  # kEnd for MainStreamsInfo
@@ -375,7 +381,7 @@ _num_files)
375
381
  metadata << [PropertyId::K_END].pack("C")
376
382
  end
377
383
 
378
- def build_non_solid_streams_info(metadata, streams)
384
+ def build_non_solid_streams_info(metadata, streams, packed_sizes)
379
385
  num_streams = streams.size
380
386
 
381
387
  # kPackInfo property (0x06)
@@ -383,10 +389,10 @@ _num_files)
383
389
  metadata << write_number(0) # Pack position
384
390
  metadata << write_number(num_streams) # Number of pack streams
385
391
 
386
- # kSize property - sizes of each pack stream
392
+ # kSize property - sizes of each pack stream (compressed)
387
393
  metadata << [PropertyId::SIZE].pack("C")
388
- streams.each do |stream|
389
- metadata << write_number(stream[:size])
394
+ packed_sizes.each do |packed|
395
+ metadata << write_number(packed)
390
396
  end
391
397
 
392
398
  # kEnd for PackInfo
@@ -402,18 +408,20 @@ _num_files)
402
408
  # External flag for all folders
403
409
  metadata << [0].pack("C")
404
410
 
405
- # Folder definitions (coders only, no sizes yet)
406
- streams.each do |_stream|
407
- # Number of coders
408
- metadata << write_number(1)
409
- # Coder info for COPY
410
- # MainByte format: bits 0-3 = num bytes for CodecID (0-15), bit 4 = is_complex, bits 5-7 = num props
411
- # For COPY: 1 byte CodecID (0x00), no properties
412
- metadata << COPY_MAIN_BYTE # MainByte: 1 byte for CodecID, no props
413
- metadata << COPY_METHOD_ID # Method ID: COPY = 0x00
411
+ # Folder definitions (coders only, no sizes yet):
412
+ # one LZMA2 coder per non-empty stream, COPY for empties
413
+ streams.each do |stream|
414
+ if stream[:size].positive?
415
+ build_folder_coder(metadata)
416
+ else
417
+ metadata << write_number(1)
418
+ metadata << COPY_MAIN_BYTE
419
+ metadata << COPY_METHOD_ID
420
+ end
414
421
  end
415
422
 
416
423
  # kCodersUnpackSize - comes after ALL folder definitions
424
+ # (original sizes; kSize above carries the packed sizes)
417
425
  metadata << [PropertyId::CODERS_UNPACK_SIZE].pack("C")
418
426
  streams.each do |stream|
419
427
  metadata << write_number(stream[:size])
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Omnizip
4
- VERSION = "0.3.42"
4
+ VERSION = "0.3.44"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omnizip
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.42
4
+ version: 0.3.44
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-29 00:00:00.000000000 Z
11
+ date: 2026-08-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64