context,deb: run the rules as a nested-namespace build identity

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.
This commit is contained in:
2026-10-03 13:00:26 +02:00
parent 5544873107
commit 2729f9d9e7
6 changed files with 526 additions and 58 deletions
+20
View File
@@ -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<PathBuf>;
+1
View File
@@ -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).
+188 -14
View File
@@ -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}");
}
}