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,18 +1,16 @@
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 Rar5
12
9
  # RAR v5 archive reader
13
10
  #
14
- # Reads RAR 5.x format archives, parsing headers and extracting file data
15
- # according to the RAR v5 specification.
11
+ # Adapter over the primary `Formats::Rar::Reader` the one RAR5
12
+ # parser verified against real archives and unrar — exposing the
13
+ # legacy entry shape (uncompressed_size, modified_time, ...).
16
14
  #
17
15
  # RAR 5 uses variable-length integers (vint) and improved header structure.
18
16
  #
@@ -23,6 +21,17 @@ module Omnizip
23
21
  # entries.each { |entry| puts entry.name }
24
22
  # end
25
23
  class Reader < Omnizip::Formats::Rar::RarFormatBase
24
+ # RAR5 compression-method byte (cinfo bits 8-10) to the legacy
25
+ # symbol vocabulary
26
+ METHOD_SYMBOLS = {
27
+ 0x30 => :store,
28
+ 0x31 => :fastest,
29
+ 0x32 => :fast,
30
+ 0x33 => :normal,
31
+ 0x34 => :good,
32
+ 0x35 => :best,
33
+ }.freeze
34
+
26
35
  # Initialize a RAR v5 reader
27
36
  def initialize
28
37
  super("rar5")
@@ -32,314 +41,88 @@ module Omnizip
32
41
  #
33
42
  # @param io [IO] The input stream
34
43
  # @return [Array<Entry>] The archive entries
35
- # @raise [FormatError] If the archive format is invalid
44
+ # @raise [FormatError] If the archive signature is invalid
36
45
  def read_archive(io)
37
- unless verify_magic_bytes(io)
46
+ data = io.read
47
+ unless verify_magic_bytes(StringIO.new(data))
38
48
  raise FormatError, "Invalid RAR v5 signature"
39
49
  end
40
50
 
41
- # Skip past the magic bytes (8 bytes)
42
- io.seek(8, ::IO::SEEK_SET)
43
-
44
- entries = []
45
-
46
- # Read main header
47
- main_header = read_header(io)
48
- unless main_header.type == block_type_code(:main_header)
49
- raise FormatError, "Expected main archive header"
50
- end
51
-
52
- @archive_flags = main_header.flags
53
-
54
- # Skip to end of main header
55
- skip_to_header_end(io, main_header)
56
-
57
- # Read blocks until end marker or EOF/error
58
- loop do
59
- header = read_header(io)
60
- break if header.type == block_type_code(:end_marker)
51
+ Dir.mktmpdir("omnizip_rar5_read") do |tmp|
52
+ archive_path = File.join(tmp, "archive.rar")
53
+ File.binwrite(archive_path, data)
61
54
 
62
- case header.type
63
- when block_type_code(:file_header)
64
- entry = read_file_entry(io, header)
65
- entries << entry if entry
66
- when block_type_code(:service_header)
67
- skip_header_data(io, header)
68
- when block_type_code(:encryption_header)
69
- read_encryption_header(io, header)
70
- else
71
- skip_header_data(io, header)
72
- end
73
- rescue EOFError, RangeError, FormatError
74
- # Truncated or corrupted file - return what we have
75
- break
55
+ primary = Rar::Reader.new(archive_path)
56
+ primary.open
57
+ primary.list_files.map { |entry| adapt_entry(entry) }
76
58
  end
77
-
78
- entries
79
- rescue EOFError, RangeError, FormatError
80
- # Handle truncated/invalid files gracefully
81
- []
82
59
  end
83
60
 
84
61
  private
85
62
 
