Skip to main content

prov_graph/graph/
shadow.rs

1//! Shadowed payloads — the files prov can read but must not.
2//!
3//! `attach --opaque` makes a promise: prov will link, move and fixity-check this
4//! file, but never read *it* as a document. When the payload happens to be
5//! something prov can parse — a `.md`, a `.yaml` — keeping that promise means
6//! every scan has to *notice* the sidecar beside it and skip the file. That
7//! check lives here rather than beside the `attach` verb because the check is a
8//! read: the census, the title scan and the id scan all owe it, and none of them
9//! is attaching anything.
10//!
11//! The convention is the fast path and the `content` pointer is authoritative. A
12//! sidecar under a non-conventional name still claims its payload; it just is
13//! not found by probing, which is why `attach`'s own sweep confirms the pointer
14//! rather than trusting the name.
15
16use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18
19use super::Graph;
20use crate::fs::ReadStorage;
21use crate::index::IdIndex;
22use crate::link;
23
24const SIDECAR_EXTENSIONS: &[&str] = &["yaml", "yml", "json", "toml", "fig", "figl"];
25
26/// Every path that could be `payload`'s sidecar under the `<payload>.<ext>`
27/// convention, in reverse-lookup preference order. The probe half of the lookup;
28/// the `content` pointer confirms a hit ([`Graph::sidecar_claims`]).
29///
30/// Note the convention cannot collide with a *separated* document's metadata
31/// half, which replaces the extension (`note.md` → `note.yaml`) rather than
32/// appending to it (`note.md.yaml`).
33pub fn sidecar_candidates(payload: &Path) -> impl Iterator<Item = PathBuf> + '_ {
34    let name = payload
35        .file_name()
36        .and_then(|n| n.to_str())
37        .unwrap_or_default();
38    SIDECAR_EXTENSIONS
39        .iter()
40        .map(move |ext| payload.with_file_name(format!("{name}.{ext}")))
41}
42
43/// A directory listing turned around: which of its files are conventionally
44/// named sidecars, and for what.
45///
46/// The lookup used to run the other way — build all six `<payload>.<ext>`
47/// candidates for a file and ask an ordered set of the listing whether it holds
48/// any of them. That is six `PathBuf` allocations and six ordered-set probes
49/// *per file scanned*, to answer "no" in every workspace with no attachments in
50/// it; over twenty thousand documents it was 16% of a `check`, most of it
51/// comparing paths component by component inside the set.
52///
53/// Inverting it costs one pass over the listing — the same
54/// `<payload>.<ext>` convention read backwards, so the two are exact inverses —
55/// and leaves the per-file question a single hash lookup that usually misses.
56/// A workspace with no attachments builds an empty map and every probe is that
57/// miss.
58///
59/// The `content` pointer is still what confirms a hit
60/// ([`Graph::sidecar_claims`]); this only says which files are worth asking
61/// about.
62#[derive(Debug, Default, Clone)]
63pub struct ShadowProbe {
64    /// Payload path → the conventionally named sidecars actually present, in
65    /// [`sidecar_candidates`]' preference order.
66    sidecars: HashMap<PathBuf, Vec<PathBuf>>,
67}
68
69impl ShadowProbe {
70    /// Index the workspace-relative files a scan enumerated.
71    pub fn over<'a>(listing: impl IntoIterator<Item = &'a PathBuf>) -> Self {
72        let mut ranked: HashMap<PathBuf, Vec<(usize, PathBuf)>> = HashMap::new();
73        for entry in listing {
74            let Some(rank) = entry
75                .extension()
76                .and_then(|e| e.to_str())
77                .and_then(|ext| SIDECAR_EXTENSIONS.iter().position(|known| *known == ext))
78            else {
79                continue;
80            };
81            // `photo.jpg.yaml` names `photo.jpg` — `sidecar_candidates` run
82            // backwards, which is what makes this the same question.
83            ranked
84                .entry(entry.with_extension(""))
85                .or_default()
86                .push((rank, entry.clone()));
87        }
88        let sidecars = ranked
89            .into_iter()
90            .map(|(payload, mut found)| {
91                found.sort_by_key(|(rank, _)| *rank);
92                (payload, found.into_iter().map(|(_, path)| path).collect())
93            })
94            .collect();
95        Self { sidecars }
96    }
97
98    /// The sidecars the listing holds for `payload`, in preference order —
99    /// empty for a file nothing beside it could be claiming.
100    fn sidecars_for(&self, payload: &Path) -> &[PathBuf] {
101        self.sidecars.get(payload).map_or(&[], Vec::as_slice)
102    }
103}
104
105impl<FS: ReadStorage, Ix: IdIndex> Graph<FS, Ix> {
106    /// Whether the document at `candidate` is an attachment sidecar whose
107    /// `content` resolves to `payload` — the authoritative half of the reverse
108    /// lookup, the `<payload>.<ext>` convention above being only the probe.
109    ///
110    /// Requires [`is_attachment`](crate::Document::is_attachment), so a separated
111    /// *prose* node never reads as one: its body is a document in its own right,
112    /// and prov must keep scanning it. Unreadable or unparsable candidates simply
113    /// do not claim (this runs inside best-effort scans).
114    pub async fn sidecar_claims(&self, candidate: &Path, payload: &Path) -> bool {
115        let Ok((_, doc)) = self.load(candidate).await else {
116            return false;
117        };
118        let Some(content) = doc.content_attr() else {
119            return false;
120        };
121        let dir = candidate.parent().unwrap_or(Path::new(""));
122        doc.is_attachment() && link::normalize(dir.join(content)) == payload
123    }
124
125    /// Whether `path` — a file prov *can* read — has been deliberately shadowed:
126    /// claimed as an opaque payload by an attachment sidecar beside it. The
127    /// promise `attach --opaque` makes, enforced: prov links, moves and fixity-
128    /// checks the file (through its sidecar's own `content_hash`) but never
129    /// reads *it* as a document, so its title stays out of the title index, any
130    /// `id` it shows stays out of the registry, any `fields` value it carries is
131    /// never checked against a vocabulary, and any `content_hash` it shows is
132    /// never treated as its own.
133    ///
134    /// `probe` is the [`ShadowProbe`] over the workspace-relative files the
135    /// calling scan already enumerated (its directory read), so a shadow check
136    /// costs one hash lookup rather than a stat per metadata extension — this
137    /// runs per file in the flat title and id scans, and per reachable path in
138    /// the vocabulary and fixity passes
139    /// (`validate::Workspace::reachable_documents`). A sidecar outside the
140    /// listing the probe was built over therefore does not shadow, which is the
141    /// same bound the scans themselves observe.
142    pub async fn is_shadowed_payload(&self, path: &Path, probe: &ShadowProbe) -> bool {
143        for candidate in probe.sidecars_for(path) {
144            if self.sidecar_claims(candidate, path).await {
145                return true;
146            }
147        }
148        false
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    fn paths(names: &[&str]) -> Vec<PathBuf> {
157        names.iter().map(PathBuf::from).collect()
158    }
159
160    /// The probe is `sidecar_candidates` read backwards, so the two must agree
161    /// about every file in a listing — that equivalence is the whole argument
162    /// for inverting the lookup.
163    #[test]
164    fn the_probe_answers_what_probing_every_candidate_answered() {
165        let listing = paths(&[
166            "photo.jpg",
167            "photo.jpg.yaml",
168            "notes/scan.pdf",
169            "notes/scan.pdf.json",
170            "notes/a.md",
171            "loose.toml",
172        ]);
173        let probe = ShadowProbe::over(listing.iter());
174        for path in &listing {
175            let by_candidate: Vec<PathBuf> = sidecar_candidates(path)
176                .filter(|c| listing.contains(c))
177                .collect();
178            assert_eq!(
179                probe.sidecars_for(path),
180                by_candidate.as_slice(),
181                "{}",
182                path.display()
183            );
184        }
185    }
186
187    /// A payload with more than one conventional sidecar is reported in
188    /// `SIDECAR_EXTENSIONS` order however the directory listed them, because the
189    /// caller confirms them in that order and stops at the first that claims.
190    #[test]
191    fn several_sidecars_come_back_in_preference_order() {
192        let listing = paths(&["photo.jpg.figl", "photo.jpg.yaml", "photo.jpg.json"]);
193        let probe = ShadowProbe::over(listing.iter());
194        assert_eq!(
195            probe.sidecars_for(Path::new("photo.jpg")),
196            paths(&["photo.jpg.yaml", "photo.jpg.json", "photo.jpg.figl"]).as_slice()
197        );
198    }
199
200    /// The common case: nothing in the listing could be a sidecar, so every
201    /// file's probe is a miss and no candidate path is ever built.
202    #[test]
203    fn a_listing_with_no_sidecars_claims_nothing() {
204        let listing = paths(&["index.md", "a.md", "b.md"]);
205        let probe = ShadowProbe::over(listing.iter());
206        assert!(probe.sidecars.is_empty());
207        assert!(probe.sidecars_for(Path::new("a.md")).is_empty());
208    }
209}