Skip to main content

prov_graph/
peer.rs

1//! prov's peer port — where the *other* workspaces are.
2//!
3//! [`Target::Foreign`](crate::graph::Target::Foreign) is where resolution
4//! stops. It can tell you that a reference names the workspace `notes` and the
5//! id `ajp7eq`; it cannot tell you where `notes` is, and deliberately does not
6//! try, because that map is a property of the device doing the reading rather
7//! than of the archive being read. The same reference resolves to a directory
8//! on one machine, a URL on another, and nothing at all on a third.
9//!
10//! This module is the seam between those two halves. It does not hold a map and
11//! never will. It declares the *shape* a host's map answers in — the third port
12//! beside [`fs::ReadStorage`](crate::fs::ReadStorage) and
13//! [`index::IdIndex`](crate::index::IdIndex), and the smallest of the three.
14//!
15//! ## Why a port at all, if prov holds no map
16//!
17//! Because two things about cross-workspace resolution *are* prov's, and before
18//! this module both were re-decided per host:
19//!
20//! 1. **What an answer is.** `prov-cli` answers with a directory on this disk;
21//!    diaryx answers with a published ARK permalink. Those are one type
22//!    ([`PeerLocation`]), and a consumer that can render either — an export
23//!    writing an `href`, a viewer offering to follow a link — should not need to
24//!    know which host it is talking to.
25//!
26//! 2. **What makes an answer trustworthy.** This is the load-bearing half. A
27//!    peer map is a claim about a name, and a wrong claim does not fail: it
28//!    resolves to *real documents in the wrong archive*. That failure mode is
29//!    the whole reason there is no peer table in `prov.yaml` — but it is also
30//!    fixable, because a prov workspace declares its own name
31//!    ([`workspace_id`](crate::graph::ReadSettings::workspace_id)). Comparing
32//!    the name asked for against the name found is a check only prov can
33//!    specify, and [`PeerLookup::confirm`] is where it happens, so no host
34//!    decides for itself what "confirmed" means.
35//!
36//! ## Where this port stops
37//!
38//! At an address. Nothing here opens a workspace, and nothing here can: reading
39//! the peer would need a second [`ReadStorage`](crate::fs::ReadStorage) and a
40//! second [`IdIndex`](crate::index::IdIndex), which only the host has. So a
41//! resolver hands back *where*, the host does the opening, and the layering the
42//! rest of this crate keeps — a read core that cannot reach past its own root —
43//! is undisturbed.
44//!
45//! That is also why no method on [`Graph`](crate::graph::Graph) takes a
46//! resolver and why `Graph` grows no third type parameter. Following a foreign
47//! reference is a *second step* after resolution, taken by a caller that wants
48//! it, not a deeper mode of the first one. A traversal that never follows one
49//! pays nothing, and the read core's generics stay two wide.
50
51use std::path::PathBuf;
52
53use crate::identity::Id;
54
55/// An address a resolver answers with: somewhere on this device, or somewhere
56/// on the network.
57///
58/// Both spellings are first-class. A peer that is a sibling directory and a
59/// peer that is a published site are the same kind of fact — "the host says it
60/// is over there" — and a consumer that handles only one of them would work for
61/// `prov-cli` and not for diaryx, or the reverse.
62///
63/// The same type answers for a workspace ([`PeerResolver::locate`], where it is
64/// the workspace root) and for one document inside it
65/// ([`PeerResolver::locate_document`], where it is the file). They are the same
66/// two spellings and nothing distinguishes them but which question was asked.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum PeerLocation {
69    /// A path on the device doing the reading. Absolute by convention: a
70    /// relative one would mean something different from each directory the host
71    /// is later run in, which is the per-device failure this whole design is
72    /// arranged around.
73    Path(PathBuf),
74    /// A URL. Never fetched here — prov does no network I/O — so this is
75    /// carried and handed back exactly as the host spelled it.
76    Url(String),
77}
78
79impl std::fmt::Display for PeerLocation {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        match self {
82            Self::Path(path) => write!(f, "{}", path.display()),
83            Self::Url(url) => write!(f, "{url}"),
84        }
85    }
86}
87
88/// Why a location on record could not be confirmed to be the workspace that was
89/// asked for.
90///
91/// Kept apart because what the reader has to *do* about them differs, and
92/// because two of the three are ordinary rather than wrong.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum Unconfirmed {
95    /// Nothing at the location could be opened as a workspace — the directory
96    /// is gone, or is not a workspace yet. Recording a peer before creating it
97    /// is reasonable, so this is a state to report, not an error to raise.
98    Unreadable,
99    /// The workspace is there and readable, but anonymous — it declares no
100    /// `workspace_id`, so there is nothing to compare the asked-for name
101    /// against. The fix belongs in the *peer*, not in the map.
102    Anonymous,
103    /// The resolver did not look. A [`PeerLocation::Url`] is the usual reason:
104    /// confirming one means a network round trip, which a synchronous resolver
105    /// will not take and prov would not take anywhere.
106    NotChecked,
107}
108
109impl std::fmt::Display for Unconfirmed {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        match self {
112            Self::Unreadable => f.write_str("no workspace could be read there"),
113            Self::Anonymous => f.write_str("that workspace does not name itself"),
114            Self::NotChecked => f.write_str("its name was not checked"),
115        }
116    }
117}
118
119/// What a host knows about where one workspace is — and how sure it is.
120///
121/// Note what is *not* here: an error case. A peer that cannot be found, cannot
122/// be read, or turns out to be someone else is never a failure, because a
123/// foreign reference is carried whether or not it resolves. Every variant is an
124/// answer.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum PeerLookup {
127    /// A location, and the workspace there declares the name that was asked
128    /// for. The only variant [`followable`](PeerLookup::followable) returns.
129    Confirmed(PeerLocation),
130    /// A location whose occupant could not be checked against the name. Usable
131    /// on the reader's say-so ([`followable_unverified`]), not on prov's.
132    ///
133    /// [`followable_unverified`]: PeerLookup::followable_unverified
134    Unconfirmed {
135        /// Where the host says the workspace is.
136        location: PeerLocation,
137        /// Why the claim could not be checked.
138        why: Unconfirmed,
139    },
140    /// A location occupied by a workspace that calls itself something else.
141    ///
142    /// This is the failure the design exists to prevent, caught: the map says
143    /// `notes` is here, the workspace here says it is `journal`, and following
144    /// that would land every `id:notes/…` reference on real documents in the
145    /// wrong archive. Never followable, by either accessor, at any insistence —
146    /// there is no reader preference that makes a known-wrong answer right.
147    Mismatched {
148        /// Where the host says the workspace is.
149        location: PeerLocation,
150        /// The name the workspace found there actually declares.
151        declares: String,
152    },
153    /// No location on record. The ordinary state — most workspaces have never
154    /// heard of most other workspaces.
155    Unknown,
156}
157
158impl PeerLookup {
159    /// Weigh a location against what the workspace there calls itself. **This
160    /// is the verification**, and a resolver that has read the peer's config
161    /// should reach [`Confirmed`](PeerLookup::Confirmed) only through here.
162    ///
163    /// `declares` is the peer's own
164    /// [`workspace_id`](crate::graph::ReadSettings::workspace_id), empty when it
165    /// is anonymous — the same convention that field already uses, so a host can
166    /// pass it straight through without deciding what an empty name means.
167    ///
168    /// Placing the comparison in a constructor is the point. A host that made
169    /// this judgment itself would be free to accept a near-miss, or to skip the
170    /// check on a fast path and still say `Confirmed`; here the only way to
171    /// claim confirmation is to have the evidence in hand at the call.
172    pub fn confirm(asked: &str, location: PeerLocation, declares: &str) -> Self {
173        if declares.is_empty() {
174            Self::Unconfirmed {
175                location,
176                why: Unconfirmed::Anonymous,
177            }
178        } else if declares == asked {
179            Self::Confirmed(location)
180        } else {
181            Self::Mismatched {
182                location,
183                declares: declares.to_string(),
184            }
185        }
186    }
187
188    /// A location whose workspace could not be opened at all.
189    pub fn unreadable(location: PeerLocation) -> Self {
190        Self::Unconfirmed {
191            location,
192            why: Unconfirmed::Unreadable,
193        }
194    }
195
196    /// A location the resolver did not check — a URL, typically.
197    pub fn unchecked(location: PeerLocation) -> Self {
198        Self::Unconfirmed {
199            location,
200            why: Unconfirmed::NotChecked,
201        }
202    }
203
204    /// The location to follow, or `None`. `Some` only when the workspace there
205    /// answered to the name asked for.
206    ///
207    /// This is the strict accessor and the default one. A caller that reaches
208    /// for it cannot resolve into the wrong archive, because the archive
209    /// confirmed it is the right one.
210    pub fn followable(&self) -> Option<&PeerLocation> {
211        match self {
212            Self::Confirmed(location) => Some(location),
213            _ => None,
214        }
215    }
216
217    /// The location to follow when the reader has accepted an unconfirmed one —
218    /// an anonymous peer, or a URL nothing local can check.
219    ///
220    /// Still `None` for [`Mismatched`](PeerLookup::Mismatched). The escape is
221    /// for *absent* evidence, never for evidence pointing the other way.
222    pub fn followable_unverified(&self) -> Option<&PeerLocation> {
223        match self {
224            Self::Confirmed(location) | Self::Unconfirmed { location, .. } => Some(location),
225            Self::Mismatched { .. } | Self::Unknown => None,
226        }
227    }
228
229    /// Every location on record, followable or not — for saying *why* a
230    /// reference did not resolve. A diagnostic must be able to name the
231    /// mismatched directory; that is the whole content of the complaint.
232    pub fn location(&self) -> Option<&PeerLocation> {
233        match self {
234            Self::Confirmed(location)
235            | Self::Unconfirmed { location, .. }
236            | Self::Mismatched { location, .. } => Some(location),
237            Self::Unknown => None,
238        }
239    }
240
241    /// Whether the host has no location on record at all.
242    pub fn is_unknown(&self) -> bool {
243        matches!(self, Self::Unknown)
244    }
245}
246
247/// A host's map from a workspace name to where that workspace is.
248///
249/// This is the trait a host implements to make foreign references followable.
250/// It is not generic over anything and takes `&self` throughout, so it is
251/// dyn-compatible: a consumer can hold `&dyn PeerResolver` and be handed
252/// `prov-cli`'s device-local peer file, diaryx's ARK resolution, or
253/// [`NoPeers`], without being written twice.
254///
255/// **The implementor's obligation** is the one prov cannot enforce from here:
256/// build every answer through [`PeerLookup::confirm`] when the peer's own name
257/// is readable, and through [`unreadable`](PeerLookup::unreadable) /
258/// [`unchecked`](PeerLookup::unchecked) when it is not. Returning
259/// `Confirmed` for a location whose occupant was never read is the one way to
260/// defeat this design, and it takes deliberate effort.
261pub trait PeerResolver {
262    /// Where the workspace named `workspace` is.
263    ///
264    /// A name that is not [well-formed](crate::link::is_valid_workspace_id)
265    /// has no workspace to find and should answer
266    /// [`Unknown`](PeerLookup::Unknown) rather than be looked up: it cannot be
267    /// any workspace's `workspace_id`, so a map entry matching it was
268    /// hand-written wrong.
269    fn locate(&self, workspace: &str) -> PeerLookup;
270
271    /// Where one *document* in that workspace is — the whole reference answered
272    /// at once, rather than the workspace it lives in.
273    ///
274    /// Defaults to `None`, because most resolvers cannot answer it. Turning
275    /// `id:notes/ajp7eq` into a file means opening `notes` and reading *its*
276    /// registry, which the host can do and this crate cannot; turning it into a
277    /// permalink means knowing that host's URL scheme. A caller that gets
278    /// `None` falls back to [`locate`](PeerResolver::locate) and does the
279    /// opening itself.
280    ///
281    /// The same obligation applies twice over: answer only for a workspace
282    /// whose identity you have confirmed. There is no [`PeerLookup`] wrapper on
283    /// this one to carry the doubt in, so an unconfirmed answer here is
284    /// indistinguishable from a confirmed one.
285    fn locate_document(&self, workspace: &str, id: &Id) -> Option<PeerLocation> {
286        let _ = (workspace, id);
287        None
288    }
289}
290
291impl<T: PeerResolver + ?Sized> PeerResolver for &T {
292    fn locate(&self, workspace: &str) -> PeerLookup {
293        (**self).locate(workspace)
294    }
295
296    fn locate_document(&self, workspace: &str, id: &Id) -> Option<PeerLocation> {
297        (**self).locate_document(workspace, id)
298    }
299}
300
301/// No peers — every workspace is somewhere this host cannot see.
302///
303/// The honest default rather than a degenerate one: it is what a workspace with
304/// no configured map already behaves like, and what every consumer that has not
305/// been given a resolver should use. Mirrors [`NoIndex`](crate::index::NoIndex).
306#[derive(Debug, Clone, Copy, Default)]
307pub struct NoPeers;
308
309impl PeerResolver for NoPeers {
310    fn locate(&self, _workspace: &str) -> PeerLookup {
311        PeerLookup::Unknown
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    fn dir(path: &str) -> PeerLocation {
320        PeerLocation::Path(PathBuf::from(path))
321    }
322
323    #[test]
324    fn a_workspace_answering_to_the_name_asked_for_is_confirmed() {
325        assert_eq!(
326            PeerLookup::confirm("notes", dir("/vaults/notes"), "notes"),
327            PeerLookup::Confirmed(dir("/vaults/notes"))
328        );
329    }
330
331    #[test]
332    fn a_workspace_calling_itself_something_else_is_never_followable() {
333        // The failure this design exists to prevent: the map says `notes`, the
334        // archive says `journal`, and following it would resolve every
335        // `id:notes/…` reference to real documents in the wrong workspace.
336        let lookup = PeerLookup::confirm("notes", dir("/vaults/journal"), "journal");
337        assert_eq!(
338            lookup,
339            PeerLookup::Mismatched {
340                location: dir("/vaults/journal"),
341                declares: "journal".into(),
342            }
343        );
344        assert_eq!(lookup.followable(), None);
345        // And not on insistence either — the reader's escape hatch is for
346        // missing evidence, not for evidence pointing the other way.
347        assert_eq!(lookup.followable_unverified(), None);
348        // But it is still nameable, because the diagnostic *is* the directory.
349        assert_eq!(lookup.location(), Some(&dir("/vaults/journal")));
350    }
351
352    #[test]
353    fn an_anonymous_peer_is_unconfirmed_rather_than_mismatched() {
354        // Nothing to compare against is not the same as comparing and
355        // disagreeing: the peer may well be `notes`, it just has not said so.
356        // So the strict accessor declines and the permissive one allows.
357        let lookup = PeerLookup::confirm("notes", dir("/vaults/notes"), "");
358        assert_eq!(
359            lookup,
360            PeerLookup::Unconfirmed {
361                location: dir("/vaults/notes"),
362                why: Unconfirmed::Anonymous,
363            }
364        );
365        assert_eq!(lookup.followable(), None);
366        assert_eq!(lookup.followable_unverified(), Some(&dir("/vaults/notes")));
367    }
368
369    #[test]
370    fn an_unchecked_url_is_followable_only_unverified() {
371        let lookup = PeerLookup::unchecked(PeerLocation::Url("https://diaryx.org".into()));
372        assert_eq!(lookup.followable(), None);
373        assert!(lookup.followable_unverified().is_some());
374        assert!(!lookup.is_unknown());
375    }
376
377    #[test]
378    fn an_unknown_peer_yields_no_location_at_all() {
379        let lookup = PeerLookup::Unknown;
380        assert!(lookup.is_unknown());
381        assert_eq!(lookup.location(), None);
382        assert_eq!(lookup.followable_unverified(), None);
383    }
384
385    #[test]
386    fn no_peers_knows_nothing_and_offers_no_documents() {
387        let id = Id("ajp7eq".into());
388        assert_eq!(NoPeers.locate("notes"), PeerLookup::Unknown);
389        assert_eq!(NoPeers.locate_document("notes", &id), None);
390    }
391
392    /// A resolver held behind `&dyn` works, which is the whole reason the trait
393    /// takes no generics: one consumer serves `prov-cli`'s peer file and
394    /// diaryx's ARK resolution without being written twice.
395    #[test]
396    fn a_resolver_is_usable_through_a_trait_object() {
397        struct One;
398        impl PeerResolver for One {
399            fn locate(&self, workspace: &str) -> PeerLookup {
400                PeerLookup::confirm(workspace, dir("/vaults/notes"), "notes")
401            }
402        }
403        let erased: &dyn PeerResolver = &One;
404        assert!(erased.locate("notes").followable().is_some());
405        assert_eq!(erased.locate("other").followable(), None);
406        // And the blanket impl for references composes with it, so a caller
407        // holding `&&dyn` or a plain `&One` is not a different consumer.
408        fn ask(peers: impl PeerResolver) -> bool {
409            peers.locate("notes").followable().is_some()
410        }
411        assert!(ask(erased));
412        assert!(ask(&One));
413    }
414}