omnizip 0.3.19 → 0.3.21

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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +95 -3
  3. data/TODO.refactor/00-overview.md +2 -2
  4. data/TODO.refactor/07-respond-to-replacement.md +225 -79
  5. data/lib/omnizip/algorithm.rb +2 -2
  6. data/lib/omnizip/algorithms/bzip2.rb +1 -0
  7. data/lib/omnizip/algorithms/deflate.rb +1 -0
  8. data/lib/omnizip/algorithms/lzma/xz_utils_decoder.rb +3 -3
  9. data/lib/omnizip/algorithms/lzma.rb +11 -5
  10. data/lib/omnizip/algorithms/lzma2/encoder.rb +2 -0
  11. data/lib/omnizip/algorithms/lzma2/xz_encoder_adapter.rb +2 -0
  12. data/lib/omnizip/algorithms/lzma2.rb +2 -0
  13. data/lib/omnizip/algorithms/ppmd7.rb +2 -0
  14. data/lib/omnizip/algorithms/ppmd_base.rb +2 -0
  15. data/lib/omnizip/algorithms/registration.rb +1 -1
  16. data/lib/omnizip/algorithms/zstandard/encoder.rb +57 -18
  17. data/lib/omnizip/algorithms/zstandard/match_finder.rb +9 -5
  18. data/lib/omnizip/algorithms/zstandard.rb +1 -0
  19. data/lib/omnizip/chunked/writer.rb +3 -0
  20. data/lib/omnizip/commands/archive_extract_command.rb +3 -1
  21. data/lib/omnizip/commands/archive_list_command.rb +4 -3
  22. data/lib/omnizip/commands/profile_show_command.rb +2 -0
  23. data/lib/omnizip/convenience.rb +2 -0
  24. data/lib/omnizip/extraction/filter_chain.rb +4 -0
  25. data/lib/omnizip/extraction/selective_extractor.rb +7 -0
  26. data/lib/omnizip/file_type.rb +4 -0
  27. data/lib/omnizip/formats/ole.rb +2 -0
  28. data/lib/omnizip/formats/rar/decompressor.rb +1 -0
  29. data/lib/omnizip/formats/rar/license_validator.rb +2 -0
  30. data/lib/omnizip/formats/rar/reader.rb +1 -2
  31. data/lib/omnizip/formats/rpm.rb +1 -0
  32. data/lib/omnizip/formats/seven_zip/writer.rb +8 -0
  33. data/lib/omnizip/formats/tar/reader.rb +2 -0
  34. data/lib/omnizip/formats/xar/reader.rb +2 -0
  35. data/lib/omnizip/formats/xar/writer.rb +1 -0
  36. data/lib/omnizip/formats/xz.rb +2 -0
  37. data/lib/omnizip/formats/xz_impl/block_decoder.rb +3 -0
  38. data/lib/omnizip/formats/xz_impl/stream_decoder.rb +8 -0
  39. data/lib/omnizip/formats/zip/reader.rb +2 -0
  40. data/lib/omnizip/implementations/seven_zip/lzma/encoder.rb +1 -0
  41. data/lib/omnizip/implementations/xz_utils/lzma2/decoder.rb +4 -9
  42. data/lib/omnizip/implementations/xz_utils/lzma2/encoder.rb +1 -16
  43. data/lib/omnizip/io/buffered_input.rb +1 -0
  44. data/lib/omnizip/io/buffered_output.rb +1 -0
  45. data/lib/omnizip/io/source.rb +4 -0
  46. data/lib/omnizip/io/stream_manager.rb +3 -1
  47. data/lib/omnizip/link_handler.rb +2 -0
  48. data/lib/omnizip/metadata/metadata_validator.rb +2 -0
  49. data/lib/omnizip/models/filter_config.rb +2 -0
  50. data/lib/omnizip/version.rb +1 -1
  51. data/lib/omnizip/zip/file.rb +1 -1
  52. data/lib/omnizip/zip/input_stream.rb +2 -1
  53. metadata +1 -3
  54. data/lib/omnizip/algorithms/sevenzip_lzma2.rb +0 -119
  55. data/lib/omnizip/algorithms/xz_lzma2.rb +0 -114
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: affda0906d52a418254670adb02e96312d6e268d1df4aafbba575ade503edfbe
4
- data.tar.gz: ae4e84af21221350bf4d67a1b295546674527e091b500a715cb96f8547870990
3
+ metadata.gz: 5d0052490f16345b27296100499706bddd9936ea4a4b839dc34ea862bef32d77
4
+ data.tar.gz: f33f511d967b2a4f5d881b0e9205c72e57a9744480fdcb914b5ee44df9a14983
5
5
  SHA512:
