debendencies 1.0.0.pre1
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 +7 -0
- data/bin/debendencies +3 -0
- data/lib/debendencies/cli.rb +81 -0
- data/lib/debendencies/elf_analysis.rb +68 -0
- data/lib/debendencies/errors.rb +5 -0
- data/lib/debendencies/package_dependency.rb +75 -0
- data/lib/debendencies/package_finding.rb +60 -0
- data/lib/debendencies/package_version.rb +103 -0
- data/lib/debendencies/symbols_file_parsing.rb +58 -0
- data/lib/debendencies/utils.rb +62 -0
- data/lib/debendencies/version.rb +5 -0
- data/lib/debendencies.rb +116 -0
- metadata +192 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: cfb4b703eb13f5256ed10c8608f31ff2fb3458301dde2d0e599f63994cb8cf78
|
4
|
+
data.tar.gz: da9f79cad3cf0dd3b62dcf63a6bcdb141da6155d6e618a4768a9a2b4c55f0828
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 8310ab69c81538f55178835ed89b479c8d3c46fc3e073537fd57007b7752055e343b77b811ad465c9365ec9c876da4620088762ab233674e461df260b101315b
|
7
|
+
data.tar.gz: acd7ed848689e4ced849bc5ae4227a6324b197268d64c35bd5d031e9f378725856742a3086e5206c1f8ac40485ac522a3fd4011f22e066be79f270acbf4bec94
|
data/bin/debendencies
ADDED
@@ -0,0 +1,81 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "optparse"
|
3
|
+
require_relative "../debendencies"
|
4
|
+
require_relative "version"
|
5
|
+
|
6
|
+
class Debendencies
|
7
|
+
class CLI
|
8
|
+
def initialize
|
9
|
+
@options = {
|
10
|
+
format: "oneline",
|
11
|
+
}
|
12
|
+
end
|
13
|
+
|
14
|
+
def run
|
15
|
+
option_parser.parse!
|
16
|
+
require "json" if @options[:format] == "json"
|
17
|
+
|
18
|
+
paths = ARGV
|
19
|
+
if paths.empty?
|
20
|
+
puts option_parser
|
21
|
+
exit 1
|
22
|
+
end
|
23
|
+
|
24
|
+
debendencies = Debendencies.new(logger: get_logger)
|
25
|
+
begin
|
26
|
+
debendencies.scan(*paths)
|
27
|
+
dependencies = debendencies.resolve
|
28
|
+
rescue Error => e
|
29
|
+
abort(e.message)
|
30
|
+
end
|
31
|
+
|
32
|
+
case @options[:format]
|
33
|
+
when "oneline"
|
34
|
+
puts dependencies.map { |d| d.to_s }.join(", ")
|
35
|
+
when "multiline"
|
36
|
+
dependencies.each { |d| puts d.to_s }
|
37
|
+
when "json"
|
38
|
+
puts JSON.generate(dependencies.map { |d| d.as_json })
|
39
|
+
else
|
40
|
+
puts "Invalid format: #{@options[:format]}"
|
41
|
+
exit 1
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def option_parser
|
48
|
+
@option_parser ||= OptionParser.new do |opts|
|
49
|
+
opts.banner = "Usage: debendencies <PATHS...>"
|
50
|
+
|
51
|
+
opts.on("-f", "--format FORMAT", "Output format (oneline|multiline|json). Default: oneline") do |format|
|
52
|
+
if !["oneline", "multiline", "json"].include?(format)
|
53
|
+
abort "Invalid format: #{format.inspect}"
|
54
|
+
end
|
55
|
+
@options[:format] = format
|
56
|
+
end
|
57
|
+
|
58
|
+
opts.on("--verbose", "Show verbose output") do
|
59
|
+
@options[:verbose] = true
|
60
|
+
end
|
61
|
+
|
62
|
+
opts.on("-h", "--help", "Show this help message") do
|
63
|
+
puts opts
|
64
|
+
exit
|
65
|
+
end
|
66
|
+
|
67
|
+
opts.on("--version", "Show version") do
|
68
|
+
puts VERSION_STRING
|
69
|
+
exit
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def get_logger
|
75
|
+
if @options[:verbose]
|
76
|
+
require "logger"
|
77
|
+
Logger.new(STDERR)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "set"
|
3
|
+
require_relative "errors"
|
4
|
+
require_relative "utils"
|
5
|
+
|
6
|
+
class Debendencies
|
7
|
+
module Private
|
8
|
+
class << self
|
9
|
+
# Extracts from an ELF file using `objdump`:
|
10
|
+
#
|
11
|
+
# - The ELF file's own soname, if possible. Can be nil.
|
12
|
+
# - The list of shared library dependencies (sonames).
|
13
|
+
#
|
14
|
+
# @param path [String] Path to the ELF file to analyze.
|
15
|
+
# @return [String, Array<Array<String>>]
|
16
|
+
# @raise [Error] If `objdump` fails.
|
17
|
+
def extract_soname_and_dependency_libs(path)
|
18
|
+
popen(["objdump", "-p", path],
|
19
|
+
spawn_error_message: "Error scanning ELF file dependencies: cannot spawn 'objdump'",
|
20
|
+
fail_error_message: "Error scanning ELF file dependencies: 'objdump' failed") do |io|
|
21
|
+
soname = nil
|
22
|
+
dependent_libs = []
|
23
|
+
|
24
|
+
io.each_line do |line|
|
25
|
+
case line
|
26
|
+
when /^\s*SONAME\s+(.+)$/
|
27
|
+
soname = $1.strip
|
28
|
+
when /^\s*NEEDED\s+(.+)$/
|
29
|
+
dependent_libs << $1.strip
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
[soname, dependent_libs]
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Extracts dynamic symbols from ELF files using `nm`.
|
38
|
+
#
|
39
|
+
# @param paths [Array<String>] Paths to the ELF files to analyze.
|
40
|
+
# @param cache [Hash<String, Set<String>>]
|
41
|
+
# @return [Set<String>] Set of dynamic symbols.
|
42
|
+
# @raise [Error] If `nm` fails.
|
43
|
+
def extract_dynamic_symbols(paths, cache = {})
|
44
|
+
result = Set.new
|
45
|
+
|
46
|
+
paths.each do |path|
|
47
|
+
subresult = cache[path] ||=
|
48
|
+
popen(["nm", "-D", path],
|
49
|
+
spawn_error_message: "Error extracting dynamic symbols from #{path}: cannot spawn 'nm'",
|
50
|
+
fail_error_message: "Error extracting dynamic symbols from #{path}: 'nm' failed") do |io|
|
51
|
+
io.each_line.lazy.map do |line|
|
52
|
+
# Line is in the following format:
|
53
|
+
#
|
54
|
+
# U waitpid
|
55
|
+
# 0000000000126190 B want_pending_command
|
56
|
+
# ^^^^^^^^^^^^^^^^^^^^
|
57
|
+
# we want to extract this
|
58
|
+
$1 if line =~ /^\S*\s+[A-Za-z]\s+(.+)/
|
59
|
+
end.compact.to_set
|
60
|
+
end
|
61
|
+
result.merge(subresult)
|
62
|
+
end
|
63
|
+
|
64
|
+
result
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Debendencies
|
4
|
+
# Represents a single Debian package dependency, e.g., `libc`.
|
5
|
+
# Could potentially have version constraints, e.g., `libc (>= 2.28, <= 2.30)`.
|
6
|
+
#
|
7
|
+
# `version_constraints` is either nil or non-empty.
|
8
|
+
class PackageDependency
|
9
|
+
attr_reader :name, :version_constraints
|
10
|
+
|
11
|
+
def initialize(name, version_constraints = nil)
|
12
|
+
@name = name
|
13
|
+
@version_constraints = version_constraints
|
14
|
+
end
|
15
|
+
|
16
|
+
def eql?(other)
|
17
|
+
@name == other.name && @version_constraints == other.version_constraints
|
18
|
+
end
|
19
|
+
|
20
|
+
alias_method :==, :eql?
|
21
|
+
|
22
|
+
def hash
|
23
|
+
@name.hash ^ @version_constraints.hash
|
24
|
+
end
|
25
|
+
|
26
|
+
def as_json
|
27
|
+
result = { name: name }
|
28
|
+
result[:version_constraints] = version_constraints.map { |vc| vc.as_json } if version_constraints
|
29
|
+
result
|
30
|
+
end
|
31
|
+
|
32
|
+
def to_s
|
33
|
+
if version_constraints.nil?
|
34
|
+
name
|
35
|
+
else
|
36
|
+
"#{name} (#{version_constraints.map { |vc| vc.to_s }.join(", ")})"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# Represents a version constraint, e.g., `>= 2.28-1`.
|
42
|
+
class VersionConstraint
|
43
|
+
# A comparison operator, e.g., `>=`.
|
44
|
+
#
|
45
|
+
# @return [String]
|
46
|
+
attr_reader :operator
|
47
|
+
|
48
|
+
# A Debian package version, e.g., `2.28-1`.
|
49
|
+
# @return [String]
|
50
|
+
attr_reader :version
|
51
|
+
|
52
|
+
def initialize(operator, version)
|
53
|
+
@operator = operator
|
54
|
+
@version = version
|
55
|
+
end
|
56
|
+
|
57
|
+
def eql?(other)
|
58
|
+
@operator == other.operator && @version == other.version
|
59
|
+
end
|
60
|
+
|
61
|
+
alias_method :==, :eql?
|
62
|
+
|
63
|
+
def hash
|
64
|
+
@operator.hash ^ @version.hash
|
65
|
+
end
|
66
|
+
|
67
|
+
def as_json
|
68
|
+
{ operator: operator, version: version }
|
69
|
+
end
|
70
|
+
|
71
|
+
def to_s
|
72
|
+
"#{operator} #{version}"
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "open3"
|
3
|
+
require_relative "errors"
|
4
|
+
require_relative "elf_analysis"
|
5
|
+
require_relative "symbols_file_parsing"
|
6
|
+
require_relative "utils"
|
7
|
+
|
8
|
+
class Debendencies
|
9
|
+
module Private
|
10
|
+
class << self
|
11
|
+
# Finds the package providing a specific library soname. This is done using `dpkg-query -S`.
|
12
|
+
#
|
13
|
+
# @return [String] The package name (like "libc6"), or nil if no package provides the library.
|
14
|
+
def find_package_providing_lib(soname)
|
15
|
+
output, error_output, status = Open3.capture3("dpkg-query", "-S", "*/#{soname}")
|
16
|
+
if !status.success?
|
17
|
+
if !status.signaled? && error_output.include?("no path found matching pattern")
|
18
|
+
return nil
|
19
|
+
else
|
20
|
+
raise Error, "Error finding packages that provide #{soname}: 'dpkg-query' failed: #{status}: #{error_output.chomp}"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# Output is in the following format:
|
25
|
+
# libfoo1:amd64: /usr/lib/x86_64-linux-gnu/libfoo.so.1
|
26
|
+
#
|
27
|
+
# The architecture could be omitted, like so:
|
28
|
+
# libfoo1: /usr/lib/x86_64-linux-gnu/libfoo1.so.1
|
29
|
+
#
|
30
|
+
# In theory, the output could contain multiple results, indicating alternatives.
|
31
|
+
# We don't support alternatives, so we just return the first result.
|
32
|
+
# See rationale in HOW-IT-WORKS.md.
|
33
|
+
|
34
|
+
return nil if output.empty?
|
35
|
+
line = output.split("\n").first
|
36
|
+
line.split(":", 2).first
|
37
|
+
end
|
38
|
+
|
39
|
+
# Finds the minimum version of the package that provides the necessary library symbols
|
40
|
+
# used by the given ELF files.
|
41
|
+
def find_min_package_version(soname, symbols_file_path, dependent_elf_file_paths, symbol_extraction_cache = {}, logger = nil)
|
42
|
+
dependent_symbols = extract_dynamic_symbols(dependent_elf_file_paths, symbol_extraction_cache)
|
43
|
+
return nil if dependent_symbols.empty?
|
44
|
+
|
45
|
+
max_used_package_version = nil
|
46
|
+
|
47
|
+
list_symbols(symbols_file_path, soname) do |dependency_symbol, package_version|
|
48
|
+
if dependent_symbols.include?(dependency_symbol)
|
49
|
+
logger&.info("Found in-use dependency symbol: #{dependency_symbol} (version: #{package_version})")
|
50
|
+
if max_used_package_version.nil? || package_version > max_used_package_version
|
51
|
+
max_used_package_version = package_version
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
max_used_package_version
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Debendencies
|
4
|
+
module Private
|
5
|
+
# Represents a package version in the format used by Debian packages.
|
6
|
+
# This class is only used internally to compare package versions.
|
7
|
+
# It's not exposed through the public API.
|
8
|
+
#
|
9
|
+
# Version number formats and comparison rules are defined in the Debian Policy Manual:
|
10
|
+
# https://www.debian.org/doc/debian-policy/ch-controlfields.html#version
|
11
|
+
# https://pmhahn.github.io/dpkg-compare-versions/
|
12
|
+
class PackageVersion
|
13
|
+
include Comparable
|
14
|
+
|
15
|
+
attr_reader :epoch, :upstream_version, :debian_revision
|
16
|
+
|
17
|
+
def initialize(version_string)
|
18
|
+
@version_string = version_string
|
19
|
+
# Parse version into epoch, upstream_version, and debian_revision
|
20
|
+
@epoch, version = parse_epoch(version_string)
|
21
|
+
@upstream_version, @debian_revision = parse_upstream_and_revision(version)
|
22
|
+
end
|
23
|
+
|
24
|
+
def <=>(other)
|
25
|
+
# Compare epoch
|
26
|
+
result = @epoch <=> other.epoch
|
27
|
+
return result unless result == 0
|
28
|
+
|
29
|
+
# Compare upstream version
|
30
|
+
result = compare_upstream_version(@upstream_version, other.upstream_version)
|
31
|
+
return result unless result == 0
|
32
|
+
|
33
|
+
# Compare debian revision
|
34
|
+
compare_debian_revision(@debian_revision, other.debian_revision)
|
35
|
+
end
|
36
|
+
|
37
|
+
def to_s
|
38
|
+
@version_string
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def parse_epoch(version)
|
44
|
+
if version.include?(":")
|
45
|
+
epoch, rest = version.split(":", 2)
|
46
|
+
[epoch.to_i, rest]
|
47
|
+
else
|
48
|
+
[0, version]
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def parse_upstream_and_revision(version)
|
53
|
+
if version.include?("-")
|
54
|
+
upstream, debian_revision = version.rpartition("-").values_at(0, 2)
|
55
|
+
else
|
56
|
+
upstream = version
|
57
|
+
debian_revision = ""
|
58
|
+
end
|
59
|
+
[upstream, debian_revision]
|
60
|
+
end
|
61
|
+
|
62
|
+
def compare_upstream_version(ver1, ver2)
|
63
|
+
compare_version_parts(split_version(ver1), split_version(ver2))
|
64
|
+
end
|
65
|
+
|
66
|
+
def compare_debian_revision(ver1, ver2)
|
67
|
+
# Empty string counts as 0 in Debian revision comparison
|
68
|
+
ver1 = "0" if ver1.empty?
|
69
|
+
ver2 = "0" if ver2.empty?
|
70
|
+
compare_version_parts(split_version(ver1), split_version(ver2))
|
71
|
+
end
|
72
|
+
|
73
|
+
def split_version(version)
|
74
|
+
version.scan(/\d+|[a-zA-Z]+|~|[^\da-zA-Z~]+/)
|
75
|
+
end
|
76
|
+
|
77
|
+
def compare_version_parts(parts1, parts2)
|
78
|
+
parts1.zip(parts2).each do |part1, part2|
|
79
|
+
# Handle nil cases
|
80
|
+
part1 ||= ""
|
81
|
+
part2 ||= ""
|
82
|
+
|
83
|
+
if part1 =~ /^\d+$/ && part2 =~ /^\d+$/
|
84
|
+
result = part1.to_i <=> part2.to_i
|
85
|
+
else
|
86
|
+
result = compare_lexically(part1, part2)
|
87
|
+
end
|
88
|
+
return result unless result == 0
|
89
|
+
end
|
90
|
+
|
91
|
+
parts1.size <=> parts2.size
|
92
|
+
end
|
93
|
+
|
94
|
+
def compare_lexically(part1, part2)
|
95
|
+
# Special handling for '~' which sorts before everything else
|
96
|
+
return -1 if part1 == "~" && part2 != "~"
|
97
|
+
return 1 if part1 != "~" && part2 == "~"
|
98
|
+
|
99
|
+
part1 <=> part2
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require_relative "package_version"
|
3
|
+
|
4
|
+
class Debendencies
|
5
|
+
module Private
|
6
|
+
class << self
|
7
|
+
# Parses a symbols file. Yields:
|
8
|
+
#
|
9
|
+
# - All symbols for the specified library soname.
|
10
|
+
# - The package version that provides that symbol.
|
11
|
+
#
|
12
|
+
# For example, it yields `["fopen@GLIBC_1.0", "5"]`.
|
13
|
+
#
|
14
|
+
# @param path [String] Path to the symbols file.
|
15
|
+
# @param soname [String] Soname of the library to yield symbols for.
|
16
|
+
# @yield [String, PackageVersion]
|
17
|
+
def list_symbols(path, soname)
|
18
|
+
File.open(path, "r:utf-8") do |f|
|
19
|
+
# Skips lines in the symbols file until we encounter the start of the section for the given library
|
20
|
+
f.each_line do |line|
|
21
|
+
break if line.start_with?("#{soname} ")
|
22
|
+
end
|
23
|
+
|
24
|
+
f.each_line do |line|
|
25
|
+
# Ignore alternative package specifiers and metadata fields like these:
|
26
|
+
#
|
27
|
+
# | libtinfo6 #MINVER#, libtinfo6 (<< 6.2~)
|
28
|
+
# * Build-Depends-Package: libncurses-dev
|
29
|
+
next if line =~ /^\s*[\|\*]/
|
30
|
+
|
31
|
+
# We look for a line like this:
|
32
|
+
#
|
33
|
+
# NCURSES6_TIC_5.0.19991023@NCURSES6_TIC_5.0.19991023 6.1
|
34
|
+
#
|
35
|
+
# Stop when we reach the section for next library
|
36
|
+
break if line !~ /^\s+(\S+)\s+(\S+)/
|
37
|
+
|
38
|
+
raw_symbol = $1
|
39
|
+
package_version_string = $2
|
40
|
+
yield [raw_symbol.sub(/@Base$/, ""), PackageVersion.new(package_version_string)]
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def find_symbols_file(package_name, architecture)
|
46
|
+
path = File.join(symbols_dir, "#{package_name}:#{architecture}.symbols")
|
47
|
+
path if File.exist?(path)
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
# Mocked during tests.
|
53
|
+
def symbols_dir
|
54
|
+
"/var/lib/dpkg/info"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require_relative "errors"
|
3
|
+
|
4
|
+
class Debendencies
|
5
|
+
module Private
|
6
|
+
ELF_MAGIC = String.new("\x7FELF").force_encoding("binary").freeze
|
7
|
+
|
8
|
+
class << self
|
9
|
+
def elf_file?(path)
|
10
|
+
File.open(path, "rb") do |f|
|
11
|
+
f.read(4) == ELF_MAGIC
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def path_resembles_library?(path)
|
16
|
+
!!(path =~ /\.so($|\.\d+)/)
|
17
|
+
end
|
18
|
+
|
19
|
+
def dpkg_architecture
|
20
|
+
read_string_envvar("DEB_HOST_ARCH") ||
|
21
|
+
read_string_envvar("DEB_BUILD_ARCH") ||
|
22
|
+
@dpkg_architecture ||= begin
|
23
|
+
popen(["dpkg", "--print-architecture"],
|
24
|
+
spawn_error_message: "Error getting dpkg architecture: cannot spawn 'dpkg'",
|
25
|
+
fail_error_message: "Error getting dpkg architecture: 'dpkg --print-architecture' failed") do |io|
|
26
|
+
io.read.chomp
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# Runs a command and yields its standard output as an IO object.
|
32
|
+
# Like IO.popen but with better error handling.
|
33
|
+
# On success, returns the result of the block, otherwise raises an Error.
|
34
|
+
def popen(command_args, spawn_error_message:, fail_error_message:)
|
35
|
+
begin
|
36
|
+
begin
|
37
|
+
io = IO.popen(command_args)
|
38
|
+
rescue SystemCallError => e
|
39
|
+
raise Error, "#{spawn_error_message}: #{e}"
|
40
|
+
end
|
41
|
+
|
42
|
+
result = yield io
|
43
|
+
ensure
|
44
|
+
io.close if io
|
45
|
+
end
|
46
|
+
|
47
|
+
if $?.success?
|
48
|
+
result
|
49
|
+
else
|
50
|
+
raise Error, "#{fail_error_message}: #{$?}"
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
def read_string_envvar(name)
|
57
|
+
value = ENV[name]
|
58
|
+
value if value && !value.empty?
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
data/lib/debendencies.rb
ADDED
@@ -0,0 +1,116 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "set"
|
3
|
+
require_relative "debendencies/elf_analysis"
|
4
|
+
require_relative "debendencies/package_finding"
|
5
|
+
require_relative "debendencies/package_dependency"
|
6
|
+
require_relative "debendencies/errors"
|
7
|
+
require_relative "debendencies/utils"
|
8
|
+
|
9
|
+
class Debendencies
|
10
|
+
def initialize(logger: nil)
|
11
|
+
@logger = logger
|
12
|
+
|
13
|
+
# Shared libraries (sonames) that have been scanned.
|
14
|
+
@scanned_libs = Set.new
|
15
|
+
|
16
|
+
# Shared libraries (sonames) that the scanned ELF files depend on.
|
17
|
+
# Maps each soname to an array of ELF file path that depend on it (the dependents).
|
18
|
+
@dependency_libs = {}
|
19
|
+
|
20
|
+
@symbol_extraction_cache = {}
|
21
|
+
end
|
22
|
+
|
23
|
+
def scan(*paths)
|
24
|
+
paths.each do |path|
|
25
|
+
if File.directory?(path)
|
26
|
+
scan_directory(path)
|
27
|
+
else
|
28
|
+
scan_file(path)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# Resolves the Debian package dependencies of all scanned ELF files.
|
34
|
+
# Returns an array of PackageDependency objects:
|
35
|
+
#
|
36
|
+
# [
|
37
|
+
# PackageDependency.new('libc6', [VersionConstraint.new('>=', '2.28')]),
|
38
|
+
# PackageDependency.new('libfoo1'),
|
39
|
+
# ]
|
40
|
+
#
|
41
|
+
# @return [Array<PackageDependency>]
|
42
|
+
def resolve
|
43
|
+
result = []
|
44
|
+
|
45
|
+
@dependency_libs.each_pair do |dependency_soname, dependent_elf_file_paths|
|
46
|
+
# ELF files in a package could depend on libraries included in the same package,
|
47
|
+
# so omit resolving scanned libraries.
|
48
|
+
if @scanned_libs.include?(dependency_soname)
|
49
|
+
@logger&.info("Skipping dependency resolution for scanned library: #{dependency_soname}")
|
50
|
+
next
|
51
|
+
end
|
52
|
+
|
53
|
+
package_name = Private.find_package_providing_lib(dependency_soname)
|
54
|
+
raise Error, "Error resolving package dependencies: no package provides #{dependency_soname}" if package_name.nil?
|
55
|
+
@logger&.info("Resolved package providing #{dependency_soname}: #{package_name}")
|
56
|
+
version_constraints = maybe_create_version_constraints(package_name, dependency_soname, dependent_elf_file_paths)
|
57
|
+
@logger&.info("Resolved version constraints: #{version_constraints&.map { |vc| vc.as_json }.inspect}")
|
58
|
+
|
59
|
+
result << PackageDependency.new(package_name, version_constraints)
|
60
|
+
end
|
61
|
+
|
62
|
+
result.uniq!
|
63
|
+
result
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
def scan_directory(dir)
|
69
|
+
Dir.glob("**/*", base: dir) do |entry|
|
70
|
+
path = File.join(dir, entry)
|
71
|
+
|
72
|
+
if File.symlink?(path)
|
73
|
+
# Libraries tend to have multiple symlinks (e.g. libfoo.so -> libfoo.so.1 -> libfoo.so.1.2.3)
|
74
|
+
# and we only want to process libraries once, so ignore symlinks.
|
75
|
+
@logger&.warn("Skipping symlink: #{path}")
|
76
|
+
next
|
77
|
+
end
|
78
|
+
|
79
|
+
scan_file(path) if File.file?(path) && File.executable?(path)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def scan_file(path)
|
84
|
+
@logger&.info("Scanning ELF file: #{path}")
|
85
|
+
return @logger&.warn("Skipping non-ELF file: #{path}") if !Private.elf_file?(path)
|
86
|
+
|
87
|
+
soname, dependency_libs = Private.extract_soname_and_dependency_libs(path)
|
88
|
+
@logger&.info("Detected soname: #{soname || "(none)"}")
|
89
|
+
@logger&.info("Detected dependencies: #{dependency_libs.inspect}")
|
90
|
+
if Private.path_resembles_library?(path) && soname.nil?
|
91
|
+
raise Error, "Error scanning ELF file: cannot determine shared library name (soname) for #{path}"
|
92
|
+
end
|
93
|
+
|
94
|
+
@scanned_libs << soname if soname
|
95
|
+
dependency_libs.each do |dependency_soname|
|
96
|
+
dependents = (@dependency_libs[dependency_soname] ||= [])
|
97
|
+
dependents << path
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def maybe_create_version_constraints(package_name, soname, dependent_elf_files)
|
102
|
+
symbols_file_path = Private.find_symbols_file(package_name, Private.dpkg_architecture)
|
103
|
+
if symbols_file_path
|
104
|
+
@logger&.info("Found symbols file for #{package_name}: #{symbols_file_path}")
|
105
|
+
min_version = Private.find_min_package_version(soname,
|
106
|
+
symbols_file_path,
|
107
|
+
dependent_elf_files,
|
108
|
+
@symbol_extraction_cache,
|
109
|
+
@logger)
|
110
|
+
[VersionConstraint.new(">=", min_version.to_s)] if min_version
|
111
|
+
else
|
112
|
+
@logger&.warn("No symbols file found for #{package_name}")
|
113
|
+
nil
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
metadata
ADDED
@@ -0,0 +1,192 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: debendencies
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0.pre1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Hongli Lai
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-08-21 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: set
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: stringio
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '3'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '3'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: json
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '2'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '2'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: optparse
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0.4'
|
62
|
+
- - "<"
|
63
|
+
- !ruby/object:Gem::Version
|
64
|
+
version: '2'
|
65
|
+
type: :runtime
|
66
|
+
prerelease: false
|
67
|
+
version_requirements: !ruby/object:Gem::Requirement
|
68
|
+
requirements:
|
69
|
+
- - ">="
|
70
|
+
- !ruby/object:Gem::Version
|
71
|
+
version: '0.4'
|
72
|
+
- - "<"
|
73
|
+
- !ruby/object:Gem::Version
|
74
|
+
version: '2'
|
75
|
+
- !ruby/object:Gem::Dependency
|
76
|
+
name: open3
|
77
|
+
requirement: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
79
|
+
- - ">="
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
version: '0.1'
|
82
|
+
- - "<"
|
83
|
+
- !ruby/object:Gem::Version
|
84
|
+
version: '2'
|
85
|
+
type: :runtime
|
86
|
+
prerelease: false
|
87
|
+
version_requirements: !ruby/object:Gem::Requirement
|
88
|
+
requirements:
|
89
|
+
- - ">="
|
90
|
+
- !ruby/object:Gem::Version
|
91
|
+
version: '0.1'
|
92
|
+
- - "<"
|
93
|
+
- !ruby/object:Gem::Version
|
94
|
+
version: '2'
|
95
|
+
- !ruby/object:Gem::Dependency
|
96
|
+
name: tmpdir
|
97
|
+
requirement: !ruby/object:Gem::Requirement
|
98
|
+
requirements:
|
99
|
+
- - ">="
|
100
|
+
- !ruby/object:Gem::Version
|
101
|
+
version: '0.1'
|
102
|
+
- - "<"
|
103
|
+
- !ruby/object:Gem::Version
|
104
|
+
version: '2'
|
105
|
+
type: :runtime
|
106
|
+
prerelease: false
|
107
|
+
version_requirements: !ruby/object:Gem::Requirement
|
108
|
+
requirements:
|
109
|
+
- - ">="
|
110
|
+
- !ruby/object:Gem::Version
|
111
|
+
version: '0.1'
|
112
|
+
- - "<"
|
113
|
+
- !ruby/object:Gem::Version
|
114
|
+
version: '2'
|
115
|
+
- !ruby/object:Gem::Dependency
|
116
|
+
name: fileutils
|
117
|
+
requirement: !ruby/object:Gem::Requirement
|
118
|
+
requirements:
|
119
|
+
- - "~>"
|
120
|
+
- !ruby/object:Gem::Version
|
121
|
+
version: '1'
|
122
|
+
type: :runtime
|
123
|
+
prerelease: false
|
124
|
+
version_requirements: !ruby/object:Gem::Requirement
|
125
|
+
requirements:
|
126
|
+
- - "~>"
|
127
|
+
- !ruby/object:Gem::Version
|
128
|
+
version: '1'
|
129
|
+
- !ruby/object:Gem::Dependency
|
130
|
+
name: tempfile
|
131
|
+
requirement: !ruby/object:Gem::Requirement
|
132
|
+
requirements:
|
133
|
+
- - ">="
|
134
|
+
- !ruby/object:Gem::Version
|
135
|
+
version: '0.1'
|
136
|
+
- - "<"
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '2'
|
139
|
+
type: :runtime
|
140
|
+
prerelease: false
|
141
|
+
version_requirements: !ruby/object:Gem::Requirement
|
142
|
+
requirements:
|
143
|
+
- - ">="
|
144
|
+
- !ruby/object:Gem::Version
|
145
|
+
version: '0.1'
|
146
|
+
- - "<"
|
147
|
+
- !ruby/object:Gem::Version
|
148
|
+
version: '2'
|
149
|
+
description: Scans executables and shared libraries for their shared library dependencies,
|
150
|
+
and outputs a list of Debian package names that provide those libraries.
|
151
|
+
email:
|
152
|
+
- hongli@hongli.nl
|
153
|
+
executables:
|
154
|
+
- debendencies
|
155
|
+
extensions: []
|
156
|
+
extra_rdoc_files: []
|
157
|
+
files:
|
158
|
+
- bin/debendencies
|
159
|
+
- lib/debendencies.rb
|
160
|
+
- lib/debendencies/cli.rb
|
161
|
+
- lib/debendencies/elf_analysis.rb
|
162
|
+
- lib/debendencies/errors.rb
|
163
|
+
- lib/debendencies/package_dependency.rb
|
164
|
+
- lib/debendencies/package_finding.rb
|
165
|
+
- lib/debendencies/package_version.rb
|
166
|
+
- lib/debendencies/symbols_file_parsing.rb
|
167
|
+
- lib/debendencies/utils.rb
|
168
|
+
- lib/debendencies/version.rb
|
169
|
+
homepage: https://github.com/FooBarWidget/debendencies
|
170
|
+
licenses:
|
171
|
+
- MIT
|
172
|
+
metadata: {}
|
173
|
+
post_install_message:
|
174
|
+
rdoc_options: []
|
175
|
+
require_paths:
|
176
|
+
- lib
|
177
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
178
|
+
requirements:
|
179
|
+
- - ">="
|
180
|
+
- !ruby/object:Gem::Version
|
181
|
+
version: '0'
|
182
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
183
|
+
requirements:
|
184
|
+
- - ">="
|
185
|
+
- !ruby/object:Gem::Version
|
186
|
+
version: '0'
|
187
|
+
requirements: []
|
188
|
+
rubygems_version: 3.5.3
|
189
|
+
signing_key:
|
190
|
+
specification_version: 4
|
191
|
+
summary: Debian package shared library dependencies inferer
|
192
|
+
test_files: []
|