Increase coverage of spack.relocate (#16475)

- add docstrings and make parameter names consistent in `relocate.py`
- Make `replace_prefix_*` and other functions private (as they are implementation details)
- remove unused function _replace_prefix_nullterm()

- Add unit tests for `relocate.py` functions
  - add patchelf to Travis and use it during tests
  - add hello_world fixture with a compiled binary, so we can test relocation
This commit is contained in:
Massimiliano Culpo 2020-05-09 19:41:50 +02:00 committed by GitHub
parent 8671ac6a1a
commit b06bc31029
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 287 additions and 121 deletions

View File

@ -32,6 +32,25 @@ jobs:
dist: trusty dist: trusty
os: linux os: linux
language: python language: python
addons:
apt:
# Everything but patchelf, that is not available for trusty
packages:
- ccache
- cmake
- gfortran
- graphviz
- gnupg2
- kcov
- mercurial
- ninja-build
- perl
- perl-base
- realpath
- r-base
- r-base-core
- r-base-dev
- zsh
env: [ TEST_SUITE=unit, COVERAGE=true ] env: [ TEST_SUITE=unit, COVERAGE=true ]
- python: '2.7' - python: '2.7'
os: linux os: linux
@ -84,6 +103,7 @@ addons:
- kcov - kcov
- mercurial - mercurial
- ninja-build - ninja-build
- patchelf
- perl - perl
- perl-base - perl-base
- realpath - realpath

View File

@ -11,6 +11,7 @@
import llnl.util.tty as tty import llnl.util.tty as tty
import macholib.MachO import macholib.MachO
import macholib.mach_o import macholib.mach_o
import spack.architecture
import spack.cmd import spack.cmd
import spack.repo import spack.repo
import spack.spec import spack.spec
@ -385,57 +386,79 @@ def macholib_get_paths(cur_path):
return (rpaths, deps, ident) return (rpaths, deps, ident)
def modify_elf_object(path_name, new_rpaths): def _set_elf_rpaths(target, rpaths):
""" """Replace the original RPATH of the target with the paths passed
Replace orig_rpath with new_rpath in RPATH of elf object path_name as arguments.
This function uses ``patchelf`` to set RPATHs.
Args:
target: target executable. Must be an ELF object.
rpaths: paths to be set in the RPATH
Returns:
A string concatenating the stdout and stderr of the call
to ``patchelf``
""" """
# Join the paths using ':' as a separator
rpaths_str = ':'.join(rpaths)
new_joined = ':'.join(new_rpaths) # If we're relocating patchelf itself, make a copy and use it
bak_path = None
# if we're relocating patchelf itself, use it if target.endswith("/bin/patchelf"):
bak_path = path_name + ".bak" bak_path = target + ".bak"
shutil.copy(target, bak_path)
if path_name[-13:] == "/bin/patchelf":
shutil.copy(path_name, bak_path)
patchelf = executable.Executable(bak_path)
else:
patchelf = executable.Executable(_patchelf())
patchelf, output = executable.Executable(bak_path or _patchelf()), None
try: try:
patchelf('--force-rpath', '--set-rpath', '%s' % new_joined, # TODO: revisit the use of --force-rpath as it might be conditional
'%s' % path_name, output=str, error=str) # TODO: if we want to support setting RUNPATH from binary packages
patchelf_args = ['--force-rpath', '--set-rpath', rpaths_str, target]
output = patchelf(*patchelf_args, output=str, error=str)
except executable.ProcessError as e: except executable.ProcessError as e:
msg = 'patchelf --force-rpath --set-rpath %s failed with error %s' % ( msg = 'patchelf --force-rpath --set-rpath {0} failed with error {1}'
path_name, e) tty.warn(msg.format(target, e))
tty.warn(msg) finally:
if os.path.exists(bak_path): if bak_path and os.path.exists(bak_path):
os.remove(bak_path) os.remove(bak_path)
return output
def needs_binary_relocation(m_type, m_subtype): def needs_binary_relocation(m_type, m_subtype):
""" """Returns True if the file with MIME type/subtype passed as arguments
Check whether the given filetype is a binary that may need relocation. needs binary relocation, False otherwise.
Args:
m_type (str): MIME type of the file
m_subtype (str): MIME subtype of the file
""" """
if m_type == 'application': if m_type == 'application':
if (m_subtype == 'x-executable' or m_subtype == 'x-sharedlib' or if m_subtype in ('x-executable', 'x-sharedlib', 'x-mach-binary'):
m_subtype == 'x-mach-binary'):
return True return True
return False return False
def needs_text_relocation(m_type, m_subtype): def needs_text_relocation(m_type, m_subtype):
"""Returns True if the file with MIME type/subtype passed as arguments
needs text relocation, False otherwise.
Args:
m_type (str): MIME type of the file
m_subtype (str): MIME subtype of the file
""" """
Check whether the given filetype is text that may need relocation. return m_type == 'text'
"""
return (m_type == "text")
def replace_prefix_text(path_name, old_dir, new_dir): def _replace_prefix_text(filename, old_dir, new_dir):
"""Replace all the occurrences of the old install prefix with a
new install prefix in text files that are utf-8 encoded.
Args:
filename (str): target text file (utf-8 encoded)
old_dir (str): directory to be searched in the file
new_dir (str): substitute for the old directory
""" """
Replace old install prefix with new install prefix with open(filename, 'rb+') as f:
in text files using utf-8 encoded strings.
"""
with open(path_name, 'rb+') as f:
data = f.read() data = f.read()
f.seek(0) f.seek(0)
# Replace old_dir with new_dir if it appears at the beginning of a path # Replace old_dir with new_dir if it appears at the beginning of a path
@ -454,13 +477,18 @@ def replace_prefix_text(path_name, old_dir, new_dir):
f.truncate() f.truncate()
def replace_prefix_bin(path_name, old_dir, new_dir): def _replace_prefix_bin(filename, old_dir, new_dir):
""" """Replace all the occurrences of the old install prefix with a
Attempt to replace old install prefix with new install prefix new install prefix in binary files.
in binary files by prefixing new install prefix with os.sep
until the lengths of the prefixes are the same.
"""
The new install prefix is prefixed with ``os.sep`` until the
lengths of the prefixes are the same.
Args:
filename (str): target binary file
old_dir (str): directory to be searched in the file
new_dir (str): substitute for the old directory
"""
def replace(match): def replace(match):
occurances = match.group().count(old_dir.encode('utf-8')) occurances = match.group().count(old_dir.encode('utf-8'))
olen = len(old_dir.encode('utf-8')) olen = len(old_dir.encode('utf-8'))
@ -468,11 +496,12 @@ def replace(match):
padding = (olen - nlen) * occurances padding = (olen - nlen) * occurances
if padding < 0: if padding < 0:
return data return data
return match.group().replace(old_dir.encode('utf-8'), return match.group().replace(
os.sep.encode('utf-8') * padding + old_dir.encode('utf-8'),
new_dir.encode('utf-8')) os.sep.encode('utf-8') * padding + new_dir.encode('utf-8')
)
with open(path_name, 'rb+') as f: with open(filename, 'rb+') as f:
data = f.read() data = f.read()
f.seek(0) f.seek(0)
original_data_len = len(data) original_data_len = len(data)
@ -482,43 +511,7 @@ def replace(match):
ndata = pat.sub(replace, data) ndata = pat.sub(replace, data)
if not len(ndata) == original_data_len: if not len(ndata) == original_data_len:
raise BinaryStringReplacementError( raise BinaryStringReplacementError(
path_name, original_data_len, len(ndata)) filename, original_data_len, len(ndata))
f.write(ndata)
f.truncate()
def replace_prefix_nullterm(path_name, old_dir, new_dir):
"""
Attempt to replace old install prefix with new install prefix
in binary files by replacing with null terminated string
that is the same length unless the old path is shorter
Used on linux to replace mach-o rpaths
"""
def replace(match):
occurances = match.group().count(old_dir.encode('utf-8'))
olen = len(old_dir.encode('utf-8'))
nlen = len(new_dir.encode('utf-8'))
padding = (olen - nlen) * occurances
if padding < 0:
return data
return match.group().replace(old_dir.encode('utf-8'),
new_dir.encode('utf-8')) + b'\0' * padding
if len(new_dir) > len(old_dir):
raise BinaryTextReplaceError(old_dir, new_dir)
with open(path_name, 'rb+') as f:
data = f.read()
f.seek(0)
original_data_len = len(data)
pat = re.compile(re.escape(old_dir).encode('utf-8') + b'([^\0]*?)\0')
if not pat.search(data):
return
ndata = pat.sub(replace, data)
if not len(ndata) == original_data_len:
raise BinaryStringReplacementError(
path_name, original_data_len, len(ndata))
f.write(ndata) f.write(ndata)
f.truncate() f.truncate()
@ -597,50 +590,89 @@ def relocate_macho_binaries(path_names, old_layout_root, new_layout_root,
paths_to_paths) paths_to_paths)
def elf_find_paths(orig_rpaths, old_layout_root, prefix_to_prefix): def _transform_rpaths(orig_rpaths, orig_root, new_prefixes):
new_rpaths = list() """Return an updated list of RPATHs where each entry in the original list
starting with the old root is relocated to another place according to the
mapping passed as argument.
Args:
orig_rpaths (list): list of the original RPATHs
orig_root (str): original root to be substituted
new_prefixes (dict): dictionary that maps the original prefixes to
where they should be relocated
Returns:
List of paths
"""
new_rpaths = []
for orig_rpath in orig_rpaths: for orig_rpath in orig_rpaths:
if orig_rpath.startswith(old_layout_root): # If the original RPATH doesn't start with the target root
for old_prefix, new_prefix in prefix_to_prefix.items(): # append it verbatim and proceed
if orig_rpath.startswith(old_prefix): if not orig_rpath.startswith(orig_root):
new_rpaths.append(re.sub(re.escape(old_prefix),
new_prefix, orig_rpath))
else:
new_rpaths.append(orig_rpath) new_rpaths.append(orig_rpath)
continue
# Otherwise inspect the mapping and transform + append any prefix
# that starts with a registered key
for old_prefix, new_prefix in new_prefixes.items():
if orig_rpath.startswith(old_prefix):
new_rpaths.append(
re.sub(re.escape(old_prefix), new_prefix, orig_rpath)
)
return new_rpaths return new_rpaths
def relocate_elf_binaries(path_names, old_layout_root, new_layout_root, def relocate_elf_binaries(binaries, orig_root, new_root,
prefix_to_prefix, rel, old_prefix, new_prefix): new_prefixes, rel, orig_prefix, new_prefix):
""" """Relocate the binaries passed as arguments by changing their RPATHs.
Use patchelf to get the original rpaths and then replace them with
Use patchelf to get the original RPATHs and then replace them with
rpaths in the new directory layout. rpaths in the new directory layout.
New rpaths are determined from a dictionary mapping the prefixes in the
New RPATHs are determined from a dictionary mapping the prefixes in the
old directory layout to the prefixes in the new directory layout if the old directory layout to the prefixes in the new directory layout if the
rpath was in the old layout root, i.e. system paths are not replaced. rpath was in the old layout root, i.e. system paths are not replaced.
Args:
binaries (list): list of binaries that might need relocation, located
in the new prefix
orig_root (str): original root to be substituted
new_root (str): new root to be used, only relevant for relative RPATHs
new_prefixes (dict): dictionary that maps the original prefixes to
where they should be relocated
rel (bool): True if the RPATHs are relative, False if they are absolute
orig_prefix (str): prefix where the executable was originally located
new_prefix (str): prefix where we want to relocate the executable
""" """
for path_name in path_names: for new_binary in binaries:
orig_rpaths = _elf_rpaths_for(path_name) orig_rpaths = _elf_rpaths_for(new_binary)
new_rpaths = list() # TODO: Can we deduce `rel` from the original RPATHs?
if rel: if rel:
# get the file path in the old_prefix # Get the file path in the original prefix
orig_path_name = re.sub(re.escape(new_prefix), old_prefix, orig_binary = re.sub(
path_name) re.escape(new_prefix), orig_prefix, new_binary
# get the normalized rpaths in the old prefix using the file path )
# Get the normalized RPATHs in the old prefix using the file path
# in the orig prefix # in the orig prefix
orig_norm_rpaths = _normalize_relative_paths(orig_path_name, orig_norm_rpaths = _normalize_relative_paths(
orig_rpaths) orig_binary, orig_rpaths
# get the normalize rpaths in the new prefix )
norm_rpaths = elf_find_paths(orig_norm_rpaths, old_layout_root, # Get the normalize RPATHs in the new prefix
prefix_to_prefix) new_norm_rpaths = _transform_rpaths(
# get the relativized rpaths in the new prefix orig_norm_rpaths, orig_root, new_prefixes
new_rpaths = _make_relative(path_name, new_layout_root, )
norm_rpaths) # Get the relative RPATHs in the new prefix
modify_elf_object(path_name, new_rpaths) new_rpaths = _make_relative(
new_binary, new_root, new_norm_rpaths
)
_set_elf_rpaths(new_binary, new_rpaths)
else: else:
new_rpaths = elf_find_paths(orig_rpaths, old_layout_root, new_rpaths = _transform_rpaths(
prefix_to_prefix) orig_rpaths, orig_root, new_prefixes
modify_elf_object(path_name, new_rpaths) )
_set_elf_rpaths(new_binary, new_rpaths)
def make_link_relative(cur_path_names, orig_path_names): def make_link_relative(cur_path_names, orig_path_names):
@ -684,7 +716,7 @@ def make_elf_binaries_relative(cur_path_names, orig_path_names,
if orig_rpaths: if orig_rpaths:
new_rpaths = _make_relative(orig_path, old_layout_root, new_rpaths = _make_relative(orig_path, old_layout_root,
orig_rpaths) orig_rpaths)
modify_elf_object(cur_path, new_rpaths) _set_elf_rpaths(cur_path, new_rpaths)
def check_files_relocatable(cur_path_names, allow_root): def check_files_relocatable(cur_path_names, allow_root):
@ -738,11 +770,11 @@ def relocate_text(path_names, old_layout_root, new_layout_root,
sbangnew = '#!/bin/bash %s/bin/sbang' % new_spack_prefix sbangnew = '#!/bin/bash %s/bin/sbang' % new_spack_prefix
for path_name in path_names: for path_name in path_names:
replace_prefix_text(path_name, old_install_prefix, new_install_prefix) _replace_prefix_text(path_name, old_install_prefix, new_install_prefix)
for orig_dep_prefix, new_dep_prefix in prefix_to_prefix.items(): for orig_dep_prefix, new_dep_prefix in prefix_to_prefix.items():
replace_prefix_text(path_name, orig_dep_prefix, new_dep_prefix) _replace_prefix_text(path_name, orig_dep_prefix, new_dep_prefix)
replace_prefix_text(path_name, old_layout_root, new_layout_root) _replace_prefix_text(path_name, old_layout_root, new_layout_root)
replace_prefix_text(path_name, sbangre, sbangnew) _replace_prefix_text(path_name, sbangre, sbangnew)
def relocate_text_bin(path_names, old_layout_root, new_layout_root, def relocate_text_bin(path_names, old_layout_root, new_layout_root,
@ -758,9 +790,9 @@ def relocate_text_bin(path_names, old_layout_root, new_layout_root,
for path_name in path_names: for path_name in path_names:
for old_dep_prefix, new_dep_prefix in prefix_to_prefix.items(): for old_dep_prefix, new_dep_prefix in prefix_to_prefix.items():
if len(new_dep_prefix) <= len(old_dep_prefix): if len(new_dep_prefix) <= len(old_dep_prefix):
replace_prefix_bin( _replace_prefix_bin(
path_name, old_dep_prefix, new_dep_prefix) path_name, old_dep_prefix, new_dep_prefix)
replace_prefix_bin(path_name, old_spack_prefix, new_spack_prefix) _replace_prefix_bin(path_name, old_spack_prefix, new_spack_prefix)
else: else:
if len(path_names) > 0: if len(path_names) > 0:
raise BinaryTextReplaceError( raise BinaryTextReplaceError(

View File

@ -103,6 +103,31 @@ def _factory(output):
return _factory return _factory
@pytest.fixture()
def hello_world(tmpdir):
source = tmpdir.join('main.c')
source.write("""
#include <stdio.h>
int main(){
printf("Hello world!");
}
""")
def _factory(rpaths):
gcc = spack.util.executable.which('gcc')
executable = source.dirpath('main.x')
rpath_str = ':'.join(rpaths)
opts = [
'-Wl,--disable-new-dtags',
'-Wl,-rpath={0}'.format(rpath_str),
str(source), '-o', str(executable)
]
gcc(*opts)
return executable
return _factory
@pytest.mark.requires_executables( @pytest.mark.requires_executables(
'/usr/bin/gcc', 'patchelf', 'strings', 'file' '/usr/bin/gcc', 'patchelf', 'strings', 'file'
) )
@ -118,9 +143,7 @@ def test_file_is_relocatable(source_file, is_relocatable):
assert spack.relocate.file_is_relocatable(executable) is is_relocatable assert spack.relocate.file_is_relocatable(executable) is is_relocatable
@pytest.mark.requires_executables( @pytest.mark.requires_executables('patchelf', 'strings', 'file')
'patchelf', 'strings', 'file'
)
def test_patchelf_is_relocatable(): def test_patchelf_is_relocatable():
patchelf = spack.relocate._patchelf() patchelf = spack.relocate._patchelf()
assert llnl.util.filesystem.is_exe(patchelf) assert llnl.util.filesystem.is_exe(patchelf)
@ -194,3 +217,94 @@ def test_normalize_relative_paths(start_path, relative_paths, expected):
start_path, relative_paths start_path, relative_paths
) )
assert normalized == expected assert normalized == expected
def test_set_elf_rpaths(mock_patchelf):
# Try to relocate a mock version of patchelf and check
# the call made to patchelf itself
patchelf = mock_patchelf('echo $@')
rpaths = ['/usr/lib', '/usr/lib64', '/opt/local/lib']
output = spack.relocate._set_elf_rpaths(patchelf, rpaths)
# Assert that the arguments of the call to patchelf are as expected
assert '--force-rpath' in output
assert '--set-rpath ' + ':'.join(rpaths) in output
assert patchelf in output
def test_set_elf_rpaths_warning(mock_patchelf):
# Mock a failing patchelf command and ensure it warns users
patchelf = mock_patchelf('exit 1')
rpaths = ['/usr/lib', '/usr/lib64', '/opt/local/lib']
# To avoid using capfd in order to check if the warning was triggered
# here we just check that output is not set
output = spack.relocate._set_elf_rpaths(patchelf, rpaths)
assert output is None
@pytest.mark.requires_executables('patchelf', 'strings', 'file', 'gcc')
def test_replace_prefix_bin(hello_world):
# Compile an "Hello world!" executable and set RPATHs
executable = hello_world(rpaths=['/usr/lib', '/usr/lib64'])
# Relocate the RPATHs
spack.relocate._replace_prefix_bin(str(executable), '/usr', '/foo')
# Check that the RPATHs changed
patchelf = spack.util.executable.which('patchelf')
output = patchelf('--print-rpath', str(executable), output=str)
assert output.strip() == '/foo/lib:/foo/lib64'
@pytest.mark.requires_executables('patchelf', 'strings', 'file', 'gcc')
def test_relocate_elf_binaries_absolute_paths(hello_world, tmpdir):
# Create an executable, set some RPATHs, copy it to another location
orig_binary = hello_world(rpaths=[str(tmpdir.mkdir('lib')), '/usr/lib64'])
new_root = tmpdir.mkdir('another_dir')
shutil.copy(str(orig_binary), str(new_root))
# Relocate the binary
new_binary = new_root.join('main.x')
spack.relocate.relocate_elf_binaries(
binaries=[str(new_binary)],
orig_root=str(orig_binary.dirpath()),
new_root=None, # Not needed when relocating absolute paths
new_prefixes={
str(tmpdir): '/foo'
},
rel=False,
# Not needed when relocating absolute paths
orig_prefix=None, new_prefix=None
)
# Check that the RPATHs changed
patchelf = spack.util.executable.which('patchelf')
output = patchelf('--print-rpath', str(new_binary), output=str)
assert output.strip() == '/foo/lib:/usr/lib64'
@pytest.mark.requires_executables('patchelf', 'strings', 'file', 'gcc')
def test_relocate_elf_binaries_relative_paths(hello_world, tmpdir):
# Create an executable, set some RPATHs, copy it to another location
orig_binary = hello_world(
rpaths=['$ORIGIN/lib', '$ORIGIN/lib64', '/opt/local/lib']
)
new_root = tmpdir.mkdir('another_dir')
shutil.copy(str(orig_binary), str(new_root))
# Relocate the binary
new_binary = new_root.join('main.x')
spack.relocate.relocate_elf_binaries(
binaries=[str(new_binary)],
orig_root=str(orig_binary.dirpath()),
new_root=str(new_root),
new_prefixes={str(tmpdir): '/foo'},
rel=True,
orig_prefix=str(orig_binary.dirpath()),
new_prefix=str(new_root)
)
# Check that the RPATHs changed
patchelf = spack.util.executable.which('patchelf')
output = patchelf('--print-rpath', str(new_binary), output=str)
assert output.strip() == '/foo/lib:/foo/lib64:/opt/local/lib'