diff --git a/changes/2396.feature.md b/changes/2396.feature.md index 7267ac472c..8487acf9ab 100644 --- a/changes/2396.feature.md +++ b/changes/2396.feature.md @@ -1 +1 @@ -Linux system packages (`.deb`, `.rpm` and `.pkg.tar.zst`) can now be signed with a GPG signing identity, using the `--identity` option to `briefcase package`. Signing is not currently supported when building with Docker. +Linux system packages (`.deb`, `.rpm` and `.pkg.tar.zst`) can now be signed with a GPG signing identity, using the `--identity` option to `briefcase package`. This includes packages built inside a Docker container. diff --git a/docs/en/how-to/code-signing/linux.md b/docs/en/how-to/code-signing/linux.md index 8943a5babf..1117d4ec68 100644 --- a/docs/en/how-to/code-signing/linux.md +++ b/docs/en/how-to/code-signing/linux.md @@ -14,6 +14,8 @@ $ gpg --full-generate-key You will be prompted to select a key type, key size and expiry date, and to provide a name and email address that will identify the key. If possible, use an ECC key based on Curve 25519 (an `ed25519` signing key), which is the default in recent GnuPG versions and produces smaller, faster signatures. If you need to support older tools that don't understand ECC keys, generate an RSA key of at least 4096 bits instead. The email address should be an address you control, as users will use it (along with your public key) to identify that the package really came from you. +If you plan to sign packages built with Docker, create a key that does not require a passphrase: when GnuPG prompts you to enter a passphrase, leave the field blank and confirm. When a signing key is exported to a Docker container, GnuPG cannot prompt for the passphrase, so a key with a passphrase will fail the signing step. See [Docker builds](#docker-builds) for details. + ## Obtain the identity of your key Briefcase uses the *fingerprint* of your key to identify the signing identity. To see the fingerprints of all the secret keys on your system, run: @@ -64,4 +66,8 @@ As with other platforms, `--adhoc-sign` is useful during development and testing ## Docker builds -Signing is not currently supported when building with Docker (i.e., when the `--target` option is used). When packaging with Docker, you must opt out of signing — either by selecting "Don't sign" when prompted, or by providing the `--adhoc-sign` option. Selecting a signing identity when building with Docker will cause an error. +Linux system packages can be signed when building with Docker (i.e., when the `--target` option is used), using the same GPG signing identity as native builds. + +When signing a package built with Docker, Briefcase exports the selected secret key from the host machine's GPG keyring, and imports it into the build container so that the signing step can run inside the container. The exported key is removed immediately after signing, and is never stored in the Docker image. + +One caveat applies when building with Docker: because the signing step runs inside a headless container, GnuPG is not able to prompt for a passphrase. If your signing key requires a passphrase, the signing step will fail. To sign packages built with Docker, use a key (or sub-key) that does not require a passphrase, or build the package without the `--target` option and sign it natively. diff --git a/docs/en/reference/platforms/linux/system.md b/docs/en/reference/platforms/linux/system.md index 967ac1e423..d67ea320c2 100644 --- a/docs/en/reference/platforms/linux/system.md +++ b/docs/en/reference/platforms/linux/system.md @@ -105,7 +105,7 @@ Signing is performed as follows, depending on the packaging format: If the relevant signing tool is not installed, Briefcase will report an error suggesting how to install it. If no signing identity is available, or if `--adhoc-sign` is used, the package will be produced without a signature. -Signing is not supported when building with Docker (i.e., using the `--target` option); in this case, the package must be produced without a signature. +When building with Docker, the signing tool is installed in the build container, and the signing identity is exported from the host and imported into the container for the duration of the signing step. Note that a key requiring a passphrase cannot be used to sign a package built with Docker, as GnuPG cannot prompt for a passphrase inside the container. ## Additional options diff --git a/src/briefcase/commands/package.py b/src/briefcase/commands/package.py index 6f5d32ca48..fdf8de5f8f 100644 --- a/src/briefcase/commands/package.py +++ b/src/briefcase/commands/package.py @@ -104,8 +104,11 @@ def _package_app( :param packaging_format: The format of the packaging artefact to create. """ # Annotate the packaging format onto the app so that distribution path - # resolution works correctly during the resume check. - app.packaging_format = packaging_format + # resolution works correctly during the resume check. If no packaging + # format was specified, the format determined during app finalization + # is used. + if packaging_format: + app.packaging_format = packaging_format resume = self.can_resume(app, **options) diff --git a/src/briefcase/commands/publish.py b/src/briefcase/commands/publish.py index 014ef2e8af..302e9ad73e 100644 --- a/src/briefcase/commands/publish.py +++ b/src/briefcase/commands/publish.py @@ -77,8 +77,10 @@ def _publish_app( """ state = None - # Annotate the packaging format onto the app - app.packaging_format = packaging_format + # Annotate the packaging format onto the app. If no packaging format was + # specified, the format determined during app finalization is used. + if packaging_format: + app.packaging_format = packaging_format if update or not self.distribution_path(app).exists(): state = self.package_command( diff --git a/src/briefcase/integrations/gnupg.py b/src/briefcase/integrations/gnupg.py index 0e339e8aa6..4728716c4c 100644 --- a/src/briefcase/integrations/gnupg.py +++ b/src/briefcase/integrations/gnupg.py @@ -1,6 +1,7 @@ from __future__ import annotations import subprocess +from pathlib import Path from briefcase.exceptions import BriefcaseCommandError from briefcase.integrations.base import Tool, ToolCache @@ -66,3 +67,29 @@ def identities(self) -> dict[str, str]: identities[fingerprint] = record[9] return identities + + def export_secret_key(self, identity: str, output_path: Path): + """Export a secret key to a file. + + The exported key can be imported into a different environment (e.g., a Docker + container) to enable signing there. + + :param identity: The fingerprint of the identity to export + :param output_path: The path of the file to write the exported key to + """ + try: + self.tools.subprocess.run( + [ + "gpg", + "--batch", + "--output", + output_path, + "--export-secret-keys", + identity, + ], + check=True, + ) + except subprocess.CalledProcessError as e: + raise BriefcaseCommandError( + f"Error exporting the GPG signing key for identity {identity}." + ) from e diff --git a/src/briefcase/platforms/linux/system.py b/src/briefcase/platforms/linux/system.py index dc44e6d0ea..cccf97aeb7 100644 --- a/src/briefcase/platforms/linux/system.py +++ b/src/briefcase/platforms/linux/system.py @@ -2,11 +2,12 @@ import gzip import re +import shlex import subprocess import tarfile from collections.abc import Collection from pathlib import Path -from typing import cast +from typing import Any, cast from briefcase.commands import ( BuildCommand, @@ -273,6 +274,31 @@ def finalize_app_config( self.console.verbose(f"Targeting Python{app.python_version_tag}") + # If no packaging format was selected (or the "system" alias was used), + # determine the format implied by the vendor base. This must be done + # before the app tools are verified, so the Docker image can be built + # with the tools needed to package and sign the app. + if getattr(app, "packaging_format", None) in (None, "system"): + app.packaging_format = { + DEBIAN: "deb", + RHEL: "rpm", + ARCH: "pkg", + SUSE: "rpm", + }.get(app.target_vendor_base) + + if app.packaging_format is None: + if self.use_docker: + raise BriefcaseCommandError( + "Briefcase doesn't know the system packaging format for " + f"{app.target_vendor}. You may be able to proceed by " + "manually specifying a format with the packaging_format " + "option in the app configuration" + ) + + # Native builds don't require a packaging format until the app + # is packaged; retain the unresolved "system" alias. + app.packaging_format = "system" + return LinuxSystemAppConfig(super().finalize_app_config(app, **kwargs)) def _deb_devirtualize(self, package: str) -> str: @@ -388,6 +414,27 @@ def _system_requirement_tools(self, app: LinuxSystemAppConfig): system_installer, ) + def _signing_tool(self, app: LinuxSystemAppConfig) -> tuple[str, str, str]: + """Utility method returning the tool used to sign a package. + + :param app: The app being packaged + :returns: A triple of (tool name, executable name, package name) for the tool + used to sign the package. + :raises KeyError: If the packaging format cannot be determined. + """ + # The packaging format may not be set on a draft app config. + packaging_format = getattr(app, "packaging_format", None) + tool_name, executable_name, package_name = { + "deb": ("debsigs", "debsigs", "debsigs"), + "rpm": ("rpmsign", "rpmsign", "rpm-sign"), + "pkg": ("gpg", "gpg", "gnupg"), + }[packaging_format] + if packaging_format == "rpm" and app.target_vendor_base == SUSE: + # On SUSE, rpmsign is provided by rpm-build; there is no separate + # `rpm-sign` package. + package_name = "rpm-build" + return tool_name, executable_name, package_name + def verify_system_packages(self, app: LinuxSystemAppConfig): """Verify that the required system packages are installed. @@ -742,6 +789,22 @@ def verify_app_tools(self, app: FinalizedAppConfig): verify_python = not hasattr(self.tools[app], "app_context") if self.use_docker: + # The Docker image is built before the signing identity is selected, + # so the signing tool must be installed in the image for the signing + # step to be able to run inside the container. + system_requires = getattr(app, "system_requires", None) + if system_requires is None: + system_requires = [] + app.system_requires = system_requires + try: + _, _, package_name = self._signing_tool(app) + except KeyError: + # An unknown packaging format has no signing tool that can be + # identified. + package_name = None + if package_name is not None and package_name not in system_requires: + system_requires.append(package_name) + DockerAppContext.verify( tools=self.tools, app=app, @@ -1057,19 +1120,6 @@ class LinuxSystemSigningMixin(_MixinBase): "available on the system." ) - def _signing_tool(self, app: LinuxSystemAppConfig) -> tuple[str, str, str]: - """Utility method returning the tool used to sign a package. - - :param app: The app being packaged - :returns: A triple of (tool name, executable name, package name) for the tool - used to sign the package. - """ - return { - "deb": ("debsigs", "debsigs", "debsigs"), - "rpm": ("rpmsign", "rpmsign", "rpm-sign"), - "pkg": ("gpg", "gpg", "gnupg"), - }[app.packaging_format] - def _verify_signing_tool(self, app: LinuxSystemAppConfig): """Verify that the app environment contains the signing tool. @@ -1090,12 +1140,14 @@ def _verify_signing_tool(self, app: LinuxSystemAppConfig): if install_cmd := self._system_requirement_tools(app)[3]: raise BriefcaseCommandError( f"Can't find the {tool_name} tools. " - f"Try running `sudo {' '.join(install_cmd)} {package_name}`." + f"Try running `sudo {' '.join(install_cmd)} {package_name}`. " + "Alternatively, use `--adhoc-sign` to skip signing the package." ) from None else: raise BriefcaseCommandError( f"Can't find the {executable_name} tool. " - f"Install this first to sign the {app.packaging_format}." + f"Install this first to sign the {app.packaging_format}. " + "Alternatively, use `--adhoc-sign` to skip signing the package." ) from None def signature_path(self, app: LinuxSystemAppConfig) -> Path: @@ -1202,13 +1254,48 @@ def sign_package(self, app: LinuxSystemAppConfig, identity: str): ], }[app.packaging_format] + subprocess_kwargs: dict[str, Any] = {} + key_file_path: Path | None = None + try: - self.tools[app].app_context.run(sign_command, check=True) + if self.use_docker: + # When packaging inside Docker, the secret key must be made available + # to the container. Export the key to the bundle path (which is mounted + # in to the container), then import it and sign the package in a single + # container run, so the key is not retained in the image or container. + # + # The bundle and dist folders are mounted in to the container, and the + # Docker layer rewrites the host paths in the commands to their + # container equivalents. + key_file_path = self.bundle_path(app) / "signing-key.gpg" + subprocess_kwargs["mounts"] = [(self.dist_path, "/dist")] + self.tools.gnupg.export_secret_key(identity, key_file_path) + self.tools.os.chmod(key_file_path, 0o600) + sign_command = [ + "sh", + "-c", + " && ".join( + " ".join(shlex.quote(arg) for arg in command) + for command in [ + ["gpg", "--batch", "--import", str(key_file_path)], + sign_command, + ] + ), + ] + + self.tools[app].app_context.run( + sign_command, + check=True, + **subprocess_kwargs, + ) except subprocess.CalledProcessError as e: raise BriefcaseCommandError( f"Error while signing .{app.packaging_format} package for " f"{app.app_name}." ) from e + finally: + if self.use_docker: + key_file_path.unlink(missing_ok=True) def clean_dist_folder(self, app, **options): super().clean_dist_folder(app, **options) @@ -1237,12 +1324,6 @@ def package_app(self, app, identity=None, adhoc_sign=False, **kwargs): else: identity = self.select_identity(identity=identity) if identity: - if self.use_docker: - raise BriefcaseCommandError( - "Signing system packages is not supported when using " - "Docker. Re-run the package command without the " - "`--target` option, or select `Don't sign`." - ) # Signing is required; verify the signing tool is available. self._verify_signing_tool(app) else: @@ -1285,13 +1366,25 @@ class LinuxSystemPackageCommand( def packaging_formats(self): return ["deb", "rpm", "pkg", "system"] + @property + def default_packaging_format(self): + # The app's finalized configuration determines the packaging format. + return None + def _verify_packaging_tools(self, app: LinuxSystemAppConfig): """Verify that the local environment contains the packaging tools.""" - tool_name, executable_name, package_name = { - "deb": ("dpkg", "dpkg-deb", "dpkg-dev"), - "rpm": ("rpm-build", "rpmbuild", "rpm-build"), - "pkg": ("makepkg", "makepkg", "pacman"), - }[app.packaging_format] + try: + tool_name, executable_name, package_name = { + "deb": ("dpkg", "dpkg-deb", "dpkg-dev"), + "rpm": ("rpm-build", "rpmbuild", "rpm-build"), + "pkg": ("makepkg", "makepkg", "pacman"), + }[app.packaging_format] + except KeyError as e: + raise BriefcaseCommandError( + "Briefcase doesn't know the system packaging format for " + f"{app.target_vendor}. You may be able to build a package " + "by manually specifying a format with -p/--packaging-format" + ) from e if not self.tools.shutil.which(executable_name): if install_cmd := self._system_requirement_tools(app)[3]: @@ -1308,21 +1401,6 @@ def _verify_packaging_tools(self, app: LinuxSystemAppConfig): def verify_app_tools(self, app: FinalizedAppConfig): app = cast(LinuxSystemAppConfig, app) super().verify_app_tools(app) - # If "system" packaging format was selected, determine what that means. - if app.packaging_format == "system": - app.packaging_format = { - DEBIAN: "deb", - RHEL: "rpm", - ARCH: "pkg", - SUSE: "rpm", - }.get(app.target_vendor_base) - - if app.packaging_format is None: - raise BriefcaseCommandError( - "Briefcase doesn't know the system packaging format for " - f"{app.target_vendor}. You may be able to build a package " - "by manually specifying a format with -p/--packaging-format" - ) if not self.use_docker: self._verify_packaging_tools(app) @@ -1697,6 +1775,11 @@ def _package_pkg( class LinuxSystemPublishCommand(LinuxSystemDockerMixin, PublishCommand): description = "Publish a Linux system project." + @property + def default_packaging_format(self): + # The app's finalized configuration determines the packaging format. + return None + # Declare the briefcase command bindings create = LinuxSystemCreateCommand diff --git a/tests/commands/package/test_call.py b/tests/commands/package/test_call.py index 4280182fed..d77e98ab0c 100644 --- a/tests/commands/package/test_call.py +++ b/tests/commands/package/test_call.py @@ -1068,3 +1068,26 @@ def test_create_before_package_external_app( # The dist folder has been created. assert (tmp_path / "base_path/dist").exists() + + +def test_package_app_no_packaging_format(package_command, first_app): + """If no packaging format is specified, the finalized packaging format on the app is + retained.""" + # The app has been finalized with a concrete packaging format + first_app.packaging_format = "pkg" + + package_command._package_app(first_app, update=False, packaging_format=None) + + # The packaging format was not modified + assert first_app.packaging_format == "pkg" + + +def test_package_app_explicit_packaging_format(package_command, first_app): + """An explicitly specified packaging format is annotated onto the app.""" + # The app has been finalized with a concrete packaging format + first_app.packaging_format = "pkg" + + package_command._package_app(first_app, update=False, packaging_format="box") + + # The explicit packaging format has been annotated onto the app + assert first_app.packaging_format == "box" diff --git a/tests/integrations/gnupg/test_GnuPG__export_secret_key.py b/tests/integrations/gnupg/test_GnuPG__export_secret_key.py new file mode 100644 index 0000000000..555d697f25 --- /dev/null +++ b/tests/integrations/gnupg/test_GnuPG__export_secret_key.py @@ -0,0 +1,46 @@ +import subprocess +from pathlib import Path +from unittest import mock + +import pytest + +from briefcase.exceptions import BriefcaseCommandError +from briefcase.integrations.subprocess import Subprocess + +from .conftest import JANE + + +def test_export_secret_key(mock_tools, gpg): + """A secret key is exported to a file.""" + mock_tools.subprocess = mock.MagicMock(spec_set=Subprocess) + output_path = Path("/path/to/key.gpg") + + gpg.export_secret_key(JANE, output_path) + + mock_tools.subprocess.run.assert_called_once_with( + [ + "gpg", + "--batch", + "--output", + output_path, + "--export-secret-keys", + JANE, + ], + check=True, + ) + + +def test_export_secret_key_error(mock_tools, gpg): + """If the key can't be exported, an error is raised.""" + mock_tools.subprocess = mock.MagicMock(spec_set=Subprocess) + mock_tools.subprocess.run.side_effect = subprocess.CalledProcessError( + returncode=1, + cmd=["gpg", "--batch", "--export-secret-keys", JANE], + ) + output_path = Path("/path/to/key.gpg") + + with pytest.raises( + BriefcaseCommandError, + match=rf"Error exporting the GPG signing key for identity {JANE}\.", + ): + gpg.export_secret_key(JANE, output_path) diff --git a/tests/platforms/linux/system/signing/test_package_app.py b/tests/platforms/linux/system/signing/test_package_app.py index 7bdac11d4b..77cc879bdb 100644 --- a/tests/platforms/linux/system/signing/test_package_app.py +++ b/tests/platforms/linux/system/signing/test_package_app.py @@ -179,12 +179,12 @@ def test_package_app_unknown_format_signs(package_command, first_app, mock_gpg): package_command.sign_package.assert_not_called() -def test_signs_raises_in_docker( +def test_package_app_signs_in_docker( package_command, first_app, mock_gpg, ): - """Signing is not supported when building with Docker.""" + """If an identity is available, a Docker build is signed with it.""" first_app.packaging_format = "deb" mock_gpg.identities.return_value = { JANE: "Jane Doe ", @@ -196,23 +196,19 @@ def test_signs_raises_in_docker( # Accept the default selection (the single available identity) package_command.console.values = [""] - with pytest.raises( - BriefcaseCommandError, - match=r"Signing system packages is not supported when using Docker", - ): - package_command.package_app(first_app) + package_command.package_app(first_app) - package_command._package_deb.assert_not_called() - package_command._verify_signing_tool.assert_not_called() - package_command.sign_package.assert_not_called() + package_command._package_deb.assert_called_once_with(first_app) + package_command._verify_signing_tool.assert_called_once_with(first_app) + package_command.sign_package.assert_called_once_with(first_app, identity=JANE) -def test_explicit_identity_raises_in_docker( +def test_package_app_explicit_identity_in_docker( package_command, first_app, mock_gpg, ): - """An explicit identity is rejected when building with Docker.""" + """An explicit identity is used to sign a Docker build.""" first_app.packaging_format = "deb" mock_gpg.identities.return_value = { JANE: "Jane Doe ", @@ -223,15 +219,11 @@ def test_explicit_identity_raises_in_docker( package_command.sign_package = mock.MagicMock() package_command.target_image = "debian:bookworm" - with pytest.raises( - BriefcaseCommandError, - match=r"Signing system packages is not supported when using Docker", - ): - package_command.package_app(first_app, identity="jane@example.com") + package_command.package_app(first_app, identity="jane@example.com") - package_command._package_deb.assert_not_called() - package_command._verify_signing_tool.assert_not_called() - package_command.sign_package.assert_not_called() + package_command._package_deb.assert_called_once_with(first_app) + package_command._verify_signing_tool.assert_called_once_with(first_app) + package_command.sign_package.assert_called_once_with(first_app, identity=JANE) def test_package_app_dont_sign_in_docker(package_command, first_app, mock_gpg): diff --git a/tests/platforms/linux/system/signing/test_sign_package.py b/tests/platforms/linux/system/signing/test_sign_package.py index 3c8ff60bfc..7c54e14761 100644 --- a/tests/platforms/linux/system/signing/test_sign_package.py +++ b/tests/platforms/linux/system/signing/test_sign_package.py @@ -1,3 +1,4 @@ +import shlex import subprocess from pathlib import Path from unittest import mock @@ -5,10 +6,21 @@ import pytest from briefcase.exceptions import BriefcaseCommandError +from briefcase.integrations.docker import DockerAppContext from .conftest import JANE +def make_docker_context(package_command, first_app): + """Replace the app context with a Docker context with a mocked run method.""" + app_context = DockerAppContext(tools=package_command.tools, app=first_app) + app_context.run = mock.MagicMock() + package_command.tools[first_app].app_context = app_context + # Enable Docker for the command. + package_command.target_image = "somevendor:surprising" + return app_context + + def test_sign_deb_package(package_command, first_app): """A .deb package is signed with debsigs.""" first_app.packaging_format = "deb" @@ -86,3 +98,125 @@ def test_sign_package_error(package_command, first_app): match=r"Error while signing .deb package for first-app.", ): package_command.sign_package(first_app, identity=JANE) + + +@pytest.mark.parametrize( + ("format", "extension"), + [ + ("deb", "deb"), + ("rpm", "rpm"), + ("pkg", "pkg.tar.zst"), + ], +) +def test_sign_package_in_docker( + package_command, + first_app, + mock_gpg, + format, + extension, +): + """A package is signed inside a Docker container after importing the signing key. + + The sign command uses host paths; the Docker layer is responsible for rewriting them + to their container equivalents. + """ + first_app.packaging_format = format + dist_path = Path("/path/to/dist") / f"first-app.{extension}" + package_command.distribution_path = mock.MagicMock(return_value=dist_path) + if format == "deb": + sign_command = [ + "debsigs", + "--sign=origin", + f"--default-key={JANE}", + str(dist_path), + ] + elif format == "rpm": + sign_command = [ + "rpmsign", + "--define", + f"_gpg_name {JANE}", + "--addsign", + str(dist_path), + ] + else: + signature_path = Path(f"{dist_path}.sig") + package_command.signature_path = mock.MagicMock(return_value=signature_path) + sign_command = [ + "gpg", + "--detach-sign", + "-u", + JANE, + "--output", + str(signature_path), + str(dist_path), + ] + + make_docker_context(package_command, first_app) + + key_file_path = package_command.bundle_path(first_app) / "signing-key.gpg" + key_file_path.touch() + + package_command.sign_package(first_app, identity=JANE) + + mock_gpg.export_secret_key.assert_called_once_with(JANE, key_file_path) + package_command.tools.os.chmod.assert_called_once_with(key_file_path, 0o600) + + import_command = ["gpg", "--batch", "--import", str(key_file_path)] + command = " && ".join( + " ".join(shlex.quote(arg) for arg in cmd) + for cmd in [import_command, sign_command] + ) + package_command.tools[first_app].app_context.run.assert_called_once_with( + ["sh", "-c", command], + check=True, + mounts=[(package_command.dist_path, "/dist")], + ) + + # The exported key file is removed after signing. + assert not key_file_path.exists() + + +def test_sign_package_error_in_docker(package_command, first_app, mock_gpg): + """If signing inside Docker fails, an error is raised and the key file is + removed.""" + first_app.packaging_format = "deb" + package_command.distribution_path = mock.MagicMock( + return_value=Path("/path/to/dist/first-app.deb") + ) + app_context = make_docker_context(package_command, first_app) + app_context.run.side_effect = subprocess.CalledProcessError( + returncode=1, + cmd=["sh", "-c", "gpg --batch --import signing-key.gpg && debsigs"], + ) + + key_file_path = package_command.bundle_path(first_app) / "signing-key.gpg" + key_file_path.touch() + + with pytest.raises( + BriefcaseCommandError, + match=r"Error while signing .deb package for first-app.", + ): + package_command.sign_package(first_app, identity=JANE) + + # The exported key file is removed after signing. + assert not key_file_path.exists() + + +def test_sign_package_key_export_error_in_docker(package_command, first_app, mock_gpg): + """If the signing key can't be exported, an error is raised and the key file is + removed.""" + first_app.packaging_format = "deb" + package_command.distribution_path = mock.MagicMock( + return_value=Path("/path/to/dist/first-app.deb") + ) + make_docker_context(package_command, first_app) + mock_gpg.export_secret_key.side_effect = BriefcaseCommandError("boom") + + key_file_path = package_command.bundle_path(first_app) / "signing-key.gpg" + key_file_path.touch() + + with pytest.raises(BriefcaseCommandError, match=r"boom"): + package_command.sign_package(first_app, identity=JANE) + + package_command.tools[first_app].app_context.run.assert_not_called() + assert not key_file_path.exists() diff --git a/tests/platforms/linux/system/signing/test_verify_signing_tool.py b/tests/platforms/linux/system/signing/test_verify_signing_tool.py index 9192411236..903a405405 100644 --- a/tests/platforms/linux/system/signing/test_verify_signing_tool.py +++ b/tests/platforms/linux/system/signing/test_verify_signing_tool.py @@ -48,7 +48,30 @@ def test_verify_signing_tool_missing( BriefcaseCommandError, match=( rf"Can't find the {tool_name} tools. " - rf"Try running `sudo apt install {package_name}`." + rf"Try running `sudo apt install {package_name}`. " + r"Alternatively, use `--adhoc-sign` to skip signing the package." + ), + ): + package_command._verify_signing_tool(first_app) + + +def test_verify_signing_tool_missing_suse(package_command, first_app): + """On SUSE, the missing tool hint for rpmsign names the rpm-build package.""" + first_app.packaging_format = "rpm" + first_app.target_vendor_base = "suse" + package_command.tools[ + first_app + ].app_context.check_output.side_effect = subprocess.CalledProcessError( + returncode=1, + cmd=["sh", "-c", "command -v rpmsign"], + ) + + with pytest.raises( + BriefcaseCommandError, + match=( + r"Can't find the rpmsign tools. " + r"Try running `sudo zypper install rpm-build`\. " + r"Alternatively, use `--adhoc-sign` to skip signing the package." ), ): package_command._verify_signing_tool(first_app) @@ -68,7 +91,10 @@ def test_verify_signing_tool_missing_unknown_vendor(package_command, first_app): with pytest.raises( BriefcaseCommandError, - match=r"Can't find the debsigs tool. Install this first to sign the deb.", + match=( + r"Can't find the debsigs tool. Install this first to sign the deb. " + r"Alternatively, use `--adhoc-sign` to skip signing the package." + ), ): package_command._verify_signing_tool(first_app) diff --git a/tests/platforms/linux/system/test_mixin__finalize_app_config.py b/tests/platforms/linux/system/test_mixin__finalize_app_config.py index 6d9cb00ee8..26693138e8 100644 --- a/tests/platforms/linux/system/test_mixin__finalize_app_config.py +++ b/tests/platforms/linux/system/test_mixin__finalize_app_config.py @@ -289,12 +289,15 @@ def test_properties_unknown_basevendor(create_command, first_app_config): } # A different vendor and version that will be ignored first_app_config.ubuntu = { - "surprise_1": "YYYY", + "surprise_1": "ZZZZ", "jammy": { "surprise_1": "ZZZZ", }, } + # An explicit packaging format; the vendor base can't be resolved to one + first_app_config.packaging_format = "deb" + finalized_config = create_command.finalize_app_config(first_app_config) # The target's config attributes have been merged into the app @@ -643,3 +646,147 @@ def test_finalized_attrs(create_command, first_app_config): assert finalized_config.debugger is debugger assert finalized_config.debugger_host == "some-host" assert finalized_config.debugger_port == 8765 + + +@pytest.mark.parametrize( + ("os_release", "input_format", "output_format"), + [ + # System packaging maps to the format implied by the vendor base + ( + "ID=somevendor\nVERSION_CODENAME=surprising\nID_LIKE=debian\n", + "system", + "deb", + ), + ( + "ID=fedora\nVERSION_CODENAME=\nID_LIKE=rhel\n", + "system", + "rpm", + ), + ( + "ID=somevendor\nVERSION_CODENAME=surprising\nID_LIKE=suse\n", + "system", + "rpm", + ), + ( + "ID=cachyos\nVERSION_ID=20230625.0.160368\n", + "system", + "pkg", + ), + # An explicit packaging format is preserved, even if it doesn't match + # the vendor base + ( + "ID=somevendor\nVERSION_CODENAME=surprising\nID_LIKE=debian\n", + "rpm", + "rpm", + ), + ], +) +def test_packaging_format_resolution( + create_command, + first_app_config, + tmp_path, + os_release, + input_format, + output_format, +): + """If "system" packaging format was selected, it is resolved to the format implied + by the vendor base; explicit formats are preserved.""" + create_command.target_image = None + create_command.target_glibc_version = MagicMock(return_value="2.42") + + create_command.tools.platform.freedesktop_os_release = MagicMock( + return_value=parse_freedesktop_os_release(os_release) + ) + + first_app_config.packaging_format = input_format + + finalized_config = create_command.finalize_app_config(first_app_config) + + assert finalized_config.packaging_format == output_format + + +def test_packaging_format_resolution_absent(create_command, first_app_config, tmp_path): + """If no packaging format is specified, the format implied by the vendor base is + used.""" + create_command.target_image = None + create_command.target_glibc_version = MagicMock(return_value="2.42") + + create_command.tools.platform.freedesktop_os_release = MagicMock( + return_value=parse_freedesktop_os_release( + dedent( + """\ + ID=somevendor + VERSION_CODENAME=surprising + ID_LIKE=debian + """ + ) + ) + ) + + # No packaging format has been specified on the app configuration + + finalized_config = create_command.finalize_app_config(first_app_config) + + assert finalized_config.packaging_format == "deb" + + +@pytest.mark.parametrize( + ("target_image", "expected_format"), + [ + # Docker builds need a concrete format to determine the tools that + # must be installed in the target image + ( + "somevendor:surprising", + None, + ), + # Native builds don't need a packaging format until the app is packaged + ( + None, + "system", + ), + ], +) +def test_packaging_format_resolution_unknown_vendor( + create_command, + first_app_config, + tmp_path, + target_image, + expected_format, +): + """If the vendor base can't be determined, Docker builds raise an error; native + builds retain the unresolved "system" packaging format.""" + create_command.target_image = target_image + if target_image: + create_command.tools.docker = MagicMock() + create_command.tools.docker.check_output.return_value = dedent( + """\ + ID=somevendor + VERSION_CODENAME=surprising + """ + ) + create_command.target_glibc_version = MagicMock(return_value="2.42") + + if not target_image: + create_command.tools.platform.freedesktop_os_release = MagicMock( + return_value=parse_freedesktop_os_release( + dedent( + """\ + ID=somevendor + VERSION_CODENAME=surprising + """ + ) + ) + ) + + first_app_config.packaging_format = "system" + + if expected_format is None: + with pytest.raises( + BriefcaseCommandError, + match=r"Briefcase doesn't know the system packaging format for somevendor.", + ): + create_command.finalize_app_config(first_app_config) + else: + finalized_config = create_command.finalize_app_config(first_app_config) + + assert finalized_config.packaging_format == "system" diff --git a/tests/platforms/linux/system/test_mixin__verify.py b/tests/platforms/linux/system/test_mixin__verify.py index 2a64b39c6c..15c825b6ec 100644 --- a/tests/platforms/linux/system/test_mixin__verify.py +++ b/tests/platforms/linux/system/test_mixin__verify.py @@ -1,5 +1,7 @@ from unittest.mock import MagicMock +import pytest + import briefcase.platforms.linux.system from briefcase.integrations.docker import Docker, DockerAppContext from briefcase.integrations.subprocess import Subprocess @@ -116,6 +118,89 @@ def test_linux_docker(create_command, first_app_config, tmp_path, monkeypatch): create_command.verify_docker_python.assert_not_called() +@pytest.mark.parametrize( + ("vendor_base", "packaging_format", "expected_requires"), + [ + # The signing tool is added to the image requirements for a known format + ("debian", "deb", ["debsigs"]), + ("rhel", "rpm", ["rpm-sign"]), + ("arch", "pkg", ["gnupg"]), + # On SUSE, rpmsign is provided by rpm-build; there is no `rpm-sign` + # package + ("suse", "rpm", ["rpm-build"]), + # An unresolved "system" packaging format has no signing tool; format + # resolution happens during app config finalization. + ("basevendor", "system", []), + ], +) +def test_linux_docker_adds_signing_tool( + create_command, + first_app_config, + tmp_path, + monkeypatch, + vendor_base, + packaging_format, + expected_requires, +): + """If Docker is enabled on Linux, the signing tool is added to the image + requirements. + + This must happen during any command's app tool verification, because the Docker + image is built before the signing identity is selected; if the signing tool isn't in + the image, signing a package built with Docker will fail. + """ + create_command.tools.host_os = "Linux" + create_command.target_image = "somevendor:surprising" + create_command.extra_docker_build_args = [] + + # Force a dummy vendor:codename for test purposes. + first_app_config.target_vendor = "somevendor" + first_app_config.target_codename = "surprising" + first_app_config.target_vendor_base = vendor_base + first_app_config.packaging_format = packaging_format + first_app_config.python_version_tag = "3" + + # Mock Docker tool verification + mock__version_compat = MagicMock(spec=Docker._version_compat) + mock__user_access = MagicMock(spec=Docker._user_access) + mock__buildx_installed = MagicMock(spec=Docker._buildx_installed) + mock__is_user_mapping_enabled = MagicMock(spec=Docker._is_user_mapping_enabled) + monkeypatch.setattr( + briefcase.platforms.linux.system.Docker, + "_version_compat", + mock__version_compat, + ) + monkeypatch.setattr( + briefcase.platforms.linux.system.Docker, + "_user_access", + mock__user_access, + ) + monkeypatch.setattr( + briefcase.platforms.linux.system.Docker, + "_buildx_installed", + mock__buildx_installed, + ) + monkeypatch.setattr( + briefcase.platforms.linux.system.Docker, + "_is_user_mapping_enabled", + mock__is_user_mapping_enabled, + ) + mock_docker_app_context_verify = MagicMock(spec=DockerAppContext.verify) + monkeypatch.setattr( + briefcase.platforms.linux.system.DockerAppContext, + "verify", + mock_docker_app_context_verify, + ) + create_command.verify_docker_python = MagicMock() + + # Verify the tools + create_command.verify_tools() + create_command.verify_app_tools(app=first_app_config) + + # The signing tool has been added to the image requirements + assert getattr(first_app_config, "system_requires", None) == expected_requires + + def test_non_linux_docker(create_command, first_app_config, tmp_path, monkeypatch): """If Docker is enabled on non-Linux, the Docker alias is set.""" create_command.tools.host_os = "Darwin" diff --git a/tests/platforms/linux/system/test_package.py b/tests/platforms/linux/system/test_package.py index 32eddc7619..0cf492f87b 100644 --- a/tests/platforms/linux/system/test_package.py +++ b/tests/platforms/linux/system/test_package.py @@ -36,6 +36,30 @@ def test_formats(package_command): assert package_command.packaging_formats == ["deb", "rpm", "pkg", "system"] +def test_default_format(package_command): + """No default packaging format is defined; the app configuration determines the + format.""" + assert package_command.default_packaging_format is None + + +def test_verify_packaging_tools_unknown_format(package_command, first_app): + """An unresolved packaging format raises an error naming the vendor.""" + # Restore the real implementation of _verify_packaging_tools + del package_command._verify_packaging_tools + + first_app.packaging_format = "system" + + with pytest.raises( + BriefcaseCommandError, + match=( + r"Briefcase doesn't know the system packaging format for somevendor. " + r"You may be able to build a package by manually specifying a format " + r"with -p/--packaging-format" + ), + ): + package_command._verify_packaging_tools(first_app) + + @pytest.mark.parametrize( ("format", "vendor", "codename", "revision", "filename"), [ @@ -119,50 +143,62 @@ def test_build_env_abi_failure(package_command, first_app, format): @pytest.mark.parametrize( - ("base_vendor", "input_format", "output_format"), + ("base_vendor", "packaging_format", "expected_requires"), [ - # System packaging maps to known formats - ("debian", "system", "deb"), - ("rhel", "system", "rpm"), - ("arch", "system", "pkg"), - # Explicit output format is preserved - ("debian", "deb", "deb"), - ("redhat", "rpm", "rpm"), - ("arch", "pkg", "pkg"), - # This is technically possible, but probably ill-advised - ("debian", "rpm", "rpm"), - # Unknown base vendor, but explicit packaging format - (None, "deb", "deb"), - (None, "rpm", "rpm"), - (None, "pkg", "pkg"), + # Known formats add the signing tool for that format + ("debian", "deb", ["debsigs"]), + ("rhel", "rpm", ["rpm-sign"]), + ("arch", "pkg", ["gnupg"]), + # On SUSE, rpmsign is provided by rpm-build; there is no `rpm-sign` package + ("suse", "rpm", ["rpm-build"]), ], ) -def test_adjust_packaging_format( +def test_docker_packaging_format_adjusts_signing_tools( package_command, first_app, base_vendor, - input_format, - output_format, + packaging_format, + expected_requires, ): - """The packaging format can be adjusted based on host system knowledge.""" + """When using Docker, the signing tool is added to the image requirements.""" first_app.target_vendor_base = base_vendor - first_app.packaging_format = input_format + first_app.packaging_format = packaging_format + package_command.target_image = "somevendor:surprising" + package_command.extra_docker_build_args = [] + package_command.verify_docker_python = mock.MagicMock() + package_command.tools[first_app].app_context = mock.MagicMock() package_command.verify_app_tools(first_app) - assert first_app.packaging_format == output_format + assert getattr(first_app, "system_requires", []) == expected_requires -def test_unknown_packaging_format(package_command, first_app): - """An unknown packaging format raises an error.""" - first_app.target_vendor_base = None - first_app.packaging_format = "system" +def test_docker_packaging_format_signing_tools_are_not_duplicated( + package_command, + first_app, +): + """The signing tool is not added to the image requirements more than once.""" + first_app.target_vendor_base = "debian" + first_app.packaging_format = "deb" + package_command.target_image = "somevendor:surprising" + package_command.extra_docker_build_args = [] + package_command.verify_docker_python = mock.MagicMock() + package_command.tools[first_app].app_context = mock.MagicMock() - with pytest.raises( - BriefcaseCommandError, - match=r"Briefcase doesn't know the system packaging format for somevendor.", - ): - package_command.verify_app_tools(first_app) + package_command.verify_app_tools(first_app) + package_command.verify_app_tools(first_app) + + assert first_app.system_requires == ["debsigs"] + + +def test_native_packaging_does_not_add_signing_tools(package_command, first_app): + """The signing tool is not added to the host requirements when not using Docker.""" + first_app.target_vendor_base = "debian" + first_app.packaging_format = "deb" + + package_command.verify_app_tools(first_app) + + assert getattr(first_app, "system_requires", None) is None def test_package_deb_app(package_command, first_app, mock_gpg): diff --git a/tests/platforms/linux/system/test_package__deb.py b/tests/platforms/linux/system/test_package__deb.py index 69d282cc7d..a01d542ff9 100644 --- a/tests/platforms/linux/system/test_package__deb.py +++ b/tests/platforms/linux/system/test_package__deb.py @@ -154,6 +154,9 @@ def test_verify_docker(package_command, first_app_deb, monkeypatch): # dpkg_deb was not inspected dpkg_deb.exists.assert_not_called() + # The signing tool has been added to the image requirements + assert first_app_deb.system_requires == ["debsigs"] + @pytest.mark.skipif(sys.platform == "win32", reason="Can't build debs on Windows") def test_deb_package(package_command, first_app_deb, mock_gpg, tmp_path): diff --git a/tests/platforms/linux/system/test_package__pkg.py b/tests/platforms/linux/system/test_package__pkg.py index 78de4d619a..60f443f0dd 100644 --- a/tests/platforms/linux/system/test_package__pkg.py +++ b/tests/platforms/linux/system/test_package__pkg.py @@ -161,6 +161,9 @@ def test_verify_docker(package_command, first_app_pkg, monkeypatch): # makepkg was not inspected makepkg.exists.assert_not_called() + # The signing tool has been added to the image requirements + assert first_app_pkg.system_requires == ["gnupg"] + @pytest.mark.parametrize( "changelog_filename", diff --git a/tests/platforms/linux/system/test_package__rpm.py b/tests/platforms/linux/system/test_package__rpm.py index 891c983e47..74a6ebc974 100644 --- a/tests/platforms/linux/system/test_package__rpm.py +++ b/tests/platforms/linux/system/test_package__rpm.py @@ -161,6 +161,9 @@ def test_verify_docker(package_command, first_app_rpm, monkeypatch): # rpmbuild was not inspected rpmbuild.exists.assert_not_called() + # The signing tool has been added to the image requirements + assert first_app_rpm.system_requires == ["rpm-sign"] + @pytest.mark.parametrize( "changelog_filename", diff --git a/tests/platforms/linux/system/test_publish.py b/tests/platforms/linux/system/test_publish.py new file mode 100644 index 0000000000..314fd65749 --- /dev/null +++ b/tests/platforms/linux/system/test_publish.py @@ -0,0 +1,104 @@ +from unittest import mock + +import pytest + +from briefcase.channels.base import BasePublicationChannel +from briefcase.commands.base import full_options +from briefcase.platforms.linux.system import LinuxSystemPublishCommand + + +class DummyLinuxSystemPublishCommand(LinuxSystemPublishCommand): + """A publish command that tracks the package command invocations.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.actions = [] + + def package_command(self, app, **kwargs): + self.actions.append(("package", app.app_name, kwargs.copy())) + # Remove arguments consumed by the underlying call to package_app() + kwargs.pop("update", None) + kwargs.pop("packaging_format", None) + return full_options({"package_state": app.app_name}, kwargs) + + +@pytest.fixture +def publish_command(mock_tools, dummy_console, first_app, tmp_path): + command = DummyLinuxSystemPublishCommand( + console=dummy_console, + tools=mock_tools, + base_path=tmp_path / "base_path", + data_path=tmp_path / "briefcase", + ) + mock_tools.host_os = "Linux" + + # Run outside docker for these tests. + command.target_image = None + + return command + + +def test_default_format(publish_command): + """No default packaging format is defined; the app configuration determines the + format.""" + assert publish_command.default_packaging_format is None + + +@pytest.mark.parametrize( + ("packaging_format", "expected"), + [ + # If no packaging format is specified, the finalized packaging format on + # the app is retained + (None, "rpm"), + # An explicit format is annotated onto the app + ("deb", "deb"), + ], +) +def test_publish_app_packaging_format( + publish_command, + first_app, + packaging_format, + expected, + tmp_path, +): + """The packaging format requested on the command line is used; if none is given, the + app's finalized packaging format is preserved.""" + # The app has been finalized with a concrete packaging format. + first_app.packaging_format = "rpm" + + channel = mock.MagicMock(spec_set=BasePublicationChannel) + channel.publish_app.return_value = {"publish_state": "first-app"} + + # The distribution artefact doesn't exist, so packaging will be triggered. + publish_command.distribution_path = mock.MagicMock( + return_value=tmp_path / "base_path" / "dist" / f"first-app.{expected}" + ) + publish_command.verify_app = mock.MagicMock() + + state = publish_command._publish_app( + first_app, + update=False, + packaging_format=packaging_format, + channel=channel, + ) + + # The expected packaging format was annotated onto the app, and used when + # triggering the package command. + assert first_app.packaging_format == expected + assert publish_command.actions == [ + ( + "package", + "first-app", + {"update": False, "packaging_format": packaging_format}, + ) + ] + + # The app was published to the requested channel. + channel.publish_app.assert_called_once_with( + first_app, + command=publish_command, + package_state="first-app", + ) + + assert state == {"publish_state": "first-app"}