Skip to content

Commit c6ef089

Browse files
committed
dumpfile: Canonicalize parsed entries per composefs-dump(5)
The composefs-dump(5) spec leaves several fields unspecified or explicitly ignored. Canonicalize them at parse time so that parsed entries have a single canonical representation regardless of which implementation produced them: - **Directory sizes**: "This is ignored for directories." Drop the size field from Item::Directory, always emit 0. - **Hardlink metadata**: "We ignore all the fields except the payload." Zero uid/gid/mode/mtime and skip xattrs, matching the C parser which bails out early (mkcomposefs.c:477-491). - **Xattr ordering**: The spec doesn't define an order. Sort lexicographically so output is deterministic regardless of on-disk ordering. The parser still accepts any input values for backward compatibility. Assisted-by: OpenCode (Claude Opus 4) Signed-off-by: Colin Walters <walters@verbum.org>
1 parent 0fd8096 commit c6ef089

4 files changed

Lines changed: 138 additions & 68 deletions

File tree

crates/composefs/src/dumpfile.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -561,7 +561,7 @@ mod tests {
561561
use super::*;
562562
use crate::fsverity::Sha256HashValue;
563563

564-
const SIMPLE_DUMP: &str = r#"/ 4096 40755 2 0 0 0 1000.0 - - -
564+
const SIMPLE_DUMP: &str = r#"/ 0 40755 2 0 0 0 1000.0 - - -
565565
/empty_file 0 100644 1 0 0 0 1000.0 - - -
566566
/small_file 5 100644 1 0 0 0 1000.0 - hello -
567567
/symlink 7 120777 1 0 0 0 1000.0 /target - -
@@ -592,10 +592,10 @@ mod tests {
592592
// The nlink/uid/gid/rdev fields on hardlink lines use `-` here,
593593
// matching the C composefs writer convention. The parser must
594594
// accept these without trying to parse them as integers.
595-
let dumpfile = r#"/ 4096 40755 2 0 0 0 1000.0 - - -
595+
let dumpfile = r#"/ 0 40755 2 0 0 0 1000.0 - - -
596596
/original 11 100644 2 0 0 0 1000.0 - hello_world -
597597
/hardlink1 0 @120000 - - - - 0.0 /original - -
598-
/dir1 4096 40755 2 0 0 0 1000.0 - - -
598+
/dir1 0 40755 2 0 0 0 1000.0 - - -
599599
/dir1/hardlink2 0 @120000 - - - - 0.0 /original - -
600600
"#;
601601

crates/composefs/src/dumpfile_parse.rs

Lines changed: 91 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const XATTR_LIST_MAX: usize = u16::MAX as usize;
3333
// See above
3434
const XATTR_SIZE_MAX: usize = u16::MAX as usize;
3535

36-
#[derive(Debug, PartialEq, Eq)]
36+
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
3737
/// An extended attribute entry
3838
pub struct Xattr<'k> {
3939
/// key
@@ -122,8 +122,6 @@ pub enum Item<'p> {
122122
},
123123
/// A directory
124124
Directory {
125-
/// Size of a directory is not necessarily meaningful
126-
size: u64,
127125
/// Number of links
128126
nlink: u32,
129127
},
@@ -375,16 +373,14 @@ impl<'p> Entry<'p> {
375373
(false, u32::from_str_radix(modeval, 8)?)
376374
};
377375

378-
// For hardlinks, the C parser skips the remaining numeric fields
379-
// (nlink, uid, gid, rdev, mtime) and only reads the payload (target
380-
// path). We match that: consume the tokens without parsing them as
381-
// integers, so values like `-` are accepted.
376+
// Per composefs-dump(5): for hardlinks "we ignore all the fields
377+
// except the payload." The C parser does the same (mkcomposefs.c
378+
// bails out early). Skip everything and zero ignored fields.
382379
if is_hardlink {
383380
let ty = FileType::from_raw_mode(mode);
384381
if ty == FileType::Directory {
385382
anyhow::bail!("Invalid hardlinked directory");
386383
}
387-
// Skip nlink, uid, gid, rdev, mtime
388384
for field in ["nlink", "uid", "gid", "rdev", "mtime"] {
389385
next(field)?;
390386
}
@@ -395,7 +391,7 @@ impl<'p> Entry<'p> {
395391
path,
396392
uid: 0,
397393
gid: 0,
398-
mode,
394+
mode: 0,
399395
mtime: Mtime { sec: 0, nsec: 0 },
400396
item: Item::Hardlink { target },
401397
xattrs: Vec::new(),
@@ -410,7 +406,7 @@ impl<'p> Entry<'p> {
410406
let payload = optional_str(next("payload")?);
411407
let content = optional_str(next("content")?);
412408
let fsverity_digest = optional_str(next("digest")?);
413-
let xattrs = components
409+
let mut xattrs = components
414410
.try_fold((Vec::new(), 0usize), |(mut acc, total_namelen), line| {
415411
let xattr = Xattr::parse(line)?;
416412
// Limit the total length of keys.
@@ -422,6 +418,10 @@ impl<'p> Entry<'p> {
422418
Ok((acc, total_namelen))
423419
})?
424420
.0;
421+
// Canonicalize xattr ordering — the composefs-dump(5) spec doesn't
422+
// define an order, and different implementations emit them differently
423+
// (C uses EROFS on-disk order, Rust uses BTreeMap order).
424+
xattrs.sort();
425425

426426
let ty = FileType::from_raw_mode(mode);
427427
let item = {
@@ -474,8 +474,9 @@ impl<'p> Entry<'p> {
474474
FileType::Directory => {
475475
Self::check_nonregfile(content, fsverity_digest)?;
476476
Self::check_rdev(rdev)?;
477-
478-
Item::Directory { size, nlink }
477+
// Per composefs-dump(5): "SIZE: The size of the file.
478+
// This is ignored for directories." We discard it.
479+
Item::Directory { nlink }
479480
}
480481
FileType::Socket => {
481482
anyhow::bail!("sockets are not supported");
@@ -512,8 +513,10 @@ impl<'p> Entry<'p> {
512513
impl Item<'_> {
513514
pub(crate) fn size(&self) -> u64 {
514515
match self {
515-
Item::Regular { size, .. } | Item::Directory { size, .. } => *size,
516+
Item::Regular { size, .. } => *size,
516517
Item::RegularInline { content, .. } => content.len() as u64,
518+
// Directories always report 0; the spec says size is ignored.
519+
Item::Directory { .. } => 0,
517520
_ => 0,
518521
}
519522
}
@@ -556,9 +559,14 @@ impl Display for Mtime {
556559
impl Display for Entry<'_> {
557560
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
558561
escape(f, self.path.as_os_str().as_bytes(), EscapeMode::Standard)?;
562+
let hardlink_prefix = if matches!(self.item, Item::Hardlink { .. }) {
563+
"@"
564+
} else {
565+
""
566+
};
559567
write!(
560568
f,
561-
" {} {:o} {} {} {} {} {} ",
569+
" {} {hardlink_prefix}{:o} {} {} {} {} {} ",
562570
self.item.size(),
563571
self.mode,
564572
self.item.nlink(),
@@ -858,18 +866,78 @@ mod tests {
858866
fn test_parse() {
859867
const CONTENT: &str = include_str!("tests/assets/special.dump");
860868
for line in CONTENT.lines() {
861-
// Test a full round trip by parsing, serialize, parsing again
869+
// Test a full round trip by parsing, serializing, parsing again.
870+
// The serialized form may differ from the input (e.g. xattr
871+
// ordering is canonicalized), so we check structural equality
872+
// and that serialization is idempotent.
862873
let e = Entry::parse(line).unwrap();
863874
let serialized = e.to_string();
864-
if line != serialized {
865-
dbg!(&line, &e, &serialized);
866-
}
867-
similar_asserts::assert_eq!(line, serialized);
868875
let e2 = Entry::parse(&serialized).unwrap();
869876
similar_asserts::assert_eq!(e, e2);
877+
// Serialization must be idempotent
878+
similar_asserts::assert_eq!(serialized, e2.to_string());
870879
}
871880
}
872881

882+
#[test]
883+
fn test_canonicalize_directory_size() {
884+
// Directory size should be discarded — any input value becomes 0
885+
let e = Entry::parse("/ 4096 40755 2 0 0 0 1000.0 - - -").unwrap();
886+
assert_eq!(e.item.size(), 0);
887+
assert!(e.to_string().starts_with("/ 0 40755"));
888+
889+
let e = Entry::parse("/ 99999 40755 2 0 0 0 1000.0 - - -").unwrap();
890+
assert_eq!(e.item.size(), 0);
891+
}
892+
893+
#[test]
894+
fn test_canonicalize_hardlink_metadata() {
895+
// Hardlink metadata fields should all be zeroed — only path and
896+
// target (payload) are meaningful per composefs-dump(5).
897+
let e = Entry::parse(
898+
"/link 259 @100644 3 1000 1000 0 1695368732.385062094 /original - \
899+
35d02f81325122d77ec1d11baba655bc9bf8a891ab26119a41c50fa03ddfb408 \
900+
security.selinux=foo",
901+
)
902+
.unwrap();
903+
904+
// All metadata zeroed
905+
assert_eq!(e.uid, 0);
906+
assert_eq!(e.gid, 0);
907+
assert_eq!(e.mode, 0);
908+
assert_eq!(e.mtime, Mtime { sec: 0, nsec: 0 });
909+
assert!(e.xattrs.is_empty());
910+
911+
// Target preserved
912+
match &e.item {
913+
Item::Hardlink { target } => assert_eq!(target.as_ref(), Path::new("/original")),
914+
other => panic!("Expected Hardlink, got {other:?}"),
915+
}
916+
917+
// Serialization uses @0 for mode, zeroed fields
918+
let s = e.to_string();
919+
assert!(s.contains("@0 0 0 0 0 0.0"), "got: {s}");
920+
}
921+
922+
#[test]
923+
fn test_canonicalize_xattr_ordering() {
924+
// Xattrs should be sorted by key regardless of input order
925+
let e = Entry::parse("/ 0 40755 2 0 0 0 0.0 - - - user.z=1 security.ima=2 trusted.a=3")
926+
.unwrap();
927+
928+
let keys: Vec<&[u8]> = e.xattrs.iter().map(|x| x.key.as_bytes()).collect();
929+
assert_eq!(
930+
keys,
931+
vec![b"security.ima".as_slice(), b"trusted.a", b"user.z"],
932+
"xattrs should be sorted by key"
933+
);
934+
935+
// Re-serialization preserves sorted order
936+
let s = e.to_string();
937+
let e2 = Entry::parse(&s).unwrap();
938+
assert_eq!(e, e2);
939+
}
940+
873941
fn parse_all(name: &str, s: &str) -> Result<()> {
874942
for line in s.lines() {
875943
if line.is_empty() {
@@ -886,10 +954,10 @@ mod tests {
886954
const CASES: &[(&str, &str)] = &[
887955
(
888956
"content in fifo",
889-
"/ 4096 40755 2 0 0 0 0.0 - - -\n/fifo 0 10777 1 0 0 0 0.0 - foobar -",
957+
"/ 0 40755 2 0 0 0 0.0 - - -\n/fifo 0 10777 1 0 0 0 0.0 - foobar -",
890958
),
891-
("root with rdev", "/ 4096 40755 2 0 0 42 0.0 - - -"),
892-
("root with fsverity", "/ 4096 40755 2 0 0 0 0.0 - - 35d02f81325122d77ec1d11baba655bc9bf8a891ab26119a41c50fa03ddfb408"),
959+
("root with rdev", "/ 0 40755 2 0 0 42 0.0 - - -"),
960+
("root with fsverity", "/ 0 40755 2 0 0 0 0.0 - - 35d02f81325122d77ec1d11baba655bc9bf8a891ab26119a41c50fa03ddfb408"),
893961
];
894962
for (name, case) in CASES.iter().copied() {
895963
assert!(
@@ -919,7 +987,7 @@ mod tests {
919987
#[test]
920988
fn test_load_cfs_filtered() -> Result<()> {
921989
const FILTERED: &str =
922-
"/ 4096 40555 2 0 0 0 1633950376.0 - - - trusted.foo1=bar-1 user.foo2=bar-2\n\
990+
"/ 0 40555 2 0 0 0 1633950376.0 - - - trusted.foo1=bar-1 user.foo2=bar-2\n\
923991
/blockdev 0 60777 1 0 0 107690 1633950376.0 - - - trusted.bar=bar-2\n\
924992
/inline 15 100777 1 0 0 0 1633950376.0 - FOOBAR\\nINAFILE\\n - user.foo=bar-2\n";
925993
let mut tmpf = tempfile::tempfile()?;

0 commit comments

Comments
 (0)