Skip to main content

vissue_core/
satchel.rs

1//! A slice of the seat, packed so somebody else can open it.
2//!
3//! "Give the new person project X and tasks Y" is not one of the questions any
4//! of these tools answers. The tracker knows the work, the deed store knows
5//! what the work produced, and the pack knows what was learned; handing over a
6//! piece of that means taking a slice across all three and making it stand on
7//! its own somewhere else.
8//!
9//! What a slice has to carry, beyond the issues somebody named:
10//!
11//! - what they stand on. An issue whose blockers are absent is a task with no
12//!   account of why it is not done, so the closure walks blockers.
13//! - what plan they belong to. A child with no parent is a task with no reason.
14//! - what the work produced, by accession. The deeds themselves come from the
15//!   deed store, which is the only thing that can vouch for them; this names
16//!   them and the receiver fetches or refuses.
17//!
18//! The shape is BagIt (RFC 8493): a `data/` payload, a manifest of every file
19//! in it with a digest, and a `bag-info.txt` saying who packed it and when. A
20//! receiver checks the manifest before reading anything, which is the property
21//! a tarball does not have. `satchel.json` beside the payload is the
22//! self-description RO-Crate argues a package needs
23//! (doi:10.3233/DS-210053): the parts alone do not say what the whole was
24//! meant to be, or which of the parts were asked for rather than pulled in.
25//!
26//! This packs what the tracker holds. Deed bytes are the deed store's to
27//! export and atoms are the pack's, and `needs` is the list the deed store
28//! takes, so the three halves compose on pipes without this crate depending on
29//! either of them:
30//!
31//! ```console
32//! $ vissue satchel --out bag --project x --issue y
33//! $ packset export --into bag/data/atoms | deedar export --into bag/data/deeds -
34//! $ jq -r '.needs[]' bag/data/satchel.json | deedar export --into bag/data/deeds -
35//! $ vissue satchel --seal bag && vissue satchel --verify bag
36//! ```
37//!
38//! The accession is what makes that work. It is the one identifier crossing
39//! all three stores, so each of them can name what it needs from the others
40//! without reading their formats.
41
42use 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
51/// The format this writes, so a reader that meets a later one can say so
52/// rather than guess.
53pub const VERSION: &str = "vissue-satchel/1";
54
55/// What was asked for, as opposed to what came along with it.
56#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(default)]
58pub struct Slice {
59    /// Projects taken whole.
60    pub projects: Vec<String>,
61    /// Issues named one at a time.
62    pub issues: Vec<String>,
63}
64
65impl Slice {
66    /// Whether this names nothing, which is not a slice.
67    #[must_use]
68    pub fn is_empty(&self) -> bool {
69        self.projects.is_empty() && self.issues.is_empty()
70    }
71}
72
73/// The self-description written beside the payload.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct Satchel {
76    /// Format tag.
77    pub version: String,
78    /// What the packer was asked for.
79    pub asked: Slice,
80    /// Every issue in the closure, in id order.
81    pub issues: Vec<String>,
82    /// Issues that came along because something named needed them.
83    pub carried: Vec<String>,
84    /// Deed accessions the issues cite, which the deed store has to supply.
85    pub needs: Vec<String>,
86    /// Who packed it.
87    pub packed_by: String,
88    /// When, as an org inactive timestamp.
89    pub packed_at: String,
90}
91
92/// What a pack or a check found.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct Report {
95    /// Issues written.
96    pub issues: usize,
97    /// Deed accessions named.
98    pub needs: usize,
99    /// Files in the payload.
100    pub files: usize,
101    /// Anything a receiver should be told, in the order it was found.
102    pub notes: Vec<String>,
103}
104
105impl Report {
106    /// One line per fact, which is what a command prints.
107    #[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
121/// Pack a slice of the tracker into `dest`.
122///
123/// The closure is the issues named, plus every blocker they stand on and every
124/// parent they sit under, walked to a fixed point. Children are not pulled in:
125/// handing over a plan means handing over the plan, and a parent's other
126/// children are other people's work.
127///
128/// # Errors
129///
130/// Returns an error when the slice names nothing, when an issue is not in the
131/// corpus, or when `dest` cannot be written.
132pub 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    // What was named.
143    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    // Everything they stand on, to a fixed point.
165    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    // Payload first, manifest over what was written.
191    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
246/// Re-manifest a satchel over everything now in its payload.
247///
248/// Packing writes what the tracker holds, and the deed store fills in the
249/// deeds afterwards, so the manifest written at pack time covers less than the
250/// satchel ends up carrying. Sealing is the step that says the payload is
251/// complete: after it, `verify` accounts for every file, and a file added
252/// later shows up as unlisted.
253///
254/// # Errors
255///
256/// Returns an error when the directory is not a satchel or cannot be read.
257pub 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
284/// What the description names and the payload does not hold.
285///
286/// A satchel that names a deed and does not carry it is not broken, because
287/// the deed store may not have been asked yet. It is worth saying out loud, so
288/// the receiver learns it from the check rather than from opening one.
289fn 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
307/// What this check did and did not establish about who packed the satchel.
308///
309/// The payload matching the manifest says the bag arrived as it was written.
310/// It says nothing about who wrote it, because a receiver recomputing digests
311/// from the bag they were handed is checking the bag against itself. That
312/// second question needs the signature over the manifest, and the keys a
313/// reader accepts live in their deed store rather than here, so this names
314/// what is left rather than answering it.
315///
316/// Saying so is the point. A check that reports "ok" for the first question
317/// and stays quiet about the second invites the reader to hear both.
318fn 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
334/// How many atoms the pack put in, counted rather than parsed: the receiver
335/// wants to know something came, and reading them is their business.
336fn 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
347/// Which accessions actually have a directory under the payload.
348/// What the enclosed deeds still need checking for, and by what.
349///
350/// A satchel check is a check of the bag against its own manifest. It catches
351/// a payload that was corrupted or truncated and it cannot, even in principle,
352/// say whether a deed inside predates somebody asking for it: a sender who
353/// mints a deed the morning of the handover writes a manifest that agrees with
354/// it perfectly.
355///
356/// The deed store answers that, with the inclusion proof each deed travels
357/// with. Reading one is the deed store's business and not this crate's, so
358/// what belongs here is the fact that the question is open and the name of the
359/// verb that closes it. A receiver told only "the bag checks out" reads that
360/// as more than it says.
361fn 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
406/// Check a satchel: every file the manifest names is present and hashes right,
407/// and nothing in the payload is unaccounted for.
408///
409/// The second half is the one that matters. A manifest that only proves what
410/// it lists would let a packer add a file nobody agreed to, which is how a
411/// bundle becomes a delivery mechanism.
412///
413/// # Errors
414///
415/// Returns an error when the satchel cannot be read or does not check out.
416pub 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        // A named deed that never arrived is a note, not a failure: the deed
465        // store may not have been asked. What arrived and does not check out
466        // is the failure, and that is already above.
467        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
487/// Read a satchel's description without checking it.
488///
489/// # Errors
490///
491/// Returns an error when the file is absent or is not a satchel.
492pub 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        // Two spaces, the way every sha256sum file has them, so `sha256sum -c`
511        // reads this without a translator.
512        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    /// `create` reports a line; the id is its first word.
569    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    /// A slice carries what it stands on, or the receiver has a task and no
586    /// account of why it is not done.
587    #[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        // And the receiver is told which of them they did not ask for.
626        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    /// A satchel checks out when it arrives whole, and says what is wrong when
637    /// it does not.
638    #[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        // A payload file edited in transit.
656        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    /// The manifest has to account for the whole payload, not only for what it
663    /// lists. Proving what you enumerated is how a bundle becomes a delivery
664    /// mechanism for what you did not.
665    #[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    /// Sealing takes in what the deed store added, so the manifest covers the
687    /// whole payload rather than only the half the tracker wrote.
688    #[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        // The deed store writes into the payload after the fact.
704        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        // Before sealing that file is in the payload and not in the manifest,
709        // which is exactly what verify is supposed to object to.
710        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    /// Atoms are payload like anything else: the manifest covers them once
719    /// sealed, and the check says they arrived.
720    #[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        // What `packset export --into` writes.
736        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        // And an atom file added after sealing is unlisted, the same as any
758        // other payload nobody agreed to.
759        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    /// A clean check says what it established and what it did not.
765    ///
766    /// The failure this guards is a reader running one command, seeing that
767    /// the payload matches, and hearing that the bag is trustworthy. Matching
768    /// a manifest a stranger could have written establishes nothing about who
769    /// wrote it.
770    #[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        // With a signature beside the manifest, the check names the verb that
793        // answers the other question rather than implying it answered it.
794        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    /// A deed that arrived is a deed nothing here has checked the provenance
806    /// of, and the check says so rather than counting it as trust.
807    ///
808    /// The failure this guards is the same one as the signature note, one
809    /// question further along: a reader who is told the bag matches its
810    /// manifest and that three deeds arrived hears that the deeds are good.
811    /// A manifest agrees just as well with a deed minted the morning of the
812    /// handover.
813    #[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        // A bag with no deeds says nothing about deeds.
829        let bare = verify(out.path()).expect("checks out");
830        assert!(
831            !bare.notes.join(" ").contains("deeds arrived"),
832            "{:?}",
833            bare.notes
834        );
835
836        // The deed store's half arrives: one deed with the proof an export
837        // writes, one without.
838        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        // With every deed carrying one, the note names the verb that reads
867        // them rather than implying this check did.
868        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    /// A slice that names nothing is not a slice, and an issue that is not
882    /// there is not packed silently.
883    #[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}