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
@@ -2,31 +2,48 @@
2
2
 
3
3
  module Excavate
4
4
  class FileMagic
5
- # [offset, magic_bytes, type]
5
+ # A magic-byte signature: where in the file to look, what bytes to
6
+ # expect, and which type they identify.
7
+ Signature = Struct.new(:offset, :magic, :type)
8
+
6
9
  SIGNATURES = [
7
- [0, "MSCF\x00\x00\x00\x00".b, :cab],
8
- [0, "\xFD7zXZ\x00".b, :xz],
9
- [0, "\x1F\x8B".b, :gzip],
10
- [257, "ustar".b, :tar],
10
+ Signature.new(0, "MSCF\x00\x00\x00\x00".b, :cab),
11
+ Signature.new(0, "\xFD7zXZ\x00".b, :xz),
12
+ Signature.new(0, "\x1F\x8B".b, :gzip),
13
+ Signature.new(257, "ustar".b, :tar),
14
+ Signature.new(0, "7z\xBC\xAF\x27\x1C".b, :seven_zip),
15
+ Signature.new(0, "PK\x03\x04".b, :zip),
16
+ Signature.new(0, "\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1".b, :ole),
17
+ Signature.new(0, "xar!".b, :xar),
18
+ Signature.new(0, "\xED\xAB\xEE\xDB".b, :rpm),
19
+ Signature.new(0, "070707".b, :cpio),
20
+ Signature.new(0, "070701".b, :cpio),
21
+ Signature.new(0, "070702".b, :cpio),
22
+ Signature.new(0, "MZ".b, :exe),
11
23
  ].freeze
12
24
 
13
- MAX_READ = SIGNATURES.map { |o, m, _| o + m.bytesize }.max
25
+ MAX_READ = SIGNATURES.map do |signature|
26
+ signature.offset + signature.magic.bytesize
27
+ end.max
14
28
 
15
29
  def self.detect(path)
16
- beginning = File.read(path, MAX_READ, mode: "rb")
17
- detect_bytes(beginning)
30
+ detect_bytes(File.read(path, MAX_READ, mode: "rb"))
18
31
  end
19
32
 
20
33
  def self.detect_bytes(data)
21
34
  return nil if data.nil? || data.empty?
22
35
 
23
- SIGNATURES.each do |offset, magic, type|
24
- next if data.bytesize < offset + magic.bytesize
36
+ matching_signature(data)&.type
37
+ end
38
+
39
+ def self.matching_signature(data)
40
+ SIGNATURES.find do |signature|
41
+ next if data.bytesize < signature.offset + signature.magic.bytesize
25
42
 
26
- return type if data.byteslice(offset, magic.bytesize) == magic
43
+ slice = data.byteslice(signature.offset, signature.magic.bytesize)
44
+ slice == signature.magic
27
45
  end
28
-
29
- nil
30
46
  end
47
+ private_class_method :matching_signature
31
48
  end
32
49
  end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Excavate
