A file-based task manager
0
fork

Configure Feed

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

at b7f430fa061dff18bf0e0784b64de1809e1b2a2b 498 lines 18 kB view raw
1//! Mbox-format patch series for offline task transfer. 2//! 3//! Each task commit is one mbox entry (`From <sha> Mon Sep 17 00:00:00 2001` 4//! separator + RFC-822 headers + body). The body holds the commit message, 5//! a `---tsk-tree---` marker, then a length-prefixed dump of every file in 6//! the task tree, terminated by `---end---`. Length-prefix avoids any need 7//! to escape mbox `From ` lines inside file contents. 8//! 9//! Stable id is content-addressed (= SHA-1 of the root `content` blob), so 10//! `import_task` recomputes it and rejects mismatches — tampering is 11//! detectable. 12 13use crate::errors::{Error, Result}; 14use crate::object::{CONTENT_FILE, StableId, TITLE_FILE}; 15use git2::{Oid, Repository, Signature, Time}; 16use std::collections::BTreeMap; 17use std::fmt::Write as _; 18 19const MBOX_DATE: &str = "Mon Sep 17 00:00:00 2001"; 20const TREE_DELIM: &str = "---tsk-tree---"; 21const END_DELIM: &str = "---end---"; 22 23pub struct ExportOpts { 24 /// If set, embed `X-Tsk-Namespace: <ns>-<human>` on the root entry so 25 /// the recipient can opt in to binding the task into their namespace. 26 pub bind: Option<(String, u32)>, 27} 28 29pub fn export_task( 30 repo: &Repository, 31 stable: &StableId, 32 opts: &ExportOpts, 33) -> Result<String> { 34 let r = repo.find_reference(&stable.refname())?; 35 let tip = r.target().ok_or_else(|| Error::Parse("task ref empty".into()))?; 36 // Collect root → tip. 37 let mut chain: Vec<Oid> = Vec::new(); 38 let mut cur = Some(repo.find_commit(tip)?); 39 while let Some(c) = cur { 40 chain.push(c.id()); 41 cur = c.parent(0).ok(); 42 } 43 chain.reverse(); 44 let mut out = String::new(); 45 for (idx, oid) in chain.iter().enumerate() { 46 let commit = repo.find_commit(*oid)?; 47 let parent = commit.parent(0).ok().map(|p| p.id()); 48 let bind = if idx == 0 { opts.bind.as_ref() } else { None }; 49 write_entry(&mut out, repo, &commit, parent, stable, bind)?; 50 } 51 Ok(out) 52} 53 54fn fmt_git_time(t: Time) -> String { 55 let off = t.offset_minutes(); 56 let sign = if off >= 0 { '+' } else { '-' }; 57 let off = off.abs(); 58 format!("{} {}{:02}{:02}", t.seconds(), sign, off / 60, off % 60) 59} 60 61fn parse_git_time(s: &str) -> Result<Time> { 62 let (secs, offset) = s 63 .split_once(' ') 64 .ok_or_else(|| Error::Parse(format!("bad date: {s}")))?; 65 let secs: i64 = secs 66 .parse() 67 .map_err(|_| Error::Parse(format!("bad date secs: {secs}")))?; 68 let (sign, rest) = offset 69 .split_at_checked(1) 70 .ok_or_else(|| Error::Parse(format!("bad offset: {offset}")))?; 71 let off_min: i32 = if rest.len() == 4 { 72 let h: i32 = rest[..2] 73 .parse() 74 .map_err(|_| Error::Parse(format!("bad offset: {offset}")))?; 75 let m: i32 = rest[2..] 76 .parse() 77 .map_err(|_| Error::Parse(format!("bad offset: {offset}")))?; 78 h * 60 + m 79 } else { 80 return Err(Error::Parse(format!("bad offset: {offset}"))); 81 }; 82 let off_min = if sign == "-" { -off_min } else { off_min }; 83 Ok(Time::new(secs, off_min)) 84} 85 86fn write_entry( 87 out: &mut String, 88 repo: &Repository, 89 commit: &git2::Commit, 90 parent: Option<Oid>, 91 stable: &StableId, 92 bind: Option<&(String, u32)>, 93) -> Result<()> { 94 let author = commit.author(); 95 let summary = commit.summary().unwrap_or(""); 96 let message = commit.message().unwrap_or(""); 97 writeln!(out, "From {} {MBOX_DATE}", commit.id()).unwrap(); 98 writeln!( 99 out, 100 "From: {} <{}>", 101 author.name().unwrap_or(""), 102 author.email().unwrap_or("") 103 ) 104 .unwrap(); 105 writeln!(out, "Date: {}", fmt_git_time(author.when())).unwrap(); 106 writeln!(out, "Subject: [PATCH tsk] {summary}").unwrap(); 107 writeln!(out, "X-Tsk-Stable-Id: {stable}").unwrap(); 108 writeln!( 109 out, 110 "X-Tsk-Parent: {}", 111 parent.map(|o| o.to_string()).unwrap_or_else(|| "none".into()) 112 ) 113 .unwrap(); 114 if let Some((ns, human)) = bind { 115 writeln!(out, "X-Tsk-Namespace: {ns}-{human}").unwrap(); 116 } 117 writeln!(out).unwrap(); 118 out.push_str(message); 119 if !message.ends_with('\n') { 120 out.push('\n'); 121 } 122 writeln!(out).unwrap(); 123 writeln!(out, "{TREE_DELIM}").unwrap(); 124 let tree = commit.tree()?; 125 // Iterate in tree order (already sorted by name). 126 for entry in tree.iter() { 127 let Some(name) = entry.name() else { continue }; 128 if name == TITLE_FILE { 129 // title is a cache; reconstructible from content. Skip. 130 continue; 131 } 132 let blob = entry.to_object(repo)?.peel_to_blob()?; 133 let bytes = blob.content(); 134 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()))?); 139 out.push('\n'); 140 } 141 writeln!(out, "{END_DELIM}").unwrap(); 142 writeln!(out).unwrap(); 143 Ok(()) 144} 145 146#[derive(Debug)] 147struct Entry { 148 author_name: String, 149 author_email: String, 150 when: Time, 151 message: String, 152 stable: String, 153 ns_bind: Option<(String, u32)>, 154 files: BTreeMap<String, Vec<u8>>, 155} 156 157#[derive(Debug)] 158pub struct ImportResult { 159 pub stable: StableId, 160 pub commits_imported: usize, 161 /// Hint from the sender: namespace + human id under which they had this 162 /// task bound. Currently unused by the workspace layer (recipient decides 163 /// binding) but parsed and exposed so future commands can honor it. 164 #[allow(dead_code)] 165 pub ns_bind: Option<(String, u32)>, 166} 167 168pub fn import_task(repo: &Repository, mbox: &str) -> Result<ImportResult> { 169 let entries = parse_mbox(mbox)?; 170 if entries.is_empty() { 171 return Err(Error::Parse("no patch entries found".into())); 172 } 173 let stable_hex = entries[0].stable.clone(); 174 let ns_bind = entries[0].ns_bind.clone(); 175 let mut prev: Option<Oid> = None; 176 for (idx, e) in entries.iter().enumerate() { 177 if e.stable != stable_hex { 178 return Err(Error::Parse(format!( 179 "stable id mismatch across entries: {} vs {}", 180 stable_hex, e.stable 181 ))); 182 } 183 // Build the tree from the file map. 184 let mut tb = repo.treebuilder(None)?; 185 let content = e 186 .files 187 .get(CONTENT_FILE) 188 .ok_or_else(|| Error::Parse("entry missing 'content' file".into()))?; 189 let content_oid = repo.blob(content)?; 190 if idx == 0 { 191 // Verify stable id == sha of root content blob. 192 if content_oid.to_string() != stable_hex { 193 return Err(Error::Parse(format!( 194 "stable id verification failed: expected {stable_hex}, content sha is {content_oid}" 195 ))); 196 } 197 } 198 tb.insert(CONTENT_FILE, content_oid, 0o100644)?; 199 // Re-derive title cache from content. 200 let title = std::str::from_utf8(content) 201 .map_err(|e| Error::Parse(e.to_string()))? 202 .lines() 203 .next() 204 .unwrap_or(""); 205 let title_oid = repo.blob(title.as_bytes())?; 206 tb.insert(TITLE_FILE, title_oid, 0o100644)?; 207 for (name, bytes) in &e.files { 208 if name == CONTENT_FILE || name == TITLE_FILE { 209 continue; 210 } 211 let oid = repo.blob(bytes)?; 212 tb.insert(name.as_str(), oid, 0o100644)?; 213 } 214 let tree_oid = tb.write()?; 215 let sig = Signature::new(&e.author_name, &e.author_email, &e.when)?; 216 let parents: Vec<git2::Commit> = prev.into_iter().map(|o| repo.find_commit(o).unwrap()).collect(); 217 let parent_refs: Vec<&git2::Commit> = parents.iter().collect(); 218 let commit_oid = repo.commit( 219 None, 220 &sig, 221 &sig, 222 &e.message, 223 &repo.find_tree(tree_oid)?, 224 &parent_refs, 225 )?; 226 prev = Some(commit_oid); 227 } 228 let stable = StableId(stable_hex); 229 repo.reference(&stable.refname(), prev.unwrap(), true, "import")?; 230 Ok(ImportResult { 231 stable, 232 commits_imported: entries.len(), 233 ns_bind, 234 }) 235} 236 237fn parse_mbox(s: &str) -> Result<Vec<Entry>> { 238 let mut entries = Vec::new(); 239 // Split on lines starting with "From " (the mbox separator). The "From " 240 // line is part of the entry it introduces. 241 let mut starts: Vec<usize> = Vec::new(); 242 let bytes = s.as_bytes(); 243 if bytes.starts_with(b"From ") { 244 starts.push(0); 245 } 246 let mut i = 0; 247 while i + 5 < bytes.len() { 248 if bytes[i] == b'\n' && &bytes[i + 1..i + 6] == b"From " { 249 starts.push(i + 1); 250 } 251 i += 1; 252 } 253 starts.push(bytes.len()); 254 for win in starts.windows(2) { 255 let chunk = &s[win[0]..win[1]]; 256 if chunk.trim().is_empty() { 257 continue; 258 } 259 entries.push(parse_entry(chunk)?); 260 } 261 Ok(entries) 262} 263 264fn parse_entry(chunk: &str) -> Result<Entry> { 265 let mut lines = chunk.split_inclusive('\n'); 266 // First line: "From <oid> Mon Sep 17 ..." 267 let _ = lines.next(); 268 let mut author_name = String::new(); 269 let mut author_email = String::new(); 270 let mut when: Option<Time> = None; 271 let mut subject = String::new(); 272 let mut stable = String::new(); 273 let mut ns_bind: Option<(String, u32)> = None; 274 // Headers until blank line. 275 for line in lines.by_ref() { 276 let trimmed = line.trim_end_matches(['\n', '\r']); 277 if trimmed.is_empty() { 278 break; 279 } 280 if let Some(v) = trimmed.strip_prefix("From: ") { 281 // "Name <email>" 282 if let Some((n, rest)) = v.split_once(" <") { 283 author_name = n.to_string(); 284 author_email = rest.trim_end_matches('>').to_string(); 285 } else { 286 author_name = v.to_string(); 287 } 288 } else if let Some(v) = trimmed.strip_prefix("Date: ") { 289 when = Some(parse_git_time(v)?); 290 } else if let Some(v) = trimmed.strip_prefix("Subject: ") { 291 subject = v.strip_prefix("[PATCH tsk] ").unwrap_or(v).to_string(); 292 } else if let Some(v) = trimmed.strip_prefix("X-Tsk-Stable-Id: ") { 293 stable = v.to_string(); 294 } else if let Some(_v) = trimmed.strip_prefix("X-Tsk-Parent: ") { 295 // Informational only; we use the previous imported commit as parent. 296 } else if let Some(v) = trimmed.strip_prefix("X-Tsk-Namespace: ") { 297 if let Some((ns, h)) = v.rsplit_once('-') 298 && let Ok(human) = h.parse::<u32>() 299 { 300 ns_bind = Some((ns.to_string(), human)); 301 } 302 } 303 } 304 if stable.is_empty() { 305 return Err(Error::Parse("missing X-Tsk-Stable-Id".into())); 306 } 307 let when = when.ok_or_else(|| Error::Parse("missing Date".into()))?; 308 // Body up to TREE_DELIM line. 309 let mut message = String::new(); 310 let mut in_tree = false; 311 for line in lines.by_ref() { 312 let trimmed = line.trim_end_matches(['\n', '\r']); 313 if trimmed == TREE_DELIM { 314 in_tree = true; 315 break; 316 } 317 message.push_str(line); 318 } 319 if !in_tree { 320 return Err(Error::Parse("missing tree delimiter".into())); 321 } 322 // Restore commit message: drop the trailing blank line we emitted. 323 while message.ends_with("\n\n") { 324 message.pop(); 325 } 326 // If the message is just the subject line (no body), use subject. 327 let message = if message.trim().is_empty() { 328 subject 329 } else { 330 message.trim_end_matches('\n').to_string() 331 }; 332 // Parse file blocks until END_DELIM. 333 let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new(); 334 // We need byte-level reads for sizes, so switch back to slice indexing. 335 // Compute remaining input from `lines`. 336 let remaining = lines.collect::<String>(); 337 let mut rest = remaining.as_bytes(); 338 loop { 339 // Read line. 340 let nl = rest 341 .iter() 342 .position(|b| *b == b'\n') 343 .ok_or_else(|| Error::Parse("unexpected eof in tree".into()))?; 344 let line = std::str::from_utf8(&rest[..nl]).map_err(|e| Error::Parse(e.to_string()))?; 345 let line_trim = line.trim_end_matches('\r'); 346 rest = &rest[nl + 1..]; 347 if line_trim == END_DELIM { 348 break; 349 } 350 let name = line_trim 351 .strip_prefix("file: ") 352 .ok_or_else(|| Error::Parse(format!("expected 'file:' got: {line_trim:?}")))? 353 .to_string(); 354 let nl = rest 355 .iter() 356 .position(|b| *b == b'\n') 357 .ok_or_else(|| Error::Parse("unexpected eof reading size".into()))?; 358 let size_line = std::str::from_utf8(&rest[..nl]).map_err(|e| Error::Parse(e.to_string()))?; 359 let size_line = size_line.trim_end_matches('\r'); 360 rest = &rest[nl + 1..]; 361 let size: usize = size_line 362 .strip_prefix("size: ") 363 .ok_or_else(|| Error::Parse(format!("expected 'size:' got: {size_line:?}")))? 364 .parse() 365 .map_err(|_| Error::Parse(format!("bad size: {size_line}")))?; 366 if rest.len() < size + 1 { 367 return Err(Error::Parse("truncated file body".into())); 368 } 369 let bytes = rest[..size].to_vec(); 370 // Trailing \n separator (not part of size). 371 if rest[size] != b'\n' { 372 return Err(Error::Parse("missing newline after file body".into())); 373 } 374 rest = &rest[size + 1..]; 375 files.insert(name, bytes); 376 } 377 Ok(Entry { 378 author_name, 379 author_email, 380 when, 381 message, 382 stable, 383 ns_bind, 384 files, 385 }) 386} 387 388#[cfg(test)] 389mod test { 390 use super::*; 391 use crate::object::{self, Task}; 392 use std::path::Path; 393 394 fn init_repo(p: &Path) -> Repository { 395 let r = Repository::init(p).unwrap(); 396 let mut cfg = r.config().unwrap(); 397 cfg.set_str("user.name", "Tester").unwrap(); 398 cfg.set_str("user.email", "t@e").unwrap(); 399 r 400 } 401 402 #[test] 403 fn round_trip_single_commit() { 404 let dir = tempfile::tempdir().unwrap(); 405 let src = init_repo(dir.path()); 406 let mut t = Task::new("Hello\n\nbody text"); 407 t.properties.insert("status".into(), vec!["open".into()]); 408 let stable = object::create(&src, &t, "create").unwrap(); 409 410 let mbox = export_task(&src, &stable, &ExportOpts { bind: None }).unwrap(); 411 assert!(mbox.starts_with("From ")); 412 assert!(mbox.contains("X-Tsk-Stable-Id:")); 413 assert!(mbox.contains(TREE_DELIM)); 414 415 let dst_dir = tempfile::tempdir().unwrap(); 416 let dst = init_repo(dst_dir.path()); 417 let res = import_task(&dst, &mbox).unwrap(); 418 assert_eq!(res.stable, stable); 419 let read_back = object::read(&dst, &res.stable).unwrap().unwrap(); 420 assert_eq!(read_back.content, t.content); 421 assert_eq!(read_back.properties, t.properties); 422 } 423 424 #[test] 425 fn round_trip_preserves_history() { 426 let dir = tempfile::tempdir().unwrap(); 427 let src = init_repo(dir.path()); 428 let t = Task::new("v1"); 429 let stable = object::create(&src, &t, "create").unwrap(); 430 let mut t2 = t.clone(); 431 t2.content = "v2".into(); 432 object::update(&src, &stable, &t2, "edit-2").unwrap(); 433 let mut t3 = t2.clone(); 434 t3.content = "v3".into(); 435 object::update(&src, &stable, &t3, "edit-3").unwrap(); 436 437 let mbox = export_task(&src, &stable, &ExportOpts { bind: None }).unwrap(); 438 439 let dst_dir = tempfile::tempdir().unwrap(); 440 let dst = init_repo(dst_dir.path()); 441 let res = import_task(&dst, &mbox).unwrap(); 442 assert_eq!(res.commits_imported, 3); 443 444 let head = dst 445 .find_reference(&res.stable.refname()) 446 .unwrap() 447 .target() 448 .unwrap(); 449 let tip = dst.find_commit(head).unwrap(); 450 assert_eq!(tip.summary().unwrap(), "edit-3"); 451 let mid = tip.parent(0).unwrap(); 452 assert_eq!(mid.summary().unwrap(), "edit-2"); 453 let root = mid.parent(0).unwrap(); 454 assert_eq!(root.summary().unwrap(), "create"); 455 } 456 457 #[test] 458 fn tamper_detected_via_stable_id_check() { 459 let dir = tempfile::tempdir().unwrap(); 460 let src = init_repo(dir.path()); 461 let stable = 462 object::create(&src, &Task::new("original content"), "create").unwrap(); 463 let mbox = export_task(&src, &stable, &ExportOpts { bind: None }).unwrap(); 464 // Flip the content body without updating the stable id header. 465 // Equal-length substitution so size-prefix parsing still aligns; only 466 // the SHA check should reject it. 467 let tampered = mbox.replace("original content", "OVERRIDDEN BYTES"); 468 assert_eq!("original content".len(), "OVERRIDDEN BYTES".len()); 469 let dst_dir = tempfile::tempdir().unwrap(); 470 let dst = init_repo(dst_dir.path()); 471 let err = import_task(&dst, &tampered).unwrap_err(); 472 let msg = format!("{err}"); 473 assert!( 474 msg.contains("stable id verification failed"), 475 "expected verification error, got: {msg}" 476 ); 477 } 478 479 #[test] 480 fn ns_bind_header_round_trips() { 481 let dir = tempfile::tempdir().unwrap(); 482 let src = init_repo(dir.path()); 483 let stable = object::create(&src, &Task::new("bind me"), "create").unwrap(); 484 let mbox = export_task( 485 &src, 486 &stable, 487 &ExportOpts { 488 bind: Some(("alpha".into(), 7)), 489 }, 490 ) 491 .unwrap(); 492 assert!(mbox.contains("X-Tsk-Namespace: alpha-7")); 493 let dst_dir = tempfile::tempdir().unwrap(); 494 let dst = init_repo(dst_dir.path()); 495 let res = import_task(&dst, &mbox).unwrap(); 496 assert_eq!(res.ns_bind, Some(("alpha".to_string(), 7))); 497 } 498}