86
- # Skip to the end of a header
87
- #
88
- # @param io [IO] The input stream
89
- # @param header [HeaderBlock] The header
90
- def skip_to_header_end(io, header)
91
- # Calculate end of header: header_start + 4 (CRC) + header_size + vint_length
92
- header_end = header.header_start + 4 + header.size.to_i + header.vint_length.to_i
93
-
94
- # Validate the offset is reasonable (max 1GB)
95
- max_offset = 1_073_741_824
96
- raise RangeError, "Header offset too large" if header_end > max_offset
97
- raise RangeError, "Header offset negative" if header_end.negative?
98
-
99
- io.seek(header_end, ::IO::SEEK_SET)
100
- end
101
-
102
- # Read a RAR v5 header
103
- #
104
- # @param io [IO] The input stream
105
- # @return [HeaderBlock] The header block
106
- def read_header(io)
107
- # Record start position (before CRC)
108
- header_start = io.pos
109
-
110
- header_crc = io.read(4)&.unpack1("V")
111
-
112
- # Read header size vint and track its length
113
- header_size, vint_length = read_vint_with_length(io)
114
-
115
- # Now we're at the start of header content
116
- # header_size is the size of the content (from header_type to end)
117
- content_start = io.pos
118
-
119
- header_type = read_vint(io)
120
- header_flags = read_vint(io)
121
-
122
- # Read extra size if present
123
- extra_size = 0
124
- extra_size = read_vint(io) if header_flags & 0x0001 != 0
125
-
126
- # Read data size if present
127
- data_size = 0
128
- data_size = read_vint(io) if header_flags & 0x0002 != 0
129
-
130
- # Store positions for proper skipping
131
- HeaderBlock.new(
132
- crc: header_crc,
133
- size: header_size,
134
- vint_length: vint_length,
135
- type: header_type,
136
- flags: header_flags,
137
- extra_size: extra_size,
138
- data_size: data_size,
139
- header_start: header_start,
140
- content_start: content_start,
141
- )
142
- end
143
-
144
- # Read a vint and return both value and length
145
- #
146
- # @param io [IO] The input stream
147
- # @return [Array<Integer, Integer>] Value and length in bytes
148
- def read_vint_with_length(io)
149
- result = 0
150
- shift = 0
151
- length = 0
152
-
153
- loop do
154
- byte = io.read(1)&.unpack1("C")
155
- raise FormatError, "Unexpected EOF" unless byte
156
-
157
- length += 1
158
- result |= (byte & 0x7F) << shift
159
- break if byte.nobits?(0x80)
160
-
161
- shift += 7
162
- end
163
-
164
- [result, length]
165
- end
166
-
167
- # Read a file entry from archive
168
- #
169
- # @param io [IO] The input stream
170
- # @param header [HeaderBlock] The file header
171
- # @return [Entry] The file entry
172
- def read_file_entry(io, header)
173
- flags = read_vint(io)
174
- unpacked_size = read_vint(io)
175
- attributes = read_vint(io)
176
-
177
- # Read modification time if present; nil when the header
178
- # does not carry one
179
- mtime = nil
180
- if flags & 0x02 != 0 # time_present flag
181
- mtime = read_file_time(io)
182
- end
183
-
184
- # Read CRC32 if present
185
- data_crc = 0
186
- if flags & 0x04 != 0 # crc32_present flag
187
- data_crc = io.read(4)&.unpack1("V")
188
- end
189
-
190
- # Read compression info
191
- compression_flags = read_vint(io)
192
- host_os = read_vint(io)
193
- name_length = read_vint(io)
194
-
195
- # Read filename (UTF-8 encoded)
196
- name_bytes = io.read(name_length)
197
- filename = name_bytes.force_encoding("UTF-8")
198
-
199
- # Determine if directory
200
- is_directory = flags.anybits?(0x01) # directory flag
201
-
202
- # Skip to end of header
203
- skip_to_header_end(io, header)
204
-
205
- # Record data offset before skipping
206
- data_offset = io.pos
207
-
208
- # Skip file data (so we can read the next header)
209
- if header.data_size.to_i.positive?
210
- io.seek(header.data_size,
211
- ::IO::SEEK_CUR)
212
- end
213
-
63
+ # Map a primary RarEntry onto the legacy Entry shape
64
+ def adapt_entry(entry)
214
65
  Entry.new(
215
- name: filename,
216
- compressed_size: header.data_size || 0,
217
- uncompressed_size: unpacked_size,
218
- crc32: data_crc,
219
- compression_method: extract_compression_method(compression_flags),
220
- modified_time: mtime,
221
- attributes: attributes,
222
- encrypted: header.flags.anybits?(0x04),
223
- data_offset: data_offset,
224
- host_os: host_os,
225
- is_directory: is_directory,
66
+ name: entry.name,
67
+ compressed_size: entry.compressed_size,
68
+ uncompressed_size: entry.size,
69
+ crc32: entry.crc,
70
+ compression_method: METHOD_SYMBOLS[entry.method],
71
+ modified_time: entry.mtime,
72
+ attributes: entry.attributes,
73
+ encrypted: entry.encrypted,
74
+ host_os: entry.host_os,
75
+ is_directory: entry.is_dir,
226
76
  )
