omnizip 0.3.45 → 0.3.46

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: c60c442839bff64ee405d6eb9c5f3ead4a97fa53df7ffd381ca0508dbb00b5e8
4
- data.tar.gz: 22b8abfb94d6403e16ffea3c36115c6c7eb15eed654ef15f8a824453aa2eef20
3
+ metadata.gz: eb792b03b604528825ce30f9d2ef6c7b6ac077535f5a4bf027498a44138ab692
4
+ data.tar.gz: 777e4ed705e6aa51b53ca8bccf2b394ae24f6f9b4404325db398a24e13b5a811
5
5
  SHA512:
6
- metadata.gz: f9a8b45c51e6c36d30273d7ac897eb4854793ed1548d4055dcf90335b836c9d3d1bb4db2faa374cf407345657fd5b459f16bf40021427387ae103ce54f1ed644
7
- data.tar.gz: 412293ad5b9d9d3c361fe03f8ed88707f3cd5dc4fc4bc1221198e877a71eb98322ca4cc5664ac329dee117192738d4d1af6b8e79ca8cc080639cf69e83816d89
6
+ metadata.gz: d512bfb76c29f2ca7a59bc6e46f558c2bc659b38dc1878b50ea879f91f93beff7c52a27a033b002f0a3e27ae2968484a8cb27b68aaa462a86aad9906f360a854
7
+ data.tar.gz: 25fdb5dacbe8dd8efb6835d916c8b455bcf07197845126dc8b832bbbf80418b80e904c5338a21e0300df6db83fd872fb6faa132f40e9b947ba19496a38eb44d2
data/CHANGELOG.md CHANGED
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.45] - 2026-08-31
11
+
12
+ ### Changed
13
+ - The ZIP ArchiveHandler — the seam the facade, the convenience
14
+ layer, and the CLI all route through — now sits on the native
15
+ `Formats::Zip` tree instead of the rubyzip-compat
16
+ `Omnizip::Zip::File` layer (which remains for in-place entry
17
+ editing and Metadata, by design). The native reader gains
18
+ `#read_entry` (decompress + CRC-verify, shared with extraction)
19
+ and the central-directory entry model gains the `Omnizip::Entry`
20
+ contract plus DOS-time decoding.
21
+ - CLI `archive list` and `archive extract` route through the
22
+ handler registry instead of hand-rolled format dispatch:
23
+ `archive list foo.tar` no longer falls into the 7z reader, zip
24
+ listing no longer opens the compat layer, and every handler's
25
+ `extract_to` returns the extracted file paths (7z/rar/tar
26
+ previously returned the reader or nil). Verbose listings show
27
+ real per-entry compressed sizes for the formats that track them.
28
+
29
+ ### Fixed
30
+ - Parity hardening: a nil PAR2 index crashed five frames deep as a
31
+ `TypeError`; the repairer boundary now raises a named
32
+ `ArgumentError` and the spec fixture can no longer select a nil
33
+ index (the order-coupled CI flake from 0.3.44).
34
+
10
35
  ## [0.3.44] - 2026-08-31
11
36
 
12
37
  ### Fixed
@@ -1,19 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "tmpdir"
4
+ require "tempfile"
4
5
 
5
6
  module Omnizip
6
7
  module ArchiveHandlers
7
- # Read-only adapter for the RAR format. RAR compression is
8
- # patented software (see link:README[RAR write support]); this
9
- # handler exposes extraction/listing/reading only, so the
10
- # convenience API and the Archive facade can operate on existing
11
- # archives truthfully instead of raising on every operation.
8
+ # Adapter for the RAR format: extraction/listing/reading, plus
9
+ # creation through Formats::Rar (RAR5 STORE is unrar-verified;
10
+ # compressed methods fall back to STORE with a warning — see the
11
+ # README interop table).
12
12
  class RarHandler
13
- def create(_path, **_options)
14
- raise Omnizip::UnsupportedFormatError,
15
- "RAR archives cannot be created (patented format); " \
16
- "Omnizip reads RAR archives only"
13
+ def create(path, **options, &block)
14
+ proxy = nil
15
+ result = Omnizip::Formats::Rar.create(path, options) do |writer|
16
+ proxy = WriterProxy.new(writer)
17
+ block&.call(proxy)
18
+ end
19
+ proxy&.cleanup
20
+ result
17
21
  end
18
22
 
19
23
  def extract_to(path, output_dir, password: nil, **_)
@@ -47,6 +51,44 @@ module Omnizip
47
51
  File.binread(dest)
48
52
  end
49
53
  end
