Module: Debendencies::Private

Defined in:
lib/debendencies/utils.rb,
lib/debendencies/elf_analysis.rb,
lib/debendencies/package_finding.rb,
lib/debendencies/package_version.rb,
lib/debendencies/symbols_file_parsing.rb

Defined Under Namespace

Classes: PackageVersion

Constant Summary collapse

ELF_MAGIC =
String.new("\x7FELF").force_encoding("binary").freeze

Class Method Summary collapse

Class Method Details

.dpkg_architectureObject



19
20
21
22
23
24
25
26
27
28
29
# File 'lib/debendencies/utils.rb', line 19

def dpkg_architecture
  read_string_envvar("DEB_HOST_ARCH") ||
    read_string_envvar("DEB_BUILD_ARCH") ||
    @dpkg_architecture ||= begin
        popen(["dpkg", "--print-architecture"],
              spawn_error_message: "Error getting dpkg architecture: cannot spawn 'dpkg'",
              fail_error_message: "Error getting dpkg architecture: 'dpkg --print-architecture' failed") do |io|
          io.read.chomp
        end
      end
end

.elf_file?(path) ⇒ Boolean

Returns:

  • (Boolean)


9
10
11
12
13
# File 'lib/debendencies/utils.rb', line 9

def elf_file?(path)
  File.open(path, "rb") do |f|
    f.read(4) == ELF_MAGIC
  end
end

.extract_dynamic_symbols(paths, cache = {}) ⇒ Set<String>

Extracts dynamic symbols from ELF files using ‘nm`.

Parameters:

  • paths (Array<String>)

    Paths to the ELF files to analyze.

  • cache (Hash<String, Set<String>>) (defaults to: {})

Returns:

  • (Set<String>)

    Set of dynamic symbols.

Raises:

  • (Error)

    If ‘nm` fails.



43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/debendencies/elf_analysis.rb', line 43

def extract_dynamic_symbols(paths, cache = {})
  result = Set.new

  paths.each do |path|
    subresult = cache[path] ||=
      popen(["nm", "-D", path],
            spawn_error_message: "Error extracting dynamic symbols from #{path}: cannot spawn 'nm'",
            fail_error_message: "Error extracting dynamic symbols from #{path}: 'nm' failed") do |io|
        io.each_line.lazy.map do |line|
          # Line is in the following format:
          #
          #                  U waitpid
          # 0000000000126190 B want_pending_command
          #                    ^^^^^^^^^^^^^^^^^^^^
          #                    we want to extract this
          $1 if line =~ /^\S*\s+[A-Za-z]\s+(.+)/
        end.compact.to_set
      end
    result.merge(subresult)
  end

  result
end

.extract_soname_and_dependency_libs(path) ⇒ String, Array<Array<String>>

Extracts from an ELF file using ‘objdump`:

  • The ELF file’s own soname, if possible. Can be nil.

  • The list of shared library dependencies (sonames).

Parameters:

  • path (String)

    Path to the ELF file to analyze.

Returns:

  • (String, Array<Array<String>>)

Raises:

  • (Error)

    If ‘objdump` fails.



17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# File 'lib/debendencies/elf_analysis.rb', line 17

def extract_soname_and_dependency_libs(path)
  popen(["objdump", "-p", path],
        spawn_error_message: "Error scanning ELF file dependencies: cannot spawn 'objdump'",
        fail_error_message: "Error scanning ELF file dependencies: 'objdump' failed") do |io|
    soname = nil
    dependent_libs = []

    io.each_line do |line|
      case line
      when /^\s*SONAME\s+(.+)$/
        soname = $1.strip
      when /^\s*NEEDED\s+(.+)$/
        dependent_libs << $1.strip
      end
    end

    [soname, dependent_libs]
  end
end

.find_min_package_version(soname, symbols_file_path, dependent_elf_file_paths, symbol_extraction_cache = {}, logger = nil) ⇒ Object

Finds the minimum version of the package that provides the necessary library symbols used by the given ELF files.



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/debendencies/package_finding.rb', line 41

