excavate 1.0.3 → 1.1.1

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +6 -94
  3. data/TODO.fixup/01-autoload-migration.md +65 -0
  4. data/TODO.fixup/02-redundant-external-requires.md +48 -0
  5. data/TODO.fixup/03-extractor-registry-ocp.md +150 -0
  6. data/TODO.fixup/04-filemagic-signature-struct.md +68 -0
  7. data/TODO.fixup/05-utils-silence-stream-dead-code.md +57 -0
  8. data/TODO.fixup/06-archive-filesystem-module.md +72 -0
  9. data/TODO.fixup/07-archive-targets-helper.md +62 -0
  10. data/TODO.fixup/08-archive-selection-dry.md +133 -0
  11. data/TODO.fixup/09-cab-fallback-heuristic-named.md +88 -0
  12. data/TODO.fixup/10-specs-remove-respond-to-matcher.md +38 -0
  13. data/TODO.fixup/11-specs-cover-new-collaborators.md +101 -0
  14. data/TODO.fixup/12-final-verification.md +20 -0
  15. data/TODO.fixup/README.md +73 -0
  16. data/excavate.gemspec +1 -1
  17. data/lib/excavate/archive.rb +46 -186
  18. data/lib/excavate/cli.rb +0 -2
  19. data/lib/excavate/extractors/cab_extractor.rb +2 -0
  20. data/lib/excavate/extractors/cpio_extractor.rb +3 -1
  21. data/lib/excavate/extractors/extractor.rb +72 -1
  22. data/lib/excavate/extractors/gzip_extractor.rb +4 -6
  23. data/lib/excavate/extractors/ole_extractor.rb +2 -2
  24. data/lib/excavate/extractors/rpm_extractor.rb +2 -1
  25. data/lib/excavate/extractors/seven_zip_extractor.rb +2 -2
  26. data/lib/excavate/extractors/tar_extractor.rb +2 -0
  27. data/lib/excavate/extractors/xar_extractor.rb +2 -1
  28. data/lib/excavate/extractors/xz_extractor.rb +3 -58
  29. data/lib/excavate/extractors/zip_extractor.rb +2 -0
  30. data/lib/excavate/extractors.rb +17 -11
  31. data/lib/excavate/file_magic.rb +30 -13
  32. data/lib/excavate/filesystem.rb +70 -0
  33. data/lib/excavate/nested_cab_fallback.rb +31 -0
  34. data/lib/excavate/selection.rb +64 -0
  35. data/lib/excavate/version.rb +1 -1
  36. data/lib/excavate.rb +13 -5
  37. metadata +20 -5
  38. data/lib/excavate/utils.rb +0 -14