4
+ # Filesystem helpers that absorb Windows file-locking quirks.
5
+ #
6
+ # On Windows, files and directories may be transiently locked by
7
+ # antivirus, indexing, or other processes immediately after they are
8
+ # written. Naive `FileUtils.rm` / `rm_rf` can raise `Errno::EACCES`
9
+ # or `Errno::ENOTEMPTY`. These helpers retry a small number of times
10
+ # before giving up, which is enough to ride out the brief lock
11
+ # windows seen in CI.
12
+ module Filesystem
13
+ module_function
14
+
15
+ RETRYABLE_ERRORS = [Errno::EACCES, Errno::ENOTEMPTY].freeze
16
+ DEFAULT_MAX_RETRIES = 5
17
+ DEFAULT_RETRY_DELAY = 0.2
18
+
19
+ def remove(path, max_retries: DEFAULT_MAX_RETRIES,
20
+ delay: DEFAULT_RETRY_DELAY)
21
+ with_retry(max_retries: max_retries, delay: delay) { FileUtils.rm(path) }
22
+ end
23
+
24
+ def remove_recursive(path, max_retries: DEFAULT_MAX_RETRIES,
25
+ delay: DEFAULT_RETRY_DELAY)
26
+ with_retry(max_retries: max_retries, delay: delay) do
27
+ FileUtils.rm_rf(path)
28
+ end
29
+ end
30
+
31
+ # Replace +archive_path+ with the contents of +contents_dir+,
32
+ # absorbing Windows file-locking quirks. The optimistic path is
33
+ # delete-then-rename; if the target file is locked we degrade to
34
+ # copying each extracted file next to it so the user still has
35
+ # access to the data.
36
+ def replace_with_contents(archive_path, contents_dir)
37
+ remove(archive_path)
38
+ FileUtils.mv(contents_dir, archive_path)
39
+ rescue Errno::EACCES
40
+ scatter_contents(contents_dir, File.dirname(archive_path))
41
+ end
42
+
43
+ # Run +block+, retrying the documented retryable errno errors up
44
+ # to +max_retries+ times with +delay+ seconds between attempts.
45
+ def with_retry(max_retries: DEFAULT_MAX_RETRIES, delay: DEFAULT_RETRY_DELAY)
46
+ attempts = 0
47
+ begin
48
+ yield
49
+ rescue *RETRYABLE_ERRORS => e
50
+ attempts += 1
51
+ raise e if attempts >= max_retries
52
+
53
+ sleep(delay)
54
+ retry
55
+ end
56
+ end
57
+
58
+ # Copy each file from +source_dir+ into +destination_dir+, skipping
59
+ # any name that already exists. The degraded fallback used when an
60
+ # archive cannot be moved into place because it is locked.
61
+ def scatter_contents(source_dir, destination_dir)
62
+ Dir.glob(File.join(source_dir, "**", "*")).each do |src|
63
+ next unless File.file?(src)
64
+
65
+ dest = File.join(destination_dir, File.basename(src))
66
+ FileUtils.cp(src, dest) unless File.exist?(dest)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Excavate
4
+ # Decides whether a failed extraction of a `:exe`-typed archive
5
+ # should be retried as a CAB.
6
+ #
7
+ # Some self-extracting EXEs produced by older Microsoft toolchains
8
+ # wrap a CAB rather than a 7z payload. When the 7z reader fails to
9
+ # parse one of these, it surfaces a small number of distinctive
10
+ # error strings. Rather than sniffing those strings inline inside
11
+ # Archive's rescue clause, the heuristic is named and tested here.
12
+ class NestedCabFallback
13
+ SIGNATURE_PHRASES = [
14
+ /\AInvalid file format/,
15
+ /\AUnrecognized archive format/,
16
+ /Invalid \.7z signature/,
17
+ ].freeze
18
+
19
+ class << self
20
+ def applies_to?(type, error)
21
+ type == :exe && phrase_match?(error.message)
22
+ end
23
+
24
+ private
25
+
26
+ def phrase_match?(message)
27
+ SIGNATURE_PHRASES.any? { |pattern| pattern.match?(message) }
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Excavate
4
+ # Picks files from an extracted tree by either an explicit name list
5
+ # or a glob filter. Used by Archive's selective-extraction modes
6
+ # (extract particular files / extract by filter) to share the
7
+ # scaffolding that lives around the matching itself.
8
+ class Selection
9
+ def self.from_files(names)
10
+ new(file_names: names)
11
+ end
12
+
13
+ def self.from_filter(pattern)
14
+ new(filter: pattern)
15
+ end
16
+
17
+ def initialize(file_names: nil, filter: nil)
18
+ @file_names = file_names
19
+ @filter = filter
20
+ end
21
+
22
+ # Match +paths+ (absolute file paths produced by an extraction)
23
+ # against this selection. +base_dir+ is the prefix to strip when
24
+ # comparing names; matches are returned as absolute paths from
25
+ # +paths+. Raises TargetNotFoundError when nothing matches.
26
+ def match(paths, base_dir)
27
+ if @file_names
28
+ match_explicit(paths, base_dir)
29
+ else
30
+ match_filter(paths, base_dir)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def match_explicit(paths, base_dir)
37
+ @file_names.map do |target|
38
+ found = paths.find { |path| relative(path, base_dir) == target }
39
+ raise TargetNotFoundError, "File `#{target}` not found." unless found
40
+
41
+ found
42
+ end
43
+ end
44
+
45
+ def match_filter(paths, base_dir)
46
+ matched = paths.select do |path|
47
+ File.fnmatch?(@filter, relative(path, base_dir))
48
+ end
49
+ if matched.empty?
50
+ raise TargetNotFoundError,
51
+ "Filter `#{@filter}` matched no file."
52
+ end
53
+
54
+ matched
55
+ end
56
+
57
+ # Strip +base_dir+ prefix and any leading slash/backslash so that
58
+ # the result is an archive-relative path that can be compared
59
+ # against caller-supplied names or filters.
60
+ def relative(path, base_dir)
61
+ path.sub(base_dir, "").delete_prefix("/").delete_prefix("\\")
62
+ end
63
+ end
64
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Excavate
4
- VERSION = "1.0.3"
4
+ VERSION = "1.1.1"
5
5
  end
