omnizip 0.4.3 → 0.4.5

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: 34bab2534b75e9ca958c00130c6b5962dbd5422bd6e0f4683c8e756415d9961b
4
- data.tar.gz: 9089d88fbe8ce8bda17fecd2a21454db2daa23c86e930b69cf8bb86a495b1ce0
3
+ metadata.gz: 5a55c37209e986f7e4499e8a485163728e9c8f0dff506e1aa852672a9585653f
4
+ data.tar.gz: ba0b5437026feed4c1fe4d0ab0146f868386ebe2284ec7b08b45c7260930d582
5
5
  SHA512:
6
- metadata.gz: d92441da2c757c65e1e5865abd446cd2a296adafcdf56d5dabfc00f6c792fd6f9b8f3e51dda0231ce5d8829eaf4b81662cfdadc13cdbc9495c73d088968c6c81
7
- data.tar.gz: 30591da9a2e0af6922a49596133f98e5f05bf30e927b698e4b413c1f9f0d1907ab6f0f9a0cc32eff385772b7445aff36e271066690c85e3a134dd040e8185591
6
+ metadata.gz: 13d25032be26ccd096718846ac74126fd14ab3e2729d1e9ed366a3c431815b69c0817e33092f214eea4ed041e41e2eebe070ac92862bd54c70b0379017281637
7
+ data.tar.gz: 8e68e9a4a0d7f489be9956baf6f2d18bb82d5cc0db123adaf689ab73aefca1dcb44c0172a8e774c0cbcfbcca2927c9f40b62da184678588e084642b3b86cd7eb
data/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ 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
@@ -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)
@@ -68,8 +68,13 @@ module Omnizip
68
68
  # @return [void]
69
69
  def compress(input_stream, output_stream, options = nil)
70
70
  input_data = input_stream.read
71
- encoder = Encoder.new(output_stream, build_encoder_options(options))
72
- encoder.encode_stream(input_data)
71
+ level = build_encoder_options(options)[:level] || 6
72
+ output_stream.write(
73
+ Backends.compress("zlib", input_data, level) do
74
+ encoder = Encoder.new(output_stream, build_encoder_options(options))
75
+ encoder.encode_stream(input_data)
76
+ end,
77
+ )
73
78
  end
74
79
 
75
80
  # Decompress Deflate-compressed data
@@ -42,21 +42,22 @@ module Omnizip
42
42
  # @param options [Hash] Compression options
43
43
  # @option options [Integer] :level Compression level (1-9)
44
44
  def compress(input, output, options = {})
45
- level = options[:level] || Zlib::DEFAULT_COMPRESSION
46
-
45
+ raw_level = options[:level] || Zlib::DEFAULT_COMPRESSION
47
46
  data = input.read
48
47
  return if data.nil? || data.empty?
49
48
 
50
- # Use Zlib::Deflate with maximum window size
51
- deflater = Zlib::Deflate.new(
52
- level,
53
- Zlib::MAX_WBITS, # Maximum window size
54
- Zlib::MAX_MEM_LEVEL,
55
- )
56
-
57
- compressed = deflater.deflate(data, Zlib::FINISH)
58
- deflater.close
59
-
49
+ # Zlib::DEFAULT_COMPRESSION (-1) maps to the FFI's 6.
50
+ level = raw_level.negative? ? 6 : raw_level.clamp(0, 9)
51
+ compressed = Backends.compress("zlib", data, level) do
52
+ deflater = Zlib::Deflate.new(
53
+ raw_level,
54
+ Zlib::MAX_WBITS,
55
+ Zlib::MAX_MEM_LEVEL,
56
+ )
57
+ result = deflater.deflate(data, Zlib::FINISH)
58
+ deflater.close
59
+ result
60
+ end
60
61
  output.write(compressed)
61
62
  end
62
63
 
@@ -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
@@ -86,8 +86,13 @@ module Omnizip
86
86
  # @return [void]
87
87
  def compress(input_stream, output_stream, options = nil)
88
88
  input_data = input_stream.read
89
- encoder = Encoder.new(output_stream, build_encoder_options(options))
90
- encoder.encode_stream(input_data)
89
+ level = build_encoder_options(options)[:level] || 3
90
+ output_stream.write(
91
+ Backends.compress("zstd", input_data, level) do
92
+ encoder = Encoder.new(output_stream, build_encoder_options(options))
93
+ encoder.encode_stream(input_data)
94
+ end,
95
+ )
91
96
  end
92
97
 
93
98
  # Decompress Zstandard-compressed data
@@ -27,38 +27,51 @@ module Omnizip
27
27
  # over frame stability.
28
28
  module Backends
29
29
  # Codecs whose Rust encoder is BYTE-IDENTICAL to the pure-Ruby
