diff --git a/requirements-devel.txt b/requirements-devel.txt index 061a3137a4..9f60915fa8 100644 --- a/requirements-devel.txt +++ b/requirements-devel.txt @@ -11,7 +11,7 @@ click==8.1.3 codespell==2.2.4 colorama==0.4.6 coverage==7.2.5 -craft-archives==1.0.0 +craft-archives==1.1.2 craft-cli==2.0.0 craft-grammar==1.1.1 craft-parts==1.21.1 diff --git a/requirements.txt b/requirements.txt index c6ce22bcf9..ba4458dff1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ cffi==1.15.1 chardet==5.1.0 charset-normalizer==3.1.0 click==8.1.3 -craft-archives==1.0.0 +craft-archives==1.1.2 craft-cli==2.0.0 craft-grammar==1.1.1 craft-parts==1.21.1 diff --git a/setup.cfg b/setup.cfg index 13464c92c6..12aef2f795 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [codespell] ignore-words-list = buildd,crate,keyserver,comandos,ro,astroid -skip = waf,*.tar,*.xz,*.zip,*.bz2,*.7z,*.gz,*.deb,*.rpm,*.snap,*.gpg,*.pyc,*.png,*.ico,*.jar,*.so,changelog,.git,.hg,.mypy_cache,.tox,.venv,venv,_build,buck-out,__pycache__,build,dist,.vscode,parts,stage,prime,test_appstream.py,./snapcraft.spec,./.direnv,./.pytest_cache,.ruff_cache +skip = waf,*.tar,*.xz,*.zip,*.bz2,*.7z,*.gz,*.deb,*.rpm,*.snap,*.gpg,*.pyc,*.png,*.ico,*.jar,*.so,changelog,.git,.hg,.mypy_cache,.tox,.venv,venv,_build,buck-out,__pycache__,build,dist,.vscode,parts,stage,prime,test_appstream.py,./snapcraft.spec,./.direnv,./.pytest_cache,.ruff_cache,*.asc quiet-level = 4 [flake8] diff --git a/snapcraft/commands/upload.py b/snapcraft/commands/upload.py index e0c8bfcffb..eeab23168a 100644 --- a/snapcraft/commands/upload.py +++ b/snapcraft/commands/upload.py @@ -80,9 +80,11 @@ def run(self, parsed_args): client = store.StoreClientCLI() - snap_yaml = get_data_from_snap_file(snap_file) + snap_yaml, manifest_yaml = get_data_from_snap_file(snap_file) snap_name = snap_yaml["name"] - built_at = snap_yaml.get("snapcraft-started-at") + built_at = None + if manifest_yaml: + built_at = manifest_yaml.get("snapcraft-started-at") client.verify_upload(snap_name=snap_name) diff --git a/snapcraft/meta/snap_yaml.py b/snapcraft/meta/snap_yaml.py index 9e15fba5fc..80290e8a55 100644 --- a/snapcraft/meta/snap_yaml.py +++ b/snapcraft/meta/snap_yaml.py @@ -26,7 +26,7 @@ from pydantic_yaml import YamlModel from snapcraft import errors -from snapcraft.projects import App, Project +from snapcraft.projects import App, Project, UniqueStrList from snapcraft.utils import get_ld_library_paths, process_version @@ -176,6 +176,41 @@ def get_content_dirs(self, installed_path: Path) -> Set[Path]: return content_dirs +class Links(_SnapMetadataModel): + """Metadata links used in snaps.""" + + contact: Optional[UniqueStrList] + donation: Optional[UniqueStrList] + issues: Optional[UniqueStrList] + source_code: Optional[UniqueStrList] + website: Optional[UniqueStrList] + + @staticmethod + def _normalize_value( + value: Optional[Union[str, UniqueStrList]] + ) -> Optional[List[str]]: + if isinstance(value, str): + value = [value] + return value + + @classmethod + def from_project(cls, project: Project) -> "Links": + """Create Links from a Project.""" + return cls( + contact=cls._normalize_value(project.contact), + donation=cls._normalize_value(project.donation), + issues=cls._normalize_value(project.issues), + source_code=cls._normalize_value(project.source_code), + website=cls._normalize_value(project.website), + ) + + def __bool__(self) -> bool: + """Return True if any of the Links attributes are set.""" + return any( + [self.contact, self.donation, self.issues, self.source_code, self.website] + ) + + class SnapMetadata(_SnapMetadataModel): """The snap.yaml model. @@ -210,6 +245,7 @@ class Config: layout: Optional[Dict[str, Dict[str, str]]] system_usernames: Optional[Dict[str, Any]] provenance: Optional[str] + links: Optional[Links] @classmethod def unmarshal(cls, data: Dict[str, Any]) -> "SnapMetadata": @@ -405,6 +441,8 @@ def write(project: Project, prime_dir: Path, *, arch: str): # project provided assumes and computed assumes total_assumes = sorted(project.assumes + list(assumes)) + links = Links.from_project(project) + snap_metadata = SnapMetadata( name=project.name, title=project.title, @@ -427,6 +465,7 @@ def write(project: Project, prime_dir: Path, *, arch: str): layout=project.layout, system_usernames=project.system_usernames, provenance=project.provenance, + links=links if links else None, ) if project.passthrough: for name, value in project.passthrough.items(): diff --git a/snapcraft_legacy/_store.py b/snapcraft_legacy/_store.py index 3aa074b8c2..8f264e687e 100644 --- a/snapcraft_legacy/_store.py +++ b/snapcraft_legacy/_store.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2016-2022 Canonical Ltd +# Copyright 2016-2023 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License version 3 as @@ -52,6 +52,7 @@ def get_data_from_snap_file(snap_path): + manifest_yaml = None with tempfile.TemporaryDirectory() as temp_dir: unsquashfs_path = get_snap_tool_path("unsquashfs") try: @@ -61,9 +62,9 @@ def get_data_from_snap_file(snap_path): "-d", os.path.join(temp_dir, "squashfs-root"), snap_path, - "-e", # cygwin unsquashfs on windows uses unix paths. Path("meta", "snap.yaml").as_posix(), + Path("snap", "manifest.yaml").as_posix(), ] ) except subprocess.CalledProcessError: @@ -73,7 +74,12 @@ def get_data_from_snap_file(snap_path): os.path.join(temp_dir, "squashfs-root", "meta", "snap.yaml") ) as yaml_file: snap_yaml = yaml_utils.load(yaml_file) - return snap_yaml + manifest_path = Path(temp_dir, "squashfs-root", "snap", "manifest.yaml") + if manifest_path.exists(): + with open(manifest_path) as manifest_yaml_file: + manifest_yaml = yaml_utils.load(manifest_yaml_file) + + return snap_yaml, manifest_yaml @contextlib.contextmanager @@ -435,11 +441,11 @@ def list_keys(): """Lists keys available to sign assertions.""" keys = list(_get_usable_keys()) account_info = StoreClientCLI().get_account_information() - enabled_keys = { + enabled_keys = [ account_key["public-key-sha3-384"] for account_key in account_info["account_keys"] - } - if keys and enabled_keys: + ] + if keys: tabulated_keys = tabulate( [ ( @@ -453,19 +459,29 @@ def list_keys(): headers=["", "Name", "SHA3-384 fingerprint", ""], tablefmt="plain", ) + print( + "The following keys are available on this system:" + ) print(tabulated_keys) - elif not keys and enabled_keys: - registered_keys = "\n".join([f"- {key}" for key in enabled_keys]) + else: print( "No keys have been created on this system. " - " See 'snapcraft create-key --help' to create a key.\n" - "The following SHA3-384 key fingerprints have been registered " - f"but are not available on this system:\n{registered_keys}" + "See 'snapcraft create-key --help' to create a key." ) + if enabled_keys: + local_hashes = {key["sha3-384"] for key in keys} + registered_keys = "\n".join( + (f"- {key}" for key in enabled_keys if key not in local_hashes) + ) + if registered_keys: + print( + "The following SHA3-384 key fingerprints have been registered " + f"but are not available on this system:\n{registered_keys}" + ) else: print( - "No keys have been registered." - " See 'snapcraft register-key --help' to register a key." + "No keys have been registered with this account. " + "See 'snapcraft register-key --help' to register a key." ) @@ -574,7 +590,7 @@ def sign_build(snap_filename, key_name=None, local=False): if not os.path.exists(snap_filename): raise FileNotFoundError("The file {!r} does not exist.".format(snap_filename)) - snap_yaml = get_data_from_snap_file(snap_filename) + snap_yaml, _ = get_data_from_snap_file(snap_filename) snap_name = snap_yaml["name"] grade = snap_yaml.get("grade", "stable") @@ -625,7 +641,7 @@ def upload_metadata(snap_filename, force): logger.debug("Uploading metadata to the Store (force=%s)", force) # get the metadata from the snap - snap_yaml = get_data_from_snap_file(snap_filename) + snap_yaml, _ = get_data_from_snap_file(snap_filename) metadata = { "summary": snap_yaml["summary"], "description": snap_yaml["description"], diff --git a/snapcraft_legacy/internal/meta/package_repository.py b/snapcraft_legacy/internal/meta/package_repository.py new file mode 100644 index 0000000000..b5cbe3126f --- /dev/null +++ b/snapcraft_legacy/internal/meta/package_repository.py @@ -0,0 +1,404 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2019 Canonical Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import abc +import logging +import re +from copy import deepcopy +from typing import Any, Dict, List, Optional + +from . import errors + +logger = logging.getLogger(__name__) + + +class PackageRepository(abc.ABC): + @abc.abstractmethod + def marshal(self) -> Dict[str, Any]: + ... + + @classmethod + def unmarshal(cls, data: Dict[str, str]) -> "PackageRepository": + if not isinstance(data, dict): + raise errors.PackageRepositoryValidationError( + url=str(data), + brief=f"Invalid package repository object {data!r}.", + details="Package repository must be a valid dictionary object.", + resolution="Verify repository configuration and ensure that the correct syntax is used.", + ) + + if "ppa" in data: + return PackageRepositoryAptPpa.unmarshal(data) + + return PackageRepositoryApt.unmarshal(data) + + @classmethod + def unmarshal_package_repositories(cls, data: Any) -> List["PackageRepository"]: + repositories = list() + + if data is not None: + if not isinstance(data, list): + raise errors.PackageRepositoryValidationError( + url=str(data), + brief=f"Invalid package-repositories list object {data!r}.", + details="Package repositories must be a list of objects.", + resolution="Verify 'package-repositories' configuration and ensure that the correct syntax is used.", + ) + + for repository in data: + package_repo = cls.unmarshal(repository) + repositories.append(package_repo) + + return repositories + + +class PackageRepositoryAptPpa(PackageRepository): + def __init__(self, *, ppa: str) -> None: + self.type = "apt" + self.ppa = ppa + + self.validate() + + def marshal(self) -> Dict[str, Any]: + data = dict(type="apt") + data["ppa"] = self.ppa + return data + + def validate(self) -> None: + if not self.ppa: + raise errors.PackageRepositoryValidationError( + url=self.ppa, + brief=f"Invalid PPA {self.ppa!r}.", + details="PPAs must be non-empty strings.", + resolution="Verify repository configuration and ensure that 'ppa' is correctly specified.", + ) + + @classmethod + def unmarshal(cls, data: Dict[str, str]) -> "PackageRepositoryAptPpa": + if not isinstance(data, dict): + raise errors.PackageRepositoryValidationError( + url=str(data), + brief=f"Invalid package repository object {data!r}.", + details="Package repository must be a valid dictionary object.", + resolution="Verify repository configuration and ensure that the correct syntax is used.", + ) + + data_copy = deepcopy(data) + + ppa = data_copy.pop("ppa", "") + repo_type = data_copy.pop("type", None) + + if repo_type != "apt": + raise errors.PackageRepositoryValidationError( + url=ppa, + brief=f"Unsupported type {repo_type!r}.", + details="The only currently supported type is 'apt'.", + resolution="Verify repository configuration and ensure that 'type' is correctly specified.", + ) + + if not isinstance(ppa, str): + raise errors.PackageRepositoryValidationError( + url=ppa, + brief=f"Invalid PPA {ppa!r}.", + details="PPA must be a valid string.", + resolution="Verify repository configuration and ensure that 'ppa' is correctly specified.", + ) + + if data_copy: + keys = ", ".join([repr(k) for k in data_copy.keys()]) + raise errors.PackageRepositoryValidationError( + url=ppa, + brief=f"Found unsupported package repository properties {keys}.", + resolution="Verify repository configuration and ensure that it is correct.", + ) + + return cls(ppa=ppa) + + +class PackageRepositoryApt(PackageRepository): + def __init__( + self, + *, + architectures: Optional[List[str]] = None, + components: Optional[List[str]] = None, + formats: Optional[List[str]] = None, + key_id: str, + key_server: Optional[str] = None, + name: Optional[str] = None, + path: Optional[str] = None, + suites: Optional[List[str]] = None, + url: str, + ) -> None: + self.type = "apt" + self.architectures = architectures + self.components = components + self.formats = formats + self.key_id = key_id + self.key_server = key_server + + if name is None: + # Default name is URL, stripping non-alphanumeric characters. + self.name: str = re.sub(r"\W+", "_", url) + else: + self.name = name + + self.path = path + self.suites = suites + self.url = url + + self.validate() + + def marshal(self) -> Dict[str, Any]: + data: Dict[str, Any] = {"type": "apt"} + + if self.architectures: + data["architectures"] = self.architectures + + if self.components: + data["components"] = self.components + + if self.formats: + data["formats"] = self.formats + + data["key-id"] = self.key_id + + if self.key_server: + data["key-server"] = self.key_server + + data["name"] = self.name + + if self.path: + data["path"] = self.path + + if self.suites: + data["suites"] = self.suites + + data["url"] = self.url + + return data + + def validate(self) -> None: # noqa: C901 + if self.formats is not None: + for repo_format in self.formats: + if repo_format not in ["deb", "deb-src"]: + raise errors.PackageRepositoryValidationError( + url=self.url, + brief=f"Invalid format {repo_format!r}.", + details="Valid formats include: deb and deb-src.", + resolution="Verify the repository configuration and ensure that 'formats' is correctly specified.", + ) + + if not self.key_id or not re.match(r"^[0-9A-F]{40}$", self.key_id): + raise errors.PackageRepositoryValidationError( + url=self.url, + brief=f"Invalid key identifier {self.key_id!r}.", + details="Key IDs must be 40 upper-case hex characters.", + resolution="Verify the repository configuration and ensure that 'key-id' is correctly specified.", + ) + + if not self.url: + raise errors.PackageRepositoryValidationError( + url=self.url, + brief=f"Invalid URL {self.url!r}.", + details="URLs must be non-empty strings.", + resolution="Verify the repository configuration and ensure that 'url' is correctly specified.", + ) + + if self.suites: + for suite in self.suites: + if suite.endswith("/"): + raise errors.PackageRepositoryValidationError( + url=self.url, + brief=f"Invalid suite {suite!r}.", + details="Suites must not end with a '/'.", + resolution="Verify the repository configuration and remove the trailing '/ from suites or use the 'path' property to define a path.", + ) + + if self.path is not None and self.path == "": + raise errors.PackageRepositoryValidationError( + url=self.url, + brief=f"Invalid path {self.path!r}.", + details="Paths must be non-empty strings.", + resolution="Verify the repository configuration and ensure that 'path' is a non-empty string such as '/'.", + ) + + if self.path and self.components: + raise errors.PackageRepositoryValidationError( + url=self.url, + brief=f"Components {self.components!r} cannot be combined with path {self.path!r}.", + details="Path and components are incomptiable options.", + resolution="Verify the repository configuration and remove 'path' or 'components'.", + ) + + if self.path and self.suites: + raise errors.PackageRepositoryValidationError( + url=self.url, + brief=f"Suites {self.suites!r} cannot be combined with path {self.path!r}.", + details="Path and suites are incomptiable options.", + resolution="Verify the repository configuration and remove 'path' or 'suites'.", + ) + + if self.suites and not self.components: + raise errors.PackageRepositoryValidationError( + url=self.url, + brief="No components specified.", + details="Components are required when using suites.", + resolution="Verify the repository configuration and ensure that 'components' is correctly specified.", + ) + + if self.components and not self.suites: + raise errors.PackageRepositoryValidationError( + url=self.url, + brief="No suites specified.", + details="Suites are required when using components.", + resolution="Verify the repository configuration and ensure that 'suites' is correctly specified.", + ) + + @classmethod # noqa: C901 + def unmarshal(cls, data: Dict[str, Any]) -> "PackageRepositoryApt": # noqa: C901 + if not isinstance(data, dict): + raise errors.PackageRepositoryValidationError( + url=str(data), + brief=f"Invalid package repository object {data!r}.", + details="Package repository must be a valid dictionary object.", + resolution="Verify repository configuration and ensure that the correct syntax is used.", + ) + + data_copy = deepcopy(data) + + architectures = data_copy.pop("architectures", None) + components = data_copy.pop("components", None) + formats = data_copy.pop("formats", None) + key_id = data_copy.pop("key-id", None) + key_server = data_copy.pop("key-server", None) + name = data_copy.pop("name", None) + path = data_copy.pop("path", None) + suites = data_copy.pop("suites", None) + url = data_copy.pop("url", "") + repo_type = data_copy.pop("type", None) + + if repo_type != "apt": + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"Unsupported type {repo_type!r}.", + details="The only currently supported type is 'apt'.", + resolution="Verify repository configuration and ensure that 'type' is correctly specified.", + ) + + if architectures is not None and ( + not isinstance(architectures, list) + or not all(isinstance(x, str) for x in architectures) + ): + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"Invalid architectures {architectures!r}.", + details="Architectures must be a list of valid architecture strings.", + resolution="Verify repository configuration and ensure that 'architectures' is correctly specified.", + ) + + if components is not None and ( + not isinstance(components, list) + or not all(isinstance(x, str) for x in components) + or not components + ): + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"Invalid components {components!r}.", + details="Components must be a list of strings.", + resolution="Verify repository configuration and ensure that 'components' is correctly specified.", + ) + + if formats is not None and ( + not isinstance(formats, list) + or not all(isinstance(x, str) for x in formats) + ): + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"Invalid formats {formats!r}.", + details="Formats must be a list of strings.", + resolution="Verify repository configuration and ensure that 'formats' is correctly specified.", + ) + + if not isinstance(key_id, str): + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"Invalid key identifier {key_id!r}.", + details="Key identifiers must be a valid string.", + resolution="Verify repository configuration and ensure that 'key-id' is correctly specified.", + ) + + if key_server is not None and not isinstance(key_server, str): + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"Invalid key server {key_server!r}.", + details="Key servers must be a valid string.", + resolution="Verify repository configuration and ensure that 'key-server' is correctly specified.", + ) + + if name is not None and not isinstance(name, str): + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"Invalid name {name!r}.", + details="Names must be a valid string.", + resolution="Verify repository configuration and ensure that 'name' is correctly specified.", + ) + + if path is not None and not isinstance(path, str): + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"Invalid path {path!r}.", + details="Paths must be a valid string.", + resolution="Verify repository configuration and ensure that 'path' is correctly specified.", + ) + + if suites is not None and ( + not isinstance(suites, list) + or not all(isinstance(x, str) for x in suites) + or not suites + ): + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"Invalid suites {suites!r}.", + details="Suites must be a list of strings.", + resolution="Verify repository configuration and ensure that 'suites' is correctly specified.", + ) + + if not isinstance(url, str): + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"Invalid URL {url!r}.", + details="URLs must be a valid string.", + resolution="Verify repository configuration and ensure that 'url' is correctly specified.", + ) + + if data_copy: + keys = ", ".join([repr(k) for k in data_copy.keys()]) + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"Found unsupported package repository properties {keys}.", + resolution="Verify repository configuration and ensure it is correct.", + ) + + return cls( + architectures=architectures, + components=components, + formats=formats, + key_id=key_id, + key_server=key_server, + name=name, + suites=suites, + url=url, + ) diff --git a/snapcraft_legacy/internal/meta/snap.py b/snapcraft_legacy/internal/meta/snap.py index cd6eba60b7..b355d10153 100644 --- a/snapcraft_legacy/internal/meta/snap.py +++ b/snapcraft_legacy/internal/meta/snap.py @@ -20,13 +20,12 @@ from copy import deepcopy from typing import Any, Dict, List, Optional, Sequence, Set -from craft_archives.repo.package_repository import PackageRepository - from snapcraft_legacy import yaml_utils from snapcraft_legacy.internal import common from snapcraft_legacy.internal.meta import errors from snapcraft_legacy.internal.meta.application import Application from snapcraft_legacy.internal.meta.hooks import Hook +from snapcraft_legacy.internal.meta.package_repository import PackageRepository from snapcraft_legacy.internal.meta.plugs import ContentPlug, Plug from snapcraft_legacy.internal.meta.slots import ContentSlot, Slot from snapcraft_legacy.internal.meta.system_user import SystemUser diff --git a/snapcraft_legacy/internal/project_loader/_config.py b/snapcraft_legacy/internal/project_loader/_config.py index b0814d7704..354f36b94f 100644 --- a/snapcraft_legacy/internal/project_loader/_config.py +++ b/snapcraft_legacy/internal/project_loader/_config.py @@ -23,7 +23,6 @@ from typing import List, Set import jsonschema -from craft_archives.repo import apt_key_manager, apt_sources_manager from snapcraft_legacy import formatting_utils, project from snapcraft_legacy.internal import deprecations, repo, states, steps @@ -31,6 +30,7 @@ from snapcraft_legacy.internal.pluginhandler._part_environment import ( get_snapcraft_global_environment, ) +from snapcraft_legacy.internal.repo import apt_key_manager, apt_sources_manager from snapcraft_legacy.project._schema import Validator from . import errors, grammar_processing, replace_attr diff --git a/snapcraft_legacy/internal/repo/apt_key_manager.py b/snapcraft_legacy/internal/repo/apt_key_manager.py new file mode 100644 index 0000000000..0ea6f82a54 --- /dev/null +++ b/snapcraft_legacy/internal/repo/apt_key_manager.py @@ -0,0 +1,226 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2015-2020 Canonical Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import logging +import pathlib +import subprocess +import tempfile +from typing import List, Optional + +import gnupg + +from snapcraft_legacy.internal.meta import package_repository + +from . import apt_ppa, errors + +logger = logging.getLogger(__name__) + + +class AptKeyManager: + def __init__( + self, + *, + gpg_keyring: pathlib.Path = pathlib.Path( + "/etc/apt/trusted.gpg.d/snapcraft.gpg" + ), + key_assets: pathlib.Path, + ) -> None: + self._gpg_keyring = gpg_keyring + self._key_assets = key_assets + + def find_asset_with_key_id(self, *, key_id: str) -> Optional[pathlib.Path]: + """Find snap key asset matching key_id. + + The key asset much be named with the last 8 characters of the key + identifier, in upper case. + + :param key_id: Key ID to search for. + + :returns: Path of key asset if match found, otherwise None. + """ + key_file = key_id[-8:].upper() + ".asc" + key_path = self._key_assets / key_file + + if key_path.exists(): + return key_path + + return None + + def get_key_fingerprints(self, *, key: str) -> List[str]: + """List fingerprints found in specified key. + + Do this by importing the key into a temporary keyring, + then querying the keyring for fingerprints. + + :param key: Key data (string) to parse. + + :returns: List of key fingerprints/IDs. + """ + with tempfile.NamedTemporaryFile(suffix="keyring") as temp_file: + return ( + gnupg.GPG(keyring=temp_file.name).import_keys(key_data=key).fingerprints + ) + + def is_key_installed(self, *, key_id: str) -> bool: + """Check if specified key_id is installed. + + Check if key is installed by attempting to export the key. + Unfortunately, apt-key does not exit with error and + we have to do our best to parse the output. + + :param key_id: Key ID to check for. + + :returns: True if key is installed. + """ + try: + proc = subprocess.run( + ["sudo", "apt-key", "export", key_id], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=True, + ) + except subprocess.CalledProcessError as error: + # Export shouldn't exit with failure based on testing, + # but assume the key is not installed and log a warning. + logger.warning(f"Unexpected apt-key failure: {error.output}") + return False + + apt_key_output = proc.stdout.decode() + + if "BEGIN PGP PUBLIC KEY BLOCK" in apt_key_output: + return True + + if "nothing exported" in apt_key_output: + return False + + # The two strings above have worked in testing, but if neither is + # present for whatever reason, assume the key is not installed + # and log a warning. + logger.warning(f"Unexpected apt-key output: {apt_key_output}") + return False + + def install_key(self, *, key: str) -> None: + """Install given key. + + :param key: Key to install. + + :raises: AptGPGKeyInstallError if unable to install key. + """ + cmd = [ + "sudo", + "apt-key", + "--keyring", + str(self._gpg_keyring), + "add", + "-", + ] + + try: + logger.debug(f"Executing: {cmd!r}") + env = dict() + env["LANG"] = "C.UTF-8" + subprocess.run( + cmd, + input=key.encode(), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=True, + env=env, + ) + except subprocess.CalledProcessError as error: + raise errors.AptGPGKeyInstallError(output=error.output.decode(), key=key) + + logger.debug(f"Installed apt repository key:\n{key}") + + def install_key_from_keyserver( + self, *, key_id: str, key_server: str = "keyserver.ubuntu.com" + ) -> None: + """Install key from specified key server. + + :param key_id: Key ID to install. + :param key_server: Key server to query. + + :raises: AptGPGKeyInstallError if unable to install key. + """ + env = dict() + env["LANG"] = "C.UTF-8" + + cmd = [ + "sudo", + "apt-key", + "--keyring", + str(self._gpg_keyring), + "adv", + "--keyserver", + key_server, + "--recv-keys", + key_id, + ] + + try: + logger.debug(f"Executing: {cmd!r}") + subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=True, + env=env, + ) + except subprocess.CalledProcessError as error: + raise errors.AptGPGKeyInstallError( + output=error.output.decode(), key_id=key_id, key_server=key_server + ) + + def install_package_repository_key( + self, *, package_repo: package_repository.PackageRepository + ) -> bool: + """Install required key for specified package repository. + + For both PPA and other Apt package repositories: + 1) If key is already installed, return False. + 2) Install key from local asset, if available. + 3) Install key from key server, if available. An unspecified + keyserver will default to using keyserver.ubuntu.com. + + :param package_repo: Apt PackageRepository configuration. + + :returns: True if key configuration was changed. False if + key already installed. + + :raises: AptGPGKeyInstallError if unable to install key. + """ + key_server: Optional[str] = None + if isinstance(package_repo, package_repository.PackageRepositoryAptPpa): + key_id = apt_ppa.get_launchpad_ppa_key_id(ppa=package_repo.ppa) + elif isinstance(package_repo, package_repository.PackageRepositoryApt): + key_id = package_repo.key_id + key_server = package_repo.key_server + else: + raise RuntimeError(f"unhandled package repo type: {package_repo!r}") + + # Already installed, nothing to do. + if self.is_key_installed(key_id=key_id): + return False + + key_path = self.find_asset_with_key_id(key_id=key_id) + if key_path is not None: + self.install_key(key=key_path.read_text()) + else: + if key_server is None: + key_server = "keyserver.ubuntu.com" + self.install_key_from_keyserver(key_id=key_id, key_server=key_server) + + return True diff --git a/snapcraft_legacy/internal/repo/apt_ppa.py b/snapcraft_legacy/internal/repo/apt_ppa.py new file mode 100644 index 0000000000..f4269d1d04 --- /dev/null +++ b/snapcraft_legacy/internal/repo/apt_ppa.py @@ -0,0 +1,50 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2020 Canonical Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import logging +from typing import Tuple + +import lazr.restfulclient.errors +from launchpadlib.launchpad import Launchpad + +from . import errors + +logger = logging.getLogger(__name__) + + +def split_ppa_parts(*, ppa: str) -> Tuple[str, str]: + ppa_split = ppa.split("/") + if len(ppa_split) != 2: + raise errors.AptPPAInstallError(ppa=ppa, reason="invalid PPA format") + return ppa_split[0], ppa_split[1] + + +def get_launchpad_ppa_key_id(*, ppa: str) -> str: + """Query Launchpad for PPA's key ID.""" + owner, name = split_ppa_parts(ppa=ppa) + launchpad = Launchpad.login_anonymously("snapcraft", "production") + launchpad_url = f"~{owner}/+archive/{name}" + + logger.debug(f"Loading launchpad url: {launchpad_url}") + try: + key_id = launchpad.load(launchpad_url).signing_key_fingerprint + except lazr.restfulclient.errors.NotFound as error: + raise errors.AptPPAInstallError( + ppa=ppa, reason="not found on launchpad" + ) from error + + logger.debug(f"Retrieved launchpad PPA key ID: {key_id}") + return key_id diff --git a/snapcraft_legacy/internal/repo/apt_sources_manager.py b/snapcraft_legacy/internal/repo/apt_sources_manager.py new file mode 100644 index 0000000000..8d979c37c6 --- /dev/null +++ b/snapcraft_legacy/internal/repo/apt_sources_manager.py @@ -0,0 +1,248 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2015-2021 Canonical Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +"""Manage the host's apt source repository configuration.""" + +import io +import logging +import os +import pathlib +import re +import subprocess +import tempfile +from typing import List, Optional + +from snapcraft_legacy.internal import os_release +from snapcraft_legacy.internal.meta import package_repository +from snapcraft_legacy.project._project_options import ProjectOptions + +from . import apt_ppa + +logger = logging.getLogger(__name__) + + +def _construct_deb822_source( + *, + architectures: Optional[List[str]] = None, + components: Optional[List[str]] = None, + formats: Optional[List[str]] = None, + suites: List[str], + url: str, +) -> str: + """Construct deb-822 formatted sources.list config string.""" + with io.StringIO() as deb822: + if formats: + type_text = " ".join(formats) + else: + type_text = "deb" + + print(f"Types: {type_text}", file=deb822) + + print(f"URIs: {url}", file=deb822) + + suites_text = " ".join(suites) + print(f"Suites: {suites_text}", file=deb822) + + if components: + components_text = " ".join(components) + print(f"Components: {components_text}", file=deb822) + + if architectures: + arch_text = " ".join(architectures) + else: + arch_text = _get_host_arch() + + print(f"Architectures: {arch_text}", file=deb822) + + return deb822.getvalue() + + +def _get_host_arch() -> str: + return ProjectOptions().deb_arch + + +def _sudo_write_file(*, dst_path: pathlib.Path, content: bytes) -> None: + """Workaround for writing privileged files in destructive mode.""" + try: + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + temp_file.write(content) + temp_file.flush() + f_name = temp_file.name + + try: + command = [ + "sudo", + "install", + "--owner=root", + "--group=root", + "--mode=0644", + f_name, + str(dst_path), + ] + subprocess.run(command, check=True) + except subprocess.CalledProcessError as error: + raise RuntimeError( + f"Failed to install repository config with: {command!r}" + ) from error + finally: + os.unlink(f_name) + + +class AptSourcesManager: + """Manage apt source configuration in /etc/apt/sources.list.d. + + :param sources_list_d: Path to sources.list.d directory. + """ + + # pylint: disable=too-few-public-methods + def __init__( + self, + *, + sources_list_d: pathlib.Path = pathlib.Path("/etc/apt/sources.list.d"), + ) -> None: + self._sources_list_d = sources_list_d + + def _install_sources( + self, + *, + architectures: Optional[List[str]] = None, + components: Optional[List[str]] = None, + formats: Optional[List[str]] = None, + name: str, + suites: List[str], + url: str, + ) -> bool: + """Install sources list configuration. + + Write config to: + /etc/apt/sources.list.d/snapcraft-.sources + + :returns: True if configuration was changed. + """ + config = _construct_deb822_source( + architectures=architectures, + components=components, + formats=formats, + suites=suites, + url=url, + ) + + if name not in ["default", "default-security"]: + name = "snapcraft-" + name + + config_path = self._sources_list_d / f"{name}.sources" + if config_path.exists() and config_path.read_text() == config: + # Already installed and matches, nothing to do. + logger.debug("Ignoring unchanged sources: %s", str(config_path)) + return False + + _sudo_write_file(dst_path=config_path, content=config.encode()) + logger.debug("Installed sources: %s", str(config_path)) + return True + + def _install_sources_apt( + self, *, package_repo: package_repository.PackageRepositoryApt + ) -> bool: + """Install repository configuration. + + 1) First check to see if package repo is implied path, + or "bare repository" config. This is indicated when no + path, components, or suites are indicated. + 2) If path is specified, convert path to a suite entry, + ending with "/". + + Relatedly, this assumes all of the error-checking has been + done already on the package_repository object in a proper + fashion, but do some sanity checks here anyways. + + :returns: True if source configuration was changed. + """ + if ( + not package_repo.path + and not package_repo.components + and not package_repo.suites + ): + suites = ["/"] + elif package_repo.path: + # Suites denoting exact path must end with '/'. + path = package_repo.path + if not path.endswith("/"): + path += "/" + suites = [path] + elif package_repo.suites: + suites = package_repo.suites + if not package_repo.components: + raise RuntimeError("no components with suite") + else: + raise RuntimeError("no suites or path") + + if package_repo.name: + name = package_repo.name + else: + name = re.sub(r"\W+", "_", package_repo.url) + + return self._install_sources( + architectures=package_repo.architectures, + components=package_repo.components, + formats=package_repo.formats, + name=name, + suites=suites, + url=package_repo.url, + ) + + def _install_sources_ppa( + self, *, package_repo: package_repository.PackageRepositoryAptPpa + ) -> bool: + """Install PPA formatted repository. + + Create a sources list config by: + - Looking up the codename of the host OS and using it as the "suites" + entry. + - Formulate deb URL to point to PPA. + - Enable only "deb" formats. + + :returns: True if source configuration was changed. + """ + owner, name = apt_ppa.split_ppa_parts(ppa=package_repo.ppa) + codename = os_release.OsRelease().version_codename() + + return self._install_sources( + components=["main"], + formats=["deb"], + name=f"ppa-{owner}_{name}", + suites=[codename], + url=f"http://ppa.launchpad.net/{owner}/{name}/ubuntu", + ) + + def install_package_repository_sources( + self, + *, + package_repo: package_repository.PackageRepository, + ) -> bool: + """Install configured package repositories. + + :param package_repo: Repository to install the source configuration for. + + :returns: True if source configuration was changed. + """ + logger.debug("Processing repo: %r", package_repo) + if isinstance(package_repo, package_repository.PackageRepositoryAptPpa): + return self._install_sources_ppa(package_repo=package_repo) + + if isinstance(package_repo, package_repository.PackageRepositoryApt): + return self._install_sources_apt(package_repo=package_repo) + + raise RuntimeError(f"unhandled package repository: {package_repository!r}") diff --git a/tests/legacy/data/test-snap-with-started-at.snap b/tests/legacy/data/test-snap-with-started-at.snap index 5a8879d939..d4b641988e 100644 Binary files a/tests/legacy/data/test-snap-with-started-at.snap and b/tests/legacy/data/test-snap-with-started-at.snap differ diff --git a/tests/legacy/unit/commands/test_list_keys.py b/tests/legacy/unit/commands/test_list_keys.py index 9e0e23b835..6558f4ba10 100644 --- a/tests/legacy/unit/commands/test_list_keys.py +++ b/tests/legacy/unit/commands/test_list_keys.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright (C) 2016-2019 Canonical Ltd +# Copyright (C) 2016-2023 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License version 3 as @@ -59,16 +59,14 @@ def test_list_keys_successfully(self): self.assertThat(result.exit_code, Equals(0)) self.assertThat( result.output, - Contains( + Equals( dedent( - """\ - Name SHA3-384 fingerprint - * default {default_sha3_384} - - another {another_sha3_384} (not registered) - """ - ).format( - default_sha3_384=get_sample_key("default")["sha3-384"], - another_sha3_384=get_sample_key("another")["sha3-384"], + f"""\ + The following keys are available on this system: + Name SHA3-384 fingerprint + * default {get_sample_key("default")["sha3-384"]} + - another {get_sample_key("another")["sha3-384"]} (not registered) + """ ) ), ) @@ -95,10 +93,10 @@ def test_list_keys_no_keys_on_system(self): self.assertThat(result.exit_code, Equals(0)) self.assertThat( result.output, - Contains( + Equals( dedent( """\ - No keys have been created on this system. See 'snapcraft create-key --help' to create a key. + No keys have been created on this system. See 'snapcraft create-key --help' to create a key. The following SHA3-384 key fingerprints have been registered but are not available on this system: - vdEeQvRxmZ26npJCFaGnl-VfGz0lU2jZZkWp_s7E-RxVCNtH2_mtjcxq2NkDKkIp """ @@ -132,8 +130,15 @@ def test_list_keys_without_registered(self): self.assertThat(result.exit_code, Equals(0)) self.assertThat( result.output, - Contains( - "No keys have been registered. " - "See 'snapcraft register-key --help' to register a key." + Equals( + dedent( + f"""\ + The following keys are available on this system: + Name SHA3-384 fingerprint + - default {get_sample_key("default")["sha3-384"]} (not registered) + - another {get_sample_key("another")["sha3-384"]} (not registered) + No keys have been registered with this account. See 'snapcraft register-key --help' to register a key. + """ + ) ), ) diff --git a/tests/legacy/unit/commands/test_sign_build.py b/tests/legacy/unit/commands/test_sign_build.py index c02078d901..71ddfc1e71 100644 --- a/tests/legacy/unit/commands/test_sign_build.py +++ b/tests/legacy/unit/commands/test_sign_build.py @@ -81,7 +81,7 @@ def test_sign_build_missing_account_info( self, mock_get_snap_data, ): - mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"} + mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"}, None raised = self.assertRaises( storeapi.errors.StoreBuildAssertionPermissionError, @@ -104,7 +104,7 @@ def test_sign_build_no_usable_keys( self, mock_get_snap_data, ): - mock_get_snap_data.return_value = {"name": "snap-test", "grade": "stable"} + mock_get_snap_data.return_value = {"name": "snap-test", "grade": "stable"}, None self.useFixture( fixtures.MockPatch("subprocess.check_output", return_value="[]".encode()) @@ -138,7 +138,7 @@ def test_sign_build_no_usable_named_key( "account_id": "abcd", "snaps": {"16": {"test-snap": {"snap-id": "snap-id"}}}, } - mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"} + mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"}, None self.useFixture( fixtures.MockPatch( "subprocess.check_output", return_value='[{"name": "default"}]'.encode() @@ -172,7 +172,7 @@ def test_sign_build_unregistered_key( "account_keys": [{"public-key-sha3-384": "another_hash"}], "snaps": {"16": {"test-snap": {"snap-id": "snap-id"}}}, } - mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"} + mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"}, None self.useFixture( fixtures.MockPatch( "subprocess.check_output", @@ -210,7 +210,7 @@ def test_sign_build_locally_successfully( "account_id": "abcd", "snaps": {"16": {"test-snap": {"snap-id": "snap-id"}}}, } - mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"} + mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"}, None fake_check_output = fixtures.MockPatch( "subprocess.check_output", side_effect=mock_check_output, @@ -253,7 +253,7 @@ def test_sign_build_missing_grade( "account_keys": [{"public-key-sha3-384": "a_hash"}], "snaps": {"16": {"test-snap": {"snap-id": "snap-id"}}}, } - mock_get_snap_data.return_value = {"name": "test-snap"} + mock_get_snap_data.return_value = {"name": "test-snap"}, None fake_check_output = fixtures.MockPatch( "subprocess.check_output", side_effect=mock_check_output ) @@ -301,7 +301,7 @@ def test_sign_build_upload_successfully( ], "snaps": {"16": {"test-snap": {"snap-id": "snap-id"}}}, } - mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"} + mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"}, None fake_check_output = fixtures.MockPatch( "subprocess.check_output", side_effect=mock_check_output, @@ -350,7 +350,7 @@ def test_sign_build_upload_existing( "account_id": "abcd", "snaps": {"16": {"test-snap": {"snap-id": "snap-id"}}}, } - mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"} + mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"}, None snap_build_path = self.snap_test.snap_path + "-build" with open(snap_build_path, "wb") as fd: diff --git a/tests/legacy/unit/meta/test_package_repository.py b/tests/legacy/unit/meta/test_package_repository.py new file mode 100644 index 0000000000..c81c6c5688 --- /dev/null +++ b/tests/legacy/unit/meta/test_package_repository.py @@ -0,0 +1,411 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2019 Canonical Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +import pytest + +from snapcraft_legacy.internal.meta import errors +from snapcraft_legacy.internal.meta.package_repository import ( + PackageRepository, + PackageRepositoryApt, + PackageRepositoryAptPpa, +) + + +def test_apt_name(): + repo = PackageRepositoryApt( + architectures=["amd64", "i386"], + components=["main", "multiverse"], + formats=["deb", "deb-src"], + key_id="A" * 40, + key_server="keyserver.ubuntu.com", + suites=["xenial", "xenial-updates"], + url="http://archive.ubuntu.com/ubuntu", + ) + + assert repo.name == "http_archive_ubuntu_com_ubuntu" + + +@pytest.mark.parametrize( + "arch", ["amd64", "armhf", "arm64", "i386", "ppc64el", "riscv", "s390x"] +) +def test_apt_valid_architectures(arch): + package_repo = PackageRepositoryApt( + key_id="A" * 40, url="http://test", architectures=[arch] + ) + + assert package_repo.architectures == [arch] + + +def test_apt_invalid_url(): + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryApt( + key_id="A" * 40, + url="", + ) + + assert exc_info.value.brief == "Invalid URL ''." + assert exc_info.value.details == "URLs must be non-empty strings." + assert ( + exc_info.value.resolution + == "Verify the repository configuration and ensure that 'url' is correctly specified." + ) + + +def test_apt_invalid_path(): + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryApt( + key_id="A" * 40, + path="", + url="http://archive.ubuntu.com/ubuntu", + ) + + assert exc_info.value.brief == "Invalid path ''." + assert exc_info.value.details == "Paths must be non-empty strings." + assert ( + exc_info.value.resolution + == "Verify the repository configuration and ensure that 'path' is a non-empty string such as '/'." + ) + + +def test_apt_invalid_path_with_suites(): + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryApt( + key_id="A" * 40, + path="/", + suites=["xenial", "xenial-updates"], + url="http://archive.ubuntu.com/ubuntu", + ) + + assert ( + exc_info.value.brief + == "Suites ['xenial', 'xenial-updates'] cannot be combined with path '/'." + ) + assert exc_info.value.details == "Path and suites are incomptiable options." + assert ( + exc_info.value.resolution + == "Verify the repository configuration and remove 'path' or 'suites'." + ) + + +def test_apt_invalid_path_with_components(): + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryApt( + key_id="A" * 40, + path="/", + components=["main"], + url="http://archive.ubuntu.com/ubuntu", + ) + + assert ( + exc_info.value.brief == "Components ['main'] cannot be combined with path '/'." + ) + assert exc_info.value.details == "Path and components are incomptiable options." + assert ( + exc_info.value.resolution + == "Verify the repository configuration and remove 'path' or 'components'." + ) + + +def test_apt_invalid_missing_components(): + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryApt( + key_id="A" * 40, + suites=["xenial", "xenial-updates"], + url="http://archive.ubuntu.com/ubuntu", + ) + + assert exc_info.value.brief == "No components specified." + assert exc_info.value.details == "Components are required when using suites." + assert ( + exc_info.value.resolution + == "Verify the repository configuration and ensure that 'components' is correctly specified." + ) + + +def test_apt_invalid_missing_suites(): + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryApt( + key_id="A" * 40, + components=["main"], + url="http://archive.ubuntu.com/ubuntu", + ) + + assert exc_info.value.brief == "No suites specified." + assert exc_info.value.details == "Suites are required when using components." + assert ( + exc_info.value.resolution + == "Verify the repository configuration and ensure that 'suites' is correctly specified." + ) + + +def test_apt_invalid_suites_as_path(): + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryApt( + key_id="A" * 40, + suites=["my-suite/"], + url="http://archive.ubuntu.com/ubuntu", + ) + + assert exc_info.value.brief == "Invalid suite 'my-suite/'." + assert exc_info.value.details == "Suites must not end with a '/'." + assert ( + exc_info.value.resolution + == "Verify the repository configuration and remove the trailing '/ from suites or use the 'path' property to define a path." + ) + + +def test_apt_marshal(): + repo = PackageRepositoryApt( + architectures=["amd64", "i386"], + components=["main", "multiverse"], + formats=["deb", "deb-src"], + key_id="A" * 40, + key_server="xkeyserver.ubuntu.com", + name="test-name", + suites=["xenial", "xenial-updates"], + url="http://archive.ubuntu.com/ubuntu", + ) + + assert repo.marshal() == { + "architectures": ["amd64", "i386"], + "components": ["main", "multiverse"], + "formats": ["deb", "deb-src"], + "key-id": "A" * 40, + "key-server": "xkeyserver.ubuntu.com", + "name": "test-name", + "suites": ["xenial", "xenial-updates"], + "type": "apt", + "url": "http://archive.ubuntu.com/ubuntu", + } + + +def test_apt_unmarshal_invalid_extra_keys(): + test_dict = { + "architectures": ["amd64", "i386"], + "components": ["main", "multiverse"], + "formats": ["deb", "deb-src"], + "key-id": "A" * 40, + "key-server": "keyserver.ubuntu.com", + "name": "test-name", + "suites": ["xenial", "xenial-updates"], + "type": "apt", + "url": "http://archive.ubuntu.com/ubuntu", + "foo": "bar", + "foo2": "bar", + } + + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryApt.unmarshal(test_dict) + + assert ( + exc_info.value.brief + == "Found unsupported package repository properties 'foo', 'foo2'." + ) + assert exc_info.value.details is None + assert ( + exc_info.value.resolution + == "Verify repository configuration and ensure it is correct." + ) + + +def test_apt_unmarshal_invalid_data(): + test_dict = "not-a-dict" + + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryApt.unmarshal(test_dict) + + assert exc_info.value.brief == "Invalid package repository object 'not-a-dict'." + assert ( + exc_info.value.details + == "Package repository must be a valid dictionary object." + ) + assert ( + exc_info.value.resolution + == "Verify repository configuration and ensure that the correct syntax is used." + ) + + +def test_apt_unmarshal_invalid_type(): + test_dict = { + "architectures": ["amd64", "i386"], + "components": ["main", "multiverse"], + "formats": ["deb", "deb-src"], + "key-id": "A" * 40, + "key-server": "keyserver.ubuntu.com", + "name": "test-name", + "suites": ["xenial", "xenial-updates"], + "type": "aptx", + "url": "http://archive.ubuntu.com/ubuntu", + } + + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryApt.unmarshal(test_dict) + + assert exc_info.value.brief == "Unsupported type 'aptx'." + assert exc_info.value.details == "The only currently supported type is 'apt'." + assert ( + exc_info.value.resolution + == "Verify repository configuration and ensure that 'type' is correctly specified." + ) + + +def test_ppa_marshal(): + repo = PackageRepositoryAptPpa(ppa="test/ppa") + + assert repo.marshal() == {"type": "apt", "ppa": "test/ppa"} + + +def test_ppa_invalid_ppa(): + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryAptPpa(ppa="") + + assert exc_info.value.brief == "Invalid PPA ''." + assert exc_info.value.details == "PPAs must be non-empty strings." + assert ( + exc_info.value.resolution + == "Verify repository configuration and ensure that 'ppa' is correctly specified." + ) + + +def test_ppa_unmarshal_invalid_data(): + test_dict = "not-a-dict" + + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryAptPpa.unmarshal(test_dict) + + assert exc_info.value.brief == "Invalid package repository object 'not-a-dict'." + assert ( + exc_info.value.details + == "Package repository must be a valid dictionary object." + ) + assert ( + exc_info.value.resolution + == "Verify repository configuration and ensure that the correct syntax is used." + ) + + +def test_ppa_unmarshal_invalid_apt_ppa_type(): + test_dict = {"type": "aptx", "ppa": "test/ppa"} + + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryAptPpa.unmarshal(test_dict) + + assert exc_info.value.brief == "Unsupported type 'aptx'." + assert exc_info.value.details == "The only currently supported type is 'apt'." + assert ( + exc_info.value.resolution + == "Verify repository configuration and ensure that 'type' is correctly specified." + ) + + +def test_ppa_unmarshal_invalid_apt_ppa_extra_keys(): + test_dict = {"type": "apt", "ppa": "test/ppa", "test": "foo"} + + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryAptPpa.unmarshal(test_dict) + + assert ( + exc_info.value.brief + == "Found unsupported package repository properties 'test'." + ) + assert exc_info.value.details is None + assert ( + exc_info.value.resolution + == "Verify repository configuration and ensure that it is correct." + ) + + +def test_unmarshal_package_repositories_list_none(): + assert PackageRepository.unmarshal_package_repositories(None) == list() + + +def test_unmarshal_package_repositories_list_empty(): + assert PackageRepository.unmarshal_package_repositories(list()) == list() + + +def test_unmarshal_package_repositories_list_ppa(): + test_dict = {"type": "apt", "ppa": "test/foo"} + test_list = [test_dict] + + unmarshalled_list = [ + repo.marshal() + for repo in PackageRepository.unmarshal_package_repositories(test_list) + ] + + assert unmarshalled_list == test_list + + +def test_unmarshal_package_repositories_list_apt(): + test_dict = { + "architectures": ["amd64", "i386"], + "components": ["main", "multiverse"], + "formats": ["deb", "deb-src"], + "key-id": "A" * 40, + "key-server": "keyserver.ubuntu.com", + "name": "test-name", + "suites": ["xenial", "xenial-updates"], + "type": "apt", + "url": "http://archive.ubuntu.com/ubuntu", + } + + test_list = [test_dict] + + unmarshalled_list = [ + repo.marshal() + for repo in PackageRepository.unmarshal_package_repositories(test_list) + ] + + assert unmarshalled_list == test_list + + +def test_unmarshal_package_repositories_list_all(): + test_ppa = {"type": "apt", "ppa": "test/foo"} + + test_deb = { + "architectures": ["amd64", "i386"], + "components": ["main", "multiverse"], + "formats": ["deb", "deb-src"], + "key-id": "A" * 40, + "key-server": "keyserver.ubuntu.com", + "name": "test-name", + "suites": ["xenial", "xenial-updates"], + "type": "apt", + "url": "http://archive.ubuntu.com/ubuntu", + } + + test_list = [test_ppa, test_deb] + + unmarshalled_list = [ + repo.marshal() + for repo in PackageRepository.unmarshal_package_repositories(test_list) + ] + + assert unmarshalled_list == test_list + + +def test_unmarshal_package_repositories_invalid_data(): + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepository.unmarshal_package_repositories("not-a-list") + + assert ( + exc_info.value.brief == "Invalid package-repositories list object 'not-a-list'." + ) + assert exc_info.value.details == "Package repositories must be a list of objects." + assert ( + exc_info.value.resolution + == "Verify 'package-repositories' configuration and ensure that the correct syntax is used." + ) diff --git a/tests/legacy/unit/repo/test_apt_key_manager.py b/tests/legacy/unit/repo/test_apt_key_manager.py new file mode 100644 index 0000000000..e5e14bbaab --- /dev/null +++ b/tests/legacy/unit/repo/test_apt_key_manager.py @@ -0,0 +1,339 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2020 Canonical Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +import subprocess +from unittest import mock +from unittest.mock import call + +import gnupg +import pytest + +from snapcraft_legacy.internal.meta.package_repository import ( + PackageRepositoryApt, + PackageRepositoryAptPpa, +) +from snapcraft_legacy.internal.repo import apt_ppa, errors +from snapcraft_legacy.internal.repo.apt_key_manager import AptKeyManager + + +@pytest.fixture(autouse=True) +def mock_environ_copy(): + with mock.patch("os.environ.copy") as m: + yield m + + +@pytest.fixture(autouse=True) +def mock_gnupg(tmp_path, autouse=True): + with mock.patch("gnupg.GPG", spec=gnupg.GPG) as m: + m.return_value.import_keys.return_value.fingerprints = [ + "FAKE-KEY-ID-FROM-GNUPG" + ] + yield m + + +@pytest.fixture(autouse=True) +def mock_run(): + with mock.patch("subprocess.run", spec=subprocess.run) as m: + yield m + + +@pytest.fixture(autouse=True) +def mock_apt_ppa_get_signing_key(): + with mock.patch( + "snapcraft_legacy.internal.repo.apt_ppa.get_launchpad_ppa_key_id", + spec=apt_ppa.get_launchpad_ppa_key_id, + return_value="FAKE-PPA-SIGNING-KEY", + ) as m: + yield m + + +@pytest.fixture +def key_assets(tmp_path): + key_assets = tmp_path / "key-assets" + key_assets.mkdir(parents=True) + yield key_assets + + +@pytest.fixture +def gpg_keyring(tmp_path): + yield tmp_path / "keyring.gpg" + + +@pytest.fixture +def apt_gpg(key_assets, gpg_keyring): + yield AptKeyManager( + gpg_keyring=gpg_keyring, + key_assets=key_assets, + ) + + +def test_find_asset( + apt_gpg, + key_assets, +): + key_id = "8" * 40 + expected_key_path = key_assets / ("8" * 8 + ".asc") + expected_key_path.write_text("key") + + key_path = apt_gpg.find_asset_with_key_id(key_id=key_id) + + assert key_path == expected_key_path + + +def test_find_asset_none( + apt_gpg, +): + key_path = apt_gpg.find_asset_with_key_id(key_id="foo") + + assert key_path is None + + +def test_get_key_fingerprints( + apt_gpg, + mock_gnupg, +): + with mock.patch("tempfile.NamedTemporaryFile") as m: + m.return_value.__enter__.return_value.name = "/tmp/foo" + ids = apt_gpg.get_key_fingerprints(key="8" * 40) + + assert ids == ["FAKE-KEY-ID-FROM-GNUPG"] + assert mock_gnupg.mock_calls == [ + call(keyring="/tmp/foo"), + call().import_keys(key_data="8888888888888888888888888888888888888888"), + ] + + +@pytest.mark.parametrize( + "stdout,expected", + [ + (b"nothing exported", False), + (b"BEGIN PGP PUBLIC KEY BLOCK", True), + (b"invalid", False), + ], +) +def test_is_key_installed( + stdout, + expected, + apt_gpg, + mock_run, +): + mock_run.return_value.stdout = stdout + + is_installed = apt_gpg.is_key_installed(key_id="foo") + + assert is_installed is expected + assert mock_run.mock_calls == [ + call( + ["sudo", "apt-key", "export", "foo"], + check=True, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + ) + ] + + +def test_is_key_installed_with_apt_key_failure( + apt_gpg, + mock_run, +): + mock_run.side_effect = subprocess.CalledProcessError( + cmd=["apt-key"], returncode=1, output=b"some error" + ) + + is_installed = apt_gpg.is_key_installed(key_id="foo") + + assert is_installed is False + + +def test_install_key( + apt_gpg, + gpg_keyring, + mock_run, +): + key = "some-fake-key" + apt_gpg.install_key(key=key) + + assert mock_run.mock_calls == [ + call( + ["sudo", "apt-key", "--keyring", str(gpg_keyring), "add", "-"], + check=True, + env={"LANG": "C.UTF-8"}, + input=b"some-fake-key", + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + ) + ] + + +def test_install_key_with_apt_key_failure(apt_gpg, mock_run): + mock_run.side_effect = subprocess.CalledProcessError( + cmd=["foo"], returncode=1, output=b"some error" + ) + + with pytest.raises(errors.AptGPGKeyInstallError) as exc_info: + apt_gpg.install_key(key="FAKEKEY") + + assert exc_info.value._output == "some error" + assert exc_info.value._key == "FAKEKEY" + + +def test_install_key_from_keyserver(apt_gpg, gpg_keyring, mock_run): + apt_gpg.install_key_from_keyserver(key_id="FAKE_KEYID", key_server="key.server") + + assert mock_run.mock_calls == [ + call( + [ + "sudo", + "apt-key", + "--keyring", + str(gpg_keyring), + "adv", + "--keyserver", + "key.server", + "--recv-keys", + "FAKE_KEYID", + ], + check=True, + env={"LANG": "C.UTF-8"}, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + ) + ] + + +def test_install_key_from_keyserver_with_apt_key_failure( + apt_gpg, gpg_keyring, mock_run +): + mock_run.side_effect = subprocess.CalledProcessError( + cmd=["apt-key"], returncode=1, output=b"some error" + ) + + with pytest.raises(errors.AptGPGKeyInstallError) as exc_info: + apt_gpg.install_key_from_keyserver( + key_id="fake-key-id", key_server="fake-server" + ) + + assert exc_info.value._output == "some error" + assert exc_info.value._key_id == "fake-key-id" + + +@mock.patch( + "snapcraft_legacy.internal.repo.apt_key_manager.AptKeyManager.is_key_installed" +) +@pytest.mark.parametrize( + "is_installed", + [True, False], +) +def test_install_package_repository_key_already_installed( + mock_is_key_installed, + is_installed, + apt_gpg, +): + mock_is_key_installed.return_value = is_installed + package_repo = PackageRepositoryApt( + components=["main", "multiverse"], + key_id="8" * 40, + key_server="xkeyserver.com", + suites=["xenial"], + url="http://archive.ubuntu.com/ubuntu", + ) + + updated = apt_gpg.install_package_repository_key(package_repo=package_repo) + + assert updated is not is_installed + + +@mock.patch( + "snapcraft_legacy.internal.repo.apt_key_manager.AptKeyManager.is_key_installed", + return_value=False, +) +@mock.patch("snapcraft_legacy.internal.repo.apt_key_manager.AptKeyManager.install_key") +def test_install_package_repository_key_from_asset( + mock_install_key, + mock_is_key_installed, + apt_gpg, + key_assets, +): + key_id = "123456789012345678901234567890123456AABB" + expected_key_path = key_assets / "3456AABB.asc" + expected_key_path.write_text("key-data") + + package_repo = PackageRepositoryApt( + components=["main", "multiverse"], + key_id=key_id, + suites=["xenial"], + url="http://archive.ubuntu.com/ubuntu", + ) + + updated = apt_gpg.install_package_repository_key(package_repo=package_repo) + + assert updated is True + assert mock_install_key.mock_calls == [call(key="key-data")] + + +@mock.patch( + "snapcraft_legacy.internal.repo.apt_key_manager.AptKeyManager.is_key_installed", + return_value=False, +) +@mock.patch( + "snapcraft_legacy.internal.repo.apt_key_manager.AptKeyManager.install_key_from_keyserver" +) +def test_install_package_repository_key_apt_from_keyserver( + mock_install_key_from_keyserver, + mock_is_key_installed, + apt_gpg, +): + key_id = "8" * 40 + + package_repo = PackageRepositoryApt( + components=["main", "multiverse"], + key_id=key_id, + key_server="key.server", + suites=["xenial"], + url="http://archive.ubuntu.com/ubuntu", + ) + + updated = apt_gpg.install_package_repository_key(package_repo=package_repo) + + assert updated is True + assert mock_install_key_from_keyserver.mock_calls == [ + call(key_id=key_id, key_server="key.server") + ] + + +@mock.patch( + "snapcraft_legacy.internal.repo.apt_key_manager.AptKeyManager.is_key_installed", + return_value=False, +) +@mock.patch( + "snapcraft_legacy.internal.repo.apt_key_manager.AptKeyManager.install_key_from_keyserver" +) +def test_install_package_repository_key_ppa_from_keyserver( + mock_install_key_from_keyserver, + mock_is_key_installed, + apt_gpg, +): + package_repo = PackageRepositoryAptPpa( + ppa="test/ppa", + ) + + updated = apt_gpg.install_package_repository_key(package_repo=package_repo) + + assert updated is True + assert mock_install_key_from_keyserver.mock_calls == [ + call(key_id="FAKE-PPA-SIGNING-KEY", key_server="keyserver.ubuntu.com") + ] diff --git a/tests/legacy/unit/repo/test_apt_ppa.py b/tests/legacy/unit/repo/test_apt_ppa.py new file mode 100644 index 0000000000..095f70113b --- /dev/null +++ b/tests/legacy/unit/repo/test_apt_ppa.py @@ -0,0 +1,62 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2020 Canonical Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +from unittest import mock +from unittest.mock import call + +import launchpadlib +import pytest + +from snapcraft_legacy.internal.repo import apt_ppa, errors + + +@pytest.fixture +def mock_launchpad(autouse=True): + with mock.patch( + "snapcraft_legacy.internal.repo.apt_ppa.Launchpad", + spec=launchpadlib.launchpad.Launchpad, + ) as m: + m.login_anonymously.return_value.load.return_value.signing_key_fingerprint = ( + "FAKE-PPA-SIGNING-KEY" + ) + yield m + + +def test_split_ppa_parts(): + owner, name = apt_ppa.split_ppa_parts(ppa="test-owner/test-name") + + assert owner == "test-owner" + assert name == "test-name" + + +def test_split_ppa_parts_invalid(): + with pytest.raises(errors.AptPPAInstallError) as exc_info: + apt_ppa.split_ppa_parts(ppa="ppa-missing-slash") + + assert exc_info.value._ppa == "ppa-missing-slash" + + +def test_get_launchpad_ppa_key_id( + mock_launchpad, +): + key_id = apt_ppa.get_launchpad_ppa_key_id(ppa="ppa-owner/ppa-name") + + assert key_id == "FAKE-PPA-SIGNING-KEY" + assert mock_launchpad.mock_calls == [ + call.login_anonymously("snapcraft", "production"), + call.login_anonymously().load("~ppa-owner/+archive/ppa-name"), + ] diff --git a/tests/legacy/unit/repo/test_apt_sources_manager.py b/tests/legacy/unit/repo/test_apt_sources_manager.py new file mode 100644 index 0000000000..2f8f59bd10 --- /dev/null +++ b/tests/legacy/unit/repo/test_apt_sources_manager.py @@ -0,0 +1,269 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2021 Canonical Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +import pathlib +import subprocess +from textwrap import dedent +from unittest import mock +from unittest.mock import call + +import pytest + +from snapcraft_legacy.internal.meta.package_repository import ( + PackageRepositoryApt, + PackageRepositoryAptPpa, +) +from snapcraft_legacy.internal.repo import apt_ppa, apt_sources_manager, errors + + +@pytest.fixture(autouse=True) +def mock_apt_ppa_get_signing_key(): + with mock.patch( + "snapcraft_legacy.internal.repo.apt_ppa.get_launchpad_ppa_key_id", + spec=apt_ppa.get_launchpad_ppa_key_id, + return_value="FAKE-PPA-SIGNING-KEY", + ) as m: + yield m + + +@pytest.fixture(autouse=True) +def mock_environ_copy(): + with mock.patch("os.environ.copy") as m: + yield m + + +@pytest.fixture(autouse=True) +def mock_host_arch(): + with mock.patch( + "snapcraft_legacy.internal.repo.apt_sources_manager.ProjectOptions" + ) as m: + m.return_value.deb_arch = "FAKE-HOST-ARCH" + yield m + + +@pytest.fixture(autouse=True) +def mock_run(): + with mock.patch("subprocess.run") as m: + yield m + + +@pytest.fixture() +def mock_sudo_write(): + def write_file(*, dst_path: pathlib.Path, content: bytes) -> None: + dst_path.write_bytes(content) + + with mock.patch( + "snapcraft_legacy.internal.repo.apt_sources_manager._sudo_write_file" + ) as m: + m.side_effect = write_file + yield m + + +@pytest.fixture(autouse=True) +def mock_version_codename(): + with mock.patch( + "snapcraft_legacy.internal.os_release.OsRelease.version_codename", + return_value="FAKE-CODENAME", + ) as m: + yield m + + +@pytest.fixture +def apt_sources_mgr(tmp_path): + sources_list_d = tmp_path / "sources.list.d" + sources_list_d.mkdir(parents=True) + + yield apt_sources_manager.AptSourcesManager( + sources_list_d=sources_list_d, + ) + + +@mock.patch("tempfile.NamedTemporaryFile") +@mock.patch("os.unlink") +def test_sudo_write_file(mock_unlink, mock_tempfile, mock_run, tmp_path): + mock_tempfile.return_value.__enter__.return_value.name = "/tmp/foobar" + + apt_sources_manager._sudo_write_file(dst_path="/foo/bar", content=b"some-content") + + assert mock_tempfile.mock_calls == [ + call(delete=False), + call().__enter__(), + call().__enter__().write(b"some-content"), + call().__enter__().flush(), + call().__exit__(None, None, None), + ] + assert mock_run.mock_calls == [ + call( + [ + "sudo", + "install", + "--owner=root", + "--group=root", + "--mode=0644", + "/tmp/foobar", + "/foo/bar", + ], + check=True, + ) + ] + assert mock_unlink.mock_calls == [call("/tmp/foobar")] + + +def test_sudo_write_file_fails(mock_run): + mock_run.side_effect = subprocess.CalledProcessError( + cmd=["sudo"], returncode=1, output=b"some error" + ) + + with pytest.raises(RuntimeError) as error: + apt_sources_manager._sudo_write_file( + dst_path="/foo/bar", content=b"some-content" + ) + + assert ( + str(error.value).startswith( + "Failed to install repository config with: ['sudo', 'install'" + ) + is True + ) + + +@pytest.mark.parametrize( + "package_repo,name,content", + [ + ( + PackageRepositoryApt( + architectures=["amd64", "arm64"], + components=["test-component"], + formats=["deb", "deb-src"], + key_id="A" * 40, + suites=["test-suite1", "test-suite2"], + url="http://test.url/ubuntu", + ), + "snapcraft-http_test_url_ubuntu.sources", + dedent( + """\ + Types: deb deb-src + URIs: http://test.url/ubuntu + Suites: test-suite1 test-suite2 + Components: test-component + Architectures: amd64 arm64 + """ + ).encode(), + ), + ( + PackageRepositoryApt( + architectures=["amd64", "arm64"], + components=["test-component"], + key_id="A" * 40, + name="NO-FORMAT", + suites=["test-suite1", "test-suite2"], + url="http://test.url/ubuntu", + ), + "snapcraft-NO-FORMAT.sources", + dedent( + """\ + Types: deb + URIs: http://test.url/ubuntu + Suites: test-suite1 test-suite2 + Components: test-component + Architectures: amd64 arm64 + """ + ).encode(), + ), + ( + PackageRepositoryApt( + key_id="A" * 40, + name="WITH-PATH", + path="some-path", + url="http://test.url/ubuntu", + ), + "snapcraft-WITH-PATH.sources", + dedent( + """\ + Types: deb + URIs: http://test.url/ubuntu + Suites: some-path/ + Architectures: FAKE-HOST-ARCH + """ + ).encode(), + ), + ( + PackageRepositoryApt( + key_id="A" * 40, + name="IMPLIED-PATH", + url="http://test.url/ubuntu", + ), + "snapcraft-IMPLIED-PATH.sources", + dedent( + """\ + Types: deb + URIs: http://test.url/ubuntu + Suites: / + Architectures: FAKE-HOST-ARCH + """ + ).encode(), + ), + ( + PackageRepositoryAptPpa(ppa="test/ppa"), + "snapcraft-ppa-test_ppa.sources", + dedent( + """\ + Types: deb + URIs: http://ppa.launchpad.net/test/ppa/ubuntu + Suites: FAKE-CODENAME + Components: main + Architectures: FAKE-HOST-ARCH + """ + ).encode(), + ), + ], +) +def test_install(package_repo, name, content, apt_sources_mgr, mock_sudo_write): + sources_path = apt_sources_mgr._sources_list_d / name + + changed = apt_sources_mgr.install_package_repository_sources( + package_repo=package_repo + ) + + assert changed is True + assert sources_path.read_bytes() == content + assert mock_sudo_write.mock_calls == [ + call( + content=content, + dst_path=sources_path, + ) + ] + + # Verify a second-run does not incur any changes. + mock_sudo_write.reset_mock() + + changed = apt_sources_mgr.install_package_repository_sources( + package_repo=package_repo + ) + + assert changed is False + assert sources_path.read_bytes() == content + assert mock_sudo_write.mock_calls == [] + + +def test_install_ppa_invalid(apt_sources_mgr): + repo = PackageRepositoryAptPpa(ppa="ppa-missing-slash") + + with pytest.raises(errors.AptPPAInstallError) as exc_info: + apt_sources_mgr.install_package_repository_sources(package_repo=repo) + + assert exc_info.value._ppa == "ppa-missing-slash" diff --git a/tests/legacy/unit/store/test_keys.py b/tests/legacy/unit/store/test_keys.py new file mode 100644 index 0000000000..f818dee658 --- /dev/null +++ b/tests/legacy/unit/store/test_keys.py @@ -0,0 +1,78 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2023 Canonical Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +from unittest import mock + +import pytest + +from snapcraft_legacy import _store + +NO_KEYS_OUTPUT = """\ +No keys have been created on this system. See 'snapcraft create-key --help' to create a key. +No keys have been registered with this account. See 'snapcraft register-key --help' to register a key. +""" +ONLY_CREATED_KEYS_OUTPUT = """\ +The following keys are available on this system: + Name SHA3-384 fingerprint +- ctrl ModelM (not registered) +- alt Dvorak (not registered) +No keys have been registered with this account. See 'snapcraft register-key --help' to register a key. +""" +UNAVAILABLE_KEYS_OUTPUT = """\ +No keys have been created on this system. See 'snapcraft create-key --help' to create a key. +The following SHA3-384 key fingerprints have been registered but are not available on this system: +- Bach +- ModelM +""" +SOME_KEYS_OVERLAP_OUTPUT = """\ +The following keys are available on this system: + Name SHA3-384 fingerprint +* ctrl ModelM +- alt Dvorak (not registered) +The following SHA3-384 key fingerprints have been registered but are not available on this system: +- Bach +""" +LOCAL_KEYS = [ + {"name": "ctrl", "sha3-384": "ModelM"}, + {"name": "alt", "sha3-384": "Dvorak"}, +] +REMOTE_KEYS = [ + {"public-key-sha3-384": "Bach"}, + {"public-key-sha3-384": "ModelM"}, +] +NO_KEYS = [] + + +@pytest.mark.parametrize( + ("usable_keys", "account_keys", "stdout"), + [ + (NO_KEYS, NO_KEYS, NO_KEYS_OUTPUT), + (LOCAL_KEYS, NO_KEYS, ONLY_CREATED_KEYS_OUTPUT), + (NO_KEYS, REMOTE_KEYS, UNAVAILABLE_KEYS_OUTPUT), + (LOCAL_KEYS, REMOTE_KEYS, SOME_KEYS_OVERLAP_OUTPUT), + ], +) +def test_list_keys(monkeypatch, capsys, usable_keys, account_keys, stdout): + monkeypatch.setattr(_store, "_get_usable_keys", lambda: usable_keys) + mock_client = mock.Mock(spec_set=_store.StoreClientCLI) + mock_client.get_account_information.return_value = {"account_keys": account_keys} + monkeypatch.setattr(_store, "StoreClientCLI", lambda: mock_client) + + _store.list_keys() + + out, err = capsys.readouterr() + + assert out == stdout + assert err == "" diff --git a/tests/spread/core22/package-repositories/task.yaml b/tests/spread/core22/package-repositories/task.yaml index c8eff1af2f..c147fa0517 100644 --- a/tests/spread/core22/package-repositories/task.yaml +++ b/tests/spread/core22/package-repositories/task.yaml @@ -6,6 +6,7 @@ environment: SNAP/test_apt_keyserver: test-apt-keyserver SNAP/test_apt_ppa: test-apt-ppa SNAP/test_pin: test-pin + SNAP/test_multi_keys: test-multi-keys SNAPCRAFT_BUILD_ENVIRONMENT: "" restore: | diff --git a/tests/spread/core22/package-repositories/test-multi-keys/snap/keys/9E61EF26.asc b/tests/spread/core22/package-repositories/test-multi-keys/snap/keys/9E61EF26.asc new file mode 100644 index 0000000000..be4fa1d55f --- /dev/null +++ b/tests/spread/core22/package-repositories/test-multi-keys/snap/keys/9E61EF26.asc @@ -0,0 +1,239 @@ +# NOTE: this keyring has expired keys and subkeys. Lines with "pub:e:" are expired +# keys, and lines with "sub:e:" are expired subkeys. python-gnupg returns the +# fingerprints for all primary keys, expired or not: +# pub:e:4096:1:B8F999C007BB6C57:1360109177:1549910347::-:::sc::::::23::0: +# fpr:::::::::8735F5AF62A99A628EC13377B8F999C007BB6C57: +# uid:e::::1455302347::A8FC88656336852AD4301DF059CEE6134FD37C21::Puppet Labs Nightly Build Key (Puppet Labs Nightly Build Key) ::::::::::0: +# uid:e::::1455302347::4EF2A82F1FF355343885012A832C628E1A4F73A8::Puppet Labs Nightly Build Key (Puppet Labs Nightly Build Key) ::::::::::0: +# sub:e:4096:1:AE8282E5A5FC3E74:1360109177:1549910293:::::e::::::23: +# fpr:::::::::F838D657CCAF0E4A6375B0E9AE8282E5A5FC3E74: +# gpg: key 7F438280EF8D349F: 8 signatures not checked due to missing keys +# pub:e:4096:1:7F438280EF8D349F:1471554366:1629234366::-:::sc::::::23::0: +# fpr:::::::::6F6B15509CF8E59E6E469F327F438280EF8D349F: +# uid:e::::1471554366::B648B946D1E13EEA5F4081D8FE5CF4D001200BC7::Puppet, Inc. Release Key (Puppet, Inc. Release Key) ::::::::::0: +# sub:e:4096:1:A2D80E04656674AE:1471554366:1629234366:::::e::::::23: +# fpr:::::::::07F5ABF8FE84BC3736D2AAD3A2D80E04656674AE: +# pub:-:4096:1:4528B6CD9E61EF26:1554759562:1743975562::-:::scESC::::::23::0: +# fpr:::::::::D6811ED3ADEEB8441AF5AA8F4528B6CD9E61EF26: +# uid:-::::1554759562::B648B946D1E13EEA5F4081D8FE5CF4D001200BC7::Puppet, Inc. Release Key (Puppet, Inc. Release Key) ::::::::::0: +# sub:-:4096:1:F230A24E9F057A83:1554759562:1743975562:::::e::::::23: +# fpr:::::::::90A29D0A6576E2CA185AED3EF230A24E9F057A83: +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=h0KC +-----END PGP PUBLIC KEY BLOCK----- diff --git a/tests/spread/core22/package-repositories/test-multi-keys/snap/snapcraft.yaml b/tests/spread/core22/package-repositories/test-multi-keys/snap/snapcraft.yaml new file mode 100644 index 0000000000..19fe27377d --- /dev/null +++ b/tests/spread/core22/package-repositories/test-multi-keys/snap/snapcraft.yaml @@ -0,0 +1,38 @@ +name: test-multi-keys +version: '1.0' +summary: test +description: test installing a package repository with an asset file with multiple keys +grade: stable +confinement: strict +base: core22 + +parts: + test-ppa: + plugin: nil + build-packages: + - test-ppa + stage-packages: + - test-ppa + - puppet-tools-release # Comes from the Puppet repo + override-build: | + craftctl default + # This file comes from the puppet-tools-release package. + test -f ${CRAFT_PART_INSTALL}/usr/share/doc/puppet-tools-release/bill-of-materials + +apps: + test-ppa: + command: usr/bin/test-ppa + +package-repositories: + - type: apt + formats: [deb, deb-src] + components: [main] + suites: [focal] + key-id: 78E1918602959B9C59103100F1831DDAFC42E99D + url: http://ppa.launchpad.net/snappy-dev/snapcraft-daily/ubuntu + - type: apt # puppet bolt + components: [puppet-tools] + suites: [focal] + url: http://apt.puppet.com + # Asset 9E61EF26.asc has multiple keys inside + key-id: D6811ED3ADEEB8441AF5AA8F4528B6CD9E61EF26 diff --git a/tests/spread/core22/patchelf/classic-python/task.yaml b/tests/spread/core22/patchelf/classic-python/task.yaml new file mode 100644 index 0000000000..e28f835b2e --- /dev/null +++ b/tests/spread/core22/patchelf/classic-python/task.yaml @@ -0,0 +1,41 @@ +summary: Build and run a Python-based core22 classic snap + +# To ensure the patchelf fixes are correct, we run this test on focal systems. +systems: + - ubuntu-20.04 + - ubuntu-20.04-64 + - ubuntu-20.04-amd64 + - ubuntu-20.04-arm64 + - ubuntu-20.04-armhf + - ubuntu-20.04-s390x + - ubuntu-20.04-ppc64el + +prepare: | + # Clone the snapcraft-docs/python-ctypes-example + git clone https://github.com/snapcraft-docs/python-ctypes-example.git + cd python-ctypes-example + # A known "good" commit from "main" at the time of writing this test + git checkout 31939ef68d8c383b9202f2588a704b3271bae009 + + # Replace the existing snap command with a call to the provisioned python3 + sed -i 's|command: bin/test-ctypes.py|command: bin/python3|' snap/snapcraft.yaml + +execute: | + cd python-ctypes-example + + # Build the core22 snap + unset SNAPCRAFT_BUILD_ENVIRONMENT + snapcraft --use-lxd + + # Install the new snap + sudo snap install --classic --dangerous example-python-ctypes*.snap + + # Run the snap's command; success means patchelf correctly linked the Python + # interpreter to core22's libc. Failure would output things like: + # version `GLIBC_2.35' not found (required by /snap/example-python-ctypes/x1/bin/python3) + example-python-ctypes -c "import ctypes; print(ctypes.__file__)" | MATCH "/snap/example-python-ctypes/" + +restore: | + cd python-ctypes-example + snapcraft clean + rm -f ./*.snap diff --git a/tests/spread/general/metadata-links/task.yaml b/tests/spread/general/metadata-links/task.yaml index 47730acec2..41b2976613 100644 --- a/tests/spread/general/metadata-links/task.yaml +++ b/tests/spread/general/metadata-links/task.yaml @@ -6,10 +6,18 @@ environment: prepare: | snap install yq + #shellcheck source=tests/spread/tools/snapcraft-yaml.sh + . "$TOOLS_DIR/snapcraft-yaml.sh" + set_base snapcraft.yaml + restore: | snapcraft clean rm -rf ./*.snap + #shellcheck source=tests/spread/tools/snapcraft-yaml.sh + . "$TOOLS_DIR/snapcraft-yaml.sh" + restore_yaml snapcraft.yaml + execute: | # Create a snap to trigger `snap pack`. snapcraft diff --git a/tests/unit/commands/test_upload.py b/tests/unit/commands/test_upload.py index a286822518..12068667e0 100644 --- a/tests/unit/commands/test_upload.py +++ b/tests/unit/commands/test_upload.py @@ -57,6 +57,20 @@ def snap_file(): ) +@pytest.fixture +def snap_file_with_started_at(): + return str( + ( + pathlib.Path(unit.__file__) + / ".." + / ".." + / "legacy" + / "data" + / "test-snap-with-started-at.snap" + ).resolve() + ) + + ################## # Upload Command # ################## @@ -96,6 +110,40 @@ def test_default( emitter.assert_message("Revision 10 created for 'basic'") +@pytest.mark.usefixtures("memory_keyring") +@pytest.mark.parametrize( + "command_class", (commands.StoreUploadCommand, commands.StoreLegacyPushCommand) +) +def test_built_at( + emitter, + fake_store_notify_upload, + fake_store_verify_upload, + snap_file_with_started_at, + command_class, +): + cmd = command_class(None) + + cmd.run( + argparse.Namespace( + snap_file=snap_file_with_started_at, + channels=None, + ) + ) + + assert fake_store_verify_upload.mock_calls == [call(ANY, snap_name="basic")] + assert fake_store_notify_upload.mock_calls == [ + call( + ANY, + snap_name="basic", + upload_id="2ecbfac1-3448-4e7d-85a4-7919b999f120", + built_at="2019-05-07T19:25:53.939041Z", + channels=None, + snap_file_size=4096, + ) + ] + emitter.assert_message("Revision 10 created for 'basic'") + + @pytest.mark.usefixtures("memory_keyring") def test_default_channels( emitter, fake_store_notify_upload, fake_store_verify_upload, snap_file diff --git a/tests/unit/meta/test_snap_yaml.py b/tests/unit/meta/test_snap_yaml.py index 5c70600c15..dafe7a6816 100644 --- a/tests/unit/meta/test_snap_yaml.py +++ b/tests/unit/meta/test_snap_yaml.py @@ -124,6 +124,111 @@ def test_assumes(simple_project, new_dir): ) +def test_links_scalars(simple_project, new_dir): + snap_yaml.write( + simple_project( + contact="me@acme.com", + issues="https://hubhub.com/issues", + donation="https://moneyfornothing.com", + source_code="https://closed.acme.com", + website="https://acme.com", + ), + prime_dir=Path(new_dir), + arch="amd64", + ) + yaml_file = Path("meta/snap.yaml") + assert yaml_file.is_file() + + content = yaml_file.read_text() + assert content == textwrap.dedent( + """\ + name: mytest + version: 1.29.3 + summary: Single-line elevator pitch for your amazing snap + description: test-description + architectures: + - amd64 + base: core22 + apps: + app1: + command: bin/mytest + confinement: strict + grade: stable + environment: + LD_LIBRARY_PATH: ${SNAP_LIBRARY_PATH}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH} + PATH: $SNAP/usr/sbin:$SNAP/usr/bin:$SNAP/sbin:$SNAP/bin:$PATH + links: + contact: + - me@acme.com + donation: + - https://moneyfornothing.com + issues: + - https://hubhub.com/issues + source-code: + - https://closed.acme.com + website: + - https://acme.com + """ + ) + + +def test_links_lists(simple_project, new_dir): + snap_yaml.write( + simple_project( + contact=[ + "me@acme.com", + "you@acme.com", + ], + issues=[ + "https://hubhub.com/issues", + "https://corner.com/issues", + ], + donation=["https://moneyfornothing.com", "https://prince.com"], + source_code="https://closed.acme.com", + website="https://acme.com", + ), + prime_dir=Path(new_dir), + arch="amd64", + ) + yaml_file = Path("meta/snap.yaml") + assert yaml_file.is_file() + + content = yaml_file.read_text() + assert content == textwrap.dedent( + """\ + name: mytest + version: 1.29.3 + summary: Single-line elevator pitch for your amazing snap + description: test-description + architectures: + - amd64 + base: core22 + apps: + app1: + command: bin/mytest + confinement: strict + grade: stable + environment: + LD_LIBRARY_PATH: ${SNAP_LIBRARY_PATH}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH} + PATH: $SNAP/usr/sbin:$SNAP/usr/bin:$SNAP/sbin:$SNAP/bin:$PATH + links: + contact: + - me@acme.com + - you@acme.com + donation: + - https://moneyfornothing.com + - https://prince.com + issues: + - https://hubhub.com/issues + - https://corner.com/issues + source-code: + - https://closed.acme.com + website: + - https://acme.com + """ + ) + + @pytest.fixture def complex_project(): snapcraft_yaml = textwrap.dedent( @@ -1081,3 +1186,60 @@ def test_architectures_all(simple_project, new_dir): "${SNAP_LIBRARY_PATH}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}:" "$SNAP/lib:$SNAP/usr/lib\n" ) in content + + +############## +# Test Links # +############## + + +def test_links_for_scalars(simple_project): + project = simple_project( + contact="me@acme.com", + issues="https://hubhub.com/issues", + donation="https://moneyfornothing.com", + source_code="https://closed.acme.com", + website="https://acme.com", + ) + + links = snap_yaml.Links.from_project(project) + + assert links.contact == [project.contact] + assert links.issues == [project.issues] + assert links.donation == [project.donation] + assert links.source_code == [project.source_code] + assert links.website == [project.website] + + assert bool(links) is True + + +def test_links_for_lists(simple_project): + project = simple_project( + contact=[ + "me@acme.com", + "you@acme.com", + ], + issues=[ + "https://hubhub.com/issues", + "https://corner.com/issues", + ], + donation=["https://moneyfornothing.com", "https://prince.com"], + ) + + links = snap_yaml.Links.from_project(project) + + assert links.contact == project.contact + assert links.issues == project.issues + assert links.donation == project.donation + assert links.source_code is None + assert links.website is None + + assert bool(links) is True + + +def test_no_links(simple_project): + project = simple_project() + + links = snap_yaml.Links.from_project(project) + + assert bool(links) is False