omnizip 0.3.38 → 0.3.40

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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -0
  3. data/README.adoc +139 -140
  4. data/config/formats/rar3_spec.yml +1 -0
  5. data/lib/omnizip/algorithm_registry.rb +7 -0
  6. data/lib/omnizip/algorithms/deflate64.rb +5 -8
  7. data/lib/omnizip/buffer/memory_extractor.rb +31 -6
  8. data/lib/omnizip/buffer/seven_zip_bridge.rb +154 -0
  9. data/lib/omnizip/buffer.rb +6 -5
  10. data/lib/omnizip/commands/archive_create_command.rb +7 -2
  11. data/lib/omnizip/commands/decompress_command.rb +29 -1
  12. data/lib/omnizip/commands/metadata_command.rb +67 -3
  13. data/lib/omnizip/converter/seven_zip_to_zip_strategy.rb +14 -52
  14. data/lib/omnizip/formats/rar/block_parser.rb +18 -8
  15. data/lib/omnizip/formats/rar/constants.rb +4 -0
  16. data/lib/omnizip/formats/rar/decompressor.rb +12 -8
  17. data/lib/omnizip/formats/rar/header.rb +7 -15
  18. data/lib/omnizip/formats/rar/reader.rb +18 -14
  19. data/lib/omnizip/formats/rar/writer.rb +121 -136
  20. data/lib/omnizip/formats/rar.rb +18 -8
  21. data/lib/omnizip/formats/rar3/reader.rb +11 -50
  22. data/lib/omnizip/formats/rar3/writer.rb +13 -10
  23. data/lib/omnizip/formats/rar3.rb +15 -0
  24. data/lib/omnizip/formats/rar5/reader.rb +8 -10
  25. data/lib/omnizip/formats/rar5.rb +15 -0
  26. data/lib/omnizip/formats/seven_zip/writer.rb +17 -0
  27. data/lib/omnizip/formats.rb +2 -0
  28. data/lib/omnizip/version.rb +1 -1
  29. data/readme-docs/api-usage.adoc +6 -5
  30. data/readme-docs/architecture.adoc +6 -5
  31. data/readme-docs/compression-algorithms.adoc +1 -1
  32. data/readme-docs/installation.adoc +1 -1
  33. data/readme-docs/performance-profiler.adoc +1 -1
  34. metadata +4 -1
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require "tmpdir"
5
+
6
+ module Omnizip
7
+ module Buffer
8
+ # Bridges the in-memory Buffer API onto the file-based 7z
9
+ # reader/writer through a temporary file, since the 7z format has
10
+ # no in-memory stream implementation.
11
+ module SevenZipBridge
12
+ class << self
13
+ # Create a 7z archive in memory
14
+ #
15
+ # @param buffer [StringIO] Destination buffer
16
+ # @param options [Hash] Writer options
17
+ # @yield [WriteProxy] Block adding entries
18
+ def create(buffer, options = {}, &block)
19
+ Dir.mktmpdir("omnizip_buffer_7z") do |tmp|
20
+ archive_path = File.join(tmp, "buffer.7z")
21
+ writer = Formats::SevenZip::Writer.new(archive_path, options)
22
+ block&.call(WriteProxy.new(writer))
23
+ writer.write
24
+
25
+ buffer.write(File.binread(archive_path))
26
+ end
27
+ end
28
+
29
+ # Read a 7z archive from memory
30
+ #
31
+ # @param buffer [StringIO] Archive bytes
32
+ # @yield [ReadProxy] Block reading entries
33
+ # @return [ReadProxy, Object] Proxy or block result
34
+ def open(buffer, &block)
35
+ Dir.mktmpdir("omnizip_buffer_7z") do |tmp|
36
+ archive_path = File.join(tmp, "buffer.7z")
37
+ File.binwrite(archive_path, buffer.string)
38
+
39
+ reader = Formats::SevenZip::Reader.new(archive_path)
40
+ reader.open
41
+ proxy = ReadProxy.new(reader, tmp)
42
+
43
+ block ? yield(proxy) : proxy
44
+ end
45
+ end
46
+ end
47
+
48
+ # Mirrors the MemoryArchive write interface onto a 7z writer
49
+ class WriteProxy
50
+ # @param writer [Formats::SevenZip::Writer]
51
+ def initialize(writer)
52
+ @writer = writer
53
+ end
54
+
55
+ # Add an entry; a trailing slash with empty content adds a
56
+ # directory entry
57
+ #
58
+ # @param name [String] Entry name
59
+ # @param data [String] Entry content
60
+ # @return [self]
61
+ def add(name, data, **_options)
62
+ if name.end_with?("/") && data.to_s.empty?
63
+ @writer.add_directory_entry(name)
64
+ else
65
+ @writer.add_data(name, data)
66
+ end
67
+ self
68
+ end
69
+ end
70
+
71
+ # Mirrors the MemoryArchive read interface onto a 7z reader
72
+ class ReadProxy
73
+ # @param reader [Formats::SevenZip::Reader] opened reader
74
+ # @param tmp [String] Scratch directory for extraction
75
+ def initialize(reader, tmp)
76
+ @reader = reader
77
+ @tmp = tmp
78
+ end
79
+
80
+ # Yield an Entry wrapper per archive entry
81
+ def each_entry
82
+ @reader.list_files.each do |entry|
83
+ yield Entry.new(entry, @reader, @tmp)
84
+ end
85
+ end
86
+
87
+ # Filename => content for every file entry
88
+ #
89
+ # @return [Hash<String, String>]
90
+ def extract_all_to_memory
91
+ result = {}
92
+ each_entry do |entry|
93
+ result[entry.name] = entry.read unless entry.directory?
94
+ end
95
+ result
96
+ end
97
+
98
+ # Entry names; directories keep a trailing slash like the ZIP
99
+ # listing convention
100
+ #
101
+ # @return [Array<String>]
102
+ def entry_names
103
+ @reader.list_files.map do |entry|
104
+ entry.is_dir ? "#{entry.name}/" : entry.name
105
+ end
106
+ end
107
+
108
+ # Read-only access to the underlying entries
109
+ #
110
+ # @return [Array<Formats::SevenZip::Models::FileEntry>]
111
+ def raw_entries
112
+ @reader.list_files
113
+ end
114
+
115
+ # Entry wrapper matching MemoryArchive::Entry's surface
116
+ class Entry
117
+ attr_reader :name, :size
118
+
119
+ # @param entry [Formats::SevenZip::Models::FileEntry]
120
+ # @param reader [Formats::SevenZip::Reader]
121
+ # @param tmp [String] Scratch directory
122
+ def initialize(entry, reader, tmp)
123
+ @entry = entry
124
+ @reader = reader
125
+ @tmp = tmp
126
+ @name = entry.is_dir ? "#{entry.name}/" : entry.name
127
+ @size = entry.size
128
+ end
129
+
130
+ # @return [Boolean]
131
+ def directory?
132
+ @entry.is_dir
133
+ end
134
+
135
+ # @return [Time, nil]
136
+ def time
137
+ @entry.mtime
138
+ end
139
+
140
+ # Extract and return this entry's content
141
+ #
142
+ # @return [String]
143
+ def read(_size = nil)
144
+ return "" if directory? || @entry.has_stream == false
145
+
146
+ dest = File.join(@tmp, "entry", @entry.name)
147
+ @reader.extract_entry(@entry.name, dest)
148
+ File.binread(dest)
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -30,6 +30,7 @@ module Omnizip
30
30
  # Nested classes - autoloaded
