omnizip 0.3.52 → 0.3.53

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: 8002bf03f99715929f352dfb92a91a10d02f6dac7152006a6cb87ca20b3647ae
4
- data.tar.gz: b095258b0b0af358ae9c0a21482a6f8b29de1620bb2162d64dd8794f29eb8f59
3
+ metadata.gz: a7e72a57eaeb6e597e40020de67aa0242da65c6e6e8ceaa933d779d926e55666
4
+ data.tar.gz: 105cf4f238610f518462c115d398403fef5daca9b2994788361b027c7c5ec648
5
5
  SHA512:
6
- metadata.gz: 527be0108ee0fa63b6e7ebafdeaafd05430a17c08e0d59b0f731db7a3b76401da96daec205947c7c3b5baa07aba886515959513ef3d4b31438889761219e958d
7
- data.tar.gz: e1f8665f545fc81a4459cbdffbb9e5d5aca219a177a5b2d2ac3bd47c589a2ceb11b02d8cfdc748ac39381417f12682bf53cf14105dcbcd3d04bd59b77d7f9e87
6
+ metadata.gz: a5301b14047d1150885624527d03176c61a979464b7a16329a47449e13fe05679c1e5ce11a650bfd29101aaaf502b5ab81513558fc080dca85554266ae30f8dc
7
+ data.tar.gz: a44ffd04cc02fa03e2b23a330da4f6612d268bfd350d9b9672e42d7a107dcad65201ed9707651b3533fdd790a5841a5ff9297d6f3a8e937b51e0f1614d756c55
data/CHANGELOG.md CHANGED
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.52] - 2026-09-01
11
+
12
+ ### Changed
13
+ - The extract command's pattern path opens archives through the
14
+ Archive facade instead of a hand-rolled extension `case` that
15
+ misrouted `.cpio`/`.iso`/`.rpm` files into the 7z reader — pattern
16
+ extraction now works for every routed format. The metadata
17
+ command's 7z view lists through the handler registry (the detail
18
+ contract gains `:crc`), and SafeExtract extracts through
19
+ `extract_archive`, so every routed format gets rollback and
20
+ verification instead of ZIP-only with a `NotImplementedError`
21
+ for 7z. SelectiveExtractor understands both entry contracts
22
+ (legacy `entry_name` and reader-session `name`) and skips
23
+ directory entries when extracting.
24
+ - `CliOutputFormatter.format_size` is now the single byte formatter;
25
+ the nine private `format_bytes`/`format_size` copies (four
26
+ different shapes, two unit ceilings) become delegators.
27
+
28
+ ### Fixed
29
+ - The long-deferred parity spec flake: `Par2Verifier` crashed five
30
+ frames deep on a nil PAR2 path (`File.exist?(nil)` → TypeError)
31
+ where the repairer already raised a clean ArgumentError; the
32
+ verifier now mirrors it, and the spec's `.verify` group gains the
33
+ nil-index fallback `.repair` already had. The dead shadowed
34
+ `analyze_file` definition in `Par2Creator` (silently overridden
35
+ by a second definition with a divergent file_id format) is
36
+ deleted. The tautological "LZMA2 is implicit" xz assertion became
37
+ a real block-header parse asserting the explicit 0x21 filter ID.
38
+
10
39
  ## [0.3.51] - 2026-09-01
11
40
 
12
41
  ### Changed
@@ -38,7 +38,7 @@ module Omnizip
38
38
  extracted = []
39
39
  reader.entries.each do |entry|
40
40
  dest_path = ::File.join(output_dir, entry.filename)
41
- raise "File exists: #{dest_path}" if ::File.exist?(dest_path)
41
+ raise Errno::EEXIST, "File exists: #{dest_path}" if ::File.exist?(dest_path)
42
42
 
43
43
  reader.extract_entry(entry, output_dir)
44
44
  extracted << dest_path
@@ -151,24 +151,12 @@ module Omnizip
151
151
 
152
152
  private
153
153
 
154
- # Detect archive format from magic bytes
154
+ # Delegate to the shared buffer-side sniffer
155
155
  #
156
156
  # @return [Symbol] Detected format
157
157
  # @raise [Omnizip::FormatError] If format cannot be detected
158
158
  def detect_format
