omnizip 0.3.37 → 0.3.39

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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -9
  3. data/README.adoc +108 -140
  4. data/config/formats/rar3_spec.yml +1 -0
  5. data/docs/guides/archive-formats/gzip-format.adoc +2 -4
  6. data/docs/guides/archive-formats/ole-format.adoc +54 -219
  7. data/docs/guides/archive-formats/rar5.adoc +15 -15
  8. data/docs/guides/archive-formats/rpm-format.adoc +48 -189
  9. data/docs/reference/api/overview.adoc +16 -17
  10. data/docs/troubleshooting/index.adoc +1 -4
  11. data/lib/omnizip/archive_handler.rb +2 -0
  12. data/lib/omnizip/archive_handlers/cpio_handler.rb +47 -0
  13. data/lib/omnizip/archive_handlers/iso_handler.rb +54 -0
  14. data/lib/omnizip/archive_handlers.rb +2 -0
  15. data/lib/omnizip/cli.rb +25 -0
  16. data/lib/omnizip/convenience.rb +6 -4
  17. data/lib/omnizip/formats/iso/directory_builder.rb +24 -0
  18. data/lib/omnizip/formats/iso/reader.rb +2 -2
  19. data/lib/omnizip/formats/iso/writer.rb +20 -4
  20. data/lib/omnizip/formats/iso.rb +3 -1
  21. data/lib/omnizip/formats/rar/block_parser.rb +18 -8
  22. data/lib/omnizip/formats/rar/constants.rb +4 -0
  23. data/lib/omnizip/formats/rar/decompressor.rb +12 -8
  24. data/lib/omnizip/formats/rar/header.rb +7 -15
  25. data/lib/omnizip/formats/rar/rar5/compression/lzss.rb +5 -3
  26. data/lib/omnizip/formats/rar/rar5/header.rb +50 -33
  27. data/lib/omnizip/formats/rar/rar5/multi_volume/volume_writer.rb +17 -34
  28. data/lib/omnizip/formats/rar/rar5/vint.rb +25 -37
  29. data/lib/omnizip/formats/rar/rar5/writer.rb +50 -14
  30. data/lib/omnizip/formats/rar/reader.rb +18 -14
  31. data/lib/omnizip/formats/rar/writer.rb +121 -136
  32. data/lib/omnizip/formats/rar.rb +5 -11
  33. data/lib/omnizip/formats/rar3/reader.rb +11 -50
  34. data/lib/omnizip/formats/rar3/writer.rb +13 -10
  35. data/lib/omnizip/formats/rar3.rb +15 -0
  36. data/lib/omnizip/formats/rar5/reader.rb +8 -10
  37. data/lib/omnizip/formats/rar5.rb +15 -0
  38. data/lib/omnizip/formats/seven_zip/parser.rb +22 -7
  39. data/lib/omnizip/formats/seven_zip/writer.rb +78 -19
  40. data/lib/omnizip/formats.rb +2 -0
  41. data/lib/omnizip/version.rb +1 -1
  42. data/readme-docs/api-usage.adoc +5 -4
  43. data/readme-docs/architecture.adoc +5 -4
  44. metadata +5 -1
@@ -89,9 +89,18 @@ header_data: "")
89
89
 
90
90
  # Main archive header
91
91
  class MainHeader < Header
92
- def initialize(flags: 0)
93
- # Main header has no data
94
- super(HEADER_TYPE_MAIN, flags: flags)
92
+ # Archive flags: 0x0001 volume, 0x0002 volume number
93
+ # present, 0x0004 solid, 0x0008 recovery, 0x0010 locked
94
+ ARCHIVE_FLAG_VOLUME = 0x0001
95
+ ARCHIVE_FLAG_VOLUME_NUMBER = 0x0002
96
+
97
+ def initialize(archive_flags: 0, volume_number: nil)
98
+ data = VINT.encode(archive_flags)
99
+ if archive_flags.anybits?(ARCHIVE_FLAG_VOLUME_NUMBER)
100
+ data.concat(VINT.encode(volume_number.to_i))
101
+ end
102
+
103
+ super(HEADER_TYPE_MAIN, header_data: data.pack("C*"))
95
104
  end
