summaryrefslogtreecommitdiff
path: root/lib/spack/spack/hooks/absolutify_elf_sonames.py
blob: d203c6d1efb92661b7500cd140e7902c0c16a363 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# Copyright 2013-2024 Lawrence Livermore National Security, LLC and other
# Spack Project Developers. See the top-level COPYRIGHT file for details.
#
# SPDX-License-Identifier: (Apache-2.0 OR MIT)

import os

import llnl.util.tty as tty
from llnl.util.filesystem import BaseDirectoryVisitor, visit_directory_tree
from llnl.util.lang import elide_list

import spack.bootstrap
import spack.config
import spack.relocate
from spack.util.elf import ElfParsingError, parse_elf
from spack.util.executable import Executable


def is_shared_library_elf(filepath):
    """Return true if filepath is most a shared library.
    Our definition of a shared library for ELF requires:
    1. a dynamic section,
    2. a soname OR lack of interpreter.
    The problem is that PIE objects (default on Ubuntu) are
    ET_DYN too, and not all shared libraries have a soname...
    no interpreter is typically the best indicator then."""
    try:
        with open(filepath, "rb") as f:
            elf = parse_elf(f, interpreter=True, dynamic_section=True)
            return elf.has_pt_dynamic and (elf.has_soname or not elf.has_pt_interp)
    except (IOError, OSError, ElfParsingError):
        return False


class SharedLibrariesVisitor(BaseDirectoryVisitor):
    """Visitor that collects all shared libraries in a prefix, with the
    exception of an exclude list."""

    def __init__(self, exclude_list):
        # List of file and directory names to be excluded
        self.exclude_list = frozenset(exclude_list)

        # Map from (ino, dev) -> path. We need 1 path per file, if there are hardlinks,
        # we don't need to store the path multiple times.
        self.libraries = dict()

        # Set of (ino, dev) pairs (excluded by symlinks).
        self.excluded_through_symlink = set()

    def visit_file(self, root, rel_path, depth):
        # Check if excluded
        basename = os.path.basename(rel_path)
        if basename in self.exclude_list:
            return

        filepath = os.path.join(root, rel_path)
        s = os.lstat(filepath)
        identifier = (s.st_ino, s.st_dev)

        # We're hitting a hardlink or symlink of an excluded lib, no need to parse.
        if identifier in self.libraries or identifier in self.excluded_through_symlink:
            return

        # Register the file if it's a shared lib that needs to be patched.
        if is_shared_library_elf(filepath):
            self.libraries[identifier] = rel_path

    def visit_symlinked_file(self, root, rel_path, depth):
        # We don't need to follow the symlink and parse the file, since we will hit
        # it by recursing the prefix anyways. We only need to check if the target
        # should be excluded based on the filename of the symlink. E.g. when excluding
        # libf.so, which is a symlink to libf.so.1.2.3, we keep track of the stat data
        # of the latter.
        basename = os.path.basename(rel_path)
        if basename not in self.exclude_list:
            return

        # Register the (ino, dev) pair as ignored (if the symlink is not dangling)
        filepath = os.path.join(root, rel_path)
        try:
            s = os.stat(filepath)
        except OSError:
            return
        self.excluded_through_symlink.add((s.st_ino, s.st_dev))

    def before_visit_dir(self, root, rel_path, depth):
        # Allow skipping over directories. E.g. `<prefix>/lib/stubs` can be skipped by
        # adding `"stubs"` to the exclude list.
        return os.path.basename(rel_path) not in self.exclude_list

    def before_visit_symlinked_dir(self, root, rel_path, depth):
        # Never enter symlinked dirs, since we don't want to leave the prefix, and
        # we'll enter the target dir inside the prefix anyways since we're recursing
        # everywhere.
        return False

    def get_shared_libraries_relative_paths(self):
        """Get the libraries that should be patched, with the excluded libraries
        removed."""
        for identifier in self.excluded_through_symlink:
            self.libraries.pop(identifier, None)

        return [rel_path for rel_path in self.libraries.values()]


def patch_sonames(patchelf, root, rel_paths):
    """Set the soname to the file's own path for a list of
    given shared libraries."""
    fixed = []
    for rel_path in rel_paths:
        filepath = os.path.join(root, rel_path)
        normalized = os.path.normpath(filepath)
        args = ["--set-soname", normalized, normalized]
        output = patchelf(*args, output=str, error=str, fail_on_error=False)
        if patchelf.returncode == 0:
            fixed.append(rel_path)
        else:
            # Note: treat as warning to avoid (long) builds to fail post-install.
            tty.warn("patchelf: failed to set soname of {}: {}".format(normalized, output.strip()))
    return fixed


def find_and_patch_sonames(prefix, exclude_list, patchelf):
    # Locate all shared libraries in the prefix dir of the spec, excluding
    # the ones set in the non_bindable_shared_objects property.
    visitor = SharedLibrariesVisitor(exclude_list)
    visit_directory_tree(prefix, visitor)

    # Patch all sonames.
    relative_paths = visitor.get_shared_libraries_relative_paths()
    return patch_sonames(patchelf, prefix, relative_paths)


def post_install(spec, explicit=None):
    # Skip if disabled
    if not spack.config.get("config:shared_linking:bind", False):
        return

    # Skip externals
    if spec.external:
        return

    # Only enable on platforms using ELF.
    if not spec.satisfies("platform=linux") and not spec.satisfies("platform=cray"):
        return

    # Disable this hook when bootstrapping, to avoid recursion.
    if spack.bootstrap.is_bootstrapping():
        return

    # Should failing to locate patchelf be a hard error?
    patchelf_path = spack.relocate._patchelf()
    if not patchelf_path:
        return
    patchelf = Executable(patchelf_path)

    fixes = find_and_patch_sonames(spec.prefix, spec.package.non_bindable_shared_objects, patchelf)

    if not fixes:
        return

    # Unfortunately this does not end up in the build logs.
    tty.info(
        "{}: Patched {} {}: {}".format(
            spec.name,
            len(fixes),
            "soname" if len(fixes) == 1 else "sonames",
            ", ".join(elide_list(fixes, max_num=5)),
        )
    )