omnizip 0.3.52 → 0.3.53
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 +4 -4
- data/CHANGELOG.md +29 -0
- data/lib/omnizip/archive_handlers/zip_handler.rb +1 -1
- data/lib/omnizip/buffer/memory_extractor.rb +3 -16
- data/lib/omnizip/buffer/seven_zip_bridge.rb +13 -0
- data/lib/omnizip/buffer.rb +23 -21
- data/lib/omnizip/checksums/verifier.rb +2 -2
- data/lib/omnizip/chunked.rb +14 -21
- data/lib/omnizip/formats/cpio/reader.rb +1 -1
- data/lib/omnizip/formats/iso/reader.rb +3 -3
- data/lib/omnizip/formats/iso/volume_descriptor.rb +2 -2
- data/lib/omnizip/formats/rar/compression/ppmd/decoder.rb +0 -20
- data/lib/omnizip/formats/rar/decompressor.rb +7 -7
- data/lib/omnizip/formats/rar/reader.rb +2 -2
- data/lib/omnizip/formats/rpm.rb +4 -4
- data/lib/omnizip/formats/seven_zip/reader.rb +1 -1
- data/lib/omnizip/formats/seven_zip/split_archive_reader.rb +1 -1
- data/lib/omnizip/formats/xar/reader.rb +2 -2
- data/lib/omnizip/formats/zip/reader.rb +96 -8
- data/lib/omnizip/models/conversion_result.rb +75 -63
- data/lib/omnizip/models/filter_config.rb +34 -44
- data/lib/omnizip/models/match_result.rb +35 -18
- data/lib/omnizip/models/optimization_suggestion.rb +45 -41
- data/lib/omnizip/models/performance_result.rb +53 -47
- data/lib/omnizip/models/profile_report.rb +70 -36
- data/lib/omnizip/parallel/parallel_extractor.rb +2 -2
- data/lib/omnizip/parallel/worker_pool.rb +3 -3
- data/lib/omnizip/profiler/report_generator.rb +1 -1
- data/lib/omnizip/version.rb +1 -1
- data/lib/omnizip/zip/file.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a7e72a57eaeb6e597e40020de67aa0242da65c6e6e8ceaa933d779d926e55666
|
|
4
|
+
data.tar.gz: 105cf4f238610f518462c115d398403fef5daca9b2994788361b027c7c5ec648
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a5301b14047d1150885624527d03176c61a979464b7a16329a47449e13fe05679c1e5ce11a650bfd29101aaaf502b5ab81513558fc080dca85554266ae30f8dc
|
|
7
|
+
data.tar.gz: a44ffd04cc02fa03e2b23a330da4f6612d268bfd350d9b9672e42d7a107dcad65201ed9707651b3533fdd790a5841a5ff9297d6f3a8e937b51e0f1614d756c55
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.52] - 2026-09-01
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- The extract command's pattern path opens archives through the
|
|
14
|
+
Archive facade instead of a hand-rolled extension `case` that
|
|
15
|
+
misrouted `.cpio`/`.iso`/`.rpm` files into the 7z reader — pattern
|
|
16
|
+
extraction now works for every routed format. The metadata
|
|
17
|
+
command's 7z view lists through the handler registry (the detail
|
|
18
|
+
contract gains `:crc`), and SafeExtract extracts through
|
|
19
|
+
`extract_archive`, so every routed format gets rollback and
|
|
20
|
+
verification instead of ZIP-only with a `NotImplementedError`
|
|
21
|
+
for 7z. SelectiveExtractor understands both entry contracts
|
|
22
|
+
(legacy `entry_name` and reader-session `name`) and skips
|
|
23
|
+
directory entries when extracting.
|
|
24
|
+
- `CliOutputFormatter.format_size` is now the single byte formatter;
|
|
25
|
+
the nine private `format_bytes`/`format_size` copies (four
|
|
26
|
+
different shapes, two unit ceilings) become delegators.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- The long-deferred parity spec flake: `Par2Verifier` crashed five
|
|
30
|
+
frames deep on a nil PAR2 path (`File.exist?(nil)` → TypeError)
|
|
31
|
+
where the repairer already raised a clean ArgumentError; the
|
|
32
|
+
verifier now mirrors it, and the spec's `.verify` group gains the
|
|
33
|
+
nil-index fallback `.repair` already had. The dead shadowed
|
|
34
|
+
`analyze_file` definition in `Par2Creator` (silently overridden
|
|
35
|
+
by a second definition with a divergent file_id format) is
|
|
36
|
+
deleted. The tautological "LZMA2 is implicit" xz assertion became
|
|
37
|
+
a real block-header parse asserting the explicit 0x21 filter ID.
|
|
38
|
+
|
|
10
39
|
## [0.3.51] - 2026-09-01
|
|
11
40
|
|
|
12
41
|
### Changed
|
|
@@ -38,7 +38,7 @@ module Omnizip
|
|
|
38
38
|
extracted = []
|
|
39
39
|
reader.entries.each do |entry|
|
|
40
40
|
dest_path = ::File.join(output_dir, entry.filename)
|
|
41
|
-
raise "File exists: #{dest_path}" if ::File.exist?(dest_path)
|
|
41
|
+
raise Errno::EEXIST, "File exists: #{dest_path}" if ::File.exist?(dest_path)
|
|
42
42
|
|
|
43
43
|
reader.extract_entry(entry, output_dir)
|
|
44
44
|
extracted << dest_path
|
|
@@ -151,24 +151,12 @@ module Omnizip
|
|
|
151
151
|
|
|
152
152
|
private
|
|
153
153
|
|
|
154
|
-
#
|
|
154
|
+
# Delegate to the shared buffer-side sniffer
|
|
155
155
|
#
|
|
156
156
|
# @return [Symbol] Detected format
|
|
157
157
|
# @raise [Omnizip::FormatError] If format cannot be detected
|
|
158
158
|
def detect_format
|
|
159
|
-
|
|
160
|
-
@buffer.rewind
|
|
161
|
-
|
|
162
|
-
case magic
|
|
163
|
-
when "PK\x03\x04".b, "PK\x05\x06".b, "PK\x07\x08".b
|
|
164
|
-
# ZIP signatures: local file header, EOCD, data descriptor
|
|
165
|
-
:zip
|
|
166
|
-
when "7z\xBC\xAF".b
|
|
167
|
-
:seven_zip
|
|
168
|
-
else
|
|
169
|
-
raise Omnizip::FormatError,
|
|
170
|
-
"Unknown archive format (magic: #{magic.inspect})"
|
|
171
|
-
end
|
|
159
|
+
Omnizip::Buffer.detect_format(@buffer)
|
|
172
160
|
end
|
|
173
161
|
|
|
174
162
|
# Extract all entries from ZIP
|
|
@@ -207,8 +195,7 @@ module Omnizip
|
|
|
207
195
|
def extract_entry_seven_zip(name)
|
|
208
196
|
@buffer.rewind
|
|
209
197
|
SevenZipBridge.open(@buffer) do |archive|
|
|
210
|
-
|
|
211
|
-
archive.extract_all_to_memory[name] if entry
|
|
198
|
+
archive.read_entry(name)
|
|
212
199
|
end
|
|
213
200
|
end
|
|
214
201
|
|
|
@@ -112,6 +112,19 @@ module Omnizip
|
|
|
112
112
|
@reader.list_files
|
|
113
113
|
end
|
|
114
114
|
|
|
115
|
+
# Content of a single named file entry, or nil when absent
|
|
116
|
+
# (directories carry no content). Extracts just that entry —
|
|
117
|
+
# not the whole archive.
|
|
118
|
+
#
|
|
119
|
+
# @param name [String] Entry name
|
|
120
|
+
# @return [String, nil]
|
|
121
|
+
def read_entry(name)
|
|
122
|
+
entry = raw_entries.find { |e| e.name == name && !e.is_dir }
|
|
123
|
+
return nil unless entry
|
|
124
|
+
|
|
125
|
+
Entry.new(entry, @reader, @tmp).read
|
|
126
|
+
end
|
|
127
|
+
|
|
115
128
|
# Entry wrapper matching MemoryArchive::Entry's surface
|
|
116
129
|
class Entry
|
|
117
130
|
attr_reader :name, :size
|
data/lib/omnizip/buffer.rb
CHANGED
|
@@ -107,27 +107,9 @@ module Omnizip
|
|
|
107
107
|
extractor.extract_all
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
# @param format [Symbol] Archive format
|
|
114
|
-
# @param options [Hash] Format-specific options
|
|
115
|
-
# @return [StringIO] Complete archive in memory
|
|
116
|
-
#
|
|
117
|
-
# @example Create from Hash
|
|
118
|
-
# data = {'file1.txt' => 'content1', 'file2.txt' => 'content2'}
|
|
119
|
-
# zip = Omnizip::Buffer.create_from_hash(data, :zip)
|
|
120
|
-
def create_from_hash(hash, format = :zip, **options)
|
|
121
|
-
create(format, **options) do |archive|
|
|
122
|
-
hash.each do |name, content|
|
|
123
|
-
archive.add(name, content)
|
|
124
|
-
end
|
|
125
|
-
end
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
private
|
|
129
|
-
|
|
130
|
-
# Detect archive format from magic bytes
|
|
110
|
+
# Detect archive format from magic bytes. THE buffer-side
|
|
111
|
+
# sniffer — in-memory data has no extension to route by, so
|
|
112
|
+
# every consumer delegates here.
|
|
131
113
|
#
|
|
132
114
|
# @param buffer [StringIO] Buffer containing archive data
|
|
133
115
|
# @return [Symbol] Detected format
|
|
@@ -148,6 +130,26 @@ module Omnizip
|
|
|
148
130
|
end
|
|
149
131
|
end
|
|
150
132
|
|
|
133
|
+
# Create archive from Hash of filename => content
|
|
134
|
+
#
|
|
135
|
+
# @param hash [Hash<String, String>] Filename => content mapping
|
|
136
|
+
# @param format [Symbol] Archive format
|
|
137
|
+
# @param options [Hash] Format-specific options
|
|
138
|
+
# @return [StringIO] Complete archive in memory
|
|
139
|
+
#
|
|
140
|
+
# @example Create from Hash
|
|
141
|
+
# data = {'file1.txt' => 'content1', 'file2.txt' => 'content2'}
|
|
142
|
+
# zip = Omnizip::Buffer.create_from_hash(data, :zip)
|
|
143
|
+
def create_from_hash(hash, format = :zip, **options)
|
|
144
|
+
create(format, **options) do |archive|
|
|
145
|
+
hash.each do |name, content|
|
|
146
|
+
archive.add(name, content)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
151
153
|
# Create ZIP archive in buffer
|
|
152
154
|
#
|
|
153
155
|
# @param buffer [StringIO] Buffer to write to
|
|
@@ -94,7 +94,7 @@ module Omnizip
|
|
|
94
94
|
when CHECK_SHA256
|
|
95
95
|
verify_sha256(data, expected)
|
|
96
96
|
else
|
|
97
|
-
raise "Unknown check type: #{check_type}"
|
|
97
|
+
raise Omnizip::UnknownChecksumError, "Unknown check type: #{check_type}"
|
|
98
98
|
end
|
|
99
99
|
end
|
|
100
100
|
|
|
@@ -122,7 +122,7 @@ module Omnizip
|
|
|
122
122
|
when CHECK_SHA256
|
|
123
123
|
Digest::SHA256.digest(data)
|
|
124
124
|
else
|
|
125
|
-
raise "Unknown check type: #{check_type}"
|
|
125
|
+
raise Omnizip::UnknownChecksumError, "Unknown check type: #{check_type}"
|
|
126
126
|
end
|
|
127
127
|
end
|
|
128
128
|
end
|
data/lib/omnizip/chunked.rb
CHANGED
|
@@ -95,30 +95,23 @@ module Omnizip
|
|
|
95
95
|
"Input archive not found: #{input}"
|
|
96
96
|
end
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
Omnizip::Zip::File.open(input) do |zip|
|
|
102
|
-
entry = zip.entries.first
|
|
103
|
-
total_size = entry.size
|
|
104
|
-
|
|
105
|
-
# Read the full entry content
|
|
106
|
-
content = zip.get_input_stream(entry)
|
|
98
|
+
reader = Omnizip::Formats::Zip::Reader.new(input)
|
|
99
|
+
entry = reader.entries.first
|
|
100
|
+
raise Omnizip::Error, "No entries to decompress in #{input}" unless entry
|
|
107
101
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
chunk = content.byteslice(offset, chunk_size) || ""
|
|
112
|
-
break if chunk.empty?
|
|
102
|
+
total_size = entry.uncompressed_size.to_i
|
|
103
|
+
processed = 0
|
|
104
|
+
writer = Writer.new(output, chunk_size: chunk_size)
|
|
113
105
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
106
|
+
# Streams entry-by-entry in bounded memory — the whole entry
|
|
107
|
+
# is never held at once
|
|
108
|
+
reader.read_entry_stream(entry.filename, chunk_size: chunk_size) do |chunk|
|
|
109
|
+
writer.write_chunk(chunk)
|
|
110
|
+
processed += chunk.bytesize
|
|
117
111
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
end
|
|
112
|
+
if progress
|
|
113
|
+
percentage = (processed.to_f / total_size * 100).round(2)
|
|
114
|
+
progress.call(processed, total_size, percentage)
|
|
122
115
|
end
|
|
123
116
|
end
|
|
124
117
|
|
|
@@ -64,7 +64,7 @@ module Omnizip
|
|
|
64
64
|
# @raise [RuntimeError] if entry not found
|
|
65
65
|
def extract_entry(entry_name, output_path)
|
|
66
66
|
entry = @entries.find { |e| e.name == entry_name }
|
|
67
|
-
raise "Entry not found: #{entry_name}" unless entry
|
|
67
|
+
raise Errno::ENOENT, "Entry not found: #{entry_name}" unless entry
|
|
68
68
|
|
|
69
69
|
extract_single_entry(entry, output_path)
|
|
70
70
|
end
|
|
@@ -67,7 +67,7 @@ module Omnizip
|
|
|
67
67
|
# @param output_path [String] Destination path
|
|
68
68
|
def extract_entry(entry_path, output_path)
|
|
69
69
|
entry = find_entry(entry_path)
|
|
70
|
-
raise "Entry not found: #{entry_path}" unless entry
|
|
70
|
+
raise Errno::ENOENT, "Entry not found: #{entry_path}" unless entry
|
|
71
71
|
|
|
72
72
|
if entry.directory?
|
|
73
73
|
FileUtils.mkdir_p(output_path)
|
|
@@ -111,7 +111,7 @@ module Omnizip
|
|
|
111
111
|
loop do
|
|
112
112
|
@io.seek(sector * Iso::SECTOR_SIZE)
|
|
113
113
|
data = @io.read(Iso::SECTOR_SIZE)
|
|
114
|
-
raise "Failed to read volume descriptor" unless data
|
|
114
|
+
raise Omnizip::InvalidArchiveError, "Failed to read volume descriptor" unless data
|
|
115
115
|
|
|
116
116
|
vd = VolumeDescriptor.parse(data)
|
|
117
117
|
|
|
@@ -127,7 +127,7 @@ module Omnizip
|
|
|
127
127
|
|
|
128
128
|
return if @primary_volume_descriptor
|
|
129
129
|
|
|
130
|
-
raise "No primary volume descriptor found"
|
|
130
|
+
raise Omnizip::InvalidArchiveError, "No primary volume descriptor found"
|
|
131
131
|
end
|
|
132
132
|
|
|
133
133
|
# Parse directory structure
|
|
@@ -29,7 +29,7 @@ module Omnizip
|
|
|
29
29
|
#
|
|
30
30
|
# @param data [String] Binary data
|
|
31
31
|
def parse(data)
|
|
32
|
-
raise "Invalid volume descriptor size" unless data.bytesize >= 2048
|
|
32
|
+
raise Omnizip::InvalidArchiveError, "Invalid volume descriptor size" unless data.bytesize >= 2048
|
|
33
33
|
|
|
34
34
|
# Byte 0: Volume descriptor type
|
|
35
35
|
@type = data.getbyte(0)
|
|
@@ -37,7 +37,7 @@ module Omnizip
|
|
|
37
37
|
# Bytes 1-5: Standard identifier "CD001"
|
|
38
38
|
@identifier = data[1, 5]
|
|
39
39
|
unless @identifier == ISO_IDENTIFIER
|
|
40
|
-
raise "Invalid ISO identifier: expected #{ISO_IDENTIFIER}, got #{@identifier}"
|
|
40
|
+
raise Omnizip::InvalidArchiveError, "Invalid ISO identifier: expected #{ISO_IDENTIFIER}, got #{@identifier}"
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
# Byte 6: Version (should be 1)
|
|
@@ -230,26 +230,6 @@ module Omnizip
|
|
|
230
230
|
# Decode as 8 direct bits
|
|
231
231
|
@range_decoder.decode_direct_bits(8)
|
|
232
232
|
end
|
|
233
|
-
|
|
234
|
-
# Decode RAR-specific escape code
|
|
235
|
-
#
|
|
236
|
-
# RAR variant H uses different escape code values
|
|
237
|
-
# and handling compared to standard PPMd7.
|
|
238
|
-
#
|
|
239
|
-
# Escape codes in RAR:
|
|
240
|
-
# - 0: New symbol follows
|
|
241
|
-
# - 1: Same as last symbol (run-length)
|
|
242
|
-
# - 2-255: Reserved for future use
|
|
243
|
-
#
|
|
244
|
-
# @return [Integer, nil] Escape code or nil
|
|
245
|
-
def decode_escape_code
|
|
246
|
-
# RAR escape codes differ from PPMd7
|
|
247
|
-
# This is a placeholder for the proper implementation
|
|
248
|
-
|
|
249
|
-
# For now, return 0 (new symbol follows)
|
|
250
|
-
# Real implementation would decode from range coder
|
|
251
|
-
0
|
|
252
|
-
end
|
|
253
233
|
end
|
|
254
234
|
end
|
|
255
235
|
end
|
|
@@ -61,7 +61,7 @@ module Omnizip
|
|
|
61
61
|
# @param password [String, nil] Optional password
|
|
62
62
|
# @raise [RuntimeError] if extraction fails
|
|
63
63
|
def extract(archive_path, output_dir, password: nil)
|
|
64
|
-
raise "RAR extraction not available" unless available?
|
|
64
|
+
raise Omnizip::RarNotAvailableError, "RAR extraction not available" unless available?
|
|
65
65
|
|
|
66
66
|
if gem_available?
|
|
67
67
|
extract_with_gem(archive_path, output_dir, password)
|
|
@@ -78,7 +78,7 @@ module Omnizip
|
|
|
78
78
|
# @return [Array<Hash>] Entry information
|
|
79
79
|
# @raise [RuntimeError] if listing fails
|
|
80
80
|
def list(archive_path)
|
|
81
|
-
raise "RAR extraction not available" unless available?
|
|
81
|
+
raise Omnizip::RarNotAvailableError, "RAR extraction not available" unless available?
|
|
82
82
|
|
|
83
83
|
if gem_available?
|
|
84
84
|
list_with_gem(archive_path)
|
|
@@ -97,7 +97,7 @@ module Omnizip
|
|
|
97
97
|
# @param password [String, nil] Optional password
|
|
98
98
|
def extract_entry(archive_path, entry_name, output_path,
|
|
99
99
|
password: nil)
|
|
100
|
-
raise "RAR extraction not available" unless available?
|
|
100
|
+
raise Omnizip::RarNotAvailableError, "RAR extraction not available" unless available?
|
|
101
101
|
|
|
102
102
|
if gem_available?
|
|
103
103
|
extract_entry_with_gem(archive_path, entry_name,
|
|
@@ -185,7 +185,7 @@ module Omnizip
|
|
|
185
185
|
require "unrar"
|
|
186
186
|
Unrar.extract(archive_path, output_dir, password: password)
|
|
187
187
|
rescue StandardError => e
|
|
188
|
-
raise "Gem extraction failed: #{e.message}"
|
|
188
|
+
raise Omnizip::DecompressionError, "Gem extraction failed: #{e.message}"
|
|
189
189
|
end
|
|
190
190
|
|
|
191
191
|
# Extract with system command
|
|
@@ -193,7 +193,7 @@ module Omnizip
|
|
|
193
193
|
cmd = build_extract_command(archive_path, output_dir, password)
|
|
194
194
|
return if system(*cmd)
|
|
195
195
|
|
|
196
|
-
raise "Command extraction failed: #{archive_path}"
|
|
196
|
+
raise Omnizip::DecompressionError, "Command extraction failed: #{archive_path}"
|
|
197
197
|
end
|
|
198
198
|
|
|
199
199
|
# List with unrar gem
|
|
@@ -210,13 +210,13 @@ module Omnizip
|
|
|
210
210
|
}
|
|
211
211
|
end
|
|
212
212
|
rescue StandardError => e
|
|
213
|
-
raise "Gem listing failed: #{e.message}"
|
|
213
|
+
raise Omnizip::DecompressionError, "Gem listing failed: #{e.message}"
|
|
214
214
|
end
|
|
215
215
|
|
|
216
216
|
# List with system command
|
|
217
217
|
def list_with_command(archive_path)
|
|
218
218
|
output = `"#{command_path}" vb "#{archive_path}" 2>&1`
|
|
219
|
-
raise "Command listing failed" unless $CHILD_STATUS.success?
|
|
219
|
+
raise Omnizip::DecompressionError, "Command listing failed" unless $CHILD_STATUS.success?
|
|
220
220
|
|
|
221
221
|
output.split("\n").map do |line|
|
|
222
222
|
{ name: line.strip, size: 0, compressed_size: 0,
|
|
@@ -64,7 +64,7 @@ module Omnizip
|
|
|
64
64
|
def extract_entry(entry_name, output_path, password: nil)
|
|
65
65
|
ensure_open
|
|
66
66
|
entry = @entries.find { |e| e.name == entry_name }
|
|
67
|
-
raise "Entry not found: #{entry_name}" unless entry
|
|
67
|
+
raise Errno::ENOENT, "Entry not found: #{entry_name}" unless entry
|
|
68
68
|
|
|
69
69
|
# Create directory if needed
|
|
70
70
|
FileUtils.mkdir_p(File.dirname(output_path))
|
|
@@ -150,7 +150,7 @@ module Omnizip
|
|
|
150
150
|
def parse_archive(io)
|
|
151
151
|
# Read and validate header
|
|
152
152
|
@header = Header.read(io)
|
|
153
|
-
raise "Invalid RAR archive" unless @header.valid?
|
|
153
|
+
raise Omnizip::InvalidArchiveError, "Invalid RAR archive" unless @header.valid?
|
|
154
154
|
|
|
155
155
|
# Update archive info
|
|
156
156
|
@archive_info.version = @header.version
|
data/lib/omnizip/formats/rpm.rb
CHANGED
|
@@ -301,7 +301,7 @@ module Omnizip
|
|
|
301
301
|
#
|
|
302
302
|
# @param output_dir [String] Output directory
|
|
303
303
|
def extract(output_dir)
|
|
304
|
-
raise "RPM not opened" unless @file
|
|
304
|
+
raise Omnizip::IOError, "RPM not opened" unless @file
|
|
305
305
|
|
|
306
306
|
FileUtils.mkdir_p(output_dir)
|
|
307
307
|
|
|
@@ -322,7 +322,7 @@ module Omnizip
|
|
|
322
322
|
#
|
|
323
323
|
# @return [String] Raw compressed payload data
|
|
324
324
|
def raw_payload
|
|
325
|
-
raise "RPM not opened" unless @file
|
|
325
|
+
raise Omnizip::IOError, "RPM not opened" unless @file
|
|
326
326
|
|
|
327
327
|
payload_io = payload
|
|
328
328
|
payload_io.read
|
|
@@ -370,7 +370,7 @@ module Omnizip
|
|
|
370
370
|
end
|
|
371
371
|
|
|
372
372
|
def payload
|
|
373
|
-
raise "RPM not opened" unless @file
|
|
373
|
+
raise Omnizip::IOError, "RPM not opened" unless @file
|
|
374
374
|
|
|
375
375
|
# Calculate payload offset
|
|
376
376
|
offset = @lead.length
|
|
@@ -434,7 +434,7 @@ module Omnizip
|
|
|
434
434
|
|
|
435
435
|
output
|
|
436
436
|
rescue StandardError => e
|
|
437
|
-
raise "Failed to decompress with #{cmd}: #{e.message}"
|
|
437
|
+
raise Omnizip::DecompressionError, "Failed to decompress with #{cmd}: #{e.message}"
|
|
438
438
|
end
|
|
439
439
|
|
|
440
440
|
def extract_cpio(source, output_dir)
|
|
@@ -103,7 +103,7 @@ module Omnizip
|
|
|
103
103
|
end
|
|
104
104
|
|
|
105
105
|
entry = @entries.find { |e| e.name == entry_name }
|
|
106
|
-
raise "Entry not found: #{entry_name}" unless entry
|
|
106
|
+
raise Errno::ENOENT, "Entry not found: #{entry_name}" unless entry
|
|
107
107
|
|
|
108
108
|
# Create directory if needed
|
|
109
109
|
FileUtils.mkdir_p(File.dirname(output_path))
|
|
@@ -70,7 +70,7 @@ module Omnizip
|
|
|
70
70
|
# @raise [RuntimeError] if entry not found or extraction fails
|
|
71
71
|
def extract_entry(entry_name, output_path)
|
|
72
72
|
entry = @entries.find { |e| e.name == entry_name }
|
|
73
|
-
raise "Entry not found: #{entry_name}" unless entry
|
|
73
|
+
raise Errno::ENOENT, "Entry not found: #{entry_name}" unless entry
|
|
74
74
|
|
|
75
75
|
# Create directory if needed
|
|
76
76
|
FileUtils.mkdir_p(File.dirname(output_path))
|
|
@@ -203,7 +203,7 @@ module Omnizip
|
|
|
203
203
|
@file.seek(@header.header_size)
|
|
204
204
|
compressed_toc = @file.read(@header.toc_compressed_size)
|
|
205
205
|
|
|
206
|
-
raise "Failed to read TOC" unless compressed_toc
|
|
206
|
+
raise Omnizip::InvalidArchiveError, "Failed to read TOC" unless compressed_toc
|
|
207
207
|
|
|
208
208
|
# Parse TOC
|
|
209
209
|
@toc = Toc.parse(compressed_toc, @header.toc_uncompressed_size)
|
|
@@ -272,7 +272,7 @@ module Omnizip
|
|
|
272
272
|
inf.close
|
|
273
273
|
result
|
|
274
274
|
rescue Zlib::Error => e
|
|
275
|
-
raise "Failed to decompress data: #{e.message}"
|
|
275
|
+
raise Omnizip::DecompressionError, "Failed to decompress data: #{e.message}"
|
|
276
276
|
end
|
|
277
277
|
end
|
|
278
278
|
|
|
@@ -151,19 +151,94 @@ dereference_links: false)
|
|
|
151
151
|
read_entry_data(entry)
|
|
152
152
|
end
|
|
153
153
|
|
|
154
|
+
# Stream a single entry's decompressed content in +chunk_size+
|
|
155
|
+
# chunks, yielding each. STORE copies raw bytes and DEFLATE
|
|
156
|
+
# feeds an incremental inflater, so memory stays bounded by
|
|
157
|
+
# the chunk size; methods without an incremental decoder fall
|
|
158
|
+
# back to the whole-entry read (yielded as one chunk). The
|
|
159
|
+
# streamed path verifies the entry CRC after the last chunk.
|
|
160
|
+
#
|
|
161
|
+
# @param entry_name [String] Name of the entry to stream
|
|
162
|
+
# @param chunk_size [Integer] Read granularity in bytes
|
|
163
|
+
# @yield [chunk] Decompressed chunk
|
|
164
|
+
# @return [void]
|
|
165
|
+
def read_entry_stream(entry_name, chunk_size: 64 * 1024, &block)
|
|
166
|
+
ensure_read
|
|
167
|
+
entry = @entries.find { |e| e.filename == entry_name }
|
|
168
|
+
raise Errno::ENOENT, "Entry not found: #{entry_name}" unless entry
|
|
169
|
+
return if entry.directory? || entry.symlink?
|
|
170
|
+
|
|
171
|
+
crc = Omnizip::Checksums::Crc32.new
|
|
172
|
+
streamed =
|
|
173
|
+
File.open(file_path, "rb") do |io|
|
|
174
|
+
io.seek(entry_data_offset(io, entry), ::IO::SEEK_SET)
|
|
175
|
+
|
|
176
|
+
# Bound reads by the entry payload — what follows it in
|
|
177
|
+
# the file is the central directory, not entry data
|
|
178
|
+
remaining = entry.compressed_size
|
|
179
|
+
|
|
180
|
+
case entry.compression_method
|
|
181
|
+
when COMPRESSION_STORE
|
|
182
|
+
stream_raw(io, chunk_size, crc, remaining, &block)
|
|
183
|
+
when COMPRESSION_DEFLATE
|
|
184
|
+
stream_deflate(io, chunk_size, crc, remaining, &block)
|
|
185
|
+
else
|
|
186
|
+
yield(read_entry_data(entry))
|
|
187
|
+
false
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
return unless streamed
|
|
192
|
+
|
|
193
|
+
if crc.finalize != entry.crc32
|
|
194
|
+
raise Omnizip::ChecksumError,
|
|
195
|
+
"CRC mismatch for #{entry.filename}"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
154
199
|
private
|
|
155
200
|
|
|
201
|
+
def stream_raw(io, chunk_size, crc, remaining)
|
|
202
|
+
while remaining.positive? &&
|
|
203
|
+
(chunk = io.read([chunk_size, remaining].min))
|
|
204
|
+
break if chunk.empty?
|
|
205
|
+
|
|
206
|
+
remaining -= chunk.bytesize
|
|
207
|
+
crc.update(chunk)
|
|
208
|
+
yield chunk
|
|
209
|
+
end
|
|
210
|
+
true
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def stream_deflate(io, chunk_size, crc, remaining, &block)
|
|
214
|
+
require "zlib"
|
|
215
|
+
inflater = Zlib::Inflate.new(-Zlib::MAX_WBITS)
|
|
216
|
+
begin
|
|
217
|
+
while remaining.positive? &&
|
|
218
|
+
(chunk = io.read([chunk_size, remaining].min))
|
|
219
|
+
break if chunk.empty?
|
|
220
|
+
|
|
221
|
+
remaining -= chunk.bytesize
|
|
222
|
+
emit_inflated(inflater.inflate(chunk), crc, &block)
|
|
223
|
+
end
|
|
224
|
+
emit_inflated(inflater.finish, crc, &block)
|
|
225
|
+
ensure
|
|
226
|
+
inflater.close
|
|
227
|
+
end
|
|
228
|
+
true
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def emit_inflated(out, crc)
|
|
232
|
+
return if out.nil? || out.empty?
|
|
233
|
+
|
|
234
|
+
crc.update(out)
|
|
235
|
+
yield out
|
|
236
|
+
end
|
|
237
|
+
|
|
156
238
|
# Decompress and CRC-verify a single entry's data
|
|
157
239
|
def read_entry_data(entry)
|
|
158
240
|
File.open(file_path, "rb") do |io|
|
|
159
|
-
io.seek(entry
|
|
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)
|
|
241
|
+
io.seek(entry_data_offset(io, entry), ::IO::SEEK_SET)
|
|
167
242
|
|
|
168
243
|
compressed_data = io.read(entry.compressed_size)
|
|
169
244
|
decompressed_data = decompress_data(
|
|
@@ -184,6 +259,19 @@ dereference_links: false)
|
|
|
184
259
|
end
|
|
185
260
|
end
|
|
186
261
|
|
|
262
|
+
# File offset of an entry's compressed payload: the local
|
|
263
|
+
# header's fixed 30 bytes plus its filename/extra fields.
|
|
264
|
+
def entry_data_offset(io, entry)
|
|
265
|
+
io.seek(entry.local_header_offset, ::IO::SEEK_SET)
|
|
266
|
+
|
|
267
|
+
fixed_header = io.read(30)
|
|
268
|
+
_signature, _version, _flags, _method, _time, _date, _crc32,
|
|
269
|
+
_comp_size, _uncomp_size, filename_length, extra_length = fixed_header.unpack("VvvvvvVVVvv")
|
|
270
|
+
|
|
271
|
+
io.read(filename_length + extra_length)
|
|
272
|
+
io.pos
|
|
273
|
+
end
|
|
274
|
+
|
|
187
275
|
# Parse the central directory once, on demand
|
|
188
276
|
def ensure_read
|
|
189
277
|
read if @entries.empty? && @central_directory.empty?
|