159
- magic = @buffer.read(4).to_s.b
160
- @buffer.rewind
161
-
162
- case magic
163
- when "PK\x03\x04".b, "PK\x05\x06".b, "PK\x07\x08".b
164
- # ZIP signatures: local file header, EOCD, data descriptor
165
- :zip
166
- when "7z\xBC\xAF".b
167
- :seven_zip
168
- else
169
- raise Omnizip::FormatError,
170
- "Unknown archive format (magic: #{magic.inspect})"
171
- end
159
+ Omnizip::Buffer.detect_format(@buffer)
172
160
  end
173
161
 
174
162
  # Extract all entries from ZIP
@@ -207,8 +195,7 @@ module Omnizip
207
195
  def extract_entry_seven_zip(name)
208
196
  @buffer.rewind
209
197
  SevenZipBridge.open(@buffer) do |archive|
210
- entry = archive.raw_entries.find { |e| e.name == name }
211
- archive.extract_all_to_memory[name] if entry
198
+ archive.read_entry(name)
212
199
  end
213
200
  end
214
201
 
@@ -112,6 +112,19 @@ module Omnizip
112
112
  @reader.list_files
113
113
  end
114
114
 
115
+ # Content of a single named file entry, or nil when absent
116
+ # (directories carry no content). Extracts just that entry —
117
+ # not the whole archive.
118
+ #
119
+ # @param name [String] Entry name
120
+ # @return [String, nil]
121
+ def read_entry(name)
122
+ entry = raw_entries.find { |e| e.name == name && !e.is_dir }
123
+ return nil unless entry
124
+
125
+ Entry.new(entry, @reader, @tmp).read
126
+ end
127
+
115
128
  # Entry wrapper matching MemoryArchive::Entry's surface
116
129
  class Entry
117
130
  attr_reader :name, :size
@@ -107,27 +107,9 @@ module Omnizip
107
107
  extractor.extract_all
108
108
  end
109
109
 
110
- # Create archive from Hash of filename => content
111
- #
112
- # @param hash [Hash<String, String>] Filename => content mapping
113
- # @param format [Symbol] Archive format
114
- # @param options [Hash] Format-specific options
115
- # @return [StringIO] Complete archive in memory
116
- #
117
- # @example Create from Hash
118
- # data = {'file1.txt' => 'content1', 'file2.txt' => 'content2'}
119
- # zip = Omnizip::Buffer.create_from_hash(data, :zip)
120
- def create_from_hash(hash, format = :zip, **options)
121
- create(format, **options) do |archive|
122
- hash.each do |name, content|
123
- archive.add(name, content)
124
- end
125
- end
126
- end
127
-
128
- private
129
-
130
- # Detect archive format from magic bytes
110
+ # Detect archive format from magic bytes. THE buffer-side
111
+ # sniffer — in-memory data has no extension to route by, so
112
+ # every consumer delegates here.
131
113
  #
132
114
  # @param buffer [StringIO] Buffer containing archive data
133
115
  # @return [Symbol] Detected format
@@ -148,6 +130,26 @@ module Omnizip
148
130
  end
149
131
  end
150
132
 
133
+ # Create archive from Hash of filename => content
134
+ #
135
+ # @param hash [Hash<String, String>] Filename => content mapping
136
+ # @param format [Symbol] Archive format
137
+ # @param options [Hash] Format-specific options
138
+ # @return [StringIO] Complete archive in memory
139
+ #
140
+ # @example Create from Hash
141
+ # data = {'file1.txt' => 'content1', 'file2.txt' => 'content2'}
142
+ # zip = Omnizip::Buffer.create_from_hash(data, :zip)
143
+ def create_from_hash(hash, format = :zip, **options)
144
+ create(format, **options) do |archive|
145
+ hash.each do |name, content|
146
+ archive.add(name, content)
147
+ end
148
+ end
149
+ end
150
+
151
+ private
152
+
151
153
  # Create ZIP archive in buffer
152
154
  #
153
155
  # @param buffer [StringIO] Buffer to write to
@@ -94,7 +94,7 @@ module Omnizip
94
94
  when CHECK_SHA256
95
95
  verify_sha256(data, expected)
96
96
  else
97
- raise "Unknown check type: #{check_type}"
97
+ raise Omnizip::UnknownChecksumError, "Unknown check type: #{check_type}"
98
98
  end
99
99
  end
100
100
 
@@ -122,7 +122,7 @@ module Omnizip
122
122
  when CHECK_SHA256
123
123
  Digest::SHA256.digest(data)