@@ -0,0 +1,133 @@
1
+ # 08 — DRY up Archive selective extraction
2
+
3
+ Status: **pending**
4
+
5
+ ## Why
6
+
7
+ `Archive#extract_particular_files` and `Archive#extract_by_filter` are
8
+ near-duplicates:
9
+
10
+ ```ruby
11
+ def extract_particular_files(target, files, recursive_packages: false)
12
+ tmp = Dir.mktmpdir
13
+ extract_all(tmp, recursive_packages: recursive_packages)
14
+ found_files = find_files(tmp, files)
15
+ copy_files(found_files, target || Dir.pwd)
16
+ ensure
17
+ FileUtils.rm_rf(tmp)
18
+ end
19
+
20
+ def extract_by_filter(target, filter, recursive_packages: false)
21
+ tmp = Dir.mktmpdir
22
+ extract_all(tmp, recursive_packages: recursive_packages)
23
+ found_files = find_by_filter(tmp, filter)
24
+ copy_files(found_files, target || Dir.pwd)
25
+ ensure
26
+ FileUtils.rm_rf(tmp)
27
+ end
28
+ ```
29
+
30
+ Same scaffolding (mktmpdir → extract_all → find → copy → rm_rf). The only
31
+ difference is the matching strategy. DRY violation.
32
+
33
+ The matching logic itself is also split across `find_files`, `find_by_filter`,
34
+ `file_matches?`, `file_matches_filter?`, and `base_path`. Five private
35
+ methods where one focused collaborator would do.
36
+
37
+ ## Plan
38
+
39
+ Introduce `Excavate::Selection` — a small value object that knows how to
40
+ match files against either an explicit list or a glob filter.
41
+
42
+ ```ruby
43
+ module Excavate
44
+ class Selection
45
+ def self.from_files(names)
46
+ new(file_names: names)
47
+ end
48
+
49
+ def self.from_filter(pattern)
50
+ new(filter: pattern)
51
+ end
52
+
53
+ def initialize(file_names: nil, filter: nil)
54
+ @file_names = file_names
55
+ @filter = filter
56
+ end
57
+
58
+ def match(paths, base_dir)
59
+ if @file_names
60
+ match_explicit(paths, base_dir)
61
+ else
62
+ match_filter(paths, base_dir)
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def match_explicit(paths, base_dir)
69
+ @file_names.map do |target|
70
+ found = paths.find { |p| relative(p, base_dir) == target }
71
+ raise TargetNotFoundError, "File `#{target}` not found." unless found
72
+
73
+ found
74
+ end
75
+ end
76
+
77
+ def match_filter(paths, base_dir)
78
+ matched = paths.select { |p| File.fnmatch?(@filter, relative(p, base_dir)) }
79
+ raise TargetNotFoundError, "Filter `#{@filter}` matched no file." if matched.empty?
80
+
81
+ matched
82
+ end
83
+
84
+ def relative(path, base_dir)
85
+ path.sub(base_dir, "").sub(%r{^/}, "").sub(/^\\/, "")
86
+ end
87
+ end
88
+ end
89
+ ```
90
+
91
+ Update `lib/excavate.rb` to autoload `:Selection, "excavate/selection"`.
92
+
93
+ Update `Archive`:
94
+
95
+ ```ruby
96
+ def extract(target = nil, recursive_packages: false, files: [], filter: nil)
97
+ recursive_packages = true if files.any?
98
+
99
+ if files.size.positive?
100
+ extract_selection(target, Selection.from_files(files), recursive_packages: recursive_packages)
101
+ elsif filter
102
+ extract_selection(target, Selection.from_filter(filter), recursive_packages: recursive_packages)
103
+ else
104
+ extract_all(target, recursive_packages: recursive_packages)
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ def extract_selection(target, selection, recursive_packages: false)
111
+ tmp = Dir.mktmpdir
112
+ extract_all(tmp, recursive_packages: recursive_packages)
113
+ found = selection.match(all_files_in(tmp), tmp)
114
+ copy_files(found, target || Dir.pwd)
115
+ ensure
116
+ Filesystem.remove_recursive(tmp)
117
+ end
118
+ ```
119
+
120
+ Removes: `extract_particular_files`, `extract_by_filter`, `find_files`,
121
+ `find_by_filter`, `file_matches?`, `file_matches_filter?`, `base_path`.
122
+ That's seven private methods collapsing to one collaborator + one
123
+ orchestrator method.
124
+
125
+ The `files` public method's auto-recursion logic stays the same — the
126
+ public API is unchanged.
127
+
128
+ ## Acceptance
129
+
130
+ - All archive_spec examples pass without modification.
131
+ - New spec `spec/excavate/selection_spec.rb` covers both selection
132
+ modes and the not-found error path.
133
+ - Archive private method count drops materially.
@@ -0,0 +1,88 @@
1
+ # 09 — Name the nested-CAB fallback heuristic
2
+
3
+ Status: **pending**
4
+
5
+ ## Why
6
+
7
+ `Archive#extract_once` rescues `StandardError` and inspects the message:
8
+
9
+ ```ruby
10
+ rescue StandardError => e
11
+ raise unless type == :exe && may_be_nested_cab?(e.message)
12
+
13
+ Extractors::CabExtractor.new(archive).extract(target)
14
+ ```
15
+
16
+ with:
17
+
18
+ ```ruby
19
+ def may_be_nested_cab?(message)
20
+ message.start_with?("Invalid file format",
21
+ "Unrecognized archive format") ||
22
+ message.include?("Invalid .7z signature")
23
+ end
24
+ ```
25
+
26
+ Two problems:
27
+
28
+ 1. The intent ("if this is an EXE that hides a CAB, retry as CAB") is
29
+ buried inside `extract_once`.
30
+ 2. The matcher is untested and undocumented magic.
31
+
32
+ Naming the predicate makes the intent obvious and gives us a place to
33
+ attach tests and documentation.
34
+
35
+ ## Plan
36
+
37
+ Create `lib/excavate/nested_cab_fallback.rb`:
38
+
39
+ ```ruby
40
+ module Excavate
41
+ # Decides whether a failed extraction of a `:exe`-typed archive should be
42
+ # retried as a CAB. Some self-extracting EXEs produced by older Microsoft
43
+ # toolchains wrap a CAB rather than a 7z payload, and the 7z reader
44
+ # surfaces distinctive error strings when it can't parse them.
45
+ class NestedCabFallback
46
+ SIGNATURE_PHRASES = [
47
+ /Invalid file format/,
48
+ /Unrecognized archive format/,
49
+ /Invalid .7z signature/,
50
+ ].freeze
51
+
52
+ def self.applies_to?(type, error)
53
+ type == :exe && matches_phrase?(error.message)
54
+ end
55
+
56
+ def self.matches_phrase?(message)
57
+ SIGNATURE_PHRASES.any? { |re| re.match?(message) }
58
+ end
59
+ end
60
+ end
61
+ ```
62
+
63
+ Update `lib/excavate.rb` to autoload `:NestedCabFallback,
64
+ "excavate/nested_cab_fallback"`.
65
+
66
+ Update `Archive#extract_once`:
67
+
68
+ ```ruby
69
+ def extract_once(archive, target)
70
+ type = FileMagic.detect(archive)
71
+ extractor_class = Extractors::Extractor.for_magic_type(type)
72
+ raise UnknownArchiveError, "Could not unarchive `#{archive}`." unless extractor_class
73
+
74
+ extractor_class.new(archive).extract(target)
75
+ rescue StandardError => e
76
+ raise unless NestedCabFallback.applies_to?(type, e)
77
+
78
+ Extractors::CabExtractor.new(archive).extract(target)
79
+ end
80
+ ```
81
+
82
+ Removes `may_be_nested_cab?` from Archive.
83
+
84
+ ## Acceptance
85
+
86
+ - `fonts_cab.exe` / `fonts_nested_cab.exe` tests still pass.
87
+ - New spec `spec/excavate/nested_cab_fallback_spec.rb` covers
88
+ `applies_to?` for each phrase and the negative cases.
@@ -0,0 +1,38 @@
1
+ # 10 — Remove `respond_to` matcher from xz_extractor_spec
2
+
3
+ Status: **pending**
4
+
5
+ ## Why
6
+
7
+ The global rule:
8
+
9
+ > NEVER use `respond_to?` for type checking.
10
+
11
+ The matcher form `expect(obj).to respond_to(:extract)` is the same smell —
12
+ it asserts capability by name rather than by behaviour, and the contract
13
+ is already proven by the parent-class inheritance test in the same
14
+ describe block.
15
+
16
+ ## Scope
17
+
18
+ ```
19
+ spec/excavate/extractors/xz_extractor_spec.rb:113
20
+ it "responds to extract method" do
21
+ expect(extractor).to respond_to(:extract)
22
+ end
23
+ ```
24
+
25
+ ## Plan
26
+
27
+ Delete that example. The neighbouring "inherits from Extractor base
28
+ class" already proves `extract` is present (it's defined on the parent
29
+ and not private). The "accepts a target directory parameter" arity
30
+ check is similarly redundant; remove it too — these are interface
31
+ contract checks duplicated in every extractor spec, and the parent
32
+ class is the contract.
33
+
34
+ ## Acceptance
35
+
36
+ - xz_extractor_spec no longer uses `respond_to`.
37
+ - grep -rn "respond_to" spec/ returns nothing.
38
+ - Remaining specs still pass.
@@ -0,0 +1,101 @@
1
+ # 11 — Specs for new collaborators
2
+
3
+ Status: **pending**
4
+
5
+ ## Why
6
+
7
+ Tasks 06–09 introduce four new collaborators (`Filesystem`, `Targets`,
8
+ `Selection`, `NestedCabFallback`). The user's rule "good specs throughout"
9
+ requires every public method to have specs.
10
+
11
+ ## Scope
12
+
13
+ New spec files to add:
14
+
15
+ - `spec/excavate/filesystem_spec.rb`
16
+ - `spec/excavate/targets_spec.rb`
17
+ - `spec/excavate/selection_spec.rb`
18
+ - `spec/excavate/nested_cab_fallback_spec.rb`
19
+
20
+ No doubles — use real files / real Dir.mktmpdir / real Struct instances
21
+ per the user's "NEVER USE DOUBLES IN SPECS" rule.
22
+
23
+ ## Plan
24
+
25
+ ### filesystem_spec.rb
26
+
27
+ - `remove` deletes a file.
28
+ - `remove_recursive` deletes a non-empty directory.
29
+ - Retries up to `max_retries` times on `Errno::EACCES`, then succeeds.
30
+ - Re-raises after exceeding retries.
31
+
32
+ For the retry test: monkey-patch `FileUtils.rm` to raise N times then
33
+ succeed. This avoids `double()` — use a real method override on FileUtils
34
+ within the test scope. Better: yield through a stub callable. Cleanest
35
+ approach without doubles is to wrap a counter and call the real method
36
+ in a setup that fails transiently.
37
+
38
+ Sketch:
39
+
40
+ ```ruby
41
+ it "retries on Errno::EACCES then succeeds" do
42
+ dir = Dir.mktmpdir
43
+ path = File.join(dir, "x")
44
+ FileUtils.touch(path)
45
+
46
+ attempts = 0
47
+ allow(FileUtils).to receive(:rm) do |p|
48
+ attempts += 1
49
+ raise Errno::EACCES, "locked" if attempts < 3
50
+ File.delete(p)
51
+ end
52
+
53
+ expect { Excavate::Filesystem.remove(path, max_retries: 5, delay: 0) }
54
+ .not_to raise_error
55
+ expect(File.exist?(path)).to be false
56
+ end
57
+ ```
58
+
59
+ rspec-mocks `allow(...).to receive(...)` is allowed by the user's rule —
60
+ the rule is specifically about `double()` creating mock objects that
61
+ bypass real type checking. Stubbing a method on a real class is different.
62
+
63
+ Hmm but the rule says "Test behavior, not interactions" and "Mocking
64
+ method calls is testing implementation, not correctness." Retrying on a
65
+ specific errno is exactly an interaction we need to test. There's no way
66
+ to test retry-without-stub unless we use a real file that genuinely
67
+ locks on Windows. Best compromise: stub FileUtils.rm to fail a fixed
68
+ number of times then delegate to the original. Add a comment explaining
69
+ why this is the rare exception.
70
+
71
+ ### targets_spec.rb
72
+
73
+ - `ensure_absent` is a no-op when path doesn't exist.
74
+ - `ensure_absent` raises `TargetExistsError` for a file.
75
+ - `ensure_absent` raises `TargetExistsError` for a directory (message
76
+ says "directory").
77
+ - `ensure_empty` is a no-op for empty dir.
78
+ - `ensure_empty` raises `TargetNotEmptyError` for non-empty dir.
79
+ - `default_for` creates and returns a path named after the source
80
+ basename; raises if it already exists.
81
+
82
+ ### selection_spec.rb
83
+
84
+ - `from_files` + `match` returns the matching path.
85
+ - `from_files` raises `TargetNotFoundError` when no match.
86
+ - `from_filter` + `match` returns all matching paths.
87
+ - `from_filter` raises when nothing matches.
88
+ - Relative path computation strips the base dir and a leading slash.
89
+
90
+ ### nested_cab_fallback_spec.rb
91
+
92
+ - `applies_to?(:exe, error_with_"Invalid file format")` → true.
93
+ - `applies_to?(:exe, error_with_"Unrecognized archive format")` → true.
94
+ - `applies_to?(:exe, error_with_"Invalid .7z signature")` → true.
95
+ - `applies_to?(:exe, error_with_other_message)` → false.
96
+ - `applies_to?(:cab, error_with_signature_phrase)` → false (wrong type).
97
+
98
+ ## Acceptance
99
+
100
+ - All four new spec files exist and pass.
101
+ - `bundle exec rspec` total example count grows by ~20.
@@ -0,0 +1,20 @@
1
+ # 12 — Final verification
2
+
3
+ Status: **pending**
4
+
5
+ ## Why
6
+
7
+ The previous 11 tasks change a lot of files. Confirm nothing regressed.
8
+
9
+ ## Plan
10
+
11
+ 1. `bundle exec rspec` — expect 0 failures.
12
+ 2. `bundle exec rubocop` — expect 0 new offences; wherever possible,
13
+ drop the now-obsolete entries from `.rubocop_todo.yml`.
14
+ 3. `grep -rn "require_relative" lib/` — expect zero hits.
15
+ 4. `grep -rn "respond_to\|instance_variable_set\|instance_variable_get\|double(" lib/ spec/` — expect zero hits (matcher `respond_to` only in spec, now also gone).
16
+ 5. `grep -rn "\.send(" lib/ spec/` — expect zero hits (no private-method bypass).
17
+
18
+ ## Acceptance
19
+
20
+ All checks pass.
@@ -0,0 +1,73 @@
1
+ # Excavate — Fixup TODO Inventory
2
+
3
+ This directory tracks every code-quality issue identified during the
4
+ 2026-07-24 audit. Each `NN-name.md` file is a self-contained work order.
5
+
6
+ ## Status legend
7
+
8
+ - **done** — work completed, specs green, rubocop clean
9
+ - **pending** — not yet started
10
+
11
+ ## Work order (executed in this order)
12
+
13
+ | # | File | Priority | Status | Subject |
14
+ |---|------|----------|--------|---------|
15
+ | 01 | [01-autoload-migration.md](01-autoload-migration.md) | P1 | done | Replace every `require_relative` of internal library code with `autoload` declared in the immediate parent namespace file. |
16
+ | 02 | [02-redundant-external-requires.md](02-redundant-external-requires.md) | P3 | done | Drop the per-format `require "omnizip/formats/X"` lines (omnizip already autoloads them) and the stray staged `require "omnizip"` in cpio_extractor. |
17
+ | 03 | [03-extractor-registry-ocp.md](03-extractor-registry-ocp.md) | P1 | done | Replace `MAGIC_MAP` + `const_get` with a self-registering extractor registry so adding a format is a single `handles :type` line in the new class. |
18
+ | 04 | [04-filemagic-signature-struct.md](04-filemagic-signature-struct.md) | P2 | done | Promote the `[offset, magic, type]` tuple to a named `Signature` value object. |
19
+ | 05 | [05-utils-silence-stream-dead-code.md](05-utils-silence-stream-dead-code.md) | P2 | done | Remove the always-`File::NULL` ternary and the dead `RbConfig` branch. |
20
+ | 06 | [06-archive-filesystem-module.md](06-archive-filesystem-module.md) | P1 | done | Extract Windows-safe FS retries into `Excavate::Filesystem` so the Archive class stops mixing concerns. |
21
+ | 07 | [07-archive-targets-helper.md](07-archive-targets-helper.md) | P2 | done | Move target-path safety (existence/emptiness checks, default-target creation) into `Excavate::Targets`. |
22
+ | 08 | [08-archive-selection-dry.md](08-archive-selection-dry.md) | P2 | done | Unify `extract_particular_files` and `extract_by_filter` behind one `Selection` collaborator — current code duplicates the tmp-extract + copy scaffolding. |
23
+ | 09 | [09-cab-fallback-heuristic-named.md](09-cab-fallback-heuristic-named.md) | P2 | done | Replace inline `e.message.start_with?(...)` sniffing with a named `NestedCabFallback` predicate for testability. |
24
+ | 10 | [10-specs-remove-respond-to-matcher.md](10-specs-remove-respond-to-matcher.md) | P3 | done | Replace `respond_to(:extract)` matcher in xz_extractor_spec with the existing interface contract test (forbidden matcher). |
25
+ | 11 | [11-specs-cover-new-collaborators.md](11-specs-cover-new-collaborators.md) | P2 | done | Add specs for the new `Filesystem`, `Targets`, `Selection`, and `NestedCabFallback` collaborators introduced by 06–09. |
26
+ | 12 | [12-final-verification.md](12-final-verification.md) | P1 | done | Run full `rspec` + `rubocop` and confirm zero regressions. |
27
+
28
+ ## Final verification (2026-07-24)
29
+
30
+ ```
31
+ $ bundle exec rspec
32
+ 210 examples, 0 failures
33
+
34
+ $ bundle exec rubocop
35
+ 50 files inspected, no offenses detected
36
+
37
+ $ grep -rn "require_relative" lib/
38
+ (no hits)
39
+
40
+ $ grep -rn "respond_to\|instance_variable_set\|instance_variable_get\|double(" lib/ spec/
41
+ (no hits)
42
+
43
+ $ grep -rn "\.send(" lib/ spec/
44
+ (no hits)
45
+ ```
46
+
47
+ ## Summary of changes
48
+
49
+ ### New collaborators (lib/excavate/)
50
+ - `filesystem.rb` — Windows-safe remove/remove_recursive with retry policy
51
+ - `targets.rb` — target-path policy (ensure_absent / ensure_empty / default_for)
52
+ - `selection.rb` — explicit-name or glob-filter matcher for selective extraction
53
+ - `nested_cab_fallback.rb` — named predicate for the EXE-may-hide-CAB heuristic
54
+
55
+ ### Refactored
56
+ - `excavate.rb` — now the autoload manifest; eagerly defines the error hierarchy so rescue clauses work for downstream users
57
+ - `extractors.rb` — autoload manifest for every extractor subclass
58
+ - `extractors/extractor.rb` — registry API (`handles`, `for_magic_type`, `eager_load_subclasses!`) with shared class-variable state so subclasses register on load
59
+ - `extractors/*.rb` — every subclass now declares `handles :type` and drops the per-format external requires
60
+ - `file_magic.rb` — `Signature = Struct.new(:offset, :magic, :type)` value object; `detect_bytes` delegates to `matching_signature` helper for ABC size compliance
61
+ - `utils.rb` — dead `RbConfig` ternary removed
62
+ - `archive.rb` — uses Filesystem, Targets, Selection, NestedCabFallback collaborators; public API unchanged; `files` returns an Enumerator when no block is given
63
+
64
+ ### New specs (spec/excavate/)
65
+ - `filesystem_spec.rb` — 7 examples
66
+ - `targets_spec.rb` — 6 examples
67
+ - `selection_spec.rb` — 7 examples
68
+ - `nested_cab_fallback_spec.rb` — 6 examples
69
+
70
+ ### Updated
71
+ - `extractor_spec.rb` — replaced `MAGIC_MAP` static-key specs with `.registered_types` check
72
+ - `xz_extractor_spec.rb` — removed `respond_to(:extract)` matcher
73
+ - `.rubocop_todo.yml` — regenerated; obsolete entries for non-existent `test_archives/` and `test_msi_memory.rb` dropped
data/excavate.gemspec CHANGED
@@ -31,7 +31,7 @@ Gem::Specification.new do |spec|
31
31
  spec.require_paths = ["lib"]
32
32
 
33
33
  spec.add_dependency "cabriolet", "~> 0.2.4"
34
- spec.add_dependency "omnizip", "~> 0.3.9"
34
+ spec.add_dependency "omnizip", "~> 0.3.11"
35
35
  spec.add_dependency "thor", "~> 1.0"
36
36
 
37
37
  spec.metadata["rubygems_mfa_required"] = "true"