omnizip 0.3.43 → 0.3.45

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: 436437db6697f7363305eda4e6a3e2b78b922aa12b3c43e29756efe187310b60
4
- data.tar.gz: 6e0b17e143c50beae378cdb5b7902e773d888bca14183fcf83e61dfeabe56a5e
3
+ metadata.gz: c60c442839bff64ee405d6eb9c5f3ead4a97fa53df7ffd381ca0508dbb00b5e8
4
+ data.tar.gz: 22b8abfb94d6403e16ffea3c36115c6c7eb15eed654ef15f8a824453aa2eef20
5
5
  SHA512:
6
- metadata.gz: f1e20ae301093cacb8c582e1b554a678c72cefa6bf21095d129bebc14b8c4095a68a87da653ef5ea16cb65c00de9edd6ae7b0d92191e49c25e05438ceba264ab
7
- data.tar.gz: a6ad938ab6bdfd75586bcef013ba056cc523681ef34449d03bc44daf18a591e6485b292b7c62bd17c72c13c20c7ff8c45caf4faf6735d5cff79a0c2f4a5a010b
6
+ metadata.gz: f9a8b45c51e6c36d30273d7ac897eb4854793ed1548d4055dcf90335b836c9d3d1bb4db2faa374cf407345657fd5b459f16bf40021427387ae103ce54f1ed644
7
+ data.tar.gz: 412293ad5b9d9d3c361fe03f8ed88707f3cd5dc4fc4bc1221198e877a71eb98322ca4cc5664ac329dee117192738d4d1af6b8e79ca8cc080639cf69e83816d89
data/CHANGELOG.md CHANGED
@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.44] - 2026-08-31
11
+
12
+ ### Fixed
13
+ - Non-solid 7z archives now compress: the writer advertised
14
+ `algorithm: :lzma2` but the `solid: false` path concatenated raw
15
+ data under COPY coders (30 KB of compressible input produced a
16
+ 30 KB archive). Each file now compresses independently with the
17
+ same SDK encoder as the solid path, folders carry LZMA2 coders
18
+ with the dictionary property, and kSize holds packed sizes while
19
+ kCodersUnpackSize keeps originals. 7zz-verified, extraction
20
+ byte-identical (30 KB -> 532 B in the fixture).
21
+ - Latent 7z reader bug exposed by the above: multi-folder
22
+ non-solid archives read folder 1's packed stream for every entry
23
+ (pack positions never skipped earlier folders' bytes). Stored
24
+ archives masked it because pack sizes equalled unpack sizes, and
25
+ the tool-compat specs verify extraction through 7zz rather than
26
+ our reader. Fixed; new specs pin byte-exact multi-file
27
+ extraction through our own reader.
28
+ - `omnizip archive verify` is reachable: the command existed but
29
+ was registered in no CLI group, and its `Formats::Rar.verify`
30
+ call passed a kwarg the module method never accepted (an arity
31
+ crash on first contact). The module method forwards
32
+ `use_recovery:` now; RAR archives verify with per-file results
33
+ and unsupported formats exit non-zero with an honest message.
34
+
35
+ ## [0.3.43] - 2026-08-29
36
+
37
+ ### Changed
38
+ - Reader invariant, library-wide: `Formats::SevenZip::Reader` and
39
+ `Formats::Rar::Reader` parse on first use, so `list_files`/
40
+ `extract_entry`/`extract_all` no longer silently operate on empty
41
+ entries when `#open` was not called first. "A reader is always
42
+ usable" is recorded in `CONTEXT.md`; `#open` remains for eager
43
+ loading. New specs pin the behavior for both readers.
44
+ - 7z entries in single-stream folders report their exact
45
+ `compressed_size` (folder pack sizes were parsed but never mapped
46
+ onto entries); solid folders share packed bytes across streams, so
47
+ those entries keep nil rather than a fabricated split.
48
+
10
49
  ## [0.3.42] - 2026-08-29
11
50
 
12
51
  ### Changed
@@ -19,6 +19,9 @@ module Omnizip
19
19
  def extract_to(path, output_dir, password: nil, **_)
20
20
  Omnizip::Formats::Rar::Decompressor.extract(path, output_dir,
21
21
  password: password)
22
+ reader = Omnizip::Formats::Rar::Reader.new(path).open
23
+ reader.list_files.reject(&:is_dir)
24
+ .map { |e| ::File.join(output_dir, e.name) }
22
25
  end
23
26
 
24
27
  def list(path, details: false, **_)
@@ -27,6 +30,7 @@ module Omnizip
27
30
  if details
28
31
  entries.map do |e|
29
32
  { name: e.name, size: e.size, directory: e.is_dir,
33
+ compressed_size: (e.compressed_size if e.compressed_size.positive?),
30
34
  mtime: e.mtime }
31
35
  end
32
36
  else
@@ -15,9 +15,13 @@ module Omnizip
15
15
  end
16
16
 
17
17
  def extract_to(path, output_dir, **options)
18
+ extracted = []
18
19
  Omnizip::Formats::SevenZip.open(path, options) do |reader|
19
20
  reader.extract_all(output_dir)
21
+ extracted = reader.list_files.reject(&:is_dir)
22
+ .map { |e| ::File.join(output_dir, e.name) }
20
23
  end
24
+ extracted
21
25
  end
22
26
 
23
27
  def list(path, details: false, **options)
@@ -26,6 +30,7 @@ module Omnizip
26
30
  if details
27
31
  entries.map do |e|
28
32
  { name: e.name, size: e.size, directory: e.is_dir,
33
+ compressed_size: (e.compressed_size if e.compressed_size&.positive?),
29
34
  mtime: e.mtime }
30
35
  end
31
36
  else
@@ -17,6 +17,8 @@ module Omnizip
17
17
 
18
18
  def extract_to(path, output_dir, **_)
19
19
  Omnizip::Formats::Tar.extract(path, output_dir)
20
+ Omnizip::Formats::Tar.list(path).reject(&:directory?)
21
+ .map { |e| ::File.join(output_dir, e.name) }
20
22
  end
21
23
 
22
24
  def list(path, details: false, **_)
@@ -3,65 +3,69 @@
3
3
  module Omnizip
4
4
  module ArchiveHandlers
5
5
  # Adapter that exposes the canonical +create+/+open+/+extract_to+
6
- # /+list+ interface for the ZIP format. Wraps +Omnizip::Zip::File+.
6
+ # /+list+ interface for the ZIP format on the native
7
+ # +Formats::Zip+ tree. In-place entry editing (+add_entry+ /
8
+ # +remove_entry+) still goes through the rubyzip-compat
9
+ # +Omnizip::Zip::File+ layer: it is an in-place central-directory
10
+ # rewriter the native tree does not have (the same reason the
11
+ # Metadata subsystem lives there).
7
12
  class ZipHandler
8
- # Zip::File has no archive-level compression options (method and
9
- # level are per-entry in Zip::OutputStream), so extra keywords —
10
- # e.g. profile-injected ones — are absorbed here.
11
- def create(path, **_options, &block)
12
- Omnizip::Zip::File.create(path) do |file|
13
- block&.call(FileProxy.new(file))
14
- end
13
+ def create(path, compression_method: nil, level: nil, **_, &block)
14
+ writer = Formats::Zip::Writer.new(path)
15
+ block&.call(WriterProxy.new(writer))
16
+
17
+ # The native writer takes archive-level compression at #write
18
+ # time; keep profile keywords out of it
19
+ kwargs = {}
20
+ kwargs[:compression_method] = compression_method if compression_method
21
+ kwargs[:level] = level if level
22
+ writer.write(**kwargs)
23
+ path
15
24
  end
16
25
 
17
26
  def open(path, &block)
18
- Omnizip::Zip::File.open(path, &block)
27
+ reader = Formats::Zip::Reader.new(path)
28
+ block ? yield(reader) : reader
19
29
  end
20
30
 
21
31
  def extract_to(path, output_dir, overwrite: false, **_)
22
- extracted = []
23
- Omnizip::Zip::File.open(path) do |zip|
24
- zip.each do |entry|
25
- dest_path = ::File.join(output_dir, entry.name)
26
- on_exists = if overwrite
27
- proc { true }
28
- else
29
- proc { |_e, p| raise "File exists: #{p}" }
30
- end
31
- zip.extract(entry, dest_path, &on_exists)
32
+ reader = Formats::Zip::Reader.new(path)
33
+
34
+ if overwrite
35
+ reader.extract_all(output_dir)
36
+ reader.entries.map { |e| ::File.join(output_dir, e.filename) }
37
+ else
38
+ extracted = []
39
+ reader.entries.each do |entry|
40
+ dest_path = ::File.join(output_dir, entry.filename)
41
+ raise "File exists: #{dest_path}" if ::File.exist?(dest_path)
42
+
43
+ reader.extract_entry(entry, output_dir)
32
44
  extracted << dest_path
33
45
  end
46
+ extracted
34
47
  end
35
- extracted
36
48
  end
37
49
 
38
50
  def list(path, details: false, **_)
39
- Omnizip::Zip::File.open(path) do |zip|
40
- if details
41
- zip.entries.map do |entry|
42
- {
43
- name: entry.name,
44
- size: entry.size,
45
- compressed_size: entry.compressed_size,
46
- compression_method: entry.compression_method,
47
- crc: entry.crc,
48
- time: entry.time,
49
- directory: entry.directory?,
50
- }
51
- end
52
- else
53
- zip.names
54
- end
51
+ reader = Formats::Zip::Reader.new(path)
52
+ return reader.entries.map(&:filename) unless details
53
+
54
+ reader.entries.map do |entry|
55
+ {
56
+ name: entry.filename,
57
+ size: entry.uncompressed_size,
58
+ compressed_size: entry.compressed_size,
59
+ compression_method: entry.compression_method,
60
+ crc: entry.crc32,
61
+ time: entry.time,
62
+ directory: entry.directory?,
63
+ }
55
64
  end
56
65
  end
57
66
 
58
67
  def read_entry(path, entry_name)
59
- Omnizip::Zip::File.open(path) do |zip|
60
- entry = zip.get_entry(entry_name)
61
- raise Errno::ENOENT, "Entry not found: #{entry_name}" unless entry
62
-
63
- zip.read(entry)
64
- end
68
+ Formats::Zip::Reader.new(path).read_entry(entry_name)
65
69
  end
66
70
 
67
71
  def add_entry(path, entry_name, source_path)
@@ -73,20 +77,24 @@ module Omnizip
73
77
  end
74
78
 
75
79
  # Translates the generic +add(name, source_path)+ and
76
- # +add_data(name, data)+ interface. +Zip::File#add_data+ is
77
- # private, so in-memory content goes through the public block
78
- # form +add(name) { data }+.
79
- class FileProxy
80
- def initialize(file)
81
- @file = file
80
+ # +add_data(name, data)+ interface onto the native writer
81
+ class WriterProxy
82
+ def initialize(writer)
83
+ @writer = writer
82
84
  end
83
85
 
84
- def add(name, source_path = nil)
85
- @file.add(name, source_path)
86
+ def add(name, source_path = nil, &block)
87
+ if source_path
88
+ @writer.add_file(source_path, name)
89
+ elsif block
90
+ @writer.add_data(name, yield)
91
+ else
92
+ @writer.add_directory(name)
93
+ end
86
94
  end
87
95
 
88
96
  def add_data(name, data)
89
- @file.add(name) { data }
97
+ @writer.add_data(name, data)
90
98
  end
91
99
  end
92
100
  end
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.
@@ -111,54 +111,24 @@ module Omnizip
111
111
  end
112
112
 
113
113
  def extract_archive(archive_file, output_dir, verbose)
114
- reader = case File.extname(archive_file).downcase
115
- when ".rar"
116
- Formats::Rar::Reader.new(archive_file).open
117
- when ".tar"
118
- Formats::Tar::Reader.new(archive_file).read
119
- when ".gz", ".gzip"
120
- # GZIP files are single-file compression, extract directly
121
- extract_gzip(archive_file, output_dir, verbose)
122
- return 1
123
- when ".bz2", ".bzip2"
124
- # BZIP2 files are single-file compression, extract directly
125
- extract_bzip2(archive_file, output_dir, verbose)
126
- return 1
127
- when ".xz"
128
- # XZ files are single-file compression, extract directly
129
- extract_xz(archive_file, output_dir, verbose)
130
- return 1
131
- else
132
- Formats::SevenZip::Reader.new(archive_file).open
133
- end
134
- file_count = 0
135
-
136
- reader.entries.each do |entry|
137
- output_path = File.join(output_dir, entry.name)
138
-
139
- if entry.directory?
140
- CliOutputFormatter.verbose_puts(
141
- "Creating directory: #{entry.name}",
142
- verbose,
143
- )
144
- FileUtils.mkdir_p(output_path)
145
- else
146
- CliOutputFormatter.verbose_puts(
147
- "Extracting: #{entry.name}",
148
- verbose,
149
- )
150
-
151
- # Ensure parent directory exists
152
- FileUtils.mkdir_p(File.dirname(output_path))
153
-
154
- # Extract file
155
- reader.extract_entry(entry.name, output_path)
156
-
157
- file_count += 1
158
- end
114
+ # Single-file compression formats decompress directly
115
+ case File.extname(archive_file).downcase
116
+ when ".gz", ".gzip"
117
+ extract_gzip(archive_file, output_dir, verbose)
118
+ return 1
119
+ when ".bz2", ".bzip2"
120
+ extract_bzip2(archive_file, output_dir, verbose)
121
+ return 1
122
+ when ".xz"
123
+ extract_xz(archive_file, output_dir, verbose)
124
+ return 1
159
125
  end
160
126
 
161
- file_count
127
+ # Archives route through the handler seam: the extension
128
+ # routes pick each format's reader instead of hand-rolled
129
+ # branches, and zip/7z/rar/tar/cpio/iso all extract the same
130
+ # way
131
+ Omnizip.extract_archive(archive_file, output_dir).size
162
132
  rescue StandardError => e
163
133
  raise Omnizip::CompressionError,
164
134
  "Failed to extract archive: #{e.message}"
@@ -58,19 +58,12 @@ module Omnizip
58
58
  end
59
59
 
60
60
  def list_archive(archive_file, verbose, patterns, excludes, count_only)
61
- archive = if archive_file.end_with?(".zip")
62
- Omnizip::Zip::File.open(archive_file)
63
- elsif archive_file.end_with?(".rar")
64
- Formats::Rar::Reader.new(archive_file).open
65
- else
66
- Formats::SevenZip::Reader.new(archive_file).open
67
- end
61
+ # Route through the handler seam: every format's reader is
62
+ # chosen by the extension routes, not by hand-rolled branches
63
+ entries = Omnizip.list_archive(archive_file, details: true)
64
+ .map { |h| EntryAdapter.new(h) }
68
65
 
69
- entries = if patterns || excludes
70
- filter_entries(archive, patterns, excludes)
71
- else
72
- archive.entries
73
- end
66
+ entries = filter_entries(entries, patterns, excludes) if patterns || excludes
74
67
 
75
68
  if count_only
76
69
  puts "Matches: #{entries.size}"
@@ -91,11 +84,9 @@ module Omnizip
91
84
  rescue StandardError => e
92
85
  raise Omnizip::CompressionError,
93
86
  "Failed to list archive: #{e.message}"
94
- ensure
95
- archive.close if archive.is_a?(Omnizip::Zip::File)
96
87
  end
97
88
 
98
- def filter_entries(archive, patterns, excludes)
89
+ def filter_entries(entries, patterns, excludes)
99
90
  filter = Extraction::FilterChain.new
100
91
 
101
92
  # Add include patterns
@@ -104,7 +95,7 @@ module Omnizip
104
95
  # Add exclude patterns
105
96
  excludes&.each { |pattern| filter.exclude_pattern(pattern) }
106
97
 
107
- filter.filter(archive.entries)
98
+ filter.filter(entries)
108
99
  end
109
100
 
110
101
  def display_simple_listing_filtered(entries)
@@ -202,16 +193,29 @@ module Omnizip
202
193
  end
203
194
 
204
195
  def entry_compressed_size(entry)
205
- entry.compressed_size if entry.is_a?(Omnizip::Formats::SevenZip::Models::FileEntry)
196
+ entry.compressed_size
206
197
  end
207
198
 
208
199
  def entry_has_stream?(entry)
209
- entry.has_stream? if entry.is_a?(Omnizip::Formats::SevenZip::Models::FileEntry)
200
+ entry.has_stream?
210
201
  end
211
202
 
212
203
  def entry_mtime(entry)
213
204
  entry.entry_mtime
214
205
  end
206
+
207
+ # Presents handler list-detail hashes under the Omnizip::Entry
208
+ # contract the display helpers already use
209
+ # :fields, not :hash — Struct members must not override
210
+ # Struct#hash
211
+ EntryAdapter = Struct.new(:fields) do
212
+ def entry_name = fields[:name]
213
+ def entry_directory? = fields[:directory]
214
+ def entry_size = fields[:size]
215
+ def entry_mtime = fields[:mtime] || fields[:time]
216
+ def compressed_size = fields[:compressed_size]
217
+ def has_stream? = !fields[:directory] && fields[:size].to_i.positive?
218
+ end
215
219
  end
216
220
  end
217
221
  end
@@ -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
@@ -458,6 +458,12 @@ module Omnizip
458
458
  pack_idx += num_streams
459
459
  end
460
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
+
461
467
  # Check if this is a BCJ2 multi-stream folder
462
468
  if Bcj2StreamDecompressor.bcj2_folder?(folder)
463
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])
@@ -60,6 +60,36 @@ module Omnizip
60
60
  end