227
77
  end
228
78
 
229
- # Extract compression method from flags
230
- #
231
- # @param flags [Integer] The compression flags
232
- # @return [Symbol] The compression method
233
- def extract_compression_method(flags)
234
- method_code = flags & 0x07
235
-
236
- case method_code
237
- when 0 then :store
238
- when 1 then :fastest
239
- when 2 then :fast
240
- when 3 then :normal
241
- when 4 then :good
242
- when 5 then :best
243
- else :normal
79
+ class HeaderBlock
80
+ attr_accessor :crc, :size, :vint_length, :type, :flags, :extra_size,
81
+ :data_size, :header_start, :content_start
82
+
83
+ def initialize(crc: nil, size: nil, vint_length: 1, type: nil, flags: nil,
84
+ extra_size: nil, data_size: nil, header_start: nil, content_start: nil)
85
+ @crc = crc
86
+ @size = size
87
+ @vint_length = vint_length
88
+ @type = type
89
+ @flags = flags
90
+ @extra_size = extra_size
91
+ @data_size = data_size
92
+ @header_start = header_start
93
+ @content_start = content_start
244
94
  end
245
95
  end
246
96
 
247
- # Read file time in RAR5 format
248
- #
249
- # @param io [IO] The input stream
250
- # @return [Time] The file time
251
- # RAR5 file headers store a 32-bit Unix timestamp when the
252
- # mtime flag is set
253
- def read_file_time(io)
254
- unix_time = io.read(4)&.unpack1("V")
255
- Time.at(unix_time) if unix_time
256
- end
257
-
258
- # Read encryption header
259
- #
260
- # @param io [IO] The input stream
261
- # @param header [HeaderBlock] The header
262
- # @return [void]
263
- def read_encryption_header(io, header)
264
- # Read and skip encryption data
265
- skip_header_data(io, header)
266
- end
267
-
268
- # Skip header data
269
- #
270
- # @param io [IO] The input stream
271
- # @param header [HeaderBlock] The header
272
- # @return [void]
273
- def skip_header_data(io, header)
274
- # Calculate end of header: header_start + 4 (CRC) + header_size + vint_length
275
- header_end = header.header_start.to_i + 4 + header.size.to_i + header.vint_length.to_i
276
- current_pos = io.pos
277
-
278
- # Validate the offset is reasonable (max 1GB)
279
- max_offset = 1_073_741_824
280
- if header_end > max_offset || header_end.negative?
281
- raise RangeError, "Invalid header offset"
97
+ # RAR v5 archive entry model
98
+ class Entry
99
+ include Omnizip::Entry
100
+
101
+ attr_accessor :name, :compressed_size, :uncompressed_size, :crc32,
102
+ :compression_method, :modified_time, :attributes,
103
+ :encrypted, :data_offset, :host_os, :is_directory
104
+
105
+ def entry_name = name
106
+ def entry_directory? = is_directory
107
+ def entry_size = uncompressed_size
108
+ def entry_mtime = modified_time
109
+
110
+ def initialize(name: nil, compressed_size: nil, uncompressed_size: nil,
111
+ crc32: nil, compression_method: nil, modified_time: nil,
112
+ attributes: nil, encrypted: nil, data_offset: nil,
113
+ host_os: nil, is_directory: nil)
114
+ @name = name
115
+ @compressed_size = compressed_size
116
+ @uncompressed_size = uncompressed_size
117
+ @crc32 = crc32
118
+ @compression_method = compression_method
119
+ @modified_time = modified_time
120
+ @attributes = attributes
121
+ @encrypted = encrypted
122
+ @data_offset = data_offset
123
+ @host_os = host_os
124
+ @is_directory = is_directory
282
125
  end
