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
os: linux
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 ]
- python: '2.7'
os: linux
@ -84,6 +103,7 @@ addons:
- kcov
- mercurial
- ninja-build
- patchelf
- perl
- perl-base
- realpath

View File

@ -11,6 +11,7 @@
import llnl.util.tty as tty
import macholib.MachO
import macholib.mach_o
import spack.architecture
import spack.cmd
import spack.repo
import spack.spec
@ -385,57 +386,79 @@ def macholib_get_paths(cur_path):
return (rpaths, deps, ident)
def modify_elf_object(path_name, new_rpaths):
"""
Replace orig_rpath with new_rpath in RPATH of elf object path_name
def _set_elf_rpaths(target, rpaths):
"""Replace the original RPATH of the target with the paths passed
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, use it
bak_path = path_name + ".bak"
if path_name[-13:] == "/bin/patchelf":
shutil.copy(path_name, bak_path)
patchelf = executable.Executable(bak_path)
else:
patchelf = executable.Executable(_patchelf())
# If we're relocating patchelf itself, make a copy and use it
bak_path = None
if target.endswith("/bin/patchelf"):
bak_path = target + ".bak"
shutil.copy(target, bak_path)
patchelf, output = executable.Executable(bak_path or _patchelf()), None
try:
patchelf('--force-rpath', '--set-rpath', '%s' % new_joined,
'%s' % path_name, output=str, error=str)
# TODO: revisit the use of --force-rpath as it might be conditional
# 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:
msg = 'patchelf --force-rpath --set-rpath %s failed with error %s' % (
path_name, e)
tty.warn(msg)
if os.path.exists(bak_path):
os.remove(bak_path)
msg = 'patchelf --force-rpath --set-rpath {0} failed with error {1}'
tty.warn(msg.format(target, e))
finally:
if bak_path and os.path.exists(bak_path):
os.remove(bak_path)
return output
def needs_binary_relocation(m_type, m_subtype):
"""
Check whether the given filetype is a binary that may need relocation.
"""Returns True if the file with MIME type/subtype passed as arguments
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_subtype == 'x-executable' or m_subtype == 'x-sharedlib' or
m_subtype == 'x-mach-binary'):
if m_subtype in ('x-executable', 'x-sharedlib', 'x-mach-binary'):
return True
return False
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
in text files using utf-8 encoded strings.
"""
with open(path_name, 'rb+') as f:
with open(filename, 'rb+') as f:
data = f.read()
f.seek(0)
# 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()
def replace_prefix_bin(path_name, old_dir, new_dir):
"""
Attempt to replace old install prefix with new install prefix
in binary files by prefixing new install prefix with os.sep
until the lengths of the prefixes are the same.
"""
def _replace_prefix_bin(filename, old_dir, new_dir):
"""Replace all the occurrences of the old install prefix with a
new install prefix in binary files.
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):
occurances = match.group().count(old_dir.encode('utf-8'))
olen = len(old_dir.encode('utf-8'))
@ -468,11 +496,12 @@ def replace(match):
padding = (olen - nlen) * occurances
if padding < 0:
return data
return match.group().replace(old_dir.encode('utf-8'),
os.sep.encode('utf-8') * padding +
new_dir.encode('utf-8'))
return match.group().replace(
old_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()
f.seek(0)
original_data_len = len(data)
@ -482,43 +511,7 @@ def replace(match):
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.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))
filename, original_data_len, len(ndata))
f.write(ndata)
f.truncate()
@ -597,50 +590,89 @@ def relocate_macho_binaries(path_names, old_layout_root, new_layout_root,
paths_to_paths)
def elf_find_paths(orig_rpaths, old_layout_root, prefix_to_prefix):
new_rpaths = list()
def _transform_rpaths(orig_rpaths, orig_root, new_prefixes):
"""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:
if orig_rpath.startswith(old_layout_root):
for old_prefix, new_prefix in prefix_to_prefix.items():
if orig_rpath.startswith(old_prefix):
new_rpaths.append(re.sub(re.escape(old_prefix),
new_prefix, orig_rpath))
else:
# If the original RPATH doesn't start with the target root
# append it verbatim and proceed
if not orig_rpath.startswith(orig_root):
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
def relocate_elf_binaries(path_names, old_layout_root, new_layout_root,
prefix_to_prefix, rel, old_prefix, new_prefix):
"""
Use patchelf to get the original rpaths and then replace them with
def relocate_elf_binaries(binaries, orig_root, new_root,
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
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
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:
orig_rpaths = _elf_rpaths_for(path_name)
new_rpaths = list()
for new_binary in binaries:
orig_rpaths = _elf_rpaths_for(new_binary)
# TODO: Can we deduce `rel` from the original RPATHs?
if rel:
# get the file path in the old_prefix
orig_path_name = re.sub(re.escape(new_prefix), old_prefix,
path_name)
# get the normalized rpaths in the old prefix using the file path
# Get the file path in the original prefix
orig_binary = re.sub(
re.escape(new_prefix), orig_prefix, new_binary
)
# Get the normalized RPATHs in the old prefix using the file path
# in the orig prefix
orig_norm_rpaths = _normalize_relative_paths(orig_path_name,
orig_rpaths)
# get the normalize rpaths in the new prefix
norm_rpaths = elf_find_paths(orig_norm_rpaths, old_layout_root,
prefix_to_prefix)
# get the relativized rpaths in the new prefix
new_rpaths = _make_relative(path_name, new_layout_root,
norm_rpaths)
modify_elf_object(path_name, new_rpaths)
orig_norm_rpaths = _normalize_relative_paths(
orig_binary, orig_rpaths
)
# Get the normalize RPATHs in the new prefix
new_norm_rpaths = _transform_rpaths(
orig_norm_rpaths, orig_root, new_prefixes
)
# Get the relative RPATHs in the new prefix
new_rpaths = _make_relative(
new_binary, new_root, new_norm_rpaths
)
_set_elf_rpaths(new_binary, new_rpaths)
else:
new_rpaths = elf_find_paths(orig_rpaths, old_layout_root,
prefix_to_prefix)
modify_elf_object(path_name, new_rpaths)
new_rpaths = _transform_rpaths(
orig_rpaths, orig_root, new_prefixes
)
_set_elf_rpaths(new_binary, new_rpaths)
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:
new_rpaths = _make_relative(orig_path, old_layout_root,
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):
@ -738,11 +770,11 @@ def relocate_text(path_names, old_layout_root, new_layout_root,
sbangnew = '#!/bin/bash %s/bin/sbang' % new_spack_prefix
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():
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, sbangre, sbangnew)
_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, sbangre, sbangnew)
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 old_dep_prefix, new_dep_prefix in prefix_to_prefix.items():
if len(new_dep_prefix) <= len(old_dep_prefix):
replace_prefix_bin(
_replace_prefix_bin(
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:
if len(path_names) > 0:
raise BinaryTextReplaceError(

View File

@ -103,6 +103,31 @@ def _factory(output):
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(
'/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
@pytest.mark.requires_executables(
'patchelf', 'strings', 'file'
)
@pytest.mark.requires_executables('patchelf', 'strings', 'file')
def test_patchelf_is_relocatable():
patchelf = spack.relocate._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
)
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'