From 7b47a3b32c01a66d31025996d67336645ca341be Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Fri, 2 Oct 2026 12:23:02 +0200 Subject: [PATCH 1/3] deb: build the rules as an unprivileged user in pkh chroots Launchpad's buildds and sbuild compile as a non-root user, and packages rely on it: coreutils' configure refuses to run as root (the one remaining standard-tier e2e failure). Run the rules - and the fakeroot-wrapped binary step - as an on-demand 'pkh-build' user whenever the build runs in a context pkh controls (the ephemeral chroot); ssh and schroot contexts keep their own user. setpriv drops to the user without touching the environment, so the dpkg architecture variables and DEB_BUILD_OPTIONS survive the transition. The staged tree, the scratch HOME and the shared ccache directory are handed to the user on every build; everything before the rules (dependency installation, quilt) legitimately stays root. Minimal mmdebstrap chroots carry no passwd package, so the user is appended to /etc/passwd and /etc/group directly when useradd is missing, as sbuild's build-user setup does. --- src/deb/local.rs | 188 ++++++++++++++++++++++++++++++++++++++++++++++- src/deb/mod.rs | 3 + 2 files changed, 189 insertions(+), 2 deletions(-) diff --git a/src/deb/local.rs b/src/deb/local.rs index 233c6e5..19c369b 100644 --- a/src/deb/local.rs +++ b/src/deb/local.rs @@ -88,6 +88,170 @@ pub(crate) fn apply_ccache_env(env: &mut HashMap, enabled: bool, env.insert("CCACHE_NOHASHDIR".to_string(), "true".to_string()); } +#[allow(clippy::too_many_arguments)] +/// The unprivileged user the rules run as inside a pkh-managed chroot: +/// Launchpad's buildds and sbuild compile as a non-root user, and packages +/// rely on it (coreutils' configure refuses to run as root). The user is +/// created on demand in the build context; contexts pkh does not control +/// (ssh, schroot) keep running the rules as their own user. +pub const BUILD_USER: &str = "pkh-build"; + +/// Ensure [`BUILD_USER`] exists in the build context and owns everything +/// the rules must write: the staged tree (whose parent is where +/// dh_builddeb drops the .debs), the scratch HOME and the shared ccache +/// directory. Returns the (uid, gid) for the setpriv invocation. Ownership +/// is re-applied on every build: a resumed build stages the tree fresh +/// from the host, where everything is root-owned again. +fn ensure_build_user( + ctx: &Arc, + workroot: &Path, + ccache_dir: Option<&str>, +) -> Result<(String, String), Box> { + let probe = ctx + .command("id") + .arg("-u") + .arg(BUILD_USER) + .output() + .map_err(|e| format!("cannot look up {BUILD_USER}: {e}"))?; + let fresh = !probe.status.success(); + if fresh { + create_build_user(ctx)?; + } + + let mut ids = (String::new(), String::new()); + for (flag, slot) in [("-u", 0), ("-g", 1)] { + let output = ctx + .command("id") + .arg(flag) + .arg(BUILD_USER) + .output() + .map_err(|e| format!("cannot look up {BUILD_USER}: {e}"))?; + if !output.status.success() { + return Err(format!("cannot resolve the ids of '{BUILD_USER}'").into()); + } + let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if slot == 0 { + ids.0 = value; + } else { + ids.1 = value; + } + } + + // The shared ccache directory moves once, when the user is fresh: it + // was root-owned by earlier root builds, and root keeps writing + // through the new ownership anyway. + if fresh && let Some(dir) = ccache_dir { + let _ = ctx + .command("chown") + .arg("-R") + .arg(format!("{}:{}", ids.0, ids.1)) + .arg(dir) + .status(); + } + for dir in [workroot, Path::new("/tmp/pkh-home")] { + let status = ctx + .command("chown") + .arg("-R") + .arg(format!("{}:{}", ids.0, ids.1)) + .arg(dir) + .status() + .map_err(|e| format!("cannot hand {} to {BUILD_USER}: {e}", dir.display()))?; + if !status.success() { + return Err(format!("cannot hand '{}' to '{BUILD_USER}'", dir.display()).into()); + } + } + Ok(ids) +} + +/// Create [`BUILD_USER`] in the build context. Minimal mmdebstrap chroots +/// carry no passwd package (no useradd), so the passwd and group entries +/// are appended directly when needed, as sbuild's build-user setup does. +fn create_build_user(ctx: &Arc) -> Result<(), Box> { + let have_useradd = ctx + .command("sh") + .arg("-c") + .arg("command -v useradd") + .output() + .is_ok_and(|out| out.status.success() && !out.stdout.is_empty()); + if have_useradd { + let status = ctx + .command("useradd") + .arg("--home-dir") + .arg("/tmp/pkh-home") + .arg("--shell") + .arg("/usr/sbin/nologin") + .arg("--user-group") + .arg(BUILD_USER) + .status() + .map_err(|e| format!("cannot run useradd: {e}"))?; + if !status.success() { + return Err(format!("useradd failed for '{BUILD_USER}'").into()); + } + return Ok(()); + } + + // The first free id from 64500 on: below nobody (65534), above the + // range chroots hand to installed users. + let passwd = ctx.read_file(Path::new("/etc/passwd"))?; + let group = ctx.read_file(Path::new("/etc/group"))?; + let used = |text: &str, id: u32| { + text.lines().any(|line| { + line.split(':') + .nth(2) + .is_some_and(|field| field == id.to_string()) + }) + }; + let uid = (64500..=65500) + .find(|id| !used(&passwd, *id) && !used(&group, *id)) + .ok_or("no free uid for the build user")?; + for (file, entry) in [ + ("/etc/group", format!("{BUILD_USER}:x:{uid}:")), + ( + "/etc/passwd", + format!("{BUILD_USER}:x:{uid}:{uid}:/tmp/pkh-home:/usr/sbin/nologin"), + ), + ] { + let status = ctx + .command("sh") + .arg("-c") + .arg(format!("echo {} >> {file}", shlex_quote(&entry))) + .status() + .map_err(|e| format!("cannot append to {file}: {e}"))?; + if !status.success() { + return Err(format!("cannot append the {BUILD_USER} entry to {file}").into()); + } + } + Ok(()) +} + +/// Single-quote a string for /bin/sh. +fn shlex_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', r"'\''")) +} + +/// The rules command for the given user: `setpriv` drops to the build user +/// without touching the environment (a login shell would reset it), so the +/// dpkg architecture variables and DEB_BUILD_OPTIONS survive the +/// transition; without a user the rules run as the context's own. +fn rules_command<'a>( + ctx: &'a Arc, + user: Option<&(String, String)>, + program: &str, +) -> ContextCommand<'a> { + match user { + Some((uid, gid)) => { + let mut cmd = ctx.command("setpriv"); + cmd.arg(format!("--reuid={uid}")) + .arg(format!("--regid={gid}")) + .arg("--init-groups") + .arg("--") + .arg(program); + cmd + } + None => ctx.command(program), + } +} + #[allow(clippy::too_many_arguments)] pub async fn build( package: &str, @@ -107,6 +271,9 @@ pub async fn build( session: Option<&session::Session>, build_resume: bool, ccache: bool, + // Some(&BUILD_USER) hands the rules to an unprivileged build user + // (rootless builds); none runs them as the build context's own user. + build_user: Option<&str>, // Accumulator of the measured dependency-installation and build // durations; the caller records it on the build record. timings: &mut session::BuildTimings, @@ -485,12 +652,29 @@ pub async fn build( .unwrap_or_else(|| "binary-targets".to_string()); env.insert("DEB_RULES_REQUIRES_ROOT".to_string(), rules_requires_root); + // Rootless builds: the rules (and the fakeroot-wrapped binary step) + // run as an unprivileged user owning the staged tree. Everything + // before this point - dependency installation, quilt patch + // application, staging cleanup - legitimately needs the context's + // root and stays with it. + let rule_ids = match build_user { + Some(_) => { + let workroot = Path::new(package_dir_str) + .parent() + .ok_or("cannot find the staged tree's parent directory")? + .to_path_buf(); + let ccache_dir = env.get("CCACHE_DIR").cloned(); + Some(ensure_build_user(&ctx, &workroot, ccache_dir.as_deref())?) + } + None => None, + }; + // Run the build step log::debug!("Building (debian/rules build) package..."); enter_phase(view, Phase::Building); let started = Instant::now(); let status = cap( - ctx.command("debian/rules") + rules_command(&ctx, rule_ids.as_ref(), "debian/rules") .current_dir(package_dir_str) .envs(env.clone()) .arg("build"), @@ -520,7 +704,7 @@ pub async fn build( enter_phase(view, Phase::ProducingBinaries); let started = Instant::now(); let status = cap( - ctx.command("fakeroot") + rules_command(&ctx, rule_ids.as_ref(), "fakeroot") .current_dir(package_dir_str) .envs(env.clone()) .arg("debian/rules") diff --git a/src/deb/mod.rs b/src/deb/mod.rs index e34f1dc..00119fe 100644 --- a/src/deb/mod.rs +++ b/src/deb/mod.rs @@ -607,6 +607,9 @@ async fn build_binary_package_impl( session.as_ref(), build_resume, ccache, + // Rootless builds: only a context pkh controls (the + // ephemeral chroot) can carry the on-demand build user. + guard.is_some().then_some(local::BUILD_USER), &mut timings, ) .await? From 672d17136b039a26ae0c47c10105371d9ad4c87a Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Fri, 2 Oct 2026 12:26:46 +0200 Subject: [PATCH 2/3] deb: run the user probes through sh with stderr dropped A missing build user is the expected state in every fresh ephemeral chroot: without the redirect the id failures leak into the build log as errors on every run. --- src/deb/local.rs | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/deb/local.rs b/src/deb/local.rs index 19c369b..7c0b116 100644 --- a/src/deb/local.rs +++ b/src/deb/local.rs @@ -107,12 +107,15 @@ fn ensure_build_user( workroot: &Path, ccache_dir: Option<&str>, ) -> Result<(String, String), Box> { - let probe = ctx - .command("id") - .arg("-u") - .arg(BUILD_USER) - .output() - .map_err(|e| format!("cannot look up {BUILD_USER}: {e}"))?; + // Through sh with the stderr dropped: a missing user is the expected + // state in a fresh chroot, not a message the build log needs. + let id_of = |ctx: &Arc, what: &str| { + ctx.command("sh") + .arg("-c") + .arg(format!("id {what} {BUILD_USER} 2>/dev/null")) + .output() + }; + let probe = id_of(ctx, "-u").map_err(|e| format!("cannot look up {BUILD_USER}: {e}"))?; let fresh = !probe.status.success(); if fresh { create_build_user(ctx)?; @@ -120,12 +123,7 @@ fn ensure_build_user( let mut ids = (String::new(), String::new()); for (flag, slot) in [("-u", 0), ("-g", 1)] { - let output = ctx - .command("id") - .arg(flag) - .arg(BUILD_USER) - .output() - .map_err(|e| format!("cannot look up {BUILD_USER}: {e}"))?; + let output = id_of(ctx, flag).map_err(|e| format!("cannot look up {BUILD_USER}: {e}"))?; if !output.status.success() { return Err(format!("cannot resolve the ids of '{BUILD_USER}'").into()); } From abc89c45447bd167e04cdf6fd1d9d8804ed43916 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Fri, 2 Oct 2026 12:43:32 +0200 Subject: [PATCH 3/3] test: drop the coreutils xfail, rootless builds clear it The e2e canary against the rootless build records coreutils as xpass: with the rules running as the build user, its configure no longer refuses the build. --- tests/e2e/catalog.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/e2e/catalog.yml b/tests/e2e/catalog.yml index c62c335..5f19007 100644 --- a/tests/e2e/catalog.yml +++ b/tests/e2e/catalog.yml @@ -125,7 +125,6 @@ packages: category: core dist: ubuntu tags: [c, autotools] - xfail: pkh deb builds as root in the chroot and coreutils configure refuses root (needs a non-root build user, as Launchpad buildds use) - name: util-linux category: core