31
31
  autoload :MemoryArchive, "omnizip/buffer/memory_archive"
32
32
  autoload :MemoryExtractor, "omnizip/buffer/memory_extractor"
33
+ autoload :SevenZipBridge, "omnizip/buffer/seven_zip_bridge"
33
34
 
34
35
  class << self
35
36
  # Create archive in memory
@@ -54,7 +55,7 @@ module Omnizip
54
55
  when :zip
55
56
  create_zip(buffer, options, &block)
56
57
  when :seven_zip, :"7z"
57
- raise NotImplementedError, "7z format support coming in Phase 2"
58
+ SevenZipBridge.create(buffer, options, &block)
58
59
  else
59
60
  raise ArgumentError, "Unsupported format: #{format}"
60
61
  end
@@ -84,7 +85,7 @@ module Omnizip
84
85
  when :zip
85
86
  open_zip(buffer, &block)
86
87
  when :seven_zip, :"7z"
87
- raise NotImplementedError, "7z format support coming in Phase 2"
88
+ SevenZipBridge.open(buffer, &block)
88
89
  else
89
90
  raise ArgumentError, "Unsupported format: #{format}"
90
91
  end
@@ -132,14 +133,14 @@ module Omnizip
132
133
  # @return [Symbol] Detected format
133
134
  # @raise [Omnizip::FormatError] If format cannot be detected
