Skip to main content

didbot_lookup/
fetch.rs

1//! The one round trip behind a cache miss, and what this reader will not
2//! make it for.
3
4use std::future::Future;
5use std::pin::Pin;
6use std::sync::Arc;
7
8use didbot_http::{AddressError, PrivateAddresses};
9use didbot_identity::DidDocument;
10use serde::Deserialize;
11use serde_json::Value;
12use url::Url;
13
14use crate::record::RecordRef;
15
16/// The most of one record's JSON this reader holds in memory.
17///
18/// **Reasonable range: 8 KiB to 256 KiB.** The records a gate is asked
19/// about are small — a reply gate lists at most five rules, an AI
20/// preference four axes — and this bound is what makes the cache's memory
21/// arithmetic in [`Limits`](crate::Limits) hold: entries times this.
22/// Smaller than [`didbot_http::MAX_JSON_BODY`], which bounds one document
23/// held once rather than [`Limits::entries`](crate::Limits::entries) of
24/// them held at the same time.
25pub const MAX_RECORD_BYTES: usize = 64 * 1024;
26
27/// The atproto error name a repository answers with for "there is no
28/// record at that key" -- the one 400 this reader reads as absence rather
29/// than as a failure, exactly as `didbot_serve::operator_poll` does.
30const RECORD_NOT_FOUND: &str = "RecordNotFound";
31
32/// A future a [`Fetch`] implementation hands back, boxed so the trait stays
33/// object-safe and a reader can be held as `Arc<dyn Fetch>`.
34pub type FetchFuture<'a> = Pin<Box<dyn Future<Output = Fetched> + Send + 'a>>;
35
36/// Why a record's value is not known.
37///
38/// Every variant is distinct from [`Answer::Absent`](crate::Answer), which
39/// is the repository having answered that it holds nothing at that key.
40/// This reader never collapses the two: a rule that refuses on absence
41/// would otherwise start refusing the moment somebody else's server went
42/// down.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum Unknown {
45    /// Nothing has fetched this record, so the cache holds no answer for
46    /// it. What a read gets before its write's prefetch has run, and again
47    /// once an answer has aged out.
48    NotFetched,
49    /// The fetch did not finish inside its deadline.
50    Deadline,
51    /// This reader will not fetch from there. Carries why.
52    Refused(String),
53    /// There is no way to resolve that DID to a server.
54    Unresolvable(String),
55    /// The repository did not answer, or answered with something other
56    /// than a record.
57    Unreachable(String),
58}
59
60impl Unknown {
61    /// A short word for a log line and a counter.
62    #[must_use]
63    pub fn as_str(&self) -> &'static str {
64        match self {
65            Unknown::NotFetched => "not-fetched",
66            Unknown::Deadline => "deadline",
67            Unknown::Refused(_) => "refused",
68            Unknown::Unresolvable(_) => "unresolvable",
69            Unknown::Unreachable(_) => "unreachable",
70        }
71    }
72}
73
74/// What one round trip established.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum Fetched {
77    /// The repository holds records there. Always an array, each element
78    /// `{"uri": …, "value": …}` — one element for a reference naming one
79    /// record, one per record for a reference naming a collection. One
80    /// shape means a caller reads both the same way, and the uri is what a
81    /// refusal carries so a person can open the record it came from.
82    Present(Value),
83    /// The repository answered, and holds nothing there: no record at that
84    /// key, or no record in that collection.
85    Absent,
86    /// Neither was established; see [`Unknown`].
87    Unknown(Unknown),
88}
89
90/// One round trip to a repository this deployment does not hold.
91///
92/// Separate from the cache so the cache's own behaviour — its bounds, its
93/// TTLs, its counters — is testable with no network, and so a deployment
94/// that resolves repositories some other way substitutes this rather than
95/// the reader.
96pub trait Fetch: Send + Sync {
97    /// Read `record` from the repository that holds it.
98    fn fetch<'a>(&'a self, record: &'a RecordRef) -> FetchFuture<'a>;
99}
100
101/// What this deployment already holds, so the reader never fetches it over
102/// the network.
103///
104/// A repository this server hosts is read from its own store, and a name
105/// this server answers to is this server. Both are refusals rather than
106/// fetches: a DID document that points back here, whether stale or
107/// planted, would otherwise turn every foreign read into a request this
108/// deployment makes of itself.
109pub trait LocalRepositories: Send + Sync {
110    /// Whether this deployment holds `did`'s repository.
111    fn holds(&self, did: &str) -> bool;
112    /// Whether `host` is a name this deployment answers to.
113    fn serves(&self, host: &str) -> bool;
114}
115
116/// A deployment that holds nothing, for a caller with no registry to ask —
117/// a test, or a reader pointed only at the public network.
118#[derive(Debug, Clone, Copy, Default)]
119pub struct NoLocalRepositories;
120
121impl LocalRepositories for NoLocalRepositories {
122    fn holds(&self, _did: &str) -> bool {
123        false
124    }
125    fn serves(&self, _host: &str) -> bool {
126        false
127    }
128}
129
130/// What `com.atproto.repo.getRecord` answers with on success.
131#[derive(Debug, Deserialize)]
132struct GetRecordResponse {
133    value: Value,
134}
135
136/// What `com.atproto.repo.listRecords` answers with on success.
137#[derive(Debug, Deserialize)]
138struct ListRecordsResponse {
139    records: Vec<ListedRecord>,
140    #[serde(default)]
141    cursor: Option<String>,
142}
143
144/// One record in a [`ListRecordsResponse`].
145#[derive(Debug, Deserialize)]
146struct ListedRecord {
147    uri: String,
148    value: Value,
149}
150
151/// The most records one collection read takes from a repository.
152///
153/// **Reasonable range: 10 to 100.** It is the `limit` the request carries
154/// and the page it reads; a repository holding more than this in the
155/// collection asked for is answered [`Unknown::Unreachable`] rather than
156/// with a part of the set, because a rule that resolved a preference from
157/// the first page would be deciding on records it chose not to read. One
158/// hundred is atproto's own maximum for the method, and the sets this
159/// reader was built for -- a global default and a handful of scopes -- are
160/// two orders of magnitude under it.
161pub const MAX_COLLECTION_RECORDS: usize = 100;
162
163/// The real fetch: resolve the DID to its `#atproto_pds` endpoint, then
164/// `com.atproto.repo.getRecord` there.
165///
166/// Both hops go through [`didbot_http::pinned_client`], which resolves the
167/// host once, refuses it at a private address, pins the connection to what
168/// it resolved to and follows no redirect. Both hops read a bounded body.
169/// Every URL here is named by somebody else — the DID's own document says
170/// which server holds the repository — which is the same posture
171/// `didbot_serve::tree::oidc` takes toward an issuer a token names.
172pub struct Network {
173    private: PrivateAddresses,
174    local: Arc<dyn LocalRepositories>,
175}
176
177impl Network {
178    /// A fetcher that refuses what `local` already holds, and refuses a
179    /// private address unless `private` allows one.
180    #[must_use]
181    pub fn new(local: Arc<dyn LocalRepositories>, private: PrivateAddresses) -> Self {
182        Self { private, local }
183    }
184
185    /// The `#atproto_pds` endpoint `did`'s document names.
186    async fn endpoint(&self, did: &str) -> Result<String, Unknown> {
187        let document_url = didbot_identity::document_url(did)
188            .map_err(|error| Unknown::Unresolvable(format!("{did}: {error}")))?;
189        let body = self
190            .get_bounded(&document_url, didbot_http::MAX_JSON_BODY)
191            .await?;
192        let document: DidDocument = serde_json::from_slice(&body)
193            .map_err(|error| Unknown::Unreachable(format!("parsing {document_url}: {error}")))?;
194        // The check `didbot_identity::resolve` and the operator poll both
195        // make: a document served at the right URL claiming a different
196        // subject is a document about somebody else, and the server it
197        // names holds somebody else's repository.
198        if document.id != did {
199            return Err(Unknown::Unreachable(format!(
200                "the did document at {document_url} claims {:?}, expected {did:?}",
201                document.id
202            )));
203        }
204        let endpoint = document.pds_endpoint().ok_or_else(|| {
205            Unknown::Unreachable(format!(
206                "{did}'s did document names no #atproto_pds service"
207            ))
208        })?;
209        Ok(endpoint.to_owned())
210    }
211
212    /// A GET through a client pinned to `url`'s own addresses, with its
213    /// body read against `max_bytes`.
214    async fn get_bounded(&self, url: &str, max_bytes: usize) -> Result<Vec<u8>, Unknown> {
215        let response = self.send(url).await?;
216        let response = response
217            .error_for_status()
218            .map_err(|error| Unknown::Unreachable(format!("{url} answered: {error}")))?;
219        didbot_http::read_bounded(response, max_bytes)
220            .await
221            .map_err(|error| Unknown::Unreachable(format!("reading {url}: {error}")))
222    }
223
224    /// A GET through a client pinned to `url`'s own addresses, with the
225    /// status left to the caller.
226    async fn send(&self, url: &str) -> Result<reqwest::Response, Unknown> {
227        let parsed =
228            Url::parse(url).map_err(|_| Unknown::Unreachable(format!("{url:?} is not a URL")))?;
229        if let Some(host) = parsed.host_str() {
230            if self.local.serves(host) {
231                return Err(Unknown::Refused(format!(
232                    "{host} is this deployment; a repository it holds is read from its own store"
233                )));
234            }
235        }
236        let client = didbot_http::pinned_client(&parsed, self.private)
237            .await
238            .map_err(|error| match error {
239                AddressError::Refused { .. } => Unknown::Refused(error.to_string()),
240                other => Unknown::Unreachable(other.to_string()),
241            })?;
242        client
243            .get(parsed)
244            .send()
245            .await
246            .map_err(|error| Unknown::Unreachable(format!("fetching {url}: {error}")))
247    }
248
249    /// `com.atproto.repo.listRecords` for every record in `collection` at
250    /// `endpoint`, as an array of `{"uri": …, "value": …}`.
251    ///
252    /// An empty collection is [`Fetched::Absent`], the same answer a key
253    /// nothing holds gets: the repository answered, and holds nothing
254    /// there.
255    async fn list_records(&self, endpoint: &str, collection: &RecordRef) -> Fetched {
256        let url = format!(
257            "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit={MAX_COLLECTION_RECORDS}",
258            endpoint.trim_end_matches('/'),
259            urlencode(&collection.did),
260            urlencode(&collection.collection),
261        );
262        let body = match self.get_bounded(&url, MAX_RECORD_BYTES).await {
263            Ok(body) => body,
264            Err(unknown) => return Fetched::Unknown(unknown),
265        };
266        let listed: ListRecordsResponse = match serde_json::from_slice(&body) {
267            Ok(listed) => listed,
268            Err(error) => {
269                return Fetched::Unknown(Unknown::Unreachable(format!("parsing {url}: {error}")))
270            }
271        };
272        if listed.cursor.is_some() && listed.records.len() >= MAX_COLLECTION_RECORDS {
273            return Fetched::Unknown(Unknown::Unreachable(format!(
274                "{} holds more than {MAX_COLLECTION_RECORDS} records, which is more of somebody \
275                 else's repository than one write reads",
276                collection.at_uri()
277            )));
278        }
279        if listed.records.is_empty() {
280            return Fetched::Absent;
281        }
282        Fetched::Present(Value::Array(
283            listed
284                .records
285                .into_iter()
286                .map(|record| serde_json::json!({ "uri": record.uri, "value": record.value }))
287                .collect(),
288        ))
289    }
290
291    /// `com.atproto.repo.getRecord` for `record` at `endpoint`, as an array
292    /// holding the one `{"uri": …, "value": …}` it found, so a caller reads
293    /// one record and a whole collection the same way.
294    async fn get_record(&self, endpoint: &str, record: &RecordRef, rkey: &str) -> Fetched {
295        let url = format!(
296            "{}/xrpc/com.atproto.repo.getRecord?repo={}&collection={}&rkey={}",
297            endpoint.trim_end_matches('/'),
298            urlencode(&record.did),
299            urlencode(&record.collection),
300            urlencode(rkey),
301        );
302        let response = match self.send(&url).await {
303            Ok(response) => response,
304            Err(unknown) => return Fetched::Unknown(unknown),
305        };
306        // atproto answers "no such record" as a 400 naming `RecordNotFound`
307        // in the error body, and a well-behaved implementation may answer
308        // 404 instead, so both can mean absence. Every other 400 is a
309        // repository that was reached and refused the request -- a
310        // collection it does not know, a proxy rewriting the query -- and
311        // reading those as absence would have a rule decide on a fact
312        // nobody established.
313        let status = response.status().as_u16();
314        if status == 404 {
315            return Fetched::Absent;
316        }
317        if status == 400 {
318            // Under the bound a record is read under: a body past it names
319            // no error, and the answer is unknown rather than absent.
320            let body = didbot_http::read_bounded(response, MAX_RECORD_BYTES)
321                .await
322                .unwrap_or_default();
323            let named = serde_json::from_slice::<Value>(&body)
324                .ok()
325                .and_then(|value| {
326                    value
327                        .get("error")
328                        .and_then(Value::as_str)
329                        .map(str::to_owned)
330                });
331            return match named.as_deref() {
332                Some(RECORD_NOT_FOUND) => Fetched::Absent,
333                Some(other) => Fetched::Unknown(Unknown::Unreachable(format!(
334                    "{url} answered 400 {other}, which is not {RECORD_NOT_FOUND}"
335                ))),
336                None => Fetched::Unknown(Unknown::Unreachable(format!(
337                    "{url} answered 400 with no atproto error name"
338                ))),
339            };
340        }
341        let response = match response.error_for_status() {
342            Ok(response) => response,
343            Err(error) => {
344                return Fetched::Unknown(Unknown::Unreachable(format!("{url} answered: {error}")))
345            }
346        };
347        let body = match didbot_http::read_bounded(response, MAX_RECORD_BYTES).await {
348            Ok(body) => body,
349            Err(error) => {
350                return Fetched::Unknown(Unknown::Unreachable(format!("reading {url}: {error}")))
351            }
352        };
353        match serde_json::from_slice::<GetRecordResponse>(&body) {
354            Ok(body) => Fetched::Present(Value::Array(vec![serde_json::json!({
355                "uri": record.at_uri(),
356                "value": body.value,
357            })])),
358            Err(error) => Fetched::Unknown(Unknown::Unreachable(format!("parsing {url}: {error}"))),
359        }
360    }
361}
362
363impl Fetch for Network {
364    fn fetch<'a>(&'a self, record: &'a RecordRef) -> FetchFuture<'a> {
365        Box::pin(async move {
366            if self.local.holds(&record.did) {
367                return Fetched::Unknown(Unknown::Refused(format!(
368                    "{} is hosted here; its records are read from this server's own store",
369                    record.did
370                )));
371            }
372            let endpoint = match self.endpoint(&record.did).await {
373                Ok(endpoint) => endpoint,
374                Err(unknown) => return Fetched::Unknown(unknown),
375            };
376            match &record.rkey {
377                Some(rkey) => self.get_record(&endpoint, record, rkey).await,
378                None => self.list_records(&endpoint, record).await,
379            }
380        })
381    }
382}
383
384/// Percent-encodes everything a query parameter's value may not carry
385/// literally, so a DID, an NSID or a record key reaches the remote server
386/// as one value rather than as extra parameters.
387fn urlencode(value: &str) -> String {
388    let mut encoded = String::with_capacity(value.len());
389    for byte in value.bytes() {
390        match byte {
391            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
392                encoded.push(byte as char);
393            }
394            other => encoded.push_str(&format!("%{other:02X}")),
395        }
396    }
397    encoded
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    /// A record key is `*` -- among the characters atproto allows -- and a
405    /// DID carries colons. Both have to survive the query string as one
406    /// value each.
407    #[test]
408    fn a_query_value_carries_no_syntax_of_its_own() {
409        assert_eq!(urlencode("did:plc:abc"), "did%3Aplc%3Aabc");
410        assert_eq!(urlencode("3k-a.b_c~d"), "3k-a.b_c~d");
411        assert_eq!(urlencode("a&b=c"), "a%26b%3Dc");
412    }
413
414    #[tokio::test]
415    async fn a_repository_this_deployment_holds_is_never_fetched() {
416        struct Ours;
417        impl LocalRepositories for Ours {
418            fn holds(&self, did: &str) -> bool {
419                did == "did:web:agent.pds.example"
420            }
421            fn serves(&self, _host: &str) -> bool {
422                false
423            }
424        }
425        let network = Network::new(Arc::new(Ours), PrivateAddresses::Refused);
426        let record = RecordRef::new(
427            "did:web:agent.pds.example",
428            "app.bsky.feed.threadgate",
429            "3kabc",
430        );
431        assert!(matches!(
432            network.fetch(&record).await,
433            Fetched::Unknown(Unknown::Refused(_))
434        ));
435    }
436
437    #[tokio::test]
438    async fn a_did_method_nothing_here_resolves_is_unresolvable_not_unreachable() {
439        let network = Network::new(Arc::new(NoLocalRepositories), PrivateAddresses::Refused);
440        let record = RecordRef::new("did:example:abc", "app.bsky.feed.threadgate", "3kabc");
441        assert!(matches!(
442            network.fetch(&record).await,
443            Fetched::Unknown(Unknown::Unresolvable(_))
444        ));
445    }
446
447    /// A `did:web` naming this deployment's own hostname is refused before
448    /// anything resolves it, so a document pointing back here cannot turn a
449    /// foreign read into a request this server makes of itself.
450    #[tokio::test]
451    async fn this_deployments_own_name_is_refused() {
452        struct Us;
453        impl LocalRepositories for Us {
454            fn holds(&self, _did: &str) -> bool {
455                false
456            }
457            fn serves(&self, host: &str) -> bool {
458                host == "pds.example"
459            }
460        }
461        let network = Network::new(Arc::new(Us), PrivateAddresses::Allowed);
462        let record = RecordRef::new("did:web:pds.example", "app.bsky.feed.threadgate", "3kabc");
463        assert!(matches!(
464            network.fetch(&record).await,
465            Fetched::Unknown(Unknown::Refused(_))
466        ));
467    }
468}