omnizip 0.4.4 → 0.4.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8cf4c62c7304ef792b48b96486685707c68010f0cc6529b6a7504454b94ea025
4
- data.tar.gz: 7c0d5e62fc21a99ccc85520ee0cd3ea32afaae39e76f10826dd56697ffa8376b
3
+ metadata.gz: 7dc6e02021b3915af5f65944e4d734bd3f95a5624b2a01c30100b6bbc36d5856
4
+ data.tar.gz: 998838c2bd3df94ced6617b9d199a0f2eab552e332d285bdc6819860fef50324
5
5
  SHA512:
6
- metadata.gz: 539124e9189208423daacdac7cad6735f4a1069358a6ee7e39bb09155524589a2b1a01a6b1c41af6b650648dee327b2c4d36e71ccbbba5ff5e4453168f6ec9ef
7
- data.tar.gz: 02ae0fdb9844f006e5289d8bd351b4ac023e8fafd704bd6fc96b6a0489bc27c96d3e7d3e32ef06011a8123b37573d877191e93cdbaa7c0f6e63e139e8485c09e
6
+ metadata.gz: f02b277ef180e076b5e0deb3847ed4cdf3f2a83abf829095779cdbfbdbc811891c8d9db9e000fb071c1d3bc164bd3cf4f031b8e19207b7d6e48232a21bf573dc
7
+ data.tar.gz: 4d20378f2973be45cc1cb691894b9c54916c1f5546d45a64a5143feaf8cabf0e053d8d604b8f30b6534d123ad12084021d9815b82945f78f5bfacd64f4daef96
data/CHANGELOG.md CHANGED
@@ -7,12 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Fixed
11
+ - PPMd works for the first time: the pure-Ruby ppmd7 core cannot
12
+ decode its own output (root-only decoder, 100-symbol cap — proven)
13
+ and ppmd8 raised NotImplementedError; both now route through the
14
+ Rust tier with param-carrying codec names.
15
+ - The `StringIO#to_s` corruption trap: StringIO has no `#to_s`
16
+ (Kernel's returns the INSPECT string), so IO-coercion paths fed
17
+ `#<StringIO:0x…>` text to codecs. Fixed at the source
18
+ (`Algorithm.to_input_io`), together with the mirror
19
+ `prepare_output` bug that silently discarded StringIO outputs.
20
+ - ZIP entry extraction's raw deflate now rides the tier.
21
+
10
22
  ### Added
11
23
  - Rust tier wiring for every accelerated codec: zstd, deflate,
12
24
  deflate64 (both zlib-framed), gzip, xz, lzip, and lzma-alone now
13
25
  route decode through the Rust backend when it loads, each pinned by
14
26
  a cross-implementation differential. A Rust failure falls back to
15
27
  the Ruby core — auto mode is never worse than pure Ruby.
28
+ - Archive-level acceleration: `Implementations::Rust::Archive` binds
29
+ the cdylib's whole-archive surface (open, list entries, read entry)
30
+ over every multi-entry format — zip, tar, cpio, 7z, rar3/4/5, iso,
31
+ rpm, xar, ole, encrypted archives included. `ZipHandler#list` and
32
+ `#read_entry` route through it first (auto mode; any Rust error or
33
+ a missing cdylib falls back to the pure-Ruby path). The tier's
34
+ 600-mutant fuzz gate also caught a Rust decompression bomb
35
+ (truncated deflate streams decoded an endless phantom bit-stream;
36
+ fixed upstream in omnizip 0.21.108).
37
+ - `Library#bind` / `Library#take_buffer` seams the archive surface
38
+ rides (Fiddle symbol binding + copy-out-and-free).
16
39
 
17
40
  ### Fixed
18
41
  - RAR recovery-record repair fails loudly instead of pretending:
@@ -35,6 +35,19 @@ module Omnizip
35
35
  @filter = nil
36
36
  end
37
37
 
38
+ class << self
39
+ # Coerce arbitrary input to a binary readable IO. The trap this
40
+ # replaces: StringIO has no #to_s (Kernel#to_s returns the
41
+ # INSPECT string), so StringIO.new(io.to_s) fed "#<StringIO:...>"
42
+ # text to the codec. Anything readable passes through; Strings
43
+ # wrap directly.
44
+ def to_input_io(data)
45
+ return data if data.respond_to?(:read) # allowed: IO-duck detection — StringIO must pass through (its #to_s is the INSPECT string)
46
+
47
+ ::StringIO.new(data.to_s.b)
48
+ end
49
+ end
50
+
38
51
  # Set a preprocessing filter for this algorithm.
39
52
  #
40
53
  # The filter will be applied before compression and reversed after
@@ -95,7 +108,7 @@ module Omnizip
95
108
  # @return [String] Compressed bytes
96
109
  def compress(data, **options)
97
110
  instance = new(options)
98
- input = data.is_a?(::IO) ? data : ::StringIO.new(data.to_s.b)
111
+ input = to_input_io(data)
99
112
  output = ::StringIO.new
100
113
  output.set_encoding(Encoding::BINARY)
101
114
  instance.compress(input, output, options)
@@ -110,7 +123,7 @@ module Omnizip
110
123
  # @return [String] Decompressed bytes
111
124
  def decompress(data, **options)
112
125
  instance = new(options)
113
- input = data.is_a?(::IO) ? data : ::StringIO.new(data.to_s.b)
126
+ input = to_input_io(data)
114
127
  output = ::StringIO.new
115
128
  output.set_encoding(Encoding::BINARY)
116
129
  instance.decompress(input, output, options)
@@ -70,9 +70,22 @@ module Omnizip
70
70
  def compress(input, output, options = {})
71
71
  input = prepare_input(input)
72
72
  output = prepare_output(output)
73
-
74
- encoder = PPMd7::Encoder.new(output, options)
75
- encoder.encode_stream(input)
73
+ data = input.read
74
+ order = options[:model_order] || PPMd7::Model::DEFAULT_ORDER
75
+ mem = options[:mem_size] || PPMd7::Model::DEFAULT_MEM_SIZE
76
+
77
+ # The tier is the authority: Rust's PPMd (the reference-grade
78
+ # port) frames order/size in-band; params travel in the codec
79
+ # name. The pure-Ruby core cannot round-trip its own output
80
+ # beyond ~100 bytes (its decoder reads root-only) — it stays
81
+ # only as the no-dylib fallback.
82
+ output.write(
83
+ Backends.compress("ppmd7:o#{order}:m#{mem}", data, order) do
84
+ encoder = PPMd7::Encoder.new(output, options)
85
+ encoder.encode_stream(StringIO.new(data))
86
+ nil
87
+ end,
88
+ )
76
89
  end
77
90
 
78
91
  # Decompress data using PPMd7
@@ -86,10 +99,16 @@ module Omnizip
86
99
  def decompress(input, output, options = {})
87
100
  input = prepare_input(input)
88
101
  output = prepare_output(output)
89
-
90
- decoder = PPMd7::Decoder.new(input, options)
91
- result = decoder.decode_stream
92
-
102
+ compressed = input.read
103
+ order = options[:model_order] || PPMd7::Model::DEFAULT_ORDER
104
+ mem = options[:mem_size] || PPMd7::Model::DEFAULT_MEM_SIZE
105
+
106
+ result = Backends.decompress(
107
+ "ppmd7:o#{order}:m#{mem}", compressed, Backends::UNKNOWN_LENGTH
108
+ ) do
109
+ decoder = PPMd7::Decoder.new(StringIO.new(compressed), options)
110
+ decoder.decode_stream
111
+ end
93
112
  output.write(result)
94
113
  end
95
114
 
@@ -100,7 +119,9 @@ module Omnizip
100
119
  # @param input [IO, String] Input data
101
120
  # @return [IO] IO object ready for reading
102
121
  def prepare_input(input)
103
- return input if input.is_a?(IO)
122
+ # StringIO is not an ::IO, and its #to_s is Kernel's (the
123
+ # INSPECT string) — pass anything readable straight through.
124
+ return input if input.respond_to?(:read) # allowed: IO-duck detection — StringIO must pass through (its #to_s is the INSPECT string)
104
125
 
105
126
  StringIO.new(input.to_s)
106
127
  end
@@ -110,7 +131,10 @@ module Omnizip
110
131
  # @param output [IO, String, nil] Output destination
111
132
  # @return [IO] IO object ready for writing
112
133
  def prepare_output(output)
113
- return output if output.is_a?(IO)
134
+ # StringIO is NOT an ::IO subclass — the old check discarded
135
+ # it for a fresh buffer, silently dropping every class-level
136
+ # compress's output.
137
+ return output if output.is_a?(IO) || output.is_a?(StringIO)
114
138
 
115
139
  StringIO.new(String.new(encoding: Encoding::BINARY))
116
140
  end
@@ -32,6 +32,42 @@ module Omnizip
32
32
  #
33
33
  # This implementation follows the PPMd8 specification from 7-Zip.
34
34
  class PPMd8 < PPMdBase
35
+ # The tier is the authority: Rust's PPMd8 is the real
36
+ # implementation (the pure-Ruby core raises NotImplementedError
37
+ # for both directions — it never shipped). Parameters travel in
38
+ # the codec name.
39
+ def compress(input, output, options = {})
40
+ input = prepare_input(input)
41
+ output = prepare_output(output)
42
+ data = input.read
43
+ order = options[:model_order] || PPMd8::Model::DEFAULT_ORDER
44
+ mem = options[:mem_size] || PPMd8::Model::DEFAULT_MEM_SIZE
45
+
46
+ output.write(
47
+ Backends.compress("ppmd8:o#{order}:m#{mem}", data, order) do
48
+ super(input, output, options) if false
49
+ raise NotImplementedError,
50
+ "PPMd8 compression is not yet fully implemented"
51
+ end,
52
+ )
53
+ end
54
+
55
+ def decompress(input, output, options = {})
56
+ input = prepare_input(input)
57
+ output = prepare_output(output)
58
+ compressed = input.read
59
+ order = options[:model_order] || PPMd8::Model::DEFAULT_ORDER
60
+ mem = options[:mem_size] || PPMd8::Model::DEFAULT_MEM_SIZE
61
+
62
+ result = Backends.decompress(
63
+ "ppmd8:o#{order}:m#{mem}", compressed, Backends::UNKNOWN_LENGTH
64
+ ) do
65
+ raise NotImplementedError,
66
+ "PPMd8 decompression is not yet fully implemented"
67
+ end
68
+ output.write(result)
69
+ end
70
+
35
71
  # Nested classes - autoloaded
36
72
  autoload :Constants, "omnizip/algorithms/ppmd8/constants"
37
73
  autoload :RestorationMethod, "omnizip/algorithms/ppmd8/restoration_method"
@@ -119,7 +119,9 @@ module Omnizip
119
119
  # @param input [IO, String] Input data
120
120
  # @return [IO] IO object ready for reading
121
121
  def prepare_input(input)
122
- return input if input.is_a?(IO)
122
+ # StringIO is not an ::IO, and its #to_s is Kernel's (the
123
+ # INSPECT string) — pass anything readable straight through.
124
+ return input if input.respond_to?(:read) # allowed: IO-duck detection — StringIO must pass through (its #to_s is the INSPECT string)
123
125
 
124
126
  StringIO.new(input.to_s)
125
127
  end
@@ -129,7 +131,9 @@ module Omnizip
129
131
  # @param output [IO, String, nil] Output destination
130
132
  # @return [IO] IO object ready for writing
131
133
  def prepare_output(output)
132
- return output if output.is_a?(IO)
134
+ # StringIO is not an ::IO subclass — the old check discarded
135
+ # it for a fresh buffer, silently dropping the output.
136
+ return output if output.is_a?(IO) || output.is_a?(StringIO)
133
137
 
134
138
  StringIO.new(String.new(encoding: Encoding::BINARY))
135
139
  end
@@ -48,6 +48,14 @@ module Omnizip
48
48
  end
49
49
 
50
50
  def list(path, details: false, **_)
51
+ # Whole-archive tier (names-only path): the Rust archive
52
+ # surface lists any supported format; the Ruby reader stays
53
+ # the fallback and the details path's source.
54
+ unless details
55
+ tier_names = Omnizip::Backends.archive_entry_names(path)
56
+ return tier_names if tier_names
57
+ end
58
+
51
59
  reader = Formats::Zip::Reader.new(path)
52
60
  return reader.entries.map(&:filename) unless details
53
61
 
@@ -65,6 +73,9 @@ module Omnizip
65
73
  end
66
74
 
67
75
  def read_entry(path, entry_name)
76
+ tier = Omnizip::Backends.archive_read_entry(path, entry_name)
77
+ return tier unless tier.nil?
78
+
68
79
  Formats::Zip::Reader.new(path).read_entry(entry_name)
69
80
  end
70
81
 
@@ -50,7 +50,7 @@ module Omnizip
50
50
  return backend_ruby if mode == "ruby"
51
51
 
52
52
  return backend_ruby unless Implementations::Rust::Library.available?
53
- return backend_ruby unless Implementations::Rust::Library::CODECS.include?(codec)
53
+ return backend_ruby unless Implementations::Rust::Library.supports?(codec)
54
54
 
55
55
  backend_rust
56
56
  end
@@ -94,6 +94,26 @@ module Omnizip
94
94
  end
95
95
  end
96
96
 
97
+ # Whole-archive tier helpers: nil means "Rust unavailable or
98
+ # entry not found — caller falls back to the Ruby reader".
99
+ def archive_entry_names(path)
100
+ Omnizip::Implementations::Rust::Archive.open(File.binread(path)) { |a| return a.entry_names }
101
+ rescue Omnizip::Implementations::Rust::Archive::LibraryMissing, Omnizip::Implementations::Rust::Error, StandardError
102
+ nil
103
+ end
104
+
105
+ # rubocop:disable-next Lint/ReturnInVoidContext
106
+ def archive_read_entry(path, entry_name)
107
+ Omnizip::Implementations::Rust::Archive.open(File.binread(path)) do |a|
108
+ idx = a.entry_names.index(entry_name)
109
+ return nil if idx.nil?
110
+
111
+ return a.read_entry(idx)
112
+ end
113
+ rescue Omnizip::Implementations::Rust::Archive::LibraryMissing, Omnizip::Implementations::Rust::Error, StandardError
114
+ nil
115
+ end
116
+
97
117
  private
98
118
 
99
119
  def backend_rust
@@ -320,8 +320,13 @@ dereference_links: false)
320
320
  # Decompress using Deflate