134
135
  def detect_format(buffer)
135
- magic = buffer.read(4)
136
+ magic = buffer.read(4).to_s.b
136
137
  buffer.rewind
137
138
 
138
139
  case magic
139
- when "PK\x03\x04", "PK\x05\x06", "PK\x07\x08"
140
+ when "PK\x03\x04".b, "PK\x05\x06".b, "PK\x07\x08".b
140
141
  # ZIP signatures: local file header, EOCD, data descriptor
141
142
  :zip
142
- when "7z\xBC\xAF"
143
+ when "7z\xBC\xAF".b
143
144
  :seven_zip
144
145
  else
145
146
  raise Omnizip::FormatError,
@@ -154,8 +154,13 @@ module Omnizip
154
154
  writer_opts) do |rar|
155
155
  inputs.each do |input|
156
156
  if File.directory?(input)
157
- raise ArgumentError,
158
- "RAR5 writer does not support directories yet. Add individual files."
157
+ CliOutputFormatter.verbose_puts(
158
+ "Adding directory: #{input}",
159
+ verbose,
160
+ )
161
+ # Strip the parent so the tree lands under its own
162
+ # name, matching the .7z creation behavior
163
+ rar.add_directory(input, File.dirname(input))
159
164
  else
160
165
  CliOutputFormatter.verbose_puts(
161
166
  "Adding file: #{input}",
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+
3
5
  #
4
6
  # Copyright (C) 2024 Ribose Inc.
5
7
  #
@@ -31,12 +33,20 @@ module Omnizip
31
33
 
32
34
  # Execute the decompress command.
33
35
  #
36
+ # Archives (.zip/.7z/.tar/...) extract into the output
37
+ # directory; single-file streams decompress to the output file.
38
+ #
34
39
  # @param input_file [String] Path to input file
35
- # @param output_file [String] Path to output file
40
+ # @param output_file [String] Path to output file or directory
36
41
  # @return [void]
37
42
  def run(input_file, output_file)
38
43
  validate_inputs(input_file, output_file)
39
44
 
45
+ if archive_input?(input_file) || File.directory?(output_file)
46
+ run_archive_extract(input_file, output_file)
47
+ return
48
+ end
49
+
40
50
  algorithm_name = options[:algorithm] || detect_algorithm
41
51
  verbose = options[:verbose] || false
42
52
 
@@ -70,8 +80,26 @@ module Omnizip
70
80
  end
71
81
  end
72
82
 
83
+ ARCHIVE_EXTENSIONS = %w[.zip .7z .tar .rar .cpio .iso].freeze
84
+
73
85
  private
74
86
 
87
+ def archive_input?(path)
88
+ ext = File.extname(path).downcase
89
+ ARCHIVE_EXTENSIONS.include?(ext)
90
+ end
91
+
92
+ def run_archive_extract(input_file, output_dir)
93
+ FileUtils.mkdir_p(output_dir) unless File.directory?(output_dir)
94
+
95
+ entries = Omnizip.list_archive(input_file)
96
+ Omnizip.extract_archive(input_file, output_dir)
97
+
98
+ puts "Extracted #{entries.size} file(s) to: #{output_dir}"
99
+ rescue StandardError => e
100
+ raise Omnizip::IOError, "Failed to extract archive: #{e.message}"
101
+ end
102
+
75
103
  def validate_inputs(input_file, output_file)
76
104
  unless File.exist?(input_file)
77
105
  raise Omnizip::IOError, "Input file not found: #{input_file}"
@@ -18,19 +18,83 @@ module Omnizip
18
18
  raise Errno::ENOENT, "Archive not found: #{archive_path}"
19
19
  end
20
20
 
21
+ if seven_zip?(archive_path)
22
+ show_seven_zip_metadata(archive_path, pattern)
23
+ return
24
+ end
25
+
26
+ unless zip?(archive_path)
27
+ raise ArgumentError,
28
+ "Metadata is supported for .zip and .7z archives: " \
29
+ "#{archive_path}"
30
+ end
31
+
21
32
  Omnizip::Zip::File.open(archive_path) do |archive|
22
- if options[:show]
33
+ if options[:show] || editing_nothing?
23
34
  show_metadata(archive, pattern)
24
35
  else
25
36
  edit_metadata(archive, pattern)
37
+ puts "Metadata updated successfully"
26
38
  end
27
39
  end
28
-
29
- puts "Metadata updated successfully" unless options[:show]
30
40
  end
31
41
 
32
42
  private
33
43
 
44
+ def zip?(path)
45
+ path.end_with?(".zip")
46
+ end
47
+
48
+ def seven_zip?(path)
49
+ path.end_with?(".7z")
50
+ end
51
+
52
+ # True when no edit option was given — editing would be a no-op
53
+ def editing_nothing?
54
+ %i[comment set_mtime chmod set_attribute].none? { |key| options[key] }
55
+ end
56
+
57
+ # Read-only view for 7z archives; the edit path is zip-only
58
+ def show_seven_zip_metadata(archive_path, pattern)
59
+ if options[:comment] || options[:set_mtime] ||
60
+ options[:chmod] || options[:set_attribute]
61
+ raise ArgumentError,
62
+ "Metadata editing is only supported for ZIP archives"
63
+ end
64
+
65
+ reader = Omnizip::Formats::SevenZip::Reader.new(archive_path)
66
+ reader.open
67
+
68
+ if pattern
69
+ entries = reader.list_files.select do |e|
70
+ File.fnmatch(pattern, e.name)
71
+ end
72
+ if entries.empty?
73
+ warn "No entries match pattern: #{pattern}"
74
+ return
75
+ end
76
+
77
+ entries.each do |entry|
78
+ puts "Entry: #{entry.name}"
79
+ puts " Type: #{entry.is_dir ? 'Directory' : 'File'}"
80
+ puts " Size: #{format_size(entry.size)}"
81
+ # Per-entry packed sizes are not tracked by the parser
82
+ if entry.compressed_size&.positive?
83
+ puts " Compressed: #{format_size(entry.compressed_size)}"
84
+ end
85
+ puts " Modified: #{entry.mtime}" if entry.mtime
86
+ puts " CRC32: 0x#{entry.crc.to_s(16).upcase}" if entry.crc
87
+ end
88
+ else
89
+ entries = reader.list_files
90
+ files = entries.reject(&:is_dir)
91
+ puts "Archive: #{archive_path}"
92
+ puts "Entries: #{entries.size} " \
93
+ "(#{files.size} files, #{entries.size - files.size} dirs)"
94
+ puts "Total size: #{format_size(files.sum(&:size))}"
95
+ end
96
+ end
97
+
34
98
  def show_metadata(archive, pattern)
35
99
  if pattern
36
100
  # Show entry metadata
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "tmpdir"
4
+
3
5
  module Omnizip
4
6
  module Converter
5
7
  # Convert 7-Zip archives to ZIP format
@@ -8,18 +10,22 @@ module Omnizip
8
10
  # @return [ConversionResult] Conversion result
9
11
  def convert
10
12
  start_time = Time.now
11
- 0
12
13
 
13
- # Open source 7z archive
14
14
  reader = Omnizip::Formats::SevenZip::Reader.new(source_path)
15
- reader.read
15
+ reader.open
16
+ entry_count = reader.list_files.size
16
17
 
17
- # Collect entries
18
- entries_data = collect_seven_zip_entries(reader)
19
- entry_count = entries_data.size
18
+ Dir.mktmpdir("omnizip_convert") do |tmp|
19
+ reader.extract_all(tmp)
20
20
 
21
- # Create target ZIP archive
22
- create_zip(entries_data)
21
+ writer = Omnizip::Formats::Zip::Writer.new(target_path)
22
+ Dir.glob(File.join(tmp, "**", "*")).each do |path|
23
+ next if File.directory?(path)
24
+
25
+ writer.add_file(path, path.delete_prefix("#{tmp}/"))
26
+ end
27
+ writer.write
28
+ end
23
29
 
24
30
  create_result(start_time, entry_count)
25
31
  end
@@ -43,50 +49,6 @@ module Omnizip
43
49
  def self.can_convert?(source, target)
44
50
  source.end_with?(".7z") && target.end_with?(".zip")
45
51
  end
46
-
47
- private
48
-
49
- def collect_seven_zip_entries(reader)
50
- entries = []
51
-
52
- reader.entries.each do |entry|
53
- data = {
54
- name: entry.name,
55
- content: nil,
56
- mtime: entry.mtime || Time.now,
57
- }
58
-
59
- # Extract entry data
60
- unless entry.name.end_with?("/")
61
- File.open(source_path, "rb") do |io|
62
- data[:content] = reader.extract_entry_data(io, entry)
63
- end
64
- end
65
-
66
- entries << data
67
- end
68
-
69
- entries
70
- end
71
-
72
- def create_zip(entries)
73
- Omnizip::Zip::File.create(target_path) do |zip|
74
- entries.each do |entry_data|
75
- if entry_data[:name].end_with?("/")
76
- # Directory entry
77
- zip.add(entry_data[:name])
78
- else
79
- # File entry
80
- zip.add(entry_data[:name]) { entry_data[:content] }
81
- end
82
- end
83
-
84
- # Set archive comment if preserving metadata
85
- if options.preserve_metadata
86
- zip.comment = "Converted from 7z by Omnizip"
87
- end
88
- end
89
- end
90
52
  end
91
53
  end
92
54
  end
@@ -76,6 +76,9 @@ module Omnizip
76
76
  # Read file name
77
77
  name_bytes = io.read(name_size)
78
78
  entry.name = decode_filename(name_bytes, head_flags)
79
+ # RAR4 archives store DOS-style backslash separators;
80
+ # normalize to forward slashes like unrar on Unix.
81
+ entry.name = entry.name.tr("\\", "/")
79
82
 
80
83
  # Set entry properties
81
84
  entry.size = unpack_size
@@ -88,18 +91,25 @@ module Omnizip
88
91
  entry.attributes = attr
89
92
  entry.mtime = dos_time_to_time(file_time)
90
93
 
91
- # Set flags
92
- entry.is_dir = head_flags.anybits?(FILE_DIRECTORY)
94
+ # Set flags. RAR4 directory entries carry the full
95
+ # dictionary mask 0xE0 in HEAD_FLAGS (a directory has no
96
+ # dictionary); the exact match avoids misreading a large
97
+ # dictionary size on files as the marker.
98
+ entry.is_dir = head_flags.allbits?(FILE_DIRECTORY)
99
+ # WinRAR writes directory names with a trailing backslash;
100
+ # normalize it away for host paths.
101
+ entry.name = entry.name.chomp("\\") if entry.is_dir
93
102
  entry.encrypted = head_flags.anybits?(FILE_ENCRYPTED)
94
103
  entry.split_before = head_flags.anybits?(FILE_SPLIT_BEFORE)
95
104
  entry.split_after = head_flags.anybits?(FILE_SPLIT_AFTER)
96
105
 
97
- # Skip remaining header data and file data
98
- # Fixed fields: TYPE(1) + FLAGS(2) + SIZE(2) + PACK_SIZE(4) + UNPACK_SIZE(4) +
99
- # HOST_OS(1) + FILE_CRC(4) + FILE_TIME(4) + VERSION(1) + METHOD(1) +
100
- # NAME_SIZE(2) + ATTR(4) = 30 bytes
101
- remaining = head_size - (name_size + 30)
102
- remaining += 8 if head_flags.anybits?(FILE_LARGE)
106
+ # Skip remaining header data and file data. HEAD_SIZE
107
+ # counts the whole block including the 2-byte HEAD_CRC;
108
+ # consumed so far: 2 (CRC) + 30 (fixed fields) + name, plus
109
+ # 8 more when high-size words were present. Leftover bytes
110
+ # cover salt/extra time fields without interpreting them.
111
+ remaining = head_size - (name_size + 32)
112
+ remaining -= 8 if head_flags.anybits?(FILE_LARGE)
103
113
  io.read(remaining) if remaining.positive?
104
114
  io.read(pack_size) # Skip compressed data
105
115
 
@@ -21,6 +21,10 @@ module Omnizip
21
21
  BLOCK_SUBBLOCK = 0x7A
22
22
  BLOCK_ENDARC = 0x7B
23
23
 
24
+ # HEAD_FLAGS bit 0x8000 (LONG_BLOCK): the block carries a
25
+ # data area whose size is the PACK_SIZE field at offset 7
26
+ BLOCK_LONG = 0x8000
27
+
24
28
  # Archive flags
25
29
  ARCHIVE_VOLUME = 0x0001
26
30
  ARCHIVE_COMMENT = 0x0002
@@ -152,7 +152,7 @@ module Omnizip
152
152
  "WinRAR", "UnRAR.exe"
153
153
  ),