6
- metadata.gz: 91044c5b1067662c5177e2b8ca262ad0434e4a4cf94e9ea321251723499188fc4b57b3793e2ed88ce51ea406cdb1bfb6d31921ef3b08a7baaf4fd6e62102dc5b
7
- data.tar.gz: b6614848a2eb450b748bca1d3d297b7bcd5751607124687237c75ebdeeb43b033ffe6d9af49af457e94aa1cace5ab615c3fe6d04f6a5bb3206a863249225ead6
6
+ metadata.gz: 722b0ece40f17d907a74df2fd9a333945f5060101f6c816bc613c56b1e653d907b31fc803afe40b840217e70dd6301304e0c39660071a0fab20d3df4e6a9c884
7
+ data.tar.gz: f3810c7f1d35b12a88dbc2651945ff18d66399b42f5bb3db1299aece2545fceb9e6336025d2e3555d4b8aa4608f3991091ddd33dca0d575a1b6153da0b4c6a02
data/CHANGELOG.md CHANGED
@@ -5,6 +5,100 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.3.19] - 2026-08-25
11
+
12
+ ### Fixed
13
+ - 7-Zip LZMA2 encoder corrupted every chunk after the first: pos_state was
14
+ computed from the chunk-relative position while decoders count positions
15
+ continuously across chunk boundaries. The stale duplicated encoder is now a
16
+ thin subclass of the fixed XZ Utils encoder (also picking up its state-reset,
17
+ 64 KiB chunk-cap and symbol-queue fixes).
18
+
19
+ ## [0.3.18] - 2026-08-25
20
+
21
+ ### Added
22
+ - Zstandard long-distance matching (LDM): a sparse hash table over the whole
23
+ frame finds matches beyond the 128 KiB block window at levels >= 19 for
24
+ multi-block inputs, bringing zstd-22 output to reference parity
25
+ (0.146 vs omnizip-rs 0.1456 on the benchmark corpus).
26
+
27
+ ## [0.3.17] - 2026-08-25
28
+
29
+ ### Changed
30
+ - Zstandard greedy parser: rep0 fast-path and backward match extension into
31
+ the pending literals (default-level text ratio 0.177 -> 0.167).
32
+
33
+ ## [0.3.16] - 2026-08-24
34
+
35
+ ### Added
36
+ - Zstandard Compressed_Blocks now carry a sequences section: LZ77 match
37
+ finder (greedy/lazy/lazy2) and LL/OF/ML FSE sequence encoding with
38
+ Predefined/FSE_Compressed table selection. Text ratio 0.49 -> ~0.17.
39
+
40
+ ### Changed
41
+ - Migrated ConversionOptions, ParallelOptions, ProgressOptions and EtaResult
42
+ models to lutaml-model (breaking change for hand-rolled hash access).
43
+
44
+ ## [0.3.15] - 2026-08-24
45
+
46
+ ### Fixed
47
+ - Zstandard codec rebuilt per RFC 8878: FSE tables from stream, FSE Huffman
48
+ weights, treeless literals, repeat mode, XXH64 frame checksums, window
49
+ descriptor; `compress` with default options now really compresses instead
50
+ of emitting stored frames (issues #27, BUGREPORT 01-10).
51
+ - XZ LZMA2 encoder: chunking, repeat-offset state and wire format repaired
52
+ (issue #26; missing `tempfile` require, symbol-buffer overflow at ~10 MB).
53
+
54
+ ## [0.3.14] - 2026-07-25
55
+
56
+ ### Changed
57
+ - Documented the Algorithms vs Implementations boundary; deepened the
58
+ Algorithm base class with a class-level facade and IO::Source/Sink.
59
+ - Extracted `Omnizip::Parallel::Engine`.
60
+
61
+ ## [0.3.13] - 2026-07-24
62
+
63
+ ### Changed
64
+ - Reverted the v0.3.12 require-omnizip-everywhere band-aid in favor of
65
+ per-file entry-point requires.
66
+
67
+ ## [0.3.12] - 2026-07-24
68
+
69
+ ### Fixed
70
+ - Circular dependency at gemspec load (`version.rb` no longer requires
71
+ omnizip); missing entry-point requires at the top of internal files.
72
+
73
+ ## [0.3.11] - 2026-07-22
74
+
75
+ ### Fixed
76
+ - Format files broke when required directly by external code; LinkHandler
77
+ symlink gating; BCJ filter autoload.
78
+
79
+ ## [0.3.10] - 2026-07-21
80
+
81
+ ### Changed
82
+ - Migrated CompressionOptions and AlgorithmMetadata to lutaml-model;
83
+ decoupled Convenience from ZIP via ArchiveHandler; replaced `respond_to?`
84
+ type checks with `is_a?`.
85
+
86
+ ## [0.3.9] - 2026-03-24
87
+
88
+ ### Fixed
89
+ - Windows unrar detection; 7-Zip SDK version validation (major version only).
90
+
91
+ ## [0.3.8] - 2026-02-24
92
+
93
+ ### Fixed
94
+ - Formats now auto-register when their files are loaded directly.
95
+
96
+ ## [0.3.7] - 2026-02-23
97
+
98
+ ### Fixed
99
+ - XZ dictionary-size bounds checking (OOM prevention); block header parser
100
+ autoload.
101
+
8
102
  ## [0.3.6] - 2026-02-23
9
103
 
10
104
  ### Changed
@@ -18,8 +112,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
18
112
  - Fixed RAR format `verify` and `repair` convenience methods
19
113
  - Fixed library loading to ensure convenience methods are available at startup
20
114
 
21
- ## [Unreleased]
22
-
23
115
  ### Added
24
116
  - **XAR Format Support**: Full read/write support for XAR (eXtensible ARchive) format
25
117
  - XAR is primarily used on macOS for software packages (.pkg files) and installers
@@ -594,4 +686,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
594
686
  - Native compression support documented
595
687
  - All 6 compression methods explained
596
688
  - Performance characteristics detailed
597
- - Real-world usage examples
689
+ - Real-world usage examples
@@ -26,7 +26,7 @@ as absolute rules:
26
26
  | 04 | `04-filter-base-consolidation.md` | medium | **DONE** |
27
27
  | 05 | `05-send-private-methods.md` | high | **DONE** |
28
28
  | 06 | `06-instance-variable-access.md` | high | **DONE** |
29
- | 07 | `07-respond-to-replacement.md` | medium | **PARTIAL** — IO::Source/Sink adapter built; type-check sites converted; ~76 IO capability checks remain in xz_impl/* and ole/* (legitimate duck-typing for ungetbyte / set_encoding etc.) |
29
+ | 07 | `07-respond-to-replacement.md` | medium | **DONE** — 11 type checks converted; 45 feature-detection sites annotated `# allowed:` and enforced by a spec; 1 deferred type check in `algorithms/lzma.rb` pending `LzmaOptions` unification |
30
30
  | 08 | `08-spec-doubles.md` | low | **DONE** |
31
31
  | 09 | `09-cli-shared-module.md` | low | **DONE** |
32
32
  | 10 | `10-format-detector-ocp.md` | medium | **DONE** |
@@ -43,7 +43,7 @@ as absolute rules:
43
43
  | `.send` on private methods | 17 | **0** |
44
44
  | `instance_variable_set`/`get` | 54 | **0** |
45
45
  | `double()` in specs | 1 | **0** |
46
- | `respond_to?` | 111 | 76 (mostly IO capability detection) |
46
+ | `respond_to?` | 60 at track 07 start (111 originally) | 49 — 46 annotated `# allowed:` (45 feature detection + 1 deferred type check) plus 3 doc comments |
47
47
  | Hand-rolled model `to_h` | 26 | 24 (2 migrated to lutaml-model) |
48
48
 
49
49
  ## Architecture summary
@@ -1,91 +1,237 @@
1
1
  # 07 — Replace `respond_to?` with proper typing
2
2
 
3
3
  Priority: **medium**.
4
- Status: **partialsee Remaining Work**.
4
+ Status: **DONE**, with one known exception `algorithms/lzma.rb:200` is a type
5
+ check we chose not to convert, not feature detection. See "The one exception".
5
6
 
6
7
  ## Rule
7
8
 
8
9
  > NEVER use `respond_to?` for type checking. Use `is_a?` for type checks,
9
10
  > or better yet, design the type hierarchy so the check isn't needed.
10
11
 
11
- ## Done in this pass
12
-
13
- - Removed `opts.respond_to?(:"#{k}=")` from `parallel_compressor.rb` and
14
- `parallel_extractor.rb` by adding `ParallelOptions#apply(hash)` with a
15
- fixed setter whitelist.
16
- - Removed `format.respond_to?(:metadata)` from
17
- `optimization_registry.rb` by relying on `Strategy.metadata` being
18
- defined on the base class.
19
-
20
- ## Remaining work
21
-
22
- 109 `respond_to?` occurrences remain in `lib/`. They fall into these
23
- categories (sampled):
24
-
25
- ### A. Duck-typed IO/String inputs (largest category)
26
-
27
- `input.respond_to?(:read)` / `output.respond_to?(:write)` in
28
- `formats/xz.rb`, `formats/lzma_alone.rb`, `formats/lzip.rb`,
29
- `formats/xz_impl/*.rb`, `pipe/*.rb`.
30
-
31
- **Fix:** introduce `Omnizip::IO::Source.for(input)` and
32
- `Omnizip::IO::Sink.for(output)` adapter methods in `lib/omnizip/io.rb`
33
- that wrap String / StringIO / File / Pathname behind a single API.
34
- Replace each `respond_to?`-guarded branch with a polymorphic call on the
35
- adapter.
36
-
37
- ### B. Job-size duck typing
38
-
39
- `job.respond_to?(:size)` in `parallel/job_scheduler.rb` (6 occurrences).
40
-
41
- **Fix:** define `Parallel::Job` base with `#size` returning a default of
42
- 1. Jobs that know their size override; others inherit the default. The
43
- scheduler stops needing to ask.
44
-
45
- ### C. Entry-type duck typing
46
-
47
- `entry.respond_to?(:name)`, `entry.respond_to?(:path)`,
48
- `entry.respond_to?(:filename)`, `entry.respond_to?(:read)`,
49
- `entry.respond_to?(:get_input_stream)` in
50
- `extraction/selective_extractor.rb`, `parallel/parallel_extractor.rb`,
51
- `commands/archive_list_command.rb`, `pipe/stream_decompressor.rb`,
52
- `formats/rar/reader.rb`.
53
-
54
- **Fix:** define `Omnizip::Models::Entry` as a common base for
55
- zip/seven_zip/rar/tar entry classes. Each format's entry inherits and
56
- implements the contract (`#name`, `#size`, `#unix_perms`, `#mtime`,
57
- etc., returning nil when not applicable).
58
-
59
- ### D. Platform feature detection
60
-
61
- `File.respond_to?(:symlink)`, `File.respond_to?(:link)`,
62
- `io.respond_to?(:pos)`, `io.respond_to?(:seek)` in `link_handler.rb`,
63
- `file_type.rb`.
64
-
65
- **Fix:** add `Platform.symlinks_supported?`,
66
- `Platform.seekable?(io)` predicates in `lib/omnizip/platform.rb`.
67
-
68
- ### E. Hash-to-object metaprogramming
69
-
70
- `opts.respond_to?(:"#{k}=")` same as ParallelOptions pattern. Fix
71
- case-by-case with whitelisted `apply(hash)` methods.
72
-
73
- ### F. Reader-specific methods
74
-
75
- `reader.respond_to?(:split_reader)`,
76
- `elem.respond_to?(:text)`, `entry.respond_to?(:method)` these are
77
- narrow cases. Convert to `is_a?` checks against the concrete class, or
78
- introduce a small type hierarchy.
79
-
80
- ## Recommended execution order
81
-
82
- 1. Tracks A and D — small, localized, no API change for callers.
83
- 2. Track B single file, base class addition.
84
- 3. Track C larger; needs the Entry base class first.
85
- 4. Track Ecase-by-case.
86
- 5. Track F case-by-case.
12
+ ## Outcome
13
+
14
+ `lib/` went from 60 `respond_to?` occurrences to 49:
15
+
16
+ | | count |
17
+ |---|---|
18
+ | Converted to a real type check | 11 |
19
+ | Retained as genuine feature detection, annotated `# allowed:` | 45 |
20
+ | Retained as a **deferred type check**, annotated `# allowed:` | 1 |
21
+ | Prose inside doc comments (not code) | 3 |
22
+
23
+ 46 sites carry an `# allowed:` annotation, but they are not all the same kind of
24
+ thing. 45 are feature detection on objects we do not control. **One is a type
25
+ check we decided not to convert** — counting it with the other 45 would overstate
26
+ how complete this track is, so it is broken out here and below.
27
+
28
+ The three prose occurrences are in `entry.rb`, `io/source.rb` and
29
+ `parallel/job_scheduler.rb`, where they document the pattern that was removed.
30
+ They are kept deliberately — rewording them so a `grep` returns zero would be
31
+ dishonest.
32
+
33
+ `spec/omnizip/respond_to_annotation_spec.rb` enforces the convention. It lexes
34
+ `lib/` with `Ripper` and fails on any `respond_to?` **identifier token** that
35
+ lacks a `# allowed: <reason>` **comment token** on its own line or the one
36
+ above. Being lexer-based rather than substring-based, a `# allowed:` inside a
37
+ string literal cannot satisfy it and a `respond_to?` inside a heredoc cannot
38
+ trip it.
39
+
40
+ Note what that spec does and does not do: it enforces that a reason is
41
+ **stated**, not that the reason is **good**. It cannot tell feature detection
42
+ from a deferred type check, which is exactly why the one exception is recorded
43
+ here in prose rather than left for the spec to imply.
44
+
45
+ ## The governing distinction
46
+
47
+ A check is a **type check** (convert it) when every runtime type is enumerable
48
+ from the source. It is **feature detection** (annotate it) when a third party
49
+ can register or pass something we cannot enumerate.
50
+
51
+ The second half is what this pass got wrong at first. omnizip's world is open:
52
+
53
+ - `FilterRegistry.register` (`filter_registry.rb:30`) raises only on a nil name
54
+ or nil class. **No inheritance check.**
55
+ - `ProfileRegistry.register` (`profile_registry.rb:23`) requires only
56
+ `is_a?(CompressionProfile)`, not membership in the built-in set.
57
+ - `ArchiveHandler.register` (`archive_handler.rb:26`) accepts any object.
58
+ - The third positional `options` parameter on every `Algorithm#compress` /
59
+ `#decompress` is public and unconstrained.
60
+
61
+ So a check can look like a type check, and be one inside this repo today, while
62
+ still being reachable with a type we cannot name. Those stay, annotated.
63
+
64
+ ## What was converted (11)
65
+
66
+ | site | conversion |
67
+ |---|---|
68
+ | `algorithms/registration.rb:22` | guard dropped; `Algorithm.inherited` defines `register_algorithm` on every subclass |
69
+ | `algorithms/lzma/xz_utils_decoder.rb:291` | guard dropped; `@range_decoder ||=` runs earlier in the same method |
70
+ | `commands/archive_list_command.rb:72`, `:105` | `archive.entries`; the private builder returns one of three readers, all of which expose it |
71
+ | `implementations/seven_zip/lzma2/encoder.rb`, `implementations/xz_utils/lzma2/encoder.rb` | both `StringCompat` modules deleted, `byteslice` inlined at all three call sites — see below |
72
+ | `commands/archive_extract_command.rb:225` | `archive.is_a?(Omnizip::Zip::File)`; closed 4-way set, only ZIP closes |
73
+ | `implementations/xz_utils/lzma2/decoder.rb:420` | guard dropped; `@lzma_decoder` is always an `XzUtilsDecoder`, which exposes `range_decoder` |
74
+ | `io/stream_manager.rb:74` | `@source.close if @owned`; `@owned` is only true for a path the manager itself opened as a `File` |
75
+ | `algorithms/xz_lzma2.rb:59`, `algorithms/sevenzip_lzma2.rb:64` | routed through `Omnizip::IO::Source.for`, which accepts any `#read` duck |
76
+
77
+ The String branch in those last two was deliberately **not** routed through the
78
+ adapter: `Source::StringSource#read` treats a String that exists on disk as a
79
+ file path, so routing it would silently start reading files off disk whenever
80
+ input collided with a filename.
81
+
82
+ ### The `StringCompat` modules
83
+
84
+ Each held `if "".respond_to?(:byteslice)` with a `string.bytes[start, length]`
85
+ fallback, under a comment claiming Ruby 3.0-3.1 lacks `String#byteslice`.
86
+ **That comment was simply wrong** `String#byteslice` has existed since Ruby
87
+ 1.9.3, so the fallback was unreachable on every Ruby that could ever have run
88
+ this gem.
89
+
90
+ Deleting only the `respond_to?` branch would have left a module named `Compat`
91
+ whose sole purpose was that branch, forwarding one call to `String#byteslice`
92
+ and nothing else — and two of the three call sites still carried a "for Ruby
93
+ 3.0-3.1 compatibility" comment that would have become a lie. So both modules
94
+ were deleted outright and `temp_buffer.byteslice(0, out_pos.value)` inlined at
95
+ all three call sites (`xz_utils/lzma2/encoder.rb` ×2,
96
+ `seven_zip/lzma2/encoder.rb` ×1).
97
+
98
+ The separate comment at the two XZ Utils call sites is **kept**: it explains why
99
+ `byteslice` rather than `[]`, citing
100
+ https://bugs.ruby-lang.org/issues/15985 (the `[]` operator can return extra
101
+ bytes around NULs). That reason is real and outlives the compat shim.
102
+
103
+ ## What was retained as feature detection (45)
104
+
105
+ Grouped by what the object actually is. `algorithms/lzma.rb:200` is deliberately
106
+ excluded from these counts and covered in the next section.
107
+
108
+ - **Caller-supplied IO** (24) — `formats/xz_impl/stream_decoder.rb` (8),
109
+ `formats/xz_impl/block_decoder.rb` (3), `algorithms/lzma/xz_utils_decoder.rb`
110
+ (2), `file_type.rb` (3), `io/source.rb` (4), `io/stream_manager.rb` (2),
111
+ `io/buffered_input.rb`, `io/buffered_output.rb`. Ruby has no `Seekable` or
112
+ `Ungettable` interface to type against; `StringIO`, `File`, `Socket`,
113
+ `Tempfile` and a user's own duck all differ in exactly `seek` / `pos` / `size`
114
+ / `ungetbyte` / `set_encoding` / `close`.
115
+ - **Public `options` parameter** (7) — `algorithms/lzma.rb` (4),
116
+ `algorithms/bzip2.rb`, `algorithms/deflate.rb`, `algorithms/zstandard.rb`.
117
+ - **Open registries** (4) — `models/filter_config.rb`,
118
+ `commands/profile_show_command.rb`, `convenience.rb` (2).
119
+ - **Other caller-supplied objects** (10) — foreign archives and entries in
120
+ `extraction/selective_extractor.rb` (5) and `extraction/filter_chain.rb` (3),
121
+ an output sink in `implementations/seven_zip/lzma/encoder.rb`, and a metadata
122
+ object in `metadata/metadata_validator.rb`.
123
+
124
+ ## The one exception (1)
125
+
126
+ `algorithms/lzma.rb:200` is annotated *"type check deferred, not feature
127
+ detection"*. That annotation is accurate and the code is honest, but it means
128
+ **track 07 is complete except for this single site**. It is a type check that
129
+ survives, not a capability probe.
130
+
131
+ ```ruby
132
+ level = if options.respond_to?(:level)
133
+ ```
134
+
135
+ Two unrelated value objects reach `build_encoder_options` carrying `#level`:
136
+ `Models::CompressionOptions` and
137
+ `Formats::Rar::Rar5::Solid::SolidEncoder::LzmaOptions` (`solid_encoder.rb:95`,
138
+ reaching this method from `:52`). They share no ancestor. Rewriting the check to
139
+ `is_a?(Models::CompressionOptions)` would send solid-RAR5 compression down the
140
+ `else` branch and pin its level to 5 instead of the configured 1–5 — a silent
141
+ regression, which is why the conversion was rejected rather than attempted.
142
+
143
+ **What would close it:** unify `SolidEncoder::LzmaOptions`,
144
+ `Compression::LZMA::LzmaOptions` (`rar5/compression/lzma.rb:184`) and
145
+ `Models::CompressionOptions` into one type. That is RAR5 work, it breaks
146
+ `solid_encoder_spec.rb` and `lzma_spec.rb` assertions on `dict_size`, and
147
+ `CompressionOptions` names the attribute `dictionary_size`. Once those are one
148
+ type, this site converts to `is_a?` in a single line and track 07 is
149
+ unconditionally done.
150
+
151
+ Three cheaper-looking alternatives were considered and rejected: reaching from
152
+ `algorithms/` into `formats/rar/rar5/solid/` with a two-armed `is_a?`; a shared
153
+ `LevelledOptions` mixin introduced for exactly two call sites; and replacing
154
+ `LzmaOptions` with `CompressionOptions` outright, which the specs block.
155
+
156
+ ## Corrections to the previous version of this document
157
+
158
+ - The count was **60**, not 109.
159
+ - Category B (`job.respond_to?(:size)` in `parallel/job_scheduler.rb`) and
160
+ category E (`opts.respond_to?(:"#{k}=")`) were **already done**.
161
+ - Category D was wrong. No `File.respond_to?(:symlink)` / `(:link)` sites
162
+ remained and `link_handler.rb` had none at all. The survivors filed under D
163
+ were `io.respond_to?(:pos)` / `(:seek)` in `file_type.rb`, which are **IO**
164
+ capability checks. The proposed `Platform.seekable?(io)` was rejected:
165
+ seekability is not a platform property, and the helper would still have
166
+ contained `respond_to?`, merely relocated.
167
+ - `formats/ole/` contained **zero** occurrences. The capability cluster was
168
+ `formats/xz_impl/` plus `algorithms/lzma/xz_utils_decoder.rb`.
169
+ - Category C was partly done already: `Omnizip::Entry` existed and was included
170
+ by 12 entry classes. Converting `extraction/filter_chain.rb` to use it was
171
+ attempted and rejected — below.
172
+
173
+ ## Rejected: converting `extraction/filter_chain.rb`
174
+
175
+ `FilterChain#extract_filename` still walks `name` / `path` / `filename`. Routing
176
+ it through `Omnizip::Entry` looked like the intended fix, but a foreign entry
177
+ with `name == "secret.txt"` and `to_s == "opaque"` matches `*.txt` today and
178
+ would not afterwards. Because these filters drive **exclusion** as well as
179
+ inclusion, a filter that stops matching means a file the caller meant to block
180
+ gets extracted. `Extraction.extract_with_filter` is public, so this is
181
+ reachable. The conversion needs every entry reaching `FilterChain` to include
182
+ `Omnizip::Entry`, which the public API cannot guarantee.
183
+
184
+ ## Bug found while doing this work
185
+
186
+ Auditing `IO::Source` for this track surfaced a live `NameError` in shipped
187
+ code. `io/source.rb` had **no `require` statements at all**, while `Source.for`
188
+ and `Sink.for` both `case` on `::IO, ::StringIO, ::Tempfile`. After a bare
189
+ `require "omnizip"` neither `StringIO` nor `Tempfile` is defined, so any input
190
+ that was not already an `::IO` raised:
191
+
192
+ ```
193
+ $ ruby -Ilib -e 'require "omnizip"
194
+ sink = Object.new; def sink.write(d); end
195
+ Omnizip::Formats::Xz.create("hello", sink)'
196
+ NameError: uninitialized constant Tempfile
197
+ ```
198
+
199
+ The adapter had 12 production call sites across `Formats::Xz`, `Formats::Lzip`,
200
+ `Formats::LzmaAlone` and `Algorithms::LZMA2`. The failure moved with the caller
201
+ — whichever constant the load path happened to pull in first appeared to work —
202
+ which is why it survived.
203
+
204
+ Fixed in its own commit across 15 files: 12 requires added to 11 files that
205
+ referenced a constant without requiring it, and 4 requires relocated out of
206
+ method and class bodies into the file prologue.
207
+
208
+ `spec/omnizip/constant_require_spec.rb` enforces the invariant statically —
209
+ `Ripper.lex` finds the constant references, `Ripper.sexp` confirms the matching
210
+ require is a top-level unconditional statement in the prologue, accepting both
211
+ `require "x"` and `require("x")`. `spec/omnizip/clean_load_spec.rb` adds four
212
+ subprocess regression proofs. Those must shell out: an in-process example passes
213
+ on the broken library, because `spec_helper` and sibling specs load both
214
+ constants as a side effect. That is the same masking that hid the bug.
87
215
 
88
216
  ## Acceptance
89
217
 
90
- `grep -rn "respond_to?" lib/` returns zero matches (or each remaining
91
- occurrence is annotated with `# allowed: <reason>`).
218
+ - `grep -rn "respond_to?" lib/` 49: 46 annotated code sites (45 feature
219
+ detection + 1 deferred type check) and 3 doc comments.
220
+ - `spec/omnizip/respond_to_annotation_spec.rb` passes, and fails loudly on any
221
+ new unannotated occurrence.
222
+ - The one deferred type check is tracked in "The one exception" above, not
223
+ quietly folded into the feature-detection count.
224
+
225
+ ## Follow-ups
226
+
227
+ - Unify `SolidEncoder::LzmaOptions`, `Compression::LZMA::LzmaOptions` and
228
+ `Models::CompressionOptions`, which would let `algorithms/lzma.rb:200`
229
+ convert.
230
+ - Normalize algorithm `options` to a single type. `Algorithm.compress(data,
231
+ level: 9)` forwards a Hash, so `bzip2`, `deflate` and `zstandard` silently
232
+ drop the level today — reached from `formats/zip/writer.rb:390`,
233
+ `zip/output_stream.rb:278` and `parallel/parallel_compressor.rb:104`. This
234
+ changes compressed output, so it needs its own pass. It would delete most of
235
+ the `options` annotations.
236
+ - Give `FilterChain` a guaranteed `Omnizip::Entry` contract, which would let
237
+ `extract_filename` convert.
@@ -16,6 +16,8 @@
16
16
  # See the COPYING file for the complete text of the license.
17
17
  #
18
18
 
19
+ require "stringio"
20
+
19
21
  module Omnizip
20
22
  # Abstract base class for compression algorithms.
21
23
  #
@@ -23,8 +25,6 @@ module Omnizip
23
25
  # the required methods. Algorithms are automatically registered with the
24
26
  # AlgorithmRegistry when defined.
25
27
  class Algorithm
26
- require "stringio"
27
-
28
28
  attr_reader :options, :filter
29
29
 
30
30
  # Initialize algorithm with options.
@@ -99,6 +99,7 @@ module Omnizip
99
99
 
100
100
  opts = {}
101
101
 
102
+ # allowed: options is a public parameter; any object with #level is honored
102
103
  if options.respond_to?(:level)
103
104
  level = options.level || 9
104
105
  opts[:block_size] = block_size_for_level(level)
@@ -94,6 +94,7 @@ module Omnizip
94
94
 
95
95
  opts = {}
96
96
 
97
+ # allowed: options is a public parameter; any object with #level is honored
97
98
  if options.respond_to?(:level)
98
99
  opts[:level] = map_compression_level(options.level)
99
100
  end
@@ -288,9 +288,7 @@ check_rc_finished: true)
288
288
  end
289
289
 
290
290
  # Read range decoder init bytes (must happen after set_input sets correct stream)
291
- if @range_decoder.respond_to?(:read_init_bytes) && @range_decoder.init_bytes_remaining.positive?
292
- @range_decoder.read_init_bytes
293
- end
291
+ @range_decoder.read_init_bytes if @range_decoder.init_bytes_remaining.positive?
294
292
 
295
293
  # Main decoding loop
296
294
  # XZ Utils pattern (lzma_decoder.c:305-306):
@@ -439,6 +437,7 @@ check_rc_finished: true)
439
437
  next_byte = stream.getbyte
440
438
  if next_byte
441
439
  # Put the byte back
440
+ # allowed: caller-supplied stream; peek-back is optional
442
441
  stream.ungetbyte(next_byte) if stream.respond_to?(:ungetbyte)
443
442
  raise Omnizip::DecompressionError,
444
443
  "LZMA_Alone file has data after the end-of-payload marker. The file may be corrupted or contain concatenated streams."
@@ -452,6 +451,7 @@ check_rc_finished: true)
452
451
  begin
453
452
  next_byte = stream.getbyte
454
453
  if next_byte
454
+ # allowed: caller-supplied stream; peek-back is optional
455
455
  stream.ungetbyte(next_byte) if stream.respond_to?(:ungetbyte)
456
456
  raise Omnizip::DecompressionError,
457
457
  "LZMA_Alone file has more compressed data than expected. The uncompressed size field (#{@uncompressed_size} bytes) appears to be too small."
@@ -155,6 +155,7 @@ module Omnizip
155
155
  decoder_opts[:uncompressed_size] =
156
156
  @uncompressed_size
157
157
  end
158
+ # allowed: options is a public parameter; the body needs #key? and #[]
158
159
  decoder_opts[:uncompressed_size] ||= options[:size] if options.respond_to?(:key?) && options.key?(:size)
159
160
 
160
161
  decoder = Decoder.new(input_stream, decoder_opts)
@@ -174,6 +175,7 @@ module Omnizip
174
175
  opts = {}
175
176
 
176
177
  # Handle Hash-like options
178
+ # allowed: options is a public parameter; the body needs #[] and #key?
177
179
  if options.respond_to?(:[])
178
180
  opts[:lc] = options[:lc] if options[:lc]
179
181
  opts[:lp] = options[:lp] if options[:lp]
@@ -192,13 +194,16 @@ module Omnizip
192
194
  end
193
195
 
194
196
  # Handle level from both Hash and CompressionOptions
197
+ # Models::CompressionOptions and SolidEncoder::LzmaOptions both expose
198
+ # #level and share no ancestor; converting needs them unified first.
199
+ # allowed: type check deferred, not feature detection
195
200
  level = if options.respond_to?(:level)
196
- options.level || 5
197
- elsif options.respond_to?(:[]) && options[:level]
198
- options[:level] || 5
199
- else
200
- 5
201
+ options.level
202
+ # allowed: reader-style options are handled above; this arm needs #[]
203
+ elsif options.respond_to?(:[])
204
+ options[:level]
201
205
  end
206
+ level ||= 5
202
207
 
203
208
  opts[:dict_size] ||= dictionary_size_for_level(level)
204
209
 
@@ -218,6 +223,7 @@ module Omnizip
218
223
  opts = {}
219
224
 
220
225
  # Handle Hash-like options - pass through all decoder-relevant options
226
+ # allowed: options is a public parameter; the body needs #key? and #[]
221
227
  if options.respond_to?(:key?)
222
228
  if options.key?(:sdk_compatible)
223
229
  opts[:sdk_compatible] =
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "stringio"
4
+
3
5
  module Omnizip
4
6
  module Algorithms
5
7
  # LZMA2 encoder - delegates to XzLZMA2Encoder
@@ -20,6 +20,8 @@
20
20
  # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
21
21
  # DEALINGS IN THE SOFTWARE.
22
22
 
23
+ require "stringio"
24
+
23
25
  module Omnizip
24
26
  module Algorithms
25
27
  # Adapter for XZ Encoder to work with LZMA2 chunking
@@ -20,6 +20,8 @@
20
20
  # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
21
21
  # DEALINGS IN THE SOFTWARE.
22
22
 
23
+ require "stringio"
24
+
23
25
  module Omnizip
24
26
  module Algorithms
25
27
  # LZMA2 compression algorithm
@@ -20,6 +20,8 @@
20
20
  # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
21
21
  # DEALINGS IN THE SOFTWARE.
22
22
 
23
+ require "stringio"
24
+
23
25
  module Omnizip
24
26
  module Algorithms
25
27
  # PPMd7 compression algorithm
@@ -20,6 +20,8 @@
20
20
  # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
21
21
  # DEALINGS IN THE SOFTWARE.
22
22
 
23
+ require "stringio"
24
+
23
25
  module Omnizip
24
26
  module Algorithms
25
27
  # Base class for PPMd (Prediction by Partial Matching) algorithms
@@ -19,7 +19,7 @@ end
19
19
 
20
20
  # Now register all algorithms
21
21
  Omnizip::AlgorithmRegistry.register(:lzma, Omnizip::Algorithms::LZMA)
22
- Omnizip::Algorithms::LZMA2.register_algorithm if Omnizip::Algorithms::LZMA2.respond_to?(:register_algorithm)
22
+ Omnizip::Algorithms::LZMA2.register_algorithm
23
23
  Omnizip::AlgorithmRegistry.register(:ppmd7, Omnizip::Algorithms::PPMd7)
24
24
  Omnizip::AlgorithmRegistry.register(:ppmd8, Omnizip::Algorithms::PPMd8)
25
25
  Omnizip::AlgorithmRegistry.register(:bzip2, Omnizip::Algorithms::BZip2)