283
-
284
- if header_end > current_pos
285
- io.seek(header_end - current_pos, ::IO::SEEK_CUR)
286
- end
287
-
288
- # Skip data section if present (with bounds checking)
289
- data_size = header.data_size.to_i
290
- if data_size.positive? && data_size < max_offset
291
- io.seek(data_size, ::IO::SEEK_CUR)
292
- end
293
- end
294
- end
295
-
296
- # RAR v5 header block model
297
- class HeaderBlock
298
- attr_accessor :crc, :size, :vint_length, :type, :flags, :extra_size,
299
- :data_size, :header_start, :content_start
300
-
301
- def initialize(crc: nil, size: nil, vint_length: 1, type: nil, flags: nil,
302
- extra_size: nil, data_size: nil, header_start: nil, content_start: nil)
303
- @crc = crc
304
- @size = size
305
- @vint_length = vint_length
306
- @type = type
307
- @flags = flags
308
- @extra_size = extra_size
309
- @data_size = data_size
310
- @header_start = header_start
311
- @content_start = content_start
312
- end
313
- end
314
-
315
- # RAR v5 archive entry model
316
- class Entry
317
- include Omnizip::Entry
318
-
319
- attr_accessor :name, :compressed_size, :uncompressed_size, :crc32,
320
- :compression_method, :modified_time, :attributes,
321
- :encrypted, :data_offset, :host_os, :is_directory
322
-
323
- def entry_name = name
324
- def entry_directory? = is_directory
325
- def entry_size = uncompressed_size
326
- def entry_mtime = modified_time
327
-
328
- def initialize(name: nil, compressed_size: nil, uncompressed_size: nil,
329
- crc32: nil, compression_method: nil, modified_time: nil,
330
- attributes: nil, encrypted: nil, data_offset: nil,
331
- host_os: nil, is_directory: nil)
332
- @name = name
333
- @compressed_size = compressed_size
334
- @uncompressed_size = uncompressed_size
335
- @crc32 = crc32
336
- @compression_method = compression_method
337
- @modified_time = modified_time
338
- @attributes = attributes
339
- @encrypted = encrypted
340
- @data_offset = data_offset
341
- @host_os = host_os
342
- @is_directory = is_directory
343
126
  end
344
127
  end
345
128
  end
@@ -1,23 +1,13 @@
1
- # frozen_string_literal: true
1
+ # frozen_string: true
2
2
 
3
- begin
4
- require "lutaml/model"
5
- rescue LoadError, ArgumentError
6
- # lutaml-model not available, using simple classes
7
- end
8
-
9
- require "stringio"
3
+ require "fileutils"
4
+ require "tmpdir"
10
5
 
11
6
  module Omnizip
12
7
  module Formats
13
8
  module Rar5
14
9
  # RAR v5 archive writer
15
10
  #
16
- # Writes RAR 5.x format archives, creating headers and compressing file
17
- # data according to the RAR v5 specification.
18
- #
19
- # RAR 5 uses variable-length integers and improved header structure.
20
- #
21
11
  # @example Writing a RAR5 archive
22
12
  # writer = Rar5::Writer.new
23
13
  # File.open("archive.rar", "wb") do |file|
@@ -26,185 +16,48 @@ module Omnizip
26
16
  # ]
27
17
  # writer.write_archive(file, entries)
28
18
  # end
