Skip to content

Commit 18fff70

Browse files
committed
dumpfile: Use named escapes and only escape '=' in xattr fields
Increase alignment for dumpfile generation with the composefs C implementation - on general principle but also motivated by the goal of reimplementing it in Rust here. The C composefs implementation uses named escapes for backslash, newline, carriage return, and tab (\\ \n \r \t), while our writer was hex-escaping them uniformly (\x5c \x0a etc). Both forms parse correctly, but byte-identical output matters for cross-implementation comparison. Similarly, C only escapes '=' in xattr key/value fields (where it separates key from value). We were escaping it as \x3d in all fields including paths and content, where '=' is a normal graphic character. Assisted-by: OpenCode (Claude Opus 4) Signed-off-by: Colin Walters <walters@verbum.org>
1 parent 973b42b commit 18fff70

1 file changed

Lines changed: 105 additions & 20 deletions

File tree

crates/composefs/src/dumpfile.rs

Lines changed: 105 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -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\nb", 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

Comments
 (0)