154
154
  ].each do |path|
155
- return "\"#{path}\"" if File.exist?(path)
155
+ return path if File.exist?(path)
156
156
  end
157
157
 
158
158
  nil
@@ -176,7 +176,7 @@ module Omnizip
176
176
  def command_version
177
177
  return nil unless command_available?
178
178
 
179
- output = `#{command_path} 2>&1`
179
+ output = `"#{command_path}" 2>&1`
180
180
  output.match(/UNRAR\s+([\d.]+)/i)&.captures&.first || "unknown"
181
181
  end
182
182
 
@@ -191,7 +191,7 @@ module Omnizip
191
191
  # Extract with system command
192
192
  def extract_with_command(archive_path, output_dir, password)
193
193
  cmd = build_extract_command(archive_path, output_dir, password)
194
- return if system(cmd)
194
+ return if system(*cmd)
195
195
 
196
196
  raise "Command extraction failed: #{archive_path}"
197
197
  end
@@ -215,7 +215,7 @@ module Omnizip
215
215
 
216
216
  # List with system command
217
217
  def list_with_command(archive_path)
218
- output = `#{command_path} vb "#{archive_path}" 2>&1`
218
+ output = `"#{command_path}" vb "#{archive_path}" 2>&1`
219
219
  raise "Command listing failed" unless $CHILD_STATUS.success?