29
- class Writer < Rar::RarFormatBase
19
+ class Writer < Omnizip::Formats::Rar::RarFormatBase
30
20
  # Initialize a RAR v5 writer
31
21
  def initialize
32
22
  super("rar5")
33
- @compressor = Compressor.new
34
23
  end
35
24
 
36
25
  # Write a RAR v5 archive
37
26
  #
27
+ # Delegates to the primary `Formats::Rar::Rar5::Writer` so the
28
+ # output is spec-conformant and unrar-verified: entries spill
29
+ # to a temporary file, the primary writer produces the
30
+ # archive, and the bytes are copied into the given IO.
31
+ # Compression methods above :store are not official-RAR
32
+ # compatible, so the primary writer stores them (with a
33
+ # warning) rather than emitting an undecodable stream.
34
+ #
38
35
  # @param io [IO] The output stream
39
36
  # @param entries [Array<Hash>] The entries to write
40
37
  # @return [void]
41
38
  def write_archive(io, entries)
42
- # Write magic bytes
43
- io.write(spec.magic_bytes.pack("C*"))
44
-
45
- # Write main header
46
- write_main_header(io)
47
-
48
- # Write file entries
49
- entries.each do |entry|
50
- write_file_entry(io, entry)
39
+ Dir.mktmpdir("omnizip_rar5_write") do |tmp|
40
+ archive_path = File.join(tmp, "archive.rar")
41
+ primary = Rar::Rar5::Writer.new(archive_path,
42
+ include_mtime: true,
43
+ include_crc32: true)
44
+
45
+ entries.each do |entry|
46
+ name = entry[:name] || entry["name"]
47
+ data = entry[:data] || entry["data"]
48
+ mtime = entry[:time] || entry["time"] || Time.now
49
+
50
+ file_path = File.join(tmp, "entries", name)
51
+ FileUtils.mkdir_p(File.dirname(file_path))
52
+ File.binwrite(file_path, data.to_s)
53
+ File.utime(mtime, mtime, file_path)
54
+
55
+ primary.add_file(file_path, name)
56
+ end
57
+
58
+ primary.write
59
+ io.write(File.binread(archive_path))
51
60
  end
