omnizip 0.4.2 → 0.4.4

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: 48ed1a8b9fa1d9c51d338c611430fbd78f12de0a9d5d9de3ebff23ac3b9b767c
4
- data.tar.gz: 6b173fab258e290030227c869b703631023c621699e810753259b14ef613ef34
3
+ metadata.gz: 8cf4c62c7304ef792b48b96486685707c68010f0cc6529b6a7504454b94ea025
4
+ data.tar.gz: 7c0d5e62fc21a99ccc85520ee0cd3ea32afaae39e76f10826dd56697ffa8376b
5
5
  SHA512:
6
- metadata.gz: 07fba2b58cca8f0c0429042e4a2813d5618158cb8231625ad1e6f5c32d1bc532d45a8bb952a3087d6274862647dbab0480aca8c96561f24c5a3debf2a1f7dd63
7
- data.tar.gz: 8422215bde30fa926b235b20dddbeba195573a82d1678ea98d541be6e37e6e14c7a6ae444e53b1a4505733a6420a4d9d3a53cec3605c751c5bf6eac49678ef86
6
+ metadata.gz: 539124e9189208423daacdac7cad6735f4a1069358a6ee7e39bb09155524589a2b1a01a6b1c41af6b650648dee327b2c4d36e71ccbbba5ff5e4453168f6ec9ef
7
+ data.tar.gz: 02ae0fdb9844f006e5289d8bd351b4ac023e8fafd704bd6fc96b6a0489bc27c96d3e7d3e32ef06011a8123b37573d877191e93cdbaa7c0f6e63e139e8485c09e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Added
11
+ - Rust tier wiring for every accelerated codec: zstd, deflate,
12
+ deflate64 (both zlib-framed), gzip, xz, lzip, and lzma-alone now
13
+ route decode through the Rust backend when it loads, each pinned by
14
+ a cross-implementation differential. A Rust failure falls back to
15
+ the Ruby core — auto mode is never worse than pure Ruby.
16
+
10
17
  ### Fixed
11
18
  - RAR recovery-record repair fails loudly instead of pretending:
12
19
  `recover_with_reed_solomon` returned nil and `recover_with_xor`
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "stringio"
4
+
3
5
  # Copyright (C) 2025 Ribose Inc.
4
6
  #
5
7
  # Permission is hereby granted, free of charge, to any person obtaining a
@@ -66,8 +68,13 @@ module Omnizip
66
68
  # @return [void]
67
69
  def compress(input_stream, output_stream, options = nil)
68
70
  input_data = input_stream.read
69
- encoder = Encoder.new(output_stream, build_encoder_options(options))
70
- 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
+ )
71
78
  end
72
79
 
73
80
  # Decompress Deflate-compressed data
@@ -78,8 +85,12 @@ module Omnizip
78
85
  # @return [void]
79
86
  def decompress(input_stream, output_stream, _options = nil)
80
87
  output_stream.set_encoding(Encoding::BINARY)
81
- decoder = Decoder.new(input_stream)
82
- decompressed = decoder.decode_stream
88
+ compressed = input_stream.read
89
+ # The encoder's streams carry the zlib wrapper (RFC 1950) —
90
+ # the Rust "zlib" name speaks exactly that framing.
91
+ decompressed = Backends.decompress("zlib", compressed, Backends::UNKNOWN_LENGTH) do
92
+ Decoder.new(StringIO.new(compressed)).decode_stream
93
+ end
83
94
  output_stream.write(decompressed)
84
95
  end
85
96
 
@@ -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
 
@@ -73,10 +74,15 @@ module Omnizip
73
74
  output.set_encoding(Encoding::BINARY)
74
75
  output.binmode
75
76
 
