omnizip 0.3.44 → 0.3.45

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: 358c03758aa086cbb4f3a22845af9f309542df3c272fc640faaaf807d3b2adb0
4
- data.tar.gz: 96c8735854c4413b2cb802611eb7acd2d4480d1983b5dd4e4e4a45ebf35d3725
3
+ metadata.gz: c60c442839bff64ee405d6eb9c5f3ead4a97fa53df7ffd381ca0508dbb00b5e8
4
+ data.tar.gz: 22b8abfb94d6403e16ffea3c36115c6c7eb15eed654ef15f8a824453aa2eef20
5
5
  SHA512:
6
- metadata.gz: 67ab99068276c2eef687be2d7497c911cdb96f920dea552b9392fe860cbc805345c8bf87f5f6640934b09d46b7fc3c598c8bb140a296f37395d2020ff9e0bbd6
7
- data.tar.gz: 256599715a8f9aa94ee2e94376122a6571b8a7eb0e80548b74e57f14d2d2522a12fc6f3b5566f08c3b6a63692b06e3845a3d8948dfdf24fc53f22d79eebd434d
6
+ metadata.gz: f9a8b45c51e6c36d30273d7ac897eb4854793ed1548d4055dcf90335b836c9d3d1bb4db2faa374cf407345657fd5b459f16bf40021427387ae103ce54f1ed644
7
+ data.tar.gz: 412293ad5b9d9d3c361fe03f8ed88707f3cd5dc4fc4bc1221198e877a71eb98322ca4cc5664ac329dee117192738d4d1af6b8e79ca8cc080639cf69e83816d89
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.44] - 2026-08-31
11
+
12
+ ### Fixed
13
+ - Non-solid 7z archives now compress: the writer advertised
14
+ `algorithm: :lzma2` but the `solid: false` path concatenated raw
15
+ data under COPY coders (30 KB of compressible input produced a
16
+ 30 KB archive). Each file now compresses independently with the
17
+ same SDK encoder as the solid path, folders carry LZMA2 coders
18
+ with the dictionary property, and kSize holds packed sizes while
19
+ kCodersUnpackSize keeps originals. 7zz-verified, extraction
20
+ byte-identical (30 KB -> 532 B in the fixture).
21
+ - Latent 7z reader bug exposed by the above: multi-folder
22
+ non-solid archives read folder 1's packed stream for every entry
23
+ (pack positions never skipped earlier folders' bytes). Stored
24
+ archives masked it because pack sizes equalled unpack sizes, and
25
+ the tool-compat specs verify extraction through 7zz rather than
26
+ our reader. Fixed; new specs pin byte-exact multi-file
27
+ extraction through our own reader.
28
+ - `omnizip archive verify` is reachable: the command existed but
29
+ was registered in no CLI group, and its `Formats::Rar.verify`
30
+ call passed a kwarg the module method never accepted (an arity
31
+ crash on first contact). The module method forwards
32
+ `use_recovery:` now; RAR archives verify with per-file results
33
+ and unsupported formats exit non-zero with an honest message.
34
+
10
35
  ## [0.3.43] - 2026-08-29
11
36
 
12
37
  ### Changed
@@ -19,6 +19,9 @@ module Omnizip
19
19
  def extract_to(path, output_dir, password: nil, **_)
20
20
  Omnizip::Formats::Rar::Decompressor.extract(path, output_dir,
21
21
  password: password)
22
+ reader = Omnizip::Formats::Rar::Reader.new(path).open
23
+ reader.list_files.reject(&:is_dir)
24
+ .map { |e| ::File.join(output_dir, e.name) }
22
25
  end
23
26
 
24
27
  def list(path, details: false, **_)
@@ -27,6 +30,7 @@ module Omnizip
27
30
  if details
28
31
  entries.map do |e|
29
32
  { name: e.name, size: e.size, directory: e.is_dir,
33
+ compressed_size: (e.compressed_size if e.compressed_size.positive?),
30
34
  mtime: e.mtime }
31
35
  end
32
36
  else
@@ -15,9 +15,13 @@ module Omnizip
15
15
  end
16
16
 
17
17
  def extract_to(path, output_dir, **options)
18
+ extracted = []
18
19
  Omnizip::Formats::SevenZip.open(path, options) do |reader|
19
20
  reader.extract_all(output_dir)
21
+ extracted = reader.list_files.reject(&:is_dir)
22
+ .map { |e| ::File.join(output_dir, e.name) }
20
23
  end
24
+ extracted
21
25
  end
22
26
 
23
27
  def list(path, details: false, **options)
@@ -26,6 +30,7 @@ module Omnizip
26
30
  if details
27
31
  entries.map do |e|
28
32
  { name: e.name, size: e.size, directory: e.is_dir,
33
+ compressed_size: (e.compressed_size if e.compressed_size&.positive?),
29
34
  mtime: e.mtime }
30
35
  end
31
36
  else
@@ -17,6 +17,8 @@ module Omnizip
17
17
 
18
18
  def extract_to(path, output_dir, **_)
19
19
  Omnizip::Formats::Tar.extract(path, output_dir)
20
+ Omnizip::Formats::Tar.list(path).reject(&:directory?)
21
+ .map { |e| ::File.join(output_dir, e.name) }
20
22
  end
21
23
 
22
24
  def list(path, details: false, **_)
@@ -3,65 +3,69 @@
3
3
  module Omnizip
4
4
  module ArchiveHandlers
5
5
  # Adapter that exposes the canonical +create+/+open+/+extract_to+
6
- # /+list+ interface for the ZIP format. Wraps +Omnizip::Zip::File+.
6
+ # /+list+ interface for the ZIP format on the native
7
+ # +Formats::Zip+ tree. In-place entry editing (+add_entry+ /
8
+ # +remove_entry+) still goes through the rubyzip-compat
9
+ # +Omnizip::Zip::File+ layer: it is an in-place central-directory
10
+ # rewriter the native tree does not have (the same reason the
11
+ # Metadata subsystem lives there).
7
12
  class ZipHandler
8
- # Zip::File has no archive-level compression options (method and
9
- # level are per-entry in Zip::OutputStream), so extra keywords —
10
- # e.g. profile-injected ones — are absorbed here.
11
- def create(path, **_options, &block)
12
- Omnizip::Zip::File.create(path) do |file|
13
- block&.call(FileProxy.new(file))
14
- end
13
+ def create(path, compression_method: nil, level: nil, **_, &block)
14
+ writer = Formats::Zip::Writer.new(path)
15
+ block&.call(WriterProxy.new(writer))
16
+
17
+ # The native writer takes archive-level compression at #write
18
+ # time; keep profile keywords out of it
19
+ kwargs = {}
20
+ kwargs[:compression_method] = compression_method if compression_method
21
+ kwargs[:level] = level if level
22
+ writer.write(**kwargs)
23
+ path
15
24
  end
16
25
 
17
26
  def open(path, &block)
18
- Omnizip::Zip::File.open(path, &block)
27
+ reader = Formats::Zip::Reader.new(path)
28
+ block ? yield(reader) : reader
19
29
  end
20
30
 
21
31
  def extract_to(path, output_dir, overwrite: false, **_)
22
- extracted = []
23
- Omnizip::Zip::File.open(path) do |zip|
24
- zip.each do |entry|
25
- dest_path = ::File.join(output_dir, entry.name)
26
- on_exists = if overwrite
27
- proc { true }
28
- else
29
- proc { |_e, p| raise "File exists: #{p}" }
30
- end
31
- zip.extract(entry, dest_path, &on_exists)
32
+ reader = Formats::Zip::Reader.new(path)
33
+
34
+ if overwrite
35
+ reader.extract_all(output_dir)
36
+ reader.entries.map { |e| ::File.join(output_dir, e.filename) }
37
+ else
38
+ extracted = []
39
+ reader.entries.each do |entry|
40
+ dest_path = ::File.join(output_dir, entry.filename)
41
+ raise "File exists: #{dest_path}" if ::File.exist?(dest_path)
42
+
43
+ reader.extract_entry(entry, output_dir)
32
44
  extracted << dest_path
33
45
  end
46
+ extracted
34
47
  end
35
- extracted
36
48
  end
37
49
 
38
50
  def list(path, details: false, **_)
39
- Omnizip::Zip::File.open(path) do |zip|
40
- if details
41
- zip.entries.map do |entry|
42
- {
43
- name: entry.name,
44
- size: entry.size,
45
- compressed_size: entry.compressed_size,
46
- compression_method: entry.compression_method,
47
- crc: entry.crc,
48
- time: entry.time,
49
- directory: entry.directory?,
50
- }
51
- end
52
- else
53
- zip.names
54
- end
51
+ reader = Formats::Zip::Reader.new(path)
52
+ return reader.entries.map(&:filename) unless details
53
+
54
+ reader.entries.map do |entry|
55
+ {
56
+ name: entry.filename,
57
+ size: entry.uncompressed_size,
58
+ compressed_size: entry.compressed_size,
59
+ compression_method: entry.compression_method,
60
+ crc: entry.crc32,
61
+ time: entry.time,
62
+ directory: entry.directory?,
63
+ }
55
64
  end
56
65
  end
57
66
 
58
67
  def read_entry(path, entry_name)
59
- Omnizip::Zip::File.open(path) do |zip|
60
- entry = zip.get_entry(entry_name)
61
- raise Errno::ENOENT, "Entry not found: #{entry_name}" unless entry
62
-
63
- zip.read(entry)
64
- end
68
+ Formats::Zip::Reader.new(path).read_entry(entry_name)
65
69
  end
66
70
 
67
71
  def add_entry(path, entry_name, source_path)
@@ -73,20 +77,24 @@ module Omnizip
73
77
  end
74
78
 
75
79
  # Translates the generic +add(name, source_path)+ and
76
- # +add_data(name, data)+ interface. +Zip::File#add_data+ is
77
- # private, so in-memory content goes through the public block
78
- # form +add(name) { data }+.
79
- class FileProxy
80
- def initialize(file)
81
- @file = file
80
+ # +add_data(name, data)+ interface onto the native writer
81
+ class WriterProxy
82
+ def initialize(writer)
83
+ @writer = writer
82
84
  end
83
85
 
84
- def add(name, source_path = nil)
85
- @file.add(name, source_path)
86
+ def add(name, source_path = nil, &block)
87
+ if source_path
88
+ @writer.add_file(source_path, name)
89
+ elsif block
90
+ @writer.add_data(name, yield)
91
+ else
92
+ @writer.add_directory(name)
93
+ end
86
94
  end
87
95
 
88
96
  def add_data(name, data)
89
- @file.add(name) { data }
97
+ @writer.add_data(name, data)
90
98
  end
91
99
  end
92
100
  end
@@ -111,54 +111,24 @@ module Omnizip
111
111
  end
112
112
 
113
113
  def extract_archive(archive_file, output_dir, verbose)
114
- reader = case File.extname(archive_file).downcase
115
- when ".rar"
116
- Formats::Rar::Reader.new(archive_file).open
117
- when ".tar"
118
- Formats::Tar::Reader.new(archive_file).read
119
- when ".gz", ".gzip"
120
- # GZIP files are single-file compression, extract directly
121
- extract_gzip(archive_file, output_dir, verbose)
122
- return 1
123
- when ".bz2", ".bzip2"
124
- # BZIP2 files are single-file compression, extract directly
125
- extract_bzip2(archive_file, output_dir, verbose)
126
- return 1
127
- when ".xz"
128
- # XZ files are single-file compression, extract directly
129
- extract_xz(archive_file, output_dir, verbose)
130
- return 1
131
- else
132
- Formats::SevenZip::Reader.new(archive_file).open
133
- end
134
- file_count = 0
135
-
136
- reader.entries.each do |entry|
137
- output_path = File.join(output_dir, entry.name)
138
-
139
- if entry.directory?
140
- CliOutputFormatter.verbose_puts(
141
- "Creating directory: #{entry.name}",
142
- verbose,
143
- )
144
- FileUtils.mkdir_p(output_path)
145
- else
146
- CliOutputFormatter.verbose_puts(
147
- "Extracting: #{entry.name}",
148
- verbose,
149
- )
150
-
151
- # Ensure parent directory exists
152
- FileUtils.mkdir_p(File.dirname(output_path))
153
-
154
- # Extract file
155
- reader.extract_entry(entry.name, output_path)
156
-
157
- file_count += 1
158
- end
114
+ # Single-file compression formats decompress directly
115
+ case File.extname(archive_file).downcase
116
+ when ".gz", ".gzip"
117
+ extract_gzip(archive_file, output_dir, verbose)
118
+ return 1
119
+ when ".bz2", ".bzip2"
120
+ extract_bzip2(archive_file, output_dir, verbose)
121
+ return 1
122
+ when ".xz"
123
+ extract_xz(archive_file, output_dir, verbose)
124
+ return 1
159
125
  end
160
126
 
161
- file_count
127
+ # Archives route through the handler seam: the extension
128
+ # routes pick each format's reader instead of hand-rolled
129
+ # branches, and zip/7z/rar/tar/cpio/iso all extract the same
130
+ # way
131
+ Omnizip.extract_archive(archive_file, output_dir).size
162
132
  rescue StandardError => e
163
133
  raise Omnizip::CompressionError,
164
134
  "Failed to extract archive: #{e.message}"
@@ -58,19 +58,12 @@ module Omnizip
58
58
  end
59
59
 
60
60
  def list_archive(archive_file, verbose, patterns, excludes, count_only)
61
- archive = if archive_file.end_with?(".zip")
62
- Omnizip::Zip::File.open(archive_file)
63
- elsif archive_file.end_with?(".rar")
64
- Formats::Rar::Reader.new(archive_file).open
65
- else
66
- Formats::SevenZip::Reader.new(archive_file).open
67
- end
61
+ # Route through the handler seam: every format's reader is
62
+ # chosen by the extension routes, not by hand-rolled branches
63
+ entries = Omnizip.list_archive(archive_file, details: true)
64
+ .map { |h| EntryAdapter.new(h) }
68
65
 
69
- entries = if patterns || excludes
70
- filter_entries(archive, patterns, excludes)
71
- else
72
- archive.entries
73
- end
66
+ entries = filter_entries(entries, patterns, excludes) if patterns || excludes
74
67
 
75
68
  if count_only
76
69
  puts "Matches: #{entries.size}"
@@ -91,11 +84,9 @@ module Omnizip
91
84
  rescue StandardError => e
92
85
  raise Omnizip::CompressionError,
93
86
  "Failed to list archive: #{e.message}"
94
- ensure
95
- archive.close if archive.is_a?(Omnizip::Zip::File)
96
87
  end
97
88
 
98
- def filter_entries(archive, patterns, excludes)
89
+ def filter_entries(entries, patterns, excludes)
99
90
  filter = Extraction::FilterChain.new
100
91
 
101
92
  # Add include patterns
@@ -104,7 +95,7 @@ module Omnizip
104
95
  # Add exclude patterns
105
96
  excludes&.each { |pattern| filter.exclude_pattern(pattern) }
106
97
 
107
- filter.filter(archive.entries)
98
+ filter.filter(entries)
108
99
  end
109
100
 
110
101
  def display_simple_listing_filtered(entries)
@@ -202,16 +193,29 @@ module Omnizip
202
193
  end
203
194
 
204
195
  def entry_compressed_size(entry)
205
- entry.compressed_size if entry.is_a?(Omnizip::Formats::SevenZip::Models::FileEntry)
196
+ entry.compressed_size
206
197
  end
207
198
 
208
199
  def entry_has_stream?(entry)
209
- entry.has_stream? if entry.is_a?(Omnizip::Formats::SevenZip::Models::FileEntry)
200
+ entry.has_stream?
210
201
  end
211
202
 
212
203
  def entry_mtime(entry)
213
204
  entry.entry_mtime
214
205
  end
206
+
207
+ # Presents handler list-detail hashes under the Omnizip::Entry
208
+ # contract the display helpers already use
209
+ # :fields, not :hash — Struct members must not override
210
+ # Struct#hash
211
+ EntryAdapter = Struct.new(:fields) do
212
+ def entry_name = fields[:name]
213
+ def entry_directory? = fields[:directory]
214
+ def entry_size = fields[:size]
215
+ def entry_mtime = fields[:mtime] || fields[:time]
216
+ def compressed_size = fields[:compressed_size]
217
+ def has_stream? = !fields[:directory] && fields[:size].to_i.positive?
218
+ end
215
219
  end
216
220
  end
217
221
  end
@@ -60,6 +60,36 @@ module Omnizip
60
60
  end
61
61
 
62
62
  # Check if this is a directory entry
63
+ # DOS date/time (as stored in the central directory) to Time
64
+ def time
65
+ year = ((last_mod_date >> 9) & 0x7F) + 1980
66
+ month = (last_mod_date >> 5) & 0x0F
67
+ day = last_mod_date & 0x1F
68
+ hour = (last_mod_time >> 11) & 0x1F
69
+ minute = (last_mod_time >> 5) & 0x3F
70
+ second = (last_mod_time & 0x1F) * 2
71
+ Time.new(year, month, day, hour, minute, second)
72
+ rescue ArgumentError
73
+ nil
74
+ end
75
+
76
+ # Omnizip::Entry contract
77
+ def entry_name
78
+ filename
79
+ end
80
+
81
+ def entry_directory?
82
+ directory?
83
+ end
84
+
85
+ def entry_size
86
+ uncompressed_size
87
+ end
88
+
89
+ def entry_mtime
90
+ time
91
+ end
92
+
63
93
  def directory?
64
94
  filename.end_with?("/") ||
65
95
  external_attributes.anybits?(ATTR_DIRECTORY)
@@ -64,49 +64,14 @@ dereference_links: false)
64
64
  else
