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.
@@ -1,26 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- begin
4
- require "lutaml/model"
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
- # Reads RAR 3.x format archives, parsing headers and extracting file data
15
- # according to the RAR v3 specification.
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
- # entries = reader.read_archive(file)
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 format is invalid
40
+ # @raise [FormatError] If the archive signature is invalid
34
41
  def read_archive(io)
35
- unless verify_magic_bytes(io)
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
- entries = []
40
-
41
- # RAR4 marker is the signature itself (7 bytes): "Rar!\x1a\x07\x00"
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
- # Skip past archive header block (header + data)
53
- # SIZE field contains total block size
54
- block_end = first_block.header_start + first_block.size
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
- # Read a block header
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: filename,
234
- compressed_size: packed_size,
235
- uncompressed_size: unpacked_size,
236
- crc32: file_crc,
237
- compression_method: compression_method_name(method),
238
- modified_time: mtime,
239
- attributes: attr,
240
- encrypted: block.flags.anybits?(0x0004), # encrypted flag
241
- data_offset: data_offset,
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
- # Decode filename based on encoding flags
248
- #
249
- # @param bytes [String] The filename bytes
250
- # @param flags [Integer] The file flags
251
- # @return [String] The decoded filename
252
- def decode_filename(bytes, flags)
253
- if flags.nobits?(spec.format.file_flags[:unicode])
254
- # Legacy encoding - assume CP437 or system encoding
255
- bytes.force_encoding("CP437").encode("UTF-8", invalid: :replace)
256
- else
257
- # Unicode filename - decode UTF-8
258
- bytes.force_encoding("UTF-8")
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