omnizip 0.3.40 → 0.3.42

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: 5013ae71553867968422f57b01425820f17c5cd6e471ea0cb5b5d46d1cc6a1bd
4
- data.tar.gz: 94698e90386f195cdd7f5fd7a8ebae3010f2f206e70eb96463ce8e4d2ece09b0
3
+ metadata.gz: 060755f6c69bca46bcb999d0d36471045ae3d13e260cfa3c73601a8d440b1634
4
+ data.tar.gz: b524b77c579c824009821ca5d1bdb47327cfc55f3ebd1dab4ea8574d1ec3c631
5
5
  SHA512:
6
- metadata.gz: 415d4cdcb48375d0ba0d1d57dc2592c18d47fcafb9817d956dbe5a3ec7e1fa1048063372759de1ff0c0ef194d06eaa7f5855cfead144681e1fb454ac4fc510b2
7
- data.tar.gz: a901b525b49f60b94098d43a5c4517f5163e07b64ef08099c02f693fc50775461c584ee35f2a6227167bbd461cfcbd9e3985b2383c586610817363170f45a2ef
6
+ metadata.gz: 237f14e0c479844a473d1830e956882a0d250f6c733522bcd9f3d36b7ba10b474b4564902791aecd52948ad330b82bb0bb7e61dd97db607bf3dcd85ab3dc847b
7
+ data.tar.gz: cfe4637c8ad1223ccb8b9735fc1494d61937b636f3d79dd7fbd2c1f59f6f35eb38686152ee56732f0cb29cfbf3c12f7ddfb6dae5bd6c146b8d833fca5315a00a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,95 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.41] - 2026-08-29
11
+
12
+ ### Changed
13
+ - One RAR parser. `Formats::Rar3::Reader` and `Formats::Rar5::Reader`
14
+ are adapters over the primary `Formats::Rar::Reader` — the only
15
+ parser verified against real WinRAR archives — replacing ~600
16
+ lines of duplicate parsing. Both legacy interfaces gain real-
17
+ archive fidelity for free (exact mtimes, backslash normalization,
18
+ directory detection, CRC fields). The legacy RAR5 writer — whose
19
+ output not even unrar could list (garbage names, 2038 dates) —
20
+ now bridges onto the spec-conformant primary writer; its archives
21
+ pass `unrar t`. The primary Header learned minimal archives (file
22
+ block first, no main header) for RAR4 and RAR5, restoring a legacy
23
+ capability in one place.
24
+ - Direct-seek extraction: the parser records each entry's
25
+ data_offset, so native extraction is one seek + read instead of
26
+ re-parsing the archive per entry. unrar-backed extraction spills
27
+ the archive once per archive path instead of extracting the whole
28
+ archive per entry (O(n^2) -> O(n)). Fixed a latent off-by-two in
29
+ the fallback block walk.
30
+ - Converter symmetry: zip -> 7z uses the native reader with the
31
+ same extract-to-temp/write-from-tree shape as 7z -> zip; internal
32
+ code no longer calls the rubyzip-compat `Omnizip::Zip::File` seam.
33
+ - `omnizip archive create` passes one flat options hash;
34
+ `Formats::Rar.create` applies the RAR4/RAR5-specific semantics
35
+ (the RAR4 writer's recovery guard no longer trips on explicit
36
+ false).
37
+
38
+ ### Fixed
39
+ - Native-decode CRC guard referenced `Compression::DecompressionError`
40
+ which does not exist (the classes live under
41
+ `Compression::Dispatcher`); the NameError was silently masked by
42
+ the fallback rescue.
43
+ - Unicode-flagged RAR4 names can carry non-UTF-8 bytes;
44
+ String#tr raised and aborted the block walk under non-UTF-8
45
+ locales (CI runners). Names are scrubbed before normalization.
46
+ - Corrupt size fields drove unbounded reads: a garbage pack_size
47
+ attempted a multi-gigabyte read (NoMemoryError on CI) or an
48
+ overflowing seek (EINVAL on Linux, silently clamped on macOS).
49
+ Packed data is now seeked past — never allocated to skip — with
50
+ sizes bounds-checked against the archive length and corrupt skips
51
+ clamped to EOF so entries still list.
52
+
53
+ ## [0.3.40] - 2026-08-29
54
+
55
+ ### Fixed
56
+ - Every CLI command now works end-to-end (each was exercised against
57
+ real archives and its failure fixed):
58
+ - `omnizip list` printed "No algorithms registered." — all builtin
59
+ algorithms register lazily, so the available list was empty until
60
+ each was first used. `AlgorithmRegistry.available` now includes
61
+ the builtins.
62
+ - `omnizip convert a.7z a.zip` crashed on a fictional reader/writer
63
+ API inside the conversion strategy; rewritten on the real APIs and
64
+ round-trips byte-identically through unzip.
65
+ - `omnizip archive create x.rar ...` raised NoMethodError (a
66
+ module-level `Formats::Rar.create` never existed). It exists now
67
+ (RAR5 default, `version: 4` for RAR4); directory inputs work —
68
+ the stale "RAR5 does not support directories" restriction is gone
69
+ and trees archive under their own name like `.7z`, verified with
70
+ unrar.
71
+ - `omnizip archive metadata` opened every input as a ZIP: `.7z`
72
+ crashed with a ZIP parse error, and runs with no edit option
73
+ still printed "Metadata updated successfully". 7z now shows real
74
+ read-only metadata, unknown formats raise truthful errors, and
75
+ ZIPs default to show mode when nothing is being edited.
76
+ - `omnizip decompress archive.zip out/` — the behavior the
77
+ command's own help documents — failed with "Is a directory".
78
+ Archive inputs (zip/7z/tar/rar/cpio/iso) and directory outputs
79
+ now extract through the convenience layer, content-verified with
80
+ diff for zip, 7z, rar, and tar.
81
+ - Deflate64.metadata returned a raw Hash, crashing the CLI algorithm
82
+ table; it returns `Models::AlgorithmMetadata` like every other
83
+ algorithm.
84
+ - Guides referenced a nonexistent `:zstd` algorithm-registry key;
85
+ corrected to `:zstandard` everywhere (the registry key differs from
86
+ RPM's `:zstd` compression type).
87
+
88
+ ### Added
89
+ - `Omnizip::Buffer` 7z support: `create(:seven_zip)` /
90
+ `create_from_hash(hash, :seven_zip)`, `open`, `extract_to_memory`,
91
+ and `MemoryExtractor` all handle 7z through a temporary-file bridge
92
+ (the format has no in-memory stream implementation), including
93
+ explicit directory entries via a new
94
+ `SevenZip::Writer#add_directory_entry`. Format auto-detection fixed
95
+ to compare binary magic literals — UTF-8 literals never matched
96
+ binary data, so 7z buffers raised "Unknown archive format".
97
+ - README gains an In-Memory Archives section (examples run verbatim).
98
+
10
99
  ## [0.3.39] - 2026-08-28
11
100
 
12
101
  ### Fixed
data/CONTEXT.md ADDED
@@ -0,0 +1,63 @@
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 decoding
23
+
24
+ - **Native decode** — decompression implemented in Ruby inside
25
+ omnizip. RAR native decode is **CRC-gated**: the stored header CRC
26
+ must match the decoded bytes or the result is discarded.
27
+ - **External fallback** — delegating to an external tool (the unrar
28
+ command) when native decode cannot be trusted. RAR extraction
29
+ spills the archive once per archive path (the **extraction spill
30
+ cache**) and copies entries out of it.
31
+
32
+ ## Writing and interop
33
+
34
+ - **Interop-verified** — output accepted by the official tool for
35
+ the format (unrar for RAR, 7zz for 7z/ISO, unzip for ZIP), checked
36
+ with `test` plus a byte-identical extraction round-trip.
37
+ - **Omnizip-internal codec** — a compression stream only omnizip can
38
+ decode (RAR methods above :store). Archives carrying them must be
39
+ labeled honestly; official tools fail loudly, never silently.
40
+ - **STORE** — the uncompressed method; the interoperability floor
41
+ for every archive writer in this library.
42
+
43
+ ## Seams
44
+
45
+ - **Compat seam** — `Omnizip::Zip` (aliased into the global `Zip`
46
+ namespace by `omnizip/rubyzip_compat`): a rubyzip-compatible
47
+ layer for library consumers migrating off rubyzip. Internal code
48
+ must use the native `Formats::Zip` tree instead; the Metadata
49
+ subsystem is its one deliberate in-house consumer (in-place
50
+ central-directory editing).
51
+ - **Bridge** — an adapter that carries a file-based implementation
52
+ across to a non-file interface, spilling through a temporary file
53
+ (the RAR3/RAR5 legacy readers, `Buffer::SevenZipBridge`).
54
+ - **Primary parser** — the one parser per format family verified
55
+ against real archives (e.g. `Formats::Rar::Reader`). Legacy
56
+ interfaces are adapters over primary parsers, never parallel
57
+ implementations.
58
+
59
+ ## In memory
60
+
61
+ - **Buffer** — `Omnizip::Buffer`: in-memory archive create/read for
62
+ ZIP (stream-based) and 7z (through the bridge). Format detection
63
+ compares binary magic literals.
@@ -120,35 +120,23 @@ module Omnizip
120
120
 
121
121
  start_time = Time.now
122
122
 
123
+ # Format-agnostic options; Formats::Rar applies the
124
+ # version-specific semantics
123
125
  writer_opts = {
124
126
  version: version,
125
127
  compression: compression,
126
128
  level: level,
127
129
  include_mtime: include_mtime,
128
130
  include_crc32: include_crc32,
129
- }
130
-
131
- # Add solid compression for RAR5
132
- writer_opts[:solid] = solid if version == 5 && solid
133
-
134
- # Add multi-volume options for RAR5
135
- if version == 5 && multi_volume && volume_size
136
- writer_opts[:multi_volume] = true
137
- writer_opts[:volume_size] = volume_size
138
- writer_opts[:volume_naming] = volume_naming
139
- end
140
-
141
- # Add encryption options for RAR5
142
- if version == 5 && password
143
- writer_opts[:password] = password
144
- writer_opts[:kdf_iterations] = kdf_iterations
145
- end
146
-
147
- # Add recovery options for RAR5
148
- if version == 5 && recovery
149
- writer_opts[:recovery] = true
150
- writer_opts[:recovery_percent] = recovery_percent
151
- end
131
+ solid: solid,
132
+ multi_volume: multi_volume,
133
+ volume_size: volume_size,
134
+ volume_naming: volume_naming,
135
+ password: password,
136
+ kdf_iterations: kdf_iterations,
137
+ recovery: recovery,
138
+ recovery_percent: recovery_percent,
139
+ }.compact
152
140
 
