1use std::collections::{BTreeMap, BTreeSet};
43use std::path::{Path, PathBuf};
44
45use serde::{Deserialize, Serialize};
46
47use crate::config::Layout;
48use crate::error::{Error, Result};
49use crate::views::IssueRec;
50
51pub const VERSION: &str = "vissue-satchel/1";
54
55#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(default)]
58pub struct Slice {
59 pub projects: Vec<String>,
61 pub issues: Vec<String>,
63}
64
65impl Slice {
66 #[must_use]
68 pub fn is_empty(&self) -> bool {
69 self.projects.is_empty() && self.issues.is_empty()
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct Satchel {
76 pub version: String,
78 pub asked: Slice,
80 pub issues: Vec<String>,
82 pub carried: Vec<String>,
84 pub needs: Vec<String>,
86 pub packed_by: String,
88 pub packed_at: String,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct Report {
95 pub issues: usize,
97 pub needs: usize,
99 pub files: usize,
101 pub notes: Vec<String>,
103}
104
105impl Report {
106 #[must_use]
108 pub fn render(&self) -> String {
109 let mut out = format!(
110 "issues={} deeds={} files={}\n",
111 self.issues, self.needs, self.files
112 );
113 for note in &self.notes {
114 out.push_str(note);
115 out.push('\n');
116 }
117 out
118 }
119}
120
121pub fn pack(layout: &Layout, asked: &Slice, dest: &Path) -> Result<Report> {
133 if asked.is_empty() {
134 return Err(Error::Other(anyhow::anyhow!(
135 "a satchel needs a project or an issue to pack"
136 )));
137 }
138 let recs = crate::catalog::load_recs(layout)?;
139 let by_id: BTreeMap<&str, &IssueRec> =
140 recs.iter().map(|r| (r.heading.id.as_str(), r)).collect();
141
142 let mut named: BTreeSet<String> = BTreeSet::new();
144 for project in &asked.projects {
145 for rec in &recs {
146 if rec.project == *project {
147 named.insert(rec.heading.id.clone());
148 }
149 }
150 }
151 for id in &asked.issues {
152 if !by_id.contains_key(id.as_str()) {
153 return Err(Error::IssueNotFound { id: id.clone() });
154 }
155 named.insert(id.clone());
156 }
157 if named.is_empty() {
158 return Err(Error::Other(anyhow::anyhow!(
159 "nothing to pack: {:?} matched no issue",
160 asked.projects
161 )));
162 }
163
164 let mut chosen = named.clone();
166 let mut frontier: Vec<String> = named.iter().cloned().collect();
167 while let Some(id) = frontier.pop() {
168 let Some(rec) = by_id.get(id.as_str()) else {
169 continue;
170 };
171 let mut reach: Vec<String> = rec.heading.blocked_by();
172 if let Some(parent) = rec.heading.parent().map(str::to_string) {
173 reach.push(parent);
174 }
175 for next in reach {
176 if by_id.contains_key(next.as_str()) && chosen.insert(next.clone()) {
177 frontier.push(next);
178 }
179 }
180 }
181
182 let carried: Vec<String> = chosen.difference(&named).cloned().collect();
183 let mut needs: BTreeSet<String> = BTreeSet::new();
184 for id in &chosen {
185 if let Some(rec) = by_id.get(id.as_str()) {
186 needs.extend(rec.heading.deeds());
187 }
188 }
189
190 let data = dest.join("data");
192 let issues_dir = data.join("issues");
193 std::fs::create_dir_all(&issues_dir).map_err(Error::from)?;
194 let mut payload: Vec<(PathBuf, String)> = Vec::new();
195 for id in &chosen {
196 let Some(rec) = by_id.get(id.as_str()) else {
197 continue;
198 };
199 let text = crate::catalog::org_text_from(rec)?;
200 let rel = PathBuf::from("data/issues").join(format!("{id}.org"));
201 write_payload(dest, &rel, text.as_bytes())?;
202 payload.push((rel, digest(text.as_bytes())));
203 }
204
205 let satchel = Satchel {
206 version: VERSION.to_string(),
207 asked: asked.clone(),
208 issues: chosen.iter().cloned().collect(),
209 carried,
210 needs: needs.iter().cloned().collect(),
211 packed_by: crate::config::identity(layout),
212 packed_at: crate::model::today_inactive_bracket(),
213 };
214 let described = serde_json::to_string_pretty(&satchel)
215 .map(|json| json + "\n")
216 .map_err(|e| Error::Other(anyhow::anyhow!("{e}")))?;
217 let rel = PathBuf::from("data/satchel.json");
218 write_payload(dest, &rel, described.as_bytes())?;
219 payload.push((rel, digest(described.as_bytes())));
220
221 payload.sort();
222 write_manifest(dest, &payload)?;
223 write_declaration(dest, &satchel)?;
224
225 let mut notes = Vec::new();
226 if !satchel.needs.is_empty() {
227 notes.push(format!(
228 "{} deed accessions are named and not enclosed; the deed store exports them",
229 satchel.needs.len()
230 ));
231 }
232 if !satchel.carried.is_empty() {
233 notes.push(format!(
234 "{} issues came along as blockers or parents of what was asked for",
235 satchel.carried.len()
236 ));
237 }
238 Ok(Report {
239 issues: satchel.issues.len(),
240 needs: satchel.needs.len(),
241 files: payload.len(),
242 notes,
243 })
244}
245
246pub fn seal(dir: &Path) -> Result<Report> {
258 let satchel = describe(dir)?;
259 let mut payload: Vec<(PathBuf, String)> = Vec::new();
260 for found in walk(&dir.join("data"))? {
261 let rel = found
262 .strip_prefix(dir)
263 .map_err(|e| Error::Other(anyhow::anyhow!("{e}")))?
264 .to_path_buf();
265 let bytes = std::fs::read(&found).map_err(Error::from)?;
266 payload.push((rel, digest(&bytes)));
267 }
268 payload.sort();
269 write_manifest(dir, &payload)?;
270
271 let mut notes = shortfall(dir, &satchel);
272 let atoms = atom_lines(dir);
273 if atoms > 0 {
274 notes.push(format!("{atoms} atoms arrived from a pack"));
275 }
276 Ok(Report {
277 issues: satchel.issues.len(),
278 needs: satchel.needs.len(),
279 files: payload.len(),
280 notes,
281 })
282}
283
284fn shortfall(dir: &Path, satchel: &Satchel) -> Vec<String> {
290 let enclosed = enclosed_deeds(dir);
291 let mut out = Vec::new();
292 let short = satchel
293 .needs
294 .iter()
295 .filter(|acc| !enclosed.contains(*acc))
296 .count();
297 if short > 0 {
298 out.push(format!(
299 "{short} of {} deed accessions are named and not enclosed",
300 satchel.needs.len()
301 ));
302 }
303 out.extend(provenance_note(dir, &enclosed));
304 out
305}
306
307fn signature_note(dir: &Path) -> String {
319 let manifest = dir.join("manifest-sha256.txt");
320 let signature = manifest.with_extension("txt.sig");
321 if signature.is_file() {
322 format!(
323 "the payload matches the manifest, and the manifest carries a signature this check \
324 did not verify: `deedar vouch check {}` says whether a key you accept made it",
325 manifest.display()
326 )
327 } else {
328 "the payload matches the manifest, and the manifest is unsigned, so this establishes \
329 that the bag arrived as written and nothing about who wrote it"
330 .to_string()
331 }
332}
333
334fn atom_lines(dir: &Path) -> usize {
337 let Ok(entries) = std::fs::read_dir(dir.join("data").join("atoms")) else {
338 return 0;
339 };
340 entries
341 .flatten()
342 .filter_map(|entry| std::fs::read_to_string(entry.path()).ok())
343 .map(|text| text.lines().filter(|l| !l.trim().is_empty()).count())
344 .sum()
345}
346
347fn provenance_note(dir: &Path, enclosed: &BTreeSet<String>) -> Option<String> {
362 if enclosed.is_empty() {
363 return None;
364 }
365 let deeds = dir.join("data").join("deeds");
366 let bare: Vec<&String> = enclosed
367 .iter()
368 .filter(|acc| !deeds.join(acc).join("proof.txt").is_file())
369 .collect();
370 if bare.is_empty() {
371 return Some(format!(
372 "{} deeds arrived carrying a proof this check does not read: \
373 `deedar check {}` says whether the sender's log held them before \
374 the handover",
375 enclosed.len(),
376 dir.display()
377 ));
378 }
379 Some(format!(
380 "{} of {} enclosed deeds carry no proof, so nothing says they were \
381 logged before they were handed over: {}",
382 bare.len(),
383 enclosed.len(),
384 bare.iter()
385 .map(|acc| acc.as_str())
386 .collect::<Vec<_>>()
387 .join(", ")
388 ))
389}
390
391fn enclosed_deeds(dir: &Path) -> BTreeSet<String> {
392 let mut out = BTreeSet::new();
393 let Ok(entries) = std::fs::read_dir(dir.join("data").join("deeds")) else {
394 return out;
395 };
396 for entry in entries.flatten() {
397 if entry.path().is_dir()
398 && let Some(name) = entry.file_name().to_str()
399 {
400 out.insert(name.to_string());
401 }
402 }
403 out
404}
405
406pub fn verify(dir: &Path) -> Result<Report> {
417 let manifest = dir.join("manifest-sha256.txt");
418 let text = std::fs::read_to_string(&manifest)
419 .map_err(|_| Error::Other(anyhow::anyhow!("no manifest at {}", manifest.display())))?;
420 let mut listed: BTreeMap<PathBuf, String> = BTreeMap::new();
421 for line in text.lines().filter(|l| !l.trim().is_empty()) {
422 let Some((hash, rel)) = line.split_once(" ") else {
423 return Err(Error::Other(anyhow::anyhow!(
424 "manifest: not an entry: {line:?}"
425 )));
426 };
427 listed.insert(PathBuf::from(rel), hash.to_string());
428 }
429
430 let mut notes = Vec::new();
431 for (rel, want) in &listed {
432 let path = dir.join(rel);
433 let Ok(bytes) = std::fs::read(&path) else {
434 notes.push(format!("missing {}", rel.display()));
435 continue;
436 };
437 let got = digest(&bytes);
438 if got != *want {
439 notes.push(format!("changed {}", rel.display()));
440 }
441 }
442 for found in walk(&dir.join("data"))? {
443 let rel = found
444 .strip_prefix(dir)
445 .map_err(|e| Error::Other(anyhow::anyhow!("{e}")))?
446 .to_path_buf();
447 if !listed.contains_key(&rel) {
448 notes.push(format!("unlisted {}", rel.display()));
449 }
450 }
451
452 let described = dir.join("data").join("satchel.json");
453 let satchel: Satchel = std::fs::read_to_string(&described)
454 .map_err(|_| Error::Other(anyhow::anyhow!("no data/satchel.json")))
455 .and_then(|raw| serde_json::from_str(&raw).map_err(Error::from))?;
456 if satchel.version != VERSION {
457 notes.push(format!(
458 "packed as {} and read as {VERSION}",
459 satchel.version
460 ));
461 }
462
463 if notes.is_empty() {
464 let mut notes = shortfall(dir, &satchel);
468 let atoms = atom_lines(dir);
469 if atoms > 0 {
470 notes.push(format!("{atoms} atoms arrived from a pack"));
471 }
472 notes.push(signature_note(dir));
473 Ok(Report {
474 issues: satchel.issues.len(),
475 needs: satchel.needs.len(),
476 files: listed.len(),
477 notes,
478 })
479 } else {
480 Err(Error::Other(anyhow::anyhow!(
481 "the satchel does not check out:\n{}",
482 notes.join("\n")
483 )))
484 }
485}
486
487pub fn describe(dir: &Path) -> Result<Satchel> {
493 let path = dir.join("data").join("satchel.json");
494 let raw = std::fs::read_to_string(&path)
495 .map_err(|_| Error::Other(anyhow::anyhow!("no satchel at {}", path.display())))?;
496 serde_json::from_str(&raw).map_err(Error::from)
497}
498
499fn write_payload(dest: &Path, rel: &Path, bytes: &[u8]) -> Result<()> {
500 let path = dest.join(rel);
501 if let Some(parent) = path.parent() {
502 std::fs::create_dir_all(parent).map_err(Error::from)?;
503 }
504 std::fs::write(&path, bytes).map_err(Error::from)
505}
506
507fn write_manifest(dest: &Path, payload: &[(PathBuf, String)]) -> Result<()> {
508 let mut out = String::new();
509 for (rel, hash) in payload {
510 out.push_str(&format!("{hash} {}\n", rel.display()));
513 }
514 std::fs::write(dest.join("manifest-sha256.txt"), out).map_err(Error::from)
515}
516
517fn write_declaration(dest: &Path, satchel: &Satchel) -> Result<()> {
518 std::fs::write(
519 dest.join("bagit.txt"),
520 "BagIt-Version: 1.0\nTag-File-Character-Encoding: UTF-8\n",
521 )
522 .map_err(Error::from)?;
523 let info = format!(
524 "Bag-Software-Agent: vissue {}\nBagging-Date: {}\nSource-Organization: {}\nExternal-Description: {} issues and {} deed accessions from a vissue tracker\nInternal-Sender-Identifier: {}\n",
525 env!("CARGO_PKG_VERSION"),
526 satchel.packed_at,
527 satchel.packed_by,
528 satchel.issues.len(),
529 satchel.needs.len(),
530 VERSION,
531 );
532 std::fs::write(dest.join("bag-info.txt"), info).map_err(Error::from)
533}
534
535fn walk(dir: &Path) -> Result<Vec<PathBuf>> {
536 let mut out = Vec::new();
537 if !dir.is_dir() {
538 return Ok(out);
539 }
540 let mut stack = vec![dir.to_path_buf()];
541 while let Some(at) = stack.pop() {
542 for entry in std::fs::read_dir(&at).map_err(Error::from)? {
543 let entry = entry.map_err(Error::from)?;
544 let path = entry.path();
545 if path.is_dir() {
546 stack.push(path);
547 } else {
548 out.push(path);
549 }
550 }
551 }
552 out.sort();
553 Ok(out)
554}
555
556fn digest(bytes: &[u8]) -> String {
557 use sha2::{Digest, Sha256};
558 let hash = Sha256::digest(bytes);
559 hash.iter().map(|b| format!("{b:02x}")).collect()
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565 use crate::config::DEFAULT_PREFIX;
566 use crate::ops::{self, CreateOpts};
567
568 fn made(layout: &Layout, title: &str, opts: CreateOpts<'_>) -> String {
570 let report = ops::create(layout, "sample", title, opts).expect("create");
571 report
572 .split_whitespace()
573 .next()
574 .expect("an id in the report")
575 .to_string()
576 }
577
578 fn tracker() -> (tempfile::TempDir, Layout) {
579 let dir = tempfile::tempdir().expect("tempdir");
580 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
581 std::fs::create_dir_all(layout.projects_dir()).expect("projects");
582 (dir, layout)
583 }
584
585 #[test]
588 fn a_named_issue_brings_its_blockers_and_its_plan() {
589 let (_dir, layout) = tracker();
590 let plan = made(&layout, "the plan", CreateOpts::default());
591 let blocker = made(&layout, "the blocker", CreateOpts::default());
592 let work = made(
593 &layout,
594 "the work",
595 CreateOpts {
596 parent: Some(plan.as_str()),
597 ..CreateOpts::default()
598 },
599 );
600 ops::update(&layout, &work, None, None, Some(&blocker), None).expect("blocked by");
601 let bystander = made(&layout, "not asked for", CreateOpts::default());
602
603 let out = tempfile::tempdir().expect("out");
604 let report = pack(
605 &layout,
606 &Slice {
607 projects: Vec::new(),
608 issues: vec![work.clone()],
609 },
610 out.path(),
611 )
612 .expect("packs");
613
614 let described = describe(out.path()).expect("describes");
615 assert!(described.issues.contains(&work), "{described:?}");
616 assert!(described.issues.contains(&plan), "the plan is missing");
617 assert!(
618 described.issues.contains(&blocker),
619 "the blocker is missing"
620 );
621 assert!(
622 !described.issues.contains(&bystander),
623 "a sibling came along uninvited"
624 );
625 assert_eq!(described.carried.len(), 2, "{:?}", described.carried);
627 assert_eq!(report.issues, 3);
628 assert!(
629 out.path()
630 .join("data/issues")
631 .join(format!("{work}.org"))
632 .is_file()
633 );
634 }
635
636 #[test]
639 fn a_satchel_checks_out_until_something_moves() {
640 let (_dir, layout) = tracker();
641 let one = made(&layout, "first", CreateOpts::default());
642 let out = tempfile::tempdir().expect("out");
643 pack(
644 &layout,
645 &Slice {
646 projects: vec!["sample".into()],
647 issues: Vec::new(),
648 },
649 out.path(),
650 )
651 .expect("packs");
652
653 verify(out.path()).expect("a fresh satchel checks out");
654
655 let issue = out.path().join("data/issues").join(format!("{one}.org"));
657 std::fs::write(&issue, "* TODO something else\n").expect("write");
658 let err = verify(out.path()).expect_err("an edited payload passed");
659 assert!(format!("{err}").contains("changed"), "{err}");
660 }
661
662 #[test]
666 fn a_file_nobody_listed_is_a_finding() {
667 let (_dir, layout) = tracker();
668 made(&layout, "first", CreateOpts::default());
669 let out = tempfile::tempdir().expect("out");
670 pack(
671 &layout,
672 &Slice {
673 projects: vec!["sample".into()],
674 issues: Vec::new(),
675 },
676 out.path(),
677 )
678 .expect("packs");
679 verify(out.path()).expect("checks out");
680
681 std::fs::write(out.path().join("data/extra.sh"), "rm -rf /\n").expect("write");
682 let err = verify(out.path()).expect_err("an unlisted file passed");
683 assert!(format!("{err}").contains("unlisted"), "{err}");
684 }
685
686 #[test]
689 fn sealing_accounts_for_what_arrived_after_packing() {
690 let (_dir, layout) = tracker();
691 made(&layout, "first", CreateOpts::default());
692 let out = tempfile::tempdir().expect("out");
693 pack(
694 &layout,
695 &Slice {
696 projects: vec!["sample".into()],
697 issues: Vec::new(),
698 },
699 out.path(),
700 )
701 .expect("packs");
702
703 let deeds = out.path().join("data/deeds/deed-file-note");
705 std::fs::create_dir_all(&deeds).expect("mkdir");
706 std::fs::write(deeds.join("deed.bin"), b"deed bytes").expect("write");
707
708 let err = verify(out.path()).expect_err("an unsealed addition passed");
711 assert!(format!("{err}").contains("unlisted"), "{err}");
712
713 let report = seal(out.path()).expect("seals");
714 assert!(report.files >= 3, "{report:?}");
715 verify(out.path()).expect("a sealed satchel checks out");
716 }
717
718 #[test]
721 fn a_pack_can_put_what_the_seat_learned_in_too() {
722 let (_dir, layout) = tracker();
723 made(&layout, "first", CreateOpts::default());
724 let out = tempfile::tempdir().expect("out");
725 pack(
726 &layout,
727 &Slice {
728 projects: vec!["sample".into()],
729 issues: Vec::new(),
730 },
731 out.path(),
732 )
733 .expect("packs");
734
735 let atoms = out.path().join("data/atoms");
737 std::fs::create_dir_all(&atoms).expect("mkdir");
738 std::fs::write(
739 atoms.join("seat.jsonl"),
740 "{\"id\":\"a1\",\"text\":\"what was learned\"}\n {\"id\":\"a2\",\"text\":\"and this\"}\n",
741 )
742 .expect("write");
743
744 let sealed = seal(out.path()).expect("seals");
745 assert!(
746 sealed.notes.iter().any(|n| n.contains("2 atoms")),
747 "{:?}",
748 sealed.notes
749 );
750 let checked = verify(out.path()).expect("checks out");
751 assert!(
752 checked.notes.iter().any(|n| n.contains("2 atoms")),
753 "{:?}",
754 checked.notes
755 );
756
757 std::fs::write(atoms.join("late.jsonl"), "{\"id\":\"a3\"}\n").expect("write");
760 let err = verify(out.path()).expect_err("a late atom file passed");
761 assert!(format!("{err}").contains("unlisted"), "{err}");
762 }
763
764 #[test]
771 fn checking_a_satchel_says_what_it_did_not_check() {
772 let (_dir, layout) = tracker();
773 made(&layout, "first", CreateOpts::default());
774 let out = tempfile::tempdir().expect("out");
775 pack(
776 &layout,
777 &Slice {
778 projects: vec!["sample".into()],
779 issues: Vec::new(),
780 },
781 out.path(),
782 )
783 .expect("packs");
784
785 let unsigned = verify(out.path()).expect("checks out");
786 let said = unsigned.notes.join(" ");
787 assert!(
788 said.contains("nothing about who wrote it"),
789 "an unsigned satchel did not say so: {said}"
790 );
791
792 std::fs::write(
795 out.path().join("manifest-sha256.txt.sig"),
796 "ed25519 aa bb\n",
797 )
798 .expect("write");
799 let signed = verify(out.path()).expect("still checks out");
800 let said = signed.notes.join(" ");
801 assert!(said.contains("did not verify"), "{said}");
802 assert!(said.contains("vouch check"), "{said}");
803 }
804
805 #[test]
814 fn an_enclosed_deed_is_not_a_checked_deed() {
815 let (_dir, layout) = tracker();
816 made(&layout, "first", CreateOpts::default());
817 let out = tempfile::tempdir().expect("out");
818 pack(
819 &layout,
820 &Slice {
821 projects: vec!["sample".into()],
822 issues: Vec::new(),
823 },
824 out.path(),
825 )
826 .expect("packs");
827
828 let bare = verify(out.path()).expect("checks out");
830 assert!(
831 !bare.notes.join(" ").contains("deeds arrived"),
832 "{:?}",
833 bare.notes
834 );
835
836 let deeds = out.path().join("data").join("deeds");
839 for (accession, proof) in [("deed-file-proven", true), ("deed-file-bare", false)] {
840 let held = deeds.join(accession);
841 std::fs::create_dir_all(&held).expect("dirs");
842 std::fs::write(held.join("deed.bin"), b"bytes").expect("bytes");
843 if proof {
844 std::fs::write(
845 held.join("proof.txt"),
846 "id=deed-file-proven
847",
848 )
849 .expect("proof");
850 }
851 }
852 seal(out.path()).expect("seals");
853
854 let mixed = verify(out.path()).expect("checks out");
855 let said = mixed.notes.join(" ");
856 assert!(said.contains("deed-file-bare"), "{said}");
857 assert!(
858 said.contains("nothing says they were logged"),
859 "a deed with no proof passed unremarked: {said}"
860 );
861 assert!(
862 !said.contains("deed-file-proven"),
863 "a deed carrying a proof was named as missing one: {said}"
864 );
865
866 std::fs::write(
869 deeds.join("deed-file-bare").join("proof.txt"),
870 "id=deed-file-bare
871",
872 )
873 .expect("proof");
874 seal(out.path()).expect("seals");
875 let whole = verify(out.path()).expect("checks out");
876 let said = whole.notes.join(" ");
877 assert!(said.contains("2 deeds arrived"), "{said}");
878 assert!(said.contains("deedar check"), "{said}");
879 }
880
881 #[test]
884 fn an_empty_or_unknown_slice_is_refused() {
885 let (_dir, layout) = tracker();
886 let out = tempfile::tempdir().expect("out");
887 assert!(pack(&layout, &Slice::default(), out.path()).is_err());
888 assert!(
889 pack(
890 &layout,
891 &Slice {
892 projects: Vec::new(),
893 issues: vec!["sample-nope".into()],
894 },
895 out.path(),
896 )
897 .is_err()
898 );
899 }
900}