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