30
- # encoder (empirical, differential-corpus-proven). 2026-09-19
31
- # probe: bzip2 L6 on a 50 KB corpus => Ruby 906 B vs Rust 874 B —
32
- # DIFFERENT valid streams. No codec qualifies; encode stays
33
- # pure-Ruby under :auto so downstream content addressing never
34
- # sees tier-dependent bytes. Force with OMNIZIP_BACKEND=rust.
35
- RUST_ENCODE_IDENTICAL = {}.freeze
30
+ # 2026-09-20 policy flip (owner directive): RUST IS THE AUTHORITY
31
+ # — its encoders/decoders are the ports validated against the
32
+ # reference C/C++ implementations (bzip2, xz/liblzma, zstd,
33
+ # libdeflate), and they are determinism-contracted (same input +
34
+ # level => same bytes on every machine). :auto therefore routes
35
+ # BOTH directions through Rust whenever the cdylib loads; encode
36
+ # falls back to the Ruby core on any Rust error, so :auto is
37
+ # never worse than pure Ruby. The pure-Ruby cores remain the
38
+ # portable fallback and the OMNIZIP_BACKEND=ruby escape hatch.
39
+ RUST_AUTHORITATIVE = true
36
40
 
37
41
  # usize::MAX — the FFI's "caller does not know the plaintext
38
42
  # size" sentinel (streaming decode contract).
39
43
  UNKNOWN_LENGTH = 0xFFFF_FFFF_FFFF_FFFF
40
44
 
41
45
  class << self
42
- def for(codec, direction)
46
+ def for(codec, _direction)
43
47
  mode = ENV.fetch("OMNIZIP_BACKEND", "auto")
44
48
 
45
49
  return backend_rust if mode == "rust"
46
50
  return backend_ruby if mode == "ruby"
47
51
 
48
52
  return backend_ruby unless Implementations::Rust::Library.available?
49
- return backend_ruby unless Implementations::Rust::Library::CODECS.include?(codec)
53
+ return backend_ruby unless Implementations::Rust::Library.supports?(codec)
50
54
 
51
- direction == :encode && !RUST_ENCODE_IDENTICAL.fetch(codec, false) ? backend_ruby : backend_rust
55
+ backend_rust
52
56
  end
53
57
 
54
58
  # Compress through the tier-selected backend. The block is
55
59
  # the pure-Ruby core — evaluated ONLY on the Ruby path, so
56
- # the accelerated path never pays for it.
60
+ # the accelerated path never pays for it. A Rust encode error
61
+ # falls back to the Ruby core (auto is never worse than pure
62
+ # Ruby); only the forced `rust` mode propagates.
57
63
  def compress(codec, data, level, &ruby_core)
58
64
  backend = Backends.for(codec, :encode)
59
65
  return yield if backend == RubyBackend
60
66
 
61
- backend.compress(codec, data, level)
67
+ begin
68
+ backend.compress(codec, data, level)
69
+ rescue Implementations::Rust::Error => e
70
+ raise if ENV.fetch("OMNIZIP_BACKEND", "auto") == "rust"
71
+
72
+ warn "omnizip: rust #{codec} encode failed (#{e.message}); using ruby" if ENV["OMNIZIP_BACKEND_DEBUG"]
73
+ yield
74
+ end
62
75
  end
63
76
 
64
77
  # Decompress through the tier-selected backend (output-
@@ -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}"
@@ -16,12 +16,12 @@ module Omnizip
16
16
  # built; compress/decompress are pure C calls).
17
17
  class Library
18
18
  # Codecs the cdylib dispatches by name.
19
- # Names the cdylib dispatches: the codec-level acceleration surface
20
- # (decode tier by default; encode only under forced rust mode —
21
- # RUST_ENCODE_IDENTICAL gates the auto tier).
19
+ # Names the cdylib dispatches: the codec-level acceleration
20
+ # surface (both directions by default — Rust is the
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).
@@ -13,11 +13,11 @@ module Omnizip
13
13
  # 1. ENV['OMNIZIP_FFI_DYLIB'] (explicit path)
14
14
  # 2. ../target/release/ inside a sibling omnizip-rs checkout
15
15
  #
16
- # Tier policy (see Omnizip::Backends): decode is always
17
- # output-invariant, so `auto` uses Rust for decode whenever
18
- # available; encode uses Rust only when byte-identity with the
19
- # pure-Ruby encoder has been verified for that codec
20
- # (RUST_ENCODE_IDENTICAL), keeping content addressing stable.
16
+ # Tier policy (see Omnizip::Backends): Rust is the authority —
17
+ # validated against the reference C/C++ implementations and
18
+ # determinism-contracted — so `auto` routes BOTH directions
19
+ # through Rust whenever the library loads, with Ruby-core
20
+ # fallback on any Rust error.
21
21
  module Rust
22
22
  autoload :Library, "omnizip/implementations/rust/library"
23
23
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Omnizip
4
- VERSION = "0.4.3"
4
+ VERSION = "0.4.5"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omnizip
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.3
4
+ version: 0.4.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.