52
-
53
- # Write end marker
54
- write_end_marker(io)
55
- end
56
-
57
- private
58
-
59
- # Write main archive header
60
- #
61
- # @param io [IO] The output stream
62
- # @return [void]
63
- def write_main_header(io)
64
- flags = 0
65
-
66
- header_data = StringIO.new
67
- write_vint(header_data, flags)
68
-
69
- write_header(
70
- io,
71
- type: block_type_code(:main_header),
72
- flags: 0,
73
- data: header_data.string,
74
- )
75
- end
76
-
77
- # Write a file entry
78
- #
79
- # @param io [IO] The output stream
80
- # @param entry [Hash] The entry data
81
- # @return [void]
82
- def write_file_entry(io, entry)
83
- name = entry[:name] || entry["name"]
84
- data = entry[:data] || entry["data"]
85
- mtime = entry[:time] || entry["time"] || Time.now
86
- method = entry[:method] || entry["method"] || :normal
87
-
88
- # Compress data
89
- compressed_data = @compressor.compress(data, method: method)
90
-
91
- # Calculate sizes and CRC
92
- compressed_data.bytesize
93
- unpacked_size = data.bytesize
94
- file_crc = calculate_crc32(data)
95
-
96
- # Encode filename to UTF-8
97
- name_bytes = name.encode("UTF-8")
98
- name_length = name_bytes.bytesize
99
-
100
- # Build file header data
101
- file_flags = spec.format.file_header_flags[:time_present] |
102
- spec.format.file_header_flags[:crc32_present]
103
-
104
- compression_info = compression_method_code(method) & 0x07
105
-
106
- header_data = StringIO.new
107
- write_vint(header_data, file_flags)
108
- write_vint(header_data, unpacked_size)
109
- write_vint(header_data, 0x20) # Attributes (normal file)
110
-
111
- # Write modification time
112
- write_file_time(header_data, mtime)
113
-
114
- # Write CRC32
115
- header_data.write([file_crc].pack("V"))
116
-
117
- # Write compression info
118
- write_vint(header_data, compression_info)
119
- write_vint(header_data, spec.format.host_os[:windows])
120
- write_vint(header_data, name_length)
121
- header_data.write(name_bytes)
122
-
123
- # Write header with data
124
- write_header(
125
- io,
126
- type: block_type_code(:file_header),
127
- flags: 0x0002, # Has data size
128
- data: header_data.string,
129
- data_section: compressed_data,
130
- )
131
- end
132
-
133
- # Write end marker
134
- #
135
- # @param io [IO] The output stream
136
- # @return [void]
137
- def write_end_marker(io)
138
- write_header(
139
- io,
140
- type: block_type_code(:end_marker),
141
- flags: 0x0004, # Archive end flag
142
- data: "",
143
- )
144
- end
145
-
146
- # Write a RAR v5 header
147
- #
148
- # @param io [IO] The output stream
149
- # @param type [Integer] The header type
150
- # @param flags [Integer] The header flags
151
- # @param data [String] The header data
152
- # @param data_section [String, nil] Optional data section
153
- # @return [void]
154
- def write_header(io, type:, flags:, data:, data_section: nil)
155
- header_io = StringIO.new
156
-
157
- # Calculate header size (not including CRC)
158
- size_bytes = StringIO.new
159
- write_vint(size_bytes, type)
160
- write_vint(size_bytes, flags)
161
-
162
- if data_section
163
- flags |= 0x0002 # Has data size flag
164
- write_vint(size_bytes, data_section.bytesize)
165
- end
166
-
167
- size_bytes.write(data)
168
-
169
- header_size = size_bytes.string.bytesize
170
-
171
- # Write size vint
172
- write_vint(header_io, header_size)
173
-
174
- # Write type and flags
175
- write_vint(header_io, type)
176
- write_vint(header_io, flags)
177
-
178
- # Write data size if present
179
- write_vint(header_io, data_section.bytesize) if data_section
180
-
181
- # Write header data
182
- header_io.write(data)
183
-
184
- # Calculate CRC32 of header (excluding CRC field itself)
185
- header_crc = calculate_crc32(header_io.string)
186
-
187
- # Write CRC and header to output
188
- io.write([header_crc].pack("V"))
189
- io.write(header_io.string)
190
-
191
- # Write data section if present
192
- io.write(data_section) if data_section
193
- end
194
-
195
- # Write file time in RAR5 format
196
- #
197
- # @param io [IO] The output stream
198
- # @param time [Time] The time to write
199
- # @return [void]
200
- def write_file_time(io, time)
201
- # RAR5 uses Windows FILETIME format (100-nanosecond intervals since
202
- # 1601-01-01). Simplified implementation.
203
- unix_time = time.to_i
204
- windows_time = (unix_time + 11_644_473_600) * 10_000_000
205
-
206
- write_vint(io, 0x01) # Time flags (modification time present)
207
- io.write([windows_time].pack("Q<"))
208
61
  end
209
62
  end
210
63
  end
@@ -68,6 +68,23 @@ module Omnizip
68
68
  @entries << entry
69
69
  end
70
70
 
71
+ # Add an explicit directory entry with no data or stream
72
+ #
73
+ # @param archive_path [String] Directory path inside the
74
+ # archive (trailing slash optional)
75
+ # @return [self]
76
+ def add_directory_entry(archive_path)
77
+ entry = Models::FileEntry.new
78
+ entry.name = archive_path.chomp("/")
79
+ entry.source_path = nil
80
+ entry.size = 0
81
+ entry.is_dir = true
82
+ entry.has_stream = false
83
+ entry.mtime = Time.now
84
+ @entries << entry
85
+ self
86
+ end
87
+
71
88
  def write
72
89
  # Collect any files from the collector (if add_file/add_directory was used)
73
90
  collected_entries = @collector.collect_files