//! Mbox-format patch series for offline task transfer. //! //! Each task commit is one mbox entry (`From Mon Sep 17 00:00:00 2001` //! separator + RFC-822 headers + body). The body holds the commit message, //! a `---tsk-tree---` marker, then a length-prefixed dump of every file in //! the task tree, terminated by `---end---`. Length-prefix avoids any need //! to escape mbox `From ` lines inside file contents. //! //! Stable id is content-addressed (= SHA-1 of the root `content` blob), so //! `import_task` recomputes it and rejects mismatches — tampering is //! detectable. use crate::errors::{Error, Result}; use crate::object::{CONTENT_FILE, StableId, TITLE_FILE}; use git2::{Oid, Repository, Signature, Time}; use std::collections::BTreeMap; use std::fmt::Write as _; const MBOX_DATE: &str = "Mon Sep 17 00:00:00 2001"; const TREE_DELIM: &str = "---tsk-tree---"; const END_DELIM: &str = "---end---"; /// Standard mbox `From `-mangling: any line matching `^>*From ` gets one /// extra `>` on export so a strict mbox reader can't mistake it for an /// entry separator. Inverse on import. fn mangle_from(s: &str) -> String { let mut out = String::with_capacity(s.len() + 8); for line in s.split_inclusive('\n') { let arrows = line.bytes().take_while(|b| *b == b'>').count(); if line.len() >= arrows + 5 && &line.as_bytes()[arrows..arrows + 5] == b"From " { out.push('>'); } out.push_str(line); } out } fn unmangle_from(s: &str) -> String { let mut out = String::with_capacity(s.len()); for line in s.split_inclusive('\n') { let arrows = line.bytes().take_while(|b| *b == b'>').count(); if arrows >= 1 && line.len() >= arrows + 5 && &line.as_bytes()[arrows..arrows + 5] == b"From " { out.push_str(&line[1..]); } else { out.push_str(line); } } out } pub struct ExportOpts { /// If set, embed `X-Tsk-Namespace: -` on the root entry so /// the recipient can opt in to binding the task into their namespace. pub bind: Option<(String, u32)>, } pub fn export_task( repo: &Repository, stable: &StableId, opts: &ExportOpts, ) -> Result { let r = repo.find_reference(&stable.refname())?; let tip = r.target().ok_or_else(|| Error::Parse("task ref empty".into()))?; // Collect root → tip. let mut chain: Vec = Vec::new(); let mut cur = Some(repo.find_commit(tip)?); while let Some(c) = cur { chain.push(c.id()); cur = c.parent(0).ok(); } chain.reverse(); let mut out = String::new(); for (idx, oid) in chain.iter().enumerate() { let commit = repo.find_commit(*oid)?; let parent = commit.parent(0).ok().map(|p| p.id()); let bind = if idx == 0 { opts.bind.as_ref() } else { None }; write_entry(&mut out, repo, &commit, parent, stable, bind)?; } Ok(out) } fn fmt_git_time(t: Time) -> String { let off = t.offset_minutes(); let sign = if off >= 0 { '+' } else { '-' }; let off = off.abs(); format!("{} {}{:02}{:02}", t.seconds(), sign, off / 60, off % 60) } fn parse_git_time(s: &str) -> Result