65
65
  FileUtils.mkdir_p(File.dirname(output_path))
66
66
 
67
- File.open(file_path, "rb") do |io|
68
- # Seek to local file header
69
- io.seek(entry.local_header_offset, ::IO::SEEK_SET)
70
-
71
- # Read fixed part of local file header (30 bytes)
72
- fixed_header = io.read(30)
73
-
74
- # Extract variable lengths from fixed header
75
- _signature, _version, _flags, _method, _time, _date, _crc32,
76
- _comp_size, _uncomp_size, filename_length, extra_length = fixed_header.unpack("VvvvvvVVVvv")
77
-
78
- # Read variable parts
79
- variable_data = io.read(filename_length + extra_length)
80
-
81
- # Parse complete local file header
82
- LocalFileHeader.from_binary(fixed_header + variable_data)
83
-
84
- # Now we're positioned right after the local file header, read compressed data
85
- compressed_data = io.read(entry.compressed_size)
86
-
87
- # Decompress data
88
- decompressed_data = decompress_data(
89
- compressed_data,
90
- entry.compression_method,
91
- entry.uncompressed_size,
92
- )
93
-
94
- # Verify CRC
95
- calculated_crc = Omnizip::Checksums::Crc32.new.tap do |c|
96
- c.update(decompressed_data)
97
- end.finalize
98
- if calculated_crc != entry.crc32
99
- raise Omnizip::ChecksumError,
100
- "CRC mismatch for #{entry.filename}"
101
- end
102
-
103
- # Write decompressed data
104
- File.binwrite(output_path, decompressed_data)
105
-
106
- # Set file permissions if Unix
107
- if entry.unix_permissions.positive?
108
- File.chmod(entry.unix_permissions & 0o777, output_path)
109
- end
67
+ decompressed_data = read_entry_data(entry)
68
+
69
+ # Write decompressed data
70
+ File.binwrite(output_path, decompressed_data)
71
+
72
+ # Set file permissions if Unix
73
+ if entry.unix_permissions.positive?
74
+ File.chmod(entry.unix_permissions & 0o777, output_path)
110
75
  end
