omnizip 0.3.56 → 0.3.58

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: 3a34ac63f49299330b590a1f9010940af988984f875f7ea101f44c126d90f5d6
4
- data.tar.gz: 5a7940049434d24a4f61d073db07133b6645133dd79783462e864856e3302d24
3
+ metadata.gz: 04bb0d1c81ef7f4571014bb6679ad0d4750d4c078729daea82c88f643e246818
4
+ data.tar.gz: 7523e6a77a43db5eb02ec7c16ef4adf0751150129f329af82eb11297f93a68f6
5
5
  SHA512:
6
- metadata.gz: 8ad6baae78797134776df430810dc67b5fae438d702aa4613d02468fae496134d1e53d2d4c8c857ee51311fe1a72de463529ae0dc553bf9fc8a792be9a3a0718
7
- data.tar.gz: 385b3cec020df42f2532944a287467bd63cb155212278b0164c2e61e7db5c262e2858a306c4cccfaf949dbd0f61b5c2a013a72586166fbfca0a4323a5e46b65a
6
+ metadata.gz: da1c027f6e44246e6421760d3197501db38957aeb1541c0db6e3553ab73b5e03a221a362b0165d4196bf1185f0022b341529321cdce9c2a339c3cf2f31b4f733
7
+ data.tar.gz: 7732ed6d8268d006195f7648980e2f8619ea89d45767932afd587f508592f627697a9cc436f6f3e1a01b41a8a579034f654c1bc33cc3331fced28af5bd68b8ff
data/CHANGELOG.md CHANGED
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.57] - 2026-09-02
11
+
12
+ ### Fixed
13
+ - TAR archives no longer corrupt silently on long paths: the writer
14
+ now splits entry names over 99 bytes across the ustar prefix +
15
+ name fields at a "/" boundary, the reader joins them back for
16
+ consumers, and paths no ustar fields can represent raise
17
+ `FormatError` instead of truncating. Verified against system tar.
18
+ - `:auto` profile sampling walked directories in the OS's
19
+ iteration order, resolving different files (and compression
20
+ settings) per platform; the walk is sorted and deterministic now.
21
+ - The getting-started docs' 7z examples used a Reader API that
22
+ never shipped (`each_file`, `close`, `Reader.info`) — every
23
+ snippet raised NoMethodError verbatim. Rewritten against the real
24
+ surface and executed to verify.
25
+
26
+ ### Changed
27
+ - The last 28 bare `raise "string"` sites (RAR tree, cpio entries,
28
+ parity internals, zip compat streams, two commands) gained their
29
+ matching error classes — `lib/` now contains zero bare raises.
30
+ - The dead `--preserve-ntfs-streams` CLI flag is removed; nothing
31
+ read it and no code path implements NTFS stream preservation.
32
+
33
+ ## [0.3.56] - 2026-09-02
34
+
35
+ ### Fixed
36
+ - `FilterRegistry#entry_for` read its storage and lazy triggers
37
+ outside the mutex `register` writes under — a latent data race in
38
+ the 7z codec chain's filter resolution. Reads now hold the lock,
39
+ with the lazy trigger fired outside it (it re-enters through
40
+ register's own synchronize).
41
+ - The progress lifecycle is complete: `ProgressTracker` gains
42
+ `#start` and `#finish`, firing the reporter start/finish hooks
43
+ every reporter class overrides but nothing ever called —
44
+ "operation started/finished" output could never appear. `#finish`
45
+ also forces a final report and is idempotent.
46
+
10
47
  ## [0.3.55] - 2026-09-01
11
48
 
12
49
  ### Changed
@@ -120,8 +120,8 @@ module Omnizip
120
120
  # Open archive
121
121
  end
122
122
 
123
- def each_file
124
- # Yield archive entries
123
+ def entries
124
+ # Enumerate archive entries
125
125
  end
126
126
 
127
127
  def extract_all(output_dir)
@@ -103,11 +103,9 @@ omnizip archive list backup.7z
103
103
  require 'omnizip'
104
104
 
105
105
  reader = Omnizip::Formats::SevenZip::Reader.new('backup.7z')
106
- reader.open
107
- reader.each_file do |entry|
106
+ reader.entries.each do |entry|
108
107
  puts "#{entry.name}: #{entry.size} bytes"
109
108
  end
110
- reader.close
111
109
  ----
112
110
 
113
111
  === Using Different Compression Algorithms
@@ -180,12 +180,15 @@ writer.add_directory('/home/user/photos')
180
180
  writer.close
181
181
 
182
182
  # Get statistics
183
- info = Omnizip::Formats::SevenZip::Reader.info('backup.7z')
183
+ entries = Omnizip.list_archive('backup.7z', details: true)
184
+ files = entries.reject { |e| e[:directory] }
185
+ uncompressed = files.sum { |e| e[:size] }
186
+ compressed = File.size('backup.7z')
184
187
  puts "Archive created:"
185
- puts " Files: #{info.entry_count}"
186
- puts " Size: #{info.uncompressed_size} bytes"
187
- puts " Compressed: #{info.compressed_size} bytes"
188
- puts " Ratio: #{(info.compressed_size.to_f / info.uncompressed_size * 100).round(1)}%"
188
+ puts " Files: #{files.size}"
189
+ puts " Size: #{uncompressed} bytes"
190
+ puts " Compressed: #{compressed} bytes"
191
+ puts " Ratio: #{(compressed.to_f / uncompressed * 100).round(1)}%"
189
192
  ----
190
193
 
191
194
  === Verifying Your Archive
@@ -202,12 +205,10 @@ omnizip archive list backup.7z
202
205
  require 'omnizip'
203
206
 
204
207
  reader = Omnizip::Formats::SevenZip::Reader.new('backup.7z')
205
- reader.open
206
208
  puts "Archive contents:"
207
- reader.each_file do |entry|
209
+ reader.entries.each do |entry|
208
210
  puts " #{entry.name} (#{entry.size} bytes)"
209
211
  end
210
- reader.close
211
212
  ----
212
213
 
213
214
  === Next Steps
@@ -76,7 +76,7 @@ writer.write
76
76
 
77
77
  IMPORTANT: RAR5 CRC32 checksums only work with STORE compression. When LZMA is used, CRC32 is automatically disabled. Use BLAKE2sp (always present) for compressed file integrity.
78
78
 
79
- === RAR5 Multi-Volume Archives (v0.5.0)
79
+ === RAR5 Multi-Volume Archives
80
80
 
81
81
  Split large archives across multiple volumes:
82
82
 
@@ -110,36 +110,30 @@ volumes = writer.write
110
110
  # Minimum volume size: 64 KB
111
111
  ----
112
112
 
113
- === RAR5 Solid Compression (v0.5.0)
113
+ === RAR5 Solid Archives
114
114
 
115
- Achieve 10-30% better compression ratios for similar files:
115
+ `solid: true` requests a shared solid block, but the RAR-compatible
116
+ LZSS encoder is not implemented yet — the writer warns and writes
117
+ independent STORE entries instead, so the archive stays extractable
118
+ by official unrar:
116
119
 
117
120
  [source,ruby]
118
121
  ----
119
122
  require 'omnizip/formats/rar/rar5/writer'
120
123
 
121
- # Enable solid compression
122
124
  writer = Omnizip::Formats::Rar::Rar5::Writer.new('project.rar',
123
- compression: :lzma,
124
- level: 5,
125
- solid: true # Shared dictionary across files
125
+ solid: true # warns: falls back to independent STORE entries
126
126
  )
127
127
  writer.add_directory('source_code/')
128
128
  writer.write
129
+ ----
129
130
 
130
- # Best for:
131
- # - Source code repositories
132
- # - Log files
133
- # - Text documents
134
- # - Similar structured data
135
-
136
- # Trade-offs:
137
- # - Cannot extract individual files without decompressing entire block
138
- # - Corruption may affect multiple files
139
- # - Slightly longer extraction time for single files
131
+ Solid compression (shared dictionary across files, better ratios
132
+ for similar content) arrives when the RAR-compatible LZSS encoder
133
+ lands.
140
134
  ----
141
135
 
142
- === RAR5 AES-256 Encryption (v0.5.0)
136
+ === RAR5 AES-256 Encryption
143
137
 
144
138
  Password-protect archives with industry-standard encryption:
145
139
 
@@ -177,7 +171,7 @@ writer.write
177
171
  # - Password verification before decryption
178
172
  ----
179
173
 
180
- === RAR5 PAR2 Recovery Records (v0.5.0)
174
+ === RAR5 PAR2 Recovery Records
181
175
 
182
176
  Add error correction for archive recovery:
183
177
 
@@ -216,7 +210,7 @@ files = writer.write
216
210
  # - Works with multi-volume, solid, and encrypted archives
217
211
  ----
218
212
 
219
- === RAR5 Combined Features (v0.5.0)
213
+ === RAR5 Combined Features
220
214
 
221
215
  Use all features together for comprehensive protection:
222
216
 
@@ -262,13 +256,13 @@ puts " Total size: #{files.sum { |f| File.size(f) } / 1024 / 1024}MB"
262
256
 
263
257
  === RAR5 Best Practices
264
258
 
265
- **For Maximum Compression:**
259
+ **For Maximum Compatibility:**
266
260
  [source,ruby]
267
261
  ----
268
262
  writer = Omnizip::Formats::Rar::Rar5::Writer.new('archive.rar',
269
263
  compression: :lzma,
270
- level: 5, # Best compression
271
- solid: true, # 10-30% better ratios
264
+ level: 5, # Honored by the 7z-style writers; RAR5 entries
265
+ # are STORE until the LZSS encoder lands
272
266
  include_mtime: true
273
267
  )
274
268
  ----
data/lib/omnizip/cli.rb CHANGED
@@ -182,8 +182,6 @@ module Omnizip
182
182
  desc: "Password for encryption (7z: header encryption, RAR5: AES-256-CBC)"
183
183
  option :encrypt_headers, type: :boolean, default: false,
184
184
  desc: "Encrypt archive headers (7z only, hides filenames)"
185
- option :preserve_ntfs_streams, type: :boolean, default: false,
186
- desc: "Preserve NTFS alternate data streams (Windows only, 7z only)"
187
185
  option :rar_version, type: :numeric, default: 5,
188
186
  desc: "RAR version (4 or 5, default: 5 for pure Ruby)"
189
187
  option :rar_compression, type: :string, default: "store",
@@ -18,7 +18,7 @@ module Omnizip
18
18
  # @param input_path [String] Path to corrupted archive
19
19
  # @param output_path [String] Path for repaired archive
20
20
  def run(input_path, output_path)
21
- raise "Archive not found: #{input_path}" unless File.exist?(input_path)
21
+ raise Omnizip::IOError, "Archive not found: #{input_path}" unless File.exist?(input_path)
22
22
 
23
23
  # Detect format
24
24
  format = detect_format(input_path)
@@ -18,7 +18,7 @@ module Omnizip
18
18
  # @param archive_path [String] Path to archive
19
19
  def run(archive_path)
20
20
  unless File.exist?(archive_path)
21
- raise "Archive not found: #{archive_path}"
21
+ raise Omnizip::IOError, "Archive not found: #{archive_path}"
22
22
  end
23
23
 
24
24
  # Detect format
@@ -80,7 +80,10 @@ module Omnizip
80
80
  end
81
81
  end
82
82
 
83
- ARCHIVE_EXTENSIONS = %w[.zip .7z .tar .rar .cpio .iso .rpm .xar .msi].freeze
83
+ # Archive-vs-stream routing derives from the registry's
84
+ # extension routes so new formats route here automatically
85
+ ARCHIVE_EXTENSIONS = (Convenience::ARCHIVE_FORMAT_EXTENSIONS.keys +
86
+ Convenience::READ_ARCHIVE_FORMAT_EXTENSIONS.keys).freeze
84
87
 
85
88
  private
86
89
 
data/lib/omnizip/entry.rb CHANGED
@@ -7,7 +7,7 @@ module Omnizip
7
7
  # Each format-specific entry class (Zip::Entry, Tar::Entry,
8
8
  # SevenZip::Models::FileEntry, Iso::DirectoryRecord, Ole::Dirent,
9
9
  # Xar::Entry, Rpm::Entry, Msi::Entry, Cpio::Entry, Rar3::Entry,
10
- # Rar5::Entry, Xz::Entry, Metadata::EntryMetadata) includes this
10
+ # Rar5::Entry, Metadata::EntryMetadata) includes this
11
11
  # module so callers can rely on a single narrow interface instead
12
12
  # of duck-typing through `respond_to?(:name) / :path / :filename`
13
13
  # cascades.
@@ -232,7 +232,7 @@ module Omnizip
232
232
  when :odc
233
233
  parse_odc(io)
234
234
  else
235
- raise "Unknown CPIO format"
235
+ raise Omnizip::InvalidArchiveError, "Unknown CPIO format"
236
236
  end
237
237
  end
238
238
 
@@ -258,7 +258,7 @@ module Omnizip
258
258
  # @return [Entry] Parsed entry
259
259
  def self.parse_newc(io)
260
260
  header = io.read(NEWC_HEADER_SIZE)
261
- raise "Truncated CPIO header" unless header && header.bytesize == NEWC_HEADER_SIZE
261
+ raise Omnizip::InvalidArchiveError, "Truncated CPIO header" unless header && header.bytesize == NEWC_HEADER_SIZE
262
262
 
263
263
  # Parse ASCII hex fields
264
264
  magic = header[0, 6]
@@ -318,7 +318,7 @@ module Omnizip
318
318
  # @return [Entry] Parsed entry
319
319
  def self.parse_odc(io)
320
320
  header = io.read(76)
321
- raise "Truncated CPIO header" unless header && header.bytesize == 76
321
+ raise Omnizip::InvalidArchiveError, "Truncated CPIO header" unless header && header.bytesize == 76
322
322
 
323
323
  # Parse octal fields
324
324
  magic = header[0, 6]
@@ -232,7 +232,7 @@ module Omnizip
232
232
  archive.extract_to_file(entry_name, output_path,
233
233
  password: password)
234
234
  rescue StandardError => e
235
- raise "Gem entry extraction failed: #{e.message}"
235
+ raise Omnizip::DecompressionError, "Gem entry extraction failed: #{e.message}"
236
236
  end
237
237
 
238
238
  # Extract entry with command
@@ -255,7 +255,7 @@ module Omnizip
255
255
  cmd = build_extract_command(archive_path, cached_dir, password)
256
256
  unless system(*cmd)
257
257
  FileUtils.rm_rf(cached_dir)
258
- raise "Command entry extraction failed: #{entry_name}"
258
+ raise Omnizip::DecompressionError, "Command entry extraction failed: #{entry_name}"
259
259
  end
260
260
  extract_cache[cache_key] = cached_dir
261
261
  install_exit_sweeper
@@ -138,7 +138,7 @@ module Omnizip
138
138
  stdout, stderr, status = Open3.capture3(*cmd)
139
139
 
140
140
  unless status.success?
141
- raise "RAR creation failed: #{stderr}\n#{stdout}"
141
+ raise Omnizip::CompressionError, "RAR creation failed: #{stderr}\n#{stdout}"
142
142
  end
143
143
 
144
144
  # Test archive if requested
@@ -257,7 +257,7 @@ module Omnizip
257
257
 
258
258
  return if status.success?
259
259
 
260
- raise "Archive test failed: #{stderr}\n#{stdout}"
260
+ raise Omnizip::CompressionError, "Archive test failed: #{stderr}\n#{stdout}"
261
261
  end
262
262
 
263
263
  # Get RAR executable path
@@ -52,10 +52,10 @@ module Omnizip
52
52
  @version = 5
53
53
  parse_rar5_header(io)
54
54
  else
55
- raise "Invalid RAR signature: #{(sig_bytes + (extra_byte&.bytes || [])).inspect}"
55
+ raise Omnizip::InvalidArchiveError, "Invalid RAR signature: #{(sig_bytes + (extra_byte&.bytes || [])).inspect}"
56
56
  end
57
57
  else
58
- raise "Invalid RAR signature: #{sig_bytes.inspect}"
58
+ raise Omnizip::InvalidArchiveError, "Invalid RAR signature: #{sig_bytes.inspect}"
59
59
  end
60
60
  end
61
61
 
@@ -103,7 +103,7 @@ module Omnizip
103
103
  end
104
104
 
105
105
  unless head_type == BLOCK_ARCHIVE
106
- raise "Expected archive block, got 0x#{head_type.to_s(16)}"
106
+ raise Omnizip::InvalidArchiveError, "Expected archive block, got 0x#{head_type.to_s(16)}"
107
107
  end
108
108
 
109
109
  @flags = head_flags
@@ -140,7 +140,7 @@ module Omnizip
140
140
  end
141
141
 
142
142
  unless header_type == RAR5_HEADER_MAIN
143
- raise "Expected main header, got #{header_type}"
143
+ raise Omnizip::InvalidArchiveError, "Expected main header, got #{header_type}"
144
144
  end
145
145
 
146
146
  @flags = header_flags
@@ -77,8 +77,8 @@ module Omnizip
77
77
  # @raise [RuntimeError] if no volume is active
78
78
  # @raise [RuntimeError] if data doesn't fit
79
79
  def write_to_current_volume(data)
80
- raise "No active volume" if @current_volume_number.zero?
81
- raise "Data doesn't fit in current volume" unless can_fit_in_current_volume?(data.bytesize)
80
+ raise Omnizip::CompressionError, "No active volume" if @current_volume_number.zero?
81
+ raise Omnizip::CompressionError, "Data doesn't fit in current volume" unless can_fit_in_current_volume?(data.bytesize)
82
82
 
83
83
  @current_volume_data << data
84
84
  @current_volume_bytes += data.bytesize
@@ -80,7 +80,7 @@ module Omnizip
80
80
  #
81
81
  # @return [void]
82
82
  def write_signature
83
- raise "Volume not open" unless @io
83
+ raise Omnizip::IOError, "Volume not open" unless @io
84
84
 
85
85
  # RAR5 signature: "Rar!\x1A\x07\x01\x00"
86
86
  signature = [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x01,
@@ -92,7 +92,7 @@ module Omnizip
92
92
  #
93
93
  # @return [void]
94
94
  def write_main_header
95
- raise "Volume not open" unless @io
95
+ raise Omnizip::IOError, "Volume not open" unless @io
96
96
 
97
97
  # Archive flags: every volume declares 0x0001; volumes
98
98
  # after the first also carry the volume number field
@@ -114,7 +114,7 @@ module Omnizip
114
114
  # @param compressed_data [String] Compressed file data
115
115
  # @return [void]
116
116
  def write_file_data(file_header, compressed_data)
117
- raise "Volume not open" unless @io
117
+ raise Omnizip::IOError, "Volume not open" unless @io
118
118
 
119
119
  @io.write(file_header.encode)
120
120
  @io.write(compressed_data)
@@ -124,7 +124,7 @@ module Omnizip
124
124
  #
125
125
  # @return [void]
126
126
  def write_end_header
127
- raise "Volume not open" unless @io
127
+ raise Omnizip::IOError, "Volume not open" unless @io
128
128
 
129
129
  # End-of-archive flags: 0x0001 marks a volume with more
130
130
  # volumes following; the last volume ends with 0.
@@ -199,6 +199,11 @@ module Omnizip
199
199
  if @options[:solid] && Compression::Lzss.available?
200
200
  write_solid_block(f, @files)
201
201
  else
202
+ if @options[:solid]
203
+ warn "omnizip: solid: true requested but the " \
204
+ "RAR-compatible LZSS encoder is not available " \
205
+ "— writing independent STORE entries instead"
206
+ end
202
207
  @files.each do |file|
203
208
  write_file_entry(f, file)
204
209
  end
@@ -229,7 +229,7 @@ module Omnizip
229
229
 
230
230
  loop do
231
231
  byte = io.read(1)&.unpack1("C")
232
- raise "Unexpected EOF" unless byte
232
+ raise Omnizip::InvalidArchiveError, "Unexpected EOF" unless byte
233
233
 
234
234
  result |= (byte & 0x7F) << shift
235
235
  break if byte.nobits?(0x80)
@@ -47,6 +47,11 @@ module Omnizip
47
47
  entry.prefix = extract_string(
48
48
  header_data, PREFIX_OFFSET, PREFIX_SIZE
49
49
  )
50
+ # ustar stores long paths split across prefix + name;
51
+ # consumers see the joined path
52
+ unless entry.prefix.empty?
53
+ entry.name = "#{entry.prefix}/#{entry.name}"
54
+ end
50
55
  end
51
56
 
52
57
  # Verify checksum
@@ -69,7 +74,8 @@ module Omnizip
69
74
  header = "\0" * HEADER_SIZE
70
75
 
71
76
  # Write fields to header
72
- write_string(header, entry.name, NAME_OFFSET, NAME_SIZE)
77
+ name, prefix = split_long_name(entry.name, entry.prefix)
78
+ write_string(header, name, NAME_OFFSET, NAME_SIZE)
73
79
  write_octal(header, entry.mode, MODE_OFFSET, MODE_SIZE)
74
80
  write_octal(header, entry.uid, UID_OFFSET, UID_SIZE)
75
81
  write_octal(header, entry.gid, GID_OFFSET, GID_SIZE)
@@ -87,7 +93,7 @@ module Omnizip
87
93
  write_string(header, entry.gname, GNAME_OFFSET, GNAME_SIZE)
88
94
  write_octal(header, entry.devmajor, DEVMAJOR_OFFSET, DEVMAJOR_SIZE)
89
95
  write_octal(header, entry.devminor, DEVMINOR_OFFSET, DEVMINOR_SIZE)
90
- write_string(header, entry.prefix, PREFIX_OFFSET, PREFIX_SIZE)
96
+ write_string(header, prefix, PREFIX_OFFSET, PREFIX_SIZE)
91
97
 
92
98
  # Calculate and write checksum
93
99
  checksum = Entry.calculate_checksum(header)
@@ -130,6 +136,41 @@ module Omnizip
130
136
  field.strip.to_i(8)
131
137
  end
132
138
 
139
+ # Split a path for the ustar name (100) + prefix (155)
140
+ # fields. Paths under 100 bytes pass through; longer paths
141
+ # split at a "/" boundary, preferring the longest prefix
142
+ # that keeps both halves in bounds. Raises when no ustar
143
+ # representation exists — silent truncation corrupts
144
+ # archives.
145
+ #
146
+ # @param path [String] Full entry path
147
+ # @param prefix [String] Explicit prefix (wins when given)
148
+ # @return [Array<String>] [name, prefix] pair
149
+ def self.split_long_name(path, prefix = nil)
150
+ path = path.to_s
151
+ prefix = prefix.to_s
152
+ return [path, prefix] if !prefix.empty? || path.bytesize < NAME_SIZE
153
+
154
+ best = nil
155
+ offset = -1
156
+ while (slash = path.index("/", offset + 1))
157
+ candidate_prefix = path[0...slash]
158
+ candidate_name = path[(slash + 1)..]
159
+ offset = slash
160
+
161
+ next if candidate_prefix.bytesize >= PREFIX_SIZE
162
+ next if candidate_name.bytesize >= NAME_SIZE
163
+
164
+ best = [candidate_name, candidate_prefix]
165
+ end
166
+
167
+ return best if best
168
+
169
+ raise Omnizip::FormatError,
170
+ "Path too long for the ustar name+prefix fields " \
171
+ "(no PAX/GNU long-name support): #{path}"
172
+ end
173
+
133
174
  # Write a string to header
134
175
  #
135
176
  # @param header [String] Header data
@@ -152,6 +152,8 @@ module Omnizip
152
152
  end
153
153
 
154
154
  def write_precompressed_to_io(io, compression_method: COMPRESSION_DEFLATE)
155
+ check_classic_entry_count!
156
+ entries.each { |entry| check_classic_size_limit!(entry) }
155
157
  local_header_offsets = []
156
158
 
157
159
  entries.each do |entry|
@@ -189,6 +191,7 @@ module Omnizip
189
191
 
190
192
  # Write to an IO object
191
193
  def write_to_io(io, compression_method: COMPRESSION_DEFLATE, level: 6)
194
+ check_classic_entry_count!
192
195
  local_header_offsets = []
193
196
 
194
197
  # Write local file headers and data. An entry-level
@@ -220,6 +223,8 @@ module Omnizip
220
223
  entry[:crc32] = calculate_crc32(entry[:uncompressed_data])
221
224
  end
222
225
 
226
+ check_classic_size_limit!(entry)
227
+
223
228
  # Update local header with compressed sizes
224
229
  local_header.compressed_size = entry[:compressed_size]
225
230
  local_header.uncompressed_size = entry[:uncompressed_size]
@@ -234,6 +239,7 @@ module Omnizip
234
239
 
235
240
  # Record start of central directory
236
241
  central_directory_offset = io.pos
242
+ check_classic_offset_limit!(central_directory_offset)
237
243
 
238
244
  # Write central directory headers
239
245
  entries.each_with_index do |entry, index|
@@ -257,8 +263,42 @@ module Omnizip
257
263
  io.write(eocd.to_binary)
258
264
  end
259
265
 
266
+ # Classic (non-ZIP64) ZIP headers pack sizes and offsets as
267
+ # 32-bit values and entry counts as 16-bit; exceeding them
268
+ # raises instead of letting Array#pack fail with a bare
269
+ # RangeError. ZIP64 writing is not supported.
270
+ MAX_CLASSIC_SIZE = 0xFFFFFFFF
271
+ MAX_CLASSIC_ENTRIES = 0xFFFF
272
+ private_constant :MAX_CLASSIC_SIZE, :MAX_CLASSIC_ENTRIES
273
+
260
274
  private
261
275
 
276
+ def check_classic_entry_count!
277
+ return if entries.size <= MAX_CLASSIC_ENTRIES
278
+
279
+ raise Omnizip::UnsupportedFormatError,
280
+ "#{entries.size} entries exceed the classic ZIP " \
281
+ "limit of #{MAX_CLASSIC_ENTRIES} (ZIP64 writing is " \
282
+ "not supported)"
283
+ end
284
+
285
+ def check_classic_size_limit!(entry)
286
+ return if entry[:compressed_size].to_i <= MAX_CLASSIC_SIZE &&
287
+ entry[:uncompressed_size].to_i <= MAX_CLASSIC_SIZE
288
+
289
+ raise Omnizip::UnsupportedFormatError,
290
+ "entry #{entry[:filename].inspect} exceeds the 4 GiB " \
291
+ "classic ZIP size limit (ZIP64 writing is not supported)"
292
+ end
293
+
294
+ def check_classic_offset_limit!(offset)
295
+ return if offset <= MAX_CLASSIC_SIZE
296
+
297
+ raise Omnizip::UnsupportedFormatError,
298
+ "archive layout exceeds the 4 GiB classic ZIP " \
299
+ "offset limit (ZIP64 writing is not supported)"
300
+ end
301
+
262
302
  # Create an entry hash
263
303
  def create_entry(
264
304
  filename:,
@@ -151,7 +151,7 @@ progress: nil)
151
151
  #
152
152
  # @raise [StandardError] if no files added
153
153
  def validate_files!
154
- raise "No files added to PAR2 set" if @files.empty?
154
+ raise ArgumentError, "No files added to PAR2 set" if @files.empty?
155
155
  end
156
156
 
157
157
  # Generate unique set ID for this PAR2 set
@@ -244,7 +244,7 @@ module Omnizip
244
244
 
245
245
  # Ensure metadata is loaded
246
246
  if file_list.nil? || file_list.empty?
247
- raise "Internal error: file_list not populated in verifier"
247
+ raise Omnizip::Error, "Internal error: file_list not populated in verifier"
248
248
  end
249
249
 
250
250
  block_idx = 0
@@ -35,7 +35,7 @@ module Omnizip
35
35
  # Find next logbase where gcd(65535, logbase) == 1
36
36
  logbase += 1 while gcd(limit, logbase) != 1
37
37
 
38
- raise "Too many input blocks" if logbase >= limit
38
+ raise ArgumentError, "Too many input blocks" if logbase >= limit
39
39
 
40
40
  # Use antilog to convert logbase to base value
41
41
  # This is the key: base = antilog[logbase]
@@ -109,7 +109,7 @@ recovery_exponents, bases)
109
109
  num_rows.times do |pivot_row|
110
110
  # Get pivot value from right matrix diagonal
111
111
  pivot = right_matrix[pivot_row][pivot_row]
112
- raise "Singular matrix at row #{pivot_row}" if pivot.zero?
112
+ raise Omnizip::Error, "Singular matrix at row #{pivot_row}" if pivot.zero?
113
113
 
114
114
  # Scale pivot row to make pivot = 1
115
115
  unless pivot == 1
@@ -117,7 +117,7 @@ total_inputs, block_size)
117
117
  # @param recovery_idx [Integer] Recovery block index (0..recovery_count-1)
