A file-based task manager
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

mbox From-mangling on export, unmangle on import

Strict mbox readers split messages on lines starting with `From `.
patch::write_entry was emitting commit messages and file content
verbatim, so a body containing `From the desk of...` could be
mis-split by procmail/mailx even though tsk's own importer didn't
care.

Apply standard mboxrd From-mangling: any line matching `^>*From `
gets one extra `>` on export; the inverse runs on import. `size:`
headers count post-mangling bytes so the importer reads the right
length verbatim before unmangling.

Test exports a task with two `From ` lines in its body and asserts
zero interior `\nFrom ` boundaries in the resulting mbox.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+91 -8
+91 -8
src/patch.rs
··· 20 20 const TREE_DELIM: &str = "---tsk-tree---"; 21 21 const END_DELIM: &str = "---end---"; 22 22 23 + /// Standard mbox `From `-mangling: any line matching `^>*From ` gets one 24 + /// extra `>` on export so a strict mbox reader can't mistake it for an 25 + /// entry separator. Inverse on import. 26 + fn mangle_from(s: &str) -> String { 27 + let mut out = String::with_capacity(s.len() + 8); 28 + for line in s.split_inclusive('\n') { 29 + let arrows = line.bytes().take_while(|b| *b == b'>').count(); 30 + if line.len() >= arrows + 5 && &line.as_bytes()[arrows..arrows + 5] == b"From " { 31 + out.push('>'); 32 + } 33 + out.push_str(line); 34 + } 35 + out 36 + } 37 + 38 + fn unmangle_from(s: &str) -> String { 39 + let mut out = String::with_capacity(s.len()); 40 + for line in s.split_inclusive('\n') { 41 + let arrows = line.bytes().take_while(|b| *b == b'>').count(); 42 + if arrows >= 1 43 + && line.len() >= arrows + 5 44 + && &line.as_bytes()[arrows..arrows + 5] == b"From " 45 + { 46 + out.push_str(&line[1..]); 47 + } else { 48 + out.push_str(line); 49 + } 50 + } 51 + out 52 + } 53 + 23 54 pub struct ExportOpts { 24 55 /// If set, embed `X-Tsk-Namespace: <ns>-<human>` on the root entry so 25 56 /// the recipient can opt in to binding the task into their namespace. ··· 115 146 writeln!(out, "X-Tsk-Namespace: {ns}-{human}").unwrap(); 116 147 } 117 148 writeln!(out).unwrap(); 118 - out.push_str(message); 119 - if !message.ends_with('\n') { 149 + let mangled_msg = mangle_from(message); 150 + out.push_str(&mangled_msg); 151 + if !mangled_msg.ends_with('\n') { 120 152 out.push('\n'); 121 153 } 122 154 writeln!(out).unwrap(); ··· 131 163 } 132 164 let blob = entry.to_object(repo)?.peel_to_blob()?; 133 165 let bytes = blob.content(); 166 + let as_str = 167 + std::str::from_utf8(bytes).map_err(|e| Error::Parse(e.to_string()))?; 168 + let mangled = mangle_from(as_str); 134 169 writeln!(out, "file: {name}").unwrap(); 135 - writeln!(out, "size: {}", bytes.len()).unwrap(); 136 - // Bytes verbatim. They may contain newlines or arbitrary text; size 137 - // is the authoritative delimiter. 138 - out.push_str(std::str::from_utf8(bytes).map_err(|e| Error::Parse(e.to_string()))?); 170 + writeln!(out, "size: {}", mangled.len()).unwrap(); 171 + // Mangled bytes; size counts post-mangling. Importer reads `size` 172 + // bytes verbatim then runs the inverse unmangle. 173 + out.push_str(&mangled); 139 174 out.push('\n'); 140 175 } 141 176 writeln!(out, "{END_DELIM}").unwrap(); ··· 327 362 let message = if message.trim().is_empty() { 328 363 subject 329 364 } else { 330 - message.trim_end_matches('\n').to_string() 365 + unmangle_from(message.trim_end_matches('\n')) 331 366 }; 332 367 // Parse file blocks until END_DELIM. 333 368 let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new(); ··· 366 401 if rest.len() < size + 1 { 367 402 return Err(Error::Parse("truncated file body".into())); 368 403 } 369 - let bytes = rest[..size].to_vec(); 404 + let mangled = std::str::from_utf8(&rest[..size]) 405 + .map_err(|e| Error::Parse(e.to_string()))?; 406 + let bytes = unmangle_from(mangled).into_bytes(); 370 407 // Trailing \n separator (not part of size). 371 408 if rest[size] != b'\n' { 372 409 return Err(Error::Parse("missing newline after file body".into())); ··· 474 511 msg.contains("stable id verification failed"), 475 512 "expected verification error, got: {msg}" 476 513 ); 514 + } 515 + 516 + #[test] 517 + fn from_mangling_round_trip() { 518 + // Content has both a bare `From ` line and an already-quoted one. 519 + let s = "preamble\nFrom the desk of...\n>From me\nbody\n"; 520 + let mangled = mangle_from(s); 521 + assert_eq!( 522 + mangled, 523 + "preamble\n>From the desk of...\n>>From me\nbody\n", 524 + "every ^>*From line gets one extra '>'", 525 + ); 526 + assert_eq!(unmangle_from(&mangled), s); 527 + } 528 + 529 + #[test] 530 + fn export_survives_strict_mbox_split() { 531 + // A naive mbox parser splits entries on `\n` followed by `From `. 532 + // Confirm the export only contains exactly one such boundary per 533 + // commit, no matter what's in the body. 534 + let dir = tempfile::tempdir().unwrap(); 535 + let src = init_repo(dir.path()); 536 + let stable = object::create( 537 + &src, 538 + &Task::new("evil\n\nFrom the desk of darth vader\nFrom another rogue line"), 539 + "create", 540 + ) 541 + .unwrap(); 542 + let mbox = export_task(&src, &stable, &ExportOpts { bind: None }).unwrap(); 543 + 544 + // Count `\nFrom ` occurrences (real entry boundaries). For our 545 + // single-commit export there should be exactly zero (the `From ` 546 + // separator at the very start of the mbox isn't preceded by `\n`). 547 + let interior_boundaries = mbox.match_indices("\nFrom ").count(); 548 + assert_eq!( 549 + interior_boundaries, 0, 550 + "no spurious entry boundaries should appear in the body: {mbox}" 551 + ); 552 + 553 + // And the round-trip still reconstructs the original content. 554 + let dst_dir = tempfile::tempdir().unwrap(); 555 + let dst = init_repo(dst_dir.path()); 556 + let res = import_task(&dst, &mbox).unwrap(); 557 + let task = object::read(&dst, &res.stable).unwrap().unwrap(); 558 + assert!(task.content.contains("From the desk")); 559 + assert!(task.content.contains("From another rogue")); 477 560 } 478 561 479 562 #[test]