54
+
55
+ # Translates the generic +add(name, source_path)+ /
56
+ # +add_data(name, data)+ / +add_directory(name)+ interface onto
57
+ # the RAR writers. The writers take file paths, so in-memory
58
+ # content spills to a temporary file.
59
+ class WriterProxy
60
+ def initialize(writer)
61
+ @writer = writer
62
+ @spill = []
63
+ end
64
+
65
+ def add(name, source_path = nil)
66
+ if source_path
67
+ @writer.add_file(source_path, name)
68
+ else
69
+ @writer.add_directory_entry(name)
70
+ end
71
+ end
72
+
73
+ def add_data(name, data)
74
+ file = Tempfile.new(["omnizip_rar_entry", File.extname(name)])
75
+ file.binmode
76
+ file.write(data)
77
+ file.close
78
+ @spill << file
79
+ @writer.add_file(file.path, name)
80
+ end
81
+
82
+ def add_directory(name)
83
+ @writer.add_directory_entry(name)
84
+ end
85
+
86
+ # Remove the temporary files add_data spilled
87
+ def cleanup
88
+ @spill.each(&:unlink)
89
+ @spill.clear
90
+ end
91
+ end
50
92
  end
51
93
  end
52
94
  end
data/lib/omnizip/cli.rb CHANGED
@@ -355,6 +355,18 @@ module Omnizip
355
355
  rescue StandardError => e
356
356
  handle_error(e)
357
357
  end
358
+
359
+ desc "repair ARCHIVE OUTPUT", "Repair a damaged archive"
360
+ long_desc <<~DESC
361
+ Repair a damaged archive (currently RAR archives via recovery
362
+ records and .rev volumes). OUTPUT is the repaired archive path.
363
+ DESC
364
+ option :verbose, type: :boolean, default: false, aliases: "-v"
365
+ def repair(archive, output)
366
+ Omnizip::Commands::ArchiveRepairCommand.new(options).run(archive, output)
367
+ rescue StandardError => e
368
+ handle_error(e)
369
+ end
358
370
  end
359
371
 
360
372
  # Command-line interface for Omnizip.
@@ -106,8 +106,10 @@ header_data: "")
106
106
 
107
107
  # File header
108
108
  class FileHeader < Header
109
- # File header flags
110
- FILE_HAS_ATTRIBUTES = 0x0001
109
+ # File header flags. Per the RAR 5.0 spec, 0x0001 marks a
110
+ # directory entry (what this codebase once mislabeled as
111
+ # "has attributes" — attributes are an always-present field)
112
+ FILE_IS_DIRECTORY = 0x0001
111
113
  FILE_HAS_MTIME = 0x0002
112
114
  FILE_HAS_CRC32 = 0x0004
113
115
 
@@ -119,23 +121,27 @@ header_data: "")
119
121
 
120
122
  def initialize(filename:, file_size:, compressed_size:,
121
123
  compression_method: 0, dict_size: 0,
122
- flags: 0, mtime: nil, crc32: nil, extra_area: nil)
124
+ flags: 0, mtime: nil, crc32: nil,
125
+ directory: false, extra_area: nil)
123
126
  # Build file flags based on what's provided
124
127
  file_flags = 0
128
+ file_flags |= FILE_IS_DIRECTORY if directory
125
129
  file_flags |= FILE_HAS_MTIME if mtime
126
130
  file_flags |= FILE_HAS_CRC32 if crc32
127
131
 
128
132
  # Build header data with file information
129
133
  data = build_file_data(filename, file_size,
130
134
  compression_method, dict_size,
131
- file_flags, mtime, crc32)
135
+ file_flags, mtime, crc32,
136
+ directory: directory)
132
137
  super(HEADER_TYPE_FILE, flags: flags, data_area_size: compressed_size, header_data: data, extra_area: extra_area)
133
138
  end
134
139
 
135
140
  private
136
141
 
137
142
  def build_file_data(filename, file_size, compression_method,
138
- dict_size, file_flags, mtime, crc32)
143
+ dict_size, file_flags, mtime, crc32,
144
+ directory: false)
139
145
  data = []
140
146
 
141
147
  # File flags (VINT)
@@ -145,8 +151,9 @@ header_data: "")
145
151
  data.concat(VINT.encode(file_size))
146
152
 
147
153
  # Attributes (VINT) - ALWAYS present in RAR5.
148
- # Unix host: standard regular-file mode 0o100644.
149
- data.concat(VINT.encode(0o100644))
154
+ # Unix host: directory mode for directory entries,
155
+ # regular-file 0o100644 otherwise.
156
+ data.concat(VINT.encode(directory ? 0o040755 : 0o100644))
150
157
 
151
158
  # mtime (optional) - only if FILE_HAS_MTIME flag is set
152
159
  # RAR5 stores mtime as Unix timestamp (seconds since epoch)
@@ -121,6 +121,22 @@ module Omnizip
121
121
  }
122
122
  end
123
123
 
124
+ # Add a name-only directory entry (empty trees survive
125
+ # archive creation instead of vanishing)
126
+ #
127
+ # @param archive_path [String] Directory path inside the
128
+ # archive (trailing slash optional)
129
+ # @return [self]
130
+ def add_directory_entry(archive_path)
131
+ @files << {
132
+ input: nil,
133
+ archive: archive_path.chomp("/"),
134
+ mtime: Time.now,
135
+ directory: true,
136
+ }
137
+ self
138
+ end
139
+
124
140
  # Add directory recursively
