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:
+184
-2
@@ -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());
|
||||
}
|
||||
|
||||
#[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)]
|
||||
pub async fn build(
|
||||
package: &str,
|
||||
@@ -107,6 +269,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 +650,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 +702,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")
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user