@@ -33,7 +33,7 @@ const XATTR_LIST_MAX: usize = u16::MAX as usize;
3333// See above
3434const 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
3838pub 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> {
512513impl 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 {
556559impl 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