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