153
141
  result_files = Omnizip::Formats::Rar.create(output_file,
154
142
  writer_opts) do |rar|
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "tmpdir"
4
+
3
5
  module Omnizip
4
6
  module Converter
5
7
  # Convert ZIP archives to 7-Zip format
@@ -10,14 +12,20 @@ module Omnizip
10
12
  start_time = Time.now
11
13
  entry_count = 0
12
14
 
13
- # Open source ZIP archive
14
- Omnizip::Zip::File.open(source_path) do |zip|
15
- # Collect all entries and their data
16
- entries_data = collect_entries(zip)
17
- entry_count = entries_data.size
15
+ Dir.mktmpdir("omnizip_convert_zip") do |tmp|
16
+ # Extract the ZIP through the native reader; directories are
17
+ # implied by extracted file paths
18
+ Omnizip::Formats::Zip.extract(source_path, tmp)
19
+
20
+ writer = Omnizip::Formats::SevenZip::Writer.new(target_path,
21
+ writer_options)
22
+ Dir.glob(File.join(tmp, "**", "*")).each do |path|
23
+ next if File.directory?(path)
18
24
 
19
- # Create target 7z archive
20
- create_seven_zip(entries_data)
25
+ entry_count += 1
26
+ writer.add_file(path, path.delete_prefix("#{tmp}/"))
27
+ end
28
+ writer.write
21
29
  end
