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}