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}