96
105
  end
97
106
 
@@ -102,69 +111,73 @@ header_data: "")
102
111
  FILE_HAS_MTIME = 0x0002
103
112
  FILE_HAS_CRC32 = 0x0004
104
113
 
114
+ # Compression information field layout (RAR 5.0 spec):
115
+ # bits 0-5 algorithm version (0), bit 7 solid,
116
+ # bits 8-10 method (0=store, 1-5=LZMA), bits 11-15
117
+ # dictionary size (128 KB * 2^N).
118
+ DICT_BASE = 128 * 1024
119
+
105
120
  def initialize(filename:, file_size:, compressed_size:,
106
- compression_method: 0, flags: 0, mtime: nil, crc32: nil, extra_area: nil)
121
+ compression_method: 0, dict_size: 0,
122
+ flags: 0, mtime: nil, crc32: nil, extra_area: nil)
107
123
  # Build file flags based on what's provided
108
124
  file_flags = 0
109
125
  file_flags |= FILE_HAS_MTIME if mtime
110
126
  file_flags |= FILE_HAS_CRC32 if crc32
111
127
 
112
128
  # Build header data with file information
113
- data = build_file_data(filename, file_size, compressed_size,
114
- file_flags, compression_method, mtime, crc32)
129
+ data = build_file_data(filename, file_size,
130
+ compression_method, dict_size,
131
+ file_flags, mtime, crc32)
115
132
  super(HEADER_TYPE_FILE, flags: flags, data_area_size: compressed_size, header_data: data, extra_area: extra_area)
116
133
  end
117
134
 
118
135
  private
119
136
 
120
- def build_file_data(filename, file_size, _compressed_size,
121
- file_flags, compression_method, mtime, crc32)
137
+ def build_file_data(filename, file_size, compression_method,
138
+ dict_size, file_flags, mtime, crc32)
122
139
  data = []
123
140
 
124
141
  # File flags (VINT)
125
142
  data.concat(VINT.encode(file_flags))
126
143
 
127
- # Unp size (uncompressed size, VINT)
144
+ # Unpacked size (VINT)
128
145
  data.concat(VINT.encode(file_size))
129
146
 
130
- # Attributes (VINT) - ALWAYS present in RAR5
131
- # Use 0x2483 from official RAR (standard regular file with correct permissions)
132
- data.concat(VINT.encode(0x2483))
133
-
134
- # Mystery VINT with value 0x02 - observed in official RAR
135
- # This appears after attributes in official archives - ALWAYS present
136
- data.concat(VINT.encode(0x02))
147
+ # Attributes (VINT) - ALWAYS present in RAR5.
148
+ # Unix host: standard regular-file mode 0o100644.
149
+ data.concat(VINT.encode(0o100644))
137
150
 
138
151
  # mtime (optional) - only if FILE_HAS_MTIME flag is set
139
- # RAR5 stores mtime as Unix timestamp (seconds since epoch) in DOS format
152
+ # RAR5 stores mtime as Unix timestamp (seconds since epoch)
140
153
  # Format: 4 bytes little-endian (NOT a VINT)
141
154
  if file_flags.anybits?(FILE_HAS_MTIME) && mtime
142
- # Convert Time to Unix timestamp (seconds since 1970-01-01 00:00:00 UTC)
143
- unix_time = mtime.to_i
144
- # Pack as 32-bit unsigned little-endian
145
- data.concat([unix_time].pack("V").bytes)
155
+ data.concat([mtime.to_i].pack("V").bytes)
146
156
  end
147
157
 
148
158
  # Data CRC32 (optional) - only if FILE_HAS_CRC32 flag is set
149
159
  # Format: 4 bytes little-endian (NOT a VINT)
150
160
  if file_flags.anybits?(FILE_HAS_CRC32) && crc32
151
- # Pack as 32-bit unsigned little-endian
152
161
  data.concat([crc32].pack("V").bytes)
