From 5af126a06ff5de825356a3f91ebe6cece469be62 Mon Sep 17 00:00:00 2001 From: Jonas Jelten Date: Thu, 27 Aug 2026 23:43:15 +0200 Subject: [PATCH 01/11] feat(build): support host_arch_variant to build e.g. amd64v3 --- docs/usage/config.md | 1 + packages/debmagic/src/build/mod.rs | 8 +++++++- packages/debmagic/src/build_intent.rs | 8 ++++++-- packages/debmagic/src/cli.rs | 5 +++++ packages/debmagic/src/config.rs | 4 ++++ packages/debmagic/src/main.rs | 1 + 6 files changed, 24 insertions(+), 3 deletions(-) diff --git a/docs/usage/config.md b/docs/usage/config.md index 9f08d291..f30f7d55 100644 --- a/docs/usage/config.md +++ b/docs/usage/config.md @@ -35,6 +35,7 @@ All keys are optional. | `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. | +| `host_arch_variant` | string | — | Build for a dpkg architecture variant (e.g. `"amd64v3"` on Ubuntu) -> `DEB_HOST_ARCH_VARIANT`. | ### `source_sync_mode` diff --git a/packages/debmagic/src/build/mod.rs b/packages/debmagic/src/build/mod.rs index ebf7cf23..fc76e161 100644 --- a/packages/debmagic/src/build/mod.rs +++ b/packages/debmagic/src/build/mod.rs @@ -37,6 +37,7 @@ struct Build { sign_package: bool, clean: bool, build_debug_symbols: bool, + host_arch_variant: Option, } /// Where debsign will actually run for this build. @@ -125,6 +126,7 @@ impl Build { sign_package: intent.config.sign_package, clean: intent.config.clean, build_debug_symbols: intent.config.build_debug_symbols, + host_arch_variant: intent.config.host_arch_variant.clone(), }) } @@ -164,6 +166,7 @@ impl Build { sign_package: false, clean: false, build_debug_symbols: false, + host_arch_variant: None, }) } @@ -376,7 +379,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 diff --git a/packages/debmagic/src/build_intent.rs b/packages/debmagic/src/build_intent.rs index 088bab6a..dbdc9f15 100644 --- a/packages/debmagic/src/build_intent.rs +++ b/packages/debmagic/src/build_intent.rs @@ -30,6 +30,7 @@ pub struct BuildIntentInput { 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, } @@ -110,13 +111,15 @@ pub fn resolve_build_intent(input: BuildIntentInput) -> anyhow::Result, + #[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, #[arg( long, diff --git a/packages/debmagic/src/config.rs b/packages/debmagic/src/config.rs index 9b6fc4b2..0d06e2a0 100644 --- a/packages/debmagic/src/config.rs +++ b/packages/debmagic/src/config.rs @@ -35,6 +35,9 @@ 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, } impl Default for Config { @@ -50,6 +53,7 @@ impl Default for Config { sign_key: None, clean: false, shell_on_failure: false, + host_arch_variant: None, } } } diff --git a/packages/debmagic/src/main.rs b/packages/debmagic/src/main.rs index 07c7e2f5..5d4f5e28 100644 --- a/packages/debmagic/src/main.rs +++ b/packages/debmagic/src/main.rs @@ -76,6 +76,7 @@ fn run() -> anyhow::Result { 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(), From 38d0ee8610a657c187bf13cd5c3d3a6a5ed83304 Mon Sep 17 00:00:00 2001 From: Jonas Jelten Date: Thu, 27 Aug 2026 23:43:47 +0200 Subject: [PATCH 02/11] fix(signing): agent forwarding inside lxd --- packages/debmagic/src/build/mod.rs | 47 ++++++++++------- packages/debmagic/src/build_intent.rs | 5 +- packages/debmagic/src/cli.rs | 49 ++++++++++++++---- packages/debmagic/src/driver/driver_docker.rs | 16 ++++-- packages/debmagic/src/driver/driver_lxd.rs | 50 +++++++++++++++++-- packages/debmagic/src/driver/mod.rs | 4 ++ packages/debmagic/src/signing.rs | 37 ++++++++++---- 7 files changed, 159 insertions(+), 49 deletions(-) diff --git a/packages/debmagic/src/build/mod.rs b/packages/debmagic/src/build/mod.rs index fc76e161..99b32d5a 100644 --- a/packages/debmagic/src/build/mod.rs +++ b/packages/debmagic/src/build/mod.rs @@ -48,14 +48,15 @@ enum SignLocation { } /// 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. +/// need, so a broken gpg setup doesn't waste a whole build. Must run before +/// any environment is created — the checks are host-side and free, while +/// bootstrapping a container is not. fn prepare_signing( - environment: &Environment, + driver: DriverType, sign_with: SignWith, sign_key: Option<&str>, ) -> anyhow::Result<(SignLocation, Option)> { - let container_driver = environment.driver != DriverType::Bare; + let container_driver = driver != DriverType::Bare; let host_has_debsign = signing::check_host_debsign_available().is_ok(); let location = match sign_with { @@ -100,23 +101,17 @@ fn prepare_signing( } impl Build { - pub fn create(environment: Environment, intent: &BuildIntent) -> anyhow::Result { + pub fn create( + environment: Environment, + intent: &BuildIntent, + gpg_forwarding: Option, + ) -> anyhow::Result { let driver = create_driver( &environment, &intent.config.driver, &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, @@ -200,7 +195,11 @@ fn get_build_root_and_identifier( (package_identifier, build_root) } -fn prepare_build_env(intent: &BuildIntent, target: &PackageTarget) -> anyhow::Result { +fn prepare_build_env( + intent: &BuildIntent, + target: &PackageTarget, + gpg_forwarding: Option, +) -> anyhow::Result { let (package_identifier, build_root) = get_build_root_and_identifier(&intent.config.temp_build_dir, &target.identity); @@ -220,7 +219,7 @@ fn prepare_build_env(intent: &BuildIntent, target: &PackageTarget) -> anyhow::Re if intent.config.driver.persistent && build_root.exists() { // For persistent containers, starting first lets root inside delete // container-owned files the host user can't remove. - let build = Build::create(environment.clone(), intent) + let build = Build::create(environment.clone(), intent, gpg_forwarding) .context(format!("failed to create {:?} driver", intent.driver))?; if !incremental || !source_manifest_path(&environment).is_file() { build @@ -260,7 +259,7 @@ fn prepare_build_env(intent: &BuildIntent, target: &PackageTarget) -> anyhow::Re incremental, )?; - Build::create(environment, intent) + Build::create(environment, intent, gpg_forwarding) } pub fn get_shell_in_build(config: &Config, identity: &PackageIdentity) -> anyhow::Result<()> { @@ -305,7 +304,17 @@ fn run_build( request: &BuildRequest, build_commands: impl FnOnce(&Build) -> anyhow::Result<()>, ) -> anyhow::Result<()> { - let build = prepare_build_env(request.intent, request.target) + let gpg_forwarding = if request.intent.config.sign_package { + let (_location, forwarding) = prepare_signing( + request.intent.driver, + request.intent.config.sign_with, + request.intent.config.sign_key.as_deref(), + )?; + forwarding + } else { + None + }; + let build = prepare_build_env(request.intent, request.target, gpg_forwarding) .context("failed to prepare build environment")?; build .write_metadata() diff --git a/packages/debmagic/src/build_intent.rs b/packages/debmagic/src/build_intent.rs index dbdc9f15..e17ea9cd 100644 --- a/packages/debmagic/src/build_intent.rs +++ b/packages/debmagic/src/build_intent.rs @@ -97,10 +97,11 @@ pub fn resolve_build_intent(input: BuildIntentInput) -> anyhow::Result, - #[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 +103,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, @@ -113,23 +121,32 @@ pub struct CommonBuildArgs { )] 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, + num_args = 0..=1, + default_missing_value = "true", + action = clap::ArgAction::Set, + overrides_with = "no_sign", help = "Sign the resulting .changes/.dsc with debsign after building. Defaults to the 'sign_package' setting in the config file (false if unset)." )] pub sign: Option, #[arg( long, - action = clap::ArgAction::SetFalse, + num_args = 0..=1, + default_missing_value = "true", + action = clap::ArgAction::Set, help = "Do not sign the resulting .changes/.dsc, overriding a 'sign_package = true' default in the config file." )] pub no_sign: 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." + help = "Where debsign runs: 'host' signs on the host (requires debsign 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." )] pub sign_with: Option, @@ -141,14 +158,19 @@ pub struct CommonBuildArgs { #[arg( long, - action = clap::ArgAction::SetTrue, + num_args = 0..=1, + default_missing_value = "true", + action = clap::ArgAction::Set, + overrides_with = "no_clean", 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 clean: Option, #[arg( long, - action = clap::ArgAction::SetFalse, + num_args = 0..=1, + default_missing_value = "true", + action = clap::ArgAction::Set, help = "Do not run 'debian/rules clean' before building, overriding a 'clean = true' default in the config file." )] pub no_clean: Option, @@ -188,12 +210,21 @@ 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")] + #[arg( + short, + long, + 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( 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, diff --git a/packages/debmagic/src/driver/driver_docker.rs b/packages/debmagic/src/driver/driver_docker.rs index 4b3e2faa..d9c7df12 100644 --- a/packages/debmagic/src/driver/driver_docker.rs +++ b/packages/debmagic/src/driver/driver_docker.rs @@ -469,17 +469,23 @@ impl DriverDocker { &self, changes_file: &Path, gpg: Option<&crate::signing::GpgForwarding>, - _sign_key: Option<&str>, + sign_key: Option<&str>, ) -> anyhow::Result<()> { use crate::signing; - let gpg = gpg.context("docker container signing needs gpg forwarding info")?; + // None means signing was resolved to run on the host. + let Some(gpg) = gpg else { + return signing::sign_on_host(changes_file, sign_key); + }; 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 agent_socket = + Path::new(signing::GPG_DIR_IN_CONTAINER).join(signing::GPG_SOCKET_FILENAME); let script = signing::sign_container_script( + &agent_socket, signing::changes_filename(changes_file)?, &gpg.sign_key, // The sign container's root is not id-mapped; fix ownership of @@ -502,10 +508,12 @@ impl DriverDocker { bind_mount_arg(&staging_dir, signing::SIGN_STAGING_IN_CONTAINER) ), ]) + // The host agent socket itself is bind-mounted to the fixed + // listen path (docker bind-mounts create the parent dir). .arg(format!( - "--mount=type=bind,src={},dst={},readonly", + "--mount=type=bind,src={},dst={}", gpg.agent_extra_socket.display(), - signing::GPG_SOCKET_IN_CONTAINER + agent_socket.display() )) .args([ "--mount", diff --git a/packages/debmagic/src/driver/driver_lxd.rs b/packages/debmagic/src/driver/driver_lxd.rs index 8a1c962f..8e5486b0 100644 --- a/packages/debmagic/src/driver/driver_lxd.rs +++ b/packages/debmagic/src/driver/driver_lxd.rs @@ -1,5 +1,6 @@ use std::{ fs, + os::unix::fs::{MetadataExt, PermissionsExt}, path::{Path, PathBuf}, process::{Command, Stdio}, }; @@ -613,18 +614,32 @@ impl DriverLxd { &self, changes_file: &Path, gpg: Option<&crate::signing::GpgForwarding>, - _sign_key: Option<&str>, + sign_key: Option<&str>, ) -> anyhow::Result<()> { use crate::signing; - let gpg = gpg.context("container signing needs gpg forwarding info")?; + // None means signing was resolved to run on the host. + let Some(gpg) = gpg else { + return signing::sign_on_host(changes_file, sign_key); + }; 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 gpg_dir = self.environment.temp_dir().join("sign-gpg"); + std::fs::create_dir_all(&gpg_dir).context("failed to create gpg socket dir")?; + // The lxd proxy runs on the host as real root, which an idmapped + // mount (raw.idmap) maps to "other" on a dir owned by the host user — + // so the proxy needs o+wx to create its listen socket here. The dir + // is throwaway (build temp dir) and holds only the transient socket. + std::fs::set_permissions(&gpg_dir, std::fs::Permissions::from_mode(0o777)) + .context("failed to make gpg socket dir writable for the proxy")?; // No chown needed: raw.idmap maps container root to the host user. + let agent_socket = + Path::new(signing::GPG_DIR_IN_CONTAINER).join(signing::GPG_SOCKET_FILENAME); let script = signing::sign_container_script( + &agent_socket, signing::changes_filename(changes_file)?, &gpg.sign_key, None, @@ -680,8 +695,27 @@ impl DriverLxd { .arg("readonly=true"), "mounting signing material into sign container", )?; + run_checked( + self.lxd_cmd("config") + .arg("device") + .arg("add") + .arg(&sign_container) + .arg("debmagic-gpg-dir") + .arg("disk") + .arg(format!("source={}", gpg_dir.display())) + .arg(format!("path={}", signing::GPG_DIR_IN_CONTAINER)), + "mounting gpg socket dir 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. + // listening inside the mounted output dir: the proxy needs the + // listen path's parent to be a host-mounted directory it can + // write to (rootfs dirs don't exist yet when the device is set + // up, and /run is mounted over at boot). security.uid/gid select + // the credentials lxd connects to the host socket with; gpg-agent + // rejects connections that don't come from the socket's owner, so + // this must be the host user's ids, not the default root. + let socket_owner = std::fs::metadata(&gpg.agent_extra_socket) + .context("failed to stat host gpg-agent socket")?; run_checked( self.lxd_cmd("config") .arg("device") @@ -691,9 +725,15 @@ impl DriverLxd { .arg("proxy") .arg("bind=container") .arg(format!("connect=unix:{}", gpg.agent_extra_socket.display())) - .arg(format!("listen=unix:{}", signing::GPG_SOCKET_IN_CONTAINER)) + .arg(format!( + "listen=unix:{}/{}", + signing::GPG_DIR_IN_CONTAINER, + signing::GPG_SOCKET_FILENAME + )) .arg("uid=0") - .arg("gid=0"), + .arg("gid=0") + .arg(format!("security.uid={}", socket_owner.uid())) + .arg(format!("security.gid={}", socket_owner.gid())), "forwarding gpg-agent socket into sign container", )?; diff --git a/packages/debmagic/src/driver/mod.rs b/packages/debmagic/src/driver/mod.rs index 31935711..7d2f8539 100644 --- a/packages/debmagic/src/driver/mod.rs +++ b/packages/debmagic/src/driver/mod.rs @@ -318,6 +318,10 @@ impl Driver for DriverInstance { } impl DriverInstance { + /// Sign `changes_file` (a path on the host) with `debsign`. `gpg` + /// carries the agent socket and key for signing inside a minimal + /// same-distro container; `None` means signing was resolved to run on + /// the host instead. pub fn sign_changes( &self, changes_file: &Path, diff --git a/packages/debmagic/src/signing.rs b/packages/debmagic/src/signing.rs index 0e161215..8eb5166a 100644 --- a/packages/debmagic/src/signing.rs +++ b/packages/debmagic/src/signing.rs @@ -17,10 +17,14 @@ 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 holding the forwarded gpg-agent socket, mounted read-write +/// into sign containers at [`GPG_DIR_IN_CONTAINER`]. The lxd proxy's listen +/// path must live in a host-mounted directory: dirs of the container rootfs +/// don't exist yet when the proxy is set up, /run gets mounted over at boot, +/// and the user's output dir is not ours to litter in. +pub const GPG_DIR_IN_CONTAINER: &str = "/debmagic-gpg"; +/// Socket filename created inside the gpg dir. +pub const GPG_SOCKET_FILENAME: &str = "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"; @@ -150,6 +154,7 @@ pub fn stage_signing_material(staging_dir: &Path, sign_key: &str) -> anyhow::Res /// 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( + agent_socket: &Path, changes_filename: &str, sign_key: &str, chown_to: Option<(u32, u32)>, @@ -161,6 +166,7 @@ pub fn sign_container_script( ), None => String::new(), }; + // forward the agent socket and install just `debsign` format!( "set -e; \ export GNUPGHOME=/root/.gnupg; \ @@ -169,11 +175,11 @@ pub fn sign_container_script( 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; \ + apt-get install -y -qq --no-install-recommends debsign || apt-get install -y -qq --no-install-recommends devscripts; \ gpg --batch --import {staging}/{pubkey}; \ gpg --batch --import-ownertrust {staging}/{ownertrust}; \ cd {out} && debsign -k{key} {changes}{chown}", - sock = GPG_SOCKET_IN_CONTAINER, + sock = agent_socket.display(), staging = SIGN_STAGING_IN_CONTAINER, pubkey = PUBKEY_FILE, ownertrust = OWNERTRUST_FILE, @@ -213,8 +219,9 @@ pub fn check_host_debsign_available() -> anyhow::Result<()> { { 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." + "debsign not found on PATH. It's shipped in the debsign package (devscripts on \ + older releases); 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"), } @@ -237,7 +244,12 @@ mod tests { #[test] fn sign_script_quotes_filename_and_key() { - let script = sign_container_script("pkg_1.0_amd64.changes", "me@example.com", None); + let script = sign_container_script( + Path::new("/debmagic-gpg/S.gpg-agent"), + "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")); @@ -245,7 +257,12 @@ mod tests { #[test] fn sign_script_chowns_output_when_requested() { - let script = sign_container_script("x.changes", "key", Some((1000, 100))); + let script = sign_container_script( + Path::new("/debmagic-gpg/S.gpg-agent"), + "x.changes", + "key", + Some((1000, 100)), + ); assert!(script.contains("chown -R 1000:100 /debmagic-output")); } From 0da57de15c78ad7dcea2d9924f7157f30cd4c1c2 Mon Sep 17 00:00:00 2001 From: Jonas Jelten Date: Fri, 28 Aug 2026 12:52:27 +0200 Subject: [PATCH 03/11] feat(config): specify default container driver --- docs/usage/config.md | 2 ++ packages/debmagic/src/cli.rs | 2 +- packages/debmagic/src/config.rs | 2 ++ packages/debmagic/src/driver/config.rs | 5 +++++ packages/debmagic/src/driver/mod.rs | 3 ++- packages/debmagic/src/main.rs | 16 +++++++++++++--- packages/debmagic/tests/assets/config1.toml | 1 + 7 files changed, 26 insertions(+), 5 deletions(-) diff --git a/docs/usage/config.md b/docs/usage/config.md index f30f7d55..a456b3b5 100644 --- a/docs/usage/config.md +++ b/docs/usage/config.md @@ -20,6 +20,7 @@ All keys are optional. | Key | Type | Default | Description | |---|---|---|---| +| `driver.default` | enum | — | Build driver (`docker`, `bare`, `lxd`, `incus`) | | `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. | @@ -64,6 +65,7 @@ sign_key = "you@example.com or gpg key id" clean = false [driver] +default = "lxd" persistent = true apt_mirror = "http:///ubuntu" diff --git a/packages/debmagic/src/cli.rs b/packages/debmagic/src/cli.rs index 6921671a..21013da0 100644 --- a/packages/debmagic/src/cli.rs +++ b/packages/debmagic/src/cli.rs @@ -70,7 +70,7 @@ 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." + 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, diff --git a/packages/debmagic/src/config.rs b/packages/debmagic/src/config.rs index 0d06e2a0..c4a94252 100644 --- a/packages/debmagic/src/config.rs +++ b/packages/debmagic/src/config.rs @@ -82,6 +82,7 @@ impl Config { #[cfg(test)] mod tests { use super::*; + use crate::driver::DriverType; #[test] fn it_loads_a_simple_config() -> Result<(), anyhow::Error> { @@ -89,6 +90,7 @@ mod tests { .join("tests") .join("assets"); let cfg = Config::new(&vec![test_asset_dir.join("config1.toml")])?; + assert_eq!(cfg.driver.default, Some(DriverType::Docker)); assert!(cfg.driver.persistent); assert!( diff --git a/packages/debmagic/src/driver/config.rs b/packages/debmagic/src/driver/config.rs index 8d8c8d46..ccd218e7 100644 --- a/packages/debmagic/src/driver/config.rs +++ b/packages/debmagic/src/driver/config.rs @@ -1,5 +1,6 @@ use serde::Deserialize; +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}; @@ -7,6 +8,10 @@ use crate::driver::driver_lxd::{DriverLxdConfig, DriverLxdConfigOverrides}; #[derive(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/mod.rs b/packages/debmagic/src/driver/mod.rs index 7d2f8539..5ab8d63f 100644 --- a/packages/debmagic/src/driver/mod.rs +++ b/packages/debmagic/src/driver/mod.rs @@ -99,6 +99,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, @@ -526,7 +527,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 5d4f5e28..c546425b 100644 --- a/packages/debmagic/src/main.rs +++ b/packages/debmagic/src/main.rs @@ -51,11 +51,21 @@ fn run() -> anyhow::Result { BuildTarget::Source(source_args) => (&source_args.build, None, None, true), }; + let config_driver = load_config( + 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)", )? }; 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] From f0876c2bfc4c6a0e550dd746c00602455bd2403f Mon Sep 17 00:00:00 2001 From: Jonas Jelten Date: Fri, 28 Aug 2026 12:56:58 +0200 Subject: [PATCH 04/11] docs(config): list cli flags alongside config options --- docs/usage/config.md | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/usage/config.md b/docs/usage/config.md index a456b3b5..614881c7 100644 --- a/docs/usage/config.md +++ b/docs/usage/config.md @@ -18,25 +18,25 @@ Command-line flags override whatever the merged config resolves to. All keys are optional. -| Key | Type | Default | Description | -|---|---|---|---| -| `driver.default` | enum | — | Build driver (`docker`, `bare`, `lxd`, `incus`) | -| `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. | -| `host_arch_variant` | string | — | Build for a dpkg architecture variant (e.g. `"amd64v3"` on Ubuntu) -> `DEB_HOST_ARCH_VARIANT`. | +| 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. | +| `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_package` | bool | `false` | `--sign`/`--no-sign` | Sign the resulting `.changes`/`.dsc` with `debsign`. | +| `sign_with` | enum | `auto` | `--sign-with` | Where `debsign` runs (see below). | +| `sign_key` | string | — | `--sign-key` | GPG key ID/email for `debsign -k`. Required for container signing. | +| `clean` | bool | `false` | `--clean`/`--no-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` From e1fb0f4ca918fe5362f9ac2c666a90d15713dd6b Mon Sep 17 00:00:00 2001 From: Jonas Jelten Date: Fri, 28 Aug 2026 13:09:44 +0200 Subject: [PATCH 05/11] feat(driver): install build-essential only for building --- packages/debmagic/src/build/mod.rs | 9 +++++++++ packages/debmagic/src/driver/driver_docker.rs | 2 +- packages/debmagic/src/driver/driver_lxd.rs | 17 +++++++++++++---- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/debmagic/src/build/mod.rs b/packages/debmagic/src/build/mod.rs index 99b32d5a..30658948 100644 --- a/packages/debmagic/src/build/mod.rs +++ b/packages/debmagic/src/build/mod.rs @@ -380,6 +380,15 @@ fn run_build( pub fn build_package(intent: &BuildIntent, target: &PackageTarget) -> anyhow::Result<()> { let request = BuildRequest { intent, target }; run_build(&request, |build| { + // 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(), diff --git a/packages/debmagic/src/driver/driver_docker.rs b/packages/debmagic/src/driver/driver_docker.rs index d9c7df12..fe9105ae 100644 --- a/packages/debmagic/src/driver/driver_docker.rs +++ b/packages/debmagic/src/driver/driver_docker.rs @@ -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; \ diff --git a/packages/debmagic/src/driver/driver_lxd.rs b/packages/debmagic/src/driver/driver_lxd.rs index 8e5486b0..a203c770 100644 --- a/packages/debmagic/src/driver/driver_lxd.rs +++ b/packages/debmagic/src/driver/driver_lxd.rs @@ -37,7 +37,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 ──────────────────────────────────────────────────────────────────── @@ -345,10 +345,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, &[], From 0cf4b07443f743476749c7c9e3a15f50d93bf048 Mon Sep 17 00:00:00 2001 From: Jonas Jelten Date: Fri, 28 Aug 2026 16:16:35 +0200 Subject: [PATCH 06/11] feat(signing): allow signing in build environment --- docs/usage/build.md | 11 +- docs/usage/config.md | 32 ++- docs/usage/source.md | 7 +- packages/debmagic/src/build/mod.rs | 115 +++++---- packages/debmagic/src/build_intent.rs | 17 +- packages/debmagic/src/cli.rs | 49 +++- packages/debmagic/src/config.rs | 52 +++- packages/debmagic/src/driver/driver_bare.rs | 39 ++- packages/debmagic/src/driver/driver_docker.rs | 181 ++++++++++---- packages/debmagic/src/driver/driver_lxd.rs | 197 +++++++++++++-- packages/debmagic/src/driver/mod.rs | 130 ++++++++-- packages/debmagic/src/main.rs | 4 + packages/debmagic/src/output.rs | 174 +++++++++++++ packages/debmagic/src/signing.rs | 232 ++++++++++++++---- packages/debmagic/src/test/run.rs | 6 +- 15 files changed, 1007 insertions(+), 239 deletions(-) create mode 100644 packages/debmagic/src/output.rs diff --git a/docs/usage/build.md b/docs/usage/build.md index bf1bfe32..bf91d06d 100644 --- a/docs/usage/build.md +++ b/docs/usage/build.md @@ -145,13 +145,16 @@ Or set `build_debug_symbols = true` in the [`debmagic.toml`](config.md). 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. -Where `debsign` runs is selected by `--sign-with` (config: `sign_with`): +Where `debsign` runs is selected by `--sign-with` (config: `sign.with`): -- `auto` (default): sign on the host if `debsign` is installed there, otherwise in a container (requires a container driver). +- `auto` (default): sign on the host if `debsign` is installed there, otherwise in a separate 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. +- `build`: sign inside the build container itself, reusing its environment instead of starting a new one. + The host's gpg-agent socket is forwarded in just like `separate`, but no second container is bootstrapped — the package's build environment is trusted anyway. +- `separate`: sign inside a minimal, separate 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. + +Container signing (`build`/`separate`) requires an explicit `--sign-key`, since debsign's maintainer-based key lookup only works on the host. 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. diff --git a/docs/usage/config.md b/docs/usage/config.md index 614881c7..c4c204f2 100644 --- a/docs/usage/config.md +++ b/docs/usage/config.md @@ -31,9 +31,10 @@ All keys are optional. | `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_package` | bool | `false` | `--sign`/`--no-sign` | Sign the resulting `.changes`/`.dsc` with `debsign`. | -| `sign_with` | enum | `auto` | `--sign-with` | Where `debsign` runs (see below). | -| `sign_key` | string | — | `--sign-key` | GPG key ID/email for `debsign -k`. Required for container signing. | +| `sign.source` | bool | `false` | `--sign`/`--no-sign` | Sign the resulting `.changes`/`.dsc` with `debsign` (see below). | +| `sign.with` | enum | `auto` | `--sign-with` | Where `debsign` runs (see below). | +| `sign.key` | string | — | `--sign-key` | GPG key ID/email for `debsign -k`. Required for container signing. | +| `sign.notify` | bool | `false` | `--sign-notify`/`--no-sign-notify` | Send a desktop notification via `notify-send` just before `debsign` runs, so a hardware-key touch prompt isn't missed. | | `clean` | bool | `false` | `--clean`/`--no-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`. | @@ -46,24 +47,37 @@ 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` + +| Key | Type | Default | CLI flag | Description | +|---|---|---|---|---| +| `source` | bool | `false` | `--sign`/`--no-sign` | Sign the source package (`.changes`/`.dsc`) with `debsign`. | +| `with` | enum | `auto` | `--sign-with` | Where `debsign` runs (see below). | +| `key` | string | — | `--sign-key` | GPG key ID/email for `debsign -k`. Required for container signing. | +| `notify` | bool | `false` | `--sign-notify`/`--no-sign-notify` | Desktop notification via `notify-send` before signing. | + +#### `sign.with` | Value | Behavior | |---|---| -| `auto` (default) | Sign on the host if `debsign` is available there, otherwise in a container. | +| `auto` (default) | Sign on the host if `debsign` is available there, otherwise in a separate 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`. | +| `build` | Sign inside the build container itself (no separate container is started). Requires a container driver and `sign.key`. | +| `separate` | Sign inside a minimal, separate same-distro container, forwarding the host's gpg-agent socket. Requires `sign.key`. | ## 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 +with = "separate" +key = "you@example.com or gpg key id" +notify = true + [driver] default = "lxd" persistent = true diff --git a/docs/usage/source.md b/docs/usage/source.md index a2aad907..79fb7e95 100644 --- a/docs/usage/source.md +++ b/docs/usage/source.md @@ -29,13 +29,14 @@ 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). + By default it runs on the host; with `--sign-with separate` (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, or `--sign-with build` reuses the build container — see [Signing](build.md#signing). - `--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). - 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/packages/debmagic/src/build/mod.rs b/packages/debmagic/src/build/mod.rs index 30658948..5b2ddbdf 100644 --- a/packages/debmagic/src/build/mod.rs +++ b/packages/debmagic/src/build/mod.rs @@ -10,8 +10,9 @@ 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, + SignLocation, SignRequest, config::DriverConfig, create_driver, create_driver_from_metadata, + remove_environment_root, }; use crate::{ config::Config, @@ -28,9 +29,9 @@ 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. + driver: Driver, + /// Agent socket + key for container signing; `None` when signing on the + /// host or not signing at all. gpg_forwarding: Option, attached: bool, output_dir: PathBuf, @@ -40,53 +41,60 @@ struct Build { host_arch_variant: Option, } -/// 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, so a broken gpg setup doesn't waste a whole build. Must run before -/// any environment is created — the checks are host-side and free, while -/// bootstrapping a container is not. -fn prepare_signing( - driver: DriverType, - sign_with: SignWith, - sign_key: Option<&str>, -) -> anyhow::Result<(SignLocation, Option)> { +/// Resolve where `debsign` will run from `sign.with`, the driver, and what's +/// available on the host. Validation of the gpg setup happens in +/// [`prepare_signing`]. +fn resolve_sign_location(driver: DriverType, sign_with: SignWith) -> anyhow::Result { let container_driver = 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 => { + match sign_with { + SignWith::Host => Ok(SignLocation::Host), + SignWith::Separate => { if container_driver { - SignLocation::Container + Ok(SignLocation::EphemeralContainer) } else { // The bare driver's "build environment" is the host. - SignLocation::Host + Ok(SignLocation::Host) + } + } + SignWith::Build => { + if container_driver { + Ok(SignLocation::BuildContainer) + } else { + Err(anyhow!( + "sign.with = \"build\" needs a container build driver; \ + the bare driver can only sign on the host" + )) } } SignWith::Auto => { if host_has_debsign || !container_driver { - SignLocation::Host + Ok(SignLocation::Host) } else { - SignLocation::Container + Ok(SignLocation::EphemeralContainer) } } - }; + } +} +/// Validate everything signing will need, so a broken gpg setup doesn't waste +/// a whole build. The container checks are host-side (agent socket, secret +/// key); they run before any environment is created because bootstrapping one +/// is not free. +fn prepare_signing( + location: SignLocation, + sign_key: Option<&str>, +) -> anyhow::Result> { match location { SignLocation::Host => { signing::check_host_debsign_available()?; - Ok((location, None)) + Ok(None) } - SignLocation::Container => { + SignLocation::EphemeralContainer | SignLocation::BuildContainer => { let sign_key = sign_key.ok_or_else(|| { anyhow!( - "signing in a container requires sign_key to be set \ + "signing in a container requires sign.key to be set \ (debsign's maintainer-based key lookup only works on the host)" ) })?; @@ -95,7 +103,7 @@ fn prepare_signing( sign_key: sign_key.to_string(), }; signing::check_signing_key_available(sign_key)?; - Ok((location, Some(forwarding))) + Ok(Some(forwarding)) } } } @@ -118,7 +126,7 @@ impl Build { 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(), @@ -252,6 +260,7 @@ fn prepare_build_env( .create_dirs() .context("failed to create build directories")?; + crate::output::step("Staging source tree"); stage_source_tree( &environment, &target.identity, @@ -304,16 +313,20 @@ fn run_build( request: &BuildRequest, build_commands: impl FnOnce(&Build) -> anyhow::Result<()>, ) -> anyhow::Result<()> { - let gpg_forwarding = if request.intent.config.sign_package { - let (_location, forwarding) = prepare_signing( - request.intent.driver, - request.intent.config.sign_with, - request.intent.config.sign_key.as_deref(), - )?; - forwarding + let sign = &request.intent.config.sign; + let (sign_location, gpg_forwarding) = if sign.source { + let location = resolve_sign_location(request.intent.driver, sign.with)?; + let forwarding = prepare_signing(location, sign.key.as_deref())?; + (Some(location), forwarding) } else { - None + (None, None) }; + + 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, gpg_forwarding) .context("failed to prepare build environment")?; build @@ -332,14 +345,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)?; + if let (true, Some(location)) = (build.sign_package, sign_location) { + crate::output::stage(&format!("Signing {}", build.environment.package_identifier)); + build.driver.sign_changes(&SignRequest { + changes_file: &changes_file, + location, + gpg: build.gpg_forwarding.as_ref(), + sign_key: sign.key.as_deref(), + notify: sign.notify, + package: &build.environment.package_identifier, + })?; } Ok(()) }); @@ -380,6 +399,7 @@ 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). @@ -453,6 +473,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 e17ea9cd..f0208a8d 100644 --- a/packages/debmagic/src/build_intent.rs +++ b/packages/debmagic/src/build_intent.rs @@ -27,6 +27,8 @@ pub struct BuildIntentInput { pub no_sign: Option, pub sign_with: Option, pub sign_key: Option, + pub sign_notify: Option, + pub no_sign_notify: Option, pub clean: Option, pub no_clean: Option, pub source_sync: Option, @@ -100,15 +102,20 @@ pub fn resolve_build_intent(input: BuildIntentInput) -> anyhow::Result, + #[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, } @@ -131,7 +153,7 @@ pub struct CommonBuildArgs { default_missing_value = "true", action = clap::ArgAction::Set, overrides_with = "no_sign", - help = "Sign the resulting .changes/.dsc with debsign after building. Defaults to the 'sign_package' setting in the config file (false if unset)." + help = "Sign the resulting .changes/.dsc with debsign after building. Defaults to the 'sign.source' setting in the config file (false if unset)." )] pub sign: Option, @@ -140,22 +162,41 @@ pub struct CommonBuildArgs { num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, - help = "Do not sign the resulting .changes/.dsc, overriding a 'sign_package = true' default in the config file." + help = "Do not sign the resulting .changes/.dsc, overriding a 'sign.source = true' default in the config file." )] pub no_sign: Option, #[arg( long = "sign-with", - help = "Where debsign runs: 'host' signs on the host (requires debsign 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." + help = "Where debsign runs: 'host' signs on the host (requires debsign there), 'build' signs inside the build container itself, 'separate' signs in a minimal, separate same-distro container, 'auto' (default) uses the host if debsign is available there, else a separate container. Container signing forwards the host gpg-agent socket and requires --sign-key. Defaults to the 'sign.with' setting in the config file." )] pub sign_with: 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." + 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." )] pub sign_key: Option, + #[arg( + long = "sign-notify", + num_args = 0..=1, + default_missing_value = "true", + action = clap::ArgAction::Set, + overrides_with = "no_sign_notify", + help = "Send a desktop notification via notify-send just before debsign runs, 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( + long = "no-sign-notify", + num_args = 0..=1, + default_missing_value = "true", + action = clap::ArgAction::Set, + help = "Do not send a signing notification, overriding a 'sign.notify = true' default in the config file." + )] + pub no_sign_notify: Option, + #[arg( long, num_args = 0..=1, diff --git a/packages/debmagic/src/config.rs b/packages/debmagic/src/config.rs index c4a94252..64f562d3 100644 --- a/packages/debmagic/src/config.rs +++ b/packages/debmagic/src/config.rs @@ -18,15 +18,8 @@ pub struct Config { 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 @@ -40,6 +33,25 @@ pub struct Config { pub host_arch_variant: Option, } +/// `[sign]` section: whether and how to sign the build artifacts. +#[derive(Deserialize, Debug, Clone, Default)] +#[serde(default)] +pub struct SignConfig { + /// Sign the source package (`.changes`/`.dsc`) with `debsign` after + /// building. + pub source: bool, + /// Where `debsign` runs: on the host or inside a minimal same-distro + /// container with the host's gpg-agent socket forwarded in. + pub 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 key: Option, + /// Send a desktop notification via `notify-send` just before `debsign` + /// runs, so a hardware-key touch prompt isn't missed. + pub notify: bool, +} + impl Default for Config { fn default() -> Self { Self { @@ -48,9 +60,7 @@ impl Default for Config { 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, @@ -100,4 +110,22 @@ 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\nwith = \"separate\"\nkey = \"you@example.com\"\nnotify = true\n", + )?; + let cfg = Config::new(&vec![file.clone()])?; + std::fs::remove_dir_all(&dir).ok(); + assert!(cfg.sign.source); + assert_eq!(cfg.sign.with, SignWith::Separate); + assert_eq!(cfg.sign.key.as_deref(), Some("you@example.com")); + assert!(cfg.sign.notify); + Ok(()) + } } diff --git a/packages/debmagic/src/driver/driver_bare.rs b/packages/debmagic/src/driver/driver_bare.rs index 52209787..8f90d75c 100644 --- a/packages/debmagic/src/driver/driver_bare.rs +++ b/packages/debmagic/src/driver/driver_bare.rs @@ -1,9 +1,11 @@ use std::{path::Path, process::Command}; +use anyhow::anyhow; use serde::{Deserialize, Serialize}; use crate::driver::{ - Driver, DriverType, Environment, EnvironmentMetadata, IsolationCapability, config::DriverConfig, + DriverType, Environment, EnvironmentDriver, EnvironmentMetadata, IsolationCapability, + SignLocation, SignRequest, config::DriverConfig, }; #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -40,19 +42,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 +101,27 @@ impl Driver for DriverBare { } Ok(()) } + + fn sign_changes(&self, request: &SignRequest) -> anyhow::Result<()> { + match request.location { + // The bare driver builds on the host; the "build environment" *is* + // the host, so container-style signing isn't a thing here. + SignLocation::BuildContainer | SignLocation::EphemeralContainer + if request.gpg.is_some() => + { + return Err(anyhow!( + "sign.with = \"build\"/\"separate\" requires a container build driver; \ + the bare driver signs on the host" + )); + } + _ => {} + } + crate::signing::check_host_debsign_available()?; + crate::signing::sign_on_host( + request.changes_file, + request.sign_key, + request.notify, + request.package, + ) + } } diff --git a/packages/debmagic/src/driver/driver_docker.rs b/packages/debmagic/src/driver/driver_docker.rs index fe9105ae..6b499245 100644 --- a/packages/debmagic/src/driver/driver_docker.rs +++ b/packages/debmagic/src/driver/driver_docker.rs @@ -10,9 +10,10 @@ 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, + APT_MIRROR_SCRIPT, ContainerSignPrep, DriverType, ENVIRONMENT_DIR_IN_CONTAINER, Environment, + EnvironmentDriver, EnvironmentMetadata, IsolationCapability, SignLocation, SignRequest, + config::DriverConfig, container_name_from_metadata, container_name_metadata, + environment_fingerprint, prepare_container_sign, resource_name, run_checked, translate_path_in_container, }; @@ -361,7 +362,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,67 +463,149 @@ impl Driver for DriverDocker { fn isolation_capability(&self) -> IsolationCapability { IsolationCapability::Container } + + fn sign_changes(&self, request: &SignRequest) -> anyhow::Result<()> { + let Some(prep) = prepare_container_sign(request, &self.environment.temp_dir())? else { + return Ok(()); + }; + let agent_socket = Path::new(crate::signing::GPG_DIR_IN_CONTAINER) + .join(crate::signing::GPG_SOCKET_FILENAME); + + match request.location { + SignLocation::BuildContainer => self.sign_in_build_container(request, &prep), + SignLocation::EphemeralContainer => { + self.sign_in_ephemeral_container(request, &prep, &agent_socket) + } + SignLocation::Host => unreachable!("handled above"), + } + } } impl DriverDocker { - pub(crate) fn sign_changes( + /// Sign inside the running build container: copy the staging dir and the + /// agent socket in, then run `debsign` in the work dir where + /// `dpkg-buildpackage` left the `.changes`. + fn sign_in_build_container( &self, - changes_file: &Path, - gpg: Option<&crate::signing::GpgForwarding>, - sign_key: Option<&str>, + request: &SignRequest, + prep: &ContainerSignPrep, ) -> anyhow::Result<()> { use crate::signing; - // None means signing was resolved to run on the host. - let Some(gpg) = gpg else { - return signing::sign_on_host(changes_file, sign_key); + if !self.container_is_running()? { + self.container_start()?; + } + + // A running container can't take new mounts, so the staging dir and + // agent socket are copied in rather than mounted. Both are tiny. + let container_staging = "/tmp/debmagic-sign"; + run_checked( + Command::new("docker") + .arg("cp") + .arg(&prep.staging_dir) + .arg(format!("{}:{container_staging}", self.container_name)), + "copying signing material into the build container", + )?; + let container_socket = "/tmp/debmagic-gpg/S.gpg-agent"; + run_checked( + Command::new("docker") + .args(["exec", "--user", "root", &self.container_name]) + .args(["mkdir", "-p", "/tmp/debmagic-gpg"]), + "preparing gpg socket dir in build container", + )?; + run_checked( + Command::new("docker") + .arg("cp") + .arg(&prep.gpg.agent_extra_socket) + .arg(format!("{}:{container_socket}", self.container_name)), + "copying gpg-agent socket into the build container", + )?; + + let work_dir = Path::new(ENVIRONMENT_DIR_IN_CONTAINER).join("work"); + let scripts = signing::sign_container_scripts( + signing::ContainerSignMode::Build { + work_dir: &work_dir, + staging_dir: Path::new(container_staging), + }, + Path::new(container_socket), + &prep.changes, + &prep.gpg.sign_key, + ); + + let work = self.environment.work_dir(); + let run = |script: &str, context: &str| { + self.run_command_checked(&["sh", "-ec", script], &work, true, &[]) + .map_err(|e| anyhow!("{context}: {e}")) }; - let output_dir = changes_file + run(&scripts.setup, "preparing the build container for signing")?; + request.notify_signing(); + run(&scripts.sign, "signing in the build container")?; + Ok(()) + } + + /// Sign in a minimal, throwaway container with the agent socket forwarded + /// and the output dir mounted. + fn sign_in_ephemeral_container( + &self, + request: &SignRequest, + prep: &ContainerSignPrep, + agent_socket: &Path, + ) -> anyhow::Result<()> { + use crate::signing; + + let output_dir = request + .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 agent_socket = - Path::new(signing::GPG_DIR_IN_CONTAINER).join(signing::GPG_SOCKET_FILENAME); - let script = signing::sign_container_script( - &agent_socket, - 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() })), + let scripts = signing::sign_container_scripts( + signing::ContainerSignMode::Ephemeral { + // The sign container's root is not id-mapped; fix ownership of + // files it rewrites so the host user can manage them afterwards. + chown_to: Some((unsafe { libc::geteuid() }, unsafe { libc::getegid() })), + }, + agent_socket, + &prep.changes, + &prep.gpg.sign_key, ); println!( "[docker] $ signing {} in a minimal {} container", - changes_file.display(), + request.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) - ), - ]) - // The host agent socket itself is bind-mounted to the fixed - // listen path (docker bind-mounts create the parent dir). - .arg(format!( - "--mount=type=bind,src={},dst={}", - gpg.agent_extra_socket.display(), - agent_socket.display() - )) - .args([ - "--mount", - &bind_mount_arg(output_dir, signing::OUTPUT_DIR_IN_CONTAINER), - ]) - .arg(&self.base_image) - .args(["sh", "-ec", &script]), - "signing in docker container", - )?; + let run = |script: &str, context: &str| { + run_checked( + Command::new("docker") + .args(["run", "--rm", "--init"]) + .args([ + "--mount", + &format!( + "{},readonly", + bind_mount_arg(&prep.staging_dir, signing::SIGN_STAGING_IN_CONTAINER) + ), + ]) + // The host agent socket itself is bind-mounted to the fixed + // listen path (docker bind-mounts create the parent dir). + .arg(format!( + "--mount=type=bind,src={},dst={}", + prep.gpg.agent_extra_socket.display(), + agent_socket.display() + )) + .args([ + "--mount", + &bind_mount_arg(output_dir, signing::OUTPUT_DIR_IN_CONTAINER), + ]) + .arg(&self.base_image) + .args(["sh", "-ec", script]), + context, + ) + }; + + run(&scripts.setup, "preparing the sign container")?; + // Notify right before debsign triggers the gpg touch prompt; the + // setup above can take long enough to miss it otherwise. + request.notify_signing(); + run(&scripts.sign, "signing in docker container")?; Ok(()) } } diff --git a/packages/debmagic/src/driver/driver_lxd.rs b/packages/debmagic/src/driver/driver_lxd.rs index a203c770..5b50d07a 100644 --- a/packages/debmagic/src/driver/driver_lxd.rs +++ b/packages/debmagic/src/driver/driver_lxd.rs @@ -10,9 +10,10 @@ 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, + APT_MIRROR_SCRIPT, ContainerSignPrep, DriverType, ENVIRONMENT_DIR_IN_CONTAINER, Environment, + EnvironmentDriver, EnvironmentMetadata, IsolationCapability, SignLocation, SignRequest, + config::DriverConfig, container_name_from_metadata, container_name_metadata, + environment_fingerprint, prepare_container_sign, resource_name, run_checked, translate_path_in_container, }; @@ -535,7 +536,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 { @@ -616,26 +617,146 @@ impl Driver for DriverLxd { fn isolation_capability(&self) -> IsolationCapability { IsolationCapability::Container } + + fn sign_changes(&self, request: &SignRequest) -> anyhow::Result<()> { + let Some(prep) = prepare_container_sign(request, &self.environment.temp_dir())? else { + return Ok(()); + }; + + match request.location { + SignLocation::BuildContainer => self.sign_in_build_container(request, &prep), + SignLocation::EphemeralContainer => self.sign_in_ephemeral_container(request, &prep), + SignLocation::Host => unreachable!("handled above"), + } + } } impl DriverLxd { - pub(crate) fn sign_changes( + /// Sign inside the running build container: add the staging dir and a + /// proxy for the agent socket as devices, then run `debsign` in the work + /// dir where `dpkg-buildpackage` left the `.changes`. + fn sign_in_build_container( &self, - changes_file: &Path, - gpg: Option<&crate::signing::GpgForwarding>, - sign_key: Option<&str>, + request: &SignRequest, + prep: &ContainerSignPrep, ) -> anyhow::Result<()> { use crate::signing; - // None means signing was resolved to run on the host. - let Some(gpg) = gpg else { - return signing::sign_on_host(changes_file, sign_key); + self.with_running_container(|_| Ok(())) + .map_err(|e| anyhow::anyhow!("build container is not available for signing: {e}"))?; + + let bin = self.variant.binary(); + let container = &self.container_name; + // Devices on a running container are hot-plugged; remove them again + // after signing so a persistent container doesn't keep them around. + let staging_device = resource_name( + "debmagic-sign", + &self.environment.package_name, + &self.environment.identifier(), + ); + let socket_device = format!("{staging_device}-agent"); + let socket_dir = Path::new("/tmp/debmagic-gpg"); + let socket = socket_dir.join(signing::GPG_SOCKET_FILENAME); + + let cleanup = |driver: &Self| { + for dev in [&staging_device, &socket_device] { + let _ = driver + .lxd_cmd("config") + .args(["device", "remove"]) + .arg(container) + .arg(dev) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } }; - let output_dir = changes_file + + let result = (|| -> anyhow::Result<()> { + // The proxy device hot-plugs its listen socket, so its parent dir + // must already exist inside the running container. + self.exec_in_container_checked( + &["mkdir", "-p", &socket_dir.to_string_lossy()], + None, + true, + &[], + ) + .map_err(|e| anyhow::anyhow!("failed to create gpg socket dir in container: {e}"))?; + + run_checked( + self.lxd_cmd("config") + .arg("device") + .arg("add") + .arg(container) + .arg(&staging_device) + .arg("disk") + .arg(format!("source={}", prep.staging_dir.display())) + .arg(format!("path={}", signing::SIGN_STAGING_IN_CONTAINER)) + .arg("readonly=true"), + "mounting signing material into build container", + )?; + // Forward the host agent socket via a proxy listening in a + // container-local /tmp dir (the build root is idmapped, which the + // proxy can't write to as root). + let socket_owner = std::fs::metadata(&prep.gpg.agent_extra_socket) + .context("failed to stat host gpg-agent socket")?; + run_checked( + self.lxd_cmd("config") + .arg("device") + .arg("add") + .arg(container) + .arg(&socket_device) + .arg("proxy") + .arg("bind=container") + .arg(format!( + "connect=unix:{}", + prep.gpg.agent_extra_socket.display() + )) + .arg(format!("listen=unix:{}", socket.display())) + .arg("uid=0") + .arg("gid=0") + .arg(format!("security.uid={}", socket_owner.uid())) + .arg(format!("security.gid={}", socket_owner.gid())), + "forwarding gpg-agent socket into build container", + )?; + + let work_dir = Path::new(ENVIRONMENT_DIR_IN_CONTAINER).join("work"); + let scripts = signing::sign_container_scripts( + signing::ContainerSignMode::Build { + work_dir: &work_dir, + staging_dir: Path::new(signing::SIGN_STAGING_IN_CONTAINER), + }, + &socket, + &prep.changes, + &prep.gpg.sign_key, + ); + let work = self.environment.work_dir(); + self.exec_in_container_checked(&["sh", "-ec", &scripts.setup], Some(&work), true, &[]) + .map_err(|e| { + anyhow::anyhow!("preparing the {bin} build container for signing: {e}") + })?; + request.notify_signing(); + self.exec_in_container_checked(&["sh", "-ec", &scripts.sign], Some(&work), true, &[]) + .map_err(|e| anyhow::anyhow!("signing in the {bin} build container failed: {e}"))?; + Ok(()) + })(); + + cleanup(self); + result + } + + /// Sign in a minimal, throwaway container with the agent socket forwarded + /// and the output dir mounted. + fn sign_in_ephemeral_container( + &self, + request: &SignRequest, + prep: &ContainerSignPrep, + ) -> anyhow::Result<()> { + use crate::signing; + + let output_dir = request + .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 gpg_dir = self.environment.temp_dir().join("sign-gpg"); std::fs::create_dir_all(&gpg_dir).context("failed to create gpg socket dir")?; // The lxd proxy runs on the host as real root, which an idmapped @@ -647,12 +768,13 @@ impl DriverLxd { // No chown needed: raw.idmap maps container root to the host user. let agent_socket = Path::new(signing::GPG_DIR_IN_CONTAINER).join(signing::GPG_SOCKET_FILENAME); - let script = signing::sign_container_script( + let scripts = signing::sign_container_scripts( + signing::ContainerSignMode::Ephemeral { chown_to: None }, &agent_socket, - signing::changes_filename(changes_file)?, - &gpg.sign_key, - None, + &prep.changes, + &prep.gpg.sign_key, ); + let gpg = &prep.gpg; let sign_container = resource_name( "debmagic-sign", @@ -699,7 +821,7 @@ impl DriverLxd { .arg(&sign_container) .arg("debmagic-sign-staging") .arg("disk") - .arg(format!("source={}", staging_dir.display())) + .arg(format!("source={}", prep.staging_dir.display())) .arg(format!("path={}", signing::SIGN_STAGING_IN_CONTAINER)) .arg("readonly=true"), "mounting signing material into sign container", @@ -751,11 +873,38 @@ impl DriverLxd { &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")?; + // Same first-boot caveat as the build container: on Ubuntu images + // cloud-init may still hold the apt lock. + if matches!(self.environment.distro.distro, Distro::Ubuntu) { + self.exec_in_container_checked( + &["cloud-init", "status", "--wait"], + None, + true, + &[], + ) + .map_err(|e| anyhow::anyhow!("Error waiting for cloud-init to finish: {e}"))?; + } + + println!( + "[{bin}] $ signing {} in a minimal {} container", + request.changes_file.display(), + self.environment.distro.codename + ); + + let mut setup = self.lxd_cmd("exec"); + setup.arg(&sign_container); + setup.arg("--"); + setup.args(["sh", "-ec", &scripts.setup]); + run_checked(&mut setup, "preparing the sign container")?; + + // Notify right before debsign triggers the gpg touch prompt; the + // setup above can take long enough to miss it otherwise. + request.notify_signing(); + let mut sign = self.lxd_cmd("exec"); + sign.arg(&sign_container); + sign.arg("--"); + sign.args(["sh", "-ec", &scripts.sign]); + run_checked(&mut sign, "signing in container")?; Ok(()) })(); diff --git a/packages/debmagic/src/driver/mod.rs b/packages/debmagic/src/driver/mod.rs index 5ab8d63f..f53043f9 100644 --- a/packages/debmagic/src/driver/mod.rs +++ b/packages/debmagic/src/driver/mod.rs @@ -197,7 +197,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( @@ -230,7 +230,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<()>; @@ -238,15 +238,20 @@ pub trait Driver { fn reused_environment(&self) -> bool { true } + + /// Sign `changes_file` (a path on the host) with `debsign`, at the + /// location resolved in the request. + 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(), @@ -316,23 +321,12 @@ impl Driver for DriverInstance { Self::Lxd(d) => d.reused_environment(), } } -} -impl DriverInstance { - /// Sign `changes_file` (a path on the host) with `debsign`. `gpg` - /// carries the agent socket and key for signing inside a minimal - /// same-distro container; `None` means signing was resolved to run on - /// the host instead. - 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), } } } @@ -341,7 +335,7 @@ pub fn create_driver( environment: &Environment, driver_config: &DriverConfig, overrides: &DriverOverrides, -) -> anyhow::Result { +) -> anyhow::Result { let apt_mirror = overrides .apt_mirror .as_deref() @@ -349,14 +343,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, @@ -366,7 +360,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, @@ -381,14 +375,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, @@ -398,7 +392,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, @@ -408,6 +402,88 @@ pub fn create_driver_from_metadata( } } +/// A single `debsign` invocation: what to sign, where to run it, 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, + /// Where `debsign` runs, as resolved from `sign.with` and the driver. + pub location: SignLocation, + /// Agent socket + key for container signing; `None` for host signing. + pub gpg: Option<&'a crate::signing::GpgForwarding>, + /// `debsign -k` value; `None` lets debsign do its maintainer lookup (host only). + pub sign_key: Option<&'a str>, + /// Send a `notify-send` popup right before `debsign`. + pub notify: bool, + /// `"{name}-{version}"`, used in the notification. + pub package: &'a str, +} + +/// Where `debsign` actually runs for a build. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum SignLocation { + /// On the host, using the host's own gpg keyring. + Host, + /// In a minimal, throwaway same-distro container. + EphemeralContainer, + /// Inside the build container itself (requires a container driver). + BuildContainer, +} + +impl SignRequest<'_> { + /// Send the "touch your key" notification if enabled. Called right before + /// `debsign` runs so a hardware-key prompt isn't missed. + pub fn notify_signing(&self) { + if self.notify { + crate::signing::notify_send( + "debmagic: signing requested", + &format!("touch your key to sign {}", self.package), + ); + } + } +} + +/// Host-side preparation shared by the containerized drivers: the gpg +/// forwarding setup, the staged public key + ownertrust, and the `.changes` +/// filename. `None` when the request resolved to host signing (already run). +pub struct ContainerSignPrep { + pub gpg: crate::signing::GpgForwarding, + pub staging_dir: PathBuf, + pub changes: String, +} + +/// Stage everything a container sign needs. Returns `Ok(None)` after signing +/// on the host when `location` is `Host`, so container drivers can +/// `let Some(prep) = ... else { return Ok(()) }`. `temp_dir` is the driver's +/// build-temp dir, under which the `sign` staging dir is created. +pub fn prepare_container_sign( + request: &SignRequest, + temp_dir: &Path, +) -> anyhow::Result> { + use crate::signing; + + if request.location == SignLocation::Host { + signing::sign_on_host( + request.changes_file, + request.sign_key, + request.notify, + request.package, + )?; + return Ok(None); + } + let gpg = request + .gpg + .context("container signing needs a gpg forwarding setup")?; + let staging_dir = temp_dir.join("sign"); + signing::stage_signing_material(&staging_dir, &gpg.sign_key)?; + let changes = signing::changes_filename(request.changes_file)?.to_string(); + Ok(Some(ContainerSignPrep { + gpg: gpg.clone(), + staging_dir, + changes, + })) +} + /// 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. diff --git a/packages/debmagic/src/main.rs b/packages/debmagic/src/main.rs index c546425b..1c53bda3 100644 --- a/packages/debmagic/src/main.rs +++ b/packages/debmagic/src/main.rs @@ -21,6 +21,7 @@ pub mod build_intent; pub mod cli; pub mod config; pub mod driver; +pub mod output; pub mod package; pub mod signing; pub mod test; @@ -37,6 +38,7 @@ 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 { @@ -83,6 +85,8 @@ fn run() -> anyhow::Result { no_sign: build_args.no_sign, sign_with: build_args.sign_with, sign_key: build_args.sign_key.clone(), + sign_notify: build_args.sign_notify, + no_sign_notify: build_args.no_sign_notify, clean: build_args.clean, no_clean: build_args.no_clean, source_sync: build_args.source_sync, diff --git a/packages/debmagic/src/output.rs b/packages/debmagic/src/output.rs new file mode 100644 index 00000000..d1ec04f2 --- /dev/null +++ b/packages/debmagic/src/output.rs @@ -0,0 +1,174 @@ +//! 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::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", + } + } +} + +/// 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/signing.rs b/packages/debmagic/src/signing.rs index 8eb5166a..98a2d2de 100644 --- a/packages/debmagic/src/signing.rs +++ b/packages/debmagic/src/signing.rs @@ -44,13 +44,18 @@ pub enum SignWith { 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, + /// Sign inside a minimal, separate container of the same distro, + /// forwarding the host's gpg-agent socket. Requires `sign_key` to be set. + Separate, + /// Sign inside the build container itself (no separate container is + /// started), forwarding the host's gpg-agent socket. Requires `sign_key` + /// and a containerized build driver. + Build, } /// 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. +#[derive(Clone)] pub struct GpgForwarding { pub agent_extra_socket: PathBuf, pub sign_key: String, @@ -148,49 +153,143 @@ pub fn stage_signing_material(staging_dir: &Path, sign_key: &str) -> anyhow::Res 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( +/// The two phases of in-container signing, split so the host can send the +/// "touch your key" notification after the slow setup but immediately before +/// `debsign` runs. +pub struct SignContainerScripts { + /// Links the forwarded agent socket, installs debsign, seeds the keyring. + pub setup: String, + /// Runs `debsign` (and the ownership fixup). Triggers the gpg touch prompt. + pub sign: String, +} + +/// How the container signs, which sets where the artifacts live and whether +/// the container is a throwaway or the build environment itself. +pub enum ContainerSignMode<'a> { + /// A minimal, throwaway container: the output dir is mounted at + /// [`OUTPUT_DIR_IN_CONTAINER`], always refresh apt, and fix ownership of + /// the rewritten artifacts when the container root isn't id-mapped to the + /// host user (docker). + Ephemeral { chown_to: Option<(u32, u32)> }, + /// The build container itself: the `.changes` sits where + /// `dpkg-buildpackage` wrote it, the keyring material is at `staging_dir`, + /// and apt work is skipped when debsign is already installed. + Build { + work_dir: &'a Path, + staging_dir: &'a Path, + }, +} + +/// Shell scripts run inside a container to sign `changes_filename`. `setup` +/// links the forwarded agent socket, installs debsign and prepares the +/// keyring; `sign` invokes `debsign`. Runs as root. +pub fn sign_container_scripts( + mode: ContainerSignMode, agent_socket: &Path, 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(), +) -> SignContainerScripts { + let sock = agent_socket.display(); + let pubkey = PUBKEY_FILE; + let ownertrust = OWNERTRUST_FILE; + let key = shell_single_quote(sign_key); + let changes = shell_single_quote(changes_filename); + + let (staging, out, chown, install) = match mode { + ContainerSignMode::Ephemeral { chown_to } => { + let chown = match chown_to { + Some((uid, gid)) => format!( + " && chown -R {uid}:{gid} {out}", + out = OUTPUT_DIR_IN_CONTAINER + ), + None => String::new(), + }; + // A fresh container: always refresh apt. No -qq — the steps take + // seconds and silence looks like a hang. + let install = "echo 'debmagic: updating apt package lists'; \ + apt-get update; \ + echo 'debmagic: installing debsign'; \ + apt-get install -y --no-install-recommends debsign \ + || { echo 'debmagic: debsign package unavailable, installing devscripts instead'; \ + apt-get install -y --no-install-recommends devscripts; }" + .to_string(); + ( + SIGN_STAGING_IN_CONTAINER.to_string(), + OUTPUT_DIR_IN_CONTAINER.to_string(), + chown, + install, + ) + } + ContainerSignMode::Build { + work_dir, + staging_dir, + } => { + // The build container already holds an apt cache; only touch apt + // when debsign is genuinely missing. + let install = "command -v debsign >/dev/null \ + || { echo 'debmagic: installing debsign'; \ + apt-get update; \ + apt-get install -y --no-install-recommends debsign \ + || apt-get install -y --no-install-recommends devscripts; }" + .to_string(); + ( + staging_dir.display().to_string(), + work_dir.display().to_string(), + String::new(), + install, + ) + } }; - // forward the agent socket and install just `debsign` - format!( + + // gpg prefers the user-session socket dir (/run/user/$UID/gnupg) over + // $GNUPGHOME, so the forwarded agent socket must be linked there; the + // keyring (public key + ownertrust) still lives in $GNUPGHOME. + let setup = 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 --no-install-recommends debsign || apt-get install -y -qq --no-install-recommends devscripts; \ + {install}; \ gpg --batch --import {staging}/{pubkey}; \ - gpg --batch --import-ownertrust {staging}/{ownertrust}; \ - cd {out} && debsign -k{key} {changes}{chown}", - sock = agent_socket.display(), - 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), - ) + gpg --batch --import-ownertrust {staging}/{ownertrust}" + ); + + let sign = format!( + "set -e; \ + export GNUPGHOME=/root/.gnupg; \ + cd {out} && debsign -k{key} {changes}{chown}" + ); + + SignContainerScripts { setup, sign } +} + +/// 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}"), + } } /// Sign `changes_file` on the host with `debsign`. -pub fn sign_on_host(changes_file: &Path, sign_key: Option<&str>) -> anyhow::Result<()> { +pub fn sign_on_host( + changes_file: &Path, + sign_key: Option<&str>, + sign_notify: bool, + package: &str, +) -> anyhow::Result<()> { let output_dir = changes_file.parent().ok_or_else(|| { anyhow!( "could not get output directory of {}", @@ -206,6 +305,12 @@ pub fn sign_on_host(changes_file: &Path, sign_key: Option<&str>) -> anyhow::Resu cmd.arg(format!("-k{key}")); } cmd.arg(filename).current_dir(output_dir); + if sign_notify { + notify_send( + "debmagic: signing requested", + &format!("touch your key to sign {package}"), + ); + } run_checked(&mut cmd, &format!("signing {}", changes_file.display()))?; Ok(()) } @@ -243,27 +348,70 @@ mod tests { use super::*; #[test] - fn sign_script_quotes_filename_and_key() { - let script = sign_container_script( + fn ephemeral_script_quotes_filename_and_key() { + let scripts = sign_container_scripts( + ContainerSignMode::Ephemeral { chown_to: None }, Path::new("/debmagic-gpg/S.gpg-agent"), "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")); + assert!( + scripts + .sign + .contains("debsign -k'me@example.com' 'pkg_1.0_amd64.changes'") + ); + assert!(scripts.sign.contains("cd /debmagic-output")); + assert!( + scripts + .setup + .contains("gpg --batch --import /debmagic-sign/pubkey.asc") + ); + // Ephemeral always refreshes apt and links the session socket. + assert!(scripts.setup.contains("apt-get update")); + assert!(scripts.setup.contains("/run/user/0/gnupg/S.gpg-agent")); + assert!(!scripts.setup.contains("chown")); + assert!(!scripts.sign.contains("chown")); } #[test] - fn sign_script_chowns_output_when_requested() { - let script = sign_container_script( + fn ephemeral_script_chowns_output_when_requested() { + let scripts = sign_container_scripts( + ContainerSignMode::Ephemeral { + chown_to: Some((1000, 100)), + }, Path::new("/debmagic-gpg/S.gpg-agent"), "x.changes", "key", - Some((1000, 100)), ); - assert!(script.contains("chown -R 1000:100 /debmagic-output")); + assert!(scripts.sign.contains("chown -R 1000:100 /debmagic-output")); + assert!(!scripts.setup.contains("chown")); + } + + #[test] + fn build_script_signs_in_place_and_skips_apt_when_present() { + let scripts = sign_container_scripts( + ContainerSignMode::Build { + work_dir: Path::new("/debmagic/work"), + staging_dir: Path::new("/tmp/debmagic-sign"), + }, + Path::new("/tmp/debmagic-gpg/S.gpg-agent"), + "pkg_1.0_amd64.changes", + "me@example.com", + ); + assert!( + scripts + .sign + .contains("cd /debmagic/work && debsign -k'me@example.com'") + ); + assert!( + scripts + .setup + .contains("gpg --batch --import /tmp/debmagic-sign/pubkey.asc") + ); + assert!(scripts.setup.contains("command -v debsign")); + // The session socket dir is linked in both modes. + assert!(scripts.setup.contains("/run/user/0/gnupg/S.gpg-agent")); + assert!(!scripts.sign.contains("chown")); } #[test] 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, &[]) From 34d3f9e695e160846ed080f6c48df47c1951af58 Mon Sep 17 00:00:00 2001 From: Jonas Jelten Date: Sat, 5 Sep 2026 23:44:14 +0200 Subject: [PATCH 07/11] feat(config): show, get and set commands --- Cargo.lock | 45 +++++ Cargo.toml | 2 + debian/control | 2 + packages/debmagic/Cargo.toml | 2 + packages/debmagic/src/build_intent.rs | 34 +--- packages/debmagic/src/cli.rs | 55 ++++++ packages/debmagic/src/config.rs | 235 +++++++++++++++++++++++-- packages/debmagic/src/driver/config.rs | 4 +- packages/debmagic/src/main.rs | 68 ++++++- packages/debmagic/src/test/intent.rs | 3 +- 10 files changed, 400 insertions(+), 50 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0888bd09..9ecef319 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -387,6 +387,8 @@ dependencies = [ "libc", "serde", "serde_json", + "toml", + "toml_edit", "uuid", ] @@ -472,6 +474,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 +579,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" @@ -728,6 +742,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" @@ -1308,10 +1332,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 +1350,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 +1372,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" diff --git a/Cargo.toml b/Cargo.toml index 25c28d41..e1e6c3ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,8 @@ 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" } diff --git a/debian/control b/debian/control index fad3b267..cb914ecd 100644 --- a/debian/control +++ b/debian/control @@ -21,6 +21,8 @@ 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), diff --git a/packages/debmagic/Cargo.toml b/packages/debmagic/Cargo.toml index 622a9516..162fdee7 100644 --- a/packages/debmagic/Cargo.toml +++ b/packages/debmagic/Cargo.toml @@ -17,6 +17,8 @@ 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 } diff --git a/packages/debmagic/src/build_intent.rs b/packages/debmagic/src/build_intent.rs index f0208a8d..d0c4b4c0 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; @@ -50,41 +50,13 @@ 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())?; if let Some(persistent) = input.persistent { config.driver.persistent = persistent; @@ -199,7 +171,7 @@ mod tests { #[test] fn load_config_reads_explicit_file() -> 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"), diff --git a/packages/debmagic/src/cli.rs b/packages/debmagic/src/cli.rs index ab01354c..da2f5ae2 100644 --- a/packages/debmagic/src/cli.rs +++ b/packages/debmagic/src/cli.rs @@ -46,10 +46,65 @@ pub enum Commands { Test(TestSubcommandArgs), #[command(about = "Check the project")] Check(CheckSubcommandArgs), + #[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( diff --git a/packages/debmagic/src/config.rs b/packages/debmagic/src/config.rs index 64f562d3..be6bd913 100644 --- a/packages/debmagic/src/config.rs +++ b/packages/debmagic/src/config.rs @@ -1,14 +1,149 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crate::build::source::SourceSyncMode; use crate::driver::config::DriverConfig; use crate::signing::SignWith; 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, @@ -34,7 +169,7 @@ pub struct Config { } /// `[sign]` section: whether and how to sign the build artifacts. -#[derive(Deserialize, Debug, Clone, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, Default)] #[serde(default)] pub struct SignConfig { /// Sign the source package (`.changes`/`.dsc`) with `debsign` after @@ -69,13 +204,59 @@ impl Default for Config { } 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 @@ -87,6 +268,30 @@ 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)] @@ -99,7 +304,11 @@ mod tests { 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); @@ -120,7 +329,11 @@ mod tests { &file, "[sign]\nsource = true\nwith = \"separate\"\nkey = \"you@example.com\"\nnotify = true\n", )?; - let cfg = Config::new(&vec![file.clone()])?; + 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.with, SignWith::Separate); diff --git a/packages/debmagic/src/driver/config.rs b/packages/debmagic/src/driver/config.rs index ccd218e7..0ed85b90 100644 --- a/packages/debmagic/src/driver/config.rs +++ b/packages/debmagic/src/driver/config.rs @@ -1,11 +1,11 @@ -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 diff --git a/packages/debmagic/src/main.rs b/packages/debmagic/src/main.rs index 1c53bda3..e6e77c6f 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, @@ -53,7 +54,7 @@ fn run() -> anyhow::Result { BuildTarget::Source(source_args) => (&source_args.build, None, None, true), }; - let config_driver = load_config( + let config_driver = Config::load( build_args.common.source_dir.as_deref(), cli.config.as_deref(), )? @@ -128,7 +129,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)?; } @@ -168,6 +169,65 @@ fn run() -> anyhow::Result { Commands::Check(_args) => { println!("Check subcommand! - not implemented"); } + 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/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; From 93b77293de208e31990062f3f3976302ce329329 Mon Sep 17 00:00:00 2001 From: Jonas Jelten Date: Sun, 6 Sep 2026 00:13:36 +0200 Subject: [PATCH 08/11] refactor(signing): remove container signing machinery prepare to always run signing on the host. --- packages/debmagic/src/build/mod.rs | 109 +---- packages/debmagic/src/driver/driver_bare.rs | 24 +- packages/debmagic/src/driver/driver_docker.rs | 159 +------ packages/debmagic/src/driver/driver_lxd.rs | 313 +------------ packages/debmagic/src/driver/mod.rs | 66 +-- packages/debmagic/src/signing.rs | 421 ------------------ 6 files changed, 25 insertions(+), 1067 deletions(-) delete mode 100644 packages/debmagic/src/signing.rs diff --git a/packages/debmagic/src/build/mod.rs b/packages/debmagic/src/build/mod.rs index 5b2ddbdf..7d99d91d 100644 --- a/packages/debmagic/src/build/mod.rs +++ b/packages/debmagic/src/build/mod.rs @@ -11,14 +11,10 @@ use crate::build::source::{source_manifest_path, stage_source_tree}; use crate::build_intent::BuildIntent; use crate::driver::{ Driver, DriverType, Environment, EnvironmentDriver, EnvironmentMetadata, EnvironmentPurpose, - SignLocation, SignRequest, config::DriverConfig, create_driver, create_driver_from_metadata, + SignRequest, config::DriverConfig, create_driver, create_driver_from_metadata, remove_environment_root, }; -use crate::{ - config::Config, - package::{PackageIdentity, PackageTarget}, - signing::{self, SignWith}, -}; +use crate::{config::Config, package::{PackageIdentity, PackageTarget}}; use anyhow::{Context, anyhow}; pub mod artifacts; @@ -30,9 +26,6 @@ pub use source::SourceSyncMode; struct Build { environment: Environment, driver: Driver, - /// Agent socket + key for container signing; `None` when signing on the - /// host or not signing at all. - gpg_forwarding: Option, attached: bool, output_dir: PathBuf, sign_package: bool, @@ -41,79 +34,8 @@ struct Build { host_arch_variant: Option, } -/// Resolve where `debsign` will run from `sign.with`, the driver, and what's -/// available on the host. Validation of the gpg setup happens in -/// [`prepare_signing`]. -fn resolve_sign_location(driver: DriverType, sign_with: SignWith) -> anyhow::Result { - let container_driver = driver != DriverType::Bare; - let host_has_debsign = signing::check_host_debsign_available().is_ok(); - - match sign_with { - SignWith::Host => Ok(SignLocation::Host), - SignWith::Separate => { - if container_driver { - Ok(SignLocation::EphemeralContainer) - } else { - // The bare driver's "build environment" is the host. - Ok(SignLocation::Host) - } - } - SignWith::Build => { - if container_driver { - Ok(SignLocation::BuildContainer) - } else { - Err(anyhow!( - "sign.with = \"build\" needs a container build driver; \ - the bare driver can only sign on the host" - )) - } - } - SignWith::Auto => { - if host_has_debsign || !container_driver { - Ok(SignLocation::Host) - } else { - Ok(SignLocation::EphemeralContainer) - } - } - } -} - -/// Validate everything signing will need, so a broken gpg setup doesn't waste -/// a whole build. The container checks are host-side (agent socket, secret -/// key); they run before any environment is created because bootstrapping one -/// is not free. -fn prepare_signing( - location: SignLocation, - sign_key: Option<&str>, -) -> anyhow::Result> { - match location { - SignLocation::Host => { - signing::check_host_debsign_available()?; - Ok(None) - } - SignLocation::EphemeralContainer | SignLocation::BuildContainer => { - 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(Some(forwarding)) - } - } -} - impl Build { - pub fn create( - environment: Environment, - intent: &BuildIntent, - gpg_forwarding: Option, - ) -> anyhow::Result { + pub fn create(environment: Environment, intent: &BuildIntent) -> anyhow::Result { let driver = create_driver( &environment, &intent.config.driver, @@ -123,7 +45,6 @@ impl Build { Ok(Self { environment, driver, - gpg_forwarding, attached: false, output_dir: intent.output_dir.clone(), sign_package: intent.config.sign.source, @@ -161,7 +82,6 @@ impl Build { let attached = send_socket_command(build_root, "attach").is_ok(); Ok(Self { - gpg_forwarding: None, environment: metadata.environment.clone(), driver, attached, @@ -203,11 +123,7 @@ fn get_build_root_and_identifier( (package_identifier, build_root) } -fn prepare_build_env( - intent: &BuildIntent, - target: &PackageTarget, - gpg_forwarding: Option, -) -> anyhow::Result { +fn prepare_build_env(intent: &BuildIntent, target: &PackageTarget) -> anyhow::Result { let (package_identifier, build_root) = get_build_root_and_identifier(&intent.config.temp_build_dir, &target.identity); @@ -227,7 +143,7 @@ fn prepare_build_env( if intent.config.driver.persistent && build_root.exists() { // For persistent containers, starting first lets root inside delete // container-owned files the host user can't remove. - let build = Build::create(environment.clone(), intent, gpg_forwarding) + let build = Build::create(environment.clone(), intent) .context(format!("failed to create {:?} driver", intent.driver))?; if !incremental || !source_manifest_path(&environment).is_file() { build @@ -268,7 +184,7 @@ fn prepare_build_env( incremental, )?; - Build::create(environment, intent, gpg_forwarding) + Build::create(environment, intent) } pub fn get_shell_in_build(config: &Config, identity: &PackageIdentity) -> anyhow::Result<()> { @@ -314,20 +230,13 @@ fn run_build( build_commands: impl FnOnce(&Build) -> anyhow::Result<()>, ) -> anyhow::Result<()> { let sign = &request.intent.config.sign; - let (sign_location, gpg_forwarding) = if sign.source { - let location = resolve_sign_location(request.intent.driver, sign.with)?; - let forwarding = prepare_signing(location, sign.key.as_deref())?; - (Some(location), forwarding) - } else { - (None, None) - }; 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, gpg_forwarding) + let build = prepare_build_env(request.intent, request.target) .context("failed to prepare build environment")?; build .write_metadata() @@ -349,12 +258,10 @@ fn run_build( crate::output::stage("Exporting artifacts"); let changes_file = artifacts::export_build_artifacts(&build.environment.work_dir(), &build.output_dir)?; - if let (true, Some(location)) = (build.sign_package, sign_location) { + if build.sign_package { crate::output::stage(&format!("Signing {}", build.environment.package_identifier)); build.driver.sign_changes(&SignRequest { changes_file: &changes_file, - location, - gpg: build.gpg_forwarding.as_ref(), sign_key: sign.key.as_deref(), notify: sign.notify, package: &build.environment.package_identifier, diff --git a/packages/debmagic/src/driver/driver_bare.rs b/packages/debmagic/src/driver/driver_bare.rs index 8f90d75c..55bbfad4 100644 --- a/packages/debmagic/src/driver/driver_bare.rs +++ b/packages/debmagic/src/driver/driver_bare.rs @@ -1,11 +1,10 @@ use std::{path::Path, process::Command}; -use anyhow::anyhow; use serde::{Deserialize, Serialize}; use crate::driver::{ DriverType, Environment, EnvironmentDriver, EnvironmentMetadata, IsolationCapability, - SignLocation, SignRequest, config::DriverConfig, + SignRequest, config::DriverConfig, }; #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -103,25 +102,6 @@ impl EnvironmentDriver for DriverBare { } fn sign_changes(&self, request: &SignRequest) -> anyhow::Result<()> { - match request.location { - // The bare driver builds on the host; the "build environment" *is* - // the host, so container-style signing isn't a thing here. - SignLocation::BuildContainer | SignLocation::EphemeralContainer - if request.gpg.is_some() => - { - return Err(anyhow!( - "sign.with = \"build\"/\"separate\" requires a container build driver; \ - the bare driver signs on the host" - )); - } - _ => {} - } - crate::signing::check_host_debsign_available()?; - crate::signing::sign_on_host( - request.changes_file, - request.sign_key, - request.notify, - request.package, - ) + crate::signing::sign_changes(request) } } diff --git a/packages/debmagic/src/driver/driver_docker.rs b/packages/debmagic/src/driver/driver_docker.rs index 6b499245..067a5f0a 100644 --- a/packages/debmagic/src/driver/driver_docker.rs +++ b/packages/debmagic/src/driver/driver_docker.rs @@ -10,11 +10,10 @@ use debmagic_common::distro::DistroVersion; use serde::{Deserialize, Serialize}; use crate::driver::{ - APT_MIRROR_SCRIPT, ContainerSignPrep, DriverType, ENVIRONMENT_DIR_IN_CONTAINER, Environment, - EnvironmentDriver, EnvironmentMetadata, IsolationCapability, SignLocation, SignRequest, - config::DriverConfig, container_name_from_metadata, container_name_metadata, - environment_fingerprint, prepare_container_sign, 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)] @@ -70,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, } @@ -269,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)? @@ -347,9 +342,6 @@ impl DriverDocker { 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, }) } @@ -465,147 +457,6 @@ impl EnvironmentDriver for DriverDocker { } fn sign_changes(&self, request: &SignRequest) -> anyhow::Result<()> { - let Some(prep) = prepare_container_sign(request, &self.environment.temp_dir())? else { - return Ok(()); - }; - let agent_socket = Path::new(crate::signing::GPG_DIR_IN_CONTAINER) - .join(crate::signing::GPG_SOCKET_FILENAME); - - match request.location { - SignLocation::BuildContainer => self.sign_in_build_container(request, &prep), - SignLocation::EphemeralContainer => { - self.sign_in_ephemeral_container(request, &prep, &agent_socket) - } - SignLocation::Host => unreachable!("handled above"), - } - } -} - -impl DriverDocker { - /// Sign inside the running build container: copy the staging dir and the - /// agent socket in, then run `debsign` in the work dir where - /// `dpkg-buildpackage` left the `.changes`. - fn sign_in_build_container( - &self, - request: &SignRequest, - prep: &ContainerSignPrep, - ) -> anyhow::Result<()> { - use crate::signing; - - if !self.container_is_running()? { - self.container_start()?; - } - - // A running container can't take new mounts, so the staging dir and - // agent socket are copied in rather than mounted. Both are tiny. - let container_staging = "/tmp/debmagic-sign"; - run_checked( - Command::new("docker") - .arg("cp") - .arg(&prep.staging_dir) - .arg(format!("{}:{container_staging}", self.container_name)), - "copying signing material into the build container", - )?; - let container_socket = "/tmp/debmagic-gpg/S.gpg-agent"; - run_checked( - Command::new("docker") - .args(["exec", "--user", "root", &self.container_name]) - .args(["mkdir", "-p", "/tmp/debmagic-gpg"]), - "preparing gpg socket dir in build container", - )?; - run_checked( - Command::new("docker") - .arg("cp") - .arg(&prep.gpg.agent_extra_socket) - .arg(format!("{}:{container_socket}", self.container_name)), - "copying gpg-agent socket into the build container", - )?; - - let work_dir = Path::new(ENVIRONMENT_DIR_IN_CONTAINER).join("work"); - let scripts = signing::sign_container_scripts( - signing::ContainerSignMode::Build { - work_dir: &work_dir, - staging_dir: Path::new(container_staging), - }, - Path::new(container_socket), - &prep.changes, - &prep.gpg.sign_key, - ); - - let work = self.environment.work_dir(); - let run = |script: &str, context: &str| { - self.run_command_checked(&["sh", "-ec", script], &work, true, &[]) - .map_err(|e| anyhow!("{context}: {e}")) - }; - run(&scripts.setup, "preparing the build container for signing")?; - request.notify_signing(); - run(&scripts.sign, "signing in the build container")?; - Ok(()) - } - - /// Sign in a minimal, throwaway container with the agent socket forwarded - /// and the output dir mounted. - fn sign_in_ephemeral_container( - &self, - request: &SignRequest, - prep: &ContainerSignPrep, - agent_socket: &Path, - ) -> anyhow::Result<()> { - use crate::signing; - - let output_dir = request - .changes_file - .parent() - .context("changes file has no parent directory")?; - let scripts = signing::sign_container_scripts( - signing::ContainerSignMode::Ephemeral { - // The sign container's root is not id-mapped; fix ownership of - // files it rewrites so the host user can manage them afterwards. - chown_to: Some((unsafe { libc::geteuid() }, unsafe { libc::getegid() })), - }, - agent_socket, - &prep.changes, - &prep.gpg.sign_key, - ); - - println!( - "[docker] $ signing {} in a minimal {} container", - request.changes_file.display(), - self.environment.distro.codename - ); - let run = |script: &str, context: &str| { - run_checked( - Command::new("docker") - .args(["run", "--rm", "--init"]) - .args([ - "--mount", - &format!( - "{},readonly", - bind_mount_arg(&prep.staging_dir, signing::SIGN_STAGING_IN_CONTAINER) - ), - ]) - // The host agent socket itself is bind-mounted to the fixed - // listen path (docker bind-mounts create the parent dir). - .arg(format!( - "--mount=type=bind,src={},dst={}", - prep.gpg.agent_extra_socket.display(), - agent_socket.display() - )) - .args([ - "--mount", - &bind_mount_arg(output_dir, signing::OUTPUT_DIR_IN_CONTAINER), - ]) - .arg(&self.base_image) - .args(["sh", "-ec", script]), - context, - ) - }; - - run(&scripts.setup, "preparing the sign container")?; - // Notify right before debsign triggers the gpg touch prompt; the - // setup above can take long enough to miss it otherwise. - request.notify_signing(); - run(&scripts.sign, "signing in docker container")?; - Ok(()) + crate::signing::sign_changes(request) } } diff --git a/packages/debmagic/src/driver/driver_lxd.rs b/packages/debmagic/src/driver/driver_lxd.rs index 5b50d07a..48efaf11 100644 --- a/packages/debmagic/src/driver/driver_lxd.rs +++ b/packages/debmagic/src/driver/driver_lxd.rs @@ -1,6 +1,5 @@ use std::{ fs, - os::unix::fs::{MetadataExt, PermissionsExt}, path::{Path, PathBuf}, process::{Command, Stdio}, }; @@ -10,11 +9,10 @@ use debmagic_common::distro::{Distro, DistroVersion}; use serde::{Deserialize, Serialize}; use crate::driver::{ - APT_MIRROR_SCRIPT, ContainerSignPrep, DriverType, ENVIRONMENT_DIR_IN_CONTAINER, Environment, - EnvironmentDriver, EnvironmentMetadata, IsolationCapability, SignLocation, SignRequest, - config::DriverConfig, container_name_from_metadata, container_name_metadata, - environment_fingerprint, prepare_container_sign, 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. @@ -105,9 +103,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, } @@ -253,7 +248,6 @@ impl DriverLxd { environment: environment.clone(), container_name: container_name.clone(), project, - base_image: base_image.clone(), reused_environment: false, }; @@ -437,9 +431,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, }) } @@ -619,300 +610,6 @@ impl EnvironmentDriver for DriverLxd { } fn sign_changes(&self, request: &SignRequest) -> anyhow::Result<()> { - let Some(prep) = prepare_container_sign(request, &self.environment.temp_dir())? else { - return Ok(()); - }; - - match request.location { - SignLocation::BuildContainer => self.sign_in_build_container(request, &prep), - SignLocation::EphemeralContainer => self.sign_in_ephemeral_container(request, &prep), - SignLocation::Host => unreachable!("handled above"), - } - } -} - -impl DriverLxd { - /// Sign inside the running build container: add the staging dir and a - /// proxy for the agent socket as devices, then run `debsign` in the work - /// dir where `dpkg-buildpackage` left the `.changes`. - fn sign_in_build_container( - &self, - request: &SignRequest, - prep: &ContainerSignPrep, - ) -> anyhow::Result<()> { - use crate::signing; - - self.with_running_container(|_| Ok(())) - .map_err(|e| anyhow::anyhow!("build container is not available for signing: {e}"))?; - - let bin = self.variant.binary(); - let container = &self.container_name; - // Devices on a running container are hot-plugged; remove them again - // after signing so a persistent container doesn't keep them around. - let staging_device = resource_name( - "debmagic-sign", - &self.environment.package_name, - &self.environment.identifier(), - ); - let socket_device = format!("{staging_device}-agent"); - let socket_dir = Path::new("/tmp/debmagic-gpg"); - let socket = socket_dir.join(signing::GPG_SOCKET_FILENAME); - - let cleanup = |driver: &Self| { - for dev in [&staging_device, &socket_device] { - let _ = driver - .lxd_cmd("config") - .args(["device", "remove"]) - .arg(container) - .arg(dev) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); - } - }; - - let result = (|| -> anyhow::Result<()> { - // The proxy device hot-plugs its listen socket, so its parent dir - // must already exist inside the running container. - self.exec_in_container_checked( - &["mkdir", "-p", &socket_dir.to_string_lossy()], - None, - true, - &[], - ) - .map_err(|e| anyhow::anyhow!("failed to create gpg socket dir in container: {e}"))?; - - run_checked( - self.lxd_cmd("config") - .arg("device") - .arg("add") - .arg(container) - .arg(&staging_device) - .arg("disk") - .arg(format!("source={}", prep.staging_dir.display())) - .arg(format!("path={}", signing::SIGN_STAGING_IN_CONTAINER)) - .arg("readonly=true"), - "mounting signing material into build container", - )?; - // Forward the host agent socket via a proxy listening in a - // container-local /tmp dir (the build root is idmapped, which the - // proxy can't write to as root). - let socket_owner = std::fs::metadata(&prep.gpg.agent_extra_socket) - .context("failed to stat host gpg-agent socket")?; - run_checked( - self.lxd_cmd("config") - .arg("device") - .arg("add") - .arg(container) - .arg(&socket_device) - .arg("proxy") - .arg("bind=container") - .arg(format!( - "connect=unix:{}", - prep.gpg.agent_extra_socket.display() - )) - .arg(format!("listen=unix:{}", socket.display())) - .arg("uid=0") - .arg("gid=0") - .arg(format!("security.uid={}", socket_owner.uid())) - .arg(format!("security.gid={}", socket_owner.gid())), - "forwarding gpg-agent socket into build container", - )?; - - let work_dir = Path::new(ENVIRONMENT_DIR_IN_CONTAINER).join("work"); - let scripts = signing::sign_container_scripts( - signing::ContainerSignMode::Build { - work_dir: &work_dir, - staging_dir: Path::new(signing::SIGN_STAGING_IN_CONTAINER), - }, - &socket, - &prep.changes, - &prep.gpg.sign_key, - ); - let work = self.environment.work_dir(); - self.exec_in_container_checked(&["sh", "-ec", &scripts.setup], Some(&work), true, &[]) - .map_err(|e| { - anyhow::anyhow!("preparing the {bin} build container for signing: {e}") - })?; - request.notify_signing(); - self.exec_in_container_checked(&["sh", "-ec", &scripts.sign], Some(&work), true, &[]) - .map_err(|e| anyhow::anyhow!("signing in the {bin} build container failed: {e}"))?; - Ok(()) - })(); - - cleanup(self); - result - } - - /// Sign in a minimal, throwaway container with the agent socket forwarded - /// and the output dir mounted. - fn sign_in_ephemeral_container( - &self, - request: &SignRequest, - prep: &ContainerSignPrep, - ) -> anyhow::Result<()> { - use crate::signing; - - let output_dir = request - .changes_file - .parent() - .context("changes file has no parent directory")?; - let gpg_dir = self.environment.temp_dir().join("sign-gpg"); - std::fs::create_dir_all(&gpg_dir).context("failed to create gpg socket dir")?; - // The lxd proxy runs on the host as real root, which an idmapped - // mount (raw.idmap) maps to "other" on a dir owned by the host user — - // so the proxy needs o+wx to create its listen socket here. The dir - // is throwaway (build temp dir) and holds only the transient socket. - std::fs::set_permissions(&gpg_dir, std::fs::Permissions::from_mode(0o777)) - .context("failed to make gpg socket dir writable for the proxy")?; - // No chown needed: raw.idmap maps container root to the host user. - let agent_socket = - Path::new(signing::GPG_DIR_IN_CONTAINER).join(signing::GPG_SOCKET_FILENAME); - let scripts = signing::sign_container_scripts( - signing::ContainerSignMode::Ephemeral { chown_to: None }, - &agent_socket, - &prep.changes, - &prep.gpg.sign_key, - ); - let gpg = &prep.gpg; - - 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={}", prep.staging_dir.display())) - .arg(format!("path={}", signing::SIGN_STAGING_IN_CONTAINER)) - .arg("readonly=true"), - "mounting signing material into sign container", - )?; - run_checked( - self.lxd_cmd("config") - .arg("device") - .arg("add") - .arg(&sign_container) - .arg("debmagic-gpg-dir") - .arg("disk") - .arg(format!("source={}", gpg_dir.display())) - .arg(format!("path={}", signing::GPG_DIR_IN_CONTAINER)), - "mounting gpg socket dir into sign container", - )?; - // Forward the host gpg-agent's extra socket via a proxy device, - // listening inside the mounted output dir: the proxy needs the - // listen path's parent to be a host-mounted directory it can - // write to (rootfs dirs don't exist yet when the device is set - // up, and /run is mounted over at boot). security.uid/gid select - // the credentials lxd connects to the host socket with; gpg-agent - // rejects connections that don't come from the socket's owner, so - // this must be the host user's ids, not the default root. - let socket_owner = std::fs::metadata(&gpg.agent_extra_socket) - .context("failed to stat host gpg-agent socket")?; - 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_DIR_IN_CONTAINER, - signing::GPG_SOCKET_FILENAME - )) - .arg("uid=0") - .arg("gid=0") - .arg(format!("security.uid={}", socket_owner.uid())) - .arg(format!("security.gid={}", socket_owner.gid())), - "forwarding gpg-agent socket into sign container", - )?; - - run_checked( - self.lxd_cmd("start").arg(&sign_container), - &format!("starting {bin} sign container"), - )?; - - // Same first-boot caveat as the build container: on Ubuntu images - // cloud-init may still hold the apt lock. - if matches!(self.environment.distro.distro, Distro::Ubuntu) { - self.exec_in_container_checked( - &["cloud-init", "status", "--wait"], - None, - true, - &[], - ) - .map_err(|e| anyhow::anyhow!("Error waiting for cloud-init to finish: {e}"))?; - } - - println!( - "[{bin}] $ signing {} in a minimal {} container", - request.changes_file.display(), - self.environment.distro.codename - ); - - let mut setup = self.lxd_cmd("exec"); - setup.arg(&sign_container); - setup.arg("--"); - setup.args(["sh", "-ec", &scripts.setup]); - run_checked(&mut setup, "preparing the sign container")?; - - // Notify right before debsign triggers the gpg touch prompt; the - // setup above can take long enough to miss it otherwise. - request.notify_signing(); - let mut sign = self.lxd_cmd("exec"); - sign.arg(&sign_container); - sign.arg("--"); - sign.args(["sh", "-ec", &scripts.sign]); - run_checked(&mut sign, "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) + crate::signing::sign_changes(request) } } diff --git a/packages/debmagic/src/driver/mod.rs b/packages/debmagic/src/driver/mod.rs index f53043f9..bf1f0d29 100644 --- a/packages/debmagic/src/driver/mod.rs +++ b/packages/debmagic/src/driver/mod.rs @@ -402,37 +402,22 @@ pub fn create_driver_from_metadata( } } -/// A single `debsign` invocation: what to sign, where to run it, and whether -/// to send a desktop notification just before the gpg touch prompt. +/// 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, - /// Where `debsign` runs, as resolved from `sign.with` and the driver. - pub location: SignLocation, - /// Agent socket + key for container signing; `None` for host signing. - pub gpg: Option<&'a crate::signing::GpgForwarding>, - /// `debsign -k` value; `None` lets debsign do its maintainer lookup (host only). + /// Key ID/email to sign with; `None` falls back to the maintainer lookup. pub sign_key: Option<&'a str>, - /// Send a `notify-send` popup right before `debsign`. + /// Send a `notify-send` popup right before signing. pub notify: bool, /// `"{name}-{version}"`, used in the notification. pub package: &'a str, } -/// Where `debsign` actually runs for a build. -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub enum SignLocation { - /// On the host, using the host's own gpg keyring. - Host, - /// In a minimal, throwaway same-distro container. - EphemeralContainer, - /// Inside the build container itself (requires a container driver). - BuildContainer, -} - impl SignRequest<'_> { /// Send the "touch your key" notification if enabled. Called right before - /// `debsign` runs so a hardware-key prompt isn't missed. + /// signing runs so a hardware-key prompt isn't missed. pub fn notify_signing(&self) { if self.notify { crate::signing::notify_send( @@ -443,47 +428,6 @@ impl SignRequest<'_> { } } -/// Host-side preparation shared by the containerized drivers: the gpg -/// forwarding setup, the staged public key + ownertrust, and the `.changes` -/// filename. `None` when the request resolved to host signing (already run). -pub struct ContainerSignPrep { - pub gpg: crate::signing::GpgForwarding, - pub staging_dir: PathBuf, - pub changes: String, -} - -/// Stage everything a container sign needs. Returns `Ok(None)` after signing -/// on the host when `location` is `Host`, so container drivers can -/// `let Some(prep) = ... else { return Ok(()) }`. `temp_dir` is the driver's -/// build-temp dir, under which the `sign` staging dir is created. -pub fn prepare_container_sign( - request: &SignRequest, - temp_dir: &Path, -) -> anyhow::Result> { - use crate::signing; - - if request.location == SignLocation::Host { - signing::sign_on_host( - request.changes_file, - request.sign_key, - request.notify, - request.package, - )?; - return Ok(None); - } - let gpg = request - .gpg - .context("container signing needs a gpg forwarding setup")?; - let staging_dir = temp_dir.join("sign"); - signing::stage_signing_material(&staging_dir, &gpg.sign_key)?; - let changes = signing::changes_filename(request.changes_file)?.to_string(); - Ok(Some(ContainerSignPrep { - gpg: gpg.clone(), - staging_dir, - changes, - })) -} - /// 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. diff --git a/packages/debmagic/src/signing.rs b/packages/debmagic/src/signing.rs deleted file mode 100644 index 98a2d2de..00000000 --- a/packages/debmagic/src/signing.rs +++ /dev/null @@ -1,421 +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; - -/// Directory holding the forwarded gpg-agent socket, mounted read-write -/// into sign containers at [`GPG_DIR_IN_CONTAINER`]. The lxd proxy's listen -/// path must live in a host-mounted directory: dirs of the container rootfs -/// don't exist yet when the proxy is set up, /run gets mounted over at boot, -/// and the user's output dir is not ours to litter in. -pub const GPG_DIR_IN_CONTAINER: &str = "/debmagic-gpg"; -/// Socket filename created inside the gpg dir. -pub const GPG_SOCKET_FILENAME: &str = "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, separate container of the same distro, - /// forwarding the host's gpg-agent socket. Requires `sign_key` to be set. - Separate, - /// Sign inside the build container itself (no separate container is - /// started), forwarding the host's gpg-agent socket. Requires `sign_key` - /// and a containerized build driver. - Build, -} - -/// 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. -#[derive(Clone)] -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(()) -} - -/// The two phases of in-container signing, split so the host can send the -/// "touch your key" notification after the slow setup but immediately before -/// `debsign` runs. -pub struct SignContainerScripts { - /// Links the forwarded agent socket, installs debsign, seeds the keyring. - pub setup: String, - /// Runs `debsign` (and the ownership fixup). Triggers the gpg touch prompt. - pub sign: String, -} - -/// How the container signs, which sets where the artifacts live and whether -/// the container is a throwaway or the build environment itself. -pub enum ContainerSignMode<'a> { - /// A minimal, throwaway container: the output dir is mounted at - /// [`OUTPUT_DIR_IN_CONTAINER`], always refresh apt, and fix ownership of - /// the rewritten artifacts when the container root isn't id-mapped to the - /// host user (docker). - Ephemeral { chown_to: Option<(u32, u32)> }, - /// The build container itself: the `.changes` sits where - /// `dpkg-buildpackage` wrote it, the keyring material is at `staging_dir`, - /// and apt work is skipped when debsign is already installed. - Build { - work_dir: &'a Path, - staging_dir: &'a Path, - }, -} - -/// Shell scripts run inside a container to sign `changes_filename`. `setup` -/// links the forwarded agent socket, installs debsign and prepares the -/// keyring; `sign` invokes `debsign`. Runs as root. -pub fn sign_container_scripts( - mode: ContainerSignMode, - agent_socket: &Path, - changes_filename: &str, - sign_key: &str, -) -> SignContainerScripts { - let sock = agent_socket.display(); - let pubkey = PUBKEY_FILE; - let ownertrust = OWNERTRUST_FILE; - let key = shell_single_quote(sign_key); - let changes = shell_single_quote(changes_filename); - - let (staging, out, chown, install) = match mode { - ContainerSignMode::Ephemeral { chown_to } => { - let chown = match chown_to { - Some((uid, gid)) => format!( - " && chown -R {uid}:{gid} {out}", - out = OUTPUT_DIR_IN_CONTAINER - ), - None => String::new(), - }; - // A fresh container: always refresh apt. No -qq — the steps take - // seconds and silence looks like a hang. - let install = "echo 'debmagic: updating apt package lists'; \ - apt-get update; \ - echo 'debmagic: installing debsign'; \ - apt-get install -y --no-install-recommends debsign \ - || { echo 'debmagic: debsign package unavailable, installing devscripts instead'; \ - apt-get install -y --no-install-recommends devscripts; }" - .to_string(); - ( - SIGN_STAGING_IN_CONTAINER.to_string(), - OUTPUT_DIR_IN_CONTAINER.to_string(), - chown, - install, - ) - } - ContainerSignMode::Build { - work_dir, - staging_dir, - } => { - // The build container already holds an apt cache; only touch apt - // when debsign is genuinely missing. - let install = "command -v debsign >/dev/null \ - || { echo 'debmagic: installing debsign'; \ - apt-get update; \ - apt-get install -y --no-install-recommends debsign \ - || apt-get install -y --no-install-recommends devscripts; }" - .to_string(); - ( - staging_dir.display().to_string(), - work_dir.display().to_string(), - String::new(), - install, - ) - } - }; - - // gpg prefers the user-session socket dir (/run/user/$UID/gnupg) over - // $GNUPGHOME, so the forwarded agent socket must be linked there; the - // keyring (public key + ownertrust) still lives in $GNUPGHOME. - let setup = 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\"; \ - {install}; \ - gpg --batch --import {staging}/{pubkey}; \ - gpg --batch --import-ownertrust {staging}/{ownertrust}" - ); - - let sign = format!( - "set -e; \ - export GNUPGHOME=/root/.gnupg; \ - cd {out} && debsign -k{key} {changes}{chown}" - ); - - SignContainerScripts { setup, sign } -} - -/// 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}"), - } -} - -/// Sign `changes_file` on the host with `debsign`. -pub fn sign_on_host( - changes_file: &Path, - sign_key: Option<&str>, - sign_notify: bool, - package: &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); - if sign_notify { - notify_send( - "debmagic: signing requested", - &format!("touch your key to sign {package}"), - ); - } - 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 shipped in the debsign package (devscripts on \ - older releases); 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 ephemeral_script_quotes_filename_and_key() { - let scripts = sign_container_scripts( - ContainerSignMode::Ephemeral { chown_to: None }, - Path::new("/debmagic-gpg/S.gpg-agent"), - "pkg_1.0_amd64.changes", - "me@example.com", - ); - assert!( - scripts - .sign - .contains("debsign -k'me@example.com' 'pkg_1.0_amd64.changes'") - ); - assert!(scripts.sign.contains("cd /debmagic-output")); - assert!( - scripts - .setup - .contains("gpg --batch --import /debmagic-sign/pubkey.asc") - ); - // Ephemeral always refreshes apt and links the session socket. - assert!(scripts.setup.contains("apt-get update")); - assert!(scripts.setup.contains("/run/user/0/gnupg/S.gpg-agent")); - assert!(!scripts.setup.contains("chown")); - assert!(!scripts.sign.contains("chown")); - } - - #[test] - fn ephemeral_script_chowns_output_when_requested() { - let scripts = sign_container_scripts( - ContainerSignMode::Ephemeral { - chown_to: Some((1000, 100)), - }, - Path::new("/debmagic-gpg/S.gpg-agent"), - "x.changes", - "key", - ); - assert!(scripts.sign.contains("chown -R 1000:100 /debmagic-output")); - assert!(!scripts.setup.contains("chown")); - } - - #[test] - fn build_script_signs_in_place_and_skips_apt_when_present() { - let scripts = sign_container_scripts( - ContainerSignMode::Build { - work_dir: Path::new("/debmagic/work"), - staging_dir: Path::new("/tmp/debmagic-sign"), - }, - Path::new("/tmp/debmagic-gpg/S.gpg-agent"), - "pkg_1.0_amd64.changes", - "me@example.com", - ); - assert!( - scripts - .sign - .contains("cd /debmagic/work && debsign -k'me@example.com'") - ); - assert!( - scripts - .setup - .contains("gpg --batch --import /tmp/debmagic-sign/pubkey.asc") - ); - assert!(scripts.setup.contains("command -v debsign")); - // The session socket dir is linked in both modes. - assert!(scripts.setup.contains("/run/user/0/gnupg/S.gpg-agent")); - assert!(!scripts.sign.contains("chown")); - } - - #[test] - fn shell_quote_escapes_single_quotes() { - assert_eq!(shell_single_quote("a'b"), "'a'\\''b'"); - } -} From 320d2fc02a98d1b39ead65639beacc0cf5c6da5d Mon Sep 17 00:00:00 2001 From: Jonas Jelten Date: Tue, 8 Sep 2026 12:59:01 +0200 Subject: [PATCH 09/11] feat(sign): debmagic sign to replace debsign --- Cargo.lock | 103 ++- Cargo.toml | 4 + debian/control | 6 +- docs/usage/build.md | 44 +- docs/usage/config.md | 30 +- docs/usage/source.md | 5 +- packages/debmagic/Cargo.toml | 4 + packages/debmagic/README.md | 2 +- packages/debmagic/src/build/mod.rs | 7 +- packages/debmagic/src/build_intent.rs | 50 +- packages/debmagic/src/cli.rs | 122 ++-- packages/debmagic/src/config.rs | 25 +- packages/debmagic/src/driver/driver_bare.rs | 2 +- packages/debmagic/src/driver/driver_docker.rs | 10 +- packages/debmagic/src/driver/driver_lxd.rs | 9 +- packages/debmagic/src/driver/mod.rs | 22 +- packages/debmagic/src/main.rs | 69 +- packages/debmagic/src/output.rs | 36 ++ packages/debmagic/src/sign.rs | 587 ++++++++++++++++++ packages/debmagic/tests/sign.rs | 201 ++++++ packages/debmagic/tests/signing.rs | 240 ------- 21 files changed, 1153 insertions(+), 425 deletions(-) create mode 100644 packages/debmagic/src/sign.rs create mode 100644 packages/debmagic/tests/sign.rs delete mode 100644 packages/debmagic/tests/signing.rs diff --git a/Cargo.lock b/Cargo.lock index 9ecef319..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,11 @@ dependencies = [ "glob", "ignore", "libc", + "md-5", "serde", "serde_json", + "sha1", + "sha2 0.11.0", "toml", "toml_edit", "uuid", @@ -420,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]] @@ -600,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" @@ -837,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" @@ -951,7 +1018,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf1d70880e76bdc13ba52eafa6239ce793d85c8e43896507e43dd8984ff05b82" dependencies = [ "pest", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -1157,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" @@ -1170,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]] @@ -1386,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 e1e6c3ea..e7533308 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,11 @@ 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 cb914ecd..026892aa 100644 --- a/debian/control +++ b/debian/control @@ -28,9 +28,13 @@ Build-Depends: 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 bf91d06d..4245ed42 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,28 +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 | -- `auto` (default): sign on the host if `debsign` is installed there, otherwise in a separate container (requires a container driver). -- `host`: always sign on the host, using your own gpg keyring — requires `devscripts` installed locally. -- `build`: sign inside the build container itself, reusing its environment instead of starting a new one. - The host's gpg-agent socket is forwarded in just like `separate`, but no second container is bootstrapped — the package's build environment is trusted anyway. -- `separate`: sign inside a minimal, separate 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. +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). -Container signing (`build`/`separate`) 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 `..`), 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 c4c204f2..9627e0f6 100644 --- a/docs/usage/config.md +++ b/docs/usage/config.md @@ -31,11 +31,12 @@ All keys are optional. | `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`/`--no-sign` | Sign the resulting `.changes`/`.dsc` with `debsign` (see below). | -| `sign.with` | enum | `auto` | `--sign-with` | Where `debsign` runs (see below). | -| `sign.key` | string | — | `--sign-key` | GPG key ID/email for `debsign -k`. Required for container signing. | -| `sign.notify` | bool | `false` | `--sign-notify`/`--no-sign-notify` | Send a desktop notification via `notify-send` just before `debsign` runs, so a hardware-key touch prompt isn't missed. | -| `clean` | bool | `false` | `--clean`/`--no-clean` | Run `debian/rules clean` before building. Disabled by default; incompatible with `incremental`. | +| `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`. | @@ -51,19 +52,13 @@ All keys are optional. | Key | Type | Default | CLI flag | Description | |---|---|---|---|---| -| `source` | bool | `false` | `--sign`/`--no-sign` | Sign the source package (`.changes`/`.dsc`) with `debsign`. | -| `with` | enum | `auto` | `--sign-with` | Where `debsign` runs (see below). | -| `key` | string | — | `--sign-key` | GPG key ID/email for `debsign -k`. Required for container signing. | -| `notify` | bool | `false` | `--sign-notify`/`--no-sign-notify` | Desktop notification via `notify-send` before signing. | +| `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. | -#### `sign.with` - -| Value | Behavior | -|---|---| -| `auto` (default) | Sign on the host if `debsign` is available there, otherwise in a separate container. | -| `host` | Always sign on the host with `debsign`. | -| `build` | Sign inside the build container itself (no separate container is started). Requires a container driver and `sign.key`. | -| `separate` | Sign inside a minimal, separate same-distro container, forwarding the host's gpg-agent socket. Requires `sign.key`. | +Signing always runs on the host with your gpg keyring — see [Signing](build.md#signing). ## Example @@ -74,7 +69,6 @@ clean = false [sign] source = true -with = "separate" key = "you@example.com or gpg key id" notify = true diff --git a/docs/usage/source.md b/docs/usage/source.md index 79fb7e95..d1907e14 100644 --- a/docs/usage/source.md +++ b/docs/usage/source.md @@ -28,9 +28,8 @@ 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 separate` (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, or `--sign-with build` reuses the build container — see [Signing](build.md#signing). -- `--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 diff --git a/packages/debmagic/Cargo.toml b/packages/debmagic/Cargo.toml index 162fdee7..8ee261cc 100644 --- a/packages/debmagic/Cargo.toml +++ b/packages/debmagic/Cargo.toml @@ -22,4 +22,8 @@ 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 7d99d91d..95043007 100644 --- a/packages/debmagic/src/build/mod.rs +++ b/packages/debmagic/src/build/mod.rs @@ -14,7 +14,10 @@ use crate::driver::{ SignRequest, config::DriverConfig, create_driver, create_driver_from_metadata, remove_environment_root, }; -use crate::{config::Config, package::{PackageIdentity, PackageTarget}}; +use crate::{ + config::Config, + package::{PackageIdentity, PackageTarget}, +}; use anyhow::{Context, anyhow}; pub mod artifacts; @@ -263,6 +266,8 @@ fn run_build( 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, })?; diff --git a/packages/debmagic/src/build_intent.rs b/packages/debmagic/src/build_intent.rs index d0c4b4c0..92cb8a8b 100644 --- a/packages/debmagic/src/build_intent.rs +++ b/packages/debmagic/src/build_intent.rs @@ -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,17 +20,13 @@ 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 no_sign_notify: Option, pub clean: Option, - pub no_clean: Option, pub source_sync: Option, pub host_arch_variant: Option, pub shell_on_failure: Option, @@ -62,37 +58,30 @@ pub fn resolve_build_intent(input: BuildIntentInput) -> anyhow::Result 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 da2f5ae2..4d0273d0 100644 --- a/packages/debmagic/src/cli.rs +++ b/packages/debmagic/src/cli.rs @@ -2,6 +2,7 @@ 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 @@ -46,6 +47,8 @@ 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")] @@ -144,6 +147,16 @@ pub struct LxdArgs { /// Flags shared between `debmagic build binary` and `debmagic build source`. #[derive(Args, Debug)] pub struct CommonBuildArgs { + #[arg( + short, + long, + 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, @@ -206,71 +219,48 @@ pub struct CommonBuildArgs { long, num_args = 0..=1, default_missing_value = "true", - action = clap::ArgAction::Set, - overrides_with = "no_sign", - help = "Sign the resulting .changes/.dsc with debsign after building. Defaults to the 'sign.source' setting in the config file (false if unset)." + 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, - num_args = 0..=1, - default_missing_value = "true", - action = clap::ArgAction::Set, - help = "Do not sign the resulting .changes/.dsc, overriding a 'sign.source = 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 debsign there), 'build' signs inside the build container itself, 'separate' signs in a minimal, separate same-distro container, 'auto' (default) uses the host if debsign is available there, else a separate container. Container signing forwards the host gpg-agent socket and requires --sign-key. 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 = "sign-notify", num_args = 0..=1, default_missing_value = "true", - action = clap::ArgAction::Set, - overrides_with = "no_sign_notify", - help = "Send a desktop notification via notify-send just before debsign runs, so a hardware-key touch prompt isn't missed. Defaults to the 'sign.notify' setting in the config file (false if unset)." + 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( - long = "no-sign-notify", - num_args = 0..=1, - default_missing_value = "true", - action = clap::ArgAction::Set, - help = "Do not send a signing notification, overriding a 'sign.notify = true' default in the config file." - )] - pub no_sign_notify: Option, - #[arg( long, num_args = 0..=1, default_missing_value = "true", - action = clap::ArgAction::Set, - overrides_with = "no_clean", + 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 clean: Option, - #[arg( - long, - num_args = 0..=1, - default_missing_value = "true", - action = clap::ArgAction::Set, - help = "Do not run 'debian/rules clean' before building, overriding a 'clean = true' default in the config file." - )] - pub no_clean: Option, - #[arg( long = "shell-on-failure", action = clap::ArgAction::SetTrue, @@ -306,16 +296,6 @@ pub struct BinaryTargetArgs { #[command(flatten)] pub build: CommonBuildArgs, - #[arg( - short, - long, - 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( long = "debug-symbols", num_args = 0..=1, @@ -411,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 be6bd913..704bb4d3 100644 --- a/packages/debmagic/src/config.rs +++ b/packages/debmagic/src/config.rs @@ -2,7 +2,7 @@ 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, Serialize}; @@ -172,18 +172,17 @@ pub struct Config { #[derive(Serialize, Deserialize, Debug, Clone, Default)] #[serde(default)] pub struct SignConfig { - /// Sign the source package (`.changes`/`.dsc`) with `debsign` after - /// building. + /// Sign the source package (`.changes`/`.dsc`) after building. pub source: bool, - /// Where `debsign` runs: on the host or inside a minimal same-distro - /// container with the host's gpg-agent socket forwarded in. - pub 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. + /// GPG key ID/email to sign with. `None` falls back to the + /// `Changed-By:`/`Maintainer:` address of the file being signed. pub key: Option, - /// Send a desktop notification via `notify-send` just before `debsign` - /// runs, so a hardware-key touch prompt isn't missed. + /// 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, } @@ -327,7 +326,7 @@ mod tests { let file = dir.join("sign.toml"); std::fs::write( &file, - "[sign]\nsource = true\nwith = \"separate\"\nkey = \"you@example.com\"\nnotify = true\n", + "[sign]\nsource = true\nkey = \"you@example.com\"\ncommand = \"gpg --foo\"\nnotify = true\n", )?; let cfg = Config::new(&[ConfigPath::new( ConfigLayer::Explicit, @@ -336,8 +335,8 @@ mod tests { )])?; std::fs::remove_dir_all(&dir).ok(); assert!(cfg.sign.source); - assert_eq!(cfg.sign.with, SignWith::Separate); 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/driver_bare.rs b/packages/debmagic/src/driver/driver_bare.rs index 55bbfad4..83aeda8c 100644 --- a/packages/debmagic/src/driver/driver_bare.rs +++ b/packages/debmagic/src/driver/driver_bare.rs @@ -102,6 +102,6 @@ impl EnvironmentDriver for DriverBare { } fn sign_changes(&self, request: &SignRequest) -> anyhow::Result<()> { - crate::signing::sign_changes(request) + crate::sign::sign_changes(request) } } diff --git a/packages/debmagic/src/driver/driver_docker.rs b/packages/debmagic/src/driver/driver_docker.rs index 067a5f0a..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, 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, + container_name_from_metadata, container_name_metadata, environment_fingerprint, resource_name, + run_checked, translate_path_in_container, }; #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -336,7 +336,7 @@ impl DriverDocker { pub fn from_metadata( environment: &Environment, - driver_config: &DriverConfig, + _driver_config: &DriverConfig, metadata: &EnvironmentMetadata, ) -> anyhow::Result { Ok(Self { @@ -457,6 +457,6 @@ impl EnvironmentDriver for DriverDocker { } fn sign_changes(&self, request: &SignRequest) -> anyhow::Result<()> { - crate::signing::sign_changes(request) + crate::sign::sign_changes(request) } } diff --git a/packages/debmagic/src/driver/driver_lxd.rs b/packages/debmagic/src/driver/driver_lxd.rs index 48efaf11..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, 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, + 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. @@ -421,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(); @@ -610,6 +609,6 @@ impl EnvironmentDriver for DriverLxd { } fn sign_changes(&self, request: &SignRequest) -> anyhow::Result<()> { - crate::signing::sign_changes(request) + crate::sign::sign_changes(request) } } diff --git a/packages/debmagic/src/driver/mod.rs b/packages/debmagic/src/driver/mod.rs index bf1f0d29..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; @@ -239,8 +240,8 @@ pub trait EnvironmentDriver { true } - /// Sign `changes_file` (a path on the host) with `debsign`, at the - /// location resolved in the request. + /// Sign `changes_file` (a path on the host) with debmagic's signing + /// implementation. fn sign_changes(&self, request: &SignRequest) -> anyhow::Result<()>; } @@ -409,25 +410,16 @@ pub struct SignRequest<'a> { 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, } -impl SignRequest<'_> { - /// Send the "touch your key" notification if enabled. Called right before - /// signing runs so a hardware-key prompt isn't missed. - pub fn notify_signing(&self) { - if self.notify { - crate::signing::notify_send( - "debmagic: signing requested", - &format!("touch your key to sign {}", self.package), - ); - } - } -} - /// 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. diff --git a/packages/debmagic/src/main.rs b/packages/debmagic/src/main.rs index e6e77c6f..9bab51cd 100644 --- a/packages/debmagic/src/main.rs +++ b/packages/debmagic/src/main.rs @@ -24,7 +24,7 @@ pub mod config; pub mod driver; pub mod output; pub mod package; -pub mod signing; +pub mod sign; pub mod test; fn main() -> ExitCode { @@ -44,14 +44,11 @@ fn run() -> anyhow::Result { 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( @@ -79,17 +76,14 @@ 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, - no_sign_notify: build_args.no_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, @@ -169,6 +163,51 @@ 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)?; + sign::find_changes_file( + &identity.name, + &identity.version.to_string(), + args.output_dir.as_deref(), + )? + } + }; + + 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 diff --git a/packages/debmagic/src/output.rs b/packages/debmagic/src/output.rs index d1ec04f2..b08b3198 100644 --- a/packages/debmagic/src/output.rs +++ b/packages/debmagic/src/output.rs @@ -6,6 +6,7 @@ //! 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; @@ -57,6 +58,41 @@ impl Style { } } +/// 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) diff --git a/packages/debmagic/src/sign.rs b/packages/debmagic/src/sign.rs new file mode 100644 index 00000000..ccf04156 --- /dev/null +++ b/packages/debmagic/src/sign.rs @@ -0,0 +1,587 @@ +//! 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: Option<&Path>, +) -> anyhow::Result { + let output_dir = output_dir.unwrap_or_else(|| Path::new("..")); + 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/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); -} From 50d905c9f3a50a2b7a39298cf05325672ea7118e Mon Sep 17 00:00:00 2001 From: Jonas Jelten Date: Wed, 9 Sep 2026 16:05:16 +0200 Subject: [PATCH 10/11] feat(build): default output dir build/, configurable via output_dir Debian build artifacts are placed in build/ inside the package root. Configuration: `output_dir` in debmagic.toml (path relative to package root). --- docs/usage/build.md | 2 +- docs/usage/config.md | 1 + packages/debmagic/src/build_intent.rs | 12 +++++++++--- packages/debmagic/src/config.rs | 4 ++++ packages/debmagic/src/main.rs | 10 +++++++++- packages/debmagic/src/sign.rs | 3 +-- 6 files changed, 25 insertions(+), 7 deletions(-) diff --git a/docs/usage/build.md b/docs/usage/build.md index 4245ed42..bc0f7767 100644 --- a/docs/usage/build.md +++ b/docs/usage/build.md @@ -174,7 +174,7 @@ Defaults can be set in [`debmagic.toml`](config.md). debmagic sign ../mypkg_1.0_amd64.changes ``` -Without a file argument, it locates the `.changes` via `debian/changelog` and `--output`/`-o` (default `..`), preferring the source-only `_source.changes` when several match. +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 diff --git a/docs/usage/config.md b/docs/usage/config.md index 9627e0f6..c4c0c68d 100644 --- a/docs/usage/config.md +++ b/docs/usage/config.md @@ -28,6 +28,7 @@ All keys are optional. | `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. | diff --git a/packages/debmagic/src/build_intent.rs b/packages/debmagic/src/build_intent.rs index 92cb8a8b..5b2d9d86 100644 --- a/packages/debmagic/src/build_intent.rs +++ b/packages/debmagic/src/build_intent.rs @@ -49,11 +49,16 @@ pub struct BuildIntent { 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 = 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; } @@ -245,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(()) } diff --git a/packages/debmagic/src/config.rs b/packages/debmagic/src/config.rs index 704bb4d3..73d538b0 100644 --- a/packages/debmagic/src/config.rs +++ b/packages/debmagic/src/config.rs @@ -148,6 +148,9 @@ pub fn resolve_set_target( 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, @@ -191,6 +194,7 @@ 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, diff --git a/packages/debmagic/src/main.rs b/packages/debmagic/src/main.rs index 9bab51cd..053e0691 100644 --- a/packages/debmagic/src/main.rs +++ b/packages/debmagic/src/main.rs @@ -194,10 +194,18 @@ fn run() -> anyhow::Result { } 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(), - args.output_dir.as_deref(), + &output_dir, )? } }; diff --git a/packages/debmagic/src/sign.rs b/packages/debmagic/src/sign.rs index ccf04156..655e1fbf 100644 --- a/packages/debmagic/src/sign.rs +++ b/packages/debmagic/src/sign.rs @@ -441,9 +441,8 @@ pub fn sign_file( pub fn find_changes_file( package: &str, version: &str, - output_dir: Option<&Path>, + output_dir: &Path, ) -> anyhow::Result { - let output_dir = output_dir.unwrap_or_else(|| Path::new("..")); let sversion = version .split_once(':') .map(|(_, rest)| rest) From 320563ff1c1ed196f831845eaeea39a50021ccc9 Mon Sep 17 00:00:00 2001 From: Jonas Jelten Date: Wed, 9 Sep 2026 19:36:33 +0200 Subject: [PATCH 11/11] feat: add justfile --- justfile | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 justfile 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 }}