124
124
  else
125
- raise "Unknown check type: #{check_type}"
125
+ raise Omnizip::UnknownChecksumError, "Unknown check type: #{check_type}"
126
126
  end
127
127
  end
128
128
  end
@@ -95,30 +95,23 @@ module Omnizip
95
95
  "Input archive not found: #{input}"
96
96
  end
97
97
 
98
- writer = Writer.new(output, chunk_size: chunk_size)
99
- processed = 0
100
-
101
- Omnizip::Zip::File.open(input) do |zip|
102
- entry = zip.entries.first
103
- total_size = entry.size
104
-
105
- # Read the full entry content
106
- content = zip.get_input_stream(entry)
98
+ reader = Omnizip::Formats::Zip::Reader.new(input)
99
+ entry = reader.entries.first
100
+ raise Omnizip::Error, "No entries to decompress in #{input}" unless entry
107
101
 
108
- # Write in chunks
109
- offset = 0
110
- while offset < content.bytesize
111
- chunk = content.byteslice(offset, chunk_size) || ""
112
- break if chunk.empty?
102
+ total_size = entry.uncompressed_size.to_i
103
+ processed = 0
104
+ writer = Writer.new(output, chunk_size: chunk_size)
113
105
 
114
- writer.write_chunk(chunk)
115
- processed += chunk.bytesize
116
- offset += chunk.bytesize
106
+ # Streams entry-by-entry in bounded memory — the whole entry
107
+ # is never held at once
108
+ reader.read_entry_stream(entry.filename, chunk_size: chunk_size) do |chunk|
109
+ writer.write_chunk(chunk)
110
+ processed += chunk.bytesize
117
111
 
118
- if progress
119
- percentage = (processed.to_f / total_size * 100).round(2)
120
- progress.call(processed, total_size, percentage)
121
- end
112
+ if progress
113
+ percentage = (processed.to_f / total_size * 100).round(2)
114
+ progress.call(processed, total_size, percentage)
122
115
  end
123
116
  end
124
117
 
@@ -64,7 +64,7 @@ module Omnizip
64
64
  # @raise [RuntimeError] if entry not found
65
65
  def extract_entry(entry_name, output_path)
66
66
  entry = @entries.find { |e| e.name == entry_name }
67
- raise "Entry not found: #{entry_name}" unless entry
67
+ raise Errno::ENOENT, "Entry not found: #{entry_name}" unless entry
68
68
 
69
69
  extract_single_entry(entry, output_path)
70
70
  end
@@ -67,7 +67,7 @@ module Omnizip
67
67
  # @param output_path [String] Destination path
68
68
  def extract_entry(entry_path, output_path)
69
69
  entry = find_entry(entry_path)
70
- raise "Entry not found: #{entry_path}" unless entry
70
+ raise Errno::ENOENT, "Entry not found: #{entry_path}" unless entry
71
71
 
72
72
  if entry.directory?
73
73
  FileUtils.mkdir_p(output_path)
@@ -111,7 +111,7 @@ module Omnizip
111
111
  loop do
112
112
  @io.seek(sector * Iso::SECTOR_SIZE)
113
113
  data = @io.read(Iso::SECTOR_SIZE)
114
- raise "Failed to read volume descriptor" unless data
114
+ raise Omnizip::InvalidArchiveError, "Failed to read volume descriptor" unless data
115
115
 
116
116
  vd = VolumeDescriptor.parse(data)
117
117
 
@@ -127,7 +127,7 @@ module Omnizip
127
127
 
128
128
  return if @primary_volume_descriptor
129
129
 
130
- raise "No primary volume descriptor found"
130
+ raise Omnizip::InvalidArchiveError, "No primary volume descriptor found"
131
131
  end
132
132
 
133
133
  # Parse directory structure
@@ -29,7 +29,7 @@ module Omnizip
29
29
  #
30
30
  # @param data [String] Binary data
31
31
  def parse(data)
32
- raise "Invalid volume descriptor size" unless data.bytesize >= 2048
32
+ raise Omnizip::InvalidArchiveError, "Invalid volume descriptor size" unless data.bytesize >= 2048
33
33
 
34
34
  # Byte 0: Volume descriptor type
35
35
  @type = data.getbyte(0)
@@ -37,7 +37,7 @@ module Omnizip
37
37
  # Bytes 1-5: Standard identifier "CD001"