data/lib/excavate.rb CHANGED
@@ -1,10 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "excavate/version"
4
- require_relative "excavate/extractors"
5
- require_relative "excavate/archive"
6
- require_relative "excavate/file_magic"
7
- require_relative "excavate/utils"
3
+ require "fileutils"
4
+ require "tmpdir"
8
5
 
9
6
  module Excavate
10
7
  class Error < StandardError; end
@@ -17,3 +14,14 @@ module Excavate
17
14
 
18
15
  class UnknownArchiveError < Error; end
19
16
  end
17
+
18
+ module Excavate
19
+ autoload :VERSION, "excavate/version"
20
+ autoload :FileMagic, "excavate/file_magic"
21
+ autoload :Filesystem, "excavate/filesystem"
22
+ autoload :Selection, "excavate/selection"
23
+ autoload :NestedCabFallback, "excavate/nested_cab_fallback"
24
+ autoload :Extractors, "excavate/extractors"
25
+ autoload :Archive, "excavate/archive"
26
+ autoload :CLI, "excavate/cli"
27
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: excavate
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.3
4
+ version: 1.1.1
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-03-24 00:00:00.000000000 Z
11
+ date: 2026-07-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: cabriolet
@@ -30,14 +30,14 @@ dependencies:
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: 0.3.9
33
+ version: 0.3.11
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: 0.3.9
40
+ version: 0.3.11
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: thor
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -73,6 +73,19 @@ files:
73
73
  - LICENSE.adoc
74
74
  - README.adoc
75
75
  - Rakefile
76
+ - TODO.fixup/01-autoload-migration.md
77
+ - TODO.fixup/02-redundant-external-requires.md
78
+ - TODO.fixup/03-extractor-registry-ocp.md
79
+ - TODO.fixup/04-filemagic-signature-struct.md
80
+ - TODO.fixup/05-utils-silence-stream-dead-code.md
81
+ - TODO.fixup/06-archive-filesystem-module.md
82
+ - TODO.fixup/07-archive-targets-helper.md
83
+ - TODO.fixup/08-archive-selection-dry.md
84
+ - TODO.fixup/09-cab-fallback-heuristic-named.md
85
+ - TODO.fixup/10-specs-remove-respond-to-matcher.md
86
+ - TODO.fixup/11-specs-cover-new-collaborators.md
87
+ - TODO.fixup/12-final-verification.md
88
+ - TODO.fixup/README.md
76
89
  - docs/Gemfile
77
90
  - docs/_config.yml
78
91
  - docs/assets/logo.svg
@@ -165,7 +178,9 @@ files:
165
178
  - lib/excavate/extractors/xz_extractor.rb
166
179
  - lib/excavate/extractors/zip_extractor.rb
167
180
  - lib/excavate/file_magic.rb
168
- - lib/excavate/utils.rb
181
+ - lib/excavate/filesystem.rb
182
+ - lib/excavate/nested_cab_fallback.rb
183
+ - lib/excavate/selection.rb
169
184
  - lib/excavate/version.rb
170
185
  homepage: https://github.com/omnizip/excavate
171
186
  licenses:
@@ -1,14 +0,0 @@
1
- module Excavate
2
- module Utils
3
- module_function
4
-
5
- def silence_stream(stream)
6
- old_stream = stream.dup
7
- stream.reopen(/mswin|mingw/.match?(RbConfig::CONFIG["host_os"]) ? File::NULL : File::NULL)
8
- stream.sync = true
9
- yield
10
- ensure
11
- stream.reopen(old_stream)
12
- end
13
- end
14
- end