220
220
 
221
221
  output.split("\n").map do |line|
@@ -240,7 +240,7 @@ module Omnizip
240
240
  output_path, password)
241
241
  temp_dir = Dir.mktmpdir
242
242
  cmd = build_extract_command(archive_path, temp_dir, password)
243
- unless system(cmd)
243
+ unless system(*cmd)
244
244
  raise "Command entry extraction failed: #{entry_name}"
245
245
  end
246
246
 
@@ -252,9 +252,13 @@ module Omnizip
252
252
 
253
253
  # Build extract command
254
254
  def build_extract_command(archive_path, output_dir, password)
255
- cmd = "#{command_path} x -y"
256
- cmd += " -p#{password}" if password
257
- cmd += " \"#{archive_path}\" \"#{output_dir}/\""
255
+ # Array form: no shell involved, so paths and passwords
256
+ # with special characters stay literal. -idq silences the
257
+ # per-file progress banner.
258
+ cmd = [command_path, "x", "-idq", "-y"]
259
+ cmd << "-p#{password}" if password
260
+ cmd << archive_path
261
+ cmd << "#{output_dir}/"
258
262
  cmd
259
263
  end
260
264
 
@@ -84,20 +84,12 @@ module Omnizip
84
84
 
85
85
  # Parse RAR4 header