153
162
  end
154
163
 
155
- # Compression info (VINT)
156
- # Bits 0-5: method (0=STORE, 1-5=LZMA with different levels)
157
- # Bits 6+: version
158
- data.concat(VINT.encode(compression_method))
164
+ # Compression information (VINT): version 0 in bits 0-5,
165
+ # method in bits 8-10, dictionary size in bits 11-15
166
+ # (128 KB * 2^N).
167
+ dict_bits = if dict_size.positive?
168
+ Math.log2(dict_size / DICT_BASE).round.clamp(0, 15)
169
+ else
170
+ 0
171
+ end
172
+ compression_info = (compression_method << 8) | (dict_bits << 11)
173
+ data.concat(VINT.encode(compression_info))
159
174
 
160
175
  # Host OS (VINT) - 1 = Unix
161
- data.concat(VINT.encode(1)) # Unix
176
+ data.concat(VINT.encode(1))
162
177
 
163
- # Name length (VINT)
178
+ # Name length (VINT) and name
164
179
  name_bytes = filename.encode("UTF-8").bytes
165
180
  data.concat(VINT.encode(name_bytes.size))
166
-
167
- # Name
168
181
  data.concat(name_bytes)
169
182
 
170
183
  data.pack("C*")
@@ -173,9 +186,13 @@ file_flags, compression_method, mtime, crc32)
173
186
 
174
187
  # End of archive header
175
188
  class EndHeader < Header
176
- def initialize
177
- # End header is minimal
178
- super(HEADER_TYPE_END, flags: 0)
189
+ # End of archive flags: 0x0001 volume and not the last
190
+ # volume in the set
191
+ END_FLAG_VOLUME_NEXT = 0x0001
192
+
193
+ def initialize(end_flags: 0)
194
+ super(HEADER_TYPE_END,
195
+ header_data: VINT.encode(end_flags).pack("C*"))
179
196
  end
180
197
  end
181
198
  end
@@ -18,11 +18,10 @@ module Omnizip
18
18
  # writer.write_end_header
19
19
  # writer.close
20
20
  class VolumeWriter
21
- # Main header flags for volume archives
22
- # NOTE: Bits 0-1 are reserved for common flags (EXTRA_AREA, DATA_AREA)
23
- # Use bits 2+ for format-specific flags
24
- VOLUME_ARCHIVE_FLAG = 0x0004 # Bit 2: This is a volume archive
25
- VOLUME_NUMBER_FLAG = 0x0008 # Bit 3: Volume number present in extra area
21
+ # Main ARCHIVE flags (spec): 0x0001 volume,
22
+ # 0x0002 volume number field present
23
+ VOLUME_ARCHIVE_FLAG = MainHeader::ARCHIVE_FLAG_VOLUME
24
+ VOLUME_NUMBER_FLAG = MainHeader::ARCHIVE_FLAG_VOLUME_NUMBER
26
25
 
27
26
  # End header flags
28
27
  VOLUME_END_FLAG = 0x0001 # Not the last volume (more volumes follow)
@@ -95,26 +94,18 @@ module Omnizip
95
94
  def write_main_header
96
95
  raise "Volume not open" unless @io
97
96
 
98
- # Set volume-specific flags
99
- flags = VOLUME_ARCHIVE_FLAG
100
-
101
- # Add volume number in extra area for volumes 2+
102
- nil
97
+ # Archive flags: every volume declares 0x0001; volumes
98
+ # after the first also carry the volume number field
99
+ # (spec: number is N-1, absent on the first volume).
100
+ archive_flags = VOLUME_ARCHIVE_FLAG
101
+ volume_number = nil
103
102
  if @volume_number > 1
104
- flags |= VOLUME_NUMBER_FLAG
105
- # Volume number as VINT in extra area
106
- VINT.encode(@volume_number).pack("C*")
103
+ archive_flags |= VOLUME_NUMBER_FLAG
104
+ volume_number = @volume_number - 1
107
105
  end
108
106
 
