From ab0c65dc370ba9a572a926dd42e9457e58d129b0 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Tue, 29 Sep 2026 14:57:39 +0200 Subject: [PATCH] deb: skip unreadable paths while snapshotting the staged parent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The snapshot walk runs over the whole staged PARENT, which contains trees unrelated to the build; one unreadable path anywhere under it — a root-owned sysroot left there by the user's own setup script — made fs::read_dir or symlink_metadata fail and aborted the whole build seven seconds in, before any staging could happen. The walk is now best-effort below its root: unreadable directories and entries lost to a mid-walk race are warned about and skipped, and only the root itself being unreadable propagates (the package tree being invisible is a real error). Absent entries are already the handled case for the whiteout sync: they are simply untracked. --- src/deb/session.rs | 78 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/src/deb/session.rs b/src/deb/session.rs index 677d3b0..42b8aab 100644 --- a/src/deb/session.rs +++ b/src/deb/session.rs @@ -1518,13 +1518,40 @@ impl EntryMeta { /// Walk a host tree, mapping slash-separated relative paths to metadata. /// Symlinks are recorded as themselves (not followed). +/// +/// Best-effort below the root: a directory that cannot be read or an +/// entry that cannot be stat'd (a root-owned sibling tree in the staged +/// parent, a path deleted by a race mid-walk) is warned about and +/// skipped — the walk runs over the staged PARENT, which contains trees +/// unrelated to this build, and one unreadable path must not abort the +/// build. Entries absent from the map are simply untracked for the +/// whiteout sync, the already-handled case. Only the ROOT itself being +/// unreadable propagates: the package tree being unreadable is a real +/// error. pub(crate) fn walk_tree_meta(root: &Path) -> io::Result> { let mut map = BTreeMap::new(); let mut stack = vec![root.to_path_buf()]; while let Some(dir) = stack.pop() { - for entry in fs::read_dir(&dir)?.flatten() { + let entries = match fs::read_dir(&dir) { + Ok(entries) => entries, + Err(e) => { + if dir == root { + return Err(e); + } + log::warn!("skipping unreadable path {}: {e}", dir.display()); + continue; + } + }; + for entry in entries.flatten() { let path = entry.path(); - let metadata = fs::symlink_metadata(&path)?; + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + // A race deleted the entry between read_dir and stat. + Err(e) => { + log::warn!("skipping unreadable path {}: {e}", path.display()); + continue; + } + }; if metadata.is_dir() { stack.push(path); } else { @@ -2039,6 +2066,53 @@ mod tests { assert_eq!(load_snapshot(&path), Some(meta_map)); } + /// The snapshot walk is best-effort below the root: an unreadable + /// sibling directory in the staged parent (a root-owned tree the + /// user's own setup script left) is skipped with a warning instead + /// of aborting the build, and only the root's own unreadability + /// propagates. Deterministic as non-root: mode bits are checked with + /// no owner override, so a mode-000 directory blocks even its owner. + #[test] + fn walk_skips_unreadable_paths_but_fails_on_an_unreadable_root() { + use std::os::unix::fs::PermissionsExt; + + let base = tempfile::tempdir().unwrap(); + let root = base.path().join("parent"); + let readable = root.join("linux/src"); + let secret = root.join("demos/sysroot-riscv64"); + fs::create_dir_all(&readable).unwrap(); + fs::create_dir_all(&secret).unwrap(); + fs::write(readable.join("a.c"), "src").unwrap(); + fs::write(secret.join("sysroot.tar"), "root-owned").unwrap(); + fs::set_permissions(&secret, fs::Permissions::from_mode(0o000)).unwrap(); + + // The unreadable subtree is absent from the map; the walk itself + // succeeds. The directory entry itself IS stat-able (its mode + // hides its CONTENT, not its existence), so it appears as a + // tracked empty subtree root only if it were a file — here it is + // skipped whole, its contents untracked. + let walked = walk_tree_meta(&root).expect("one unreadable sibling must not abort the walk"); + + // Restore before asserting, so a failure still cleans up. + fs::set_permissions(&secret, fs::Permissions::from_mode(0o755)).unwrap(); + + assert!( + walked.contains_key("linux/src/a.c"), + "the readable tree is tracked: {walked:?}" + ); + assert!( + !walked.contains_key("demos/sysroot-riscv64/sysroot.tar"), + "the unreadable subtree's content is not tracked: {walked:?}" + ); + + // An unreadable ROOT is a real error: the package tree itself is + // invisible to this run. + fs::set_permissions(&secret, fs::Permissions::from_mode(0o000)).unwrap(); + let err = walk_tree_meta(&secret).expect_err("walking an unreadable root must fail"); + fs::set_permissions(&secret, fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); + } + /// Identity slugs are sanitized and collision-free, and the manifest /// round-trips through JSON — lineage, tarball sha and the kept /// upper included, and their absence (older manifests) parsing as