omnizip 0.3.58 → 0.4.0

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: 04bb0d1c81ef7f4571014bb6679ad0d4750d4c078729daea82c88f643e246818
4
- data.tar.gz: 7523e6a77a43db5eb02ec7c16ef4adf0751150129f329af82eb11297f93a68f6
3
+ metadata.gz: 7a04b0c83ee4e9c6b57823c8db709178ed2b88e073f658fb6573d9f69fa7459f
4
+ data.tar.gz: 1c5d9b7e86c1d1322cfd8a8193fcec2c8b7a4d28a21b6ee4b6fca3a359bc01e3
5
5
  SHA512:
6
- metadata.gz: da1c027f6e44246e6421760d3197501db38957aeb1541c0db6e3553ab73b5e03a221a362b0165d4196bf1185f0022b341529321cdce9c2a339c3cf2f31b4f733
7
- data.tar.gz: 7732ed6d8268d006195f7648980e2f8619ea89d45767932afd587f508592f627697a9cc436f6f3e1a01b41a8a579034f654c1bc33cc3331fced28af5bd68b8ff
6
+ metadata.gz: c22355d1136d66e0c6632ce6b4ad57a1b27c8cf826b0f5593318bffd20e85de77ed8072e280a88aa01433ef658fb1adc9139ff1ff8aa6d31735efb6aea1fc9f5
7
+ data.tar.gz: b06259b9ca3ede7ed15bdfa33537a9d9a17099a59ac5b0d5dc95a4f29a46ead222641abf4e8a17f17a283b1272f99d589c3541d799da0f932fc58a1401aa0d2c
data/CHANGELOG.md CHANGED
@@ -7,6 +7,47 @@ 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-accelerated backend tier: `Omnizip::Backends` is the single
12
+ tier switch between the pure-Ruby cores and the `omnizip-ffi`
13
+ cdylib (stdlib Fiddle — no compiled extension). Under the default
14
+ `auto` policy decode prefers the Rust decoders (output-invariant
15
+ and carrying fixes the Ruby cores lack); encode stays pure-Ruby
16
+ so frame bytes never depend on the tier (content addressing
17
+ stability). `OMNIZIP_BACKEND=rust|ruby` forces a tier; load
18
+ failures fall back to pure Ruby silently. A cross-tier
19
+ differential spec pins decode agreement in both encode
20
+ directions.
21
+ - BZip2 routes its byte core through the backend seam (both paths
22
+ share the algorithm class's IO plumbing).
23
+
24
+ ### Fixed
25
+ - Ruby 4.0 compatibility for the tier loader: fiddle moved out of
26
+ the default gems and is now required lazily with a fallback;
27
+ library resolution no longer references Fiddle constants when
28
+ fiddle is absent and considers `.dll` candidates on Windows.
29
+
30
+ ## [0.3.58] - 2026-09-03
31
+
32
+ ### Fixed
33
+ - `solid: true` on the RAR5 writer silently fell back to
34
+ independent STORE entries (the RAR-compatible LZSS encoder is not
35
+ available); the writer now warns about the fallback. The
36
+ creating-archives guide's solid section stops advertising
37
+ compression ratios the option cannot deliver and the stale
38
+ `(v0.5.0)` version-gate labels are gone from shipped features.
39
+ - The ZIP writer never emits ZIP64 structures; past the classic
40
+ limits `Array#pack` failed with a bare `RangeError` that named no
41
+ limit. Both write paths now raise `UnsupportedFormatError` naming
42
+ the 65,535-entry / 4 GiB size / 4 GiB offset classic-ZIP limits
43
+ (ZIP64 writing is not supported).
44
+
45
+ ### Changed
46
+ - The decompress command's archive-vs-stream routing derives from
47
+ the registry's extension routes instead of a private table, so
48
+ formats added to the registry route correctly instead of silently
49
+ falling into the single-file stream path.
50
+
10
51
  ## [0.3.57] - 2026-09-02
11
52
 
12
53
  ### Fixed
@@ -67,7 +67,10 @@ module Omnizip
67
67
  # @return [void]
68
68
  def compress(input_stream, output_stream, options = nil)
69
69
  level = level_from(options)
70
- output_stream.write(Bz2.compress(input_stream.read, level))
70
+ data = input_stream.read
71
+ output_stream.write(
72
+ Backends.compress("bzip2", data, level) { Bz2.compress(data, level) },
73
+ )
71
74
  end
72
75
 
73
76
  # Decompress BZip2-compressed data
@@ -78,7 +81,11 @@ module Omnizip
78
81
  # @return [void]
79
82
  def decompress(input_stream, output_stream, _options = nil)
80
83
  output_stream.set_encoding(Encoding::BINARY)
81
- output_stream.write(Bz2.decompress(input_stream.read))
84
+ compressed = input_stream.read
85
+ plain = Backends.decompress("bzip2", compressed, Backends::UNKNOWN_LENGTH) do
86
+ Bz2.decompress(compressed)
87
+ end
88
+ output_stream.write(plain)
82
89
  end
83
90
 
84
91
  private
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omnizip
4
+ # Byte-level codec backend resolution — THE tier switch
5
+ # (TODO.ref-parity/62). Both paths share the algorithm classes'
6
+ # IO plumbing; only the byte core swaps.
7
+ #
8
+ # Policy:
9
+ # OMNIZIP_BACKEND=ruby force pure Ruby everywhere
10
+ # OMNIZIP_BACKEND=rust require the Rust library (raises when absent)
11
+ # OMNIZIP_BACKEND=auto (default) Rust for directions marked
12
+ # safe, Ruby otherwise, whenever the
13
+ # cdylib is loadable
14
+ #
15
+ # Safety marks (owner guidance 2026-09-19: some Ruby cores are
16
+ # LESS accurate than the Rust ports — be careful which tier is
17
+ # trusted for what):
18
+ # - decompress -> :rust by default. Decode is output-invariant
19
+ # AND the Rust decoders carry fixes the Ruby cores lack;
20
+ # disagreements are treated as Ruby-side accuracy findings
21
+ # (see the cross-tier differential spec), not coin flips.
22
+ # - compress -> :ruby by default. Byte-stability for content
23
+ # addressing (DropId dedup assumes identical frames); the
24
+ # Rust encoders produce DIFFERENT valid streams (probed:
25
+ # bzip2 L6, Ruby 906 B vs Rust 874 B). OMNIZIP_BACKEND=rust
26
+ # opts into Rust encode where correctness-of-ratio is wanted
27
+ # over frame stability.
28
+ module Backends
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
36
+
37
+ # usize::MAX — the FFI's "caller does not know the plaintext
38
+ # size" sentinel (streaming decode contract).
39
+ UNKNOWN_LENGTH = 0xFFFF_FFFF_FFFF_FFFF
40
+
41
+ class << self
42
+ def for(codec, direction)
43
+ mode = ENV.fetch("OMNIZIP_BACKEND", "auto")
44
+
45
+ return backend_rust if mode == "rust"
46
+ return backend_ruby if mode == "ruby"
47
+
48
+ return backend_ruby unless Implementations::Rust::Library.available?
49
+ return backend_ruby unless Implementations::Rust::Library::CODECS.include?(codec)
50
+
51
+ direction == :encode && !RUST_ENCODE_IDENTICAL.fetch(codec, false) ? backend_ruby : backend_rust
52
+ end
53
+
54
+ # Compress through the tier-selected backend. The block is
55
+ # the pure-Ruby core — evaluated ONLY on the Ruby path, so
56
+ # the accelerated path never pays for it.
57
+ def compress(codec, data, level, &ruby_core)
58
+ backend = Backends.for(codec, :encode)
59
+ return yield if backend == RubyBackend
60
+
61
+ backend.compress(codec, data, level)
62
+ end
63
+
64
+ # Decompress through the tier-selected backend (output-
65
+ # invariant: :auto prefers Rust whenever loadable).
66
+ def decompress(codec, data, expected_len, &ruby_core)
67
+ backend = Backends.for(codec, :decode)
68
+ return yield if backend == RubyBackend
69
+
70
+ backend.decompress(codec, data, expected_len)
71
+ end
72
+
73
+ private
74
+
75
+ def backend_rust
76
+ RustBackend
77
+ end
78
+
79
+ def backend_ruby
80
+ RubyBackend
81
+ end
82
+ end
83
+
84
+ # The pure-Ruby path: a no-op backend — callers fall back to
85
+ # their own in-tree cores, which ARE the reference. Kept as an
86
+ # object for symmetry with the Rust side.
87
+ module RubyBackend
88
+ module_function
89
+
90
+ # :nocov:
91
+ def compress(codec, _data, _level)
92
+ raise Error, "#{codec}: ruby core not wired through Backends (call it directly)"
93
+ end
94
+
95
+ def decompress(codec, _data, _expected_len)
96
+ raise Error, "#{codec}: ruby core not wired through Backends (call it directly)"
97
+ end
98
+ # :nocov:
99
+ end
100
+
101
+ # The Rust-accelerated path over the Fiddle handle.
102
+ module RustBackend
103
+ module_function
104
+
105
+ def compress(codec, data, level)
106
+ Implementations::Rust::Library.instance.compress(codec, data, level)
107
+ end
108
+
109
+ def decompress(codec, data, expected_len)
110
+ Implementations::Rust::Library.instance.decompress(codec, data, expected_len)
111
+ end
112
+ end
113
+
114
+ class Error < StandardError; end
115
+ end
116
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ # Ruby 4.0 moved fiddle out of the default gems.
5
+ require "fiddle"
6
+ require "pathname"
7
+ rescue LoadError
8
+ # :nocov:
9
+ end
10
+
11
+ module Omnizip
12
+ module Implementations
13
+ module Rust
14
+ # The loaded cdylib: one handle, lazily opened, thread-safe
15
+ # after initialization (Fiddle::Function is immutable once
16
+ # built; compress/decompress are pure C calls).
17
+ class Library
18
+ # Codecs the cdylib dispatches by name.
19
+ CODECS = %w[bzip2 zstd lzma xz].freeze
20
+
21
+ class << self
22
+ # The shared handle, or nil when the cdylib cannot load.
23
+ # Load failures are cached: a missing library is a stable
24
+ # condition, not something to re-attempt per call.
25
+ def instance
26
+ return @instance if defined?(@instance)
27
+
28
+ @instance = open
29
+ end
30
+
31
+ def available?
32
+ !instance.nil?
33
+ end
34
+
35
+ # Drop the cached handle so resolution reruns (test seam:
36
+ # specs exercising fallback/absence paths need a clean
37
+ # slate; production never calls this).
38
+ def forget!
39
+ @instance = nil if defined?(@instance)
40
+ nil
41
+ end
42
+
43
+ # Built lazily: the Fiddle::TYPE_* constants only resolve
44
+ # after a successful require; environments without fiddle
45
+ # (Ruby 4.0 without the gem) never touch this table.
46
+ def function_table
47
+ {
48
+ last_error: ["ozip_last_error", [], Fiddle::TYPE_VOIDP],
49
+ free: ["ozip_free", %i[voidp size_t], Fiddle::TYPE_VOID],
50
+ compress: [
51
+ "ozip_compress",
52
+ %i[voidp voidp size_t int voidp],
53
+ Fiddle::TYPE_VOIDP,
54
+ ],
55
+ decompress: [
56
+ "ozip_decompress",
57
+ %i[voidp voidp size_t size_t voidp],
58
+ Fiddle::TYPE_VOIDP,
59
+ ],
60
+ }
61
+ end
62
+
63
+ private
64
+
65
+ def open
66
+ path = resolve_path
67
+ return nil if path.nil?
68
+
69
+ handle = Fiddle.dlopen(path.to_s)
70
+ funcs = function_table.each_with_object({}) do |(key, (name, args, ret)), h|
71
+ h[key] = Fiddle::Function.new(handle[name], args, ret)
72
+ end
73
+ new(funcs)
74
+ rescue StandardError => e # includes Fiddle::DLError
75
+ warn "omnizip: rust backend unavailable (#{e.message}); using pure Ruby" if ENV["OMNIZIP_BACKEND"] == "rust"
76
+ nil
77
+ end
78
+
79
+ def resolve_path
80
+ explicit = ENV.fetch("OMNIZIP_FFI_DYLIB", nil)
81
+ return Pathname.new(explicit) if explicit && File.file?(explicit)
82
+
83
+ candidates = [
84
+ Pathname.new(__dir__).join("../../../../ext/libomnizip_ffi"),
85
+ sibling_checkout,
86
+ ].compact.flatten
87
+
88
+ %w[.dylib .so .dll].each do |ext|
89
+ candidates.each do |dir|
90
+ path = dir.join("libomnizip_ffi#{ext}")
91
+ return path if path.file?
92
+ end
93
+ end
94
+ nil
95
+ end
96
+
97
+ # A sibling omnizip-rs checkout: ../omnizip-rs relative to
98
+ # this gem's repository root.
99
+ def sibling_checkout
100
+ gem_root = Pathname.new(__dir__).join("../../../../..")
101
+ %w[omnizip-rs target/release].map do |rel|
102
+ gem_root.join(rel, "target/release")
103
+ end
104
+ end
105
+ end
106
+
107
+ def initialize(functions)
108
+ @functions = functions.freeze
109
+ end
110
+
111
+ def compress(codec, data, level)
112
+ out_len = [0].pack("Q")
113
+ ptr = call(:compress, codec, data, data.bytesize, level, out_len)
114
+ take(ptr, out_len.unpack1("Q"))
115
+ end
116
+
117
+ def decompress(codec, data, expected_len)
118
+ out_len = [0].pack("Q")
119
+ ptr = call(:decompress, codec, data, data.bytesize, expected_len, out_len)
120
+ take(ptr, out_len.unpack1("Q"))
121
+ end
122
+
123
+ def last_error
124
+ ptr = @functions[:last_error].call
125
+ ptr.null? ? "unknown error" : ptr.to_s
126
+ end
127
+
128
+ private
129
+
130
+ def call(name, codec, input, input_len, a, out_len)
131
+ ptr = @functions[name].call(codec, input, input_len, a, out_len)
132
+ raise Error, last_error if ptr.null?
133
+
134
+ ptr
135
+ end
136
+
137
+ # Copy out + free in one breath: the buffer belongs to the
138
+ # Rust allocator and must never outlive ozip_free.
139
+ def take(ptr, len)
140
+ string = ptr.to_s(len)
141
+ @functions[:free].call(ptr, len)
142
+ string
143
+ end
144
+ end
145
+
146
+ # Raised when a codec call fails; #message carries the Rust
147
+ # side's last_error text.
148
+ class Error < StandardError; end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omnizip
4
+ module Implementations
5
+ # Rust-accelerated codec backend (TODO.ref-parity/62).
6
+ #
7
+ # Loads the omnizip-ffi cdylib through stdlib Fiddle — no
8
+ # compiled Ruby extension, no rake-compiler. When the library
9
+ # is unavailable the gem transparently stays on the pure-Ruby
10
+ # path; nothing about the fallback is an error.
11
+ #
12
+ # Library resolution order:
13
+ # 1. ENV['OMNIZIP_FFI_DYLIB'] (explicit path)
14
+ # 2. ../target/release/ inside a sibling omnizip-rs checkout
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.
21
+ module Rust
22
+ autoload :Library, "omnizip/implementations/rust/library"
23
+ end
24
+ end
25
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Omnizip
4
- VERSION = "0.3.58"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/omnizip.rb CHANGED
@@ -18,6 +18,7 @@
18
18
 
19
19
  module Omnizip
20
20
  autoload :VERSION, "omnizip/version"
21
+ autoload :Backends, "omnizip/backends"
21
22
 
22
23
  autoload :Error, "omnizip/error"
23
24
  autoload :CompressionError, "omnizip/error"
@@ -120,6 +121,7 @@ module Omnizip
120
121
  module Implementations
121
122
  autoload :SevenZip, "omnizip/implementations/seven_zip"
122
123
  autoload :XZUtils, "omnizip/implementations/xz_utils"
124
+ autoload :Rust, "omnizip/implementations/rust"
123
125
 
124
126
  module SevenZip
125
127
  module LZMA
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.3.58
4
+ version: 0.4.0
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-03 00:00:00.000000000 Z
11
+ date: 2026-09-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -334,6 +334,7 @@ files:
334
334
  - lib/omnizip/archive_handlers/tar_handler.rb
335
335
  - lib/omnizip/archive_handlers/xar_handler.rb
336
336
  - lib/omnizip/archive_handlers/zip_handler.rb
337
+ - lib/omnizip/backends.rb
337
338
  - lib/omnizip/buffer.rb
338
339
  - lib/omnizip/buffer/memory_archive.rb
339
340
  - lib/omnizip/buffer/memory_extractor.rb
@@ -598,6 +599,8 @@ files:
598
599
  - lib/omnizip/implementations/base/lzma_decoder_base.rb
599
600
  - lib/omnizip/implementations/base/lzma_encoder_base.rb
600
601
  - lib/omnizip/implementations/base/state_machine_base.rb
602
+ - lib/omnizip/implementations/rust.rb
603
+ - lib/omnizip/implementations/rust/library.rb
601
604
  - lib/omnizip/implementations/seven_zip.rb
602
605
  - lib/omnizip/implementations/seven_zip/lzma.rb
603
606
  - lib/omnizip/implementations/seven_zip/lzma/decoder.rb