22
30
 
23
31
  create_result(start_time, entry_count)
@@ -45,62 +53,12 @@ module Omnizip
45
53
 
46
54
  private
47
55
 
48
- def collect_entries(zip)
49
- entries = []
50
-
51
- zip.entries.each do |entry|
52
- data = {
53
- name: entry.name,
54
- directory: entry.directory?,
55
- mtime: entry.time,
56
- content: nil,
57
- }
58
-
59
- unless entry.directory?
60
- data[:content] = zip.get_input_stream(entry)
61
-
62
- if options.preserve_metadata && entry.unix_perms.positive?
63
- data[:unix_perms] = entry.unix_perms
64
- end
65
- end
66
-
67
- entries << data
68
- end
69
-
70
- entries
71
- end
72
-
73
- def create_seven_zip(entries)
74
- writer = Omnizip::Formats::SevenZip::Writer.new(target_path)
75
-
76
- # Set compression options
77
- compression = options.compression || :lzma2
78
- level = options.compression_level || 5
79
- options.solid.nil? || options.solid
80
-
81
- # Add each entry
82
- entries.each do |entry_data|
83
- if entry_data[:directory]
84
- # 7z doesn't have explicit directory entries
85
- # Directories are implied by file paths
86
- next
87
- end
88
-
89
- writer.add_data(
90
- entry_data[:name],
91
- entry_data[:content],
92
- algorithm: compression,
93
- level: level,
94
- )
95
- end
96
-
97
- # Apply filter if specified
98
- if options.filter
99
- add_warning("Filters not yet supported in 7z conversion")
100
- end
101
-
102
- # Write the archive
103
- writer.write
56
+ # SevenZip::Writer takes algorithm/level/solid as keywords
57
+ def writer_options
58
+ {
59
+ algorithm: options.compression || :lzma2,
60
+ level: options.compression_level || 5,
61
+ }
104
62
  end
