@@ -50,32 +50,48 @@ fn write_escaped(writer: &mut impl fmt::Write, bytes: &[u8]) -> fmt::Result {
5050 return writer. write_str ( "\\ x2d" ) ;
5151 }
5252
53- write_escaped_raw ( writer, bytes)
53+ write_escaped_raw ( writer, bytes, EscapeEquals :: No )
54+ }
55+
56+ /// Whether to escape `=` as `\x3d`.
57+ ///
58+ /// The C composefs implementation only escapes `=` in xattr key/value
59+ /// fields where it separates the key from the value. In other fields
60+ /// (paths, content, payload) `=` is a normal graphic character.
61+ #[ derive( Clone , Copy ) ]
62+ enum EscapeEquals {
63+ /// Escape `=` — used for xattr key/value fields.
64+ Yes ,
65+ /// Do not escape `=` — used for paths, content, and payload fields.
66+ No ,
5467}
5568
5669/// Escape a byte slice without the `-` sentinel logic.
5770///
58- /// This corresponds to `print_escaped` with `ESCAPE_EQUAL` (but without
59- /// `ESCAPE_LONE_DASH`) in the C composefs `composefs-info.c`. Used for
60- /// xattr values where `-` and empty are valid literal values, not
61- /// sentinels.
71+ /// This corresponds to `print_escaped` in the C composefs
72+ /// `composefs-info.c`. Used for xattr values where `-` and empty are
73+ /// valid literal values, not sentinels.
6274///
63- /// Note: we unconditionally escape `=` in all fields, whereas the C code
64- /// only uses `ESCAPE_EQUAL` for xattr keys and values. This is harmless
65- /// since `\x3d` round-trips correctly, but means our output for paths
66- /// containing `=` is slightly more verbose than the C output.
67- fn write_escaped_raw ( writer : & mut impl fmt:: Write , bytes : & [ u8 ] ) -> fmt:: Result {
75+ /// The `escape_eq` parameter controls whether `=` is escaped (only
76+ /// needed in xattr key/value fields where `=` is a separator).
77+ fn write_escaped_raw (
78+ writer : & mut impl fmt:: Write ,
79+ bytes : & [ u8 ] ,
80+ escape_eq : EscapeEquals ,
81+ ) -> fmt:: Result {
6882 for c in bytes {
6983 let c = * c;
7084
71- // The set of hex-escaped characters matches C `!isgraph(c)` in the
72- // POSIX locale (i.e. outside 0x21..=0x7E), plus `=` and `\`.
73- // The C code uses named escapes for `\\`, `\n`, `\r`, `\t` while
74- // we hex-escape everything uniformly; both forms parse correctly.
75- if c < b'!' || c == b'=' || c == b'\\' || c > b'~' {
76- write ! ( writer, "\\ x{c:02x}" ) ?;
77- } else {
78- writer. write_char ( c as char ) ?;
85+ // Named escapes matching the C composefs implementation.
86+ match c {
87+ b'\\' => writer. write_str ( "\\ \\ " ) ?,
88+ b'\n' => writer. write_str ( "\\ n" ) ?,
89+ b'\r' => writer. write_str ( "\\ r" ) ?,
90+ b'\t' => writer. write_str ( "\\ t" ) ?,
91+ b'=' if matches ! ( escape_eq, EscapeEquals :: Yes ) => write ! ( writer, "\\ x{c:02x}" ) ?,
92+ // Hex-escape non-graphic characters (outside 0x21..=0x7E in POSIX locale).
93+ c if !( b'!' ..=b'~' ) . contains ( & c) => write ! ( writer, "\\ x{c:02x}" ) ?,
94+ c => writer. write_char ( c as char ) ?,
7995 }
8096 }
8197
@@ -117,11 +133,11 @@ fn write_entry(
117133
118134 for ( key, value) in & * stat. xattrs . borrow ( ) {
119135 write ! ( writer, " " ) ?;
120- write_escaped ( writer, key. as_bytes ( ) ) ?;
136+ write_escaped_raw ( writer, key. as_bytes ( ) , EscapeEquals :: Yes ) ?;
121137 write ! ( writer, "=" ) ?;
122138 // Xattr values don't use the `-` sentinel — they're always present
123139 // when the key=value pair exists, and empty or `-` are valid values.
124- write_escaped_raw ( writer, value) ?;
140+ write_escaped_raw ( writer, value, EscapeEquals :: Yes ) ?;
125141 }
126142
127143 Ok ( ( ) )
@@ -765,6 +781,75 @@ mod tests {
765781 Ok ( ( ) )
766782 }
767783
784+ /// Helper to escape bytes through write_escaped and return the result.
785+ fn escaped ( bytes : & [ u8 ] ) -> String {
786+ let mut out = String :: new ( ) ;
787+ write_escaped ( & mut out, bytes) . unwrap ( ) ;
788+ out
789+ }
790+
791+ /// Helper to escape bytes through write_escaped_raw with the given mode.
792+ fn escaped_raw ( bytes : & [ u8 ] , eq : EscapeEquals ) -> String {
793+ let mut out = String :: new ( ) ;
794+ write_escaped_raw ( & mut out, bytes, eq) . unwrap ( ) ;
795+ out
796+ }
797+
798+ #[ test]
799+ fn test_named_escapes ( ) {
800+ // These must use named escapes matching C composefs, not \xHH.
801+ assert_eq ! ( escaped_raw( b"\\ " , EscapeEquals :: No ) , "\\ \\ " ) ;
802+ assert_eq ! ( escaped_raw( b"\n " , EscapeEquals :: No ) , "\\ n" ) ;
803+ assert_eq ! ( escaped_raw( b"\r " , EscapeEquals :: No ) , "\\ r" ) ;
804+ assert_eq ! ( escaped_raw( b"\t " , EscapeEquals :: No ) , "\\ t" ) ;
805+
806+ // Mixed: named escapes interspersed with literals
807+ assert_eq ! ( escaped_raw( b"a\n b" , EscapeEquals :: No ) , "a\\ nb" ) ;
808+ assert_eq ! ( escaped_raw( b"\t \n \\ " , EscapeEquals :: No ) , "\\ t\\ n\\ \\ " ) ;
809+ }
810+
811+ #[ test]
812+ fn test_non_graphic_hex_escapes ( ) {
813+ // Characters outside 0x21..=0x7E get \xHH
814+ assert_eq ! ( escaped_raw( b"\x00 " , EscapeEquals :: No ) , "\\ x00" ) ;
815+ assert_eq ! ( escaped_raw( b"\x1f " , EscapeEquals :: No ) , "\\ x1f" ) ;
816+ assert_eq ! ( escaped_raw( b" " , EscapeEquals :: No ) , "\\ x20" ) ; // space = 0x20 < '!'
817+ assert_eq ! ( escaped_raw( b"\x7f " , EscapeEquals :: No ) , "\\ x7f" ) ;
818+ assert_eq ! ( escaped_raw( b"\xff " , EscapeEquals :: No ) , "\\ xff" ) ;
819+ }
820+
821+ #[ test]
822+ fn test_equals_escaping_context ( ) {
823+ // '=' is literal in normal fields (paths, content, payload)
824+ assert_eq ! ( escaped_raw( b"a=b" , EscapeEquals :: No ) , "a=b" ) ;
825+ assert_eq ! ( escaped( b"key=val" ) , "key=val" ) ;
826+
827+ // '=' is escaped in xattr key/value fields
828+ assert_eq ! ( escaped_raw( b"a=b" , EscapeEquals :: Yes ) , "a\\ x3db" ) ;
829+ assert_eq ! (
830+ escaped_raw( b"overlay.redirect=/foo" , EscapeEquals :: Yes ) ,
831+ "overlay.redirect\\ x3d/foo"
832+ ) ;
833+ }
834+
835+ #[ test]
836+ fn test_escaped_sentinels ( ) {
837+ // Empty → "-"
838+ assert_eq ! ( escaped( b"" ) , "-" ) ;
839+ // Lone dash → "\x2d"
840+ assert_eq ! ( escaped( b"-" ) , "\\ x2d" ) ;
841+ // Dash in context is fine
842+ assert_eq ! ( escaped( b"a-b" ) , "a-b" ) ;
843+ }
844+
845+ #[ test]
846+ fn test_graphic_chars_literal ( ) {
847+ // All printable graphic ASCII (0x21..=0x7E) except '\' should be literal
848+ assert_eq ! ( escaped_raw( b"!" , EscapeEquals :: No ) , "!" ) ;
849+ assert_eq ! ( escaped_raw( b"~" , EscapeEquals :: No ) , "~" ) ;
850+ assert_eq ! ( escaped_raw( b"abc/def.txt" , EscapeEquals :: No ) , "abc/def.txt" ) ;
851+ }
852+
768853 mod proptest_tests {
769854 use super :: * ;
770855 use crate :: fsverity:: Sha512HashValue ;
0 commit comments