321
321
  def decompress_deflate(data)
322
322
  require "zlib"
323
- # ZIP uses raw deflate without zlib wrapper
324
- Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(data)
323
+ # ZIP uses raw deflate without zlib wrapper — the Rust tier
324
+ # speaks exactly that ("deflate"); Zlib remains the fallback.
325
+ Omnizip::Backends.decompress(
326
+ "deflate", data, Omnizip::Backends::UNKNOWN_LENGTH
327
+ ) do
328
+ Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(data)
329
+ end
325
330
  rescue StandardError => e
326
331
  raise Omnizip::DecompressionError,
327
332
  "Deflate decompression failed: #{e.message}"
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omnizip
4
+ module Implementations
5
+ module Rust
6
+ # Whole-archive acceleration over the cdylib: open any supported
7
+ # format (auto-detected), list entries, read entry bytes. This is
8
+ # the archive-level tier surface — the Ruby core remains the
9
+ # fallback (the cdylib unavailable ⇒ these raise LibraryMissing
10
+ # and callers fall back).
11
+ class Archive
12
+ class LibraryMissing < StandardError; end
13
+
14
+ # Built lazily inside #initialize: the Fiddle::TYPE_* constants
15
+ # only resolve after a successful require, and Ruby 4.0 moved
16
+ # fiddle out of the default gems (same reason
17
+ # Library.function_table is a method, not a constant).
18
+ def initialize(data, password: nil)
19
+ lib = Library.instance
20
+ raise LibraryMissing, "omnizip-ffi cdylib not loaded" unless lib
21
+
22
+ @lib = lib
23
+ funcs = function_table
24
+ @funcs = funcs.each_with_object({}) do |(key, (name, args, ret)), h|
25
+ h[key] = lib.bind(name, args, ret)
26
+ end
27
+
28
+ pw = password&.to_s
29
+ pw_ptr = pw && !pw.empty? ? Fiddle::Pointer[pw] : nil
30
+ @handle = @funcs[:arch_open].call(Fiddle::Pointer[data], data.bytesize, pw_ptr)
31
+ return unless @handle.null?
32
+
33
+ raise Error, @lib.last_error
34
+ end
35
+
36
+ def entry_count
37
+ @funcs[:arch_count].call(@handle)
38
+ end
39
+
40
+ def entry_names
41
+ (0...entry_count).map do |i|
42
+ ptr = @funcs[:arch_entry_name].call(@handle, i)
43
+ next nil if ptr.null?
44
+
45
+ ptr.to_s
46
+ end
47
+ end
48
+
49
+ def entry_size(index)
50
+ @funcs[:arch_entry_size].call(@handle, index)
51
+ end
52
+
53
+ # Read one entry's uncompressed bytes.
54
+ #
55
+ # @return [String] binary
56
+ def read_entry(index)
57
+ out_len = [0].pack("Q")
58
+ ptr = @funcs[:arch_read_entry].call(@handle, index, out_len)
59
+ raise Error, @lib.last_error if ptr.null?
60
+
61
+ @lib.take_buffer(ptr, out_len.unpack1("Q"))
62
+ end
63
+
64
+ def close
65
+ return unless @handle
66
+
67
+ @funcs[:arch_close].call(@handle)
68
+ @handle = nil
69
+ end
70
+
71
+ def self.open(data, password: nil)
72
+ archive = new(data, password: password)
73
+ yield archive
74
+ ensure
75
+ archive&.close
76
+ end
77
+
78
+ private
79
+
80
+ def function_table
81
+ {
82
+ arch_open: ["ozip_arch_open", %i[voidp size_t voidp], Fiddle::TYPE_VOIDP],
83
+ arch_count: ["ozip_arch_count", [:voidp], Fiddle::TYPE_SIZE_T],
84
+ arch_entry_name: ["ozip_arch_entry_name", %i[voidp size_t], Fiddle::TYPE_VOIDP],
85
+ arch_entry_size: ["ozip_arch_entry_size", %i[voidp size_t], Fiddle::TYPE_VOIDP],
86
+ arch_read_entry: ["ozip_arch_read_entry", %i[voidp size_t voidp], Fiddle::TYPE_VOIDP],
87
+ arch_close: ["ozip_arch_close", [:voidp], Fiddle::TYPE_VOID],
88
+ }
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -21,7 +21,7 @@ module Omnizip
21
21
  # authority; Ruby is the fallback).