105
63
  end
106
64
  end
@@ -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"
@@ -45,6 +45,7 @@ module Omnizip
45
45
  # @return [Models::RarEntry, nil] Parsed entry or nil
46
46
  def parse_rar4_file_block(io)
47
47
  entry = Models::RarEntry.new
48
+ block_start = io.pos
48
49
 
49
50
  # Read block header
50
51
  read_uint16(io)
@@ -73,12 +74,23 @@ module Omnizip
73
74
  unpack_size |= (high_unpack_size << 32)
74
75
  end
75
76
 
77
+ # Sanity bounds: corrupt headers must not drive huge reads.
78
+ # head_size can never exceed the bytes left from the block
79
+ # start, and the name must fit inside the header.
80
+ if head_size > io.size - block_start ||
81
+ name_size + 32 > head_size
82
+ return nil
83
+ end
84
+
76
85
  # Read file name
77
86
  name_bytes = io.read(name_size)
78
87
  entry.name = decode_filename(name_bytes, head_flags)
79
88
  # RAR4 archives store DOS-style backslash separators;
80
- # normalize to forward slashes like unrar on Unix.
81
- entry.name = entry.name.tr("\\", "/")
89
+ # normalize to forward slashes like unrar on Unix. Scrub
90
+ # first: unicode-flagged names may carry bytes that are not
91
+ # valid UTF-8, and String#tr raises on them under any
92
+ # locale.
93
+ entry.name = entry.name.scrub.tr("\\", "/")
82
94
 
83
95
  # Set entry properties
84
96
  entry.size = unpack_size
