Skip to content

Commit 68ba664

Browse files
committed
Add mtree CRLF/content compatibility normalization
1 parent 74989f3 commit 68ba664

2 files changed

Lines changed: 187 additions & 1 deletion

File tree

cli/src/command/core/mtree.rs

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@ pub(crate) fn transform_mtree_entries<R: Read>(
3030
filter: &PathFilter<'_>,
3131
time_filters: &TimeFilters,
3232
) -> io::Result<Vec<io::Result<Option<NormalEntry>>>> {
33+
let normalized = normalize_mtree_input(reader)?;
3334
// Use empty cwd to avoid mtree2 joining paths with current working directory
34-
let mtree = MTree::from_reader_with_cwd(reader, PathBuf::new());
35+
let mtree = MTree::from_reader_with_cwd(io::Cursor::new(normalized), PathBuf::new());
3536
let mut results = Vec::new();
3637

3738
for entry_result in mtree {
@@ -85,6 +86,98 @@ pub(crate) fn transform_mtree_entries<R: Read>(
8586
Ok(results)
8687
}
8788

89+
/// Normalizes mtree input for bsdtar compatibility before parsing with mtree2.
90+
///
91+
/// - Converts CRLF/CR line endings to LF
92+
/// - Rewrites `content=` keyword to `contents=` (libarchive alias)
93+
fn normalize_mtree_input(mut reader: impl Read) -> io::Result<Vec<u8>> {
94+
let mut raw = Vec::new();
95+
reader.read_to_end(&mut raw)?;
96+
let normalized_line_endings = normalize_line_endings(&raw);
97+
Ok(rewrite_content_keyword_alias(&normalized_line_endings))
98+
}
99+
100+
fn normalize_line_endings(input: &[u8]) -> Vec<u8> {
101+
let mut out = Vec::with_capacity(input.len());
102+
let mut idx = 0;
103+
while idx < input.len() {
104+
if input[idx] == b'\r' {
105+
if idx + 1 < input.len() && input[idx + 1] == b'\n' {
106+
idx += 1;
107+
}
108+
out.push(b'\n');
109+
} else {
110+
out.push(input[idx]);
111+
}
112+
idx += 1;
113+
}
114+
out
115+
}
116+
117+
fn rewrite_content_keyword_alias(input: &[u8]) -> Vec<u8> {
118+
let mut out = Vec::with_capacity(input.len());
119+
let mut line_start = 0;
120+
while line_start < input.len() {
121+
let mut line_end = line_start;
122+
while line_end < input.len() && input[line_end] != b'\n' {
123+
line_end += 1;
124+
}
125+
126+
rewrite_content_keyword_line(&input[line_start..line_end], &mut out);
127+
if line_end < input.len() {
128+
out.push(b'\n');
129+
line_end += 1;
130+
}
131+
line_start = line_end;
132+
}
133+
out
134+
}
135+
136+
fn rewrite_content_keyword_line(line: &[u8], out: &mut Vec<u8>) {
137+
if line.is_empty() || line[0] == b'#' {
138+
out.extend_from_slice(line);
139+
return;
140+
}
141+
142+
let mut idx = 0;
143+
while idx < line.len() && is_mtree_whitespace(line[idx]) {
144+
idx += 1;
145+
}
146+
while idx < line.len() && !is_mtree_whitespace(line[idx]) {
147+
idx += 1;
148+
}
149+
out.extend_from_slice(&line[..idx]);
150+
151+
while idx < line.len() {
152+
let ws_start = idx;
153+
while idx < line.len() && is_mtree_whitespace(line[idx]) {
154+
idx += 1;
155+
}
156+
out.extend_from_slice(&line[ws_start..idx]);
157+
158+
let token_start = idx;
159+
while idx < line.len() && !is_mtree_whitespace(line[idx]) {
160+
idx += 1;
161+
}
162+
if token_start == idx {
163+
break;
164+
}
165+
166+
let token = &line[token_start..idx];
167+
if token.starts_with(b"content=") {
168+
out.extend_from_slice(b"contents=");
169+
out.extend_from_slice(&token[b"content=".len()..]);
170+
} else {
171+
out.extend_from_slice(token);
172+
}
173+
}
174+
}
175+
176+
#[inline]
177+
fn is_mtree_whitespace(byte: u8) -> bool {
178+
matches!(byte, b' ' | b'\t')
179+
}
180+
88181
/// Creates a single archive entry from an mtree entry.
89182
fn create_entry_from_mtree(
90183
mtree_entry: &MtreeEntry,
@@ -434,4 +527,32 @@ mod tests {
434527
assert_eq!(entry2.path().to_str(), Some("file2.txt"));
435528
assert!(entry2.optional(), "file2.txt should be marked as optional");
436529
}
530+
531+
#[test]
532+
fn normalize_mtree_input_converts_crlf_and_content_alias() {
533+
let input = b"#mtree\r\nf type=file content=bar/foo\r\n";
534+
let normalized = normalize_mtree_input(&input[..]).unwrap();
535+
assert_eq!(normalized, b"#mtree\nf type=file contents=bar/foo\n");
536+
}
537+
538+
#[test]
539+
fn normalize_mtree_input_keeps_existing_contents_keyword() {
540+
let input = b"#mtree\nf type=file contents=bar/foo\n";
541+
let normalized = normalize_mtree_input(&input[..]).unwrap();
542+
assert_eq!(normalized, input);
543+
}
544+
545+
#[test]
546+
fn normalize_mtree_input_preserves_first_token() {
547+
let input = b"#mtree\ncontent=file type=file contents=bar/foo\n";
548+
let normalized = normalize_mtree_input(&input[..]).unwrap();
549+
assert_eq!(normalized, input);
550+
}
551+
552+
#[test]
553+
fn normalize_mtree_input_handles_wrapped_crlf_line() {
554+
let input = b"#mtree\r\nf uname=\\\r\nroot content=bar/foo\r\n";
555+
let normalized = normalize_mtree_input(&input[..]).unwrap();
556+
assert_eq!(normalized, b"#mtree\nf uname=\\\nroot contents=bar/foo\n");
557+
}
437558
}

cli/tests/cli/stdio/mtree.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,71 @@ fn stdio_mtree_contents_keyword() {
157157
assert_eq!(entry_names.len(), 1);
158158
}
159159

160+
/// Precondition: An mtree manifest uses CRLF line endings, wrapped lines, and `content=` alias.
161+
/// Action: Create and extract archive from the mtree manifest.
162+
/// Expectation: Parsing succeeds and entries are created with expected payloads.
163+
#[test]
164+
fn stdio_mtree_crlf_wrapped_and_content_alias() {
165+
setup();
166+
167+
let base = PathBuf::from("stdio_mtree_crlf_wrapped_and_content_alias");
168+
fs::create_dir_all(base.join("bar")).unwrap();
169+
fs::write(base.join("bar/foo"), "abc").unwrap();
170+
fs::write(base.join("bar/goo"), "xyz").unwrap();
171+
172+
fs::write(
173+
base.join("manifest.mtree"),
174+
"#mtree\r\nf type=file uname=\\\r\nroot gname=root mode=0755 content=bar/foo\r\ng type=file uname=root gname=root mode=0755 content=bar/goo\r\n",
175+
)
176+
.unwrap();
177+
178+
let output_archive = base.join("output.pna");
179+
cargo_bin_cmd!("pna")
180+
.args([
181+
"--quiet",
182+
"experimental",
183+
"stdio",
184+
"--create",
185+
"--unstable",
186+
"--overwrite",
187+
"-f",
188+
output_archive.to_str().unwrap(),
189+
"-C",
190+
base.to_str().unwrap(),
191+
"@manifest.mtree",
192+
])
193+
.assert()
194+
.success();
195+
196+
let entry_names: HashSet<String> = get_archive_entry_names(&output_archive)
197+
.into_iter()
198+
.collect();
199+
assert!(entry_names.contains("f"), "Missing f");
200+
assert!(entry_names.contains("g"), "Missing g");
201+
assert_eq!(entry_names.len(), 2);
202+
203+
let out_dir = base.join("out");
204+
fs::create_dir_all(&out_dir).unwrap();
205+
cargo_bin_cmd!("pna")
206+
.args([
207+
"--quiet",
208+
"experimental",
209+
"stdio",
210+
"--extract",
211+
"--unstable",
212+
"--overwrite",
213+
"-f",
214+
output_archive.to_str().unwrap(),
215+
"--out-dir",
216+
out_dir.to_str().unwrap(),
217+
])
218+
.assert()
219+
.success();
220+
221+
assert_eq!(fs::read(out_dir.join("f")).unwrap(), b"abc");
222+
assert_eq!(fs::read(out_dir.join("g")).unwrap(), b"xyz");
223+
}
224+
160225
/// Precondition: An mtree manifest specifies directory and file entries.
161226
/// Action: Create archive from the mtree manifest.
162227
/// Expectation: The archive contains both directory and file entries.

0 commit comments

Comments
 (0)