86
86
  #
87
+ # The 7-byte signature consumed by #parse IS the marker
88
+ # block, so the main header follows immediately.
89
+ #
87
90
  # @param io [IO] Input stream
88
91
  def parse_rar4_header(io)
89
- # Read marker block
90
- read_uint16(io)
91
- head_type = io.read(1)&.ord
92
- read_uint16(io)
93
- read_uint16(io)
94
-
95
- unless head_type == BLOCK_MARKER
96
- raise "Expected marker block, got 0x#{head_type.to_s(16)}"
97
- end
98
-
99
- # Read archive header
100
- read_uint16(io)
92
+ read_uint16(io) # HEAD_CRC
101
93
  head_type = io.read(1)&.ord
102
94
  head_flags = read_uint16(io)
103
95
  head_size = read_uint16(io)
@@ -112,9 +104,9 @@ module Omnizip
112
104
  @is_locked = head_flags.anybits?(ARCHIVE_LOCKED)
113
105
  @comment_present = head_flags.anybits?(ARCHIVE_COMMENT)
114
106
 
115
- # Skip rest of archive header
116
- # head_size includes TYPE(1) + FLAGS(2) + SIZE(2) = 5 bytes already read
117
- remaining = head_size - 5
107
+ # Skip rest of archive header. HEAD_SIZE counts the whole
108
+ # block including the 2 HEAD_CRC bytes; 7 bytes consumed.
109
+ remaining = head_size - 7
118
110
  io.read(remaining) if remaining.positive?