38
38
  @identifier = data[1, 5]
39
39
  unless @identifier == ISO_IDENTIFIER
40
- raise "Invalid ISO identifier: expected #{ISO_IDENTIFIER}, got #{@identifier}"
40
+ raise Omnizip::InvalidArchiveError, "Invalid ISO identifier: expected #{ISO_IDENTIFIER}, got #{@identifier}"
41
41
  end
42
42
 
43
43
  # Byte 6: Version (should be 1)
@@ -230,26 +230,6 @@ module Omnizip
230
230
  # Decode as 8 direct bits
231
231
  @range_decoder.decode_direct_bits(8)
232
232
  end
233
-
234
- # Decode RAR-specific escape code
235
- #
236
- # RAR variant H uses different escape code values
237
- # and handling compared to standard PPMd7.
238
- #
239
- # Escape codes in RAR:
240
- # - 0: New symbol follows
241
- # - 1: Same as last symbol (run-length)
242
- # - 2-255: Reserved for future use
243
- #
244
- # @return [Integer, nil] Escape code or nil
245
- def decode_escape_code
246
- # RAR escape codes differ from PPMd7
247
- # This is a placeholder for the proper implementation
248
-
249
- # For now, return 0 (new symbol follows)
250
- # Real implementation would decode from range coder
251
- 0
252
- end
253
233
  end
254
234
  end
255
235
  end
@@ -61,7 +61,7 @@ module Omnizip
61
61
  # @param password [String, nil] Optional password
62
62
  # @raise [RuntimeError] if extraction fails
63
63
  def extract(archive_path, output_dir, password: nil)
64
- raise "RAR extraction not available" unless available?
64
+ raise Omnizip::RarNotAvailableError, "RAR extraction not available" unless available?
65
65
 
66
66
  if gem_available?
67
67
  extract_with_gem(archive_path, output_dir, password)
@@ -78,7 +78,7 @@ module Omnizip
78
78
  # @return [Array<Hash>] Entry information
79
79
  # @raise [RuntimeError] if listing fails
80
80
  def list(archive_path)
81
- raise "RAR extraction not available" unless available?
81
+ raise Omnizip::RarNotAvailableError, "RAR extraction not available" unless available?
82
82
 
83
83
  if gem_available?
84
84
  list_with_gem(archive_path)
@@ -97,7 +97,7 @@ module Omnizip
97
97
  # @param password [String, nil] Optional password
98
98
  def extract_entry(archive_path, entry_name, output_path,
99
99
  password: nil)
100
- raise "RAR extraction not available" unless available?
100
+ raise Omnizip::RarNotAvailableError, "RAR extraction not available" unless available?
101
101
 
102
102
  if gem_available?
103
103
  extract_entry_with_gem(archive_path, entry_name,
@@ -185,7 +185,7 @@ module Omnizip
185
185
  require "unrar"
186
186
  Unrar.extract(archive_path, output_dir, password: password)
187
187
  rescue StandardError => e
188
- raise "Gem extraction failed: #{e.message}"
188
+ raise Omnizip::DecompressionError, "Gem extraction failed: #{e.message}"
189
189
  end
190
190
 
191
191
  # Extract with system command
@@ -193,7 +193,7 @@ module Omnizip
193
193
  cmd = build_extract_command(archive_path, output_dir, password)
194
194
  return if system(*cmd)
195
195
 
196
- raise "Command extraction failed: #{archive_path}"
196
+ raise Omnizip::DecompressionError, "Command extraction failed: #{archive_path}"
197
197
  end
198
198
 
199
199
  # List with unrar gem
@@ -210,13 +210,13 @@ module Omnizip
210
210
  }
211
211
  end
212
212
  rescue StandardError => e
213
- raise "Gem listing failed: #{e.message}"
213
+ raise Omnizip::DecompressionError, "Gem listing failed: #{e.message}"
214
214
  end
215
215
 
216
216
  # List with system command
217
217
  def list_with_command(archive_path)
218
218
  output = `"#{command_path}" vb "#{archive_path}" 2>&1`
219
- raise "Command listing failed" unless $CHILD_STATUS.success?
219
+ raise Omnizip::DecompressionError, "Command listing failed" unless $CHILD_STATUS.success?
220
220
 
221
221
  output.split("\n").map do |line|
