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::BTreeSet;
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
43impl<FS: ReadStorage, Ix: IdIndex> Graph<FS, Ix> {
44 /// Whether the document at `candidate` is an attachment sidecar whose
45 /// `content` resolves to `payload` — the authoritative half of the reverse
46 /// lookup, the `<payload>.<ext>` convention above being only the probe.
47 ///
48 /// Requires [`is_attachment`](crate::Document::is_attachment), so a separated
49 /// *prose* node never reads as one: its body is a document in its own right,
50 /// and prov must keep scanning it. Unreadable or unparsable candidates simply
51 /// do not claim (this runs inside best-effort scans).
52 pub async fn sidecar_claims(&self, candidate: &Path, payload: &Path) -> bool {
53 let Ok((_, doc)) = self.load(candidate).await else {
54 return false;
55 };
56 let Some(content) = doc.content_attr() else {
57 return false;
58 };
59 let dir = candidate.parent().unwrap_or(Path::new(""));
60 doc.is_attachment() && link::normalize(dir.join(content)) == payload
61 }
62
63 /// Whether `path` — a file prov *can* read — has been deliberately shadowed:
64 /// claimed as an opaque payload by an attachment sidecar beside it. The
65 /// promise `attach --opaque` makes, enforced: prov links, moves and fixity-
66 /// checks the file (through its sidecar's own `content_hash`) but never
67 /// reads *it* as a document, so its title stays out of the title index, any
68 /// `id` it shows stays out of the registry, any `fields` value it carries is
69 /// never checked against a vocabulary, and any `content_hash` it shows is
70 /// never treated as its own.
71 ///
72 /// `listing` is the set of workspace-relative files the calling scan already
73 /// enumerated (its directory read), so a shadow check costs a set lookup
74 /// rather than a stat per metadata extension — this runs per file in the flat
75 /// title and id scans, and per reachable path in the vocabulary and fixity
76 /// passes (`validate::Workspace::reachable_documents`). A sidecar outside
77 /// the listing therefore does not shadow, which is the same bound the scans
78 /// themselves observe.
79 pub async fn is_shadowed_payload(&self, path: &Path, listing: &BTreeSet<PathBuf>) -> bool {
80 for candidate in sidecar_candidates(path) {
81 if listing.contains(&candidate) && self.sidecar_claims(&candidate, path).await {
82 return true;
83 }
84 }
85 false
86 }
87}