# Copyright (C) 2018, 2020, 2021 Igalia S.L. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, this # list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR # ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import logging import os import shutil import subprocess _log = logging.getLogger(__name__) class BinaryBundler: VAR_MYDIR = 'MYDIR' def __init__(self, destination_dir, build_path=None): self._destination_dir = destination_dir self._has_patched_interpreter_relpath = False self._build_path = build_path self._should_use_sys_lib_directory = False def _is_system_dep(self, path): if self._build_path is not None: # We don't consider libwpe and libWPEBackend-fdo syslibs (even when those are not built directly). Neither dlopenwrap if any(p in path.lower() for p in ['libwpe', 'dlopenwrap']): return False return not path.startswith(self._build_path) return False def set_use_sys_lib_directory(self, should_use_sys_lib_directory): self._should_use_sys_lib_directory = should_use_sys_lib_directory def copy_and_maybe_strip_patchelf(self, orig_file, type='bin', strip=True, patchelf_removerpath=True, patchelf_nodefaultlib=False, patchelf_setinterpreter_relativepath=None, object_final_destination_dir=None): """ This does the following: 1. Copies the binary/lib (object) 2. Optionally runs patchelf with different parameters 3. Optionally strips the binary If the file is a symlink pointing another file then the symlink is preserved (copying the file pointed) """ dir_suffix = 'lib' if type == 'interpreter' else type if object_final_destination_dir is None: object_final_destination_dir = self._destination_dir # If set_use_sys_lib_directory() is enabled then system libraries are shipped in $ROOT/sys/lib to allow to redistribute them separately. # However, the interpreter is always shipped in $ROOT/lib and all the binaries are always shipped in $ROOT/bin # Even when they are system_deps because of patchelf_setinterpreter_relativepath if self._should_use_sys_lib_directory and self._is_system_dep(orig_file) and type not in ['interpreter', 'bin']: object_final_destination_dir = os.path.join(object_final_destination_dir, 'sys') object_final_destination_dir = os.path.join(object_final_destination_dir, dir_suffix) if not os.path.isdir(object_final_destination_dir): os.makedirs(object_final_destination_dir) if not os.path.isfile(orig_file): raise ValueError('Can not find file %s' % orig_file) _log.info('Add to bundle [%s]: %s' % (type, orig_file)) # Preserve symlinks and copy the targets if os.path.islink(orig_file): symlink_src_file = os.path.realpath(orig_file) symlink_src_basename = os.path.basename(symlink_src_file) symlink_dst_basename = os.path.basename(orig_file) symlink_dst_fullpath = os.path.join(object_final_destination_dir, symlink_dst_basename) if symlink_src_basename != symlink_dst_basename: try: # symlink_dst_fullpath -> symlink_src_basename os.symlink(symlink_src_basename, symlink_dst_fullpath) except FileExistsError: previous_symlink_src_path = os.path.realpath(symlink_dst_fullpath) previous_symlink_src_basename = os.path.basename(previous_symlink_src_path) if previous_symlink_src_basename != symlink_src_basename: raise RuntimeError('Not overwriting previous symlink %s pointing to %s with a symlink pointing to %s' % (symlink_dst_fullpath, previous_symlink_src_basename, symlink_src_basename)) return self.copy_and_maybe_strip_patchelf(symlink_src_file, type, strip, patchelf_removerpath, patchelf_nodefaultlib, patchelf_setinterpreter_relativepath, object_final_destination_dir) try: shutil.copy(orig_file, object_final_destination_dir) except shutil.SameFileError: # May reasonably happen if the caller tries to bundle the files 'in place'. pass if type == 'interpreter': return # no strip/patchelf over it # Running 'strip' after 'patchelf' may corrupt binaries, so run first 'strip'. # See: https://github.com/NixOS/patchelf/issues/371 if strip: if not shutil.which('strip'): _log.warning('Unable to find the strip command in the system. Please install it.') raise Exception('Missing required program "strip"') strip_command = ['strip', '--strip-unneeded', os.path.join(object_final_destination_dir, os.path.basename(orig_file))] if subprocess.call(strip_command) != 0: _log.error('The strip command returned non-zero status') patchelf_arguments = [] if patchelf_removerpath: patchelf_arguments.append("--remove-rpath") if type == 'bin': if patchelf_nodefaultlib: patchelf_arguments.append("--no-default-lib") if patchelf_setinterpreter_relativepath: previous_cwd = os.getcwd() os.chdir(self.destination_dir()) # We only want the basename of the interpreter because we are updating it # to use a relative path inside lib/ (which is were we copied it previously) new_interpreter_relpath = os.path.join('lib', os.path.basename(patchelf_setinterpreter_relativepath)) if not os.path.isfile(new_interpreter_relpath): raise ValueError('Unable to find interpreter %s at directory %s' % (new_interpreter, self.destination_dir())) patchelf_arguments.extend(['--set-interpreter', new_interpreter_relpath]) self._has_patched_interpreter_relpath = True if patchelf_arguments: # If we have resolved patchelf_arguments then we should run patchelf patchelf_arguments.append(os.path.join(object_final_destination_dir, os.path.basename(orig_file))) if not shutil.which('patchelf'): raise RuntimeError('Unable to find the "patchelf" command in the system. Please install it.') if subprocess.call(['patchelf'] + patchelf_arguments) != 0: raise RuntimeError('The "patchelf" command with arguments "%s" returned non-zero status' % ' '.join(patchelf_arguments)) if patchelf_setinterpreter_relativepath: os.chdir(previous_cwd) def destination_dir(self): return self._destination_dir def generate_wrapper_script(self, interpreter, binary_to_wrap, extra_environment_variables={}): if not os.path.isfile(os.path.join(self._destination_dir, 'bin', binary_to_wrap)): raise RuntimeError('Cannot find binary to wrap for %s' % binary_to_wrap) _log.info('Generate wrapper script %s' % binary_to_wrap) script_file = os.path.join(self._destination_dir, binary_to_wrap) lib_dir = os.path.join(self._destination_dir, 'lib') syslib_dir = os.path.join(self._destination_dir, 'sys/lib') bin_dir = os.path.join(self._destination_dir, 'bin') share_dir = os.path.join(self._destination_dir, 'sys/share') with open(script_file, 'w') as script_handle: script_handle.write('#!/bin/sh\n') script_handle.write('%s="$(dirname $(readlink -f $0))"\n' % self.VAR_MYDIR) if self._has_patched_interpreter_relpath: # The interprerter (section PT_INTERP in ELF binary) needs to be a pre-defined path # So the only way of using our interpreter in an unknown destination dir is to use relative paths # For relative paths to work, we need to CD into the main basedir, but doing that may break # any parameter the user has passed as a relative path to where she is working (like a relpath to some html file) # So we try to update the arguments resolving relative paths to absolute before executing the program # Trick to update the arguments inspired from https://unix.stackexchange.com/a/421160 script_handle.write('# Shipped binaries have a relpath to the interpreter, so update passed args with fullpaths and cd to ${MYDIR}\n') script_handle.write('args_update_relpaths_to_abs() {\n') script_handle.write(' for arg in "$@"; do\n') script_handle.write(' [ "${arg}" = "${arg#/}" ] && [ -e "${arg}" ] && arg="$(readlink -f -- "${arg}")"\n') script_handle.write(' printf %s "${arg}" | sed "s/\'/\'\\\\\\\\\'\'/g;1s/^/\'/;\\$s/\\$/\' /"\n') script_handle.write(' done\n') script_handle.write('echo " "\n') script_handle.write('}\n') script_handle.write('eval "set -- $(args_update_relpaths_to_abs "$@")"\n') script_handle.write('cd "${MYDIR}"\n') if os.path.isfile(os.path.join(share_dir, 'certs/bundle-ca-certificates.pem')): script_handle.write('# Try to use the system CA file if possible, otherwise use the bundled one\n') script_handle.write('for WEBKIT_TLS_CAFILE_PEM in /etc/ssl/certs/ca-certificates.crt /etc/ssl/cert.pem /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem "${MYDIR}/sys/share/certs/bundle-ca-certificates.pem"; do\n') script_handle.write(' [ -f "${WEBKIT_TLS_CAFILE_PEM}" ] && grep -q "BEGIN CERTIFICATE" "${WEBKIT_TLS_CAFILE_PEM}" && export WEBKIT_TLS_CAFILE_PEM && break\n') script_handle.write('done\n') for var, value in extra_environment_variables.items(): script_handle.write('export %s="%s"\n' % (var, value)) if os.path.isdir(syslib_dir): # Preffer to use the system XKB data files, but if they are not on the expected location use our own. xkb_bundled = False for entry in os.listdir(syslib_dir): if entry.startswith('libxkbcommon.so'): xkb_bundled = True break if xkb_bundled: assert os.path.isdir(os.path.join(share_dir, 'xkb')) script_handle.write('[ -d /usr/share/X11/xkb ] || export XKB_CONFIG_ROOT="${%s}/sys/share/xkb"\n' % self.VAR_MYDIR) ld_library_path = '${%s}/lib' % self.VAR_MYDIR if os.path.isdir(syslib_dir): ld_library_path += ':${%s}/sys/lib' % self.VAR_MYDIR # Update cache of gdk-pixbuf loaders if needed (first run or when the absolute paths on disk have changed). if os.path.isdir(os.path.join(syslib_dir, 'gdk-pixbuf/loaders')): script_handle.write('export GDK_PIXBUF_MODULEDIR="${%s}/sys/lib/gdk-pixbuf/loaders"\n' % self.VAR_MYDIR) script_handle.write('export GDK_PIXBUF_MODULE_FILE="${%s}/sys/lib/gdk-pixbuf/loaders.cache"\n' % self.VAR_MYDIR) script_handle.write('grep -sq "\\"${GDK_PIXBUF_MODULEDIR}/" "${GDK_PIXBUF_MODULE_FILE}" || LD_LIBRARY_PATH="%s" "${%s}/bin/gdk-pixbuf-query-loaders" --update-cache\n' % (ld_library_path, self.VAR_MYDIR)) # Same for gtk immodules cache if os.path.isdir(os.path.join(syslib_dir, 'gtk/immodules')): script_handle.write('export GTK_IM_MODULE_DIR="${%s}/sys/lib/gtk/immodules"\n' % self.VAR_MYDIR) script_handle.write('export GTK_IM_MODULE_FILE="${%s}/sys/lib/gtk/immodules/immodules.cache"\n' % self.VAR_MYDIR) script_handle.write('grep -sq "\\"${GTK_IM_MODULE_DIR}/" "${GTK_IM_MODULE_FILE}" || LD_LIBRARY_PATH="%s" "${%s}/bin/gtk-query-immodules-3.0" --update-cache "${GTK_IM_MODULE_DIR}"/*.so\n' % (ld_library_path, self.VAR_MYDIR)) # LD_LIBRARY_PATH is set at the end, just before the exec() call and before calling programs bundled, otherwise it may break commands from the system like script's usage of sed/readlink/grep script_handle.write('export LD_LIBRARY_PATH="%s"\n' % ld_library_path) dlopenwrap_libname = 'dlopenwrap.so' if os.path.isfile(os.path.join(lib_dir, dlopenwrap_libname)): script_handle.write('export LD_PRELOAD="${%s}/lib/%s"\n' % (self.VAR_MYDIR, dlopenwrap_libname)) # Prefix the program with the interpreter when we are bundling all (interpreter is copied) and the path to the interpreter isn't patched. # Otherwise prefer to not prefix it, because that allow the process to use a more meaningful progname. interpreter_basename = os.path.basename(interpreter) if os.path.isfile(os.path.join(lib_dir, interpreter_basename)) and not self._has_patched_interpreter_relpath: script_handle.write('INTERPRETER="${%s}/lib/%s"\n' % (self.VAR_MYDIR, interpreter_basename)) script_handle.write('exec "${INTERPRETER}" "${%s}/bin/%s" "$@"\n' % (self.VAR_MYDIR, binary_to_wrap)) else: script_handle.write('exec "${%s}/bin/%s" "$@"\n' % (self.VAR_MYDIR, binary_to_wrap)) os.chmod(script_file, 0o755)