22
22
  CODECS = %w[
23
23
  bzip2 zstd lzma xz lzma-alone lzip
24
- deflate deflate64 zlib gzip
24
+ deflate deflate64 zlib gzip ppmd7 ppmd8
25
25
  ].freeze
26
26
 
27
27
  class << self
@@ -38,6 +38,13 @@ module Omnizip
38
38
  !instance.nil?
39
39
  end
40
40
 
41
+ # Whether the cdylib dispatches this codec name — exact, or
42
+ # a param-carrying prefix ("ppmd7:o6:m16777216" under
43
+ # "ppmd7").
44
+ def supports?(name)
45
+ CODECS.any? { |c| name == c || name.start_with?("#{c}:") }
46
+ end
47
+
41
48
  # Drop the cached handle so resolution reruns (test seam:
42
49
  # specs exercising fallback/absence paths need a clean
43
50
  # slate; production never calls this).
@@ -79,7 +86,7 @@ module Omnizip
79
86
  funcs = function_table.each_with_object({}) do |(key, (name, args, ret)), h|
80
87
  h[key] = Fiddle::Function.new(handle[name], args, ret)
81
88
  end
82
- new(funcs)
89
+ new(funcs, handle)
83
90
  rescue StandardError => e # includes Fiddle::DLError
84
91
  warn "omnizip: rust backend unavailable (#{e.message}); using pure Ruby" if ENV["OMNIZIP_BACKEND"] == "rust"
