Skip to main content

vissue_core/
satchel.rs

1//! A slice of the tracker packed as a BagIt bag (RFC 8493) with a
2//! `satchel.json` self-description (RO-Crate, doi:10.3233/DS-210053): the
3//! issues named, the blockers they stand on, the plans they sit under, and
4//! the deed accessions their work produced as `needs`. Deed bytes are the deed
5//! store's to export and atoms the pack's; the accession is what lets the
6//! three compose on pipes.
7//!
8//! ```console
9//! $ vissue satchel --out bag --project x --issue y
10//! $ packset export --into bag/data/atoms | deedar export --into bag/data/deeds -
11//! $ jq -r '.needs[]' bag/data/satchel.json | deedar export --into bag/data/deeds -
12//! $ vissue satchel --seal bag && vissue satchel --verify bag
13//! ```
14
15use 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
24/// The format this writes, so a reader that meets a later one can say so
25/// rather than guess.
26pub const VERSION: &str = "vissue-satchel/1";
27
28/// What was asked for, as opposed to what came along with it.
29#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(default)]
31pub struct Slice {
32    /// Projects taken whole.
33    pub projects: Vec<String>,
34    /// Issues named one at a time.
35    pub issues: Vec<String>,
36}
37
38impl Slice {
39    /// Whether this names nothing, which is not a slice.
40    #[must_use]
41    pub fn is_empty(&self) -> bool {
42        self.projects.is_empty() && self.issues.is_empty()
43    }
44}
45
46/// The self-description written beside the payload.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct Satchel {
49    /// Format tag.
50    pub version: String,
51    /// What the packer was asked for.
52    pub asked: Slice,
53    /// Every issue in the closure, in id order.
54    pub issues: Vec<String>,
55    /// Issues that came along because something named needed them.
56    pub carried: Vec<String>,
57    /// Deed accessions the issues cite, which the deed store has to supply.
58    pub needs: Vec<String>,
59    /// Who packed it.
60    pub packed_by: String,
61    /// When, as an org inactive timestamp.
62    pub packed_at: String,
63}
64
65/// What a pack or a check found.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Report {
68    /// Issues written.
69    pub issues: usize,
70    /// Deed accessions named.
71    pub needs: usize,
72    /// Files in the payload.
73    pub files: usize,
74    /// Anything a receiver should be told, in the order it was found.
75    pub notes: Vec<String>,
76}
77
78impl Report {
79    /// One line per fact, which is what a command prints.
80    #[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
94/// Pack a slice of the tracker into `dest`: the issues named, their blockers
95/// and parents to a fixed point; children are not pulled in.
96///
97/// # Errors
98///
99/// Returns an error when the slice names nothing, when an issue is not in the
100/// corpus, or when `dest` cannot be written.
101pub 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    // What was named.
112    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    // Everything they stand on, to a fixed point.
134    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    // Payload first, manifest over what was written.
160    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
215/// Re-manifest a satchel over everything now in its payload, after the deed
216/// store and the pack have filled it.
217///
218/// # Errors
219///
220/// Returns an error when the directory is not a satchel or cannot be read.
221pub 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
248/// Accessions the description names and the payload does not hold.
249fn 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
267/// What the manifest check leaves open: who packed it, which the signature
268/// over the manifest answers in the deed store.
269fn 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
285/// How many atoms the pack put in, counted rather than parsed: the receiver
286/// wants to know something came, and reading them is their business.
287fn 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
298/// What the enclosed deeds still need checking for, and by what: the receipts
299/// are the deed store's to read.
300fn 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
330/// Which accessions have a directory under the payload.
331fn 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
346/// Check a satchel: every file the manifest names is present and hashes
347/// right, and nothing in the payload is unlisted.
348///
349/// # Errors
350///
351/// Returns an error when the satchel cannot be read or does not check out.
352pub 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        // A named deed that never arrived is a note, not a failure: the deed
401        // store may not have been asked. What arrived and does not check out
402        // is the failure, and that is already above.
403        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
423/// Read a satchel's description without checking it.
424///
425/// # Errors
426///
427/// Returns an error when the file is absent or is not a satchel.
428pub 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        // Two spaces, the way every sha256sum file has them, so `sha256sum -c`
447        // reads this without a translator.
448        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    /// `create` reports a line; the id is its first word.
505    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    /// A slice carries what it stands on, or the receiver has a task and no
522    /// account of why it is not done.
523    #[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        // And the receiver is told which of them they did not ask for.
562        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    /// A satchel checks out when it arrives whole, and says what is wrong when
573    /// it does not.
574    #[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        // A payload file edited in transit.
592        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    /// The manifest has to account for the whole payload, not only for what it
599    /// lists. Proving what you enumerated is how a bundle becomes a delivery
600    /// mechanism for what you did not.
601    #[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    /// Sealing takes in what the deed store added, so the manifest covers the
623    /// whole payload rather than only the half the tracker wrote.
624    #[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        // The deed store writes into the payload after the fact.
640        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        // Before sealing that file is in the payload and not in the manifest,
645        // which is exactly what verify is supposed to object to.
646        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    /// Atoms are payload like anything else: the manifest covers them once
655    /// sealed, and the check says they arrived.
656    #[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        // What `packset export --into` writes.
672        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        // And an atom file added after sealing is unlisted, the same as any
694        // other payload nobody agreed to.
695        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    /// A clean check says what it established and what it did not.
701    #[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        // With a signature beside the manifest, the check names the verb that
724        // answers the other question rather than implying it answered it.
725        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    /// An enclosed deed is reported as unchecked, and one with no receipt is
737    /// named.
738    #[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    /// A slice that names nothing is not a slice, and an issue that is not
802    /// there is not packed silently.
803    #[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}