222
222
  { name: line.strip, size: 0, compressed_size: 0,
@@ -64,7 +64,7 @@ module Omnizip
64
64
  def extract_entry(entry_name, output_path, password: nil)
65
65
  ensure_open
66
66
  entry = @entries.find { |e| e.name == entry_name }
67
- raise "Entry not found: #{entry_name}" unless entry
67
+ raise Errno::ENOENT, "Entry not found: #{entry_name}" unless entry
68
68
 
69
69
  # Create directory if needed
70
70
  FileUtils.mkdir_p(File.dirname(output_path))
@@ -150,7 +150,7 @@ module Omnizip
150
150
  def parse_archive(io)
151
151
  # Read and validate header
152
152
  @header = Header.read(io)
153
- raise "Invalid RAR archive" unless @header.valid?
153
+ raise Omnizip::InvalidArchiveError, "Invalid RAR archive" unless @header.valid?
154
154
 
155
155
  # Update archive info
156
156
  @archive_info.version = @header.version
@@ -301,7 +301,7 @@ module Omnizip
301
301
  #
302
302
  # @param output_dir [String] Output directory
303
303
  def extract(output_dir)
304
- raise "RPM not opened" unless @file
304
+ raise Omnizip::IOError, "RPM not opened" unless @file
305
305
 
306
306
  FileUtils.mkdir_p(output_dir)
307
307
 
@@ -322,7 +322,7 @@ module Omnizip
322
322
  #
323
323
  # @return [String] Raw compressed payload data
324
324
  def raw_payload
325
- raise "RPM not opened" unless @file
325
+ raise Omnizip::IOError, "RPM not opened" unless @file
326
326
 
327
327
  payload_io = payload
328
328
  payload_io.read
@@ -370,7 +370,7 @@ module Omnizip
370
370
  end
371
371
 
372
372
  def payload
373
- raise "RPM not opened" unless @file
373
+ raise Omnizip::IOError, "RPM not opened" unless @file
374
374
 
375
375
  # Calculate payload offset
376
376
  offset = @lead.length
@@ -434,7 +434,7 @@ module Omnizip
434
434
 
435
435
  output
436
436
  rescue StandardError => e
437
- raise "Failed to decompress with #{cmd}: #{e.message}"
437
+ raise Omnizip::DecompressionError, "Failed to decompress with #{cmd}: #{e.message}"
438
438
  end
439
439
 
440
440
  def extract_cpio(source, output_dir)
@@ -103,7 +103,7 @@ module Omnizip
103
103
  end
104
104
 
105
105
  entry = @entries.find { |e| e.name == entry_name }
106
- raise "Entry not found: #{entry_name}" unless entry
106
+ raise Errno::ENOENT, "Entry not found: #{entry_name}" unless entry
107
107
 
108
108
  # Create directory if needed
109
109
  FileUtils.mkdir_p(File.dirname(output_path))
@@ -70,7 +70,7 @@ module Omnizip
70
70
  # @raise [RuntimeError] if entry not found or extraction fails
71
71
  def extract_entry(entry_name, output_path)
72
72
  entry = @entries.find { |e| e.name == entry_name }
73
- raise "Entry not found: #{entry_name}" unless entry
73
+ raise Errno::ENOENT, "Entry not found: #{entry_name}" unless entry
74
74
 
75
75
  # Create directory if needed
76
76
  FileUtils.mkdir_p(File.dirname(output_path))
@@ -203,7 +203,7 @@ module Omnizip
203
203
  @file.seek(@header.header_size)
204
204
  compressed_toc = @file.read(@header.toc_compressed_size)
205
205
 
206
- raise "Failed to read TOC" unless compressed_toc
206
+ raise Omnizip::InvalidArchiveError, "Failed to read TOC" unless compressed_toc
207
207
 
208
208
  # Parse TOC
209
209
  @toc = Toc.parse(compressed_toc, @header.toc_uncompressed_size)
@@ -272,7 +272,7 @@ module Omnizip
272
272
  inf.close
273
273
  result
274
274
  rescue Zlib::Error => e
275
- raise "Failed to decompress data: #{e.message}"
275
+ raise Omnizip::DecompressionError, "Failed to decompress data: #{e.message}"
276
276
  end
277
277
  end
278
278
 
@@ -151,19 +151,94 @@ dereference_links: false)
151
151
  read_entry_data(entry)
152
152
  end
153
153
 
