omnizip 0.3.39 → 0.3.41
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 +102 -0
- data/README.adoc +31 -0
- data/lib/omnizip/algorithm_registry.rb +7 -0
- data/lib/omnizip/algorithms/deflate64.rb +5 -8
- data/lib/omnizip/buffer/memory_extractor.rb +31 -6
- data/lib/omnizip/buffer/seven_zip_bridge.rb +154 -0
- data/lib/omnizip/buffer.rb +6 -5
- data/lib/omnizip/commands/archive_create_command.rb +18 -25
- data/lib/omnizip/commands/decompress_command.rb +29 -1
- data/lib/omnizip/commands/metadata_command.rb +67 -3
- data/lib/omnizip/converter/seven_zip_to_zip_strategy.rb +14 -52
- data/lib/omnizip/converter/zip_to_seven_zip_strategy.rb +21 -63
- data/lib/omnizip/formats/rar/block_parser.rb +42 -6
- data/lib/omnizip/formats/rar/decompressor.rb +35 -8
- data/lib/omnizip/formats/rar/header.rb +18 -1
- data/lib/omnizip/formats/rar/models/rar_entry.rb +1 -1
- data/lib/omnizip/formats/rar/reader.rb +17 -22
- data/lib/omnizip/formats/rar/writer.rb +1 -1
- data/lib/omnizip/formats/rar.rb +31 -0
- data/lib/omnizip/formats/rar3/reader.rb +66 -314
- data/lib/omnizip/formats/rar5/reader.rb +80 -297
- data/lib/omnizip/formats/rar5/writer.rb +33 -180
- data/lib/omnizip/formats/seven_zip/writer.rb +17 -0
- data/lib/omnizip/version.rb +1 -1
- data/readme-docs/api-usage.adoc +1 -1
- data/readme-docs/architecture.adoc +1 -1
- data/readme-docs/compression-algorithms.adoc +1 -1
- data/readme-docs/installation.adoc +1 -1
- data/readme-docs/performance-profiler.adoc +1 -1
- metadata +3 -2
|
@@ -1,26 +1,33 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
rescue LoadError, ArgumentError
|
|
6
|
-
# lutaml-model not available, using simple classes
|
|
7
|
-
end
|
|
3
|
+
require "stringio"
|
|
4
|
+
require "tmpdir"
|
|
8
5
|
|
|
9
6
|
module Omnizip
|
|
10
7
|
module Formats
|
|
11
8
|
module Rar3
|
|
12
|
-
# RAR v3 archive reader
|
|
9
|
+
# RAR v3 (RAR4-family) archive reader
|
|
13
10
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
11
|
+
# Adapter over the primary `Formats::Rar::Reader` — the one
|
|
12
|
+
# parser verified against real WinRAR archives — exposing the
|
|
13
|
+
# legacy entry shape (uncompressed_size, modified_time, ...).
|
|
16
14
|
#
|
|
17
15
|
# @example Reading a RAR3 archive
|
|
18
16
|
# reader = Rar3::Reader.new
|
|
19
17
|
# File.open("archive.rar", "rb") do |file|
|
|
20
|
-
#
|
|
21
|
-
# entries.each { |entry| puts entry.name }
|
|
18
|
+
# reader.read_archive(file).each { |entry| puts entry.name }
|
|
22
19
|
# end
|
|
23
|
-
class Reader < Rar::RarFormatBase
|
|
20
|
+
class Reader < Omnizip::Formats::Rar::RarFormatBase
|
|
21
|
+
# RAR4 method byte to the legacy symbol vocabulary
|
|
22
|
+
METHOD_SYMBOLS = {
|
|
23
|
+
0x30 => :store,
|
|
24
|
+
0x31 => :fastest,
|
|
25
|
+
0x32 => :fast,
|
|
26
|
+
0x33 => :normal,
|
|
27
|
+
0x34 => :good,
|
|
28
|
+
0x35 => :best,
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
24
31
|
# Initialize a RAR v3 reader
|
|
25
32
|
def initialize
|
|
26
33
|
super("rar3")
|
|
@@ -30,325 +37,70 @@ module Omnizip
|
|
|
30
37
|
#
|
|
31
38
|
# @param io [IO] The input stream
|
|
32
39
|
# @return [Array<Entry>] The archive entries
|
|
33
|
-
# @raise [FormatError] If the archive
|
|
40
|
+
# @raise [FormatError] If the archive signature is invalid
|
|
34
41
|
def read_archive(io)
|
|
35
|
-
|
|
42
|
+
data = io.read
|
|
43
|
+
unless verify_magic_bytes(StringIO.new(data))
|
|
36
44
|
raise FormatError, "Invalid RAR v3 signature"
|
|
37
45
|
end
|
|
38
46
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
# Skip past the marker to read the archive header
|
|
43
|
-
io.seek(7, ::IO::SEEK_SET)
|
|
44
|
-
|
|
45
|
-
# Read first block - could be archive header or file block (for minimal archives)
|
|
46
|
-
first_block = read_block_header(io)
|
|
47
|
-
|
|
48
|
-
if first_block.type == block_type_code(:archive)
|
|
49
|
-
# Standard archive with archive header
|
|
50
|
-
@archive_flags = first_block.flags
|
|
47
|
+
Dir.mktmpdir("omnizip_rar3_read") do |tmp|
|
|
48
|
+
archive_path = File.join(tmp, "archive.rar")
|
|
49
|
+
File.binwrite(archive_path, data)
|
|
51
50
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
io.seek(block_end, ::IO::SEEK_SET)
|
|
56
|
-
elsif first_block.type == block_type_code(:file)
|
|
57
|
-
# Minimal archive without archive header - process as file block
|
|
58
|
-
entry = read_file_entry(io, first_block)
|
|
59
|
-
entries << entry if entry
|
|
60
|
-
else
|
|
61
|
-
raise FormatError,
|
|
62
|
-
"Expected archive header or file header, got type #{first_block.type}"
|
|
51
|
+
primary = Rar::Reader.new(archive_path)
|
|
52
|
+
primary.open
|
|
53
|
+
primary.list_files.map { |entry| adapt_entry(entry) }
|
|
63
54
|
end
|
|
64
|
-
|
|
65
|
-
# Read file blocks until end
|
|
66
|
-
loop do
|
|
67
|
-
block = read_block_header(io)
|
|
68
|
-
break if block.type == block_type_code(:terminator)
|
|
69
|
-
|
|
70
|
-
case block.type
|
|
71
|
-
when block_type_code(:file)
|
|
72
|
-
entry = read_file_entry(io, block)
|
|
73
|
-
entries << entry if entry
|
|
74
|
-
when block_type_code(:comment)
|
|
75
|
-
read_comment_block(io, block)
|
|
76
|
-
when block_type_code(:recovery)
|
|
77
|
-
skip_block_data(io, block)
|
|
78
|
-
else
|
|
79
|
-
skip_block_data(io, block)
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
entries
|
|
84
|
-
rescue EOFError, FormatError
|
|
85
|
-
# Handle truncated or malformed files gracefully
|
|
86
|
-
entries
|
|
87
55
|
end
|
|
88
56
|
|
|
89
57
|
private
|
|
90
58
|
|
|
91
|
-
#
|
|
92
|
-
|
|
93
|
-
# @param io [IO] The input stream
|
|
94
|
-
# @return [BlockHeader] The block header
|
|
95
|
-
def read_block_header(io)
|
|
96
|
-
# Record position BEFORE reading header
|
|
97
|
-
header_start = io.pos
|
|
98
|
-
|
|
99
|
-
header_crc = io.read(2)&.unpack1("v")
|
|
100
|
-
type = io.read(1)&.unpack1("C")
|
|
101
|
-
flags = io.read(2)&.unpack1("v")
|
|
102
|
-
size = io.read(2)&.unpack1("v")
|
|
103
|
-
|
|
104
|
-
raise FormatError, "Unexpected EOF" unless size
|
|
105
|
-
|
|
106
|
-
# For FILE blocks, the SIZE field directly contains the total header size
|
|
107
|
-
# The file_header structure starts immediately after the 7-byte block header
|
|
108
|
-
# No additional 4-byte field needed for FILE blocks
|
|
109
|
-
header_size = 7 # CRC(2) + TYPE(1) + FLAGS(2) + SIZE(2)
|
|
110
|
-
|
|
111
|
-
BlockHeader.new(
|
|
112
|
-
crc: header_crc,
|
|
113
|
-
type: type,
|
|
114
|
-
flags: flags,
|
|
115
|
-
size: size,
|
|
116
|
-
header_start: header_start,
|
|
117
|
-
header_size: header_size,
|
|
118
|
-
)
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
# Skip to the data portion of a block (after header)
|
|
122
|
-
#
|
|
123
|
-
# @param io [IO] The input stream
|
|
124
|
-
# @param block [BlockHeader] The block header
|
|
125
|
-
def skip_to_block_data(io, block)
|
|
126
|
-
target_pos = block.header_start + block.header_size
|
|
127
|
-
current_pos = io.pos
|
|
128
|
-
if target_pos > current_pos
|
|
129
|
-
io.seek(target_pos, ::IO::SEEK_SET)
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
# Read a file entry from archive
|
|
134
|
-
#
|
|
135
|
-
# @param io [IO] The input stream
|
|
136
|
-
# @param block [BlockHeader] The file block header
|
|
137
|
-
# @return [Entry] The file entry
|
|
138
|
-
def read_file_entry(io, block)
|
|
139
|
-
# The SIZE field contains the total size of the block header (including data after 7-byte prefix)
|
|
140
|
-
# The data portion after the 7-byte block header is: size - 7 bytes
|
|
141
|
-
block.header_start + 7 # Start of header data after 7-byte prefix
|
|
142
|
-
header_data_size = block.size - 7
|
|
143
|
-
|
|
144
|
-
# Read all header data at once
|
|
145
|
-
header_data = io.read(header_data_size)
|
|
146
|
-
unless header_data
|
|
147
|
-
raise FormatError,
|
|
148
|
-
"Unexpected EOF reading file header"
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
# Now parse the file_header from the start of header_data
|
|
152
|
-
pos = 0
|
|
153
|
-
|
|
154
|
-
packed_size = header_data[pos, 4].unpack1("V")
|
|
155
|
-
pos += 4
|
|
156
|
-
|
|
157
|
-
unpacked_size = header_data[pos, 4].unpack1("V")
|
|
158
|
-
pos += 4
|
|
159
|
-
|
|
160
|
-
host_os = header_data[pos, 1].unpack1("C")
|
|
161
|
-
pos += 1
|
|
162
|
-
|
|
163
|
-
file_crc = header_data[pos, 4].unpack1("V")
|
|
164
|
-
pos += 4
|
|
165
|
-
|
|
166
|
-
file_time = header_data[pos, 4].unpack1("V")
|
|
167
|
-
pos += 4
|
|
168
|
-
|
|
169
|
-
header_data[pos, 1].unpack1("C")
|
|
170
|
-
pos += 1
|
|
171
|
-
|
|
172
|
-
method = header_data[pos, 1].unpack1("C")
|
|
173
|
-
pos += 1
|
|
174
|
-
|
|
175
|
-
name_size = header_data[pos, 2].unpack1("v")
|
|
176
|
-
pos += 2
|
|
177
|
-
|
|
178
|
-
attr = header_data[pos, 4].unpack1("V")
|
|
179
|
-
pos += 4
|
|
180
|
-
|
|
181
|
-
# High size words precede the filename per the RAR4 layout
|
|
182
|
-
if block.flags & 0x0100 != 0 # large_file flag
|
|
183
|
-
high_packed = header_data[pos, 4].unpack1("V")
|
|
184
|
-
high_unpacked = header_data[pos + 4, 4].unpack1("V")
|
|
185
|
-
packed_size |= (high_packed << 32)
|
|
186
|
-
unpacked_size |= (high_unpacked << 32)
|
|
187
|
-
pos += 8
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
# Read filename
|
|
191
|
-
name_bytes = header_data[pos, name_size]
|
|
192
|
-
pos += name_size
|
|
193
|
-
filename = decode_filename(name_bytes, block.flags)
|
|
194
|
-
|
|
195
|
-
# Read salt if encrypted
|
|
196
|
-
salt = nil
|
|
197
|
-
if block.flags & 0x0400 != 0 # salt flag
|
|
198
|
-
salt = header_data[pos, 8]
|
|
199
|
-
pos + 8
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
# Base DOS time from the file header (minute precision).
|
|
203
|
-
# The RAR4 extended-time record (0x1000 flag) is a
|
|
204
|
-
# variable-length nibble format that is not parsed; the
|
|
205
|
-
# base time is authoritative for minute-granularity mtimes.
|
|
206
|
-
mtime = parse_dos_time(file_time)
|
|
207
|
-
|
|
208
|
-
# We've already read all header data, no need to seek
|
|
209
|
-
# Just skip to end of block and then past file data
|
|
210
|
-
|
|
211
|
-
# Record data offset (start of file data)
|
|
212
|
-
data_offset = block.header_start + block.size
|
|
213
|
-
|
|
214
|
-
# Current position should be at end of header data
|
|
215
|
-
# Skip past file data to prepare for next block
|
|
216
|
-
# Use read instead of seek for better compatibility with non-seekable streams
|
|
217
|
-
if packed_size.positive?
|
|
218
|
-
begin
|
|
219
|
-
io.seek(packed_size, ::IO::SEEK_CUR)
|
|
220
|
-
rescue Errno::EINVAL, Errno::ESPIPE
|
|
221
|
-
# Stream doesn't support seeking - read and discard instead
|
|
222
|
-
remaining = packed_size
|
|
223
|
-
while remaining.positive?
|
|
224
|
-
chunk = io.read([remaining, 8192].min)
|
|
225
|
-
break unless chunk
|
|
226
|
-
|
|
227
|
-
remaining -= chunk.bytesize
|
|
228
|
-
end
|
|
229
|
-
end
|
|
230
|
-
end
|
|
231
|
-
|
|
59
|
+
# Map a primary RarEntry onto the legacy Entry shape
|
|
60
|
+
def adapt_entry(entry)
|
|
232
61
|
Entry.new(
|
|
233
|
-
name:
|
|
234
|
-
compressed_size:
|
|
235
|
-
uncompressed_size:
|
|
236
|
-
crc32:
|
|
237
|
-
compression_method:
|
|
238
|
-
modified_time: mtime,
|
|
239
|
-
attributes:
|
|
240
|
-
encrypted:
|
|
241
|
-
|
|
242
|
-
host_os: host_os,
|
|
243
|
-
salt: salt,
|
|
62
|
+
name: entry.name,
|
|
63
|
+
compressed_size: entry.compressed_size,
|
|
64
|
+
uncompressed_size: entry.size,
|
|
65
|
+
crc32: entry.crc,
|
|
66
|
+
compression_method: METHOD_SYMBOLS[entry.method],
|
|
67
|
+
modified_time: entry.mtime,
|
|
68
|
+
attributes: entry.attributes,
|
|
69
|
+
encrypted: entry.encrypted,
|
|
70
|
+
host_os: entry.host_os,
|
|
244
71
|
)
|
|
245
72
|
end
|
|
246
73
|
|
|
247
|
-
#
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
74
|
+
# RAR archive entry model (legacy shape)
|
|
75
|
+
class Entry
|
|
76
|
+
include Omnizip::Entry
|
|
77
|
+
|
|
78
|
+
attr_accessor :name, :compressed_size, :uncompressed_size, :crc32,
|
|
79
|
+
:compression_method, :modified_time, :attributes,
|
|
80
|
+
:encrypted, :data_offset, :host_os, :salt
|
|
81
|
+
|
|
82
|
+
def entry_name = name
|
|
83
|
+
def entry_directory? = false
|
|
84
|
+
def entry_size = uncompressed_size
|
|
85
|
+
def entry_mtime = modified_time
|
|
86
|
+
|
|
87
|
+
def initialize(name: nil, compressed_size: nil, uncompressed_size: nil,
|
|
88
|
+
crc32: nil, compression_method: nil, modified_time: nil,
|
|
89
|
+
attributes: nil, encrypted: nil, data_offset: nil,
|
|
90
|
+
host_os: nil, salt: nil)
|
|
91
|
+
@name = name
|
|
92
|
+
@compressed_size = compressed_size
|
|
93
|
+
@uncompressed_size = uncompressed_size
|
|
94
|
+
@crc32 = crc32
|
|
95
|
+
@compression_method = compression_method
|
|
96
|
+
@modified_time = modified_time
|
|
97
|
+
@attributes = attributes
|
|
98
|
+
@encrypted = encrypted
|
|
99
|
+
@data_offset = data_offset
|
|
100
|
+
@host_os = host_os
|
|
101
|
+
@salt = salt
|
|
259
102
|
end
|
|
260
103
|
end
|
|
261
|
-
|
|
262
|
-
# Parse DOS date/time to Ruby Time
|
|
263
|
-
#
|
|
264
|
-
# @param dos_time [Integer] The DOS timestamp
|
|
265
|
-
# @return [Time] The parsed time
|
|
266
|
-
def parse_dos_time(dos_time)
|
|
267
|
-
second = (dos_time & 0x1F) * 2
|
|
268
|
-
minute = (dos_time >> 5) & 0x3F
|
|
269
|
-
hour = (dos_time >> 11) & 0x1F
|
|
270
|
-
day = (dos_time >> 16) & 0x1F
|
|
271
|
-
month = (dos_time >> 21) & 0x0F
|
|
272
|
-
year = ((dos_time >> 25) & 0x7F) + 1980
|
|
273
|
-
|
|
274
|
-
Time.new(year, month, day, hour, minute, second)
|
|
275
|
-
rescue ArgumentError
|
|
276
|
-
Time.now
|
|
277
|
-
end
|
|
278
|
-
|
|
279
|
-
# Read comment block
|
|
280
|
-
#
|
|
281
|
-
# @param io [IO] The input stream
|
|
282
|
-
# @param block [BlockHeader] The block header
|
|
283
|
-
# @return [String] The comment text
|
|
284
|
-
def read_comment_block(io, block)
|
|
285
|
-
current_pos = io.pos
|
|
286
|
-
header_end = block.header_start + block.header_size
|
|
287
|
-
data_size = block.size - block.header_size
|
|
288
|
-
remaining = data_size - (current_pos - header_end)
|
|
289
|
-
comment = io.read(remaining) if remaining.positive?
|
|
290
|
-
comment&.force_encoding("UTF-8")
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
# Skip block data
|
|
294
|
-
#
|
|
295
|
-
# @param io [IO] The input stream
|
|
296
|
-
# @param block [BlockHeader] The block header
|
|
297
|
-
# @return [void]
|
|
298
|
-
def skip_block_data(io, block)
|
|
299
|
-
# block.size is the total size, calculate remaining bytes
|
|
300
|
-
current_pos = io.pos
|
|
301
|
-
header_end = block.header_start + block.header_size
|
|
302
|
-
data_size = block.size - block.header_size
|
|
303
|
-
remaining = data_size - (current_pos - header_end)
|
|
304
|
-
io.seek(remaining, ::IO::SEEK_CUR) if remaining.positive?
|
|
305
|
-
end
|
|
306
|
-
end
|
|
307
|
-
|
|
308
|
-
# RAR v3 block header model
|
|
309
|
-
class BlockHeader
|
|
310
|
-
attr_accessor :crc, :type, :flags, :size, :header_start, :header_size
|
|
311
|
-
|
|
312
|
-
def initialize(crc: nil, type: nil, flags: nil, size: nil,
|
|
313
|
-
header_start: nil, header_size: 7)
|
|
314
|
-
@crc = crc
|
|
315
|
-
@type = type
|
|
316
|
-
@flags = flags
|
|
317
|
-
@size = size
|
|
318
|
-
@header_start = header_start
|
|
319
|
-
@header_size = header_size
|
|
320
|
-
end
|
|
321
|
-
end
|
|
322
|
-
|
|
323
|
-
# RAR archive entry model
|
|
324
|
-
class Entry
|
|
325
|
-
include Omnizip::Entry
|
|
326
|
-
|
|
327
|
-
attr_accessor :name, :compressed_size, :uncompressed_size, :crc32,
|
|
328
|
-
:compression_method, :modified_time, :attributes,
|
|
329
|
-
:encrypted, :data_offset, :host_os, :salt
|
|
330
|
-
|
|
331
|
-
def entry_name = name
|
|
332
|
-
def entry_directory? = false
|
|
333
|
-
def entry_size = uncompressed_size
|
|
334
|
-
def entry_mtime = modified_time
|
|
335
|
-
|
|
336
|
-
def initialize(name: nil, compressed_size: nil, uncompressed_size: nil,
|
|
337
|
-
crc32: nil, compression_method: nil, modified_time: nil,
|
|
338
|
-
attributes: nil, encrypted: nil, data_offset: nil,
|
|
339
|
-
host_os: nil, salt: nil)
|
|
340
|
-
@name = name
|
|
341
|
-
@compressed_size = compressed_size
|
|
342
|
-
@uncompressed_size = uncompressed_size
|
|
343
|
-
@crc32 = crc32
|
|
344
|
-
@compression_method = compression_method
|
|
345
|
-
@modified_time = modified_time
|
|
346
|
-
@attributes = attributes
|
|
347
|
-
@encrypted = encrypted
|
|
348
|
-
@data_offset = data_offset
|
|
349
|
-
@host_os = host_os
|
|
350
|
-
@salt = salt
|
|
351
|
-
end
|
|
352
104
|
end
|
|
353
105
|
end
|
|
354
106
|
end
|