diff --git a/src/deb/local.rs b/src/deb/local.rs index da688ec..438338d 100644 --- a/src/deb/local.rs +++ b/src/deb/local.rs @@ -116,8 +116,7 @@ fn ensure_build_user( .output() }; let probe = id_of(ctx, "-u").map_err(|e| format!("cannot look up {BUILD_USER}: {e}"))?; - let fresh = !probe.status.success(); - if fresh { + if !probe.status.success() { create_build_user(ctx)?; } @@ -135,32 +134,55 @@ fn ensure_build_user( } } - // 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 [Some(workroot), Some(Path::new("/tmp/pkh-home"))] + .into_iter() + .flatten() + { + hand_over(ctx, dir, &ids)?; } - 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()); - } + // The shared cache degrades to per-run compiles when it cannot be + // handed over: slower, never wrong. + if let Some(dir) = ccache_dir { + let _ = hand_over(ctx, Path::new(dir), &ids); } Ok(ids) } +/// The shell pipeline handing `dir` to the build identity: chown only the +/// entries it does not own yet, run in parallel. +/// +/// Through the overlay staging a chown copy-ups every lower-layer file it +/// touches — the full data movement of the staged tree. The serial +/// `chown -R` of a kernel tree spends minutes on it; this pipeline pays +/// the copy-up only for entries a root-side phase flipped back to root +/// (staging, quilt re-application), and the copy-ups of independent files +/// parallelize across the chroot's cores. On a resumed tree every entry +/// is already owned and the phase collapses to a metadata-only walk. +fn hand_over_script(dir: &str, uid: &str, gid: &str) -> String { + format!( + "n=$(nproc 2>/dev/null || echo 4); \ + find {} \\! -uid {uid} -print0 \ + | xargs -0 -r -P \"$n\" -n 512 chown -- {uid}:{gid}", + shlex_quote(dir) + ) +} + +/// Run [`hand_over_script`] against `dir`, failing the build when the +/// escalation cannot complete: a tree half-owned by root fails the rules +/// on their first write into it. +fn hand_over(ctx: &Arc, dir: &Path, ids: &(String, String)) -> Result<(), Box> { + let status = ctx + .command("sh") + .arg("-c") + .arg(hand_over_script(&dir.to_string_lossy(), &ids.0, &ids.1)) + .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(()) +} + /// 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. @@ -691,9 +713,10 @@ 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 recursive - // chown that can take minutes on a large carried tree, so it is a - // phase of its own in the status bar and the profile. + // 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. if build_user.is_some() { enter_phase(view, Phase::PreparingBuildUser, profiler); } @@ -1662,6 +1685,27 @@ mod tests { crate::debian::deps::parse_simple(s, true).unwrap() } + /// The handover pipeline chowns only entries the build user does not + /// own (a resumed tree needs no re-chown of already-owned files), is + /// parallel across the chroot's cores, and survives any spelling of + /// the staged path. + #[test] + fn hand_over_script_selects_and_parallelizes() { + let script = hand_over_script("/tmp/pkh-build/tree", "64500", "64500"); + assert!( + script.contains("find '/tmp/pkh-build/tree' \\! -uid 64500 -print0"), + "{script}" + ); + assert!(script.contains("xargs -0 -r -P"), "{script}"); + assert!(script.contains("chown -- 64500:64500"), "{script}"); + + let quoted = hand_over_script("/tmp/some'dir", "1000", "1000"); + assert!( + quoted.contains("find '/tmp/some'\\''dir'"), + "embedded quotes must be shell-quoted: {quoted}" + ); + } + /// The build env carries the ccache wiring when it is enabled: the /// compiler shim first on PATH, the shared cache dir, content-keyed /// compiler checks and the size cap.