Skip to main content

prov_graph/
manifest.rs

1//! Manifests — one node standing for a whole directory of opaque files.
2//!
3//! An [attachment](crate::document::Document::is_attachment) gives *one* file
4//! workspace-linked metadata by minting a sidecar beside it. That trade stops
5//! working at scale: a directory of ten thousand photographs would mean ten
6//! thousand sidecars, which is not an archive anyone can read, edit or sync.
7//!
8//! A **manifest** is the bulk form of the same idea. A node declares
9//! `manifest: photos.manifest.yaml` — mutually exclusive with `content`, because
10//! a node stands for one payload or for a set, never both — and that document is
11//! a whole-file record store listing the files under a directory it names:
12//!
13//! ```yaml
14//! # photos.manifest.yaml
15//! title: Photos — manifest
16//! root: photos/
17//! files:
18//!   - path: 2019/IMG_0001.jpg
19//!     hash: sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae
20//!   - path: 2019/IMG_0002.jpg
21//!     hash: sha256:fcde2b2edba56bf408601fb721fe9b5c338d10ee429ea04fae5511b68fbf8fb9
22//! ```
23//!
24//! Three properties fall out of that shape, and each is load-bearing:
25//!
26//! - **Rows are relative to `root`, not to the workspace.** Moving the covered
27//!   directory rewrites one line (`root:`) rather than every row, which for ten
28//!   thousand of them is the difference between a move and a rewrite.
29//! - **`hash` is optional.** A manifest with no hashes is an inventory — what is
30//!   supposed to be here — and one with hashes is that plus a fixity baseline.
31//!   Hashing ten thousand files has a real cost, so it is a choice, not a tax.
32//! - **The manifest is hashed by its node.** The sidecar's `content_hash` covers
33//!   the manifest document's bytes exactly as an attachment's covers its
34//!   payload's, which is what makes the per-file hashes trustworthy: tampering
35//!   with a row means tampering with the file the node has already pinned.
36//!
37//! `root` claims the directory **completely** for opaque payloads: a file under
38//! it that no row names is drift prov reports, which is the question a photo
39//! archive actually has ("did something appear, did something vanish?") and the
40//! one an open-ended list can never answer. Files prov *can* read as documents
41//! are not claimed — they stay ordinary documents, linked and orphan-checked as
42//! usual — so a manifest never shadows a document, and the "opacity is a role"
43//! escape hatch (`attach --opaque`) stays a single-file affair.
44//!
45//! This module is the model and its serialization, which is all the read core
46//! needs; the verbs that build, refresh and verify one are `prov`'s.
47
48use std::path::{Path, PathBuf};
49
50use crate::error::{Error, Result};
51use crate::meta::{Mapping, Value};
52
53/// The node key naming a manifest document — the bulk counterpart of `content`,
54/// and mutually exclusive with it.
55pub const MANIFEST_KEY: &str = "manifest";
56
57/// The manifest key naming the directory it covers, relative to the manifest
58/// document's own directory.
59pub const ROOT_KEY: &str = "root";
60
61/// The manifest key holding the rows.
62pub const FILES_KEY: &str = "files";
63
64/// The per-row key naming the file, relative to [`ROOT_KEY`].
65pub const PATH_KEY: &str = "path";
66
67/// The per-row key holding the digest, spelled `sha256:<hex>` as
68/// `prov_fixity::digest` produces it. Optional.
69pub const HASH_KEY: &str = "hash";
70
71/// The infix a manifest document's name carries, so a node's manifest is found
72/// beside it by convention (`photos.yaml` ↔ `photos.manifest.yaml`) the way an
73/// attachment's payload is.
74pub const MANIFEST_INFIX: &str = "manifest";
75
76/// One row: a covered file and, when the manifest carries a fixity baseline,
77/// the digest of its bytes.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct ManifestEntry {
80    /// The file, relative to the manifest's `root` and normalized.
81    pub path: PathBuf,
82    /// `sha256:<hex>`, or `None` in an unhashed (inventory-only) manifest.
83    pub hash: Option<String>,
84}
85
86/// A parsed manifest document: the directory it claims, and the files it says
87/// are in it.
88#[derive(Debug, Clone, PartialEq, Eq, Default)]
89pub struct Manifest {
90    /// The covered directory as written, relative to the manifest document's
91    /// own directory.
92    pub root: String,
93    /// The rows, sorted by [`path_sort_key`] when this library wrote them; a
94    /// manifest read back off disk keeps whatever order it was written in.
95    pub files: Vec<ManifestEntry>,
96}
97
98impl Manifest {
99    /// Read a manifest out of a loaded document's metadata.
100    ///
101    /// Strict about the two things a reader must be able to trust — a `root` it
102    /// can resolve and rows that name a path — and permissive about everything
103    /// else, since a manifest is a document a person may edit: an unknown key is
104    /// carried past, and a row that is not a mapping with a `path` is refused
105    /// rather than silently dropped, because a dropped row reads as "that file
106    /// was never claimed" and would turn a damaged manifest into a clean report.
107    pub fn from_meta(meta: &Value) -> Result<Self> {
108        let root = meta
109            .get(ROOT_KEY)
110            .and_then(Value::as_str)
111            .ok_or_else(|| Error::Structure(format!("manifest has no `{ROOT_KEY}`")))?
112            .to_string();
113        let rows = match meta.get(FILES_KEY) {
114            // A manifest over an empty directory has no rows at all, which is a
115            // legitimate state (`files:` written as null, or absent).
116            None => &[][..],
117            Some(Value::Null) => &[][..],
118            Some(value) => value.as_sequence().ok_or_else(|| {
119                Error::Structure(format!("manifest `{FILES_KEY}` must be a sequence"))
120            })?,
121        };
122        let mut files = Vec::with_capacity(rows.len());
123        for (i, row) in rows.iter().enumerate() {
124            let path = row
125                .get(PATH_KEY)
126                .and_then(Value::as_str)
127                .ok_or_else(|| Error::Structure(format!("manifest row {i} has no `{PATH_KEY}`")))?;
128            if crate::link::escapes_root(path) {
129                return Err(Error::Structure(format!(
130                    "manifest row {i} (`{path}`) climbs outside the manifest's root"
131                )));
132            }
133            files.push(ManifestEntry {
134                path: crate::link::normalize(path),
135                hash: row
136                    .get(HASH_KEY)
137                    .and_then(Value::as_str)
138                    .map(str::to_string),
139            });
140        }
141        Ok(Manifest { root, files })
142    }
143
144    /// The manifest as a whole-file document mapping, `title` first — the shape
145    /// [`from_meta`](Self::from_meta) reads back.
146    ///
147    /// Rows are emitted in the order they are held; a manifest this library
148    /// builds is sorted by [`path_sort_key`] first, so the file a person opens
149    /// reads like a directory listing rather than a walk order.
150    pub fn to_mapping(&self, title: &str) -> Mapping {
151        let mut map = Mapping::new();
152        map.insert("title".into(), Value::String(title.to_string()));
153        map.insert(ROOT_KEY.into(), Value::String(self.root.clone()));
154        map.insert(
155            FILES_KEY.into(),
156            Value::Sequence(
157                self.files
158                    .iter()
159                    .map(|entry| {
160                        let mut row = Mapping::new();
161                        row.insert(PATH_KEY.into(), Value::String(slash_path(&entry.path)));
162                        if let Some(hash) = &entry.hash {
163                            row.insert(HASH_KEY.into(), Value::String(hash.clone()));
164                        }
165                        Value::Mapping(row)
166                    })
167                    .collect(),
168            ),
169        );
170        map
171    }
172
173    /// The covered directory, workspace-relative, given where the manifest
174    /// document itself lives.
175    pub fn covered_root(&self, manifest_doc: &Path) -> PathBuf {
176        crate::link::resolve(manifest_doc, &self.root)
177    }
178
179    /// [`covered_root`](Self::covered_root), refusing one that lands outside the
180    /// workspace.
181    ///
182    /// The check cannot live in [`from_meta`](Self::from_meta), which has no
183    /// path to resolve against: `root` is relative to the *manifest's* directory,
184    /// so `../photos/` is an ordinary, correct value for a manifest one
185    /// directory down — and is exactly what a rename writes. Only the resolved
186    /// path can say whether a workspace boundary was crossed.
187    pub fn checked_root(&self, manifest_doc: &Path) -> Result<PathBuf> {
188        let root = self.covered_root(manifest_doc);
189        if crate::link::escapes_root(&root) {
190            return Err(Error::Structure(format!(
191                "manifest `{ROOT_KEY}: {}` climbs outside the workspace",
192                self.root
193            )));
194        }
195        Ok(root)
196    }
197
198    /// A row's file, workspace-relative — the covered root joined with the row's
199    /// own relative path.
200    pub fn file_path(&self, manifest_doc: &Path, entry: &ManifestEntry) -> PathBuf {
201        crate::link::normalize(self.covered_root(manifest_doc).join(&entry.path))
202    }
203
204    /// Sort the rows into the order this library writes them (§ the module doc:
205    /// byte-wise on the `/`-joined path), so two manifests built from the same
206    /// directory are the same bytes.
207    pub fn sort(&mut self) {
208        self.files.sort_by(|a, b| {
209            path_sort_key(&a.path)
210                .cmp(&path_sort_key(&b.path))
211                .then_with(|| a.hash.cmp(&b.hash))
212        });
213    }
214
215    /// Whether every row carries a digest — the difference between an inventory
216    /// and a fixity baseline. An empty manifest counts as hashed: it makes the
217    /// same promise about all zero of its files.
218    pub fn is_hashed(&self) -> bool {
219        self.files.iter().all(|entry| entry.hash.is_some())
220    }
221}
222
223/// How a manifest's rows and a directory listing disagree: `(missing, extra)` —
224/// rows whose file is not on disk, and opaque files under the root that no row
225/// claims. Both relative to the root, both sorted.
226///
227/// The completeness rule in one place, because it is the whole meaning of
228/// `root`: the manifest claims that directory entirely, so a file it does not
229/// name is drift and not merely an omission. `check` and the `manifest` report
230/// both ask this question and must not be able to answer it differently.
231pub fn diff(listed: &[ManifestEntry], on_disk: &[PathBuf]) -> (Vec<PathBuf>, Vec<PathBuf>) {
232    let rows: std::collections::BTreeSet<&Path> = listed.iter().map(|e| e.path.as_path()).collect();
233    let disk: std::collections::BTreeSet<&Path> = on_disk.iter().map(PathBuf::as_path).collect();
234    (
235        rows.difference(&disk).map(|p| p.to_path_buf()).collect(),
236        disk.difference(&rows).map(|p| p.to_path_buf()).collect(),
237    )
238}
239
240/// A path spelled with `/` separators regardless of host platform — what a
241/// manifest row holds, so a manifest written on Windows and one written on Linux
242/// describe the same directory identically.
243pub fn slash_path(path: &Path) -> String {
244    path.components()
245        .map(|c| c.as_os_str().to_string_lossy())
246        .collect::<Vec<_>>()
247        .join("/")
248}
249
250/// The row sort key: byte-wise ascending on the `/`-joined UTF-8 path, **not**
251/// `Path::cmp`, which orders component-wise and disagrees with it (`a.jpg` vs
252/// `a/b.jpg`: joined, `.` sorts before `/`; component-wise, the bare `a` is a
253/// prefix of `a.jpg` and so sorts first). The rows end up serialized as those
254/// joined strings, so the joined order is the one a reader sees.
255pub fn path_sort_key(path: &Path) -> String {
256    slash_path(path)
257}
258
259/// Where a node's manifest document sits: beside it, with `manifest` infixed
260/// before the metadata extension (`photos.yaml` → `photos.manifest.yaml`).
261///
262/// The infix rather than a replaced extension, for the same reason an attachment
263/// sidecar appends rather than replaces: the node and its manifest are both
264/// whole-file metadata documents in the same format, so a convention that only
265/// swapped the extension would name the node itself.
266pub fn manifest_sibling(node: &Path) -> PathBuf {
267    let stem = node
268        .file_stem()
269        .and_then(|s| s.to_str())
270        .unwrap_or_default();
271    let ext = node.extension().and_then(|e| e.to_str()).unwrap_or("yaml");
272    node.with_file_name(format!("{stem}.{MANIFEST_INFIX}.{ext}"))
273}
274
275/// Every path that could be the node covering the directory `dir`, under the
276/// `<dir>.<ext>` convention — the probe half of the reverse lookup, exactly as
277/// [`sidecar_candidates`](crate::graph::sidecar_candidates) is for a payload.
278/// The node's `manifest` pointer, and that manifest's `root`, are what confirm a
279/// hit.
280pub fn manifest_node_candidates(dir: &Path) -> impl Iterator<Item = PathBuf> + '_ {
281    crate::graph::sidecar_candidates(dir)
282}
283
284#[cfg(all(test, feature = "yaml"))]
285mod tests {
286    use super::*;
287
288    fn parse(text: &str) -> Result<Manifest> {
289        let map = crate::meta::parse_mapping(text, fig::Format::Yaml).unwrap();
290        Manifest::from_meta(&Value::Mapping(map))
291    }
292
293    #[test]
294    fn reads_rows_with_and_without_hashes() {
295        let m = parse(
296            "title: Photos\nroot: photos/\nfiles:\n\
297             - path: a.jpg\n  hash: sha256:abc\n\
298             - path: sub/b.jpg\n",
299        )
300        .unwrap();
301        assert_eq!(m.root, "photos/");
302        assert_eq!(m.files.len(), 2);
303        assert_eq!(m.files[0].hash.as_deref(), Some("sha256:abc"));
304        assert_eq!(m.files[1].path, PathBuf::from("sub/b.jpg"));
305        assert!(!m.is_hashed(), "one row carries no digest");
306    }
307
308    #[test]
309    fn an_empty_manifest_is_legal_and_a_rootless_one_is_not() {
310        assert!(parse("root: photos/\n").unwrap().files.is_empty());
311        assert!(parse("root: photos/\nfiles:\n").unwrap().files.is_empty());
312        assert!(parse("files:\n- path: a.jpg\n").is_err(), "no root");
313    }
314
315    #[test]
316    fn a_row_without_a_path_is_refused_rather_than_dropped() {
317        // Dropping it would read as "that file was never claimed", turning a
318        // damaged manifest into a clean report — the one failure mode a fixity
319        // record must not have.
320        let err = parse("root: photos/\nfiles:\n- hash: sha256:abc\n").unwrap_err();
321        assert!(err.to_string().contains("path"), "{err}");
322    }
323
324    #[test]
325    fn neither_root_nor_row_may_climb_out_of_the_workspace() {
326        // A row climbs out of the manifest's own root, which nothing resolves
327        // away, so it is refused at parse time.
328        assert!(parse("root: photos/\nfiles:\n- path: ../../etc/passwd\n").is_err());
329
330        // A `root` can only be judged once resolved: `../photos/` is correct for
331        // a manifest one directory down (and is what a rename writes), while the
332        // same spelling from the workspace root is an escape.
333        let m = parse("root: ../photos/\n").unwrap();
334        assert!(
335            m.checked_root(Path::new("albums/trip.manifest.yaml"))
336                .is_ok()
337        );
338        assert!(m.checked_root(Path::new("trip.manifest.yaml")).is_err());
339    }
340
341    #[test]
342    fn rows_resolve_against_the_root_not_the_workspace() {
343        let m = parse("root: photos/\nfiles:\n- path: 2019/a.jpg\n").unwrap();
344        let doc = Path::new("albums/trip.manifest.yaml");
345        assert_eq!(m.covered_root(doc), PathBuf::from("albums/photos"));
346        assert_eq!(
347            m.file_path(doc, &m.files[0]),
348            PathBuf::from("albums/photos/2019/a.jpg")
349        );
350    }
351
352    #[test]
353    fn rows_sort_byte_wise_on_the_joined_path() {
354        // `a.jpg` before `a/b.jpg` — the order the serialized file shows, which
355        // `Path::cmp` would invert.
356        let mut m = Manifest {
357            root: "photos/".into(),
358            files: vec![
359                ManifestEntry {
360                    path: PathBuf::from("a/b.jpg"),
361                    hash: None,
362                },
363                ManifestEntry {
364                    path: PathBuf::from("a.jpg"),
365                    hash: None,
366                },
367            ],
368        };
369        m.sort();
370        assert_eq!(m.files[0].path, PathBuf::from("a.jpg"));
371    }
372
373    #[test]
374    fn round_trips_through_a_mapping() {
375        let m = parse("root: photos/\nfiles:\n- path: a.jpg\n  hash: sha256:abc\n").unwrap();
376        let text =
377            crate::meta::serialize_mapping(&m.to_mapping("Photos — manifest"), fig::Format::Yaml)
378                .unwrap();
379        assert!(text.contains("root: photos/"), "{text}");
380        assert_eq!(parse(&text).unwrap(), m);
381    }
382
383    #[test]
384    fn the_manifest_sits_beside_its_node_without_naming_it() {
385        assert_eq!(
386            manifest_sibling(Path::new("albums/photos.yaml")),
387            PathBuf::from("albums/photos.manifest.yaml")
388        );
389        assert_ne!(
390            manifest_sibling(Path::new("photos.yaml")),
391            PathBuf::from("photos.yaml")
392        );
393    }
394}