125
141
  #
126
142
  # @param dir_path [String] Directory path
@@ -227,11 +243,18 @@ module Omnizip
227
243
  # @param files [Array<Hash>] Files to compress in solid mode
228
244
  # @return [void]
229
245
  def write_solid_block(io, files)
246
+ # Directory entries carry no data; write their headers
247
+ # separately so the solid stream indexes stay aligned
248
+ files.each do |file|
249
+ write_directory_file_entry(io, file) if file[:directory]
250
+ end
251
+ solid_files = files.reject { |f| f[:directory] }
252
+
230
253
  # Create solid manager
231
254
  manager = Solid::SolidManager.new(level: @options[:level])
232
255
 
233
256
  # Add all files to solid stream
234
- files.each do |file|
257
+ solid_files.each do |file|
235
258
  data = File.binread(file[:input])
236
259
  manager.add_file(file[:archive], data, mtime: file[:mtime],
237
260
  stat: file[:stat])
@@ -242,7 +265,7 @@ module Omnizip
242
265
 
243
266
  # Write each file header with references to the solid block
244
267
  # In RAR5, all files share the same compressed data
245
- files.each_with_index do |file, idx|
268
+ solid_files.each_with_index do |file, idx|
246
269
  file_info = result[:files][idx]
247
270
 
248
271
  # Calculate CRC32 if needed (note: only for STORE in non-solid)
@@ -286,6 +309,8 @@ module Omnizip
286
309
  # @param io [IO] Output stream
287
310
  # @param file [Hash] File information
288
311
  def write_file_entry(io, file)
312
+ return write_directory_file_entry(io, file) if file[:directory]
313
+
289
314
  # Read file data
290
315
  data = File.binread(file[:input])
291
316
 
@@ -350,6 +375,24 @@ module Omnizip
350
375
  io.write(final_data)
351
376
  end
352
377
 
378
+ # Write a directory entry: FileFlags directory bit, zero
379
+ # sizes, no data area
380
+ #
381
+ # @param io [IO] Output stream
382
+ # @param file [Hash] Entry with :archive name and :mtime
383
+ def write_directory_file_entry(io, file)
384
+ header = FileHeader.new(
385
+ filename: file[:archive],
386
+ file_size: 0,
387
+ compressed_size: 0,
388
+ compression_method: Compression::Store::METHOD,
389
+ dict_size: 0,
390
+ mtime: @options[:include_mtime] ? file[:mtime] : nil,
391
+ directory: true,
392
+ )
393
+ io.write(header.encode)
394
+ end
395
+
353
396
  # Write End header
354
397
  #
355
398
  # @param io [IO] Output stream
@@ -93,6 +93,21 @@ module Omnizip
93
93
  warn_unimplemented_options
94
94
  end
95
95
 
96
+ # Add a name-only directory entry (empty trees survive
97
+ # archive creation instead of vanishing)
98
+ #
99
+ # @param archive_path [String] Directory path inside the
100
+ # archive (trailing slash optional)
101
+ # @return [self]
102
+ def add_directory_entry(archive_path)
103
+ @files << {
104
+ source: nil,
105
+ archive_path: archive_path.chomp("/"),
106
+ directory: true,
107
+ }
108
+ self
109
+ end
110
+
96
111
  # Add file to archive
97
112
  #
98
113
  # @param file_path [String] Path to file
@@ -241,10 +256,17 @@ module Omnizip
241
256
  (directory ? METHOD_STORE : select_compression_method(file_data))
242
257
  compressed_data = compress_data(file_data, method)
243
258
 
244
- stat = File.stat(file_path)
245
- file_attr = directory ? 0o040755 : stat.mode
246
- file_time = dos_time(stat.mtime)
247
- data_crc = Zlib.crc32(file_data)
259
+ if directory && file_path.nil?
260
+ # Name-only directory entry: no source to stat
261
+ file_attr = 0o040755
262
+ file_time = dos_time(Time.now)
263
+ data_crc = 0
264
+ else
265
+ stat = File.stat(file_path)
266
+ file_attr = directory ? 0o040755 : stat.mode
267
+ file_time = dos_time(stat.mtime)
268
+ data_crc = Zlib.crc32(file_data)
269
+ end
248
270
 
249
271
  name_bytes = archive_path.encode("UTF-8").bytes
250
272
 
@@ -174,8 +174,8 @@ module Omnizip
174
174
  # @param archive_path [String] Path to corrupted RAR archive
175
175
  # @param output_path [String] Path for repaired archive
176
176
  # @return [ArchiveRepairer::RepairResult] Repair result
177
- def repair(archive_path, output_path)
178
- ArchiveRepairer.new.repair(archive_path, output_path)
177
+ def repair(archive_path, output_path, options = {})
178
+ ArchiveRepairer.new.repair(archive_path, output_path, options)
179
179
  end
180
180
 
181
181
  private
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Omnizip
4
- VERSION = "0.3.45"
4
+ VERSION = "0.3.46"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omnizip
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.45
4
+ version: 0.3.46
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.