A file-based task manager
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}