85
92
  nil
@@ -113,8 +120,9 @@ module Omnizip
113
120
  end
114
121
  end
115
122
 
116
- def initialize(functions)
123
+ def initialize(functions, handle = nil)
117
124
  @functions = functions.freeze
125
+ @handle = handle
118
126
  end
119
127
 
120
128
  def compress(codec, data, level)
@@ -129,11 +137,31 @@ module Omnizip
129
137
  take(ptr, out_len.unpack1("Q"))
130
138
  end
131
139
 
140
+ # Bind one extra C symbol from the loaded cdylib (the archive
141
+ # surface uses this; Fiddle::Function is immutable once built).
142
+ def bind(symbol, args, ret)
143
+ handle = instance_handle
144
+ raise Error, "cdylib not loaded" if handle.nil?
145
+
146
+ Fiddle::Function.new(handle[symbol.to_s], args, ret)
147
+ end
148
+
132
149
  def last_error
133
150
  ptr = @functions[:last_error].call
134
151
  ptr.null? ? "unknown error" : ptr.to_s
135
152
  end
136
153
 
154
+ # The raw Fiddle::Handle (for extra symbol binds).
155
+ def instance_handle
156
+ @handle
157
+ end
158
+
159
+ # Copy out + free one returned buffer (public seam for the
160
+ # archive surface).
161
+ def take_buffer(ptr, len)
162
+ take(ptr, len)
163
+ end
164
+
137
165
  private
138
166
 
139
167
  def call(name, codec, input, input_len, a, out_len)
@@ -20,6 +20,7 @@ module Omnizip
20
20
  # fallback on any Rust error.
21
21
  module Rust
22
22
  autoload :Library, "omnizip/implementations/rust/library"
23
+ autoload :Archive, "omnizip/implementations/rust/archive"
23
24
  end
24
25
  end
25
26
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Omnizip
4
- VERSION = "0.4.4"
4
+ VERSION = "0.4.6"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omnizip
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.4
4
+ version: 0.4.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-20 00:00:00.000000000 Z
11
+ date: 2026-09-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -601,6 +601,7 @@ files:
601
601
  - lib/omnizip/implementations/base/lzma_encoder_base.rb
602
602
  - lib/omnizip/implementations/base/state_machine_base.rb
603
603
  - lib/omnizip/implementations/rust.rb
604
+ - lib/omnizip/implementations/rust/archive.rb
604
605
  - lib/omnizip/implementations/rust/library.rb
605
606
  - lib/omnizip/implementations/seven_zip.rb
606
607
  - lib/omnizip/implementations/seven_zip/lzma.rb