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, 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 /// A ContextDriver is the interface for the logic happening inside a context
pub trait ContextDriver { pub trait ContextDriver {
fn ensure_available(&self, src: &Path, dest_root: &str) -> io::Result<PathBuf>; fn ensure_available(&self, src: &Path, dest_root: &str) -> io::Result<PathBuf>;
+1
View File
@@ -8,6 +8,7 @@ mod ssh;
mod unshare; mod unshare;
pub use api::{Context, ContextCommand, ContextConfig, LineSink, OverlayStaging, Stream}; 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 // 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 // needed crate-internally (test-run capture wrapper), so keep it out of the
// public surface (and its documentation requirement). // 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 super::shell::shell_quote;
use log::debug; use log::debug;
use std::fs; use std::fs;
@@ -534,6 +536,9 @@ impl UnshareDriver {
env: &[(String, String)], env: &[(String, String)],
cwd: Option<&str>, cwd: Option<&str>,
) -> ContextCommand<'_> { ) -> ContextCommand<'_> {
let mapped = env
.iter()
.any(|(key, value)| key == MAPPED_IDENTITY_ENV && value == "1");
let mut cmd = self.parent().command("unshare"); let mut cmd = self.parent().command("unshare");
cmd.envs(env.iter().cloned()); cmd.envs(env.iter().cloned());
@@ -548,25 +553,112 @@ impl UnshareDriver {
.arg("--map-auto") .arg("--map-auto")
.arg("-r") .arg("-r")
.arg("--mount") .arg("--mount")
.arg("--fork") .arg("--fork");
.arg("-R")
.arg(&self.path);
if let Some(dir) = cwd { if mapped {
cmd.arg("-w").arg(dir); // 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 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: /// Build the shell script executed by `bash -c` inside the user namespace:
/// bring up `/dev/pts`, then run `program` with `args`. /// bring up `/dev/pts`, then run `program` with `args`.
/// ///
@@ -590,7 +682,7 @@ fn build_namespace_script(program: &str, args: &[String]) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { 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 { fn tail_after_devpts_setup(script: &str) -> &str {
script script
@@ -639,4 +731,86 @@ mod tests {
let script = build_namespace_script("prog", &[String::new(), "x".to_string()]); let script = build_namespace_script("prog", &[String::new(), "x".to_string()]);
assert_eq!(tail_after_devpts_setup(&script), "'prog' '' 'x'"); 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}");
}
} }
+298 -39
View File
@@ -7,6 +7,7 @@ use crate::report::BuildView;
use log::warn; use log::warn;
use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::error::Error; use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
@@ -96,17 +97,105 @@ pub(crate) fn apply_ccache_env(env: &mut HashMap<String, String>, enabled: bool,
/// (ssh, schroot) keep running the rules as their own user. /// (ssh, schroot) keep running the rules as their own user.
pub const BUILD_USER: &str = "pkh-build"; 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<Context>) -> 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 /// Ensure [`BUILD_USER`] exists in the build context and owns everything
/// the rules must write: the staged tree (whose parent is where /// the rules must write: the staged tree (whose parent is where
/// dh_builddeb drops the .debs), the scratch HOME and the shared ccache /// dh_builddeb drops the .debs), the scratch HOME and the shared ccache
/// directory. Returns the (uid, gid) for the setpriv invocation. Ownership /// directory. Returns how the rules drop privileges.
/// is re-applied on every build: a resumed build stages the tree fresh ///
/// from the host, where everything is root-owned again. /// 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( fn ensure_build_user(
ctx: &Arc<Context>, ctx: &Arc<Context>,
workroot: &Path, workroot: &Path,
session: Option<&session::Session>,
build_resume: bool,
ccache_dir: Option<&str>, ccache_dir: Option<&str>,
) -> Result<(String, String), Box<dyn Error>> { ccache_host_dir: Option<&Path>,
) -> Result<RulesIdentity, Box<dyn Error>> {
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 // Through sh with the stderr dropped: a missing user is the expected
// state in a fresh chroot, not a message the build log needs. // state in a fresh chroot, not a message the build log needs.
let id_of = |ctx: &Arc<Context>, what: &str| { let id_of = |ctx: &Arc<Context>, what: &str| {
@@ -145,7 +234,162 @@ fn ensure_build_user(
if let Some(dir) = ccache_dir { if let Some(dir) = ccache_dir {
let _ = hand_over(ctx, Path::new(dir), &ids); 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<Context>, id: u32) -> Result<(), Box<dyn Error>> {
// 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<PathBuf> {
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<dyn Error>> {
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 /// The shell pipeline handing `dir` to the build identity: chown only the
@@ -183,9 +427,9 @@ fn hand_over(ctx: &Arc<Context>, dir: &Path, ids: &(String, String)) -> Result<(
Ok(()) Ok(())
} }
/// Create [`BUILD_USER`] in the build context. Minimal mmdebstrap chroots /// Create [`BUILD_USER`] in the build context (the setpriv fallback).
/// carry no passwd package (no useradd), so the passwd and group entries /// Chroots carrying the passwd package take the useradd path; minimal
/// are appended directly when needed, as sbuild's build-user setup does. /// mmdebstrap ones append the entries directly at a fixed, free id.
fn create_build_user(ctx: &Arc<Context>) -> Result<(), Box<dyn Error>> { fn create_build_user(ctx: &Arc<Context>) -> Result<(), Box<dyn Error>> {
let have_useradd = ctx let have_useradd = ctx
.command("sh") .command("sh")
@@ -224,24 +468,7 @@ fn create_build_user(ctx: &Arc<Context>) -> Result<(), Box<dyn Error>> {
let uid = (64500..=65500) let uid = (64500..=65500)
.find(|id| !used(&passwd, *id) && !used(&group, *id)) .find(|id| !used(&passwd, *id) && !used(&group, *id))
.ok_or("no free uid for the build user")?; .ok_or("no free uid for the build user")?;
for (file, entry) in [ write_build_user_entries(ctx, uid)
("/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. /// Single-quote a string for /bin/sh.
@@ -249,17 +476,26 @@ fn shlex_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', r"'\''")) format!("'{}'", value.replace('\'', r"'\''"))
} }
/// The rules command for the given user: `setpriv` drops to the build user /// The rules command for the given build identity. Under the mapped
/// without touching the environment (a login shell would reset it), so the /// namespace the command is flagged for the unshare driver, which pivots
/// dpkg architecture variables and DEB_BUILD_OPTIONS survive the /// into the chroot and creates the nested namespace: the process simply
/// transition; without a user the rules run as the context's own. /// 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>( fn rules_command<'a>(
ctx: &'a Arc<Context>, ctx: &'a Arc<Context>,
user: Option<&(String, String)>, identity: Option<&RulesIdentity>,
program: &str, program: &str,
) -> ContextCommand<'a> { ) -> ContextCommand<'a> {
match user { match identity {
Some((uid, gid)) => { 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"); let mut cmd = ctx.command("setpriv");
cmd.arg(format!("--reuid={uid}")) cmd.arg(format!("--reuid={uid}"))
.arg(format!("--regid={gid}")) .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 // 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 // build user is cosmetic — the .deb is assembled in the binary step's
// own fakeroot session, where files unknown to its database are // own fakeroot session, where files unknown to its database are
// recorded as root-owned anyway. Handing the tree over is a selective, // recorded as root-owned anyway. The mapped-namespace identity owns
// parallel chown (through the overlay staging each chown copy-ups the // the tree by construction (no chown at all); the setpriv fallback
// file it touches), and it is a phase of its own in the status bar and // hands it over with a selective, parallel chown, and it is a phase
// the profile. // of its own in the status bar and the profile.
if build_user.is_some() { if build_user.is_some() {
enter_phase(view, Phase::PreparingBuildUser, profiler); 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 { let rule_ids = match build_user {
Some(_) => { Some(_) => {
let workroot = Path::new(package_dir_str) let workroot = Path::new(package_dir_str)
.parent() .parent()
.ok_or("cannot find the staged tree's parent directory")? .ok_or("cannot find the staged tree's parent directory")?
.to_path_buf(); .to_path_buf();
let ccache_dir = env.get("CCACHE_DIR").cloned(); Some(ensure_build_user(
Some(ensure_build_user(&ctx, &workroot, ccache_dir.as_deref())?) &ctx,
&workroot,
session,
build_resume,
env.get("CCACHE_DIR").map(String::as_str),
ccache_host_dir.as_deref(),
)?)
} }
None => None, 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 // Run the build step
log::debug!("Building (debian/rules build) package..."); log::debug!("Building (debian/rules build) package...");
+14 -2
View File
@@ -46,8 +46,10 @@ pub enum Phase {
/// Drop the resumed tree's input-untracked stamp caches (quirk: /// Drop the resumed tree's input-untracked stamp caches (quirk:
/// `resume_clear`) /// `resume_clear`)
ClearingResumeCaches, ClearingResumeCaches,
/// Create the unprivileged build user and hand the staged tree to /// Establish the unprivileged build identity for the rules steps
/// it (rootless builds) /// (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, PreparingBuildUser,
/// debian/rules build /// debian/rules build
Building, Building,
@@ -642,6 +644,16 @@ async fn build_binary_package_impl(
log::info!( log::info!(
"Reusing the build artifacts of the previous attempt (incremental build)" "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 { let staged_path = if let Some(s) = &session {
+5 -3
View File
@@ -2136,9 +2136,11 @@ pub(crate) fn plan_upper_sync(
/// ///
/// Removals go through the privileged path: the upperdir's entries were /// 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 /// 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) — /// the unprivileged build identity, whose files are the invoking user's
/// so a plain removal by the invoking user fails with EPERM exactly when a /// own host-side under the mapped namespace (a subuid of theirs with the
/// host-modified file's stale upper copy must go. /// 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<usize> { pub(crate) fn apply_upper_sync(upper_root: &Path, ops: &[UpperOp]) -> io::Result<usize> {
let mut removed_prefixes: Vec<String> = Vec::new(); let mut removed_prefixes: Vec<String> = Vec::new();
let mut removed = 0; let mut removed = 0;