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 short = satchel
292        .needs
293        .iter()
294        .filter(|acc| !enclosed.contains(*acc))
295        .count();
296    if short == 0 {
297        return Vec::new();
298    }
299    vec![format!(
300        "{short} of {} deed accessions are named and not enclosed",
301        satchel.needs.len()
302    )]
303}
304
305/// What this check did and did not establish about who packed the satchel.
306///
307/// The payload matching the manifest says the bag arrived as it was written.
308/// It says nothing about who wrote it, because a receiver recomputing digests
309/// from the bag they were handed is checking the bag against itself. That
310/// second question needs the signature over the manifest, and the keys a
311/// reader accepts live in their deed store rather than here, so this names
312/// what is left rather than answering it.
313///
314/// Saying so is the point. A check that reports "ok" for the first question
315/// and stays quiet about the second invites the reader to hear both.
316fn signature_note(dir: &Path) -> String {
317    let manifest = dir.join("manifest-sha256.txt");
318    let signature = manifest.with_extension("txt.sig");
319    if signature.is_file() {
320        format!(
321            "the payload matches the manifest, and the manifest carries a signature this check \
322             did not verify: `deedar vouch check {}` says whether a key you accept made it",
323            manifest.display()
324        )
325    } else {
326        "the payload matches the manifest, and the manifest is unsigned, so this establishes \
327         that the bag arrived as written and nothing about who wrote it"
328            .to_string()
329    }
330}
331
332/// How many atoms the pack put in, counted rather than parsed: the receiver
333/// wants to know something came, and reading them is their business.
334fn atom_lines(dir: &Path) -> usize {
335    let Ok(entries) = std::fs::read_dir(dir.join("data").join("atoms")) else {
336        return 0;
337    };
338    entries
339        .flatten()
340        .filter_map(|entry| std::fs::read_to_string(entry.path()).ok())
341        .map(|text| text.lines().filter(|l| !l.trim().is_empty()).count())
342        .sum()
343}
344
345/// Which accessions actually have a directory under the payload.
346fn enclosed_deeds(dir: &Path) -> BTreeSet<String> {
347    let mut out = BTreeSet::new();
348    let Ok(entries) = std::fs::read_dir(dir.join("data").join("deeds")) else {
349        return out;
350    };
351    for entry in entries.flatten() {
352        if entry.path().is_dir()
353            && let Some(name) = entry.file_name().to_str()
354        {
355            out.insert(name.to_string());
356        }
357    }
358    out
359}
360
361/// Check a satchel: every file the manifest names is present and hashes right,
362/// and nothing in the payload is unaccounted for.
363///
364/// The second half is the one that matters. A manifest that only proves what
365/// it lists would let a packer add a file nobody agreed to, which is how a
366/// bundle becomes a delivery mechanism.
367///
368/// # Errors
369///
370/// Returns an error when the satchel cannot be read or does not check out.
371pub fn verify(dir: &Path) -> Result<Report> {
372    let manifest = dir.join("manifest-sha256.txt");
373    let text = std::fs::read_to_string(&manifest)
374        .map_err(|_| Error::Other(anyhow::anyhow!("no manifest at {}", manifest.display())))?;
375    let mut listed: BTreeMap<PathBuf, String> = BTreeMap::new();
376    for line in text.lines().filter(|l| !l.trim().is_empty()) {
377        let Some((hash, rel)) = line.split_once("  ") else {
378            return Err(Error::Other(anyhow::anyhow!(
379                "manifest: not an entry: {line:?}"
380            )));
381        };
382        listed.insert(PathBuf::from(rel), hash.to_string());
383    }
384
385    let mut notes = Vec::new();
386    for (rel, want) in &listed {
387        let path = dir.join(rel);
388        let Ok(bytes) = std::fs::read(&path) else {
389            notes.push(format!("missing {}", rel.display()));
390            continue;
391        };
392        let got = digest(&bytes);
393        if got != *want {
394            notes.push(format!("changed {}", rel.display()));
395        }
396    }
397    for found in walk(&dir.join("data"))? {
398        let rel = found
399            .strip_prefix(dir)
400            .map_err(|e| Error::Other(anyhow::anyhow!("{e}")))?
401            .to_path_buf();
402        if !listed.contains_key(&rel) {
403            notes.push(format!("unlisted {}", rel.display()));
404        }
405    }
406
407    let described = dir.join("data").join("satchel.json");
408    let satchel: Satchel = std::fs::read_to_string(&described)
409        .map_err(|_| Error::Other(anyhow::anyhow!("no data/satchel.json")))
410        .and_then(|raw| serde_json::from_str(&raw).map_err(Error::from))?;
411    if satchel.version != VERSION {
412        notes.push(format!(
413            "packed as {} and read as {VERSION}",
414            satchel.version
415        ));
416    }
417
418    if notes.is_empty() {
419        // A named deed that never arrived is a note, not a failure: the deed
420        // store may not have been asked. What arrived and does not check out
421        // is the failure, and that is already above.
422        let mut notes = shortfall(dir, &satchel);
423        let atoms = atom_lines(dir);
424        if atoms > 0 {
425            notes.push(format!("{atoms} atoms arrived from a pack"));
426        }
427        notes.push(signature_note(dir));
428        Ok(Report {
429            issues: satchel.issues.len(),
430            needs: satchel.needs.len(),
431            files: listed.len(),
432            notes,
433        })
434    } else {
435        Err(Error::Other(anyhow::anyhow!(
436            "the satchel does not check out:\n{}",
437            notes.join("\n")
438        )))
439    }
440}
441
442/// Read a satchel's description without checking it.
443///
444/// # Errors
445///
446/// Returns an error when the file is absent or is not a satchel.
447pub fn describe(dir: &Path) -> Result<Satchel> {
448    let path = dir.join("data").join("satchel.json");
449    let raw = std::fs::read_to_string(&path)
450        .map_err(|_| Error::Other(anyhow::anyhow!("no satchel at {}", path.display())))?;
451    serde_json::from_str(&raw).map_err(Error::from)
452}
453
454fn write_payload(dest: &Path, rel: &Path, bytes: &[u8]) -> Result<()> {
455    let path = dest.join(rel);
456    if let Some(parent) = path.parent() {
457        std::fs::create_dir_all(parent).map_err(Error::from)?;
458    }
459    std::fs::write(&path, bytes).map_err(Error::from)
460}
461
462fn write_manifest(dest: &Path, payload: &[(PathBuf, String)]) -> Result<()> {
463    let mut out = String::new();
464    for (rel, hash) in payload {
465        // Two spaces, the way every sha256sum file has them, so `sha256sum -c`
466        // reads this without a translator.
467        out.push_str(&format!("{hash}  {}\n", rel.display()));
468    }
469    std::fs::write(dest.join("manifest-sha256.txt"), out).map_err(Error::from)
470}
471
472fn write_declaration(dest: &Path, satchel: &Satchel) -> Result<()> {
473    std::fs::write(
474        dest.join("bagit.txt"),
475        "BagIt-Version: 1.0\nTag-File-Character-Encoding: UTF-8\n",
476    )
477    .map_err(Error::from)?;
478    let info = format!(
479        "Bag-Software-Agent: vissue {}\nBagging-Date: {}\nSource-Organization: {}\nExternal-Description: {} issues and {} deed accessions from a vissue tracker\nInternal-Sender-Identifier: {}\n",
480        env!("CARGO_PKG_VERSION"),
481        satchel.packed_at,
482        satchel.packed_by,
483        satchel.issues.len(),
484        satchel.needs.len(),
485        VERSION,
486    );
487    std::fs::write(dest.join("bag-info.txt"), info).map_err(Error::from)
488}
489
490fn walk(dir: &Path) -> Result<Vec<PathBuf>> {
491    let mut out = Vec::new();
492    if !dir.is_dir() {
493        return Ok(out);
494    }
495    let mut stack = vec![dir.to_path_buf()];
496    while let Some(at) = stack.pop() {
497        for entry in std::fs::read_dir(&at).map_err(Error::from)? {
498            let entry = entry.map_err(Error::from)?;
499            let path = entry.path();
500            if path.is_dir() {
501                stack.push(path);
502            } else {
503                out.push(path);
504            }
505        }
506    }
507    out.sort();
508    Ok(out)
509}
510
511fn digest(bytes: &[u8]) -> String {
512    use sha2::{Digest, Sha256};
513    let hash = Sha256::digest(bytes);
514    hash.iter().map(|b| format!("{b:02x}")).collect()
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use crate::config::DEFAULT_PREFIX;
521    use crate::ops::{self, CreateOpts};
522
523    /// `create` reports a line; the id is its first word.
524    fn made(layout: &Layout, title: &str, opts: CreateOpts<'_>) -> String {
525        let report = ops::create(layout, "sample", title, opts).expect("create");
526        report
527            .split_whitespace()
528            .next()
529            .expect("an id in the report")
530            .to_string()
531    }
532
533    fn tracker() -> (tempfile::TempDir, Layout) {
534        let dir = tempfile::tempdir().expect("tempdir");
535        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
536        std::fs::create_dir_all(layout.projects_dir()).expect("projects");
537        (dir, layout)
538    }
539
540    /// A slice carries what it stands on, or the receiver has a task and no
541    /// account of why it is not done.
542    #[test]
543    fn a_named_issue_brings_its_blockers_and_its_plan() {
544        let (_dir, layout) = tracker();
545        let plan = made(&layout, "the plan", CreateOpts::default());
546        let blocker = made(&layout, "the blocker", CreateOpts::default());
547        let work = made(
548            &layout,
549            "the work",
550            CreateOpts {
551                parent: Some(plan.as_str()),
552                ..CreateOpts::default()
553            },
554        );
555        ops::update(&layout, &work, None, None, Some(&blocker), None).expect("blocked by");
556        let bystander = made(&layout, "not asked for", CreateOpts::default());
557
558        let out = tempfile::tempdir().expect("out");
559        let report = pack(
560            &layout,
561            &Slice {
562                projects: Vec::new(),
563                issues: vec![work.clone()],
564            },
565            out.path(),
566        )
567        .expect("packs");
568
569        let described = describe(out.path()).expect("describes");
570        assert!(described.issues.contains(&work), "{described:?}");
571        assert!(described.issues.contains(&plan), "the plan is missing");
572        assert!(
573            described.issues.contains(&blocker),
574            "the blocker is missing"
575        );
576        assert!(
577            !described.issues.contains(&bystander),
578            "a sibling came along uninvited"
579        );
580        // And the receiver is told which of them they did not ask for.
581        assert_eq!(described.carried.len(), 2, "{:?}", described.carried);
582        assert_eq!(report.issues, 3);
583        assert!(
584            out.path()
585                .join("data/issues")
586                .join(format!("{work}.org"))
587                .is_file()
588        );
589    }
590
591    /// A satchel checks out when it arrives whole, and says what is wrong when
592    /// it does not.
593    #[test]
594    fn a_satchel_checks_out_until_something_moves() {
595        let (_dir, layout) = tracker();
596        let one = made(&layout, "first", CreateOpts::default());
597        let out = tempfile::tempdir().expect("out");
598        pack(
599            &layout,
600            &Slice {
601                projects: vec!["sample".into()],
602                issues: Vec::new(),
603            },
604            out.path(),
605        )
606        .expect("packs");
607
608        verify(out.path()).expect("a fresh satchel checks out");
609
610        // A payload file edited in transit.
611        let issue = out.path().join("data/issues").join(format!("{one}.org"));
612        std::fs::write(&issue, "* TODO something else\n").expect("write");
613        let err = verify(out.path()).expect_err("an edited payload passed");
614        assert!(format!("{err}").contains("changed"), "{err}");
615    }
616
617    /// The manifest has to account for the whole payload, not only for what it
618    /// lists. Proving what you enumerated is how a bundle becomes a delivery
619    /// mechanism for what you did not.
620    #[test]
621    fn a_file_nobody_listed_is_a_finding() {
622        let (_dir, layout) = tracker();
623        made(&layout, "first", CreateOpts::default());
624        let out = tempfile::tempdir().expect("out");
625        pack(
626            &layout,
627            &Slice {
628                projects: vec!["sample".into()],
629                issues: Vec::new(),
630            },
631            out.path(),
632        )
633        .expect("packs");
634        verify(out.path()).expect("checks out");
635
636        std::fs::write(out.path().join("data/extra.sh"), "rm -rf /\n").expect("write");
637        let err = verify(out.path()).expect_err("an unlisted file passed");
638        assert!(format!("{err}").contains("unlisted"), "{err}");
639    }
640
641    /// Sealing takes in what the deed store added, so the manifest covers the
642    /// whole payload rather than only the half the tracker wrote.
643    #[test]
644    fn sealing_accounts_for_what_arrived_after_packing() {
645        let (_dir, layout) = tracker();
646        made(&layout, "first", CreateOpts::default());
647        let out = tempfile::tempdir().expect("out");
648        pack(
649            &layout,
650            &Slice {
651                projects: vec!["sample".into()],
652                issues: Vec::new(),
653            },
654            out.path(),
655        )
656        .expect("packs");
657
658        // The deed store writes into the payload after the fact.
659        let deeds = out.path().join("data/deeds/deed-file-note");
660        std::fs::create_dir_all(&deeds).expect("mkdir");
661        std::fs::write(deeds.join("deed.bin"), b"deed bytes").expect("write");
662
663        // Before sealing that file is in the payload and not in the manifest,
664        // which is exactly what verify is supposed to object to.
665        let err = verify(out.path()).expect_err("an unsealed addition passed");
666        assert!(format!("{err}").contains("unlisted"), "{err}");
667
668        let report = seal(out.path()).expect("seals");
669        assert!(report.files >= 3, "{report:?}");
670        verify(out.path()).expect("a sealed satchel checks out");
671    }
672
673    /// Atoms are payload like anything else: the manifest covers them once
674    /// sealed, and the check says they arrived.
675    #[test]
676    fn a_pack_can_put_what_the_seat_learned_in_too() {
677        let (_dir, layout) = tracker();
678        made(&layout, "first", CreateOpts::default());
679        let out = tempfile::tempdir().expect("out");
680        pack(
681            &layout,
682            &Slice {
683                projects: vec!["sample".into()],
684                issues: Vec::new(),
685            },
686            out.path(),
687        )
688        .expect("packs");
689
690        // What `packset export --into` writes.
691        let atoms = out.path().join("data/atoms");
692        std::fs::create_dir_all(&atoms).expect("mkdir");
693        std::fs::write(
694            atoms.join("seat.jsonl"),
695            "{\"id\":\"a1\",\"text\":\"what was learned\"}\n             {\"id\":\"a2\",\"text\":\"and this\"}\n",
696        )
697        .expect("write");
698
699        let sealed = seal(out.path()).expect("seals");
700        assert!(
701            sealed.notes.iter().any(|n| n.contains("2 atoms")),
702            "{:?}",
703            sealed.notes
704        );
705        let checked = verify(out.path()).expect("checks out");
706        assert!(
707            checked.notes.iter().any(|n| n.contains("2 atoms")),
708            "{:?}",
709            checked.notes
710        );
711
712        // And an atom file added after sealing is unlisted, the same as any
713        // other payload nobody agreed to.
714        std::fs::write(atoms.join("late.jsonl"), "{\"id\":\"a3\"}\n").expect("write");
715        let err = verify(out.path()).expect_err("a late atom file passed");
716        assert!(format!("{err}").contains("unlisted"), "{err}");
717    }
718
719    /// A clean check says what it established and what it did not.
720    ///
721    /// The failure this guards is a reader running one command, seeing that
722    /// the payload matches, and hearing that the bag is trustworthy. Matching
723    /// a manifest a stranger could have written establishes nothing about who
724    /// wrote it.
725    #[test]
726    fn checking_a_satchel_says_what_it_did_not_check() {
727        let (_dir, layout) = tracker();
728        made(&layout, "first", CreateOpts::default());
729        let out = tempfile::tempdir().expect("out");
730        pack(
731            &layout,
732            &Slice {
733                projects: vec!["sample".into()],
734                issues: Vec::new(),
735            },
736            out.path(),
737        )
738        .expect("packs");
739
740        let unsigned = verify(out.path()).expect("checks out");
741        let said = unsigned.notes.join(" ");
742        assert!(
743            said.contains("nothing about who wrote it"),
744            "an unsigned satchel did not say so: {said}"
745        );
746
747        // With a signature beside the manifest, the check names the verb that
748        // answers the other question rather than implying it answered it.
749        std::fs::write(
750            out.path().join("manifest-sha256.txt.sig"),
751            "ed25519 aa bb\n",
752        )
753        .expect("write");
754        let signed = verify(out.path()).expect("still checks out");
755        let said = signed.notes.join(" ");
756        assert!(said.contains("did not verify"), "{said}");
757        assert!(said.contains("vouch check"), "{said}");
758    }
759
760    /// A slice that names nothing is not a slice, and an issue that is not
761    /// there is not packed silently.
762    #[test]
763    fn an_empty_or_unknown_slice_is_refused() {
764        let (_dir, layout) = tracker();
765        let out = tempfile::tempdir().expect("out");
766        assert!(pack(&layout, &Slice::default(), out.path()).is_err());
767        assert!(
768            pack(
769                &layout,
770                &Slice {
771                    projects: Vec::new(),
772                    issues: vec!["sample-nope".into()],
773                },
774                out.path(),
775            )
776            .is_err()
777        );
778    }
779}