debendencies 1.0.0.pre1
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|