omnizip 0.3.40 → 0.3.41

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: 919a322870094f270bd574530af4c463b6446586fdf869026b79ad017834bc1e
4
+ data.tar.gz: 55507eb0b2da4109cb094e711cda2d39df10773c9774c4a01614edc0d8548d9a
5
5
  SHA512:
6
- metadata.gz: 415d4cdcb48375d0ba0d1d57dc2592c18d47fcafb9817d956dbe5a3ec7e1fa1048063372759de1ff0c0ef194d06eaa7f5855cfead144681e1fb454ac4fc510b2
7
- data.tar.gz: a901b525b49f60b94098d43a5c4517f5163e07b64ef08099c02f693fc50775461c584ee35f2a6227167bbd461cfcbd9e3985b2383c586610817363170f45a2ef
6
+ metadata.gz: 73b03468e2bb51d227059f316ab4fce911ce19ee6e0c175559de7b1df58a69536d9284d2b5ee2a0a19366ef0c53d448d02e4b95f397042c69c569630aacea86f
7
+ data.tar.gz: dad012852021f331f77a36bf86842274f612d215e32829965bf4bbaf5ad8b9b17539eb7e531a55e73c93d206c73a66760ccde6dfcf234b49e371f17f79451df8
data/CHANGELOG.md CHANGED
@@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.40] - 2026-08-29
11
+
12
+ ### Fixed
13
+ - Every CLI command now works end-to-end (each was exercised against
14
+ real archives and its failure fixed):
15
+ - `omnizip list` printed "No algorithms registered." — all builtin
16
+ algorithms register lazily, so the available list was empty until
17
+ each was first used. `AlgorithmRegistry.available` now includes
18
+ the builtins.
19
+ - `omnizip convert a.7z a.zip` crashed on a fictional reader/writer
20
+ API inside the conversion strategy; rewritten on the real APIs and
21
+ round-trips byte-identically through unzip.
22
+ - `omnizip archive create x.rar ...` raised NoMethodError (a
23
+ module-level `Formats::Rar.create` never existed). It exists now
24
+ (RAR5 default, `version: 4` for RAR4); directory inputs work —
25
+ the stale "RAR5 does not support directories" restriction is gone
26
+ and trees archive under their own name like `.7z`, verified with
27
+ unrar.
28
+ - `omnizip archive metadata` opened every input as a ZIP: `.7z`
29
+ crashed with a ZIP parse error, and runs with no edit option
30
+ still printed "Metadata updated successfully". 7z now shows real
31
+ read-only metadata, unknown formats raise truthful errors, and
32
+ ZIPs default to show mode when nothing is being edited.
33
+ - `omnizip decompress archive.zip out/` — the behavior the
34
+ command's own help documents — failed with "Is a directory".
35
+ Archive inputs (zip/7z/tar/rar/cpio/iso) and directory outputs
36
+ now extract through the convenience layer, content-verified with
37
+ diff for zip, 7z, rar, and tar.
38
+ - Deflate64.metadata returned a raw Hash, crashing the CLI algorithm
39
+ table; it returns `Models::AlgorithmMetadata` like every other
40
+ algorithm.
41
+ - Guides referenced a nonexistent `:zstd` algorithm-registry key;
42
+ corrected to `:zstandard` everywhere (the registry key differs from
43
+ RPM's `:zstd` compression type).
44
+
45
+ ### Added
46
+ - `Omnizip::Buffer` 7z support: `create(:seven_zip)` /
47
+ `create_from_hash(hash, :seven_zip)`, `open`, `extract_to_memory`,
48
+ and `MemoryExtractor` all handle 7z through a temporary-file bridge
49
+ (the format has no in-memory stream implementation), including
50
+ explicit directory entries via a new
51
+ `SevenZip::Writer#add_directory_entry`. Format auto-detection fixed
52
+ to compare binary magic literals — UTF-8 literals never matched
53
+ binary data, so 7z buffers raised "Unknown archive format".
54
+ - README gains an In-Memory Archives section (examples run verbatim).
55
+
10
56
  ## [0.3.39] - 2026-08-28
11
57
 
12
58
  ### Fixed
@@ -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
@@ -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,45 @@ 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
245
261
  end
246
262
 
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
263
+ source = File.join(cached_dir, entry_name)
264
+ FileUtils.cp(source, output_path) if File.exist?(source)
265
+ end
266
+
267
+ # 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.
270
+ def extract_cache
271
+ @extract_cache ||= {}
272
+ end
273
+
274
+ # Drop the extraction cache and remove its directories
275
+ def clear_extract_cache!
276
+ extract_cache.each_value { |dir| FileUtils.rm_rf(dir) }
277
+ extract_cache.clear
251
278
  end
252
279
 
253
280
  # 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)"
@@ -127,6 +127,9 @@ module Omnizip
127
127
  class << self
128
128
  # Create a RAR archive, yielding the writer for entries.
129
129
  # RAR5 is the default; pass version: 4 for the RAR4 writer.
130
+ # RAR5-only options (solid, multi-volume, password, recovery)
131
+ # are ignored for RAR4 — its writer warns about the
132
+ # unimplemented ones and raises for passwords.
130
133
  #
131
134
  # @param path [String] Output archive path
132
135
  # @param options [Hash] Writer options
@@ -137,7 +140,7 @@ module Omnizip
137
140
  writer = if version == 4
138
141
  Writer.new(path, options)
139
142
  else
140
- Rar5::Writer.new(path, options)
143
+ Rar5::Writer.new(path, rar5_options(options))
141
144
  end
142
145
  yield writer if block_given?
143
146
  writer.write
@@ -173,6 +176,16 @@ module Omnizip
173
176
  def repair(archive_path, output_path)
174
177
  ArchiveRepairer.new.repair(archive_path, output_path)
175
178
  end
179
+
180
+ private
181
+
182
+ # Writer keywords the RAR5 writer understands
183
+ def rar5_options(options)
184
+ options.slice(:compression, :level, :solid, :multi_volume,
185
+ :volume_size, :volume_naming, :password,
186
+ :kdf_iterations, :recovery, :recovery_percent,
187
+ :include_mtime, :include_crc32, :dict_size)
188
+ end
176
189
  end
177
190
  end
178
191
  end