# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
# Copyright 2015-2021 Canonical Ltd.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License version 3 as published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""Helpers to detect conflicting staging files from multiple parts."""
import filecmp
import os
from typing import Any, Dict, List, Optional
from craft_parts import errors, permissions
from craft_parts.executor import filesets
from craft_parts.executor.filesets import Fileset
from craft_parts.parts import Part
from craft_parts.permissions import Permissions, permissions_are_compatible
[docs]def check_for_stage_collisions(part_list: List[Part]) -> None:
"""Verify whether parts have conflicting files to stage.
:param part_list: The list of parts to be tested.
:raises PartConflictError: If conflicts are found.
"""
all_parts_files: Dict[str, Dict[str, Any]] = {}
for part in part_list:
stage_files = part.spec.stage_files
if not stage_files:
continue
# Gather our own files up.
stage_fileset = Fileset(stage_files, name="stage")
srcdir = str(part.part_install_dir)
part_files, part_directories = filesets.migratable_filesets(
stage_fileset, srcdir
)
part_contents = part_files | part_directories
# Scan previous parts for collisions.
for other_part_name, other_part_files in all_parts_files.items():
# Our files that are also in a different part.
common = part_contents & other_part_files["files"]
conflict_files = []
for file in common:
this = os.path.join(part.part_install_dir, file)
other = os.path.join(other_part_files["installdir"], file)
permissions_this = permissions.filter_permissions(
file, part.spec.permissions
)
permissions_other = permissions.filter_permissions(
file, other_part_files["part"].spec.permissions
)
if paths_collide(this, other, permissions_this, permissions_other):
conflict_files.append(file)
if conflict_files:
raise errors.PartFilesConflict(
part_name=part.name,
other_part_name=other_part_name,
conflicting_files=conflict_files,
)
# And add our files to the list.
all_parts_files[part.name] = {
"files": part_contents,
"installdir": part.part_install_dir,
"part": part,
}
[docs]def paths_collide(
path1: str,
path2: str,
permissions_path1: Optional[List[Permissions]] = None,
permissions_path2: Optional[List[Permissions]] = None,
) -> bool:
"""Check whether the provided paths conflict to each other.
If both paths have Permissions definitions, they are considered to be conflicting
if the permissions are incompatible (as defined by
``permissions.permissions_are_compatible()``).
:param permissions_path1: The list of ``Permissions`` that affect ``path1``.
:param permissions_path2: The list of ``Permissions`` that affect ``path2``.
"""
if not (os.path.lexists(path1) and os.path.lexists(path2)):
return False
path1_is_dir = os.path.isdir(path1)
path2_is_dir = os.path.isdir(path2)
path1_is_link = os.path.islink(path1)
path2_is_link = os.path.islink(path2)
# Paths collide if they're both symlinks, but pointing to different places.
if path1_is_link and path2_is_link:
return os.readlink(path1) != os.readlink(path2)
# Paths collide if one is a symlink, but not the other.
if path1_is_link or path2_is_link:
return True
# Paths collide if one is a directory, but not the other.
if path1_is_dir != path2_is_dir:
return True
# Paths collide if neither path is a directory, and the files have
# different contents.
if not (path1_is_dir and path2_is_dir) and _file_collides(path1, path2):
return True
# Otherwise, paths conflict if they have incompatible permissions.
return not permissions_are_compatible(permissions_path1, permissions_path2)
def _file_collides(file_this: str, file_other: str) -> bool:
if not file_this.endswith(".pc"):
return not filecmp.cmp(file_this, file_other, shallow=False)
# pkgconfig files need special handling, only prefix line may be different.
with open(file_this) as pc_file_1, open(file_other) as pc_file_2:
for line_pc_1, line_pc_2 in zip(pc_file_1, pc_file_2):
if line_pc_1.startswith("prefix=") and line_pc_2.startswith("prefix="):
continue
if line_pc_1 != line_pc_2:
return True
return False