61
61
 
62
62
  # Check if this is a directory entry
63
+ # DOS date/time (as stored in the central directory) to Time
64
+ def time
65
+ year = ((last_mod_date >> 9) & 0x7F) + 1980
66
+ month = (last_mod_date >> 5) & 0x0F
67
+ day = last_mod_date & 0x1F
68
+ hour = (last_mod_time >> 11) & 0x1F
69
+ minute = (last_mod_time >> 5) & 0x3F
70
+ second = (last_mod_time & 0x1F) * 2
71
+ Time.new(year, month, day, hour, minute, second)
72
+ rescue ArgumentError
73
+ nil
74
+ end
75
+
76
+ # Omnizip::Entry contract
77
+ def entry_name
78
+ filename
79
+ end
80
+
81
+ def entry_directory?
82
+ directory?
83
+ end
84
+
85
+ def entry_size
86
+ uncompressed_size
87
+ end
88
+
89
+ def entry_mtime
90
+ time
91
+ end
92
+
63
93
  def directory?
64
94
  filename.end_with?("/") ||
65
95
  external_attributes.anybits?(ATTR_DIRECTORY)
@@ -64,49 +64,14 @@ dereference_links: false)
64
64
  else
65
65
  FileUtils.mkdir_p(File.dirname(output_path))
