1use std::collections::{BTreeMap, BTreeSet};
16use std::path::{Path, PathBuf};
17
18use serde::{Deserialize, Serialize};
19
20use crate::config::Layout;
21use crate::error::{Error, Result};
22use crate::views::IssueRec;
23
24pub const VERSION: &str = "vissue-satchel/1";
27
28#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(default)]
31pub struct Slice {
32 pub projects: Vec<String>,
34 pub issues: Vec<String>,
36}
37
38impl Slice {
39 #[must_use]
41 pub fn is_empty(&self) -> bool {
42 self.projects.is_empty() && self.issues.is_empty()
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct Satchel {
49 pub version: String,
51 pub asked: Slice,
53 pub issues: Vec<String>,
55 pub carried: Vec<String>,
57 pub needs: Vec<String>,
59 pub packed_by: String,
61 pub packed_at: String,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Report {
68 pub issues: usize,
70 pub needs: usize,
72 pub files: usize,
74 pub notes: Vec<String>,
76}
77
78impl Report {
79 #[must_use]
81 pub fn render(&self) -> String {
82 let mut out = format!(
83 "issues={} deeds={} files={}\n",
84 self.issues, self.needs, self.files
85 );
86 for note in &self.notes {
87 out.push_str(note);
88 out.push('\n');
89 }
90 out
91 }
92}
93
94pub fn pack(layout: &Layout, asked: &Slice, dest: &Path) -> Result<Report> {
102 if asked.is_empty() {
103 return Err(Error::Other(anyhow::anyhow!(
104 "a satchel needs a project or an issue to pack"
105 )));
106 }
107 let recs = crate::catalog::load_recs(layout)?;
108 let by_id: BTreeMap<&str, &IssueRec> =
109 recs.iter().map(|r| (r.heading.id.as_str(), r)).collect();
110
111 let mut named: BTreeSet<String> = BTreeSet::new();
113 for project in &asked.projects {
114 for rec in &recs {
115 if rec.project == *project {
116 named.insert(rec.heading.id.clone());
117 }
118 }
119 }
120 for id in &asked.issues {
121 if !by_id.contains_key(id.as_str()) {
122 return Err(Error::IssueNotFound { id: id.clone() });
123 }
124 named.insert(id.clone());
125 }
126 if named.is_empty() {
127 return Err(Error::Other(anyhow::anyhow!(
128 "nothing to pack: {:?} matched no issue",
129 asked.projects
130 )));
131 }
132
133 let mut chosen = named.clone();
135 let mut frontier: Vec<String> = named.iter().cloned().collect();
136 while let Some(id) = frontier.pop() {
137 let Some(rec) = by_id.get(id.as_str()) else {
138 continue;
139 };
140 let mut reach: Vec<String> = rec.heading.blocked_by();
141 if let Some(parent) = rec.heading.parent().map(str::to_string) {
142 reach.push(parent);
143 }
144 for next in reach {
145 if by_id.contains_key(next.as_str()) && chosen.insert(next.clone()) {
146 frontier.push(next);
147 }
148 }
149 }
150
151 let carried: Vec<String> = chosen.difference(&named).cloned().collect();
152 let mut needs: BTreeSet<String> = BTreeSet::new();
153 for id in &chosen {
154 if let Some(rec) = by_id.get(id.as_str()) {
155 needs.extend(rec.heading.deeds());
156 }
157 }
158
159 let data = dest.join("data");
161 let issues_dir = data.join("issues");
162 std::fs::create_dir_all(&issues_dir).map_err(Error::from)?;
163 let mut payload: Vec<(PathBuf, String)> = Vec::new();
164 for id in &chosen {
165 let Some(rec) = by_id.get(id.as_str()) else {
166 continue;
167 };
168 let text = crate::catalog::org_text_from(rec)?;
169 let rel = PathBuf::from("data/issues").join(format!("{id}.org"));
170 write_payload(dest, &rel, text.as_bytes())?;
171 payload.push((rel, digest(text.as_bytes())));
172 }
173
174 let satchel = Satchel {
175 version: VERSION.to_string(),
176 asked: asked.clone(),
177 issues: chosen.iter().cloned().collect(),
178 carried,
179 needs: needs.iter().cloned().collect(),
180 packed_by: crate::config::identity(layout),
181 packed_at: crate::model::today_inactive_bracket(),
182 };
183 let described = serde_json::to_string_pretty(&satchel)
184 .map(|json| json + "\n")
185 .map_err(|e| Error::Other(anyhow::anyhow!("{e}")))?;
186 let rel = PathBuf::from("data/satchel.json");
187 write_payload(dest, &rel, described.as_bytes())?;
188 payload.push((rel, digest(described.as_bytes())));
189
190 payload.sort();
191 write_manifest(dest, &payload)?;
192 write_declaration(dest, &satchel)?;
193
194 let mut notes = Vec::new();
195 if !satchel.needs.is_empty() {
196 notes.push(format!(
197 "{} deed accessions are named and not enclosed; the deed store exports them",
198 satchel.needs.len()
199 ));
200 }
201 if !satchel.carried.is_empty() {
202 notes.push(format!(
203 "{} issues came along as blockers or parents of what was asked for",
204 satchel.carried.len()
205 ));
206 }
207 Ok(Report {
208 issues: satchel.issues.len(),
209 needs: satchel.needs.len(),
210 files: payload.len(),
211 notes,
212 })
213}
214
215pub fn seal(dir: &Path) -> Result<Report> {
222 let satchel = describe(dir)?;
223 let mut payload: Vec<(PathBuf, String)> = Vec::new();
224 for found in walk(&dir.join("data"))? {
225 let rel = found
226 .strip_prefix(dir)
227 .map_err(|e| Error::Other(anyhow::anyhow!("{e}")))?
228 .to_path_buf();
229 let bytes = std::fs::read(&found).map_err(Error::from)?;
230 payload.push((rel, digest(&bytes)));
231 }
232 payload.sort();
233 write_manifest(dir, &payload)?;
234
235 let mut notes = shortfall(dir, &satchel);
236 let atoms = atom_lines(dir);
237 if atoms > 0 {
238 notes.push(format!("{atoms} atoms arrived from a pack"));
239 }
240 Ok(Report {
241 issues: satchel.issues.len(),
242 needs: satchel.needs.len(),
243 files: payload.len(),
244 notes,
245 })
246}
247
248fn shortfall(dir: &Path, satchel: &Satchel) -> Vec<String> {
250 let enclosed = enclosed_deeds(dir);
251 let mut out = Vec::new();
252 let short = satchel
253 .needs
254 .iter()
255 .filter(|acc| !enclosed.contains(*acc))
256 .count();
257 if short > 0 {
258 out.push(format!(
259 "{short} of {} deed accessions are named and not enclosed",
260 satchel.needs.len()
261 ));
262 }
263 out.extend(provenance_note(dir, &enclosed));
264 out
265}
266
267fn signature_note(dir: &Path) -> String {
270 let manifest = dir.join("manifest-sha256.txt");
271 let signature = manifest.with_extension("txt.sig");
272 if signature.is_file() {
273 format!(
274 "the payload matches the manifest, and the manifest carries a signature this check \
275 did not verify: `deedar vouch check {}` says whether a key you accept made it",
276 manifest.display()
277 )
278 } else {
279 "the payload matches the manifest, and the manifest is unsigned, so this establishes \
280 that the bag arrived as written and nothing about who wrote it"
281 .to_string()
282 }
283}
284
285fn atom_lines(dir: &Path) -> usize {
288 let Ok(entries) = std::fs::read_dir(dir.join("data").join("atoms")) else {
289 return 0;
290 };
291 entries
292 .flatten()
293 .filter_map(|entry| std::fs::read_to_string(entry.path()).ok())
294 .map(|text| text.lines().filter(|l| !l.trim().is_empty()).count())
295 .sum()
296}
297
298fn provenance_note(dir: &Path, enclosed: &BTreeSet<String>) -> Option<String> {
301 if enclosed.is_empty() {
302 return None;
303 }
304 let deeds = dir.join("data").join("deeds");
305 let bare: Vec<&String> = enclosed
306 .iter()
307 .filter(|acc| !deeds.join(acc).join("proof.txt").is_file())
308 .collect();
309 if bare.is_empty() {
310 return Some(format!(
311 "{} deeds arrived carrying a proof this check does not read: \
312 `deedar check {}` says whether the sender's log held them before \
313 the handover",
314 enclosed.len(),
315 dir.display()
316 ));
317 }
318 Some(format!(
319 "{} of {} enclosed deeds carry no proof, so nothing says they were \
320 logged before they were handed over: {}",
321 bare.len(),
322 enclosed.len(),
323 bare.iter()
324 .map(|acc| acc.as_str())
325 .collect::<Vec<_>>()
326 .join(", ")
327 ))
328}
329
330fn enclosed_deeds(dir: &Path) -> BTreeSet<String> {
332 let mut out = BTreeSet::new();
333 let Ok(entries) = std::fs::read_dir(dir.join("data").join("deeds")) else {
334 return out;
335 };
336 for entry in entries.flatten() {
337 if entry.path().is_dir()
338 && let Some(name) = entry.file_name().to_str()
339 {
340 out.insert(name.to_string());
341 }
342 }
343 out
344}
345
346pub fn verify(dir: &Path) -> Result<Report> {
353 let manifest = dir.join("manifest-sha256.txt");
354 let text = std::fs::read_to_string(&manifest)
355 .map_err(|_| Error::Other(anyhow::anyhow!("no manifest at {}", manifest.display())))?;
356 let mut listed: BTreeMap<PathBuf, String> = BTreeMap::new();
357 for line in text.lines().filter(|l| !l.trim().is_empty()) {
358 let Some((hash, rel)) = line.split_once(" ") else {
359 return Err(Error::Other(anyhow::anyhow!(
360 "manifest: not an entry: {line:?}"
361 )));
362 };
363 listed.insert(PathBuf::from(rel), hash.to_string());
364 }
365
366 let mut notes = Vec::new();
367 for (rel, want) in &listed {
368 let path = dir.join(rel);
369 let Ok(bytes) = std::fs::read(&path) else {
370 notes.push(format!("missing {}", rel.display()));
371 continue;
372 };
373 let got = digest(&bytes);
374 if got != *want {
375 notes.push(format!("changed {}", rel.display()));
376 }
377 }
378 for found in walk(&dir.join("data"))? {
379 let rel = found
380 .strip_prefix(dir)
381 .map_err(|e| Error::Other(anyhow::anyhow!("{e}")))?
382 .to_path_buf();
383 if !listed.contains_key(&rel) {
384 notes.push(format!("unlisted {}", rel.display()));
385 }
386 }
387
388 let described = dir.join("data").join("satchel.json");
389 let satchel: Satchel = std::fs::read_to_string(&described)
390 .map_err(|_| Error::Other(anyhow::anyhow!("no data/satchel.json")))
391 .and_then(|raw| serde_json::from_str(&raw).map_err(Error::from))?;
392 if satchel.version != VERSION {
393 notes.push(format!(
394 "packed as {} and read as {VERSION}",
395 satchel.version
396 ));
397 }
398
399 if notes.is_empty() {
400 let mut notes = shortfall(dir, &satchel);
404 let atoms = atom_lines(dir);
405 if atoms > 0 {
406 notes.push(format!("{atoms} atoms arrived from a pack"));
407 }
408 notes.push(signature_note(dir));
409 Ok(Report {
410 issues: satchel.issues.len(),
411 needs: satchel.needs.len(),
412 files: listed.len(),
413 notes,
414 })
415 } else {
416 Err(Error::Other(anyhow::anyhow!(
417 "the satchel does not check out:\n{}",
418 notes.join("\n")
419 )))
420 }
421}
422
423pub fn describe(dir: &Path) -> Result<Satchel> {
429 let path = dir.join("data").join("satchel.json");
430 let raw = std::fs::read_to_string(&path)
431 .map_err(|_| Error::Other(anyhow::anyhow!("no satchel at {}", path.display())))?;
432 serde_json::from_str(&raw).map_err(Error::from)
433}
434
435fn write_payload(dest: &Path, rel: &Path, bytes: &[u8]) -> Result<()> {
436 let path = dest.join(rel);
437 if let Some(parent) = path.parent() {
438 std::fs::create_dir_all(parent).map_err(Error::from)?;
439 }
440 std::fs::write(&path, bytes).map_err(Error::from)
441}
442
443fn write_manifest(dest: &Path, payload: &[(PathBuf, String)]) -> Result<()> {
444 let mut out = String::new();
445 for (rel, hash) in payload {
446 out.push_str(&format!("{hash} {}\n", rel.display()));
449 }
450 std::fs::write(dest.join("manifest-sha256.txt"), out).map_err(Error::from)
451}
452
453fn write_declaration(dest: &Path, satchel: &Satchel) -> Result<()> {
454 std::fs::write(
455 dest.join("bagit.txt"),
456 "BagIt-Version: 1.0\nTag-File-Character-Encoding: UTF-8\n",
457 )
458 .map_err(Error::from)?;
459 let info = format!(
460 "Bag-Software-Agent: vissue {}\nBagging-Date: {}\nSource-Organization: {}\nExternal-Description: {} issues and {} deed accessions from a vissue tracker\nInternal-Sender-Identifier: {}\n",
461 env!("CARGO_PKG_VERSION"),
462 satchel.packed_at,
463 satchel.packed_by,
464 satchel.issues.len(),
465 satchel.needs.len(),
466 VERSION,
467 );
468 std::fs::write(dest.join("bag-info.txt"), info).map_err(Error::from)
469}
470
471fn walk(dir: &Path) -> Result<Vec<PathBuf>> {
472 let mut out = Vec::new();
473 if !dir.is_dir() {
474 return Ok(out);
475 }
476 let mut stack = vec![dir.to_path_buf()];
477 while let Some(at) = stack.pop() {
478 for entry in std::fs::read_dir(&at).map_err(Error::from)? {
479 let entry = entry.map_err(Error::from)?;
480 let path = entry.path();
481 if path.is_dir() {
482 stack.push(path);
483 } else {
484 out.push(path);
485 }
486 }
487 }
488 out.sort();
489 Ok(out)
490}
491
492fn digest(bytes: &[u8]) -> String {
493 use sha2::{Digest, Sha256};
494 let hash = Sha256::digest(bytes);
495 hash.iter().map(|b| format!("{b:02x}")).collect()
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501 use crate::config::DEFAULT_PREFIX;
502 use crate::ops::{self, CreateOpts};
503
504 fn made(layout: &Layout, title: &str, opts: CreateOpts<'_>) -> String {
506 let report = ops::create(layout, "sample", title, opts).expect("create");
507 report
508 .split_whitespace()
509 .next()
510 .expect("an id in the report")
511 .to_string()
512 }
513
514 fn tracker() -> (tempfile::TempDir, Layout) {
515 let dir = tempfile::tempdir().expect("tempdir");
516 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
517 std::fs::create_dir_all(layout.projects_dir()).expect("projects");
518 (dir, layout)
519 }
520
521 #[test]
524 fn a_named_issue_brings_its_blockers_and_its_plan() {
525 let (_dir, layout) = tracker();
526 let plan = made(&layout, "the plan", CreateOpts::default());
527 let blocker = made(&layout, "the blocker", CreateOpts::default());
528 let work = made(
529 &layout,
530 "the work",
531 CreateOpts {
532 parent: Some(plan.as_str()),
533 ..CreateOpts::default()
534 },
535 );
536 ops::update(&layout, &work, None, None, Some(&blocker), None).expect("blocked by");
537 let bystander = made(&layout, "not asked for", CreateOpts::default());
538
539 let out = tempfile::tempdir().expect("out");
540 let report = pack(
541 &layout,
542 &Slice {
543 projects: Vec::new(),
544 issues: vec![work.clone()],
545 },
546 out.path(),
547 )
548 .expect("packs");
549
550 let described = describe(out.path()).expect("describes");
551 assert!(described.issues.contains(&work), "{described:?}");
552 assert!(described.issues.contains(&plan), "the plan is missing");
553 assert!(
554 described.issues.contains(&blocker),
555 "the blocker is missing"
556 );
557 assert!(
558 !described.issues.contains(&bystander),
559 "a sibling came along uninvited"
560 );
561 assert_eq!(described.carried.len(), 2, "{:?}", described.carried);
563 assert_eq!(report.issues, 3);
564 assert!(
565 out.path()
566 .join("data/issues")
567 .join(format!("{work}.org"))
568 .is_file()
569 );
570 }
571
572 #[test]
575 fn a_satchel_checks_out_until_something_moves() {
576 let (_dir, layout) = tracker();
577 let one = made(&layout, "first", CreateOpts::default());
578 let out = tempfile::tempdir().expect("out");
579 pack(
580 &layout,
581 &Slice {
582 projects: vec!["sample".into()],
583 issues: Vec::new(),
584 },
585 out.path(),
586 )
587 .expect("packs");
588
589 verify(out.path()).expect("a fresh satchel checks out");
590
591 let issue = out.path().join("data/issues").join(format!("{one}.org"));
593 std::fs::write(&issue, "* TODO something else\n").expect("write");
594 let err = verify(out.path()).expect_err("an edited payload passed");
595 assert!(format!("{err}").contains("changed"), "{err}");
596 }
597
598 #[test]
602 fn a_file_nobody_listed_is_a_finding() {
603 let (_dir, layout) = tracker();
604 made(&layout, "first", CreateOpts::default());
605 let out = tempfile::tempdir().expect("out");
606 pack(
607 &layout,
608 &Slice {
609 projects: vec!["sample".into()],
610 issues: Vec::new(),
611 },
612 out.path(),
613 )
614 .expect("packs");
615 verify(out.path()).expect("checks out");
616
617 std::fs::write(out.path().join("data/extra.sh"), "rm -rf /\n").expect("write");
618 let err = verify(out.path()).expect_err("an unlisted file passed");
619 assert!(format!("{err}").contains("unlisted"), "{err}");
620 }
621
622 #[test]
625 fn sealing_accounts_for_what_arrived_after_packing() {
626 let (_dir, layout) = tracker();
627 made(&layout, "first", CreateOpts::default());
628 let out = tempfile::tempdir().expect("out");
629 pack(
630 &layout,
631 &Slice {
632 projects: vec!["sample".into()],
633 issues: Vec::new(),
634 },
635 out.path(),
636 )
637 .expect("packs");
638
639 let deeds = out.path().join("data/deeds/deed-file-note");
641 std::fs::create_dir_all(&deeds).expect("mkdir");
642 std::fs::write(deeds.join("deed.bin"), b"deed bytes").expect("write");
643
644 let err = verify(out.path()).expect_err("an unsealed addition passed");
647 assert!(format!("{err}").contains("unlisted"), "{err}");
648
649 let report = seal(out.path()).expect("seals");
650 assert!(report.files >= 3, "{report:?}");
651 verify(out.path()).expect("a sealed satchel checks out");
652 }
653
654 #[test]
657 fn a_pack_can_put_what_the_seat_learned_in_too() {
658 let (_dir, layout) = tracker();
659 made(&layout, "first", CreateOpts::default());
660 let out = tempfile::tempdir().expect("out");
661 pack(
662 &layout,
663 &Slice {
664 projects: vec!["sample".into()],
665 issues: Vec::new(),
666 },
667 out.path(),
668 )
669 .expect("packs");
670
671 let atoms = out.path().join("data/atoms");
673 std::fs::create_dir_all(&atoms).expect("mkdir");
674 std::fs::write(
675 atoms.join("seat.jsonl"),
676 "{\"id\":\"a1\",\"text\":\"what was learned\"}\n {\"id\":\"a2\",\"text\":\"and this\"}\n",
677 )
678 .expect("write");
679
680 let sealed = seal(out.path()).expect("seals");
681 assert!(
682 sealed.notes.iter().any(|n| n.contains("2 atoms")),
683 "{:?}",
684 sealed.notes
685 );
686 let checked = verify(out.path()).expect("checks out");
687 assert!(
688 checked.notes.iter().any(|n| n.contains("2 atoms")),
689 "{:?}",
690 checked.notes
691 );
692
693 std::fs::write(atoms.join("late.jsonl"), "{\"id\":\"a3\"}\n").expect("write");
696 let err = verify(out.path()).expect_err("a late atom file passed");
697 assert!(format!("{err}").contains("unlisted"), "{err}");
698 }
699
700 #[test]
702 fn checking_a_satchel_says_what_it_did_not_check() {
703 let (_dir, layout) = tracker();
704 made(&layout, "first", CreateOpts::default());
705 let out = tempfile::tempdir().expect("out");
706 pack(
707 &layout,
708 &Slice {
709 projects: vec!["sample".into()],
710 issues: Vec::new(),
711 },
712 out.path(),
713 )
714 .expect("packs");
715
716 let unsigned = verify(out.path()).expect("checks out");
717 let said = unsigned.notes.join(" ");
718 assert!(
719 said.contains("nothing about who wrote it"),
720 "an unsigned satchel did not say so: {said}"
721 );
722
723 std::fs::write(
726 out.path().join("manifest-sha256.txt.sig"),
727 "ed25519 aa bb\n",
728 )
729 .expect("write");
730 let signed = verify(out.path()).expect("still checks out");
731 let said = signed.notes.join(" ");
732 assert!(said.contains("did not verify"), "{said}");
733 assert!(said.contains("vouch check"), "{said}");
734 }
735
736 #[test]
739 fn an_enclosed_deed_is_not_a_checked_deed() {
740 let (_dir, layout) = tracker();
741 made(&layout, "first", CreateOpts::default());
742 let out = tempfile::tempdir().expect("out");
743 pack(
744 &layout,
745 &Slice {
746 projects: vec!["sample".into()],
747 issues: Vec::new(),
748 },
749 out.path(),
750 )
751 .expect("packs");
752
753 let bare = verify(out.path()).expect("checks out");
754 assert!(
755 !bare.notes.join(" ").contains("deeds arrived"),
756 "{:?}",
757 bare.notes
758 );
759
760 let deeds = out.path().join("data").join("deeds");
761 for (accession, proof) in [("deed-file-proven", true), ("deed-file-bare", false)] {
762 let held = deeds.join(accession);
763 std::fs::create_dir_all(&held).expect("dirs");
764 std::fs::write(held.join("deed.bin"), b"bytes").expect("bytes");
765 if proof {
766 std::fs::write(
767 held.join("proof.txt"),
768 "id=deed-file-proven
769",
770 )
771 .expect("proof");
772 }
773 }
774 seal(out.path()).expect("seals");
775
776 let mixed = verify(out.path()).expect("checks out");
777 let said = mixed.notes.join(" ");
778 assert!(said.contains("deed-file-bare"), "{said}");
779 assert!(
780 said.contains("nothing says they were logged"),
781 "a deed with no proof passed unremarked: {said}"
782 );
783 assert!(
784 !said.contains("deed-file-proven"),
785 "a deed carrying a proof was named as missing one: {said}"
786 );
787
788 std::fs::write(
789 deeds.join("deed-file-bare").join("proof.txt"),
790 "id=deed-file-bare
791",
792 )
793 .expect("proof");
794 seal(out.path()).expect("seals");
795 let whole = verify(out.path()).expect("checks out");
796 let said = whole.notes.join(" ");
797 assert!(said.contains("2 deeds arrived"), "{said}");
798 assert!(said.contains("deedar check"), "{said}");
799 }
800
801 #[test]
804 fn an_empty_or_unknown_slice_is_refused() {
805 let (_dir, layout) = tracker();
806 let out = tempfile::tempdir().expect("out");
807 assert!(pack(&layout, &Slice::default(), out.path()).is_err());
808 assert!(
809 pack(
810 &layout,
811 &Slice {
812 projects: Vec::new(),
813 issues: vec!["sample-nope".into()],
814 },
815 out.path(),
816 )
817 .is_err()
818 );
819 }
820}