119
111
  end
120
112
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "stringio"
5
+ require "zlib"
5
6
 
6
7
  module Omnizip
7
8
  module Formats
@@ -246,11 +247,7 @@ module Omnizip
246
247
  # Native decompression only for RAR4 for now
247
248
  return false unless @header.version == 4
248
249
 
249
- # Check if we have the compression method
250
- return false if entry.nil? || entry.method.nil?
251
-
252
- # All RAR4 methods are supported by our Dispatcher
253
- true
250
+ !entry.nil? && !entry.method.nil?
254
251
  end
255
252
 
256
253
  # Extract entry using native decompression
@@ -258,17 +255,24 @@ module Omnizip
258
255
  # @param entry [Models::RarEntry] File entry
259
256
  # @param output_path [String] Destination path
260
257
  def extract_entry_native(entry, output_path)
261
- # Read compressed data from archive
262
258
  compressed_data = read_compressed_data(entry)
263
-
264
- # Decompress using Dispatcher
265
- File.open(output_path, "wb") do |output|
266
- # For now, assume METHOD_STORE (0x30)
267
- # Real implementation would get method from entry
268
- method = entry.method || 0x30
269
-
270
- Compression::Dispatcher.decompress(method, compressed_data, output)
259
+ method = entry.method || 0x30
260
+
261
+ output = StringIO.new(+"")
262
+ Compression::Dispatcher.decompress(method, compressed_data, output)
263
+
264
+ # The native LZ/PPMd decoders understand Omnizip's own
265
+ # encoder output, not official RAR streams — without this
266
+ # check, real WinRAR archives would silently extract as
267
+ # garbage. The header CRC is authoritative: on mismatch,
268
+ # fall back to unrar.
269
+ data = output.string
270
+ if entry.crc && Zlib.crc32(data) != entry.crc
271
+ raise Compression::DecompressionError,
272
+ "CRC mismatch for #{entry.name}"
271
273
  end
274
+
275
+ File.binwrite(output_path, data)
272
276
  rescue StandardError => e
273
277
  # Fall back to external decompressor on error
274
278
  warn "Native decompression failed for #{entry.name}: #{e.message}"