66
66
 
67
- File.open(file_path, "rb") do |io|
68
- # Seek to local file header
69
- io.seek(entry.local_header_offset, ::IO::SEEK_SET)
70
-
71
- # Read fixed part of local file header (30 bytes)
72
- fixed_header = io.read(30)
73
-
74
- # Extract variable lengths from fixed header
75
- _signature, _version, _flags, _method, _time, _date, _crc32,
76
- _comp_size, _uncomp_size, filename_length, extra_length = fixed_header.unpack("VvvvvvVVVvv")
77
-
78
- # Read variable parts
79
- variable_data = io.read(filename_length + extra_length)
80
-
81
- # Parse complete local file header
82
- LocalFileHeader.from_binary(fixed_header + variable_data)
83
-
84
- # Now we're positioned right after the local file header, read compressed data
85
- compressed_data = io.read(entry.compressed_size)
86
-
87
- # Decompress data
88
- decompressed_data = decompress_data(
89
- compressed_data,
90
- entry.compression_method,
91
- entry.uncompressed_size,
92
- )
93
-
94
- # Verify CRC
95
- calculated_crc = Omnizip::Checksums::Crc32.new.tap do |c|
96
- c.update(decompressed_data)
97
- end.finalize
98
- if calculated_crc != entry.crc32
99
- raise Omnizip::ChecksumError,
100
- "CRC mismatch for #{entry.filename}"
101
- end
102
-
103
- # Write decompressed data
104
- File.binwrite(output_path, decompressed_data)
105
-
106
- # Set file permissions if Unix
107
- if entry.unix_permissions.positive?
108
- File.chmod(entry.unix_permissions & 0o777, output_path)
109
- end
67
+ decompressed_data = read_entry_data(entry)
68
+
69
+ # Write decompressed data
70
+ File.binwrite(output_path, decompressed_data)
71
+
72
+ # Set file permissions if Unix
73
+ if entry.unix_permissions.positive?
74
+ File.chmod(entry.unix_permissions & 0o777, output_path)
110
75
  end
