deb: skip unreadable paths while snapshotting the staged parent
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.
This commit is contained in:
+76
-2
@@ -1518,13 +1518,40 @@ impl EntryMeta {
|
|||||||
|
|
||||||
/// Walk a host tree, mapping slash-separated relative paths to metadata.
|
/// Walk a host tree, mapping slash-separated relative paths to metadata.
|
||||||
/// Symlinks are recorded as themselves (not followed).
|
/// 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<BTreeMap<String, EntryMeta>> {
|
pub(crate) fn walk_tree_meta(root: &Path) -> io::Result<BTreeMap<String, EntryMeta>> {
|
||||||
let mut map = BTreeMap::new();
|
let mut map = BTreeMap::new();
|
||||||
let mut stack = vec![root.to_path_buf()];
|
let mut stack = vec![root.to_path_buf()];
|
||||||
while let Some(dir) = stack.pop() {
|
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 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() {
|
if metadata.is_dir() {
|
||||||
stack.push(path);
|
stack.push(path);
|
||||||
} else {
|
} else {
|
||||||
@@ -2039,6 +2066,53 @@ mod tests {
|
|||||||
assert_eq!(load_snapshot(&path), Some(meta_map));
|
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
|
/// Identity slugs are sanitized and collision-free, and the manifest
|
||||||
/// round-trips through JSON — lineage, tarball sha and the kept
|
/// round-trips through JSON — lineage, tarball sha and the kept
|
||||||
/// upper included, and their absence (older manifests) parsing as
|
/// upper included, and their absence (older manifests) parsing as
|
||||||
|
|||||||
Reference in New Issue
Block a user