Merge branch 'rootless-builds'

deb: build the rules as an unprivileged user in pkh chroots, the
same way Launchpad's buildds and sbuild do - setpriv drops to an
on-demand pkh-build user owning the staged tree, the scratch HOME
and the ccache; dependency installation and quilt stay root.
Validated by the e2e canary: coreutils (which refuses a root
configure) now passes.

* rootless-builds:
  test: drop the coreutils xfail, rootless builds clear it
  deb: run the user probes through sh with stderr dropped
  deb: build the rules as an unprivileged user in pkh chroots
This commit is contained in:
2026-10-02 12:58:44 +02:00
3 changed files with 187 additions and 3 deletions
+184 -2
View File
@@ -88,6 +88,168 @@ pub(crate) fn apply_ccache_env(env: &mut HashMap<String, String>, enabled: bool,
env.insert("CCACHE_NOHASHDIR".to_string(), "true".to_string()); 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<Context>,
workroot: &Path,
ccache_dir: Option<&str>,
) -> Result<(String, String), Box<dyn Error>> {
// 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<Context>, 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)?;
}
let mut ids = (String::new(), String::new());
for (flag, slot) in [("-u", 0), ("-g", 1)] {
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());
}
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<Context>) -> Result<(), Box<dyn Error>> {
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<Context>,
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)] #[allow(clippy::too_many_arguments)]
pub async fn build( pub async fn build(
package: &str, package: &str,
@@ -107,6 +269,9 @@ pub async fn build(
session: Option<&session::Session>, session: Option<&session::Session>,
build_resume: bool, build_resume: bool,
ccache: 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 // Accumulator of the measured dependency-installation and build
// durations; the caller records it on the build record. // durations; the caller records it on the build record.
timings: &mut session::BuildTimings, timings: &mut session::BuildTimings,
@@ -485,12 +650,29 @@ pub async fn build(
.unwrap_or_else(|| "binary-targets".to_string()); .unwrap_or_else(|| "binary-targets".to_string());
env.insert("DEB_RULES_REQUIRES_ROOT".to_string(), rules_requires_root); 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 // Run the build step
log::debug!("Building (debian/rules build) package..."); log::debug!("Building (debian/rules build) package...");
enter_phase(view, Phase::Building); enter_phase(view, Phase::Building);
let started = Instant::now(); let started = Instant::now();
let status = cap( let status = cap(
ctx.command("debian/rules") rules_command(&ctx, rule_ids.as_ref(), "debian/rules")
.current_dir(package_dir_str) .current_dir(package_dir_str)
.envs(env.clone()) .envs(env.clone())
.arg("build"), .arg("build"),
@@ -520,7 +702,7 @@ pub async fn build(
enter_phase(view, Phase::ProducingBinaries); enter_phase(view, Phase::ProducingBinaries);
let started = Instant::now(); let started = Instant::now();
let status = cap( let status = cap(
ctx.command("fakeroot") rules_command(&ctx, rule_ids.as_ref(), "fakeroot")
.current_dir(package_dir_str) .current_dir(package_dir_str)
.envs(env.clone()) .envs(env.clone())
.arg("debian/rules") .arg("debian/rules")
+3
View File
@@ -607,6 +607,9 @@ async fn build_binary_package_impl(
session.as_ref(), session.as_ref(),
build_resume, build_resume,
ccache, 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, &mut timings,
) )
.await? .await?
-1
View File
@@ -125,7 +125,6 @@ packages:
category: core category: core
dist: ubuntu dist: ubuntu
tags: [c, autotools] 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 - name: util-linux
category: core category: core