109
- header = MainHeader.new(flags: flags)
110
-
111
- # Extra areas are not written: MainHeader does not
112
- # model them and the omnizip-rs reference implements
113
- # RAR5 reading only, so there is no writer-side spec
114
- # to port. Volumes work without extras (the field is
115
- # optional).
116
-
117
- @io.write(header.encode)
107
+ @io.write(MainHeader.new(archive_flags: archive_flags,
108
+ volume_number: volume_number).encode)
118
109
  end
119
110
 
120
111
  # Write file data (header + compressed data)
@@ -135,18 +126,10 @@ module Omnizip
135
126
  def write_end_header
136
127
  raise "Volume not open" unless @io
137
128
 
138
- # Set END_OF_ARCHIVE flags
139
- flags = 0
140
- flags | VOLUME_END_FLAG unless @is_last
141
-
142
- # EndHeader always encodes the plain END_OF_ARCHIVE
143
- # marker: the RAR5 spec makes the volume flag optional,
144
- # readers position the marker inside the volume
145
- # sequence, and the omnizip-rs reference (read-side)
146
- # carries no writer-side volume-flag encoding to port.
147
- header = EndHeader.new
148
-
149
- @io.write(header.encode)
129
+ # End-of-archive flags: 0x0001 marks a volume with more
130
+ # volumes following; the last volume ends with 0.
131
+ end_flags = @is_last ? 0 : VOLUME_END_FLAG
132
+ @io.write(EndHeader.new(end_flags: end_flags).encode)
150
133
  end
151
134
 
152
135
  # Generate volume filename from base name
@@ -6,57 +6,45 @@ module Omnizip
6
6
  module Rar5
7
7
  # Variable-length integer encoding/decoding for RAR5 format
8
8
  module VINT
9
- # Encode integer as VINT bytes
10
- # @param value [Integer] Value to encode (0 to 2^62)
9
+ # Encode integer as VINT bytes per the RAR 5.0 spec: one or
10
+ # more bytes, each carrying 7 data bits starting with the
11
+ # least significant group; the high bit of every byte but
12
+ # the last is the continuation flag.
13
+ #
14
+ # @param value [Integer] Value to encode (0 to 2^63)
11
15
  # @return [Array<Integer>] VINT bytes
12
16
  def self.encode(value)
13
- return [value] if value < 0x80
17
+ raise ArgumentError, "VINT cannot encode negative values" if value.negative?
14
18
 
15
19
  bytes = []
16
- # Determine byte count needed
17
- byte_count = 1
18
- test_value = value
19
- while test_value >= (1 << (7 * byte_count))
20
- byte_count += 1
21
- end
22
-
23
- # First byte: continuation bits + high bits
24
- first_byte = (0xFF << (9 - byte_count)) & 0xFF
25
- first_byte |= (value >> (8 * (byte_count - 1))) & 0x7F
26
- bytes << first_byte
27
-
28
- # Remaining bytes
29
- (byte_count - 1).downto(1) do |i|
30
- bytes << ((value >> (8 * (i - 1))) & 0xFF)
20
+ loop do
21
+ byte = value & 0x7F
22
+ value >>= 7
23
+ byte |= 0x80 if value.positive?
24
+ bytes << byte
25
+ break if value.zero?
31
26
  end
32
27
 
33
28
  bytes
34
29
  end
35
30
 
36
- # Decode VINT from IO stream
31
+ # Decode VINT from IO stream (spec encoding, mirroring
32
+ # BlockParser#read_vint)
33
+ #
37
34
  # @param io [IO] Input stream
38
35
  # @return [Integer] Decoded value
39
36
  def self.decode(io)