def find_min_package_version(soname, symbols_file_path, dependent_elf_file_paths, symbol_extraction_cache = {}, logger = nil)
  dependent_symbols = extract_dynamic_symbols(dependent_elf_file_paths, symbol_extraction_cache)
  return nil if dependent_symbols.empty?

  max_used_package_version = nil

  list_symbols(symbols_file_path, soname) do |dependency_symbol, package_version|
    if dependent_symbols.include?(dependency_symbol)
      logger&.info("Found in-use dependency symbol: #{dependency_symbol} (version: #{package_version})")
      if max_used_package_version.nil? || package_version > max_used_package_version
        max_used_package_version = package_version
      end
    end
  end

  max_used_package_version
end

.find_package_providing_lib(soname) ⇒ String

Finds the package providing a specific library soname. This is done using ‘dpkg-query -S`.

Returns:

  • (String)

    The package name (like “libc6”), or nil if no package provides the library.



14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# File 'lib/debendencies/package_finding.rb', line 14

def find_package_providing_lib(soname)
  output, error_output, status = Open3.capture3("dpkg-query", "-S", "*/#{soname}")
  if !status.success?
    if !status.signaled? && error_output.include?("no path found matching pattern")
      return nil
    else
      raise Error, "Error finding packages that provide #{soname}: 'dpkg-query' failed: #{status}: #{error_output.chomp}"
    end
  end

  # Output is in the following format:
  # libfoo1:amd64: /usr/lib/x86_64-linux-gnu/libfoo.so.1
  #
  # The architecture could be omitted, like so:
  # libfoo1: /usr/lib/x86_64-linux-gnu/libfoo1.so.1
  #
  # In theory, the output could contain multiple results, indicating alternatives.
  # We don't support alternatives, so we just return the first result.
  # See rationale in HOW-IT-WORKS.md.

  return nil if output.empty?
  line = output.split("\n").first
  line.split(":", 2).first
end

.find_symbols_file(package_name, architecture) ⇒ Object



45
46
47
48
# File 'lib/debendencies/symbols_file_parsing.rb', line 45

def find_symbols_file(package_name, architecture)
  path = File.join(symbols_dir, "#{package_name}:#{architecture}.symbols")
  path if File.exist?(path)
end

.list_symbols(path, soname) {|String, PackageVersion| ... } ⇒ Object

Parses a symbols file. Yields:

  • All symbols for the specified library soname.

  • The package version that provides that symbol.

For example, it yields ‘[“fopen@GLIBC_1.0”, “5”]`.

Parameters:

  • path (String)

    Path to the symbols file.

  • soname (String)

    Soname of the library to yield symbols for.

Yields:



17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# File 'lib/debendencies/symbols_file_parsing.rb', line 17

def list_symbols(path, soname)
  File.open(path, "r:utf-8") do |f|
    # Skips lines in the symbols file until we encounter the start of the section for the given library
    f.each_line do |line|
      break if line.start_with?("#{soname} ")
    end

    f.each_line do |line|
      # Ignore alternative package specifiers and metadata fields like these:
      #
      #   | libtinfo6 #MINVER#, libtinfo6 (<< 6.2~)
      #   * Build-Depends-Package: libncurses-dev
      next if line =~ /^\s*[\|\*]/

      # We look for a line like this:
      #
      #  NCURSES6_TIC_5.0.19991023@NCURSES6_TIC_5.0.19991023 6.1
      #
      # Stop when we reach the section for next library
      break if line !~ /^\s+(\S+)\s+(\S+)/

      raw_symbol = $1
      package_version_string = $2
      yield [raw_symbol.sub(/@Base$/, ""), PackageVersion.new(package_version_string)]
    end
  end
end

.path_resembles_library?(path) ⇒ Boolean

Returns:

  • (Boolean)


15
16
17
# File 'lib/debendencies/utils.rb', line 15

def path_resembles_library?(path)
  !!(path =~ /\.so($|\.\d+)/)
end

.popen(command_args, spawn_error_message:, fail_error_message:) ⇒ Object

Runs a command and yields its standard output as an IO object. Like IO.popen but with better error handling. On success, returns the result of the block, otherwise raises an Error.



34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/debendencies/utils.rb', line 34

def popen(command_args, spawn_error_message:, fail_error_message:)
  begin
    begin
      io = IO.popen(command_args)
    rescue SystemCallError => e
      raise Error, "#{spawn_error_message}: #{e}"
    end

    result = yield io
  ensure
    io.close if io
  end

  if $?.success?
    result
  else
    raise Error, "#{fail_error_message}: #{$?}"
  end
end