118
118
  # @return [Integer] Galois field coefficient
119
119
  def coefficient(output_idx, recovery_idx)
120
- raise "Matrix not computed - call compute! first" unless @matrix
120
+ raise Omnizip::Error, "Matrix not computed - call compute! first" unless @matrix
121
121
 
122
122
  # After transpose, indices are swapped: @matrix[recovery_idx][output_idx]
123
123
  @matrix[recovery_idx][output_idx]
@@ -200,7 +200,7 @@ output_offset: 0)
200
200
 
201
201
  num_rows.times do |pivot_row|
202
202
  pivot = left_matrix[pivot_row][pivot_row]
203
- raise "Singular matrix at row #{pivot_row}" if pivot.zero?
203
+ raise Omnizip::Error, "Singular matrix at row #{pivot_row}" if pivot.zero?
204
204
 
205
205
  # Scale pivot row to make pivot = 1
206
206
  unless pivot == 1
@@ -181,11 +181,11 @@ module Omnizip
181
181
 
182
182
  private
183
183
 
184
- # Depth-first search for the first regular file under +dir+.
184
+ # Depth-first search for the first regular file under +dir+,
185
+ # in sorted child order — :auto sampling must not depend on
186
+ # the OS's directory iteration order.
185
187
  def first_file_within(dir)
