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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +6 -94
- data/TODO.fixup/01-autoload-migration.md +65 -0
- data/TODO.fixup/02-redundant-external-requires.md +48 -0
- data/TODO.fixup/03-extractor-registry-ocp.md +150 -0
- data/TODO.fixup/04-filemagic-signature-struct.md +68 -0
- data/TODO.fixup/05-utils-silence-stream-dead-code.md +57 -0
- data/TODO.fixup/06-archive-filesystem-module.md +72 -0
- data/TODO.fixup/07-archive-targets-helper.md +62 -0
- data/TODO.fixup/08-archive-selection-dry.md +133 -0
- data/TODO.fixup/09-cab-fallback-heuristic-named.md +88 -0
- data/TODO.fixup/10-specs-remove-respond-to-matcher.md +38 -0
- data/TODO.fixup/11-specs-cover-new-collaborators.md +101 -0
- data/TODO.fixup/12-final-verification.md +20 -0
- data/TODO.fixup/README.md +73 -0
- data/excavate.gemspec +1 -1
- data/lib/excavate/archive.rb +46 -186
- data/lib/excavate/cli.rb +0 -2
- data/lib/excavate/extractors/cab_extractor.rb +2 -0
- data/lib/excavate/extractors/cpio_extractor.rb +3 -1
- data/lib/excavate/extractors/extractor.rb +72 -1
- data/lib/excavate/extractors/gzip_extractor.rb +4 -6
- data/lib/excavate/extractors/ole_extractor.rb +2 -2
- data/lib/excavate/extractors/rpm_extractor.rb +2 -1
- data/lib/excavate/extractors/seven_zip_extractor.rb +2 -2
- data/lib/excavate/extractors/tar_extractor.rb +2 -0
- data/lib/excavate/extractors/xar_extractor.rb +2 -1
- data/lib/excavate/extractors/xz_extractor.rb +3 -58
- data/lib/excavate/extractors/zip_extractor.rb +2 -0
- data/lib/excavate/extractors.rb +17 -11
- data/lib/excavate/file_magic.rb +30 -13
- data/lib/excavate/filesystem.rb +70 -0
- data/lib/excavate/nested_cab_fallback.rb +31 -0
- data/lib/excavate/selection.rb +64 -0
- data/lib/excavate/version.rb +1 -1
- data/lib/excavate.rb +13 -5
- metadata +20 -5
- data/lib/excavate/utils.rb +0 -14
data/lib/excavate/file_magic.rb
CHANGED
|
@@ -2,31 +2,48 @@
|
|
|
2
2
|
|
|
3
3
|
module Excavate
|
|
4
4
|
class FileMagic
|
|
5
|
-
#
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
data/lib/excavate/version.rb
CHANGED
data/lib/excavate.rb
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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.
|
|
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-
|
|
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.
|
|
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.
|
|
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/
|
|
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:
|
data/lib/excavate/utils.rb
DELETED
|
@@ -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
|