40
- first_byte = io.readbyte
41
- return first_byte if first_byte < 0x80
42
-
43
- # Count continuation bits
44
- byte_count = 0
45
- mask = 0x80
46
- while first_byte.anybits?(mask)
47
- byte_count += 1
48
- mask >>= 1
49
- end
50
-
51
- # Extract value from first byte
52
- value = first_byte & (0xFF >> (byte_count + 1))
53
-
54
- # Read remaining bytes
55
- byte_count.times do
56
- value = (value << 8) | io.readbyte
37
+ result = 0
38
+ shift = 0
39
+ loop do
40
+ byte = io.readbyte
41
+ result |= (byte & 0x7F) << shift
42
+ break if byte.nobits?(0x80)
43
+
44
+ shift += 7
57
45
  end
58
46
 
59
- value
47
+ result
60
48
  end
61
49
  end
62
50
  end
@@ -177,7 +177,10 @@ module Omnizip
177
177
  write_signature(f)
178
178
  write_main_header(f)
179
179
 
180
- if @options[:solid]
180
+ # Solid mode needs a RAR-compatible LZSS encoder; until
181
+ # one lands, fall back to independent STORE entries so
182
+ # the archive is actually extractable by unrar.
183
+ if @options[:solid] && Compression::Lzss.available?
181
184
  write_solid_block(f, @files)
182
185
  else
183
186
  @files.each do |file|
@@ -251,6 +254,7 @@ module Omnizip
251
254
  file_size: file_info[:size],
252
255
  compressed_size: (idx.zero? ? result[:compressed_size] : 0), # Only first file has compressed size
253
256
  compression_method: compression_method,
257
+ dict_size: dictionary_size_for(compression_method),
254
258
  mtime: @options[:include_mtime] ? file[:mtime] : nil,
255
259
  crc32: nil, # No CRC32 for solid/LZMA
256
260
  )
@@ -307,12 +311,13 @@ module Omnizip
307
311
  if @encryption_manager
308
312
  encryption_result = @encryption_manager.encrypt_file_data(compressed_data)
309
313
  final_data = encryption_result[:encrypted_data]
310
- # The generated EncryptionHeader (salt/IV) is not
311
- # persisted into the archive here, so archives this
312
- # path writes cannot be decrypted afterwards; the RAR5
313
- # CRYPT extra record has no write-side reference to
314
- # port (omnizip-rar implements reading only).
315
- # Encryption remains read-verified only.
314
+ # Persist the salt/IV as the RAR5 file encryption extra
315
+ # record (spec type 0x01) — without it the archive
316
+ # cannot be decrypted by anything.
317
+ enc_record = build_encryption_extra_record(
318
+ encryption_result[:header],
319
+ )
320
+ extra_area = extra_area ? extra_area + enc_record : enc_record
316
321
  else
317
322
  final_data = compressed_data
318
323
  end
@@ -332,6 +337,7 @@ module Omnizip
332
337
  file_size: data.bytesize,
333
338
  compressed_size: final_data.bytesize,
334
339
  compression_method: compression_method,
340
+ dict_size: dictionary_size_for(compression_method),
335
341
  mtime: @options[:include_mtime] ? file[:mtime] : nil,
336
342
  crc32: file_crc32,
337
343
  extra_area: extra_area,
@@ -356,6 +362,22 @@ module Omnizip
356
362
  #
357
363
  # @param data [String] File data
358
364
  # @return [Integer] Compression method ID
365
+ # Dictionary size matching the compression modules' level
366
+ # table (128 KB << N), for the file header's dictionary
367
+ # bits. STORE needs no dictionary.
368
+ def dictionary_size_for(method)
369
+ return 0 if method == Compression::Store::METHOD
370
+
371
+ 1 << case method
372
+ when 1 then 18 # 256 KB
373
+ when 2 then 20 # 1 MB
374
+ when 3 then 22 # 4 MB
375
+ when 4 then 23 # 8 MB
376
+ when 5 then 24 # 16 MB
377
+ else 22
378
+ end
379
+ end
380
+
359
381
  def select_compression_method(data)
360
382
  case @options[:compression]
361
383
  when :store
@@ -367,7 +389,9 @@ module Omnizip
367
389
  if Compression::Lzss.available?
368
390
  Compression::Lzss.method_id(level)
369
391
  else