@@ -111,7 +123,14 @@ module Omnizip
111
123
  remaining = head_size - (name_size + 32)
112
124
  remaining -= 8 if head_flags.anybits?(FILE_LARGE)
113
125
  io.read(remaining) if remaining.positive?
114
- io.read(pack_size) # Skip compressed data
126
+ # Record where the packed data starts so extraction can seek
127
+ # straight to it instead of re-parsing the archive
128
+ entry.data_offset = io.pos
129
+ # Seek past the data: never allocate the packed bytes just
130
+ # to skip them. A corrupt pack_size (bigger than the file)
131
+ # would overflow the seek offset on Linux — clamp to EOF so
132
+ # the entry still lists.
133
+ skip_past(io, pack_size)
115
134
 
116
135
  entry
117
136
  end
@@ -135,6 +154,10 @@ module Omnizip
135
154
  extra_size = header_flags.anybits?(RAR5_FLAG_EXTRA_AREA) ? read_vint(io) : 0
136
155
  data_size = header_flags.anybits?(RAR5_FLAG_DATA_AREA) ? read_vint(io) : 0
137
156
 
157
+ # Sanity bounds: corrupt headers must not drive huge reads
158
+ remaining_bytes = io.size - io.pos
159
+ return nil if extra_size > remaining_bytes || data_size > remaining_bytes
160
+
138
161
  # Read file header
139
162
  file_flags = read_vint(io)
140
163
  unpack_size = read_vint(io)
@@ -155,7 +178,7 @@ module Omnizip
155
178
 
156
179
  # Read file name
157
180
  name_bytes = io.read(name_length)
158
- entry.name = name_bytes.force_encoding("UTF-8")
181
+ entry.name = name_bytes.to_s.scrub.force_encoding("UTF-8")
159
182
 
160
183
  # Set entry properties
161
184
  entry.size = unpack_size
@@ -168,13 +191,26 @@ module Omnizip
168
191
  entry.is_dir = file_flags.anybits?(RAR5_FLAG_IS_DIR)
169
192
  entry.version = 5
170
193
 
171
- # Skip extra area, then compressed data
194
+ # Skip extra area, then compressed data; record the data
195
+ # start for direct-seek extraction. The data is seeked past,
196
+ # never read: corrupt sizes must not drive huge allocations.
172
197
  io.read(extra_size) if extra_size.positive?
173
- io.read(data_size) if data_size.positive?
198
+ entry.data_offset = io.pos
199
+ skip_past(io, data_size)
174
200
 
175
201
  entry
176
202
  end
177
203
 
204
+ # Skip forward without allocating: clamp corrupt sizes to EOF
205
+ # so an overflowing seek (EINVAL on Linux) cannot abort the
206
+ # block walk
207
+ def skip_past(io, bytes)
208
+ return if bytes <= 0
209
+
210
+ remaining = io.size - io.pos
211
+ io.seek([bytes, remaining].min, ::IO::SEEK_CUR)
212
+ end
213
+
178
214
  # Decode filename from bytes
179
215
  #
180
216
  # @param bytes [String] Raw filename bytes
@@ -236,18 +236,54 @@ module Omnizip
236
236
  end
237
237
 
238
238
  # Extract entry with command
239
+ #
240
+ # unrar cannot extract a single entry without walking the
241
+ # archive, so the archive spills to a cache directory ONCE
242
+ # per archive path; subsequent extractions copy out of the
243
+ # cache instead of re-running unrar over the whole file.
239
244
  def extract_entry_with_command(archive_path, entry_name,
240
245
  output_path, password)
