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());
|
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")
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user