Skip to content

Commit 22f9327

Browse files
cgwaltersJohan-Liebert1
authored andcommitted
erofs/reader: Reject duplicate directory entries, validate fsck on output
The fuzzer found a crash where a malformed EROFS image had duplicate directory entry names. When two entries share a name, BTreeMap::insert silently replaces the first, leaving its leaf orphaned (unreferenced). This tripped a debug_assert in erofs_to_filesystem. Ensure we catch this problem cleanly in our EROFS parser. Assisted-by: OpenCode (Claude Opus 4) Signed-off-by: Colin Walters <walters@verbum.org>
1 parent b41bb0f commit 22f9327

2 files changed

Lines changed: 49 additions & 5 deletions

File tree

crates/composefs/src/erofs/reader.rs

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -952,6 +952,9 @@ pub enum ErofsReaderError {
952952
/// File type in directory entry doesn't match inode
953953
#[error("File type in dirent doesn't match type in inode")]
954954
FileTypeMismatch,
955+
/// Duplicate directory entry name
956+
#[error("Duplicate directory entry {0:?}")]
957+
DuplicateEntry(Box<OsStr>),
955958
}
956959

957960
type ReadResult<T> = Result<T, ErofsReaderError>;
@@ -1406,11 +1409,15 @@ fn populate_directory<ObjectID: FsVerityHashValue>(
14061409
depth + 1,
14071410
)
14081411
.with_context(|| format!("reading directory {:?}", name))?;
1409-
dir.insert(name, tree::Inode::Directory(Box::new(child_dir)));
1412+
if !dir.insert(name, tree::Inode::Directory(Box::new(child_dir))) {
1413+
return Err(ErofsReaderError::DuplicateEntry(Box::from(name)).into());
1414+
}
14101415
} else {
14111416
// Check if this is a hardlink (same nid seen before)
14121417
if let Some(&existing_leaf_id) = builder.hardlinks.get(&nid) {
1413-
dir.insert(name, tree::Inode::leaf(existing_leaf_id));
1418+
if !dir.insert(name, tree::Inode::leaf(existing_leaf_id)) {
1419+
return Err(ErofsReaderError::DuplicateEntry(Box::from(name)).into());
1420+
}
14141421
continue;
14151422
}
14161423

@@ -1478,7 +1485,9 @@ fn populate_directory<ObjectID: FsVerityHashValue>(
14781485
leaf_id,
14791486
});
14801487

1481-
dir.insert(name, tree::Inode::leaf(leaf_id));
1488+
if !dir.insert(name, tree::Inode::leaf(leaf_id)) {
1489+
return Err(ErofsReaderError::DuplicateEntry(Box::from(name)).into());
1490+
}
14821491
}
14831492
}
14841493

@@ -2540,4 +2549,37 @@ mod tests {
25402549
}
25412550
}
25422551
}
2552+
2553+
/// Regression test for a fuzzer-found crash where duplicate directory entry
2554+
/// names caused orphaned leaves (the second insert silently replaced the
2555+
/// first in the BTreeMap, leaving the first leaf unreferenced).
2556+
#[test]
2557+
fn test_duplicate_dirent_rejected() {
2558+
// Build a valid image with two files
2559+
let dumpfile = r#"/ 0 40755 2 0 0 0 1000.0 - - -
2560+
/aaa 5 100644 1 0 0 0 1000.0 - hello -
2561+
/bbb 5 100644 1 0 0 0 1000.0 - world -
2562+
"#;
2563+
let fs = dumpfile_to_filesystem::<Sha256HashValue>(dumpfile).unwrap();
2564+
let image = mkfs_erofs(&fs);
2565+
2566+
// Sanity: the unmodified image round-trips fine
2567+
erofs_to_filesystem::<Sha256HashValue>(&image).unwrap();
2568+
2569+
// Corrupt the image: rename "bbb" to "aaa" so there's a duplicate
2570+
let mut bad = image.clone();
2571+
let needle = b"bbb";
2572+
let pos = bad
2573+
.windows(needle.len())
2574+
.position(|w| w == needle)
2575+
.expect("filename not found in image");
2576+
bad[pos..pos + needle.len()].copy_from_slice(b"aaa");
2577+
2578+
let err = erofs_to_filesystem::<Sha256HashValue>(&bad).unwrap_err();
2579+
let msg = format!("{err:#}");
2580+
assert!(
2581+
msg.contains("Duplicate directory entry"),
2582+
"unexpected error: {msg}"
2583+
);
2584+
}
25432585
}

crates/composefs/src/generic_tree.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -448,13 +448,15 @@ impl<T> Directory<T> {
448448
/// If the `filename` existed previously, the content is completely overwritten, including the
449449
/// case that it was a directory.
450450
///
451+
/// Returns `true` if the entry is new, `false` if it replaced an existing entry.
452+
///
451453
/// # Arguments
452454
///
453455
/// * `filename`: the filename in the current directory. If you need to support full
454456
/// pathnames then you should call `Directory::split()` first.
455457
/// * `inode`: the inode to store under the `filename`
456-
pub fn insert(&mut self, filename: &OsStr, inode: Inode<T>) {
457-
self.entries.insert(Box::from(filename), inode);
458+
pub fn insert(&mut self, filename: &OsStr, inode: Inode<T>) -> bool {
459+
self.entries.insert(Box::from(filename), inode).is_none()
458460
}
459461

460462
/// Removes the named file from the directory, if it exists. If it doesn't exist, this is a

0 commit comments

Comments
 (0)