111
76
  end
112
77
  end
@@ -168,8 +133,57 @@ dereference_links: false)
168
133
  decompress_data(compressed_data, method, uncompressed_size)
169
134
  end
170
135
 
136
+ # Read an entry's decompressed content into a String.
137
+ # Directory entries return an empty string; symlinks return
138
+ # their target path.
139
+ #
140
+ # @param entry_name [String] Name of the entry to read
141
+ # @return [String] Decompressed content
142
+ # @raise [RuntimeError] if the entry is not found
143
+ def read_entry(entry_name)
144
+ ensure_read
145
+ entry = @entries.find { |e| e.filename == entry_name }
146
+ raise Errno::ENOENT, "Entry not found: #{entry_name}" unless entry
147
+
148
+ return "" if entry.directory?
149
+ return entry.link_target if entry.symlink?
150
+
151
+ read_entry_data(entry)
152
+ end
153
+
171
154
  private
172
155
 
156
+ # Decompress and CRC-verify a single entry's data
157
+ def read_entry_data(entry)
158
+ File.open(file_path, "rb") do |io|
159
+ io.seek(entry.local_header_offset, ::IO::SEEK_SET)
160
+
161
+ fixed_header = io.read(30)
162
+ _signature, _version, _flags, _method, _time, _date, _crc32,
163
+ _comp_size, _uncomp_size, filename_length, extra_length = fixed_header.unpack("VvvvvvVVVvv")
164
+
165
+ variable_data = io.read(filename_length + extra_length)
166
+ LocalFileHeader.from_binary(fixed_header + variable_data)
167
+
168
+ compressed_data = io.read(entry.compressed_size)
169
+ decompressed_data = decompress_data(
170
+ compressed_data,
171
+ entry.compression_method,
172
+ entry.uncompressed_size,
173
+ )
174
+
175
+ calculated_crc = Omnizip::Checksums::Crc32.new.tap do |c|
176
+ c.update(decompressed_data)
177
+ end.finalize
178
+ if calculated_crc != entry.crc32
179
+ raise Omnizip::ChecksumError,
180
+ "CRC mismatch for #{entry.filename}"
181
+ end
182
+
183
+ decompressed_data
184
+ end
185
+ end
186
+
173
187
  # Parse the central directory once, on demand
174
188
  def ensure_read
175
189
  read if @entries.empty? && @central_directory.empty?
@@ -54,6 +54,11 @@ module Omnizip
54
54
  # @param progress [Proc, nil] Progress callback
55
55
  # @raise [ArgumentError] if file doesn't exist
56
56
  def initialize(par2_file, progress: nil)
57
+ if par2_file.nil?
58
+ raise ArgumentError,
59
+ "PAR2 file is nil — no index .par2 was produced for this set"
60
+ end
61
+
57
62
  raise ArgumentError, "PAR2 file not found: #{par2_file}" unless
58
63
  File.exist?(par2_file)
59
64
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Omnizip
4
- VERSION = "0.3.43"
4
+ VERSION = "0.3.45"
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.43
4
+ version: 0.3.45
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-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64