76
- # Use Zlib::Inflate with maximum window size
77
- inflater = Zlib::Inflate.new(Zlib::MAX_WBITS)
78
- decompressed = inflater.inflate(compressed)
79
- inflater.close
77
+ # The streams this algorithm produces/consumes are zlib
78
+ # containers (RFC 1950); the Rust "zlib" name speaks exactly
79
+ # that, so the tier swap is byte-transparent.
80
+ decompressed = Backends.decompress("zlib", compressed, Backends::UNKNOWN_LENGTH) do
81
+ inflater = Zlib::Inflate.new(Zlib::MAX_WBITS)
82
+ result = inflater.inflate(compressed)
83
+ inflater.close
84
+ result
85
+ end
80
86
 
81
87
  # Force binary encoding to match original data
82
88
  decompressed.force_encoding(Encoding::BINARY)
@@ -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
@@ -98,8 +103,10 @@ module Omnizip
98
103
  # @return [void]
99
104
  def decompress(input_stream, output_stream, _options = nil)
100
105
  output_stream.set_encoding(Encoding::BINARY)
101
- decoder = Decoder.new(input_stream)
102
- decompressed = decoder.decode_stream
106
+ compressed = input_stream.read
107
+ decompressed = Backends.decompress("zstd", compressed, Backends::UNKNOWN_LENGTH) do
108
+ Decoder.new(StringIO.new(compressed)).decode_stream
109
+ end
103
110
  output_stream.write(decompressed)
104
111
  end
105
112
 
@@ -27,19 +27,23 @@ 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"
@@ -48,26 +52,46 @@ module Omnizip
48
52
  return backend_ruby unless Implementations::Rust::Library.available?
