From 2729f9d9e7bc8b13b16c11beb3dd26aa55d70970 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Sat, 3 Oct 2026 13:00:26 +0200 Subject: [PATCH] context,deb: run the rules as a nested-namespace build identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rootless builds handed the staged tree to the build user with a recursive chown; through the overlay staging every lower-layer file the chown touched copy-ups — the full data movement of the tree, 19% of a kernel build's wall clock. The rules steps are now flagged and the unshare driver enters the chroot by pivot_root instead of chroot(2), then creates a nested user namespace mapping the context's root onto a fixed build id: the chroot's files are the invoking user's host-side and the context maps that user to uid 0, so the staged tree is owned by the build identity by construction — no chown, no data movement. The kernel refuses user namespace creation to chrooted processes, hence the pivot, and the bind is recursive and taken inside the namespace: a plain bind of the chroot's host-made submounts fails EINVAL on locked children, while the rbind carries them across the pivot, and a fresh /proc is mounted before the old root is shed (umount resolves through /proc/mounts). fakeroot's chown wrappers attempt the real chown and swallow only EPERM; as the mapped identity, root targets are unmapped and fail EINVAL, so mapped builds export FAKEROOTDONTTRYCHOWN — fakeroot's own knob — and the fake ownership is still recorded. Files the build writes come out as the invoking user host-side, which also fixes the silently missing .buildinfo/.changes of rootless builds: the host-side rewrite of debian/files hit a subuid-owned file with EACCES. The wrapper is probed per build; when nested namespaces are unavailable (old util-linux, userns disabled), the previous setpriv plus selective parallel chown handover runs unchanged. A setpriv-era staged tree is handed to the invoking user once, host-side and metadata-only, before the overlay mounts it — afterwards overlayfs serves stale owners from its attribute cache. --- src/context/api.rs | 20 +++ src/context/mod.rs | 1 + src/context/unshare.rs | 202 ++++++++++++++++++++++-- src/deb/local.rs | 337 ++++++++++++++++++++++++++++++++++++----- src/deb/mod.rs | 16 +- src/deb/session.rs | 8 +- 6 files changed, 526 insertions(+), 58 deletions(-) diff --git a/src/context/api.rs b/src/context/api.rs index d6ae00f..fd21ec4 100644 --- a/src/context/api.rs +++ b/src/context/api.rs @@ -41,6 +41,26 @@ pub struct OverlayStaging { pub overlay: bool, } +/// Marker environment value (set to `1`) flagging a command as a rules +/// step of a rootless build: unshare-based contexts then enter the chroot +/// by pivoting and drop into a nested user namespace mapping the chroot's +/// root ownership onto the build identity ([`MAPPED_BUILD_ID`]), instead +/// of chrooting into the namespace's root — so the staged tree is already +/// owned and no chown is needed. See `crate::deb::local`, which sets it. +pub(crate) const MAPPED_IDENTITY_ENV: &str = "PKH_MAPPED_IDENTITY"; + +/// The build identity inside the nested namespace of a rootless build: +/// the namespace maps the chroot's root onto this id, and the chroot's +/// passwd/group entries carry it, so `getpwuid` resolves the identity for +/// the build tools. It must stay outside the context namespace's own +/// mapping (which covers 0..=65535), so the two identities never alias. +pub(crate) const MAPPED_BUILD_ID: u32 = 65536; + +const _: () = assert!( + MAPPED_BUILD_ID > 65535, + "the mapped identity must not alias a context-namespace uid" +); + /// A ContextDriver is the interface for the logic happening inside a context pub trait ContextDriver { fn ensure_available(&self, src: &Path, dest_root: &str) -> io::Result; diff --git a/src/context/mod.rs b/src/context/mod.rs index 06ad391..e08dcf1 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -8,6 +8,7 @@ mod ssh; mod unshare; pub use api::{Context, ContextCommand, ContextConfig, LineSink, OverlayStaging, Stream}; +pub(crate) use api::{MAPPED_BUILD_ID, MAPPED_IDENTITY_ENV}; // The driver trait is implementation detail of the context API; it is only // needed crate-internally (test-run capture wrapper), so keep it out of the // public surface (and its documentation requirement). diff --git a/src/context/unshare.rs b/src/context/unshare.rs index 6ef7b92..93d5cb0 100644 --- a/src/context/unshare.rs +++ b/src/context/unshare.rs @@ -1,4 +1,6 @@ -use super::api::{Context, ContextCommand, ContextDriver, LineSink}; +use super::api::{ + Context, ContextCommand, ContextDriver, LineSink, MAPPED_BUILD_ID, MAPPED_IDENTITY_ENV, +}; use super::shell::shell_quote; use log::debug; use std::fs; @@ -534,6 +536,9 @@ impl UnshareDriver { env: &[(String, String)], cwd: Option<&str>, ) -> ContextCommand<'_> { + let mapped = env + .iter() + .any(|(key, value)| key == MAPPED_IDENTITY_ENV && value == "1"); let mut cmd = self.parent().command("unshare"); cmd.envs(env.iter().cloned()); @@ -548,25 +553,112 @@ impl UnshareDriver { .arg("--map-auto") .arg("-r") .arg("--mount") - .arg("--fork") - .arg("-R") - .arg(&self.path); + .arg("--fork"); - if let Some(dir) = cwd { - cmd.arg("-w").arg(dir); + if mapped { + // The rules steps of a rootless build (see the marker in + // `crate::deb::local`): no chroot(2) — the wrapper pivots into + // the chroot and only then creates the nested build-identity + // namespace, because a user namespace cannot be created from a + // chrooted process (the kernel refuses with EPERM) and + // chroot(2) cannot run inside one. The cwd is taken inside the + // script, after the pivot. + cmd.arg("--").arg("bash").arg("-c").arg(build_pivot_script( + &self.path, + cwd, + program, + args, + MAPPED_BUILD_ID, + )); + } else { + cmd.arg("-R").arg(&self.path); + + if let Some(dir) = cwd { + cmd.arg("-w").arg(dir); + } + + // Build the bash command: set up /dev/pts and run the program + // /proc should already be bind-mounted from the host before entering the namespace + cmd.arg("--") + .arg("bash") + .arg("-c") + .arg(build_namespace_script(program, args)); } - // Build the bash command: set up /dev/pts and run the program - // /proc should already be bind-mounted from the host before entering the namespace - cmd.arg("--") - .arg("bash") - .arg("-c") - .arg(build_namespace_script(program, args)); - cmd } } +/// Build the rules-mode wrapper script: pivot into the chroot, bring up +/// `/proc` and `/dev/pts` inside it, then drop into the nested +/// build-identity namespace (mapping the namespace's root onto +/// `build_id`) and exec `program`. +/// +/// The pivot replaces chroot(2): it keeps the process root at the root of +/// the mount namespace, which is what makes the nested user namespace +/// creatable at all. The bind is recursive (`--rbind`) and taken fresh +/// inside the namespace: a plain bind of a tree carrying mounts made +/// outside the namespace (the chroot's `/proc`, apt-cache and ccache +/// binds) is denied with EINVAL — its locked children would have to move +/// with it — while a recursive bind carries them, which is exactly what +/// the rules need to keep working after the pivot. A bind made outside +/// the namespace would also carry MNT_LOCKED and fail with EINVAL. The +/// fresh `/proc` must be mounted before the old root is unmounted: umount +/// resolves its target through /proc/mounts, which lives on the old root. +/// +/// Everything before the pivot runs as the namespace's root with the +/// host's binaries and paths; everything after it resolves inside the +/// chroot with the chroot's own binaries (plain `unshare`, an Essential +/// tool). The program and every argument are POSIX-shell-quoted (see +/// [`shell_quote`]). +fn build_pivot_script( + chroot: &str, + cwd: Option<&str>, + program: &str, + args: &[String], + build_id: u32, +) -> String { + let chroot = shell_quote(chroot); + let mut script = format!( + "set -e; \ + mount --make-rprivate /; \ + mount --rbind {chroot} {chroot}; \ + cd {chroot}; \ + putold=$(mktemp -d .pkh-pivot.XXXXXX); \ + pivot_root . \"$putold\"; \ + mount -t proc proc /proc; \ + umount -l \"$putold\"; \ + rmdir \"$putold\" 2>/dev/null || true; \ + mkdir -p /dev/pts; \ + mount -t devpts devpts /dev/pts 2>/dev/null || true; \ + touch /dev/ptmx; \ + mount --bind /dev/pts/ptmx /dev/ptmx 2>/dev/null || true; " + ); + + // The nested namespace's command line, itself shell-quoted once as the + // `sh -c` argument: the cwd is taken inside the new root, then the + // program runs exec'd, as the build identity. Note that the identity + // is the only mapped id: uid/gid 0 stay unmapped, and the build env + // carries FAKEROOTDONTTRYCHOWN for the binary step — fakeroot's chown + // wrappers attempt the real chown and swallow only EPERM, so an + // unmapped root target would fail them with EINVAL (dh_fixperms + // chowns to root:root). + let mut inner = String::new(); + if let Some(dir) = cwd { + inner.push_str(&format!("cd {}; ", shell_quote(dir))); + } + inner.push_str(&format!("exec {}", shell_quote(program))); + for arg in args { + inner.push(' '); + inner.push_str(&shell_quote(arg)); + } + script.push_str(&format!( + "exec unshare --user --fork --map-user={build_id} --map-group={build_id} -- sh -c {}", + shell_quote(&inner) + )); + script +} + /// Build the shell script executed by `bash -c` inside the user namespace: /// bring up `/dev/pts`, then run `program` with `args`. /// @@ -590,7 +682,7 @@ fn build_namespace_script(program: &str, args: &[String]) -> String { #[cfg(test)] mod tests { - use super::build_namespace_script; + use super::{MAPPED_BUILD_ID, build_namespace_script, build_pivot_script, shell_quote}; fn tail_after_devpts_setup(script: &str) -> &str { script @@ -639,4 +731,86 @@ mod tests { let script = build_namespace_script("prog", &[String::new(), "x".to_string()]); assert_eq!(tail_after_devpts_setup(&script), "'prog' '' 'x'"); } + + /// The pivot script pivots before creating the nested namespace (a + /// user namespace cannot be created from a chrooted process, so no + /// chroot(2) may precede it), mounts a fresh /proc before shedding the + /// old root (umount resolves through /proc/mounts), and execs the + /// program as the mapped build identity. + #[test] + fn pivot_script_pivots_then_maps_the_identity() { + let script = build_pivot_script( + "/var/tmp/pkh/chroot", + Some("/tmp/pkh-build/tree"), + "debian/rules", + &["build".to_string()], + MAPPED_BUILD_ID, + ); + // pivot strictly before the nested unshare + let pivot = script.find("pivot_root").expect("pivot step"); + let nested = script.find("unshare --user").expect("nested step"); + assert!(pivot < nested, "{script}"); + assert!(script.contains("mount -t proc proc /proc;"), "{script}"); + assert!(script.contains("umount -l"), "{script}"); + assert!( + script.contains(&format!("--map-user={MAPPED_BUILD_ID}")), + "{script}" + ); + + // the inner command line is the single shell-quoted `sh -c` + // argument: cwd inside the new root, then the exec'd program + let inner = format!( + "cd {}; exec {} {}", + shell_quote("/tmp/pkh-build/tree"), + shell_quote("debian/rules"), + shell_quote("build") + ); + assert!( + script.ends_with(&format!("sh -c {}", shell_quote(&inner))), + "{script}" + ); + // and the whole script must parse as shell + std::process::Command::new("bash") + .arg("-n") + .arg("-c") + .arg(&script) + .status() + .expect("run bash") + .success() + .then_some(()) + .expect("the pivot script must parse: {script}"); + } + + /// Paths and arguments with shell metacharacters survive the two + /// quoting levels (outer script, inner `sh -c`). + #[test] + fn pivot_script_quotes_the_chroot_and_the_command() { + let script = build_pivot_script( + "/tmp/some'chroot", + None, + "fakeroot", + &["debian/rules".to_string(), "b'n".to_string()], + MAPPED_BUILD_ID, + ); + assert!( + script.contains(&format!("cd {}", shell_quote("/tmp/some'chroot"))), + "{script}" + ); + let inner = format!( + "exec {} {} {}", + shell_quote("fakeroot"), + shell_quote("debian/rules"), + shell_quote("b'n") + ); + assert!(script.contains(&shell_quote(&inner)), "{script}"); + std::process::Command::new("bash") + .arg("-n") + .arg("-c") + .arg(&script) + .status() + .expect("run bash") + .success() + .then_some(()) + .expect("the pivot script must parse: {script}"); + } } diff --git a/src/deb/local.rs b/src/deb/local.rs index 438338d..f81c3b1 100644 --- a/src/deb/local.rs +++ b/src/deb/local.rs @@ -7,6 +7,7 @@ use crate::report::BuildView; use log::warn; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::error::Error; +use std::fs; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -96,17 +97,105 @@ pub(crate) fn apply_ccache_env(env: &mut HashMap, enabled: bool, /// (ssh, schroot) keep running the rules as their own user. pub const BUILD_USER: &str = "pkh-build"; +/// How the rules steps run as an unprivileged build identity. +enum RulesIdentity { + /// A nested user namespace maps the chroot's root ownership onto + /// [`crate::context::MAPPED_BUILD_ID`]: the whole staged tree — the + /// chroot's files are the invoking user's host-side, and the context + /// namespace maps that user to uid 0 — shows as already owned by the + /// build identity, so nothing is chowned and no data moves. Files the + /// build writes come out as the invoking user's own host-side. + MappedNamespace, + /// Fallback when the nested namespace is unavailable: `setpriv` drops + /// to an ordinary chroot user, and the tree is handed over with a + /// selective parallel chown. Carries the (uid, gid). + Setpriv(String, String), +} + +/// Whether the build context can establish the nested build-identity +/// namespace: kernel user namespaces reachable, a new enough util-linux +/// (`--map-user`), the pivot wrapper working, and the mapping actually +/// taken. The probe runs the real wrapper over a `sh` test, so a failure +/// here means the fallback would fail the same way. +fn probe_mapped_namespace(ctx: &Arc) -> Result<(), String> { + let mut cmd = ctx.command("sh"); + cmd.env(crate::context::MAPPED_IDENTITY_ENV, "1") + .arg("-c") + .arg(format!( + "test \"$(id -u)\" = {} && test \"$(id -g)\" = {}", + crate::context::MAPPED_BUILD_ID, + crate::context::MAPPED_BUILD_ID + )); + match cmd.output() { + Ok(out) if out.status.success() => Ok(()), + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + Err(if stderr.trim().is_empty() { + format!("exit status {}", out.status) + } else { + stderr.trim().to_string() + }) + } + Err(e) => Err(e.to_string()), + } +} + /// 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. +/// directory. Returns how the rules drop privileges. +/// +/// The mapped namespace is the ownership by construction: the chroot's +/// files are the invoking user's host-side and the context maps that user +/// to uid 0, so a namespace nested inside the context's, mapping uid 0 +/// onto [`crate::context::MAPPED_BUILD_ID`], makes the build identity the +/// owner of everything without moving a byte. The fallback creates an +/// ordinary chroot user and hands the tree over with a chown — through +/// the overlay staging every chowned lower-layer file copy-ups, which on +/// a kernel tree costs minutes (see [`hand_over_script`]). fn ensure_build_user( ctx: &Arc, workroot: &Path, + session: Option<&session::Session>, + build_resume: bool, ccache_dir: Option<&str>, -) -> Result<(String, String), Box> { + ccache_host_dir: Option<&Path>, +) -> Result> { + let mut mapped = match probe_mapped_namespace(ctx) { + Ok(()) => true, + Err(reason) => { + log::info!( + "Nested user namespaces are unavailable in the build \ + context ({reason}); falling back to the chown handover" + ); + false + } + }; + // A setpriv-era upperdir must already have been handed over by the + // pre-staging migration (`migrate_upper_ownership`, called before the + // overlay mounts): chowning it now, behind the live mount, would leave + // stale-owned entries behind the overlay's attribute cache. If it is + // still foreign-owned the migration failed — take the chown fallback, + // whose in-chroot chown needs no host escalation. + if mapped + && let Some(upper) = resumed_upper_dir(session, build_resume) + && has_foreign_owner(&upper) + { + log::warn!( + "the staged tree carries setpriv-era ownership the pre-staging \ + migration could not hand over; falling back to the chown \ + handover" + ); + mapped = false; + } + if mapped { + if let Some(dir) = ccache_host_dir.filter(|dir| dir.is_dir()) { + migrate_ccache_ownership(dir); + } + write_build_user_entries(ctx, crate::context::MAPPED_BUILD_ID)?; + return Ok(RulesIdentity::MappedNamespace); + } + // 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| { @@ -145,7 +234,162 @@ fn ensure_build_user( if let Some(dir) = ccache_dir { let _ = hand_over(ctx, Path::new(dir), &ids); } - Ok(ids) + Ok(RulesIdentity::Setpriv(ids.0, ids.1)) +} + +/// Append the passwd and group entries naming [`BUILD_USER`] at `id` — +/// the identity of the mapped namespace (and of the setpriv fallback when +/// the chroot carries no useradd). Minimal mmdebstrap chroots carry no +/// passwd package, so the entries are appended directly, as sbuild's +/// build-user setup does. +fn write_build_user_entries(ctx: &Arc, id: u32) -> Result<(), Box> { + // Through sh with the stderr dropped: a missing entry is the expected + // state in a fresh chroot, not a message the build log needs. + let known = ctx + .command("sh") + .arg("-c") + .arg(format!("id -u {BUILD_USER} 2>/dev/null")) + .output() + .is_ok_and(|out| out.status.success()); + if known { + return Ok(()); + } + for (file, entry) in [ + ("/etc/group", format!("{BUILD_USER}:x:{id}:")), + ( + "/etc/passwd", + format!("{BUILD_USER}:x:{id}:{id}:/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(()) +} + +/// The host-side path of the staged tree's persistent overlay upperdir, +/// when this build resumes over one carrying setpriv-era files. Any other +/// staging mounts a fresh (empty) upper or a plain copy, and the merge +/// shows the host tree's own (invoking-user) files. +fn resumed_upper_dir(session: Option<&session::Session>, build_resume: bool) -> Option { + build_resume + .then_some(session) + .flatten() + .map(|s| s.upper_dir()) + .filter(|dir| dir.is_dir()) +} + +/// Whether any entry under `root` is not owned by the invoking user +/// host-side — the mark of a setpriv-era build (the old identity was a +/// chroot-local uid, host-side a subuid). The metadata is hot when the +/// resume sync just walked the same tree. +fn has_foreign_owner(root: &Path) -> bool { + use std::os::unix::fs::MetadataExt; + let euid = unsafe { libc::geteuid() }; + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let Ok(entries) = fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let Ok(meta) = fs::symlink_metadata(entry.path()) else { + continue; + }; + if meta.uid() != euid { + return true; + } + if meta.is_dir() { + stack.push(entry.path()); + } + } + } + false +} + +/// Hand a setpriv-era staged tree to the invoking user (one recursive, +/// host-side, metadata-only chown): the ownership the mapped-namespace +/// build identity writes with. +/// +/// This MUST run before the overlay staging mounts the upperdir. Overlayfs +/// snapshots the owner into its overlay inode at first lookup, and its +/// permission checks consult that snapshot — a chown applied behind a live +/// mount leaves every previously-looked-up entry stale-owned, and the +/// rules (running as the mapped identity) fail with EACCES on files the +/// host side swears they own. On the unmounted upperdir the same chown is +/// a plain metadata pass, never a data copy. +pub(crate) fn migrate_upper_ownership(session: &session::Session) -> Result<(), Box> { + let Some(upper) = session.upper_dir().is_dir().then_some(session.upper_dir()) else { + return Ok(()); + }; + if !has_foreign_owner(&upper) { + return Ok(()); + } + let is_root = crate::utils::root::is_root().unwrap_or(false); + log::info!( + "Handing '{}' to the invoking user once (setpriv-era ownership, \ + metadata only)", + upper.display() + ); + let owner = format!("{}:{}", unsafe { libc::geteuid() }, unsafe { + libc::getegid() + }); + let status = crate::deb::ephemeral::privileged_command("chown", is_root) + .arg("-R") + .arg(owner) + .arg(&upper) + .status() + .map_err(|e| format!("cannot hand over '{}': {e}", upper.display()))?; + if !status.success() { + return Err(format!( + "cannot hand over '{}' (ownership escalation failed)", + upper.display() + ) + .into()); + } + Ok(()) +} + +/// One-time migration of the shared ccache to the invoking user's +/// host-side ownership, keyed on a marker inside the cache so its walk +/// runs once per cache, not once per build. The ccache is bind-mounted +/// (no overlay attribute caching), so this is safe at any point of the +/// build. +fn migrate_ccache_ownership(ccache_host_dir: &Path) { + let marker = ccache_host_dir.join(".pkh-ownership"); + if marker.is_file() || !has_foreign_owner(ccache_host_dir) { + return; + } + let is_root = crate::utils::root::is_root().unwrap_or(false); + log::info!( + "Handing the shared ccache '{}' to the invoking user once \ + (setpriv-era ownership, metadata only)", + ccache_host_dir.display() + ); + let owner = format!("{}:{}", unsafe { libc::geteuid() }, unsafe { + libc::getegid() + }); + let status = crate::deb::ephemeral::privileged_command("chown", is_root) + .arg("-R") + .arg(owner) + .arg(ccache_host_dir) + .status(); + if status.is_ok_and(|s| s.success()) { + let _ = fs::write(&marker, "mapped\n"); + } else { + log::warn!( + "cannot hand the shared ccache '{}' over; its shard \ + directories stay unwritable and compiles fall back to \ + running without cache hits", + ccache_host_dir.display() + ); + } } /// The shell pipeline handing `dir` to the build identity: chown only the @@ -183,9 +427,9 @@ fn hand_over(ctx: &Arc, dir: &Path, ids: &(String, String)) -> Result<( Ok(()) } -/// 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. +/// Create [`BUILD_USER`] in the build context (the setpriv fallback). +/// Chroots carrying the passwd package take the useradd path; minimal +/// mmdebstrap ones append the entries directly at a fixed, free id. fn create_build_user(ctx: &Arc) -> Result<(), Box> { let have_useradd = ctx .command("sh") @@ -224,24 +468,7 @@ fn create_build_user(ctx: &Arc) -> Result<(), Box> { 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(()) + write_build_user_entries(ctx, uid) } /// Single-quote a string for /bin/sh. @@ -249,17 +476,26 @@ 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. +/// The rules command for the given build identity. Under the mapped +/// namespace the command is flagged for the unshare driver, which pivots +/// into the chroot and creates the nested namespace: the process simply +/// IS the build identity, no setpriv needed. The fallback `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 an identity the +/// rules run as the context's own. fn rules_command<'a>( ctx: &'a Arc, - user: Option<&(String, String)>, + identity: Option<&RulesIdentity>, program: &str, ) -> ContextCommand<'a> { - match user { - Some((uid, gid)) => { + match identity { + Some(RulesIdentity::MappedNamespace) => { + let mut cmd = ctx.command(program); + cmd.env(crate::context::MAPPED_IDENTITY_ENV, "1"); + cmd + } + Some(RulesIdentity::Setpriv(uid, gid)) => { let mut cmd = ctx.command("setpriv"); cmd.arg(format!("--reuid={uid}")) .arg(format!("--regid={gid}")) @@ -713,24 +949,47 @@ pub async fn build( // LTO/make -j process flood), and a rules chown that fails for the // build user is cosmetic — the .deb is assembled in the binary step's // own fakeroot session, where files unknown to its database are - // recorded as root-owned anyway. Handing the tree over is a selective, - // parallel chown (through the overlay staging each chown copy-ups the - // file it touches), and it is a phase of its own in the status bar and - // the profile. + // recorded as root-owned anyway. The mapped-namespace identity owns + // the tree by construction (no chown at all); the setpriv fallback + // hands it over with a selective, parallel chown, and it is a phase + // of its own in the status bar and the profile. if build_user.is_some() { enter_phase(view, Phase::PreparingBuildUser, profiler); } + let ccache_host_dir = ccache.then(|| { + session::ccache_dir(&session::SessionIdentity { + package: package.to_string(), + series: series.to_string(), + arch: arch.to_string(), + cross, + }) + }); 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())?) + Some(ensure_build_user( + &ctx, + &workroot, + session, + build_resume, + env.get("CCACHE_DIR").map(String::as_str), + ccache_host_dir.as_deref(), + )?) } None => None, }; + if matches!(rule_ids, Some(RulesIdentity::MappedNamespace)) { + // fakeroot's chown wrappers attempt the real chown and swallow only + // EPERM; as the mapped identity, targets like root:root resolve to + // nothing in the nested namespace and fail with EINVAL, which + // propagates (dh_fixperms chowns to root:root). This env is + // fakeroot's own knob to skip the real attempt — the fake + // ownership is still recorded, which is all the binary step needs. + env.insert("FAKEROOTDONTTRYCHOWN".to_string(), "1".to_string()); + } // Run the build step log::debug!("Building (debian/rules build) package..."); diff --git a/src/deb/mod.rs b/src/deb/mod.rs index 2f62376..eb510bf 100644 --- a/src/deb/mod.rs +++ b/src/deb/mod.rs @@ -46,8 +46,10 @@ pub enum Phase { /// Drop the resumed tree's input-untracked stamp caches (quirk: /// `resume_clear`) ClearingResumeCaches, - /// Create the unprivileged build user and hand the staged tree to - /// it (rootless builds) + /// Establish the unprivileged build identity for the rules steps + /// (rootless builds): a nested user namespace mapping the chroot's + /// root ownership onto the identity — the tree needs no chown — or, + /// when unavailable, a build user with a chown tree handover PreparingBuildUser, /// debian/rules build Building, @@ -642,6 +644,16 @@ async fn build_binary_package_impl( log::info!( "Reusing the build artifacts of the previous attempt (incremental build)" ); + // Before the overlay mounts (see the function's doc): a + // setpriv-era tree handed over here lets the mapped + // build identity run the rules; a failed handover makes + // the identity setup fall back to the chown handover. + if let Err(e) = local::migrate_upper_ownership(s) { + log::warn!( + "cannot hand the staged tree to the invoking user \ + ({e}); falling back to the chown handover" + ); + } } } let staged_path = if let Some(s) = &session { diff --git a/src/deb/session.rs b/src/deb/session.rs index db13e11..2567a32 100644 --- a/src/deb/session.rs +++ b/src/deb/session.rs @@ -2136,9 +2136,11 @@ pub(crate) fn plan_upper_sync( /// /// Removals go through the privileged path: the upperdir's entries were /// copy-up'd by whoever wrote them during the build — in a rootless build -/// the unprivileged build user (a subuid of the invoking user host-side) — -/// so a plain removal by the invoking user fails with EPERM exactly when a -/// host-modified file's stale upper copy must go. +/// the unprivileged build identity, whose files are the invoking user's +/// own host-side under the mapped namespace (a subuid of theirs with the +/// setpriv fallback, or of earlier builds) — so a plain removal by the +/// invoking user fails with EPERM exactly when a host-modified file's +/// stale upper copy must go. pub(crate) fn apply_upper_sync(upper_root: &Path, ops: &[UpperOp]) -> io::Result { let mut removed_prefixes: Vec = Vec::new(); let mut removed = 0;