241
- temp_dir = Dir.mktmpdir
242
- cmd = build_extract_command(archive_path, temp_dir, password)
243
- unless system(*cmd)
244
- raise "Command entry extraction failed: #{entry_name}"
246
+ cache_key = begin
247
+ File.realpath(archive_path)
248
+ rescue StandardError
249
+ archive_path
250
+ end
251
+
252
+ cached_dir = extract_cache[cache_key]
253
+ unless cached_dir
254
+ cached_dir = Dir.mktmpdir("omnizip_unrar")
255
+ cmd = build_extract_command(archive_path, cached_dir, password)
256
+ unless system(*cmd)
257
+ FileUtils.rm_rf(cached_dir)
258
+ raise "Command entry extraction failed: #{entry_name}"
259
+ end
260
+ extract_cache[cache_key] = cached_dir
261
+ install_exit_sweeper
245
262
  end
246
263
 
247
- source = File.join(temp_dir, entry_name)
248
- FileUtils.mv(source, output_path) if File.exist?(source)
249
- ensure
250
- FileUtils.rm_rf(temp_dir) if temp_dir
264
+ source = File.join(cached_dir, entry_name)
265
+ FileUtils.cp(source, output_path) if File.exist?(source)
266
+ end
267
+
268
+ # Extracted-archive cache: archive path => spill directory.
269
+ # Cleared by .clear_extract_cache!; an at_exit sweeper
270
+ # reclaims everything at normal process termination.
271
+ def extract_cache
272
+ @extract_cache ||= {}
273
+ end
274
+
275
+ # Drop the extraction cache and remove its directories
276
+ def clear_extract_cache!
277
+ extract_cache.each_value { |dir| FileUtils.rm_rf(dir) }
278
+ extract_cache.clear
279
+ end
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
251
287
  end
252
288
 
253
289
  # Build extract command
@@ -85,7 +85,9 @@ module Omnizip
85
85
  # Parse RAR4 header
86
86
  #
87
87
  # The 7-byte signature consumed by #parse IS the marker
88
- # block, so the main header follows immediately.
88
+ # block, so the main header follows immediately. Minimal
89
+ # archives skip the main header and start with a file block —
90
+ # accept those by rewinding to the block.
89
91
  #
90
92
  # @param io [IO] Input stream
91
93
  def parse_rar4_header(io)
@@ -94,6 +96,12 @@ module Omnizip
94
96
  head_flags = read_uint16(io)
95
97
  head_size = read_uint16(io)
96
98
 
99
+ if head_type == BLOCK_FILE
100
+ io.seek(-7, ::IO::SEEK_CUR)
101
+ @flags = 0
102
+ return
103
+ end
104
+
97
105
  unless head_type == BLOCK_ARCHIVE
98
106
  raise "Expected archive block, got 0x#{head_type.to_s(16)}"
99
107
  end
@@ -117,11 +125,20 @@ module Omnizip
117
125
  # RAR5 uses variable-length integer encoding. Layout per the
118
126
  # RAR 5.0 format: HeaderCRC32(4), Header size(vint), Header
119
127
  # type(vint), Header flags(vint), then type-specific fields.
128
+ header_start = io.pos
120
129
  read_uint32(io)
121
130
  read_vint(io) # header size (redundant: areas carry their own)
122
131
  header_type = read_vint(io)
123
132
  header_flags = read_vint(io)
124
133
 
134
+ # Minimal archives skip the main header and start with a
135
+ # file header (type 2) — rewind so block iteration reads it
136
+ if header_type == RAR5_HEADER_FILE
137
+ io.seek(header_start, ::IO::SEEK_SET)
138
+ @flags = 0
139
+ return
140
+ end
141
+
125
142
  unless header_type == RAR5_HEADER_MAIN
126
143
  raise "Expected main header, got #{header_type}"
127
144
  end
@@ -10,7 +10,7 @@ module Omnizip
10
10
  attr_accessor :name, :size, :compressed_size, :crc, :is_dir,
11
11
  :host_os, :mtime, :attributes, :method,
12
12
  :version, :flags, :volume_index, :split_before,
13
- :split_after, :encrypted
13
+ :split_after, :encrypted, :data_offset
14
14
 
15
15
  # Initialize RAR entry
16
16
  def initialize