49
53
  return backend_ruby unless Implementations::Rust::Library::CODECS.include?(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-
65
- # invariant: :auto prefers Rust whenever loadable).
78
+ # invariant: :auto prefers Rust whenever loadable). A Rust
79
+ # failure falls back to the Ruby core — the Ruby readers are
80
+ # the tolerant superset (legacy container variants, malformed
81
+ # inputs they historically accept), so :auto must never be
82
+ # WORSE than pure Ruby. Only the forced `rust` mode propagates.
66
83
  def decompress(codec, data, expected_len, &ruby_core)
67
84
  backend = Backends.for(codec, :decode)
68
85
  return yield if backend == RubyBackend
69
86
 
70
- backend.decompress(codec, data, expected_len)
87
+ begin
88
+ backend.decompress(codec, data, expected_len)
89
+ rescue Implementations::Rust::Error => e
90
+ raise if ENV.fetch("OMNIZIP_BACKEND", "auto") == "rust"
91
+
92
+ warn "omnizip: rust #{codec} decode failed (#{e.message}); using ruby" if ENV["OMNIZIP_BACKEND_DEBUG"]
93
+ yield
94
+ end
71
95
  end
72
96
 
73
97
  private
@@ -91,18 +91,15 @@ module Omnizip
91
91
  # Write GZIP header
92
92
  write_header(output_io, mtime, original_name)
93
93
 
94
- # Compress data with Deflate
95
- deflate = Zlib::Deflate.new(
96
- level,
97
- Zlib::MAX_WBITS + 16, # Use GZIP wrapper
98
- )
99
- compressed = deflate.deflate(input_data, Zlib::FINISH)
94
+ # RFC 1952 member: header + RAW deflate + CRC32/ISIZE footer.
95
+ # The old implementation layered zlib's own GZIP wrapper
96
+ # under this header (a second 1F 8B magic) — the gem's
97
+ # reader tolerated it, but every standard gzip tool
98
+ # rejected the output. Raw-window mode emits bare DEFLATE.
99
+ deflate = Zlib::Deflate.new(level, -Zlib::MAX_WBITS)
100
+ output_io.write(deflate.deflate(input_data, Zlib::FINISH))
100
101
  deflate.close
101
102
 
102
- # Extract just the compressed data (remove zlib's GZIP wrapper)
103
- # We'll write our own footer
104
- output_io.write(compressed[0...-8]) if compressed.bytesize > 8
105
-
106
103
  # Write GZIP footer
107
104
  write_footer(output_io, input_data)
108
105
  end
@@ -113,15 +110,27 @@ module Omnizip
113
110
  # @param output_io [IO] Output stream
114
111
  # @return [Hash] Metadata (original_name, mtime)
115
112
  def decompress_stream(input_io, output_io)
116
- # Read and parse header
117
- metadata = read_header(input_io)
118
-
119
- # Decompress with Deflate
120
- inflate = Zlib::Inflate.new(Zlib::MAX_WBITS + 16)
121
- decompressed = inflate.inflate(input_io.read)
122
- inflate.close
113
+ data = input_io.read
114
+
115
+ # Metadata comes from the header either way (cheap parse).
116
+ metadata = read_header(StringIO.new(data))
117
+
118
+ # The tier gets the WHOLE member (header + deflate + trailer)
119
+ # — the Rust "gzip" name decodes the container end-to-end.
120
+ decompressed = Backends.decompress("gzip", data, Backends::UNKNOWN_LENGTH) do
121
+ body = StringIO.new(data)
122
+ read_header(body) # skip past the header
123
+ # Legacy double-header members (written before the RFC
124
+ # 1952 fix) carry zlib's own 10-byte GZIP header here;
125
+ # standard members carry bare DEFLATE.
126
+ rest = body.read
127
+ rest = rest.byteslice(10..) if rest.byteslice(0, 2) == "\x1F\x8B".b
128
+ inflate = Zlib::Inflate.new(-Zlib::MAX_WBITS)
129
+ result = inflate.inflate(rest)
130
+ inflate.close
131
+ result
132
+ end
123
133
 
124
- # Write decompressed data
125
134
  output_io.write(decompressed)
126
135
 
127
136
  metadata
@@ -89,11 +89,17 @@ module Omnizip
89
89
 
90
90
  case format
91
91
  when :lz
92
- decode_lzip(data)
92
+ Backends.decompress("lzip", data, Backends::UNKNOWN_LENGTH) do
93
+ decode_lzip(data)
94
+ end
93
95
  when :lzma_alone
94
- decode_lzma_alone(data)
96
+ Backends.decompress("lzma-alone", data, Backends::UNKNOWN_LENGTH) do
97
+ decode_lzma_alone(data)
98
+ end
95
99
  when :xz
96
- decode_xz_stream(data)
100
+ Backends.decompress("xz", data, Backends::UNKNOWN_LENGTH) do
101
+ decode_xz_stream(data)
102
+ end
97
103
  else
98
104
  raise FormatError, "Unknown LZMA format: cannot detect valid format"
99
105
  end
@@ -16,7 +16,13 @@ module Omnizip
16
16
  # built; compress/decompress are pure C calls).
17
17
  class Library
18
18
  # Codecs the cdylib dispatches by name.
19
- CODECS = %w[bzip2 zstd lzma xz].freeze
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
+ CODECS = %w[
23
+ bzip2 zstd lzma xz lzma-alone lzip
24
+ deflate deflate64 zlib gzip
25
+ ].freeze
20
26
 
21
27
  class << self
22
28
  # The shared handle, or nil when the cdylib cannot load.
@@ -36,7 +42,10 @@ module Omnizip
36
42
  # specs exercising fallback/absence paths need a clean
37
43
  # slate; production never calls this).
38
44
  def forget!
39
- @instance = nil if defined?(@instance)
45
+ # Remove the ivar entirely: assigning nil would leave it
46
+ # defined, and `instance`'s `defined?` guard would return
47
+ # the nil forever without ever re-opening the library.
48
+ remove_instance_variable(:@instance) if defined?(@instance)
40
49
  nil
41
50
  end
42
51
 
@@ -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.2"
4
+ VERSION = "0.4.4"
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.2
4
+ version: 0.4.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.