154
+ # Stream a single entry's decompressed content in +chunk_size+
155
+ # chunks, yielding each. STORE copies raw bytes and DEFLATE
156
+ # feeds an incremental inflater, so memory stays bounded by
157
+ # the chunk size; methods without an incremental decoder fall
158
+ # back to the whole-entry read (yielded as one chunk). The
159
+ # streamed path verifies the entry CRC after the last chunk.
160
+ #
161
+ # @param entry_name [String] Name of the entry to stream
162
+ # @param chunk_size [Integer] Read granularity in bytes
163
+ # @yield [chunk] Decompressed chunk
164
+ # @return [void]
165
+ def read_entry_stream(entry_name, chunk_size: 64 * 1024, &block)
166
+ ensure_read
167
+ entry = @entries.find { |e| e.filename == entry_name }
168
+ raise Errno::ENOENT, "Entry not found: #{entry_name}" unless entry
169
+ return if entry.directory? || entry.symlink?
170
+
171
+ crc = Omnizip::Checksums::Crc32.new
172
+ streamed =
173
+ File.open(file_path, "rb") do |io|
174
+ io.seek(entry_data_offset(io, entry), ::IO::SEEK_SET)
175
+
176
+ # Bound reads by the entry payload — what follows it in
177
+ # the file is the central directory, not entry data
178
+ remaining = entry.compressed_size
179
+
180
+ case entry.compression_method
181
+ when COMPRESSION_STORE
182
+ stream_raw(io, chunk_size, crc, remaining, &block)
183
+ when COMPRESSION_DEFLATE
184
+ stream_deflate(io, chunk_size, crc, remaining, &block)
185
+ else
186
+ yield(read_entry_data(entry))
187
+ false
188
+ end
189
+ end
190
+
191
+ return unless streamed
192
+
193
+ if crc.finalize != entry.crc32
194
+ raise Omnizip::ChecksumError,
195
+ "CRC mismatch for #{entry.filename}"
196
+ end
197
+ end
198
+
154
199
  private
155
200
 
201
+ def stream_raw(io, chunk_size, crc, remaining)
202
+ while remaining.positive? &&
203
+ (chunk = io.read([chunk_size, remaining].min))
204
+ break if chunk.empty?
205
+
206
+ remaining -= chunk.bytesize
207
+ crc.update(chunk)
208
+ yield chunk
209
+ end
210
+ true
211
+ end
212
+
213
+ def stream_deflate(io, chunk_size, crc, remaining, &block)
214
+ require "zlib"
215
+ inflater = Zlib::Inflate.new(-Zlib::MAX_WBITS)
216
+ begin
217
+ while remaining.positive? &&
218
+ (chunk = io.read([chunk_size, remaining].min))
219
+ break if chunk.empty?
220
+
221
+ remaining -= chunk.bytesize
222
+ emit_inflated(inflater.inflate(chunk), crc, &block)
223
+ end
224
+ emit_inflated(inflater.finish, crc, &block)
225
+ ensure
226
+ inflater.close
227
+ end
228
+ true
229
+ end
230
+
231
+ def emit_inflated(out, crc)
232
+ return if out.nil? || out.empty?
233
+
234
+ crc.update(out)
235
+ yield out
236
+ end
237
+
156
238
  # Decompress and CRC-verify a single entry's data
157
239
  def read_entry_data(entry)
158
240
  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)
241
+ io.seek(entry_data_offset(io, entry), ::IO::SEEK_SET)
167
242
 
168
243
  compressed_data = io.read(entry.compressed_size)
169
244
  decompressed_data = decompress_data(
@@ -184,6 +259,19 @@ dereference_links: false)
184
259
  end
185
260
  end
186
261
 
262
+ # File offset of an entry's compressed payload: the local
263
+ # header's fixed 30 bytes plus its filename/extra fields.
264
+ def entry_data_offset(io, entry)
265
+ io.seek(entry.local_header_offset, ::IO::SEEK_SET)
266
+
267
+ fixed_header = io.read(30)
268
+ _signature, _version, _flags, _method, _time, _date, _crc32,
269
+ _comp_size, _uncomp_size, filename_length, extra_length = fixed_header.unpack("VvvvvvVVVvv")
270
+
271
+ io.read(filename_length + extra_length)
272
+ io.pos
273
+ end
274
+
187
275
  # Parse the central directory once, on demand
188
276
  def ensure_read
189
277
  read if @entries.empty? && @central_directory.empty?