@@ -268,7 +268,7 @@ module Omnizip
268
268
  # fall back to unrar.
269
269
  data = output.string
270
270
  if entry.crc && Zlib.crc32(data) != entry.crc
271
- raise Compression::DecompressionError,
271
+ raise Compression::Dispatcher::DecompressionError,
272
272
  "CRC mismatch for #{entry.name}"
273
273
  end
274
274
 
@@ -296,19 +296,26 @@ module Omnizip
296
296
  # @param entry [Models::RarEntry] File entry
297
297
  # @return [StringIO] Compressed data stream
298
298
  def read_compressed_data(entry)
299
- # Find the entry's data offset in the archive
299
+ # The parser records each entry's data offset, so native
300
+ # extraction is a single seek + read
301
+ if entry.data_offset && entry.compressed_size
302
+ File.open(@file_path, "rb") do |io|
303
+ io.seek(entry.data_offset)
304
+ return StringIO.new(io.read(entry.compressed_size) || "")
305
+ end
306
+ end
307
+
308
+ # Fallback for entries parsed without offsets (e.g. by older
309
+ # parsers): walk the blocks to locate the entry
300
310
  File.open(@file_path, "rb") do |io|
301
- # Skip signature and headers
302
311
  Header.read(io)
303
312
 
304
- # Parse file blocks to find our entry
305
313
  parser = BlockParser.new(@header.version)
306
314
 
307
315
  loop do
308
316
  block_start = io.pos
309
317
  break if io.eof?
310
318
 
311
- # Peek at block type
312
319
  crc_bytes = io.read(2)
313
320
  break unless crc_bytes
314
321
 
@@ -317,45 +324,33 @@ module Omnizip
317
324
 
318
325
  head_type = type_byte.ord
319
326
 
320
- # If end block, stop
321
327
  break if head_type == BLOCK_ENDARC
322
328
 
323
- # Reset to block start
324
329
  io.seek(block_start)
325
330
 
326
- # If not a file block, read and skip it
327
331
  unless head_type == BLOCK_FILE
328
- # Read header
329
332
  io.read(2) # CRC
330
333
  io.read(1) # TYPE
331
- io.read(2)&.unpack1("v") || 0
334
+ io.read(2) # FLAGS
332
335
  size = io.read(2)&.unpack1("v") || 0
333
336
 
334
- # Skip rest of block (size includes TYPE+FLAGS+SIZE = 5 bytes)
335
- remaining = size - 5
337
+ # 7 bytes consumed (CRC/TYPE/FLAGS/SIZE); skip the rest
338
+ remaining = size - 7
336
339
  io.read(remaining) if remaining.positive?
337
340
  next
338
341
  end
339
342
 
340
- # Parse this file block
341
343
  test_entry = parser.parse_file_block(io)
342
344
 
343
- # Check if this is our entry
344
345
  if test_entry && test_entry.name == entry.name
345
- # BlockParser positions us right after the compressed data
346
- # So we need to back up and read it
347
- data_end = io.pos
348
- data_start = data_end - entry.compressed_size
346
+ data_start = io.pos - entry.compressed_size
349
347
 
350
348
  io.seek(data_start)
351
- compressed = io.read(entry.compressed_size)
352
-
353
- return StringIO.new(compressed)
349
+ return StringIO.new(io.read(entry.compressed_size) || "")
354
350
  end
355
351
  end
356
352
  end
357
353
 
358
- # If we didn't find it, return empty
359
354
  StringIO.new("")
360
355
  end
361
356
  end
@@ -169,7 +169,7 @@ module Omnizip
169
169
  warn "RAR4 writer ignores volume_size: not implemented " \
170
170
  "(no corresponding flag is written)"
171
171
  end
172
- return unless @options[:recovery].to_i.positive?
172
+ return unless @options[:recovery]&.to_i&.positive?
173
173
 
174
174
  warn "RAR4 writer ignores recovery: not implemented " \
175
175
  "(no corresponding flag is written)"