186
- ::Dir.foreach(dir) do |entry|
187
- next if [".", ".."].include?(entry)
188
-
188
+ ::Dir.children(dir).sort.each do |entry|
189
189
  full_path = ::File.join(dir, entry)
190
190
  return full_path if ::File.file?(full_path)
191
191
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Omnizip
4
- VERSION = "0.3.56"
4
+ VERSION = "0.3.58"
5
5
  end
@@ -130,7 +130,7 @@ module Omnizip
130
130
  action = yield(entry, dest_path)
131
131
  return if action == false
132
132
  else
133
- raise "Destination file already exists: #{dest_path}"
133
+ raise Errno::EEXIST, "Destination file already exists: #{dest_path}"
134
134
  end
135
135
  end
136
136
 
@@ -75,7 +75,7 @@ compression: :deflate, level: 6)
75
75
  # Write data to current entry
76
76
  # @param data [String] Data to write
77
77
  def write(data)
78
- raise "No entry started. Call put_next_entry first" unless @current_entry
78
+ raise Omnizip::IOError, "No entry started. Call put_next_entry first" unless @current_entry
79
79
 
80
80
  @current_entry_data << data.b
81
81
  self
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omnizip
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.56
4
+ version: 0.3.58
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-02 00:00:00.000000000 Z
11
+ date: 2026-09-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64