111
76
  end
112
77
  end
@@ -168,8 +133,57 @@ dereference_links: false)
168
133
  decompress_data(compressed_data, method, uncompressed_size)
169
134
  end
170
135
 
136
+ # Read an entry's decompressed content into a String.
137
+ # Directory entries return an empty string; symlinks return
138
+ # their target path.
139
+ #
140
+ # @param entry_name [String] Name of the entry to read
141
+ # @return [String] Decompressed content
142
+ # @raise [RuntimeError] if the entry is not found
143
+ def read_entry(entry_name)
144
+ ensure_read
145
+ entry = @entries.find { |e| e.filename == entry_name }
146
+ raise Errno::ENOENT, "Entry not found: #{entry_name}" unless entry
147
+
148
+ return "" if entry.directory?
149
+ return entry.link_target if entry.symlink?
150
+
151
+ read_entry_data(entry)
152
+ end
153
+
171
154
  private
172
155
 
156
+ # Decompress and CRC-verify a single entry's data
157
+ def read_entry_data(entry)
158
+ File.open(file_path, "rb") do |io|
159
+ io.seek(entry.local_header_offset, ::IO::SEEK_SET)
160
+
161
+ fixed_header = io.read(30)
162
+ _signature, _version, _flags, _method, _time, _date, _crc32,
163
+ _comp_size, _uncomp_size, filename_length, extra_length = fixed_header.unpack("VvvvvvVVVvv")
164
+
165
+ variable_data = io.read(filename_length + extra_length)
166
+ LocalFileHeader.from_binary(fixed_header + variable_data)
167
+
168
+ compressed_data = io.read(entry.compressed_size)
169
+ decompressed_data = decompress_data(
170
+ compressed_data,
171
+ entry.compression_method,
172
+ entry.uncompressed_size,
173
+ )
174
+
175
+ calculated_crc = Omnizip::Checksums::Crc32.new.tap do |c|
176
+ c.update(decompressed_data)
177
+ end.finalize
178
+ if calculated_crc != entry.crc32
179
+ raise Omnizip::ChecksumError,
180
+ "CRC mismatch for #{entry.filename}"
181
+ end
182
+
183
+ decompressed_data
184
+ end
185
+ end
186
+
173
187
  # Parse the central directory once, on demand
174
188
  def ensure_read
175
189
  read if @entries.empty? && @central_directory.empty?
@@ -54,6 +54,11 @@ module Omnizip
54
54
  # @param progress [Proc, nil] Progress callback
55
55
  # @raise [ArgumentError] if file doesn't exist
56
56
  def initialize(par2_file, progress: nil)
57
+ if par2_file.nil?
58
+ raise ArgumentError,
59
+ "PAR2 file is nil — no index .par2 was produced for this set"
60
+ end
61
+
57
62
  raise ArgumentError, "PAR2 file not found: #{par2_file}" unless
58
63
  File.exist?(par2_file)
59
64
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Omnizip
4
- VERSION = "0.3.44"
4
+ VERSION = "0.3.45"
5
5
  end
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.44
4
+ version: 0.3.45
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-08-30 00:00:00.000000000 Z
11
+ date: 2026-08-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64