diff --git a/Cargo.lock b/Cargo.lock index 0888bd09..f8185faa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,6 +117,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -228,6 +237,12 @@ dependencies = [ "yaml-rust2", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-random" version = "0.1.18" @@ -278,6 +293,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -319,6 +343,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "deb822-derive" version = "0.2.0" @@ -378,6 +411,7 @@ dependencies = [ "anyhow", "clap", "config", + "deb822-lossless", "debian-changelog", "debian-control", "debmagic-common", @@ -385,8 +419,13 @@ dependencies = [ "glob", "ignore", "libc", + "md-5", "serde", "serde_json", + "sha1", + "sha2 0.11.0", + "toml", + "toml_edit", "uuid", ] @@ -418,8 +457,19 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", ] [[package]] @@ -472,6 +522,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "erased-serde" version = "0.4.9" @@ -571,6 +627,12 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hashlink" version = "0.10.0" @@ -586,6 +648,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "iana-time-zone" version = "0.1.64" @@ -728,6 +799,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -813,6 +894,16 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + [[package]] name = "memchr" version = "2.7.6" @@ -927,7 +1018,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf1d70880e76bdc13ba52eafa6239ce793d85c8e43896507e43dd8984ff05b82" dependencies = [ "pest", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -1133,6 +1224,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -1146,8 +1248,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -1308,10 +1421,12 @@ version = "0.9.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" dependencies = [ + "indexmap", "serde_core", "serde_spanned", "toml_datetime", "toml_parser", + "toml_writer", "winnow", ] @@ -1324,6 +1439,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.24.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c740b185920170a6d9191122cafef7010bd6270a3824594bff6784c04d7f09e" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_parser" version = "1.0.6+spec-1.1.0" @@ -1333,6 +1461,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "typeid" version = "1.0.3" @@ -1341,9 +1475,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" diff --git a/Cargo.toml b/Cargo.toml index 25c28d41..e7533308 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,12 +20,18 @@ glob = ">=0.3.2" libc = ">=0.2.169" serde = { version = ">=1.0.217", features = ["derive"] } serde_json = ">=1.0.139" +toml = ">=0.8" +toml_edit = ">=0.22" uuid = { version = ">=1.10.0", features = ["v4", "v5"] } chrono = { version = ">=0.4.42" } regex = { version = ">=1.12.2" } debian-changelog = { version = ">=0.2.14" } debian-control = { version = ">=0.1.39" } +deb822-lossless = { version = ">=0.2" } ignore = { version = ">=0.4.25" } +md-5 = { version = ">=0.11" } +sha1 = { version = ">=0.11" } +sha2 = { version = ">=0.11" } # dev dependencies test-case = { version = ">=3.3.1" } diff --git a/debian/control b/debian/control index fad3b267..026892aa 100644 --- a/debian/control +++ b/debian/control @@ -21,14 +21,20 @@ Build-Depends: librust-libc-dev (>=0.2.169), librust-serde-dev (>=1.0.217), librust-serde-json-dev (>=1.0.139), + librust-toml-dev (>=0.8), + librust-toml-edit-dev (>=0.22), librust-uuid-dev (>=1.10.0), librust-chrono-dev (>=0.4.42), librust-regex-dev (>=1.12.2), librust-test-case-dev (>=3.3.1), librust-pyo3-dev (>=0.27.2), + librust-deb822-lossless-dev (>=0.5.18), librust-debian-changelog-dev (>=0.2.14), librust-debian-control-dev (>= 0.1.39), - librust-ignore-dev (>=0.4.25) + librust-ignore-dev (>=0.4.25), + librust-md-5-dev (>= 0.11), + librust-sha1-dev (>= 0.11), + librust-sha2-dev (>= 0.11) Rules-Requires-Root: no X-Style: black Standards-Version: 4.7.2 diff --git a/docs/usage/build.md b/docs/usage/build.md index bf1bfe32..bc0f7767 100644 --- a/docs/usage/build.md +++ b/docs/usage/build.md @@ -26,7 +26,7 @@ debmagic build binary --driver lxd \ | `--incremental` | Do incremental builds by syncing changed sources only; implies `persistent` | | `--distro ` | [Select the target distro/release](#selecting-a-distrorelease) (e.g. `trixie`, `resolute`) | | `--proposed` | Use [build dependencies from `proposed`](#proposed-dependencies) pocket | -| `--sign` | [GPG-sign the resulting `.changes`/`.dsc`/`.buildinfo`](#signing) with `debsign` | +| `--sign` | [GPG-sign the resulting `.changes`/`.dsc`/`.buildinfo`](#signing) | | `--clean` | Run [`debian/rules clean` before building](#cleaning) | | `--debug-symbols` | [Build the automatic `-dbgsym` debug symbol packages](#building-debug-symbol-packages) | | `--apt-mirror ` | [Mirror URL](#mirror-selection) | @@ -141,25 +141,44 @@ Or set `build_debug_symbols = true` in the [`debmagic.toml`](config.md). ## Signing -`--sign` (plus optionally `--sign-key you@example.com`) GPG-signs the resulting `.changes`/`.dsc`/`.buildinfo` with `debsign` after building. -This is mainly useful for [source builds destined for Launchpad](source.md#uploading-to-launchpad), but works for binary builds too. -If your config file defaults to signing, pass `--no-sign` to skip it for one invocation. +`--sign` GPG-signs the resulting `.changes`/`.dsc`/`.buildinfo` after building — mainly useful for [source builds destined for Launchpad](source.md#uploading-to-launchpad), but works for binary builds too. +Signing is debmagic's own reimplementation of `debsign` and always runs on the host with your gpg keyring: the artifacts are exported to the host output dir first, so no container or agent forwarding is involved. +Children are signed first (`.dsc`, then `.buildinfo`) and the `.changes` checksums are rewritten after each, exactly like `debsign`. -Where `debsign` runs is selected by `--sign-with` (config: `sign_with`): +| Option | Config | Description | +|---|---|---| +| `--sign` | `sign.source` | Sign after building; `--sign=false` skips it for one invocation | +| `--sign-key ` | `sign.key` | Key ID/fingerprint/email; defaults to the `Changed-By:`/`Maintainer:` address of the file being signed | +| `--sign-tool ` | `sign.tool` | OpenPGP implementation: `gpg` (default), `sequoia` (sq), or `custom` | +| `--sign-command ` | `sign.command` | Custom signing command for `--sign-tool custom` (see below) | +| `--sign-notify` | `sign.notify` | Desktop notification + terminal bell just before signing, so a hardware-key touch prompt isn't missed after a long build | + +A custom signing command runs without a shell and must write the clearsigned result to stdout. +The file to sign is passed via the `{file}` placeholder (or, if no placeholder is used, as the last argument). -- `auto` (default): sign on the host if `debsign` is installed there, otherwise in a container (requires a container driver). -- `host`: always sign on the host, using your own gpg keyring — requires `devscripts` installed locally. -- `same`: sign inside a minimal same-distro container, forwarding the host's gpg-agent socket (`gpgconf --list-dirs agent-extra-socket`) into it. - Only signing *operations* cross the socket; private key material never enters the container, and only the public key is imported into its throwaway keyring. - Container signing requires an explicit `--sign-key`, since debsign's maintainer-based key lookup only works on the host. +| Placeholder | Expands to | +|---|---| +| `{file}` | Path of the file to sign | +| `{key}` | The resolved signing key | +| `{email}` | The bare address of the key | -Signing prerequisites (agent running, secret key available) are validated before the build starts, so a broken gpg setup fails fast instead of after the build. +Unknown placeholders are an error. Defaults can be set in [`debmagic.toml`](config.md). +## Signing an existing build + +`debmagic sign` signs a `.changes` file (and its `.dsc`/`.buildinfo` children) that already exists — the same code path `--sign` uses after a build: + +```shell +debmagic sign ../mypkg_1.0_amd64.changes +``` + +Without a file argument, it locates the `.changes` via `debian/changelog` and `--output`/`-o` (default: the `output_dir` config, `build/` under the package root), preferring the source-only `_source.changes` when several match. + ## Cleaning -`--clean` runs `debian/rules clean` before building, like plain `dpkg-buildpackage` does unless passed `-nc`; `--no-clean` skips it even if the config file defaults to cleaning. +`--clean` runs `debian/rules clean` before building, like plain `dpkg-buildpackage` does unless passed `-nc`; `--clean=false` skips it even if the config file defaults to cleaning. Non-incremental builds already stage a clean source tree, while incremental builds preserve outputs intentionally. Enable cleaning only for packages whose `clean` target performs required setup or code generation. diff --git a/docs/usage/config.md b/docs/usage/config.md index 9f08d291..c4c0c68d 100644 --- a/docs/usage/config.md +++ b/docs/usage/config.md @@ -18,23 +18,28 @@ Command-line flags override whatever the merged config resolves to. All keys are optional. -| Key | Type | Default | Description | -|---|---|---|---| -| `driver.persistent` | bool | `false` | Keep and reuse the build environment across runs instead of tearing it down. | -| `driver.apt_mirror` | string | — | Mirror used for build-dependency resolution. Not used by the `bare` driver. | -| `driver.proposed` | bool | `false` | Also enable the `-proposed` pocket. Not used by the `bare` driver. | -| `driver.docker.base_images` | map | — | Base image per distro, keyed by `":"` (e.g. `"debian:trixie"`). Falls back to `docker.io/:`. For non-Debian/Ubuntu suites (e.g. `"yocto:kirkstone"`), the map entry is what makes the suite a known DistroVersion for Docker builds. | -| `driver.lxd.project` | string | — | LXD/Incus project to use. `None` uses the default project. | -| `driver.lxd.base_images` | map | — | Base image per distro, keyed by `":"`. Falls back to the driver's default remote image. Same custom-suite registry role as Docker's map for LXD/Incus. | -| `temp_build_dir` | path | `/tmp/debmagic` | Where build trees are staged. | -| `incremental` | bool | `false` | Retain the environment and sync only source changes, preserving generated files. Binary-only; implies `persistent`; incompatible with `clean`. | -| `source_sync_mode` | enum | `tracked` | Which source files are staged (see below). | -| `build_debug_symbols` | bool | `false` | Build the automatic `-dbgsym` debug symbol package. | -| `sign_package` | bool | `false` | Sign the resulting `.changes`/`.dsc` with `debsign`. | -| `sign_with` | enum | `auto` | Where `debsign` runs (see below). | -| `sign_key` | string | — | GPG key ID/email for `debsign -k`. Required for container signing. | -| `clean` | bool | `false` | Run `debian/rules clean` before building. Disabled by default; incompatible with `incremental`. | -| `shell_on_failure` | bool | `false` | On build or test failure, drop into an interactive shell in the environment when stdout is a TTY. | +| Key | Type | Default | CLI flag | Description | +|---|---|---|---|---| +| `driver.default` | enum | — | `--driver` | Build driver (`docker`, `bare`, `lxd`, `incus`) | +| `driver.persistent` | bool | `false` | `--persistent` | Keep and reuse the build environment across runs instead of tearing it down. | +| `driver.apt_mirror` | string | — | `--apt-mirror` | Mirror used for build-dependency resolution. Not used by the `bare` driver. | +| `driver.proposed` | bool | `false` | `--proposed` | Also enable the `-proposed` pocket. Not used by the `bare` driver. | +| `driver.docker.base_images` | map | — | — | Base image per distro, keyed by `":"` (e.g. `"debian:trixie"`). Falls back to `docker.io/:`. For non-Debian/Ubuntu suites (e.g. `"yocto:kirkstone"`), the map entry is what makes the suite a known DistroVersion for Docker builds. | +| `driver.lxd.project` | string | — | — | LXD/Incus project to use. | +| `driver.lxd.base_images` | map | — | — | Base image per distro, keyed by `":"`. Falls back to the driver's default remote image. Same custom-suite registry role as Docker's map for LXD/Incus. | +| `temp_build_dir` | path | `/tmp/debmagic` | — | Where build trees are staged. | +| `output_dir` | path | `build` | `-o`/`--output-dir` | Where build artifacts are exported; relative paths resolve against the package root. | +| `incremental` | bool | `false` | `--incremental` | Retain the environment and sync only source changes, preserving generated files. Binary-only; implies `persistent`; incompatible with `clean`. | +| `source_sync_mode` | enum | `tracked` | `--source-sync` | Which source files are staged (see below). | +| `build_debug_symbols` | bool | `false` | `--debug-symbols` | Build the automatic `-dbgsym` debug symbol package. | +| `sign.source` | bool | `false` | `--sign` | Sign the resulting `.changes`/`.dsc` (see below). | +| `sign.key` | string | — | `--sign-key` | GPG key ID/email to sign with; falls back to the Changed-By/Maintainer address. | +| `sign.tool` | enum | `gpg` | `--sign-tool` | OpenPGP implementation: `gpg`, `sequoia` (sq) or `custom` (uses `sign.command`). | +| `sign.command` | string | — | `--sign-command` | Custom signing command for `sign.tool = "custom"`, run without a shell with `{file}`/`{key}`/`{email}` placeholders; writes the clearsigned result to stdout. | +| `sign.notify` | bool | `false` | `--sign-notify` | Send a desktop notification via `notify-send` just before signing, so a hardware-key touch prompt isn't missed. | +| `clean` | bool | `false` | `--clean` | Run `debian/rules clean` before building. Disabled by default; incompatible with `incremental`. | +| `shell_on_failure` | bool | `false` | `--shell-on-failure` | On build or test failure, drop into an interactive shell in the environment when stdout is a TTY. | +| `host_arch_variant` | string | — | `--host-arch-variant` | Build for a dpkg architecture variant (e.g. `"amd64v3"` on Ubuntu) -> `DEB_HOST_ARCH_VARIANT`. | ### `source_sync_mode` @@ -44,25 +49,32 @@ All keys are optional. | `committed` | Same files as `tracked`, but fails if the worktree has uncommitted changes or untracked files. | | `worktree` | Everything that isn't git-ignored, tracked or not. | -### `sign_with` +### `sign` -| Value | Behavior | -|---|---| -| `auto` (default) | Sign on the host if `debsign` is available there, otherwise in a container. | -| `host` | Always sign on the host with `debsign`. | -| `same` | Sign inside a minimal same-distro container, forwarding the host's gpg-agent socket. Requires `sign_key`. | +| Key | Type | Default | CLI flag | Description | +|---|---|---|---|---| +| `source` | bool | `false` | `--sign` | Sign the source package (`.changes`/`.dsc`) after building. | +| `key` | string | — | `--sign-key` | GPG key ID/email to sign with; falls back to the Changed-By/Maintainer address. | +| `tool` | enum | `gpg` | `--sign-tool` | OpenPGP implementation: `gpg`, `sequoia` (sq) or `custom` (uses `command`). | +| `command` | string | — | `--sign-command` | Custom signing command for `tool = "custom"`, like `debsign`'s `-p`. | +| `notify` | bool | `false` | `--sign-notify` | Desktop notification via `notify-send` before signing. | + +Signing always runs on the host with your gpg keyring — see [Signing](build.md#signing). ## Example ```toml build_debug_symbols = true -sign_package = true -sign_with = "same" -sign_key = "you@example.com or gpg key id" clean = false +[sign] +source = true +key = "you@example.com or gpg key id" +notify = true + [driver] +default = "lxd" persistent = true apt_mirror = "http:///ubuntu" diff --git a/docs/usage/source.md b/docs/usage/source.md index a2aad907..d1907e14 100644 --- a/docs/usage/source.md +++ b/docs/usage/source.md @@ -28,14 +28,14 @@ debmagic build source --sign --sign-key you@example.com \ dput ppa:your-lp-username/your-ppa /tmp/out/*_source.changes ``` -- `--sign` GPG-signs the `.dsc`/`.buildinfo`/`.changes` with `debsign` (from `devscripts`) after building. - By default it runs on the host; with `--sign-with same` (or `auto` when `debsign` isn't installed on the host) it runs in a minimal same-distro container with your gpg-agent socket forwarded in — see [Signing and cleaning](build.md#signing-and-cleaning). -- `--sign-key` picks which key/uid to sign with (`debsign`'s `-k`); omit it to let `debsign` fall back to its own maintainer-address lookup (host signing only). +- `--sign` GPG-signs the `.dsc`/`.buildinfo`/`.changes` after building, on the host with your gpg keyring — see [Signing](build.md#signing). +- `--sign-key` picks which key/uid to sign with; omit it to fall back to the `Changed-By:`/`Maintainer:` address of the file being signed. - Both can be set as defaults in `debian/debmagic.toml`/`$XDG_CONFIG_HOME/debmagic/config.toml` instead of passing them every time: ```toml - sign_package = true - sign_key = "you@example.com" + [sign] + source = true + key = "you@example.com" ``` ## What ends up in the source package diff --git a/justfile b/justfile new file mode 100644 index 00000000..1c6e3659 --- /dev/null +++ b/justfile @@ -0,0 +1,53 @@ +default: + @just --list + +# Build the whole Rust workspace +build: + cargo build + +# Run Rust unit tests +test: + cargo test + uv run pytest --ignore tests/integration . + +# Format Rust and Python code +fmt: + cargo fmt --all + uv run ruff format + +# Check formatting without writing +fmt-check: + cargo fmt --all --check + uv run ruff format --check + +# Lint Rust and Python code +lint: + cargo clippy --workspace --all-targets --all-features --locked -- -D warnings + uv run ruff check + +# Typecheck Python +typecheck: + uv run ty check . + +# Build the Sphinx documentation +docs: + uv run sphinx-build docs docs/_build + +# Live-preview the documentation +docs-live: + uv run sphinx-autobuild docs docs/_build + +# Everything CI runs (minus the self-packaging docker job) +ci: fmt-check lint typecheck test docs + +# Run pre-commit hooks on all files +pre-commit: + uv run pre-commit run --all-files + +# Build debmagic itself with the docker driver +self-build *args: + cargo run --locked -p debmagic -- build binary --driver=docker --persistent --incremental {{ args }} + +# Test debmagic itself with the docker driver +self-test *args: + cargo run --locked -p debmagic -- test --driver=docker {{ args }} diff --git a/packages/debmagic/Cargo.toml b/packages/debmagic/Cargo.toml index 622a9516..8ee261cc 100644 --- a/packages/debmagic/Cargo.toml +++ b/packages/debmagic/Cargo.toml @@ -17,7 +17,13 @@ glob = { workspace = true } libc = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +toml = { workspace = true } +toml_edit = { workspace = true } uuid = { workspace = true, features = ["v4"] } debian-changelog = { workspace = true } debian-control = { workspace = true } +deb822-lossless = { workspace = true } ignore = { workspace = true } +md-5 = { workspace = true } +sha1 = { workspace = true } +sha2 = { workspace = true } diff --git a/packages/debmagic/README.md b/packages/debmagic/README.md index d6a46204..154e4c10 100644 --- a/packages/debmagic/README.md +++ b/packages/debmagic/README.md @@ -57,7 +57,7 @@ Use `--strict` to fail on skipped or undeclared tests (exit code 2). The bare dr - `--distro ` — select the target distro/release (e.g. `trixie`, `noble`) if the changelog is ambiguous - `--persistent` — retain the build environment for repeated attempts - `--incremental` — sync only changed sources for faster rebuilds; implies `--persistent` -- `--sign` — GPG-sign the resulting `.changes`/`.dsc`/`.buildinfo` with `debsign` +- `--sign` — GPG-sign the resulting `.changes`/`.dsc`/`.buildinfo` - `--apt-mirror ` — use a faster mirror for build-dependency resolution Any of these can be persisted in a `debmagic.toml` config file instead of repeating CLI flags. diff --git a/packages/debmagic/src/build/mod.rs b/packages/debmagic/src/build/mod.rs index ebf7cf23..95043007 100644 --- a/packages/debmagic/src/build/mod.rs +++ b/packages/debmagic/src/build/mod.rs @@ -10,13 +10,13 @@ use crate::build::attach::{send_socket_command, start_socket_server}; use crate::build::source::{source_manifest_path, stage_source_tree}; use crate::build_intent::BuildIntent; use crate::driver::{ - Driver, DriverInstance, DriverType, Environment, EnvironmentMetadata, EnvironmentPurpose, - config::DriverConfig, create_driver, create_driver_from_metadata, remove_environment_root, + Driver, DriverType, Environment, EnvironmentDriver, EnvironmentMetadata, EnvironmentPurpose, + SignRequest, config::DriverConfig, create_driver, create_driver_from_metadata, + remove_environment_root, }; use crate::{ config::Config, package::{PackageIdentity, PackageTarget}, - signing::{self, SignWith}, }; use anyhow::{Context, anyhow}; @@ -28,74 +28,13 @@ pub use source::SourceSyncMode; struct Build { environment: Environment, - driver: DriverInstance, - /// Prepared when signing happens inside a container: agent socket + - /// sign key, validated before the build starts. - gpg_forwarding: Option, + driver: Driver, attached: bool, output_dir: PathBuf, sign_package: bool, clean: bool, build_debug_symbols: bool, -} - -/// Where debsign will actually run for this build. -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -enum SignLocation { - Host, - Container, -} - -/// Resolve the effective sign location and validate everything signing will -/// need *before* the build starts, so a broken gpg setup doesn't waste a -/// whole build. -fn prepare_signing( - environment: &Environment, - sign_with: SignWith, - sign_key: Option<&str>, -) -> anyhow::Result<(SignLocation, Option)> { - let container_driver = environment.driver != DriverType::Bare; - let host_has_debsign = signing::check_host_debsign_available().is_ok(); - - let location = match sign_with { - SignWith::Host => SignLocation::Host, - SignWith::Same => { - if container_driver { - SignLocation::Container - } else { - // The bare driver's "build environment" is the host. - SignLocation::Host - } - } - SignWith::Auto => { - if host_has_debsign || !container_driver { - SignLocation::Host - } else { - SignLocation::Container - } - } - }; - - match location { - SignLocation::Host => { - signing::check_host_debsign_available()?; - Ok((location, None)) - } - SignLocation::Container => { - let sign_key = sign_key.ok_or_else(|| { - anyhow!( - "signing in a container requires sign_key to be set \ - (debsign's maintainer-based key lookup only works on the host)" - ) - })?; - let forwarding = signing::GpgForwarding { - agent_extra_socket: signing::gpg_agent_extra_socket()?, - sign_key: sign_key.to_string(), - }; - signing::check_signing_key_available(sign_key)?; - Ok((location, Some(forwarding))) - } - } + host_arch_variant: Option, } impl Build { @@ -106,25 +45,15 @@ impl Build { &intent.driver_overrides, ) .context(format!("failed to create {:?} driver", environment.driver))?; - let gpg_forwarding = if intent.config.sign_package { - let (_location, forwarding) = prepare_signing( - &environment, - intent.config.sign_with, - intent.config.sign_key.as_deref(), - )?; - forwarding - } else { - None - }; Ok(Self { environment, driver, - gpg_forwarding, attached: false, output_dir: intent.output_dir.clone(), - sign_package: intent.config.sign_package, + sign_package: intent.config.sign.source, clean: intent.config.clean, build_debug_symbols: intent.config.build_debug_symbols, + host_arch_variant: intent.config.host_arch_variant.clone(), }) } @@ -156,7 +85,6 @@ impl Build { let attached = send_socket_command(build_root, "attach").is_ok(); Ok(Self { - gpg_forwarding: None, environment: metadata.environment.clone(), driver, attached, @@ -164,6 +92,7 @@ impl Build { sign_package: false, clean: false, build_debug_symbols: false, + host_arch_variant: None, }) } @@ -250,6 +179,7 @@ fn prepare_build_env(intent: &BuildIntent, target: &PackageTarget) -> anyhow::Re .create_dirs() .context("failed to create build directories")?; + crate::output::step("Staging source tree"); stage_source_tree( &environment, &target.identity, @@ -302,6 +232,13 @@ fn run_build( request: &BuildRequest, build_commands: impl FnOnce(&Build) -> anyhow::Result<()>, ) -> anyhow::Result<()> { + let sign = &request.intent.config.sign; + + let package = &request.target.identity; + crate::output::stage(&format!( + "Preparing build environment for {} {}", + package.name, package.version + )); let build = prepare_build_env(request.intent, request.target) .context("failed to prepare build environment")?; build @@ -320,14 +257,20 @@ fn run_build( socket_server_handle.join().ok(); }; - let sign_key = request.intent.config.sign_key.as_deref(); let result = build_commands(&build).and_then(|()| { + crate::output::stage("Exporting artifacts"); let changes_file = artifacts::export_build_artifacts(&build.environment.work_dir(), &build.output_dir)?; if build.sign_package { - build - .driver - .sign_changes(&changes_file, build.gpg_forwarding.as_ref(), sign_key)?; + crate::output::stage(&format!("Signing {}", build.environment.package_identifier)); + build.driver.sign_changes(&SignRequest { + changes_file: &changes_file, + sign_key: sign.key.as_deref(), + sign_tool: sign.tool, + sign_command: sign.command.as_deref(), + notify: sign.notify, + package: &build.environment.package_identifier, + })?; } Ok(()) }); @@ -368,6 +311,16 @@ fn run_build( pub fn build_package(intent: &BuildIntent, target: &PackageTarget) -> anyhow::Result<()> { let request = BuildRequest { intent, target }; run_build(&request, |build| { + crate::output::stage("Building binary packages"); + // build-essential is an implicit dependency that `apt-get build-dep` + // won't resolve, so install it explicitly. No-op when the environment + // already has it (idempotent, and the bare driver runs on the host). + build.driver.run_command_checked( + &["apt-get", "-y", "install", "build-essential"], + &build.environment.staged_source_dir(), + true, + &[], + )?; build.driver.run_command_checked( &["apt-get", "-y", "build-dep", "."], &build.environment.staged_source_dir(), @@ -376,7 +329,10 @@ pub fn build_package(intent: &BuildIntent, target: &PackageTarget) -> anyhow::Re )?; let inherited_options = std::env::var("DEB_BUILD_OPTIONS").ok(); let options = deb_build_options(inherited_options.as_deref(), build.build_debug_symbols); - let env_add = [("DEB_BUILD_OPTIONS", options.as_str())]; + let mut env_add = vec![("DEB_BUILD_OPTIONS", options.as_str())]; + if let Some(variant) = build.host_arch_variant.as_deref() { + env_add.push(("DEB_HOST_ARCH_VARIANT", variant)); + } let mut dpkg_buildpackage_args = vec!["dpkg-buildpackage", "-us", "-uc", "-ui"]; if !build.clean { // Non-incremental builds already stage a clean source tree, while @@ -429,6 +385,7 @@ pub fn build_source_package(intent: &BuildIntent, target: &PackageTarget) -> any let request = BuildRequest { intent, target }; run_build(&request, |build| { + crate::output::stage("Building source package"); let staged_source_dir = build.environment.staged_source_dir(); if build.clean { build.driver.run_command_checked( diff --git a/packages/debmagic/src/build_intent.rs b/packages/debmagic/src/build_intent.rs index 088bab6a..5b2d9d86 100644 --- a/packages/debmagic/src/build_intent.rs +++ b/packages/debmagic/src/build_intent.rs @@ -1,4 +1,4 @@ -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use anyhow::Context; @@ -6,7 +6,7 @@ use crate::{ build::source::SourceSyncMode, config::Config, driver::{DriverType, config::DriverOverrides}, - signing::SignWith, + sign::SignTool, }; /// Clap-free inputs for resolving a [`BuildIntent`]. @@ -20,16 +20,15 @@ pub struct BuildIntentInput { pub driver: DriverType, pub persistent: Option, pub incremental: Option, - /// Force incremental off (e.g. source-only builds). - pub disable_incremental: bool, pub debug_symbols: Option, pub sign: Option, - pub no_sign: Option, - pub sign_with: Option, pub sign_key: Option, + pub sign_tool: Option, + pub sign_command: Option, + pub sign_notify: Option, pub clean: Option, - pub no_clean: Option, pub source_sync: Option, + pub host_arch_variant: Option, pub shell_on_failure: Option, pub driver_overrides: DriverOverrides, } @@ -47,49 +46,24 @@ pub struct BuildIntent { pub driver_overrides: DriverOverrides, } -/// Precedence of config files is: -/// -/// 1. explicit config file passed on the command line -/// 2. `/debian/debmagic.toml` -/// 3. `/debmagic/config.toml` -pub fn load_config( - source_dir: Option<&Path>, - config_file: Option<&Path>, -) -> anyhow::Result { - let mut config_file_paths = vec![]; - let xdg_config_file = dirs::config_dir().map(|p| p.join("debmagic").join("config.toml")); - if let Some(xdg_config_file) = xdg_config_file - && xdg_config_file.is_file() - { - config_file_paths.push(xdg_config_file); - } - - if let Some(source_dir) = source_dir { - config_file_paths.push(source_dir.join("debian").join("debmagic.toml")); - } - - if let Some(config_file) = config_file { - config_file_paths.push(config_file.to_path_buf()); - } - - Config::new(&config_file_paths) -} - pub fn resolve_build_intent(input: BuildIntentInput) -> anyhow::Result { let source_dir = std::path::absolute(input.source_dir.unwrap_or(input.fallback_dir.clone())) .context("resolving source dir failed")?; - let output_dir = std::path::absolute(input.output_dir.unwrap_or(input.fallback_dir)) - .context("resolving output dir failed")?; - let mut config = load_config(Some(&source_dir), input.config_file.as_deref())?; + let mut config = Config::load(Some(&source_dir), input.config_file.as_deref())?; + + // CLI -o wins; else the config value, relative to the package root. + let output_dir = match input.output_dir { + Some(dir) => std::path::absolute(dir).context("resolving output dir failed")?, + None => std::path::absolute(source_dir.join(&config.output_dir)) + .context("resolving output dir failed")?, + }; if let Some(persistent) = input.persistent { config.driver.persistent = persistent; } - if input.disable_incremental { - config.incremental = false; - } else if let Some(incremental) = input.incremental { + if let Some(incremental) = input.incremental { config.incremental = incremental; } @@ -97,26 +71,29 @@ pub fn resolve_build_intent(input: BuildIntentInput) -> anyhow::Result anyhow::Result<()> { - let cfg = load_config(None, Some(&asset_config()))?; + let cfg = Config::load(None, Some(&asset_config()))?; assert!(cfg.driver.persistent); assert_eq!( cfg.driver.docker.base_images.get("debian:trixie"), @@ -273,7 +250,8 @@ mod tests { assert!(intent.source_dir.is_absolute()); assert!(intent.output_dir.is_absolute()); assert_eq!(intent.source_dir, std::path::absolute(&dir)?); - assert_eq!(intent.output_dir, std::path::absolute(&dir)?); + // default output dir is build/, relative to the package root + assert_eq!(intent.output_dir, std::path::absolute(dir.join("build"))?); Ok(()) } @@ -293,16 +271,4 @@ mod tests { .contains("incremental builds are incompatible with clean builds") ); } - - #[test] - fn resolve_disable_incremental_for_source_builds() -> anyhow::Result<()> { - let dir = std::env::temp_dir(); - let mut input = base_input(dir); - input.incremental = Some(true); - input.disable_incremental = true; - - let intent = resolve_build_intent(input)?; - assert!(!intent.config.incremental); - Ok(()) - } } diff --git a/packages/debmagic/src/cli.rs b/packages/debmagic/src/cli.rs index b0838237..4d0273d0 100644 --- a/packages/debmagic/src/cli.rs +++ b/packages/debmagic/src/cli.rs @@ -2,14 +2,37 @@ use std::path::PathBuf; use crate::build::source::SourceSyncMode; use crate::driver::DriverType; +use crate::sign::SignTool; use clap::{Args, Parser, Subcommand}; +/// When to use colored output. Mirrors common CLI conventions; `auto` is the +/// default and respects the `NO_COLOR` environment variable. +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, clap::ValueEnum)] +pub enum ColorChoice { + /// Color when stderr is a terminal and `NO_COLOR` is unset. + #[default] + Auto, + /// Always color, even when piped or `NO_COLOR` is set. + Always, + /// Never color. + Never, +} + #[derive(Parser, Debug)] #[command(version, about, long_about = None)] pub struct Cli { #[arg(short, long, help = "Path to config file")] pub config: Option, + #[arg( + long, + global = true, + value_enum, + default_value_t = ColorChoice::Auto, + help = "When to colorize output: 'auto' (default) colors on a terminal and respects NO_COLOR, 'always' forces color, 'never' disables it" + )] + pub color: ColorChoice, + #[command(subcommand)] pub command: Commands, } @@ -24,10 +47,67 @@ pub enum Commands { Test(TestSubcommandArgs), #[command(about = "Check the project")] Check(CheckSubcommandArgs), + #[command(about = "GPG-sign a .changes file (and its .dsc/.buildinfo) on the host")] + Sign(SignSubcommandArgs), + #[command(about = "Inspect the debmagic configuration")] + Config(ConfigSubcommandArgs), #[command(about = "Show version information")] Version {}, } +#[derive(Args, Debug)] +pub struct ConfigSubcommandArgs { + #[command(subcommand)] + pub command: ConfigCommands, +} + +#[derive(Subcommand, Debug)] +pub enum ConfigCommands { + #[command( + about = "Print the effective config as TOML, and which config file paths were considered" + )] + Show(ConfigShowSubcommandArgs), + #[command(about = "Print a single config value, addressed by dotted key (e.g. 'sign.key')")] + Get(ConfigGetSubcommandArgs), + #[command(about = "Set a single config value, addressed by dotted key (e.g. 'sign.key ROFL')")] + Set(ConfigSetSubcommandArgs), +} + +#[derive(Args, Debug)] +pub struct ConfigShowSubcommandArgs { + #[command(flatten)] + pub common: CommonCli, +} + +#[derive(Args, Debug)] +pub struct ConfigGetSubcommandArgs { + #[arg(help = "Config key, dotted path like 'sign.key' or 'driver.default'")] + pub key: String, + + #[command(flatten)] + pub common: CommonCli, +} + +#[derive(Args, Debug)] +pub struct ConfigSetSubcommandArgs { + #[arg(help = "Config key, dotted path like 'sign.key' or 'driver.default'")] + pub key: String, + + #[arg( + help = "Value to set; parsed as TOML when valid (true, 3, [\"a\"]), else treated as a string" + )] + pub value: String, + + #[arg( + long, + help = "Write to the user-wide config file instead of the project's debian/debmagic.toml" + )] + pub global: bool, + + #[command(flatten)] + pub common: CommonCli, +} + #[derive(Args, Debug)] pub struct CommonCli { #[arg( @@ -70,11 +150,27 @@ pub struct CommonBuildArgs { #[arg( short, long, - help = "Build driver type. Required for binary builds; source-only builds default to 'bare', since those need no build-deps or compilation." + num_args = 0..=1, + default_missing_value = "true", + action = clap::ArgAction::Set, + help = "Synchronize changed source inputs while preserving build outputs. Implies --persistent" + )] + pub incremental: Option, + + #[arg( + short, + long, + help = "Build driver type. Defaults to the 'driver' key in debmagic.toml; without either, source-only builds use 'bare', since those need no build-deps or compilation." )] pub driver: Option, - #[arg(long, action = clap::ArgAction::SetTrue, help = "Keep the build environment for reuse after the build finishes")] + #[arg( + long, + num_args = 0..=1, + default_missing_value = "true", + action = clap::ArgAction::Set, + help = "Keep the build environment for reuse after the build finishes" + )] pub persistent: Option, #[arg( @@ -97,7 +193,9 @@ pub struct CommonBuildArgs { #[arg( long, - action = clap::ArgAction::SetTrue, + num_args = 0..=1, + default_missing_value = "true", + action = clap::ArgAction::Set, help = "Also enable the '-proposed' pocket in the build environment. Ignored by the bare driver." )] pub proposed: Option, @@ -107,46 +205,61 @@ pub struct CommonBuildArgs { help = "Select the target distribution version, only required if the debian changelog specifies multiple versions" )] pub distro: Option, + #[arg( + long = "host-arch-variant", + help = "Build for a dpkg architecture variant (e.g. 'amd64v3' on Ubuntu), like dpkg-buildpackage's --host-arch-variant. Sets DEB_HOST_ARCH_VARIANT for the build, which makes the Ubuntu vendor hook append the variant's -march= flags and names the .changes file after the variant. Defaults to the 'host_arch_variant' setting in the config file." + )] + pub host_arch_variant: Option, + // NOTE: Option flags use ArgAction::Set with default_missing_value + // for tri-state parsing (None when absent) — SetTrue/SetFalse force an + // implicit Some(false)/Some(true) default that would always override the + // config file. #[arg( long, - action = clap::ArgAction::SetTrue, - help = "Sign the resulting .changes/.dsc with debsign after building. Defaults to the 'sign_package' setting in the config file (false if unset)." + num_args = 0..=1, + default_missing_value = "true", + value_parser = clap::value_parser!(bool), + help = "Sign the resulting .changes/.dsc after building. Defaults to the 'sign.source' setting in the config file (false if unset)." )] pub sign: Option, #[arg( - long, - action = clap::ArgAction::SetFalse, - help = "Do not sign the resulting .changes/.dsc, overriding a 'sign_package = true' default in the config file." + long = "sign-key", + help = "GPG key ID/email to sign with. Defaults to the 'sign.key' setting in the config file, or the Changed-By/Maintainer address of the file being signed if unset." )] - pub no_sign: Option, + pub sign_key: Option, #[arg( - long = "sign-with", - help = "Where debsign runs: 'host' signs on the host (requires devscripts there), 'same' signs inside a minimal same-distro container with the host gpg-agent socket forwarded in (requires --sign-key), 'auto' (default) uses the host if debsign is available there, else a container. Defaults to the 'sign_with' setting in the config file." + long = "sign-tool", + value_enum, + help = "OpenPGP implementation to sign with: 'gpg' (default), 'sequoia' (sq), or 'custom' (uses --sign-command). Defaults to the 'sign.tool' setting in the config file." )] - pub sign_with: Option, + pub sign_tool: Option, #[arg( - long = "sign-key", - help = "GPG key ID/email to sign with, passed to debsign's -k option. Defaults to the 'sign_key' setting in the config file, or debsign's own maintainer-based key lookup if unset. Required when signing in a container." + long = "sign-command", + help = "Custom signing command for --sign-tool custom, run without a shell. Supports {file}, {key} and {email} placeholders; writes the clearsigned result to stdout. Defaults to the 'sign.command' setting in the config file." )] - pub sign_key: Option, + pub sign_command: Option, #[arg( - long, - action = clap::ArgAction::SetTrue, - help = "Run 'debian/rules clean' before building, like plain dpkg-buildpackage does unless passed -nc. Defaults to the 'clean' setting in the config file (false if unset); non-incremental builds already stage a clean source tree, while incremental builds preserve outputs by design. For source builds this also installs build-dependencies first, since a clean target usually needs its own tooling." + long = "sign-notify", + num_args = 0..=1, + default_missing_value = "true", + value_parser = clap::value_parser!(bool), + help = "Send a desktop notification via notify-send just before signing, so a hardware-key touch prompt isn't missed. Defaults to the 'sign.notify' setting in the config file (false if unset)." )] - pub clean: Option, + pub sign_notify: Option, #[arg( long, - action = clap::ArgAction::SetFalse, - help = "Do not run 'debian/rules clean' before building, overriding a 'clean = true' default in the config file." + num_args = 0..=1, + default_missing_value = "true", + value_parser = clap::value_parser!(bool), + help = "Run 'debian/rules clean' before building, like plain dpkg-buildpackage does unless passed -nc. Defaults to the 'clean' setting in the config file (false if unset); non-incremental builds already stage a clean source tree, while incremental builds preserve outputs by design. For source builds this also installs build-dependencies first, since a clean target usually needs its own tooling." )] - pub no_clean: Option, + pub clean: Option, #[arg( long = "shell-on-failure", @@ -183,12 +296,11 @@ pub struct BinaryTargetArgs { #[command(flatten)] pub build: CommonBuildArgs, - #[arg(short, long, action = clap::ArgAction::SetTrue, help = "Synchronize changed source inputs while preserving build outputs. Implies --persistent")] - pub incremental: Option, - #[arg( long = "debug-symbols", - action = clap::ArgAction::SetTrue, + num_args = 0..=1, + default_missing_value = "true", + action = clap::ArgAction::Set, help = "Also build the automatic '-dbgsym' debug symbol package" )] pub debug_symbols: Option, @@ -279,3 +391,49 @@ pub struct CheckSubcommandArgs { #[command(flatten)] pub common: CommonCli, } + +#[derive(Args, Debug)] +pub struct SignSubcommandArgs { + #[arg( + long = "sign-key", + help = "GPG key ID/email to sign with. Defaults to the 'sign.key' setting in the config file, or the Changed-By/Maintainer address of the file being signed if unset." + )] + pub sign_key: Option, + + #[arg( + long = "sign-tool", + value_enum, + help = "OpenPGP implementation to sign with: 'gpg' (default), 'sequoia' (sq), or 'custom' (uses --sign-command). Defaults to the 'sign.tool' setting in the config file." + )] + pub sign_tool: Option, + + #[arg( + long = "sign-command", + help = "Custom signing command for --sign-tool custom, run without a shell. Supports {file}, {key} and {email} placeholders; writes the clearsigned result to stdout. Defaults to the 'sign.command' setting in the config file." + )] + pub sign_command: Option, + + #[arg( + long = "sign-notify", + num_args = 0..=1, + default_missing_value = "true", + value_parser = clap::value_parser!(bool), + help = "Send a desktop notification via notify-send just before signing, so a hardware-key touch prompt isn't missed. Defaults to the 'sign.notify' setting in the config file (false if unset)." + )] + pub sign_notify: Option, + + #[arg( + short, + long, + help = "Directory holding the .changes file when no file is given (default '..')." + )] + pub output_dir: Option, + + #[command(flatten)] + pub common: CommonCli, + + #[arg( + help = "The .changes, .buildinfo or .dsc file to sign; when omitted, located via debian/changelog and --output" + )] + pub file: Option, +} diff --git a/packages/debmagic/src/config.rs b/packages/debmagic/src/config.rs index 9b6fc4b2..73d538b0 100644 --- a/packages/debmagic/src/config.rs +++ b/packages/debmagic/src/config.rs @@ -1,32 +1,163 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crate::build::source::SourceSyncMode; use crate::driver::config::DriverConfig; -use crate::signing::SignWith; +use crate::sign::SignTool; use anyhow::{Context, anyhow}; use config::{Config as ConfigBuilder, File}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; + +/// Outcome of looking at one candidate config file location. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfigPathStatus { + /// The file exists and was loaded. + Used, + /// The location was considered but no file exists there. + NotFound, +} + +/// The user-global config file. `DEBMAGIC_CONFIG_GLOBAL` overrides the +/// default XDG location (like git's `GIT_CONFIG_GLOBAL`); `/dev/null` +/// disables it. +fn global_config_path() -> anyhow::Result { + if let Ok(path) = std::env::var("DEBMAGIC_CONFIG_GLOBAL") { + return Ok(PathBuf::from(path)); + } + + dirs::config_dir() + .map(|p| p.join("debmagic").join("config.toml")) + .context("cannot determine the user config directory") +} + +/// The in-package config file, always `/debian/debmagic.toml`. +fn project_config_path(source_dir: &Path) -> PathBuf { + source_dir.join("debian").join("debmagic.toml") +} + +/// The explicit `-c` config file; an error if it does not exist. +fn explicit_config_path(config_file: &Path) -> anyhow::Result { + if !config_file.is_file() { + anyhow::bail!("config file '{}' does not exist", config_file.display()); + } + Ok(ConfigPath::new( + ConfigLayer::Explicit, + config_file.to_path_buf(), + ConfigPathStatus::Used, + )) +} + +/// Which config layer a candidate file belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfigLayer { + Global, + Project, + Explicit, +} + +/// A candidate config file location and whether it was loaded. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigPath { + pub layer: ConfigLayer, + pub path: PathBuf, + pub status: ConfigPathStatus, +} + +impl ConfigPath { + fn new(layer: ConfigLayer, path: PathBuf, status: ConfigPathStatus) -> Self { + Self { + layer, + path, + status, + } + } + + pub fn is_used(&self) -> bool { + self.status == ConfigPathStatus::Used + } + + /// Write `key = value` into this file, preserving comments, formatting + /// and all other entries. The value is parsed as TOML when valid, + /// else treated as a string. + pub fn set_value(&self, key: &str, value: &str) -> anyhow::Result<()> { + let parsed: toml_edit::Value = match value.parse::() { + Ok(value) => value, + Err(_) => toml_edit::Value::from(value), + }; + + let contents = std::fs::read_to_string(&self.path).unwrap_or_default(); + let mut doc = contents + .parse::() + .with_context(|| format!("parsing config file '{}' failed", self.path.display()))?; + + let parts: Vec<&str> = key.split('.').collect(); + let (leaf, parents) = parts.split_last().context("config key must not be empty")?; + + let mut current = doc.as_table_mut(); + for part in parents { + current = current + .entry(part) + .or_insert(toml_edit::Item::Table(toml_edit::Table::new())) + .as_table_mut() + .with_context(|| format!("config key '{key}' collides with a non-table value"))?; + } + current.insert(leaf, toml_edit::value(parsed)); + + std::fs::write(&self.path, doc.to_string()) + .with_context(|| format!("writing config file '{}' failed", self.path.display()))?; + Ok(()) + } +} + +/// Config file that `set` should target, selected from the candidate +/// locations: the explicit `-c` file, else with `global` the user-global +/// config file (created on demand), else the project `debian/debmagic.toml` +/// if it exists, else the user-global config file. +pub fn resolve_set_target( + source_dir: Option<&Path>, + config_file: Option<&Path>, + global: bool, +) -> anyhow::Result { + let paths = Config::resolve_paths(source_dir, config_file)?; + + if let Some(explicit) = paths.iter().find(|p| p.layer == ConfigLayer::Explicit) { + return Ok(explicit.clone()); + } + + if !global + && let Some(project) = paths + .iter() + .find(|p| p.layer == ConfigLayer::Project && p.is_used()) + { + return Ok(project.clone()); + } + + let global_config = paths + .iter() + .find(|p| p.layer == ConfigLayer::Global) + .context("the user-global config location is unknown")?; + Ok(ConfigPath::new( + ConfigLayer::Global, + global_config.path.clone(), + ConfigPathStatus::Used, + )) +} /// documented in docs/usage/config.md -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] #[serde(default)] pub struct Config { pub driver: DriverConfig, pub temp_build_dir: PathBuf, + /// Where build artifacts are exported; relative paths resolve against + /// the package root. + pub output_dir: PathBuf, pub incremental: bool, /// Which source files are staged into the build tree. pub source_sync_mode: SourceSyncMode, /// Always build the automatic `-dbgsym` debug symbol package. pub build_debug_symbols: bool, - /// Sign the resulting `.changes`/`.dsc` with `debsign` after building. - pub sign_package: bool, - /// Where `debsign` runs: on the host or inside a minimal same-distro - /// container with the host's gpg-agent socket forwarded in. - pub sign_with: SignWith, - /// GPG key ID/email to sign with (debsign's `-k` option). `None` lets - /// debsign fall back to its own maintainer-based key lookup, but - /// container signing requires an explicit key. - pub sign_key: Option, + /// Signing of the resulting `.changes`/`.dsc`. + pub sign: SignConfig, /// Run `debian/rules clean` before building (like `dpkg-buildpackage` /// does unless passed `-nc`). Disabled by default because non-incremental /// builds already stage a clean source tree and incremental builds preserve @@ -35,6 +166,27 @@ pub struct Config { /// On build or test failure, drop into an interactive shell in the /// environment when stdout is a TTY. pub shell_on_failure: bool, + /// Build for a dpkg architecture variant (e.g. `amd64v3` on Ubuntu), + /// exported as `DEB_HOST_ARCH_VARIANT` for the build. + pub host_arch_variant: Option, +} + +/// `[sign]` section: whether and how to sign the build artifacts. +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +#[serde(default)] +pub struct SignConfig { + /// Sign the source package (`.changes`/`.dsc`) after building. + pub source: bool, + /// GPG key ID/email to sign with. `None` falls back to the + /// `Changed-By:`/`Maintainer:` address of the file being signed. + pub key: Option, + /// Which OpenPGP implementation to use. + pub tool: SignTool, + /// Custom signing command when `tool` is `custom`, like debsign's `-p`. + pub command: Option, + /// Send a desktop notification via `notify-send` just before signing, + /// so a hardware-key touch prompt isn't missed. + pub notify: bool, } impl Default for Config { @@ -42,26 +194,72 @@ impl Default for Config { Self { driver: DriverConfig::default(), temp_build_dir: PathBuf::from("/tmp/debmagic"), + output_dir: PathBuf::from("build"), incremental: false, source_sync_mode: SourceSyncMode::default(), build_debug_symbols: false, - sign_package: false, - sign_with: SignWith::default(), - sign_key: None, + sign: SignConfig::default(), clean: false, shell_on_failure: false, + host_arch_variant: None, } } } impl Config { - pub fn new(config_files: &Vec) -> anyhow::Result { + /// Precedence of config files is: + /// + /// 1. explicit config file passed on the command line + /// 2. `/debian/debmagic.toml` + /// 3. `/debmagic/config.toml` + /// + /// An explicit config file that does not exist is an error; the other + /// locations are optional and silently skipped when absent. + pub fn resolve_paths( + source_dir: Option<&Path>, + config_file: Option<&Path>, + ) -> anyhow::Result> { + let mut paths = vec![]; + + let global_config = global_config_path()?; + let status = if global_config.is_file() { + ConfigPathStatus::Used + } else { + ConfigPathStatus::NotFound + }; + paths.push(ConfigPath::new(ConfigLayer::Global, global_config, status)); + + if let Some(source_dir) = source_dir { + let project_config = project_config_path(source_dir); + let status = if project_config.is_file() { + ConfigPathStatus::Used + } else { + ConfigPathStatus::NotFound + }; + paths.push(ConfigPath::new( + ConfigLayer::Project, + project_config, + status, + )); + } + + if let Some(config_file) = config_file { + paths.push(explicit_config_path(config_file)?); + } + + Ok(paths) + } + + pub fn load(source_dir: Option<&Path>, config_file: Option<&Path>) -> anyhow::Result { + let paths = Self::resolve_paths(source_dir, config_file)?; + Self::new(&paths) + } + + pub fn new(config_files: &[ConfigPath]) -> anyhow::Result { let mut builder = ConfigBuilder::builder(); - for file in config_files { - if file.is_file() { - builder = builder.add_source(File::with_name(&file.to_string_lossy())); - } + for file in config_files.iter().filter(|f| f.is_used()) { + builder = builder.add_source(File::with_name(&file.path.to_string_lossy())); } let build = builder @@ -73,18 +271,48 @@ impl Config { config } + + /// Look up a dotted key (e.g. `sign.key`) in the serialized config, + /// returning the value as a TOML fragment. + pub fn get_value(&self, key: &str) -> anyhow::Result { + let table: toml::Table = + toml::from_str(&toml::to_string(self)?).context("serializing config failed")?; + + let mut current = &toml::Value::Table(table); + for part in key.split('.') { + current = current + .as_table() + .and_then(|t| t.get(part)) + .with_context(|| format!("no such config key: '{key}'"))?; + } + + match current { + toml::Value::String(s) => Ok(s.clone()), + toml::Value::Integer(i) => Ok(i.to_string()), + toml::Value::Float(f) => Ok(f.to_string()), + toml::Value::Boolean(b) => Ok(b.to_string()), + toml::Value::Datetime(d) => Ok(d.to_string()), + value => Ok(toml::to_string(value)?), + } + } } #[cfg(test)] mod tests { use super::*; + use crate::driver::DriverType; #[test] fn it_loads_a_simple_config() -> Result<(), anyhow::Error> { let test_asset_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tests") .join("assets"); - let cfg = Config::new(&vec![test_asset_dir.join("config1.toml")])?; + let cfg = Config::new(&[ConfigPath::new( + ConfigLayer::Explicit, + test_asset_dir.join("config1.toml"), + ConfigPathStatus::Used, + )])?; + assert_eq!(cfg.driver.default, Some(DriverType::Docker)); assert!(cfg.driver.persistent); assert!( @@ -94,4 +322,26 @@ mod tests { Ok(()) } + + #[test] + fn it_loads_the_sign_section() -> Result<(), anyhow::Error> { + let dir = std::env::temp_dir().join(format!("debmagic-test-{}", std::process::id())); + std::fs::create_dir_all(&dir)?; + let file = dir.join("sign.toml"); + std::fs::write( + &file, + "[sign]\nsource = true\nkey = \"you@example.com\"\ncommand = \"gpg --foo\"\nnotify = true\n", + )?; + let cfg = Config::new(&[ConfigPath::new( + ConfigLayer::Explicit, + file.clone(), + ConfigPathStatus::Used, + )])?; + std::fs::remove_dir_all(&dir).ok(); + assert!(cfg.sign.source); + assert_eq!(cfg.sign.key.as_deref(), Some("you@example.com")); + assert_eq!(cfg.sign.command.as_deref(), Some("gpg --foo")); + assert!(cfg.sign.notify); + Ok(()) + } } diff --git a/packages/debmagic/src/driver/config.rs b/packages/debmagic/src/driver/config.rs index 8d8c8d46..0ed85b90 100644 --- a/packages/debmagic/src/driver/config.rs +++ b/packages/debmagic/src/driver/config.rs @@ -1,12 +1,17 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; +use crate::driver::DriverType; use crate::driver::driver_bare::{DriverBareConfig, DriverBareConfigOverrides}; use crate::driver::driver_docker::{DriverDockerConfig, DriverDockerConfigOverrides}; use crate::driver::driver_lxd::{DriverLxdConfig, DriverLxdConfigOverrides}; -#[derive(Deserialize, Debug, Clone, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, Default)] #[serde(default)] pub struct DriverConfig { + /// Default driver when `--driver` is not passed. Binary builds still + /// require a driver, from the CLI or here; source builds fall back to + /// `bare`. + pub default: Option, pub persistent: bool, /// Not used by the bare driver, which builds on the host's own sources. pub apt_mirror: Option, diff --git a/packages/debmagic/src/driver/driver_bare.rs b/packages/debmagic/src/driver/driver_bare.rs index 52209787..83aeda8c 100644 --- a/packages/debmagic/src/driver/driver_bare.rs +++ b/packages/debmagic/src/driver/driver_bare.rs @@ -3,7 +3,8 @@ use std::{path::Path, process::Command}; use serde::{Deserialize, Serialize}; use crate::driver::{ - Driver, DriverType, Environment, EnvironmentMetadata, IsolationCapability, config::DriverConfig, + DriverType, Environment, EnvironmentDriver, EnvironmentMetadata, IsolationCapability, + SignRequest, config::DriverConfig, }; #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -40,19 +41,9 @@ impl DriverBare { _driver_config: driver_config.clone(), } } - - pub(crate) fn sign_changes( - &self, - changes_file: &Path, - _gpg: Option<&crate::signing::GpgForwarding>, - sign_key: Option<&str>, - ) -> anyhow::Result<()> { - crate::signing::check_host_debsign_available()?; - crate::signing::sign_on_host(changes_file, sign_key) - } } -impl Driver for DriverBare { +impl EnvironmentDriver for DriverBare { fn driver_metadata(&self) -> std::collections::HashMap { std::collections::HashMap::from([]) } @@ -109,4 +100,8 @@ impl Driver for DriverBare { } Ok(()) } + + fn sign_changes(&self, request: &SignRequest) -> anyhow::Result<()> { + crate::sign::sign_changes(request) + } } diff --git a/packages/debmagic/src/driver/driver_docker.rs b/packages/debmagic/src/driver/driver_docker.rs index 4b3e2faa..c27b0da6 100644 --- a/packages/debmagic/src/driver/driver_docker.rs +++ b/packages/debmagic/src/driver/driver_docker.rs @@ -5,15 +5,15 @@ use std::{ process::{Command, Stdio}, }; -use anyhow::{Context, anyhow}; +use anyhow::anyhow; use debmagic_common::distro::DistroVersion; use serde::{Deserialize, Serialize}; use crate::driver::{ - APT_MIRROR_SCRIPT, Driver, DriverType, ENVIRONMENT_DIR_IN_CONTAINER, Environment, - EnvironmentMetadata, IsolationCapability, config::DriverConfig, container_name_from_metadata, - container_name_metadata, environment_fingerprint, resource_name, run_checked, - translate_path_in_container, + APT_MIRROR_SCRIPT, DriverType, ENVIRONMENT_DIR_IN_CONTAINER, Environment, EnvironmentDriver, + EnvironmentMetadata, IsolationCapability, SignRequest, config::DriverConfig, + container_name_from_metadata, container_name_metadata, environment_fingerprint, resource_name, + run_checked, translate_path_in_container, }; #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -46,7 +46,7 @@ const DOCKERFILE_TEMPLATE: &str = r#" FROM {base_image} ARG USER_UID=1000 ARG USER_GID=$USER_UID -RUN apt-get update && apt-get install -y dpkg-dev python3 +RUN apt-get update && apt-get install -y --no-install-recommends dpkg-dev python3 {apt_mirror_setup} RUN set -e; \ getent group "$USER_GID" >/dev/null || groupadd --gid "$USER_GID" debmagic; \ @@ -69,9 +69,6 @@ fn shell_quote(s: &str) -> String { pub struct DriverDocker { environment: Environment, container_name: String, - /// Base image of the distro, used to spin up minimal one-shot containers - /// (e.g. for signing) that don't need the build environment's tooling. - base_image: String, reused_environment: bool, } @@ -268,7 +265,6 @@ impl DriverDocker { let mut driver = Self { environment: environment.clone(), container_name, - base_image: base_image.clone(), reused_environment: false, }; let environment_matches = container_environment_fingerprint(&driver.container_name)? @@ -340,15 +336,12 @@ impl DriverDocker { pub fn from_metadata( environment: &Environment, - driver_config: &DriverConfig, + _driver_config: &DriverConfig, metadata: &EnvironmentMetadata, ) -> anyhow::Result { Ok(Self { environment: environment.clone(), container_name: container_name_from_metadata(metadata)?, - base_image: driver_config - .docker - .base_image_for_distro(&environment.distro), reused_environment: true, }) } @@ -361,7 +354,7 @@ impl DriverDocker { } } -impl Driver for DriverDocker { +impl EnvironmentDriver for DriverDocker { fn driver_metadata(&self) -> std::collections::HashMap { container_name_metadata(&self.container_name) } @@ -462,59 +455,8 @@ impl Driver for DriverDocker { fn isolation_capability(&self) -> IsolationCapability { IsolationCapability::Container } -} - -impl DriverDocker { - pub(crate) fn sign_changes( - &self, - changes_file: &Path, - gpg: Option<&crate::signing::GpgForwarding>, - _sign_key: Option<&str>, - ) -> anyhow::Result<()> { - use crate::signing; - - let gpg = gpg.context("docker container signing needs gpg forwarding info")?; - let output_dir = changes_file - .parent() - .context("changes file has no parent directory")?; - let staging_dir = self.environment.temp_dir().join("sign"); - signing::stage_signing_material(&staging_dir, &gpg.sign_key)?; - let script = signing::sign_container_script( - signing::changes_filename(changes_file)?, - &gpg.sign_key, - // The sign container's root is not id-mapped; fix ownership of - // files it rewrites so the host user can manage them afterwards. - Some((unsafe { libc::geteuid() }, unsafe { libc::getegid() })), - ); - println!( - "[docker] $ signing {} in a minimal {} container", - changes_file.display(), - self.environment.distro.codename - ); - run_checked( - Command::new("docker") - .args(["run", "--rm", "--init"]) - .args([ - "--mount", - &format!( - "{},readonly", - bind_mount_arg(&staging_dir, signing::SIGN_STAGING_IN_CONTAINER) - ), - ]) - .arg(format!( - "--mount=type=bind,src={},dst={},readonly", - gpg.agent_extra_socket.display(), - signing::GPG_SOCKET_IN_CONTAINER - )) - .args([ - "--mount", - &bind_mount_arg(output_dir, signing::OUTPUT_DIR_IN_CONTAINER), - ]) - .arg(&self.base_image) - .args(["sh", "-ec", &script]), - "signing in docker container", - )?; - Ok(()) + fn sign_changes(&self, request: &SignRequest) -> anyhow::Result<()> { + crate::sign::sign_changes(request) } } diff --git a/packages/debmagic/src/driver/driver_lxd.rs b/packages/debmagic/src/driver/driver_lxd.rs index 8a1c962f..c1cb83a9 100644 --- a/packages/debmagic/src/driver/driver_lxd.rs +++ b/packages/debmagic/src/driver/driver_lxd.rs @@ -4,15 +4,14 @@ use std::{ process::{Command, Stdio}, }; -use anyhow::Context as _; use debmagic_common::distro::{Distro, DistroVersion}; use serde::{Deserialize, Serialize}; use crate::driver::{ - APT_MIRROR_SCRIPT, Driver, DriverType, ENVIRONMENT_DIR_IN_CONTAINER, Environment, - EnvironmentMetadata, IsolationCapability, config::DriverConfig, container_name_from_metadata, - container_name_metadata, environment_fingerprint, resource_name, run_checked, - translate_path_in_container, + APT_MIRROR_SCRIPT, DriverType, ENVIRONMENT_DIR_IN_CONTAINER, Environment, EnvironmentDriver, + EnvironmentMetadata, IsolationCapability, SignRequest, config::DriverConfig, + container_name_from_metadata, container_name_metadata, environment_fingerprint, resource_name, + run_checked, translate_path_in_container, }; // The binary name differs between LXD and Incus, but everything else is shared. @@ -36,7 +35,7 @@ impl LxdVariant { const BUILD_USER_UID: u32 = 1000; const BUILD_USER_GID: u32 = 1000; const ENVIRONMENT_CONFIG_KEY: &str = "user.debmagic.environment"; -const ENVIRONMENT_SETUP_VERSION: &str = "dpkg-dev python3; build-user-v1; raw.idmap-v1"; +const ENVIRONMENT_SETUP_VERSION: &str = "dpkg-dev-norec python3; build-user-v1; raw.idmap-v1"; // ── Config ──────────────────────────────────────────────────────────────────── @@ -103,9 +102,6 @@ pub struct DriverLxd { container_name: String, /// Resolved project name (None → omit `--project` flag). project: Option, - /// Base image of the distro, used to spin up minimal one-shot containers - /// (e.g. for signing) that don't need the build environment's tooling. - base_image: String, reused_environment: bool, } @@ -251,7 +247,6 @@ impl DriverLxd { environment: environment.clone(), container_name: container_name.clone(), project, - base_image: base_image.clone(), reused_environment: false, }; @@ -344,10 +339,19 @@ impl DriverLxd { if !reusing_container { // Install the base tooling that stock images don't include. - // build-dep is intentionally omitted here: build.rs runs it for - // every driver against the real mounted source tree. + // build-essential is intentionally omitted: binary builds pull + // it in explicitly, so source-only environments stay lean. + // build-dep is omitted too: build.rs runs it for every driver + // against the real mounted source tree. base.exec_in_container_checked( - &["apt-get", "install", "-y", "dpkg-dev", "python3"], + &[ + "apt-get", + "install", + "-y", + "--no-install-recommends", + "dpkg-dev", + "python3", + ], None, true, &[], @@ -416,7 +420,7 @@ impl DriverLxd { pub fn from_metadata( variant: LxdVariant, environment: &Environment, - driver_config: &DriverConfig, + _driver_config: &DriverConfig, metadata: &EnvironmentMetadata, ) -> anyhow::Result { let project = metadata.driver_metadata.get("project").cloned(); @@ -426,9 +430,6 @@ impl DriverLxd { environment: environment.clone(), container_name: container_name_from_metadata(metadata)?, project, - base_image: driver_config - .lxd - .base_image_for_distro(variant, &environment.distro), reused_environment: true, }) } @@ -525,7 +526,7 @@ impl DriverLxd { } } -impl Driver for DriverLxd { +impl EnvironmentDriver for DriverLxd { fn driver_metadata(&self) -> std::collections::HashMap { let mut meta = container_name_metadata(&self.container_name); if let Some(ref p) = self.project { @@ -606,115 +607,8 @@ impl Driver for DriverLxd { fn isolation_capability(&self) -> IsolationCapability { IsolationCapability::Container } -} -impl DriverLxd { - pub(crate) fn sign_changes( - &self, - changes_file: &Path, - gpg: Option<&crate::signing::GpgForwarding>, - _sign_key: Option<&str>, - ) -> anyhow::Result<()> { - use crate::signing; - - let gpg = gpg.context("container signing needs gpg forwarding info")?; - let output_dir = changes_file - .parent() - .context("changes file has no parent directory")?; - let staging_dir = self.environment.temp_dir().join("sign"); - signing::stage_signing_material(&staging_dir, &gpg.sign_key)?; - // No chown needed: raw.idmap maps container root to the host user. - let script = signing::sign_container_script( - signing::changes_filename(changes_file)?, - &gpg.sign_key, - None, - ); - - let sign_container = resource_name( - "debmagic-sign", - &self.environment.package_name, - &self.environment.identifier(), - ); - let bin = self.variant.binary(); - // The sign container is ephemeral; it disappears when stopped. - let mut init = self.lxd_cmd("init"); - init.arg("--ephemeral"); - init.args([&self.base_image, sign_container.as_str()]); - run_checked(&mut init, &format!("initialising {bin} sign container"))?; - - let result = (|| -> anyhow::Result<()> { - let host_uid = unsafe { libc::geteuid() }; - let host_gid = unsafe { libc::getegid() }; - if host_uid != 0 { - let idmap = format!("uid {host_uid} 0\ngid {host_gid} 0"); - run_checked( - self.lxd_cmd("config") - .arg("set") - .arg(&sign_container) - .arg("raw.idmap") - .arg(&idmap), - "setting raw.idmap on sign container", - )?; - } - - run_checked( - self.lxd_cmd("config") - .arg("device") - .arg("add") - .arg(&sign_container) - .arg("debmagic-output") - .arg("disk") - .arg(format!("source={}", output_dir.display())) - .arg(format!("path={}", signing::OUTPUT_DIR_IN_CONTAINER)), - "mounting output directory into sign container", - )?; - run_checked( - self.lxd_cmd("config") - .arg("device") - .arg("add") - .arg(&sign_container) - .arg("debmagic-sign-staging") - .arg("disk") - .arg(format!("source={}", staging_dir.display())) - .arg(format!("path={}", signing::SIGN_STAGING_IN_CONTAINER)) - .arg("readonly=true"), - "mounting signing material into sign container", - )?; - // Forward the host gpg-agent's extra socket via a proxy device, - // like a manually configured unix proxy but scoped to signing. - run_checked( - self.lxd_cmd("config") - .arg("device") - .arg("add") - .arg(&sign_container) - .arg("debmagic-gpg-agent") - .arg("proxy") - .arg("bind=container") - .arg(format!("connect=unix:{}", gpg.agent_extra_socket.display())) - .arg(format!("listen=unix:{}", signing::GPG_SOCKET_IN_CONTAINER)) - .arg("uid=0") - .arg("gid=0"), - "forwarding gpg-agent socket into sign container", - )?; - - run_checked( - self.lxd_cmd("start").arg(&sign_container), - &format!("starting {bin} sign container"), - )?; - - let mut exec = self.lxd_cmd("exec"); - exec.arg(&sign_container); - exec.arg("--"); - exec.args(["sh", "-ec", &script]); - run_checked(&mut exec, "signing in container")?; - Ok(()) - })(); - - let mut stop = self.lxd_cmd("stop"); - let stop_result = run_checked( - stop.arg(&sign_container), - &format!("stopping {bin} sign container"), - ); - result.and(stop_result) + fn sign_changes(&self, request: &SignRequest) -> anyhow::Result<()> { + crate::sign::sign_changes(request) } } diff --git a/packages/debmagic/src/driver/mod.rs b/packages/debmagic/src/driver/mod.rs index 31935711..4625b7b2 100644 --- a/packages/debmagic/src/driver/mod.rs +++ b/packages/debmagic/src/driver/mod.rs @@ -17,6 +17,7 @@ use crate::driver::{ driver_docker::DriverDocker, driver_lxd::{DriverLxd, LxdVariant}, }; +use crate::sign::SignTool; pub mod config; pub mod driver_bare; @@ -99,6 +100,7 @@ pub fn container_name_from_metadata(metadata: &EnvironmentMetadata) -> anyhow::R } #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum DriverType { Docker, Bare, @@ -196,7 +198,7 @@ pub struct EnvironmentMetadata { /// pockets it detects from `/etc/os-release`. pub const APT_MIRROR_SCRIPT: &str = include_str!("scripts/mirror.py"); -pub trait Driver { +pub trait EnvironmentDriver { fn driver_metadata(&self) -> HashMap; fn run_command( @@ -229,7 +231,7 @@ pub trait Driver { fn driver_type(&self) -> DriverType; - /// Isolation this Driver's Environment actually provides. + /// Isolation this driver's Environment actually provides. fn isolation_capability(&self) -> IsolationCapability; fn reset_root(&self) -> io::Result<()>; @@ -237,15 +239,20 @@ pub trait Driver { fn reused_environment(&self) -> bool { true } + + /// Sign `changes_file` (a path on the host) with debmagic's signing + /// implementation. + fn sign_changes(&self, request: &SignRequest) -> anyhow::Result<()>; } -pub enum DriverInstance { +/// A live environment driver, created for one build/test run. +pub enum Driver { Docker(DriverDocker), Bare(DriverBare), Lxd(DriverLxd), } -impl Driver for DriverInstance { +impl EnvironmentDriver for Driver { fn driver_metadata(&self) -> HashMap { match self { Self::Docker(d) => d.driver_metadata(), @@ -315,19 +322,12 @@ impl Driver for DriverInstance { Self::Lxd(d) => d.reused_environment(), } } -} -impl DriverInstance { - pub fn sign_changes( - &self, - changes_file: &Path, - gpg: Option<&crate::signing::GpgForwarding>, - sign_key: Option<&str>, - ) -> anyhow::Result<()> { + fn sign_changes(&self, request: &SignRequest) -> anyhow::Result<()> { match self { - Self::Docker(d) => d.sign_changes(changes_file, gpg, sign_key), - Self::Bare(d) => d.sign_changes(changes_file, gpg, sign_key), - Self::Lxd(d) => d.sign_changes(changes_file, gpg, sign_key), + Self::Docker(d) => d.sign_changes(request), + Self::Bare(d) => d.sign_changes(request), + Self::Lxd(d) => d.sign_changes(request), } } } @@ -336,7 +336,7 @@ pub fn create_driver( environment: &Environment, driver_config: &DriverConfig, overrides: &DriverOverrides, -) -> anyhow::Result { +) -> anyhow::Result { let apt_mirror = overrides .apt_mirror .as_deref() @@ -344,14 +344,14 @@ pub fn create_driver( let proposed = overrides.proposed.unwrap_or(driver_config.proposed); match environment.driver { - DriverType::Docker => Ok(DriverInstance::Docker(DriverDocker::create( + DriverType::Docker => Ok(Driver::Docker(DriverDocker::create( environment, driver_config, &overrides.docker, apt_mirror, proposed, )?)), - DriverType::Bare => Ok(DriverInstance::Bare(DriverBare::create( + DriverType::Bare => Ok(Driver::Bare(DriverBare::create( environment, driver_config, &overrides.bare, @@ -361,7 +361,7 @@ pub fn create_driver( DriverType::Lxd => LxdVariant::Lxd, _ => LxdVariant::Incus, }; - Ok(DriverInstance::Lxd(DriverLxd::create( + Ok(Driver::Lxd(DriverLxd::create( variant, environment, driver_config, @@ -376,14 +376,14 @@ pub fn create_driver( pub fn create_driver_from_metadata( driver_config: &DriverConfig, metadata: &EnvironmentMetadata, -) -> anyhow::Result { +) -> anyhow::Result { match metadata.environment.driver { - DriverType::Docker => Ok(DriverInstance::Docker(DriverDocker::from_metadata( + DriverType::Docker => Ok(Driver::Docker(DriverDocker::from_metadata( &metadata.environment, driver_config, metadata, )?)), - DriverType::Bare => Ok(DriverInstance::Bare(DriverBare::from_metadata( + DriverType::Bare => Ok(Driver::Bare(DriverBare::from_metadata( &metadata.environment, driver_config, metadata, @@ -393,7 +393,7 @@ pub fn create_driver_from_metadata( DriverType::Lxd => LxdVariant::Lxd, _ => LxdVariant::Incus, }; - Ok(DriverInstance::Lxd(DriverLxd::from_metadata( + Ok(Driver::Lxd(DriverLxd::from_metadata( variant, &metadata.environment, driver_config, @@ -403,6 +403,23 @@ pub fn create_driver_from_metadata( } } +/// A single signing invocation: what to sign and whether to send a desktop +/// notification just before the gpg touch prompt. +pub struct SignRequest<'a> { + /// The `.changes` file to sign (a path on the host). + pub changes_file: &'a Path, + /// Key ID/email to sign with; `None` falls back to the maintainer lookup. + pub sign_key: Option<&'a str>, + /// Which OpenPGP implementation to use. + pub sign_tool: SignTool, + /// Custom signing command when `sign_tool` is `Custom`. + pub sign_command: Option<&'a str>, + /// Send a `notify-send` popup right before signing. + pub notify: bool, + /// `"{name}-{version}"`, used in the notification. + pub package: &'a str, +} + /// Remove `root` from the host. If files are owned by a container user the host /// cannot delete, delete them from inside that environment first. Never requires /// host root. @@ -522,7 +539,7 @@ mod tests { #[test] fn environment_without_purpose_deserializes_as_build() { let json = r#"{ - "driver": "Docker", + "driver": "docker", "package_identifier": "pkg-1.0", "root_dir": "/tmp/build", "distro": { "distro": "Debian", "codename": "forky", "version": "15" } diff --git a/packages/debmagic/src/main.rs b/packages/debmagic/src/main.rs index 07c7e2f5..053e0691 100644 --- a/packages/debmagic/src/main.rs +++ b/packages/debmagic/src/main.rs @@ -6,8 +6,9 @@ use clap::{CommandFactory, Parser}; use crate::{ build::{build_package, build_source_package, get_shell_in_build}, - build_intent::{BuildIntentInput, load_config, resolve_build_intent}, - cli::{BuildTarget, Cli, Commands}, + build_intent::{BuildIntentInput, resolve_build_intent}, + cli::{BuildTarget, Cli, Commands, ConfigCommands}, + config::{Config, ConfigPathStatus, resolve_set_target}, driver::{ DriverType, config::DriverOverrides, driver_bare::DriverBareConfigOverrides, driver_docker::DriverDockerConfigOverrides, driver_lxd::DriverLxdConfigOverrides, @@ -21,8 +22,9 @@ pub mod build_intent; pub mod cli; pub mod config; pub mod driver; +pub mod output; pub mod package; -pub mod signing; +pub mod sign; pub mod test; fn main() -> ExitCode { @@ -37,25 +39,33 @@ fn main() -> ExitCode { fn run() -> anyhow::Result { let cli = Cli::parse(); + output::init_color(cli.color); let current_dir = env::current_dir()?; match &cli.command { Commands::Build(args) => { - let (build_args, debug_symbols, incremental, is_source) = match &args.target { - BuildTarget::Binary(binary_args) => ( - &binary_args.build, - binary_args.debug_symbols, - binary_args.incremental, - false, - ), - BuildTarget::Source(source_args) => (&source_args.build, None, None, true), + let (build_args, debug_symbols, is_source) = match &args.target { + BuildTarget::Binary(binary_args) => { + (&binary_args.build, binary_args.debug_symbols, false) + } + BuildTarget::Source(source_args) => (&source_args.build, None, true), }; + let config_driver = Config::load( + build_args.common.source_dir.as_deref(), + cli.config.as_deref(), + )? + .driver + .default; + let driver = if is_source { - build_args.driver.unwrap_or(DriverType::Bare) + build_args + .driver + .or(config_driver) + .unwrap_or(DriverType::Bare) } else { - build_args.driver.context( - "--driver is required for binary builds (docker, bare, lxd or incus)", + build_args.driver.or(config_driver).context( + "no build driver selected: pass --driver or set 'driver' in debmagic.toml (docker, bare, lxd or incus)", )? }; @@ -66,16 +76,16 @@ fn run() -> anyhow::Result { config_file: cli.config.clone(), driver, persistent: build_args.persistent, - incremental, - disable_incremental: is_source, + incremental: build_args.incremental, debug_symbols, sign: build_args.sign, - no_sign: build_args.no_sign, - sign_with: build_args.sign_with, sign_key: build_args.sign_key.clone(), + sign_tool: build_args.sign_tool, + sign_command: build_args.sign_command.clone(), + sign_notify: build_args.sign_notify, clean: build_args.clean, - no_clean: build_args.no_clean, source_sync: build_args.source_sync, + host_arch_variant: build_args.host_arch_variant.clone(), shell_on_failure: build_args.shell_on_failure, driver_overrides: DriverOverrides { apt_mirror: build_args.apt_mirror.clone(), @@ -113,7 +123,7 @@ fn run() -> anyhow::Result { let source_dir = args.common.source_dir.as_deref().unwrap_or(¤t_dir); let source_dir = std::path::absolute(source_dir).context("resolving source dir failed")?; - let config = load_config(Some(&source_dir), cli.config.as_deref())?; + let config = Config::load(Some(&source_dir), cli.config.as_deref())?; let identity = load_package_identity(&source_dir)?; get_shell_in_build(&config, &identity)?; } @@ -153,6 +163,118 @@ fn run() -> anyhow::Result { Commands::Check(_args) => { println!("Check subcommand! - not implemented"); } + Commands::Sign(args) => { + let source_dir = args.common.source_dir.as_deref().unwrap_or(¤t_dir); + let source_dir = + std::path::absolute(source_dir).context("resolving source dir failed")?; + let mut config = Config::load(Some(&source_dir), cli.config.as_deref())?; + + if let Some(key) = &args.sign_key { + config.sign.key = Some(key.clone()); + } + if let Some(tool) = args.sign_tool { + config.sign.tool = tool; + } + if let Some(command) = &args.sign_command { + config.sign.command = Some(command.clone()); + } + if let Some(notify) = args.sign_notify { + config.sign.notify = notify; + } + + let options = sign::SignOptions { + key: config.sign.key.clone(), + tool: config.sign.tool, + command: config.sign.command.clone(), + }; + + let file = match &args.file { + Some(file) => { + std::path::absolute(file).context("resolving the file to sign failed")? + } + None => { + let identity = load_package_identity(&source_dir)?; + // -o wins; else the config value, relative to the package root. + let output_dir = match &args.output_dir { + Some(dir) => { + std::path::absolute(dir).context("resolving output dir failed")? + } + None => std::path::absolute(source_dir.join(&config.output_dir)) + .context("resolving output dir failed")?, + }; + sign::find_changes_file( + &identity.name, + &identity.version.to_string(), + &output_dir, + )? + } + }; + + let package = file + .file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_default(); + sign::sign_file(&file, &options, config.sign.notify, &package)?; + } + Commands::Config(args) => match &args.command { + ConfigCommands::Show(show_args) => { + let source_dir = show_args + .common + .source_dir + .as_deref() + .unwrap_or(¤t_dir); + let source_dir = + std::path::absolute(source_dir).context("resolving source dir failed")?; + let paths = Config::resolve_paths(Some(&source_dir), cli.config.as_deref())?; + + eprintln!("debmagic: config files (highest precedence first):"); + for entry in &paths { + let status = match entry.status { + ConfigPathStatus::Used => "used", + ConfigPathStatus::NotFound => "not found", + }; + eprintln!("debmagic: {status:<10} {}", entry.path.display()); + } + + let config = Config::new(&paths)?; + print!("{}", toml::to_string_pretty(&config)?); + } + ConfigCommands::Get(get_args) => { + let source_dir = get_args + .common + .source_dir + .as_deref() + .unwrap_or(¤t_dir); + let source_dir = + std::path::absolute(source_dir).context("resolving source dir failed")?; + let config = Config::load(Some(&source_dir), cli.config.as_deref())?; + println!("{}", config.get_value(&get_args.key)?); + } + ConfigCommands::Set(set_args) => { + let source_dir = set_args + .common + .source_dir + .as_deref() + .unwrap_or(¤t_dir); + let source_dir = + std::path::absolute(source_dir).context("resolving source dir failed")?; + let target = + resolve_set_target(Some(&source_dir), cli.config.as_deref(), set_args.global)?; + + if let Some(parent) = target.path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating {} failed", parent.display()))?; + } + + target.set_value(&set_args.key, &set_args.value)?; + + // validate the result parses and the key landed + let config = Config::load(Some(&source_dir), cli.config.as_deref())?; + let effective = config.get_value(&set_args.key)?; + println!("debmagic: {} = {}", set_args.key, effective.trim_end()); + eprintln!("debmagic: written to {}", target.path.display()); + } + }, Commands::Version {} => { let cmd = Cli::command(); println!("{}", cmd.render_version()); diff --git a/packages/debmagic/src/output.rs b/packages/debmagic/src/output.rs new file mode 100644 index 00000000..b08b3198 --- /dev/null +++ b/packages/debmagic/src/output.rs @@ -0,0 +1,210 @@ +//! Colored stage/progress output for the CLI. +//! +//! Stage banners go to **stderr** so stdout stays usable for piped command +//! output. Color is governed by a global `--color` flag (`auto`/`always`/ +//! `never`, default `auto`); `auto` colors only on a TTY, and `never` is +//! implied by the `NO_COLOR` env var unless overridden with `always`. + +use std::io::IsTerminal; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; + +pub use crate::cli::ColorChoice; + +static COLOR: AtomicBool = AtomicBool::new(false); + +/// An ANSI SGR style. Each variant maps to its numeric/compound code (without +/// the `\x1b[` prefix / `m` suffix), so call sites read as intent, not escape +/// soup. `Reset` clears all attributes and is emitted after styled text. +#[derive(Debug, Copy, Clone)] +#[allow(dead_code)] // the palette is a vocabulary; not every style is used yet +pub enum Style { + Reset, + Bold, + Dim, + Italic, + Underline, + Red, + Green, + Yellow, + Blue, + Magenta, + Cyan, + BoldRed, + BoldGreen, + BoldYellow, + BoldCyan, +} + +impl Style { + fn code(self) -> &'static str { + match self { + Style::Reset => "0", + Style::Bold => "1", + Style::Dim => "2", + Style::Italic => "3", + Style::Underline => "4", + Style::Red => "31", + Style::Green => "32", + Style::Yellow => "33", + Style::Blue => "34", + Style::Magenta => "35", + Style::Cyan => "36", + Style::BoldRed => "1;31", + Style::BoldGreen => "1;32", + Style::BoldYellow => "1;33", + Style::BoldCyan => "1;36", + } + } +} + +/// Send a desktop notification via `notify-send`, if available. Never fails +/// the build: a headless session or missing binary just means no popup. +pub fn notify_send(summary: &str, body: &str) { + match Command::new("notify-send") + .arg(summary) + .arg(body) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + { + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + eprintln!("notify-send not found on PATH; cannot send signing notification"); + } + Err(e) => eprintln!("failed to run notify-send: {e}"), + } +} + +/// Ring the terminal bell so an attention-requiring prompt in a background +/// window is noticed. No-op when stderr is not a terminal, so piped output +/// stays clean. +pub fn bell() { + use std::io::Write; + if std::io::stderr().is_terminal() { + let _ = std::io::stderr().write_all(b"\x07"); + } +} + +/// Notify the user that their attention with both a desktop notification +/// and a terminal bell. +pub fn notify_send_bell(summary: &str, body: &str) { + notify_send(summary, body); + bell(); +} + +/// Whether color is on, after [`init_color`] has run. +pub fn color_enabled() -> bool { + COLOR.load(Ordering::Relaxed) +} + +/// Whether a `TERM` value describes a color-capable terminal, mirroring +/// strace's `is_no_color()`: no/empty TERM, or `dumb`/`unknown` (the +/// terminfo-less fallback when terminfo can't be queried) means no color. +fn term_supports_color(term: Option<&str>) -> bool { + match term { + None | Some("") => false, + Some(t) => !t.eq_ignore_ascii_case("dumb") && !t.eq_ignore_ascii_case("unknown"), + } +} + +/// Pure decision, split out for testing. Color needs a TTY, a non-empty +/// `NO_COLOR` absent (no-color.org: empty `NO_COLOR=` is a no-op), and a +/// color-capable `TERM`. +fn resolve_color( + choice: ColorChoice, + stderr_tty: bool, + no_color: Option<&str>, + term: Option<&str>, +) -> bool { + match choice { + ColorChoice::Always => true, + ColorChoice::Never => false, + ColorChoice::Auto => { + stderr_tty && no_color.is_none_or(|v| v.is_empty()) && term_supports_color(term) + } + } +} + +/// Resolve the effective color mode and store it. Must be called once at +/// startup before any output. +pub fn init_color(choice: ColorChoice) { + let no_color = std::env::var("NO_COLOR").ok(); + let term = std::env::var("TERM").ok(); + let enabled = resolve_color( + choice, + std::io::stderr().is_terminal(), + no_color.as_deref(), + term.as_deref(), + ); + COLOR.store(enabled, Ordering::Relaxed); +} + +/// Wrap `text` in an ANSI style (terminated by [`Style::Reset`]), or return +/// it unchanged when color is off. +pub fn styled(style: Style, text: &str) -> String { + if color_enabled() { + format!("\x1b[{}m{text}\x1b[{}m", style.code(), Style::Reset.code()) + } else { + text.to_string() + } +} + +/// Print a top-level stage banner (e.g. "Building package"). +pub fn stage(text: &str) { + eprintln!("{}", styled(Style::BoldCyan, &format!("==> {text}"))); +} + +/// Print a sub-step line (e.g. "Installing build dependencies"). +pub fn step(text: &str) { + eprintln!("{}", styled(Style::Bold, &format!(" -> {text}"))); +} + +#[cfg(test)] +mod tests { + use super::*; + + const COLOR_TERM: Option<&str> = Some("xterm-256color"); + + #[test] + fn auto_respects_tty_and_no_color() { + // TTY, capable TERM, no NO_COLOR → color. + assert!(resolve_color(ColorChoice::Auto, true, None, COLOR_TERM)); + // Not a TTY → no color regardless of the rest. + assert!(!resolve_color(ColorChoice::Auto, false, None, COLOR_TERM)); + // NO_COLOR set non-empty → no color. + assert!(!resolve_color( + ColorChoice::Auto, + true, + Some("1"), + COLOR_TERM + )); + // NO_COLOR set but empty → does not disable (no-color.org). + assert!(resolve_color(ColorChoice::Auto, true, Some(""), COLOR_TERM)); + } + + #[test] + fn auto_respects_term() { + assert!(!resolve_color(ColorChoice::Auto, true, None, None)); + assert!(!resolve_color(ColorChoice::Auto, true, None, Some(""))); + assert!(!resolve_color(ColorChoice::Auto, true, None, Some("dumb"))); + assert!(!resolve_color(ColorChoice::Auto, true, None, Some("DUMB"))); + assert!(!resolve_color( + ColorChoice::Auto, + true, + None, + Some("unknown") + )); + } + + #[test] + fn explicit_choices_override_env_and_tty() { + assert!(resolve_color( + ColorChoice::Always, + false, + Some("1"), + Some("dumb") + )); + assert!(!resolve_color(ColorChoice::Never, true, None, COLOR_TERM)); + } +} diff --git a/packages/debmagic/src/sign.rs b/packages/debmagic/src/sign.rs new file mode 100644 index 00000000..655e1fbf --- /dev/null +++ b/packages/debmagic/src/sign.rs @@ -0,0 +1,586 @@ +//! OpenPGP signing of build artifacts (`.changes`/`.dsc`/`.buildinfo`), as alternative to debsign. +//! +//! Signing always runs on the host with the host's gpg: the `.changes` and +//! its children are exported to the host output dir before signing, so no +//! agent forwarding or keyring seeding in containers is needed. Children are +//! signed first (`.dsc`, then `.buildinfo`), and after each child the +//! parent's checksums are rewritten. + +use std::{ + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; + +use anyhow::{Context, bail}; +use deb822_lossless::{Deb822, Paragraph}; +use debian_control::pgp; +use md5::Md5; +use serde::{Deserialize, Serialize}; +use sha1::Sha1; +use sha2::{Digest, Sha256}; + +use crate::driver::SignRequest; +use crate::output::notify_send_bell; + +/// Which OpenPGP implementation performs the signing. +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)] +#[serde(rename_all = "snake_case")] +pub enum SignTool { + /// GnuPG's `gpg` (the default). + #[default] + Gpg, + /// Sequoia's `sq`. + Sequoia, + /// A custom command from `sign.command`, run without a shell with the + /// `{file}`, `{key}` and `{email}` placeholders substituted. + Custom, +} + +/// How to sign: which key to use, and which program does the OpenPGP work. +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +#[serde(default)] +pub struct SignOptions { + /// GPG key ID/fingerprint/email to sign with. `None` falls back to the + /// `Changed-By:`/`Maintainer:` address of the file being signed, which + /// the signing tool matches against the keyring itself. + pub key: Option, + /// Which OpenPGP implementation to use. + pub tool: SignTool, + /// Custom signing command when `tool` is [`SignTool::Custom`]. Run + /// without a shell; `{file}`, `{key}` and `{email}` placeholders are + /// substituted, and if no `{file}` is given the file path is appended + /// as the last argument. The clearsigned result is read from stdout. + pub command: Option, +} + +/// Sign the `.changes` file (and its `.dsc`/`.buildinfo` children) on the +/// host, as resolved from the build's `sign` config. +pub fn sign_changes(request: &SignRequest) -> anyhow::Result<()> { + let options = SignOptions { + key: request.sign_key.map(str::to_string), + tool: request.sign_tool, + command: request.sign_command.map(str::to_string), + }; + sign_file( + request.changes_file, + &options, + request.notify, + request.package, + ) +} + +/// Read the single deb822 paragraph of a `.changes`/`.dsc`/`.buildinfo` +/// control file, losslessly, so checksum rewrites preserve the original +/// formatting of untouched fields byte-for-byte. +fn read_control(path: &Path) -> anyhow::Result { + let deb822 = + Deb822::from_file(path).with_context(|| format!("failed to parse {}", path.display()))?; + deb822 + .paragraphs() + .next() + .with_context(|| format!("{} contains no paragraph", path.display())) +} + +fn write_control(paragraph: &Paragraph, path: &Path) -> anyhow::Result<()> { + std::fs::write(path, paragraph.to_string()) + .with_context(|| format!("failed to write {}", path.display())) +} + +/// Filenames ending in `.` listed under `Files:` or any +/// `Checksums-*:` field. +fn child_filename(paragraph: &Paragraph, ext: &str) -> Option { + let suffix = format!(".{ext}"); + for key in paragraph.keys() { + if key != "Files" && !key.starts_with("Checksums-") { + continue; + } + for line in paragraph + .get(&key) + .into_iter() + .flat_map(|v| v.lines().map(str::to_string).collect::>()) + { + if let Some(name) = line.split_whitespace().next_back() + && name.ends_with(&suffix) + { + return Some(name.to_string()); + } + } + } + None +} + +/// The hash algorithms used by the checksum fields of a control file. +#[derive(Clone, Copy)] +enum Hash { + Md5, + Sha1, + Sha256, +} + +impl Hash { + fn hex(self, data: &[u8]) -> String { + fn hex(mut hasher: D, data: &[u8]) -> String { + hasher.update(data); + hasher + .finalize() + .iter() + .map(|b| format!("{b:02x}")) + .collect() + } + match self { + Hash::Md5 => hex(Md5::new(), data), + Hash::Sha1 => hex(Sha1::new(), data), + Hash::Sha256 => hex(Sha256::new(), data), + } + } +} + +/// The checksum fields debmagic understands, mapped to their hash. +const CHECKSUM_FIELDS: &[(&str, Hash)] = &[ + ("Files", Hash::Md5), + ("Checksums-Sha1", Hash::Sha1), + ("Checksums-Sha256", Hash::Sha256), +]; + +/// Rewrite one `Files:`/`Checksums-*:` line: the first token is the +/// checksum, the second the size, the last the filename; entries for +/// other files pass through unchanged. +fn rewrite_checksum_line(line: &str, filename: &str, checksum: &str, size: usize) -> String { + let tokens: Vec<&str> = line.split_whitespace().collect(); + match tokens.as_slice() { + [old_checksum, old_size, middle @ .., name] if *name == filename => { + let middle = if middle.is_empty() { + String::new() + } else { + format!(" {}", middle.join(" ")) + }; + format!("{checksum} {size}{middle} {name}") + } + _ => line.to_string(), + } +} + +/// Rewrite the size and checksum entries for the file listings in a control file +fn fixup_checksums(paragraph: &mut Paragraph, filename: &str, data: &[u8]) -> anyhow::Result<()> { + let size = data.len(); + + for key in paragraph.keys() { + if key.starts_with("Checksums-") && !CHECKSUM_FIELDS.iter().any(|(field, ..)| *field == key) + { + // An unknown checksum format would keep a stale checksum for a + // re-signed file, producing an upload that fails verification + // far away from here. + bail!("unknown checksum field '{key}:' in control file"); + } + } + + for (key, hash) in CHECKSUM_FIELDS { + let Some(value) = paragraph.get(key) else { + continue; + }; + let checksum = hash.hex(data); + let updated = value + .lines() + .filter(|line| !line.is_empty()) + .map(|line| rewrite_checksum_line(line, filename, &checksum, size)) + .collect::>() + .join("\n"); + paragraph.set(key, &updated); + } + Ok(()) +} + +/// Is the file already clearsigned? +fn is_signed(path: &Path) -> anyhow::Result { + let first = std::fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))? + .lines() + .next() + .unwrap_or_default() + .to_string(); + Ok(first == "-----BEGIN PGP SIGNED MESSAGE-----") +} + +/// Strip an existing clearsign armor, leaving the plain message. +fn unsign(path: &Path) -> anyhow::Result<()> { + let content = std::fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + let (mut payload, _) = pgp::strip_pgp_signature(&content) + .with_context(|| format!("failed to strip the signature from {}", path.display()))?; + // The armor's blank separator line before the signature is part of the + // extracted payload; drop it so re-signing doesn't accumulate blank + // lines. + if payload.ends_with("\n\n") { + payload.pop(); + } + std::fs::write(path, payload).with_context(|| format!("failed to write {}", path.display())) +} + +/// The key/user to sign as: explicit key, else the file's `Changed-By:` or +/// `Maintainer:` address. +fn guess_signas(options: &SignOptions, control: &Paragraph) -> String { + if let Some(key) = &options.key { + return key.clone(); + } + control + .get("Changed-By") + .or_else(|| control.get("Maintainer")) + .unwrap_or_default() +} + +/// Substitute the `{file}`, `{key}` and `{email}` placeholders in one +/// `sign.command` argument, rejecting unknown or unterminated ones. +fn substitute_placeholders( + arg: &str, + file: &str, + key: &str, + email: Option<&str>, +) -> anyhow::Result { + let mut out = String::new(); + let mut rest = arg; + while let Some(start) = rest.find('{') { + out.push_str(&rest[..start]); + let after = &rest[start + 1..]; + let end = after + .find('}') + .with_context(|| format!("unterminated '{{' in sign.command argument '{arg}'"))?; + let name = &after[..end]; + let value = match name { + "file" => file, + "key" => key, + "email" => match email { + Some(email) => email, + None => bail!("{{email}} used in sign.command but '{key}' contains no address"), + }, + _ => bail!( + "unknown placeholder '{{{name}}}' in sign.command (supported: {{file}}, {{key}}, {{email}})" + ), + }; + out.push_str(value); + rest = &after[end + 1..]; + } + out.push_str(rest); + Ok(out) +} + +/// The bare address of a `Name ` signer value, if any. +fn signer_email(signas: &str) -> Option<&str> { + signas + .split_whitespace() + .find(|token| token.contains('@')) + .map(|token| token.trim_matches(|c| c == '<' || c == '>')) +} + +/// Clearsign `path` in place: the file gets a trailing +/// newline appended before signing, and the armored result replaces it. +fn sign_one(path: &Path, signas: &str, options: &SignOptions) -> anyhow::Result<()> { + let unsigned = + std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))?; + let mut to_sign = unsigned; + to_sign.push(b'\n'); + + let mut cmd = Command::new("gpg"); + // gpg and sq sign what is piped to stdin and write the armor to stdout; + // a custom command receives the file path as an argument instead. + let mut pipes_stdin = true; + match options.tool { + SignTool::Gpg => { + cmd.args([ + "--no-auto-check-trustdb", + "--local-user", + signas, + "--clearsign", + "--openpgp", + "--personal-digest-preferences", + "SHA512 SHA384 SHA256 SHA224", + "--list-options", + "no-show-policy-urls", + "--armor", + "--textmode", + ]); + } + SignTool::Sequoia => { + cmd = Command::new("sq"); + cmd.arg("sign").arg("--cleartext").arg("--mode=text"); + // sq selects the key by email, fingerprint or key ID. + if signas.contains('@') { + cmd.arg("--signer-email").arg(signas); + } else { + cmd.arg("--signer").arg(signas); + } + } + SignTool::Custom => { + let command = options + .command + .as_deref() + .with_context(|| "sign.tool = \"custom\" requires sign.command to be set")?; + let mut parts = command.split_whitespace(); + let program = parts.next().with_context(|| "sign.command is empty")?; + cmd = Command::new(program); + let file = path.display().to_string(); + let email = signer_email(signas); + let mut has_file = false; + for part in parts { + has_file |= part.contains("{file}"); + cmd.arg(substitute_placeholders(part, &file, signas, email)?); + } + if !has_file { + cmd.arg(file); + } + pipes_stdin = false; + } + } + if pipes_stdin { + cmd.args(["--output", "-", "-"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()); + } else { + cmd.stdout(Stdio::piped()); + } + + let mut child = cmd + .spawn() + .with_context(|| format!("failed to run signing command for {}", path.display()))?; + if pipes_stdin { + use std::io::Write; + child + .stdin + .take() + .expect("stdin is piped") + .write_all(&to_sign) + .with_context(|| format!("failed to pipe {} to the signing command", path.display()))?; + } + let output = child + .wait_with_output() + .with_context(|| format!("waiting for the signing command of {}", path.display()))?; + if !output.status.success() { + bail!( + "signing {} failed (exit status: {}):\n{}", + path.display(), + output.status, + String::from_utf8_lossy(&output.stderr) + ); + } + + std::fs::write(path, output.stdout) + .with_context(|| format!("failed to write signed {}", path.display()))?; + Ok(()) +} + +/// Sign `path` (a `.changes`, `.buildinfo` or `.dsc` file) and, for a +/// `.changes`, its `.dsc`/`.buildinfo` children, rewriting the parent's +/// checksums after each child. +pub fn sign_file( + path: &Path, + options: &SignOptions, + notify: bool, + package: &str, +) -> anyhow::Result<()> { + let dir = path + .parent() + .context("changes file has no parent directory")?; + let mut control = read_control(path)?; + + let signas = guess_signas(options, &control); + if signas.is_empty() { + bail!( + "no signing key configured and {} has no Changed-By/Maintainer to derive one from", + path.display() + ); + } + if notify { + notify_send_bell( + "debmagic: signing requested", + &format!("touch your key to sign {package}"), + ); + } + // Children first: .dsc, then .buildinfo. + let mut signed = Vec::new(); + for ext in ["dsc", "buildinfo"] { + if let Some(name) = child_filename(&control, ext) { + let child = dir.join(&name); + if !child.is_file() { + bail!( + "{} references {} but it does not exist", + path.display(), + name + ); + } + if is_signed(&child)? { + println!("debmagic: {name} is already signed; re-signing"); + unsign(&child)?; + } + sign_one(&child, &signas, options)?; + println!("debmagic: signed {name}"); + signed.push(( + name, + std::fs::read(&child).context("re-reading signed child")?, + )); + } + } + + // Rewrite the parent's checksums for every (re)signed child. + for (name, data) in &signed { + fixup_checksums(&mut control, name, data)?; + } + write_control(&control, path)?; + + if is_signed(path)? { + println!("debmagic: {} is already signed; re-signing", path.display()); + unsign(path)?; + } + sign_one(path, &signas, options)?; + println!("debmagic: signed {}", path.display()); + Ok(()) +} + +/// Locate the `.changes` file to sign for the current source tree: any +/// `__*.changes` in `output_dir`. When several match, +/// source-only (`_source`) is preferred, and the multiarch variants +/// (`_multi`, `_+`) are accepted too. +pub fn find_changes_file( + package: &str, + version: &str, + output_dir: &Path, +) -> anyhow::Result { + let sversion = version + .split_once(':') + .map(|(_, rest)| rest) + .unwrap_or(version); + + let pattern = output_dir.join(format!("{package}_{sversion}_*.changes")); + let mut matches: Vec = glob::glob(&pattern.to_string_lossy()) + .with_context(|| format!("invalid changes glob {}", pattern.display()))? + .filter_map(Result::ok) + .collect(); + matches.sort(); + + let pick = matches + .iter() + .find(|path| { + path.file_name() + .is_some_and(|name| name.to_string_lossy().ends_with("_source.changes")) + }) + .or_else(|| matches.first()) + .with_context(|| { + format!( + "could not find a .changes file for {package} {version} in {}", + output_dir.display() + ) + })?; + Ok(pick.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_control(dir: &Path, name: &str, content: &str) -> PathBuf { + let path = dir.join(name); + std::fs::write(&path, content).unwrap(); + path + } + + fn parse_control(content: &str) -> Paragraph { + content.parse().unwrap() + } + + #[test] + fn child_filename_finds_dsc_and_buildinfo() { + let control = parse_control( + "Format: 1.8\nSource: pkg\nFiles:\n abc 123 pkg_1.0.dsc\n def 456 pkg_1.0.buildinfo\n ghi 789 other.txt\nChecksums-Sha256:\n xyz 123 pkg_1.0.dsc\n", + ); + assert_eq!( + child_filename(&control, "dsc").as_deref(), + Some("pkg_1.0.dsc") + ); + assert_eq!( + child_filename(&control, "buildinfo").as_deref(), + Some("pkg_1.0.buildinfo") + ); + assert_eq!(child_filename(&control, "deb"), None); + } + + #[test] + fn fixup_rewrites_all_checksum_sections() { + let mut control = parse_control( + "Format: 1.8\nFiles:\n oldmd5 3 hash optional pkg_1.0.dsc\nChecksums-Sha1:\n oldsha1 3 pkg_1.0.dsc\nChecksums-Sha256:\n oldsha256 3 pkg_1.0.dsc\n", + ); + let data = b"abc"; + fixup_checksums(&mut control, "pkg_1.0.dsc", data).unwrap(); + + fn hex(mut hasher: D, data: &[u8]) -> String { + hasher.update(data); + hasher + .finalize() + .iter() + .map(|b| format!("{b:02x}")) + .collect() + } + let md5 = hex(Md5::new(), data); + let joined = control.to_string(); + assert!(joined.contains(&format!("{md5} 3 hash optional pkg_1.0.dsc"))); + assert!(joined.contains(&format!(" {} 3 pkg_1.0.dsc", hex(Sha1::new(), data)))); + assert!(joined.contains(&format!(" {} 3 pkg_1.0.dsc", hex(Sha256::new(), data)))); + } + + #[test] + fn guess_signas_prefers_key_then_changed_by() { + let options = SignOptions { + key: Some("mykey".into()), + tool: SignTool::Gpg, + command: None, + }; + let control = + parse_control("Maintainer: A \nChanged-By: B \n"); + assert_eq!(guess_signas(&options, &control), "mykey"); + + let options = SignOptions::default(); + assert_eq!(guess_signas(&options, &control), "B "); + + let control = parse_control("Maintainer: A \n"); + assert_eq!(guess_signas(&options, &control), "A "); + } + + #[test] + fn render_arg_substitutes_placeholders() { + assert_eq!( + substitute_placeholders("sign --key {key} {file}", "/tmp/f.dsc", "me", None).unwrap(), + "sign --key me /tmp/f.dsc" + ); + assert_eq!( + substitute_placeholders("--sender={email}", "f", "A ", Some("a@b.c")).unwrap(), + "--sender=a@b.c" + ); + assert_eq!( + substitute_placeholders("plain", "f", "k", None).unwrap(), + "plain" + ); + + assert!(substitute_placeholders("{typo}", "f", "k", None).is_err()); + assert!(substitute_placeholders("{unterminated", "f", "k", None).is_err()); + assert!(substitute_placeholders("{email}", "f", "k", None).is_err()); + } + + #[test] + fn signer_email_extracts_address() { + assert_eq!(signer_email("B "), Some("b@example.com")); + assert_eq!(signer_email("b@example.com"), Some("b@example.com")); + assert_eq!(signer_email("ABC1234"), None); + } + + #[test] + fn unsign_strips_armor() { + let dir = std::env::temp_dir().join("debmagic-sign-test-unsign"); + std::fs::create_dir_all(&dir).unwrap(); + let path = write_control( + &dir, + "f.dsc", + "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA512\n\nFormat: 3.0\n\n-----BEGIN PGP SIGNATURE-----\nsig\n-----END PGP SIGNATURE-----\n", + ); + unsign(&path).unwrap(); + let content = std::fs::read_to_string(&path).unwrap(); + assert_eq!(content, "Format: 3.0\n"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/packages/debmagic/src/signing.rs b/packages/debmagic/src/signing.rs deleted file mode 100644 index 0e161215..00000000 --- a/packages/debmagic/src/signing.rs +++ /dev/null @@ -1,256 +0,0 @@ -//! GPG signing of build artifacts (`.changes`/`.dsc`) via `debsign`. -//! -//! Signing can happen on the host (traditional, requires `devscripts` -//! locally) or inside a minimal same-distro container. In the container case -//! the host's gpg-agent *extra* socket is forwarded in, so private key -//! material never leaves the host — the agent on the host performs the -//! signing operations, and only the public key is imported into the -//! container's keyring. - -use std::{ - path::{Path, PathBuf}, - process::{Command, Stdio}, -}; - -use anyhow::{Context, anyhow}; -use serde::{Deserialize, Serialize}; - -use crate::driver::run_checked; - -/// Where the forwarded agent socket is bind-mounted inside sign containers. -/// A fixed, always-existing path; the script symlinks it to gpg's lookup -/// locations so plain `debsign` works without extra flags or env vars. -pub const GPG_SOCKET_IN_CONTAINER: &str = "/tmp/debmagic-gpg/S.gpg-agent"; -/// Directory mounted read-only into sign containers, holding the exported -/// public key and ownertrust line produced on the host. -pub const SIGN_STAGING_IN_CONTAINER: &str = "/debmagic-sign"; -/// Mount point of the output directory inside sign containers. -pub const OUTPUT_DIR_IN_CONTAINER: &str = "/debmagic-output"; - -pub const PUBKEY_FILE: &str = "pubkey.asc"; -pub const OWNERTRUST_FILE: &str = "ownertrust.txt"; - -/// Selects where `debsign` runs. -#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, clap::ValueEnum, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SignWith { - /// Use the host if `debsign` is available there, otherwise a container - /// (requires a containerized build driver). - #[default] - Auto, - /// Always sign on the host with `debsign`. - Host, - /// Sign inside a minimal container of the same distro, forwarding the - /// host's gpg-agent socket. Requires `sign_key` to be set. - Same, -} - -/// Everything needed to GPG-sign inside a container: the host's gpg-agent -/// extra socket plus the public key to seed the container's keyring with. -pub struct GpgForwarding { - pub agent_extra_socket: PathBuf, - pub sign_key: String, -} - -/// Resolve the host's gpg-agent *extra* socket — the restricted variant -/// intended for forwarding into chroots/containers (signing works, key -/// export and management don't). -pub fn gpg_agent_extra_socket() -> anyhow::Result { - let output = Command::new("gpgconf") - .args(["--list-dirs", "agent-extra-socket"]) - .stdout(Stdio::piped()) - .output() - .context("failed to run gpgconf; is gpg installed?")?; - if !output.status.success() { - return Err(anyhow!( - "gpgconf --list-dirs agent-extra-socket failed; is gpg-agent set up?" - )); - } - let path = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim().to_string()); - if !path.exists() { - return Err(anyhow!( - "gpg-agent extra socket {} does not exist; is gpg-agent running?", - path.display() - )); - } - Ok(path) -} - -/// Verify that the host gpg setup can sign with `sign_key` (secret key -/// available via the agent). Intended as a pre-flight check so builds don't -/// fail at the signing step after all the work is done. -pub fn check_signing_key_available(sign_key: &str) -> anyhow::Result<()> { - let output = Command::new("gpg") - .args(["--batch", "--list-secret-keys", sign_key]) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() - .context("failed to run gpg; is it installed?")?; - if !output.status.success() || output.stdout.is_empty() { - return Err(anyhow!( - "no secret key for '{sign_key}' available to gpg; \ - import it on the host or pick a different sign_key" - )); - } - Ok(()) -} - -/// Export the public key for `sign_key` from the host keyring. -pub fn export_public_key(sign_key: &str) -> anyhow::Result> { - let output = Command::new("gpg") - .args(["--batch", "--export", sign_key]) - .stdout(Stdio::piped()) - .output() - .context("failed to run gpg --export")?; - if !output.status.success() || output.stdout.is_empty() { - return Err(anyhow!( - "failed to export public key for '{sign_key}' from the host keyring" - )); - } - Ok(output.stdout) -} - -/// Fingerprint of the key `debsign` will use, for ownertrust seeding. -pub fn key_fingerprint(sign_key: &str) -> anyhow::Result { - let output = Command::new("gpg") - .args(["--batch", "--with-colons", "--list-secret-keys", sign_key]) - .stdout(Stdio::piped()) - .output() - .context("failed to run gpg --list-secret-keys")?; - if !output.status.success() { - return Err(anyhow!("failed to look up fingerprint for '{sign_key}'")); - } - for line in String::from_utf8_lossy(&output.stdout).lines() { - let fields: Vec<&str> = line.split(':').collect(); - if fields.first() == Some(&"fpr") - && let Some(fpr) = fields.get(9) - { - return Ok(fpr.to_string()); - } - } - Err(anyhow!("no fingerprint found for key '{sign_key}'")) -} - -/// Stage the files a sign container needs (exported public key + ownertrust) -/// into `staging_dir` on the host; the drivers mount it read-only at -/// [`SIGN_STAGING_IN_CONTAINER`]. -pub fn stage_signing_material(staging_dir: &Path, sign_key: &str) -> anyhow::Result<()> { - std::fs::create_dir_all(staging_dir)?; - std::fs::write(staging_dir.join(PUBKEY_FILE), export_public_key(sign_key)?)?; - // Ownertrust format: "::"; 6 = ultimate. The - // key is the user's own, freshly imported into a throwaway keyring. - let ownertrust = format!("{}:6:\n", key_fingerprint(sign_key)?); - std::fs::write(staging_dir.join(OWNERTRUST_FILE), ownertrust)?; - Ok(()) -} - -/// Shell script run inside a sign container: install debsign, link the -/// forwarded agent socket where gpg looks for it, seed the throwaway keyring -/// with the public key plus ownertrust, then sign. Runs as root; `chown_to` -/// fixes ownership of the bind-mounted output dir afterwards when the -/// container's root is not id-mapped to the host user (docker). -pub fn sign_container_script( - changes_filename: &str, - sign_key: &str, - chown_to: Option<(u32, u32)>, -) -> String { - let chown = match chown_to { - Some((uid, gid)) => format!( - " && chown -R {uid}:{gid} {out}", - out = OUTPUT_DIR_IN_CONTAINER - ), - None => String::new(), - }; - format!( - "set -e; \ - export GNUPGHOME=/root/.gnupg; \ - mkdir -p /run/user/0/gnupg \"$GNUPGHOME\"; \ - chmod 700 /run/user/0/gnupg \"$GNUPGHOME\"; \ - ln -sf {sock} /run/user/0/gnupg/S.gpg-agent; \ - ln -sf {sock} \"$GNUPGHOME/S.gpg-agent\"; \ - apt-get update -qq; \ - apt-get install -y -qq devscripts; \ - gpg --batch --import {staging}/{pubkey}; \ - gpg --batch --import-ownertrust {staging}/{ownertrust}; \ - cd {out} && debsign -k{key} {changes}{chown}", - sock = GPG_SOCKET_IN_CONTAINER, - staging = SIGN_STAGING_IN_CONTAINER, - pubkey = PUBKEY_FILE, - ownertrust = OWNERTRUST_FILE, - out = OUTPUT_DIR_IN_CONTAINER, - key = shell_single_quote(sign_key), - changes = shell_single_quote(changes_filename), - ) -} - -/// Sign `changes_file` on the host with `debsign`. -pub fn sign_on_host(changes_file: &Path, sign_key: Option<&str>) -> anyhow::Result<()> { - let output_dir = changes_file.parent().ok_or_else(|| { - anyhow!( - "could not get output directory of {}", - changes_file.display() - ) - })?; - let filename = changes_file - .file_name() - .ok_or_else(|| anyhow!("could not get filename of {}", changes_file.display()))?; - - let mut cmd = Command::new("debsign"); - if let Some(key) = sign_key { - cmd.arg(format!("-k{key}")); - } - cmd.arg(filename).current_dir(output_dir); - run_checked(&mut cmd, &format!("signing {}", changes_file.display()))?; - Ok(()) -} - -pub fn check_host_debsign_available() -> anyhow::Result<()> { - match Command::new("debsign") - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - { - Ok(_) => Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(anyhow!( - "debsign not found on PATH. It's part of devscripts; install it and set up a \ - gpg signing key, or set sign_with to \"same\" with a container driver." - )), - Err(e) => Err(e).context("failed to check for debsign"), - } -} - -pub fn changes_filename(changes_file: &Path) -> anyhow::Result<&str> { - changes_file - .file_name() - .and_then(|name| name.to_str()) - .ok_or_else(|| anyhow!("could not get filename of {}", changes_file.display())) -} - -fn shell_single_quote(s: &str) -> String { - format!("'{}'", s.replace('\'', "'\\''")) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sign_script_quotes_filename_and_key() { - let script = sign_container_script("pkg_1.0_amd64.changes", "me@example.com", None); - assert!(script.contains("debsign -k'me@example.com' 'pkg_1.0_amd64.changes'")); - assert!(script.contains("gpg --batch --import /debmagic-sign/pubkey.asc")); - assert!(!script.contains("chown")); - } - - #[test] - fn sign_script_chowns_output_when_requested() { - let script = sign_container_script("x.changes", "key", Some((1000, 100))); - assert!(script.contains("chown -R 1000:100 /debmagic-output")); - } - - #[test] - fn shell_quote_escapes_single_quotes() { - assert_eq!(shell_single_quote("a'b"), "'a'\\''b'"); - } -} diff --git a/packages/debmagic/src/test/intent.rs b/packages/debmagic/src/test/intent.rs index 1cd7688f..7c29bf2e 100644 --- a/packages/debmagic/src/test/intent.rs +++ b/packages/debmagic/src/test/intent.rs @@ -3,7 +3,6 @@ use std::path::PathBuf; use anyhow::Context; use crate::{ - build_intent::load_config, config::Config, driver::{DriverType, config::DriverOverrides}, }; @@ -45,7 +44,7 @@ pub fn resolve_test_intent(input: TestIntentInput) -> anyhow::Result let source_dir = std::path::absolute(input.source_dir.unwrap_or(input.fallback_dir)) .context("resolving source dir failed")?; - let mut config = load_config(Some(&source_dir), input.config_file.as_deref())?; + let mut config = Config::load(Some(&source_dir), input.config_file.as_deref())?; if let Some(persistent) = input.persistent { config.driver.persistent = persistent; diff --git a/packages/debmagic/src/test/run.rs b/packages/debmagic/src/test/run.rs index 8690b9b9..4ec97e81 100644 --- a/packages/debmagic/src/test/run.rs +++ b/packages/debmagic/src/test/run.rs @@ -7,7 +7,7 @@ use std::{ use super::intent::TestIntent; use crate::build::source::stage_source_tree; use crate::driver::{ - Driver, DriverInstance, DriverType, Environment, EnvironmentMetadata, EnvironmentPurpose, + Driver, DriverType, Environment, EnvironmentDriver, EnvironmentMetadata, EnvironmentPurpose, IsolationCapability, config::{DriverConfig, DriverOverrides}, create_driver, remove_environment_root, @@ -39,7 +39,7 @@ pub enum TestOutcome { struct TestRun { environment: Environment, - driver: DriverInstance, + driver: Driver, } fn get_build_root_and_identifier( @@ -286,6 +286,7 @@ pub fn run_test(intent: &TestIntent) -> anyhow::Result { .write_metadata() .context("failed to write test metadata")?; + crate::output::stage("Installing autopkgtest"); test_run.driver.run_command_checked( &["apt-get", "install", "-y", "autopkgtest"], &environment.staged_source_dir(), @@ -326,6 +327,7 @@ pub fn run_test(intent: &TestIntent) -> anyhow::Result { "null", ]); + crate::output::stage(&format!("Running autopkgtest for {}", identity.name)); let exit_code = test_run .driver .run_command(&autopkgtest_cmd, &work_dir, true, &[]) diff --git a/packages/debmagic/tests/assets/config1.toml b/packages/debmagic/tests/assets/config1.toml index 5a1496f0..542e4aca 100644 --- a/packages/debmagic/tests/assets/config1.toml +++ b/packages/debmagic/tests/assets/config1.toml @@ -1,4 +1,5 @@ [driver] +default = "docker" persistent = true [driver.docker] diff --git a/packages/debmagic/tests/sign.rs b/packages/debmagic/tests/sign.rs new file mode 100644 index 00000000..80d454fb --- /dev/null +++ b/packages/debmagic/tests/sign.rs @@ -0,0 +1,201 @@ +//! End-to-end test for signing: builds a throwaway gpg key with a loopback +//! pinentry, crafts a minimal source package artifact set (`.dsc` + +//! `.changes`), then signs it with `debmagic sign` and verifies the result +//! with gpg. +//! +//! Requires gpg on the host. Ignored by default; run with: +//! +//! ```shell +//! cargo test --test signing -- --ignored --nocapture +//! ``` + +use std::{ + fs, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; + +/// Run `cmd`, panicking with stdout+stderr on failure. +fn run(cmd: &mut Command) -> String { + let output = cmd.output().expect("failed to spawn command"); + if !output.status.success() { + panic!( + "command failed: {:?}\nstdout: {}\nstderr: {}", + cmd, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + String::from_utf8_lossy(&output.stdout).into_owned() +} + +struct TestGpgHome { + dir: PathBuf, +} + +impl TestGpgHome { + /// Create an isolated GNUPGHOME with a throwaway signing key and an + /// agent that answers without pinentry. + fn create() -> Self { + let dir = std::env::temp_dir().join(format!("debmagic-sign-test-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&dir).unwrap(); + // gpg refuses to use a homedir others could access. + run(Command::new("chmod").args(["700"]).arg(&dir)); + + fs::write(dir.join("gpg-agent.conf"), "allow-loopback-pinentry\n").unwrap(); + fs::write(dir.join("gpg.conf"), "pinentry-mode loopback\n").unwrap(); + + run(Command::new("gpgconf") + .env("GNUPGHOME", &dir) + .args(["--launch", "gpg-agent"])); + + run(Command::new("gpg").env("GNUPGHOME", &dir).args([ + "--batch", + "--passphrase", + "", + "--quick-generate-key", + "debmagic sign test ", + "ed25519", + "sign", + "never", + ])); + + Self { dir } + } +} + +impl Drop for TestGpgHome { + fn drop(&mut self) { + let _ = Command::new("gpgconf") + .env("GNUPGHOME", &self.dir) + .args(["--kill", "all"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + let _ = fs::remove_dir_all(&self.dir); + } +} + +fn sha256(data: &[u8]) -> String { + let mut child = Command::new("sha256sum") + .arg("-") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("failed to spawn sha256sum"); + use std::io::Write; + child + .stdin + .as_mut() + .unwrap() + .write_all(data) + .expect("failed to pipe to sha256sum"); + let out = child.wait_with_output().expect("sha256sum failed"); + String::from_utf8_lossy(&out.stdout) + .split_whitespace() + .next() + .unwrap() + .to_string() +} + +/// Write a minimal artifact set (`hello.txt`, `.dsc`, `.changes`) with +/// consistent sizes and checksums. +fn write_fake_artifacts(output_dir: &Path) -> PathBuf { + fs::create_dir_all(output_dir).unwrap(); + let payload = b"hello from debmagic sign test\n"; + fs::write(output_dir.join("hello.txt"), payload).unwrap(); + + let files_entry = |name: &str, data: &[u8]| { + format!( + " {} {} {} {} {}", + sha256(data), + data.len(), + "x", + "optional", + name + ) + }; + + let dsc_content = format!( + "Format: 3.0 (native)\nSource: debmagic-sign-test\nBinary: debmagic-sign-test\nVersion: 1.0\nMaintainer: debmagic sign test \nArchitecture: all\nFiles:\n{}\n", + files_entry("hello.txt", payload) + ); + let dsc_name = "debmagic-sign-test_1.0.dsc"; + fs::write(output_dir.join(dsc_name), &dsc_content).unwrap(); + + let changes_content = format!( + "Format: 1.8\nSource: debmagic-sign-test\nBinary: debmagic-sign-test\nVersion: 1.0\nMaintainer: debmagic sign test \nChanged-By: debmagic sign test \nArchitecture: source all\nDistribution: unstable\nFiles:\n{}\n{}\n", + files_entry(dsc_name, dsc_content.as_bytes()), + files_entry("hello.txt", payload) + ); + let changes_path = output_dir.join("debmagic-sign-test_1.0_amd64.changes"); + fs::write(&changes_path, &changes_content).unwrap(); + changes_path +} + +#[test] +#[ignore = "needs gpg on the host"] +fn signs_changes_and_dsc_and_rewrites_checksums() { + let gpg_home = TestGpgHome::create(); + let work_dir = std::env::temp_dir().join(format!("debmagic-sign-out-{}", uuid::Uuid::new_v4())); + let changes_path = write_fake_artifacts(&work_dir); + + // Hermetic: ignore the user's global config and pin the key explicitly, + // like debsign's maintainer lookup would resolve it. + let bin = env!("CARGO_BIN_EXE_debmagic"); + run(Command::new(bin) + .env("GNUPGHOME", &gpg_home.dir) + .env("DEBMAGIC_CONFIG_GLOBAL", "/dev/null") + .args(["sign", "--sign-key", "sign@example.invalid"]) + .arg(&changes_path)); + + let signed = fs::read_to_string(&changes_path).unwrap(); + assert!( + signed.contains("-----BEGIN PGP SIGNATURE-----"), + "changes file was not signed:\n{signed}" + ); + let dsc_path = work_dir.join("debmagic-sign-test_1.0.dsc"); + let signed_dsc = fs::read_to_string(&dsc_path).unwrap(); + assert!( + signed_dsc.contains("-----BEGIN PGP SIGNATURE-----"), + "dsc file was not signed:\n{signed_dsc}" + ); + + // The .changes checksums must now match the signed .dsc. + let dsc_data = fs::read(&dsc_path).unwrap(); + let md5 = { + let mut child = Command::new("md5sum") + .arg("-") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + use std::io::Write; + child.stdin.as_mut().unwrap().write_all(&dsc_data).unwrap(); + let out = child.wait_with_output().unwrap(); + String::from_utf8_lossy(&out.stdout) + .split_whitespace() + .next() + .unwrap() + .to_string() + }; + assert!( + signed.contains(&format!( + " {md5} {} x optional debmagic-sign-test_1.0.dsc", + dsc_data.len() + )), + "changes checksums were not rewritten for the signed dsc:\n{signed}" + ); + + // Verify both signatures against the test keyring. + run(Command::new("gpg") + .env("GNUPGHOME", &gpg_home.dir) + .args(["--batch", "--verify"]) + .arg(&changes_path)); + run(Command::new("gpg") + .env("GNUPGHOME", &gpg_home.dir) + .args(["--batch", "--verify"]) + .arg(&dsc_path)); + + let _ = fs::remove_dir_all(&work_dir); +} diff --git a/packages/debmagic/tests/signing.rs b/packages/debmagic/tests/signing.rs deleted file mode 100644 index fda42693..00000000 --- a/packages/debmagic/tests/signing.rs +++ /dev/null @@ -1,240 +0,0 @@ -//! End-to-end test for container signing: builds a throwaway gpg key with a -//! loopback pinentry, crafts a minimal source package artifact set -//! (`.dsc` + `.changes`), then has the docker driver sign it in a minimal -//! container via the forwarded gpg-agent socket. -//! -//! Requires docker and gpg on the host. Ignored by default; run with: -//! -//! ```shell -//! cargo test --test signing -- --ignored --nocapture -//! ``` - -use std::{ - fs, - path::{Path, PathBuf}, - process::{Command, Stdio}, -}; - -/// Run `cmd`, panicking with stdout+stderr on failure. -fn run(cmd: &mut Command) -> String { - let output = cmd.output().expect("failed to spawn command"); - if !output.status.success() { - panic!( - "command failed: {:?}\nstdout: {}\nstderr: {}", - cmd, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - } - String::from_utf8_lossy(&output.stdout).into_owned() -} - -struct TestGpgHome { - dir: PathBuf, -} - -impl TestGpgHome { - /// Create an isolated GNUPGHOME with a throwaway signing key and an - /// agent that answers without pinentry. - fn create() -> Self { - let dir = std::env::temp_dir().join(format!("debmagic-sign-test-{}", uuid::Uuid::new_v4())); - fs::create_dir_all(&dir).unwrap(); - // gpg refuses to use a homedir others could access. - run(Command::new("chmod").args(["700"]).arg(&dir)); - - fs::write(dir.join("gpg-agent.conf"), "allow-loopback-pinentry\n").unwrap(); - fs::write(dir.join("gpg.conf"), "pinentry-mode loopback\n").unwrap(); - - run(Command::new("gpgconf") - .env("GNUPGHOME", &dir) - .args(["--launch", "gpg-agent"])); - - run(Command::new("gpg").env("GNUPGHOME", &dir).args([ - "--batch", - "--passphrase", - "", - "--quick-generate-key", - "debmagic sign test ", - "ed25519", - "sign", - "never", - ])); - - Self { dir } - } - - fn agent_extra_socket(&self) -> PathBuf { - let out = run(Command::new("gpgconf") - .env("GNUPGHOME", &self.dir) - .args(["--list-dirs", "agent-extra-socket"])); - PathBuf::from(out.trim()) - } - - fn fingerprint(&self) -> String { - let out = run(Command::new("gpg").env("GNUPGHOME", &self.dir).args([ - "--batch", - "--with-colons", - "--list-secret-keys", - "sign@example.invalid", - ])); - for line in out.lines() { - let fields: Vec<&str> = line.split(':').collect(); - if fields.first() == Some(&"fpr") { - return fields[9].to_string(); - } - } - panic!("no fingerprint found"); - } -} - -impl Drop for TestGpgHome { - fn drop(&mut self) { - let _ = Command::new("gpgconf") - .env("GNUPGHOME", &self.dir) - .args(["--kill", "all"]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); - let _ = fs::remove_dir_all(&self.dir); - } -} - -/// Write a minimal artifact set (`hello.txt`, `.dsc`, `.changes`) with -/// consistent sizes and sha256 checksums, so `debsign` accepts it. -fn write_fake_artifacts(output_dir: &Path) -> PathBuf { - fs::create_dir_all(output_dir).unwrap(); - let payload = b"hello from debmagic sign test\n"; - fs::write(output_dir.join("hello.txt"), payload).unwrap(); - - let sha256 = |data: &[u8]| -> String { - let mut child = Command::new("sha256sum") - .arg("-") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .spawn() - .expect("failed to spawn sha256sum"); - use std::io::Write; - child - .stdin - .as_mut() - .unwrap() - .write_all(data) - .expect("failed to pipe to sha256sum"); - let out = child.wait_with_output().expect("sha256sum failed"); - String::from_utf8_lossy(&out.stdout) - .split_whitespace() - .next() - .unwrap() - .to_string() - }; - - let files_entry = - |name: &str, data: &[u8]| format!(" {} {} {}", sha256(data), data.len(), name); - - let dsc_content = format!( - "Format: 3.0 (native)\nSource: debmagic-sign-test\nBinary: debmagic-sign-test\nVersion: 1.0\nMaintainer: debmagic sign test \nArchitecture: all\nFiles:\n{}\n", - files_entry("hello.txt", payload) - ); - let dsc_name = "debmagic-sign-test_1.0.dsc"; - fs::write(output_dir.join(dsc_name), &dsc_content).unwrap(); - - let changes_content = format!( - "Format: 1.8\nSource: debmagic-sign-test\nBinary: debmagic-sign-test\nVersion: 1.0\nMaintainer: debmagic sign test \nArchitecture: source all\nDistribution: unstable\nFiles:\n{}\n{}\n", - files_entry(dsc_name, dsc_content.as_bytes()), - files_entry("hello.txt", payload) - ); - let changes_path = output_dir.join("debmagic-sign-test_1.0_amd64.changes"); - fs::write(&changes_path, &changes_content).unwrap(); - changes_path -} - -#[test] -#[ignore = "needs docker and gpg on the host"] -fn docker_signs_changes_with_forwarded_agent() { - // Skip early with a clear message if docker isn't usable. - if Command::new("docker") - .args(["info"]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| !s.success()) - .unwrap_or(true) - { - eprintln!("docker not available, skipping"); - return; - } - - let gpg_home = TestGpgHome::create(); - let work_dir = std::env::temp_dir().join(format!("debmagic-sign-out-{}", uuid::Uuid::new_v4())); - let changes_path = write_fake_artifacts(&work_dir); - - let output_dir = changes_path.parent().unwrap(); - let staging_dir = work_dir.join("staging"); - fs::create_dir_all(&staging_dir).unwrap(); - - // Stage pubkey + ownertrust the same way the driver does. The export is - // binary OpenPGP data, so capture raw bytes rather than a String. - let pubkey = Command::new("gpg") - .env("GNUPGHOME", &gpg_home.dir) - .args(["--batch", "--export", "sign@example.invalid"]) - .output() - .expect("gpg export failed"); - assert!(pubkey.status.success()); - fs::write(staging_dir.join("pubkey.asc"), pubkey.stdout).unwrap(); - fs::write( - staging_dir.join("ownertrust.txt"), - format!("{}:6:\n", gpg_home.fingerprint()), - ) - .unwrap(); - - let socket = gpg_home.agent_extra_socket(); - let script = "set -e; \ - export GNUPGHOME=/root/.gnupg; \ - mkdir -p /run/user/0/gnupg \"$GNUPGHOME\"; \ - chmod 700 /run/user/0/gnupg \"$GNUPGHOME\"; \ - ln -sf /tmp/debmagic-gpg/S.gpg-agent /run/user/0/gnupg/S.gpg-agent; \ - ln -sf /tmp/debmagic-gpg/S.gpg-agent \"$GNUPGHOME/S.gpg-agent\"; \ - apt-get update -qq; \ - apt-get install -y -qq devscripts; \ - gpg --batch --import /debmagic-sign/pubkey.asc; \ - gpg --batch --import-ownertrust /debmagic-sign/ownertrust.txt; \ - cd /debmagic-output && debsign -k'sign@example.invalid' 'debmagic-sign-test_1.0_amd64.changes'"; - - run(Command::new("docker") - .args(["run", "--rm", "--init"]) - .args([ - "--mount", - &format!( - "type=bind,src={},dst=/debmagic-sign,readonly", - staging_dir.display() - ), - ]) - .arg(format!( - "--mount=type=bind,src={},dst=/tmp/debmagic-gpg/S.gpg-agent,readonly", - socket.display() - )) - .args([ - "--mount", - &format!( - "type=bind,src={},dst=/debmagic-output", - output_dir.display() - ), - ]) - .arg("docker.io/debian:trixie") - .args(["sh", "-ec", script])); - - let signed = fs::read_to_string(&changes_path).unwrap(); - assert!( - signed.contains("-----BEGIN PGP SIGNATURE-----"), - "changes file was not signed:\n{signed}" - ); - - // Verify the signature against the test keyring. The .changes file has - // the message and signature in one clearsigned document. - run(Command::new("gpg") - .env("GNUPGHOME", &gpg_home.dir) - .args(["--batch", "--verify"]) - .arg(&changes_path)); - - let _ = fs::remove_dir_all(&work_dir); -}