370
- # LZSS not implemented, fall back to STORE
392
+ warn "omnizip: RAR5 #{@options[:compression] == :lzma ? 'lzma' : 'lzss'} " \
393
+ "compression is not interoperable yet; storing data " \
394
+ "uncompressed instead"
371
395
  Compression::Store::METHOD
372
396
  end
373
397
  when :auto
@@ -418,15 +442,27 @@ module Omnizip
418
442
  def build_compression_extra_area(properties)
419
443
  return nil if properties.nil?
420
444
 
421
- extra_data = []
445
+ # Extra record format per the RAR 5.0 spec: Size(vint),
446
+ # Type(vint), Data. Type 0x03 carries the compression
447
+ # parameters.
448
+ record = VINT.encode(0x03) + properties.bytes
449
+ VINT.encode(record.length) + record
450
+ end
422
451
 
423
- # Extra record type: 0x03 = Compression parameters
424
- extra_data.concat(VINT.encode(0x03))
452
+ # File encryption extra record (spec type 0x01): AES-256
453
+ # parameters the reader needs to derive the key and IV.
454
+ def build_encryption_extra_record(header)
455
+ require "base64"
425
456
 
426
- # Compression properties (method-specific)
427
- extra_data.concat(properties.bytes)
457
+ kdf_count = Math.log2(header.kdf_iterations).round
458
+ record = [].concat(VINT.encode(0x01)) # record type
459
+ .concat(VINT.encode(header.version)) # 0 = AES-256
460
+ .concat(VINT.encode(0)) # flags: no check data
461
+ record << kdf_count # 1 byte, log2
462
+ record.concat(Base64.strict_decode64(header.salt).bytes)
463
+ record.concat(Base64.strict_decode64(header.iv).bytes)
428
464
 
429
- extra_data.pack("C*")
465
+ (VINT.encode(record.length) + record).pack("C*")
430
466
  end
431
467
 
432
468
  # Generate PAR2 recovery files for archives
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "stringio"
5
+ require "zlib"
5
6
 
6
7
  module Omnizip
7
8
  module Formats
@@ -246,11 +247,7 @@ module Omnizip
246
247
  # Native decompression only for RAR4 for now
247
248
  return false unless @header.version == 4
248
249
 
249
- # Check if we have the compression method
250
- return false if entry.nil? || entry.method.nil?
251
-
252
- # All RAR4 methods are supported by our Dispatcher
253
- true
250
+ !entry.nil? && !entry.method.nil?
254
251
  end
255
252
 
256
253
  # Extract entry using native decompression
@@ -258,17 +255,24 @@ module Omnizip
258
255
  # @param entry [Models::RarEntry] File entry
259
256
  # @param output_path [String] Destination path
260
257
  def extract_entry_native(entry, output_path)
261
- # Read compressed data from archive
262
258
  compressed_data = read_compressed_data(entry)
263
-
264
- # Decompress using Dispatcher
265
- File.open(output_path, "wb") do |output|
266
- # For now, assume METHOD_STORE (0x30)
267
- # Real implementation would get method from entry
268
- method = entry.method || 0x30
269
-
270
- Compression::Dispatcher.decompress(method, compressed_data, output)
259
+ method = entry.method || 0x30
260
+
261
+ output = StringIO.new(+"")
262
+ Compression::Dispatcher.decompress(method, compressed_data, output)
263
+
264
+ # The native LZ/PPMd decoders understand Omnizip's own
265
+ # encoder output, not official RAR streams — without this
266
+ # check, real WinRAR archives would silently extract as
267
+ # garbage. The header CRC is authoritative: on mismatch,
268
+ # fall back to unrar.
269
+ data = output.string
270
+ if entry.crc && Zlib.crc32(data) != entry.crc
271
+ raise Compression::DecompressionError,
272
+ "CRC mismatch for #{entry.name}"
271
273
  end
274
+
275
+ File.binwrite(output_path, data)
272
276
  rescue StandardError => e
273
277
  # Fall back to external decompressor on error
274
278
  warn "Native decompression failed for #{entry.name}: #{e.message}"