diff --git a/repos/system_upgrade/common/actors/targetuserspacecreator/libraries/userspacegen.py b/repos/system_upgrade/common/actors/targetuserspacecreator/libraries/userspacegen.py index c1d34f18c6..8d8044072a 100644 --- a/repos/system_upgrade/common/actors/targetuserspacecreator/libraries/userspacegen.py +++ b/repos/system_upgrade/common/actors/targetuserspacecreator/libraries/userspacegen.py @@ -73,6 +73,10 @@ def _check_deprecated_rhsm_skip(): ) +class BrokenSymlinkError(Exception): + """Raised when we encounter a broken symlink where we weren't expecting it.""" + + class _InputData(object): def __init__(self): self._consume_data() @@ -328,9 +332,131 @@ def _get_files_owned_by_rpms(context, dirpath, pkgs=None, recursive=False): return files_owned_by_rpms +def _mkdir_with_copied_mode(path, mode_from): + """ + Create directories with a file to copy the mode from. + + :param path: The directory path to create. + :param mode_from: A file or directory whose mode we will copy to the + newly created directory. + :raises subprocess.CalledProcessError: mkdir or chmod fails. For instance, + the directory already exists, the file to get permissions from does + not exist, a parent directory does not exist. + """ + # Create with maximally restrictive permissions + run(['mkdir', '-m', '0', '-p', path]) + run(['chmod', '--reference={}'.format(mode_from), path]) + + +def _choose_copy_or_link(symlink, srcdir): + """ + Copy file contents or create a symlink depending on where the pointee resides. + + :param symlink: The source symlink to follow. This must be an absolute path. + :param srcdir: The root directory that every piece of content must be present in. + :returns: A tuple of action and sourcefile. Action is one of 'copy' or 'link' and means that + the caller should either copy the sourcefile to the target location or create a symlink from + the sourcefile to the target location. sourcefile is the path to the file that should be + the source of the operation. It is either a real file outside of the srcdir hierarchy or + a file (real, directory, symlink or otherwise) inside of the srcdir hierarchy. + :raises ValueError: if the arguments are not correct + :raises BrokenSymlinkError: if the symlink is invalid + + Determine whether the file pointed to by the symlink chain is within srcdir. If it is within, + then create a synlink that points from symlink to it. + + If it is not within, then walk the symlink chain until we find something that is within srcdir + and return that. This means we will omit any symlinks that are outside of srcdir from + the symlink chain. + + If we reach a real file and it is outside of srcdir, then copy the file instead. + """ + if not symlink.startswith('/'): + raise ValueError('File{} must be an absolute path!'.format(symlink)) + + # os.path.exists follows symlinks + if not os.path.exists(symlink): + raise BrokenSymlinkError('File {} is a broken symlink!'.format(symlink)) + + # If srcdir is a symlink, then we need a name for it that we can compare + # with other paths. + canonical_srcdir = os.path.realpath(srcdir) + + pointee_as_abspath = symlink + seen = set([pointee_as_abspath]) + + # The goal of this while loop is to find the next link in a possible + # symlink chain that either points to a symlink inside of srcdir or to + # a file or directory that we can copy. + while os.path.islink(pointee_as_abspath): + # Advance pointee to the target of the previous link + pointee = os.readlink(pointee_as_abspath) + + # Note: os.path.join()'s behaviour if the pointee is an absolute path + # essentially ignores the first argument (which is what we want). + pointee_as_abspath = os.path.normpath(os.path.join(os.path.dirname(pointee_as_abspath), pointee)) + + # Make sure we aren't in a circular set of references. + # On Linux, this should not happen as the os.path.exists() call + # before the loop should catch it but we don't want to enter an + # infinite loop if that code changes later. + if pointee_as_abspath in seen: + if symlink == pointee_as_abspath: + error_msg = ('File {} is a broken symlink that references' + ' itself!'.format(pointee_as_abspath)) + else: + error_msg = ('File {} references {} which is a broken symlink' + ' that references itself!'.format(symlink, pointee_as_abspath)) + + raise BrokenSymlinkError(error_msg) + + seen.add(pointee_as_abspath) + + # To make comparisons, we need to resolve all symlinks in the directory + # structure leading up to pointee. However, we can't include pointee + # itself otherwise it will resolve to the file that it points to in the + # end. + canonical_pointee_dir, pointee_filename = os.path.split(pointee_as_abspath) + canonical_pointee_dir = os.path.realpath(canonical_pointee_dir) + + if canonical_pointee_dir.startswith(canonical_srcdir): + # Absolute path inside of the correct dir so we need to link to it + # But we need to determine what the link path should be before + # returning. + + # Construct a relative path that points from the symlinks directory + # to the pointee. + link_to = os.readlink(symlink) + canonical_symlink_dir = os.path.realpath(os.path.dirname(symlink)) + relative_path = os.path.relpath(canonical_pointee_dir, canonical_symlink_dir) + + if link_to.startswith('/'): + # The original symlink was an absolute path so we will set this + # one to absolute too + # Note: Because absolute paths are constructed inside of + # srcdir, the relative path that we need to join here has to be + # relative to srcdir, not the directory that the symlink is + # being created in. + relative_to_srcdir = os.path.relpath(canonical_pointee_dir, canonical_srcdir) + corrected_path = os.path.normpath(os.path.join(srcdir, relative_to_srcdir, pointee_filename)) + + else: + # If the original link is a relative link, then we want the new + # link to be relative as well + corrected_path = os.path.normpath(os.path.join(relative_path, pointee_filename)) + + return ("link", corrected_path) + + # pointee is a symlink that points outside of the srcdir so continue to + # the next symlink in the chain. + + # The file is not a link so copy it + return ('copy', pointee_as_abspath) + + def _copy_decouple(srcdir, dstdir): """ - Copy `srcdir` to `dstdir` while decoupling symlinks. + Copy files inside of `srcdir` to `dstdir` while decoupling symlinks. What we mean by decoupling the `srcdir` is that any symlinks pointing outside the directory will be copied as regular files. This means that the @@ -338,58 +464,55 @@ def _copy_decouple(srcdir, dstdir): symlinks. Any symlink (or symlink chains) within the directory will be preserved. + .. warning:: + `dstdir` must already exist. """ + symlinks_to_process = [] + for root, directories, files in os.walk(srcdir): + # relative path from srcdir because srcdir is replaced with dstdir for + # the copy. + relpath = os.path.relpath(root, srcdir) + + # Create all directories with proper permissions for security + # reasons (Putting private data into directories that haven't had their + # permissions set appropriately may leak the private information.) + for directory in directories: + source_dirpath = os.path.join(root, directory) + target_dirpath = os.path.join(dstdir, relpath, directory) + _mkdir_with_copied_mode(target_dirpath, source_dirpath) - for root, dummy_dirs, files in os.walk(srcdir): for filename in files: - relpath = os.path.relpath(root, srcdir) source_filepath = os.path.join(root, filename) target_filepath = os.path.join(dstdir, relpath, filename) - # Skip and report broken symlinks - if not os.path.exists(source_filepath): - api.current_logger().warning( - 'File {} is a broken symlink! Will not copy the file.'.format(source_filepath)) - continue - - # Copy symlinks to the target userspace - source_is_symlink = os.path.islink(source_filepath) - pointee = None - if source_is_symlink: - pointee = os.readlink(source_filepath) - - # If source file is a symlink within `srcdir` then preserve it, - # otherwise resolve and copy it as a file it points to - if pointee is not None and not pointee.startswith(srcdir): - # Follow the path until we hit a file or get back to /etc/pki - while not pointee.startswith(srcdir) and os.path.islink(pointee): - pointee = os.readlink(pointee) - - # Pointee points to a _regular file_ outside /etc/pki so we - # copy it instead - if not pointee.startswith(srcdir) and not os.path.islink(pointee): - source_is_symlink = False - source_filepath = pointee - else: - # pointee points back to /etc/pki - pass - - # Ensure parent directory exists - parent_dir = os.path.dirname(target_filepath) - # Note: This is secure because we know that parent_dir is located - # inside of `$target_userspace/etc/pki` which is a directory that - # is not writable by unprivileged users. If this function is used - # elsewhere we may need to be more careful before running `mkdir -p`. - run(['mkdir', '-p', parent_dir]) - - if source_is_symlink: - # Preserve the owner and permissions of the original symlink - run(['ln', '-s', pointee, target_filepath]) - run(['chmod', '--reference={}'.format(source_filepath), target_filepath]) + # Defer symlinks until later because we may end up having to copy + # the file contents and the directory may not exist yet. + if os.path.islink(source_filepath): + symlinks_to_process.append((source_filepath, target_filepath)) continue + # Not a symlink so we can copy it now too run(['cp', '-a', source_filepath, target_filepath]) + # Now process all symlinks + for source_linkpath, target_linkpath in symlinks_to_process: + try: + action, source_path = _choose_copy_or_link(source_linkpath, srcdir) + except BrokenSymlinkError as e: + # Skip and report broken symlinks + api.current_logger().warning('{} Will not copy the file!'.format(str(e))) + continue + + if action == "copy": + # Note: source_path could be a directory, so '-a' or '-r' must be + # given to cp. + run(['cp', '-a', source_path, target_linkpath]) + elif action == 'link': + run(["ln", "-s", source_path, target_linkpath]) + else: + # This will not happen unless _copy_or_link() has a bug. + raise RuntimeError("Programming error: _copy_or_link() returned an unknown action:{}".format(action)) + def _copy_certificates(context, target_userspace): """ @@ -414,6 +537,10 @@ def _copy_certificates(context, target_userspace): # Backup container /etc/pki run(['mv', target_pki, backup_pki]) + # _copy_decouple() requires we create the target_pki directory here because we don't know + # the mode inside of _copy_decouple(). + _mkdir_with_copied_mode(target_pki, backup_pki) + # Copy source /etc/pki to the container _copy_decouple('/etc/pki', target_pki) diff --git a/repos/system_upgrade/common/actors/targetuserspacecreator/tests/unit_test_targetuserspacecreator.py b/repos/system_upgrade/common/actors/targetuserspacecreator/tests/unit_test_targetuserspacecreator.py index 1a1ee56e42..e988ce02c4 100644 --- a/repos/system_upgrade/common/actors/targetuserspacecreator/tests/unit_test_targetuserspacecreator.py +++ b/repos/system_upgrade/common/actors/targetuserspacecreator/tests/unit_test_targetuserspacecreator.py @@ -59,6 +59,33 @@ def __exit__(self, exception_type, exception_value, traceback): def traverse_structure(structure, root=Path('/')): + """ + Given a description of a directory structure, return fullpaths to the + files and what they link to. + + :param structure: A dict which defined the directory structure. See below + for what it looks like. + :param root: A path to prefix to the files. On an actual run in production. + this would be `/` but since we're doing this in a unittest, it needs to + be a temporary directory. + :returns: This is a generator, so pairs of (filepath, what it links to) will + be returned one at a time, each time through the iterable. + + The semantics of `structure` are as follows: + + 1. The outermost dictionary encodes the root of a directory structure + + 2. Depending on the value for a key in a dict, each key in the dictionary + denotes the name of either a: + a) directory -- if value is dict + b) regular file -- if value is None + c) symlink -- if a value is str + + 3. The value of a symlink entry is a absolute path to a file in the context of + the structure. + + .. warning:: Empty directories are not returned. + """ for filename, links_to in structure.items(): filepath = root / filename @@ -72,14 +99,30 @@ def traverse_structure(structure, root=Path('/')): def assert_directory_structure_matches(root, initial, expected): # Assert every file that is supposed to be present is present for filepath, links_to in traverse_structure(expected, root=root / 'expected'): - assert filepath.exists() + assert filepath.exists(), "{} was supposed to exist and does not".format(filepath) if links_to is None: - assert filepath.is_file() + assert filepath.is_file(), "{} was supposed to be a file but is not".format(filepath) continue - assert filepath.is_symlink() - assert os.readlink(str(filepath)) == str(root / 'initial' / links_to.lstrip('/')) + assert filepath.is_symlink(), '{} was supposed to be a symlink but is not'.format(filepath) + + # We need to rewrite absolute paths because: + # * links_to contains an absolute path to the resource where the root + # directory is `/`. + # * In our test case, the source resource is rooted in a temporary + # directory rather than '/'. + # * The temporary directory name is root / 'initial'. + # So we rewrite the initial `/` to be `root/{initial}` to account for + # that. In production, the root directory will be `/` so no rewriting + # will happen there. + # + if links_to.startswith('/'): + links_to = str(root / 'initial' / links_to.lstrip('/')) + + actual_links_to = os.readlink(str(filepath)) + assert actual_links_to == str(links_to), ( + '{} linked to {} instead of {}'.format(filepath, actual_links_to, links_to)) # Assert there are no extra files result_dir = str(root / 'expected') @@ -95,21 +138,36 @@ def assert_directory_structure_matches(root, initial, expected): filepath = os.path.join(fileroot, filename) if os.path.islink(filepath): - links_to = '/' + os.path.relpath(os.readlink(filepath), str(root / 'initial')) + links_to = os.readlink(filepath) + # We rewrite absolute paths because the root diretory is in + # a temp dir instead of `/` in the unittest. See the comment + # where we rewrite `links_to` for the previous loop in this + # function for complete details. + if links_to.startswith('/'): + links_to = '/' + os.path.relpath(links_to, str(root / 'initial')) assert cwd[filename] == links_to @pytest.fixture def temp_directory_layout(tmp_path, initial_structure): for filepath, links_to in traverse_structure(initial_structure, root=tmp_path / 'initial'): + # Directories are inlined by traverse_structure so we need to create + # them here file_path = tmp_path / filepath file_path.parent.mkdir(parents=True, exist_ok=True) + # Real file if links_to is None: file_path.touch() continue - file_path.symlink_to(tmp_path / 'initial' / links_to.lstrip('/')) + # Symlinks + if links_to.startswith('/'): + # Absolute symlink + file_path.symlink_to(tmp_path / 'initial' / links_to.lstrip('/')) + else: + # Relative symlink + file_path.symlink_to(links_to) (tmp_path / 'expected').mkdir() assert (tmp_path / 'expected').exists() @@ -117,159 +175,560 @@ def temp_directory_layout(tmp_path, initial_structure): return tmp_path -# The semantics of initial_structure and expected_structure are as follows: -# -# 1. The outermost dictionary encodes the root of a directory structure -# -# 2. Depending on the value for a key in a dict, each key in the dictionary -# denotes the name of either a: -# a) directory -- if value is dict -# b) regular file -- if value is None -# c) symlink -- if a value is str -# -# 3. The value of a symlink entry is a absolute path to a file in the context of -# the structure. -# +# The semantics of initial_structure and expected_structure are defined in the +# traverse_structure() docstring. @pytest.mark.parametrize('initial_structure,expected_structure', [ - ({ # Copy a regular file - 'dir': { - 'fileA': None - } - }, { - 'dir': { - 'fileA': None - } - }), - ({ # Do not copy a broken symlink - 'dir': { - 'fileA': 'nonexistent' - } - }, { - 'dir': {} - }), - ({ # Copy a regular symlink - 'dir': { - 'fileA': '/dir/fileB', - 'fileB': None - } - }, { - 'dir': { - 'fileA': '/dir/fileB', - 'fileB': None - } - }), - ({ # Do not copy a chain of broken symlinks - 'dir': { - 'fileA': '/dir/fileB', - 'fileB': 'nonexistent' - } - }, { - 'dir': {} - }), - ({ # Copy a chain of symlinks - 'dir': { - 'fileA': '/dir/fileB', - 'fileB': '/dir/fileC', - 'fileC': None - } - }, { - 'dir': { - 'fileA': '/dir/fileB', - 'fileB': '/dir/fileC', - 'fileC': None - } - }), - ({ # Circular symlinks - 'dir': { - 'fileA': '/dir/fileB', - 'fileB': '/dir/fileC', - 'fileC': '/dir/fileC', - } - }, { - 'dir': {} - }), - ({ # Copy a link to a file outside the considered directory as file - 'dir': { - 'fileA': '/dir/fileB', - 'fileB': '/dir/fileC', - 'fileC': '/outside/fileOut', - 'fileE': None - }, - 'outside': { - 'fileOut': '/outside/fileD', - 'fileD': '/dir/fileE' - } - }, { - 'dir': { - 'fileA': '/dir/fileB', - 'fileB': '/dir/fileC', - 'fileC': '/dir/fileE', - 'fileE': None, - } - }), - ({ # Same test with a nested structure within the source dir - 'dir': { - 'nested': { - 'fileA': '/dir/nested/fileB', - 'fileB': '/dir/nested/fileC', + (pytest.param( + { + 'dir': { + 'fileA': None + } + }, + { + 'dir': { + 'fileA': None + }, + }, + id="Copy_a_regular_file" + )), + # Absolute symlink tests + (pytest.param( + { + 'dir': { + 'fileA': '/nonexistent' + } + }, + { + 'dir': {}, + }, + id="Absolute_do_not_copy_a_broken_symlink" + )), + (pytest.param( + { + 'dir': { + 'fileA': '/dir/fileB', + 'fileB': '/nonexistent' + } + }, + { + 'dir': {} + }, + id="Absolute_do_not_copy_a_chain_of_broken_symlinks" + )), + (pytest.param( + { + 'dir': { + 'fileA': '/nonexistent-dir/nonexistent' + }, + }, + { + 'dir': {}, + }, + id="Absolute_do_not_copy_a_broken_symlink_to_a_nonexistent_directory" + )), + (pytest.param( + { + 'dir': { + 'fileA': '/dir/fileB', + 'fileB': '/dir/fileC', + 'fileC': '/dir/fileA', + 'fileD': '/dir/fileD', + } + }, + { + 'dir': {} + }, + id="Absolute_do_not_copy_circular_symlinks" + )), + (pytest.param( + { + 'dir': { + 'fileA': '/dir/fileB', + 'fileB': None + } + }, + { + 'dir': { + 'fileA': '/dir/fileB', + 'fileB': None + } + }, + id="Absolute_copy_a_regular_symlink" + )), + (pytest.param( + { + 'dir': { + 'fileA': '/dir/fileB', + 'fileB': '/dir/fileC', + 'fileC': None + } + }, + { + 'dir': { + 'fileA': '/dir/fileB', + 'fileB': '/dir/fileC', + 'fileC': None + } + }, + id="Absolute_copy_a_chain_of_symlinks" + )), + (pytest.param( + { + 'dir': { + 'fileA': '/dir/fileB', + 'fileB': '/dir/fileC', 'fileC': '/outside/fileOut', 'fileE': None + }, + 'outside': { + 'fileOut': '/outside/fileD', + 'fileD': '/dir/fileE' + } + }, + { + 'dir': { + 'fileA': '/dir/fileB', + 'fileB': '/dir/fileC', + 'fileC': '/dir/fileE', + 'fileE': None, + } + }, + id="Absolute_copy_a_link_to_a_file_outside_the_considered_directory_as_file" + )), + (pytest.param( + { + 'dir': { + 'nested': { + 'fileA': '/dir/nested/fileB', + 'fileB': '/dir/nested/fileC', + 'fileC': '/outside/fileOut', + 'fileE': None + } + }, + 'outside': { + 'fileOut': '/outside/fileD', + 'fileD': '/dir/nested/fileE' } }, - 'outside': { - 'fileOut': '/outside/fileD', - 'fileD': '/dir/nested/fileE' - } - }, { - 'dir': { - 'nested': { - 'fileA': '/dir/nested/fileB', - 'fileB': '/dir/nested/fileC', - 'fileC': '/dir/nested/fileE', + { + 'dir': { + 'nested': { + 'fileA': '/dir/nested/fileB', + 'fileB': '/dir/nested/fileC', + 'fileC': '/dir/nested/fileE', + 'fileE': None + } + } + }, + id="Absolute_copy_a_link_to_a_file_outside_with_a_nested_structure_within_the_source_dir" + )), + (pytest.param( + { + 'dir': { + 'fileA': '/dir/fileB', + 'fileB': '/dir/fileC', + 'fileC': '/outside/nested/fileOut', 'fileE': None + }, + 'outside': { + 'nested': { + 'fileOut': '/outside/nested/fileD', + 'fileD': '/dir/fileE' + } } - } - }), - ({ # Same test with a nested structure in the outside dir - 'dir': { - 'fileA': '/dir/fileB', - 'fileB': '/dir/fileC', - 'fileC': '/outside/nested/fileOut', - 'fileE': None - }, - 'outside': { - 'nested': { - 'fileOut': '/outside/nested/fileD', - 'fileD': '/dir/fileE' + }, + { + 'dir': { + 'fileA': '/dir/fileB', + 'fileB': '/dir/fileC', + 'fileC': '/dir/fileE', + 'fileE': None, } - } - }, { - 'dir': { - 'fileA': '/dir/fileB', - 'fileB': '/dir/fileC', - 'fileC': '/dir/fileE', - 'fileE': None, - } - }), + }, + id="Absolute_copy_a_link_to_a_file_outside_with_a_nested_structure_in_the_outside_dir" + )), + (pytest.param( + { + 'dir': { + 'fileA': '/outside/fileOut', + 'fileB': None, + }, + 'outside': { + 'fileOut': '../dir/fileB', + }, + }, + { + 'dir': { + 'fileA': '/dir/fileB', + 'fileB': None, + }, + }, + id="Absolute_symlink_that_leaves_the_directory_but_returns_with_relative_outside" + )), + (pytest.param( + { + 'dir': { + 'fileA': '/outside/fileB', + 'fileB': None, + }, + 'outside': '/dir', + }, + { + 'dir': { + 'fileA': '/dir/fileB', + 'fileB': None, + }, + }, + id="Absolute_symlink_to_a_file_inside_via_a_symlink_to_the_rootdir" + )), + (pytest.param( + # This one is very tricky: + # * The user has made /etc/pki a symlink to some other directory that + # they keep certificates. + # * In the target system, we are going to make /etc/pki an actual + # directory with the contents that the actual directory on the host + # system had. + { + 'dir': '/funkydir', + 'funkydir': { + 'fileA': '/funkydir/fileB', + 'fileB': None, + }, + }, + { + 'dir': { + 'fileA': '/dir/fileB', + 'fileB': None, + }, + }, + id="Absolute_symlink_where_srcdir_is_a_symlink_on_the_host_system" + )), + # Relative symlink tests + (pytest.param( + { + 'dir': { + 'fileA': 'nonexistent' + }, + }, + { + 'dir': {}, + }, + id="Relative_do_not_copy_a_broken_symlink" + )), + (pytest.param( + { + 'dir': { + 'fileA': 'fileB', + 'fileB': 'nonexistent' + } + }, + { + 'dir': {} + }, + id="Relative_do_not_copy_a_chain_of_broken_symlinks" + )), + (pytest.param( + { + 'dir': { + 'fileA': 'nonexistent-dir/nonexistent' + }, + }, + { + 'dir': {}, + }, + id="Relative_do_not_copy_a_broken_symlink_to_a_nonexistent_directory" + )), + (pytest.param( + { + 'dir': { + 'fileA': 'fileB', + 'fileB': 'fileC', + 'fileC': 'fileA', + 'fileD': 'fileD', + } + }, + { + 'dir': {} + }, + id="Relative_do_not_copy_circular_symlinks" + )), + (pytest.param( + { + 'dir': { + 'fileA': 'fileB', + 'fileB': None, + }, + }, + { + 'dir': { + 'fileA': 'fileB', + 'fileB': None, + }, + }, + id="Relative_copy_a_regular_symlink_to_a_file_in_the_same_directory" + )), + (pytest.param( + { + 'dir': { + 'fileA': 'dir2/../fileB', + 'fileB': None, + 'dir2': { + 'fileC': None + }, + }, + }, + { + 'dir': { + 'fileA': 'fileB', + 'fileB': None, + 'dir2': { + 'fileC': None + }, + }, + }, + id="Relative_symlink_with_parent_dir_but_still_in_same_directory" + )), + (pytest.param( + { + 'dir': { + 'fileA': 'fileB', + 'fileB': 'fileC', + 'fileC': None + } + }, + { + 'dir': { + 'fileA': 'fileB', + 'fileB': 'fileC', + 'fileC': None + } + }, + id="Relative_copy_a_chain_of_symlinks" + )), + (pytest.param( + { + 'dir': { + 'fileA': 'fileB', + 'fileB': 'fileC', + 'fileC': '../outside/fileOut', + 'fileE': None + }, + 'outside': { + 'fileOut': 'fileD', + 'fileD': '../dir/fileE' + } + }, + { + 'dir': { + 'fileA': 'fileB', + 'fileB': 'fileC', + 'fileC': 'fileE', + 'fileE': None, + } + }, + id="Relative_copy_a_link_to_a_file_outside_the_considered_directory_as_file" + )), + (pytest.param( + { + 'dir': { + 'fileA': '../outside/fileOut', + 'fileB': None, + }, + 'outside': { + 'fileOut': None, + }, + }, + { + 'dir': { + 'fileA': None, + 'fileB': None, + }, + }, + id="Relative_symlink_to_outside" + )), + (pytest.param( + { + 'dir': { + 'fileA': 'nested/fileB', + 'nested': { + 'fileB': None, + }, + }, + }, + { + 'dir': { + 'fileA': 'nested/fileB', + 'nested': { + 'fileB': None, + }, + }, + }, + id="Relative_copy_a_symlink_to_a_file_in_a_subdir" + )), + (pytest.param( + { + 'dir': { + 'fileF': 'nested/fileC', + 'nested': { + 'fileA': 'fileB', + 'fileB': 'fileC', + 'fileC': '../../outside/fileOut', + 'fileE': None, + } + }, + 'outside': { + 'fileOut': 'fileD', + 'fileD': '../dir/nested/fileE', + } + }, + { + 'dir': { + 'fileF': 'nested/fileC', + 'nested': { + 'fileA': 'fileB', + 'fileB': 'fileC', + 'fileC': 'fileE', + 'fileE': None, + } + } + }, + id="Relative_copy_a_link_to_a_file_outside_with_a_nested_structure_within_the_source_dir" + )), + (pytest.param( + { + 'dir': { + 'fileA': 'fileB', + 'fileB': 'fileC', + 'fileC': '../outside/nested/fileOut', + 'fileE': None + }, + 'outside': { + 'nested': { + 'fileOut': 'fileD', + 'fileD': '../../dir/fileE' + } + } + }, + { + 'dir': { + 'fileA': 'fileB', + 'fileB': 'fileC', + 'fileC': 'fileE', + 'fileE': None, + } + }, + id="Relative_copy_a_link_to_a_file_outside_with_a_nested_structure_in_the_outside_dir" + )), + (pytest.param( + { + 'dir': { + 'fileA': '../outside/fileOut', + 'fileB': None, + }, + 'outside': { + 'fileOut': '../dir/fileB', + }, + }, + { + 'dir': { + 'fileA': 'fileB', + 'fileB': None, + }, + }, + id="Relative_symlink_that_leaves_the_directory_but_returns" + )), + (pytest.param( + { + 'dir': { + 'fileA': '../outside/fileOut', + 'fileB': None, + }, + 'outside': { + 'fileOut': '/dir/fileB', + }, + }, + { + 'dir': { + 'fileA': 'fileB', + 'fileB': None, + }, + }, + id="Relative_symlink_that_leaves_the_directory_but_returns_with_absolute_outside" + )), + (pytest.param( + { + 'dir': { + 'fileA': '../outside/fileB', + 'fileB': None, + }, + 'outside': '/dir', + }, + { + 'dir': { + 'fileA': 'fileB', + 'fileB': None, + }, + }, + id="Relative_symlink_to_a_file_inside_via_a_symlink_to_the_rootdir" + )), + (pytest.param( + # This one is very tricky: + # * The user has made /etc/pki a symlink to some other directory that + # they keep certificates. + # * In the target system, we are going to make /etc/pki an actual + # directory with the contents that the actual directory on the host + # system had. + { + 'dir': 'funkydir', + 'funkydir': { + 'fileA': 'fileB', + 'fileB': None, + }, + }, + { + 'dir': { + 'fileA': 'fileB', + 'fileB': None, + }, + }, + id="Relative_symlink_where_srcdir_is_a_symlink_on_the_host_system" + )), ] ) def test_copy_decouple(monkeypatch, temp_directory_layout, initial_structure, expected_structure): def run_mocked(command): - subprocess.Popen( + subprocess.check_call( ' '.join(command), shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - ).wait() + ) monkeypatch.setattr(userspacegen, 'run', run_mocked) + expected_dir = temp_directory_layout / 'expected' / 'dir' + expected_dir.mkdir() userspacegen._copy_decouple( str(temp_directory_layout / 'initial' / 'dir'), - str(temp_directory_layout / 'expected' / 'dir'), + str(expected_dir), ) - assert_directory_structure_matches(temp_directory_layout, initial_structure, expected_structure) + try: + assert_directory_structure_matches(temp_directory_layout, initial_structure, expected_structure) + except AssertionError: + # For debugging purposes, print out the entire directory structure if an + # assertion failed. + for rootdir, dirs, files in os.walk(temp_directory_layout): + for d in dirs: + print(os.path.join(rootdir, d)) + for f in files: + filename = os.path.join(rootdir, f) + print(" {}".format(filename)) + if os.path.islink(filename): + print(" => Links to: {}".format(os.readlink(filename))) + + # Then re-raise the assertion + raise @pytest.mark.parametrize('result,dst_ver,arch,prod_type', [