Skip to main content

didbot_serve/oauth/
permission_set.rs

1//! What an `include:` scope grants: the permission set it names, resolved
2//! from the set's lexicon authority and cached.
3//!
4//! The lexicon spec's "Lexicon Publication and Resolution" is the path. The
5//! set's NSID less its name, reversed, is a domain: `app.bsky.authFullApp`
6//! is `bsky.app`. The `_lexicon` TXT record under it names a DID. That
7//! DID's document names the server holding its repository and the key its
8//! commits are signed with. The set is the `com.atproto.lexicon.schema`
9//! record keyed by its NSID in that repository. It arrives as a
10//! `com.atproto.sync.getRecord` proof, and nothing in it is read until the
11//! proof verifies against that key.
12//!
13//! [`PermissionSets::expand`] replaces each `include:` in a request with the
14//! atoms [`didbot_scope::Include::grants`] reads out of its set. A set that
15//! does not resolve, for any reason, grants nothing, and the rest of the
16//! request is judged without it. It never fails the request.
17//!
18//! # What one request can cost
19//!
20//! Each set costs at most three requests: the DNS query, the DID document,
21//! and the proof. Each is a GET through [`didbot_http::pinned_client`] with
22//! a bounded body. One request resolves at most [`MAX_SETS`] sets, each
23//! within [`SET_DEADLINE`] and all of them within [`EXPANSION_DEADLINE`].
24//! The cache is shared by every account and holds at most [`CACHE_ENTRIES`]
25//! sets, each read from a proof of at most [`MAX_PROOF_BYTES`].
26
27use std::future::Future;
28use std::num::NonZeroUsize;
29use std::pin::Pin;
30use std::sync::{Arc, Mutex};
31use std::time::Duration;
32
33use didbot_http::PrivateAddresses;
34use didbot_identity::DidDocument;
35use didbot_lexicon::lexicon::LexiconDoc;
36use didbot_repo::CommitKey;
37use serde_json::Value;
38// `tokio`'s clock, so a test can advance the cache's lifetimes without
39// waiting.
40use tokio::time::Instant;
41
42use crate::oauth::scope::{Include, Scope, ScopeSet, MAX_GRANT_ATOMS, MAX_GRANT_BYTES};
43
44/// The collection a lexicon is published in.
45const SCHEMA_COLLECTION: &str = "com.atproto.lexicon.schema";
46
47/// The atproto error a repository names when it holds no record at a key.
48const RECORD_NOT_FOUND: &str = "RecordNotFound";
49
50/// How long a resolved set is used before it is resolved again.
51///
52/// **Reasonable range: 15 minutes to 24 hours.** The permission spec puts
53/// the floor at an access token's lifetime and calls 24 hours a firm
54/// ceiling. A set its authority changes reaches new sign-ins within this.
55pub const FRESH_FOR: Duration = Duration::from_secs(60 * 60);
56
57/// How long the last resolved copy of a set stands in while its authority
58/// cannot be reached.
59///
60/// **Reasonable range: 1 to 90 days.** Ninety days is the permission spec's
61/// recommendation for a set referenced by a new sign-in. An authority that
62/// answers, and not with the set, ends the copy at once.
63pub const KEEP_FOR: Duration = Duration::from_secs(90 * 24 * 60 * 60);
64
65/// How long a failed resolution is remembered before a sign-in tries again.
66///
67/// **Reasonable range: 10 seconds to 5 minutes.** Long enough that a set
68/// whose authority is down costs one lookup per interval rather than one
69/// per sign-in; short enough that an outage ends by itself.
70pub const RETRY_AFTER: Duration = Duration::from_secs(60);
71
72/// How long resolving one set may take: three requests, each usually well
73/// under a second.
74pub const SET_DEADLINE: Duration = Duration::from_secs(4);
75
76/// How long resolving every set in one request may take. With the client's
77/// metadata and key set fetched first, a push stays inside
78/// `crate::routes::REQUEST_TIMEOUT`.
79pub const EXPANSION_DEADLINE: Duration = Duration::from_secs(8);
80
81/// The most sets one request resolves. Past it, an `include:` grants
82/// nothing.
83pub const MAX_SETS: usize = 16;
84
85/// How many sets the cache holds.
86pub const CACHE_ENTRIES: usize = 256;
87
88/// The largest `com.atproto.sync.getRecord` proof read. A set with a
89/// hundred methods is about ten kilobytes of proof.
90pub const MAX_PROOF_BYTES: usize = 64 * 1024;
91
92/// The most of a refusal's body read, for the atproto error it names.
93const MAX_ERROR_BYTES: usize = 64 * 1024;
94
95/// Why a set did not resolve.
96#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
97pub enum Unresolved {
98    /// The set's authority names no DID, not exactly one, or one this server
99    /// cannot resolve.
100    #[error("no lexicon authority: {0}")]
101    NoAuthority(String),
102    /// A server on the way did not answer, or answered with an error.
103    #[error("unreachable: {0}")]
104    Unreachable(String),
105    /// A server answered with something that is not what the spec says it
106    /// publishes: a document for another DID, a proof that does not verify,
107    /// no record at the set's NSID, or a record that is not a
108    /// `permission-set`.
109    #[error("not a permission set: {0}")]
110    Invalid(String),
111    /// The resolution did not finish in time.
112    #[error("resolving it took longer than allowed")]
113    Deadline,
114}
115
116impl Unresolved {
117    /// Whether the authority answered, rather than failing to: the last
118    /// copy of the set no longer stands in for it.
119    fn answered(&self) -> bool {
120        matches!(self, Self::NoAuthority(_) | Self::Invalid(_))
121    }
122}
123
124/// A future a [`Resolve`] implementation hands back, boxed so the trait can
125/// be held as `Arc<dyn Resolve>`.
126pub type ResolveFuture<'a> =
127    Pin<Box<dyn Future<Output = Result<Vec<Value>, Unresolved>> + Send + 'a>>;
128
129/// One resolution of a set: its published `permissions`.
130///
131/// Apart from the cache, so a test can drive the cache with no network.
132pub trait Resolve: Send + Sync {
133    /// Resolves the set `nsid` names.
134    fn resolve<'a>(&'a self, nsid: &'a str) -> ResolveFuture<'a>;
135}
136
137/// The resolution the lexicon spec describes, over the network.
138pub struct Network {
139    doh: String,
140    private: PrivateAddresses,
141}
142
143impl Network {
144    /// Asks `doh`, a DNS-over-HTTPS resolver answering the JSON form, for
145    /// the authority's TXT record. The resolver is this deployment's own
146    /// configuration, so it may be on a private address. Every server an
147    /// authority names is refused one unless `private` allows it.
148    pub fn new(doh: impl Into<String>, private: PrivateAddresses) -> Self {
149        Self {
150            doh: doh.into(),
151            private,
152        }
153    }
154
155    /// The DID the `_lexicon` TXT record for `nsid`'s authority names.
156    ///
157    /// Exactly one `did=` value, at exactly that name. No other name is
158    /// tried.
159    async fn authority(&self, nsid: &str) -> Result<String, Unresolved> {
160        let name = lexicon_name(nsid);
161        let records = didbot_http::doh::lookup(
162            &self.doh,
163            &name,
164            didbot_http::doh::TXT,
165            PrivateAddresses::Allowed,
166        )
167        .await
168        .map_err(|error| Unresolved::Unreachable(format!("{name}: {error}")))?;
169        let dids: Vec<&str> = records
170            .iter()
171            .filter_map(|record| record.strip_prefix("did="))
172            .collect();
173        let [did] = dids.as_slice() else {
174            return Err(Unresolved::NoAuthority(format!(
175                "{name} names {} DIDs, not one",
176                dids.len()
177            )));
178        };
179        didbot_identity::validate_did(did)
180            .map_err(|error| Unresolved::NoAuthority(format!("{name} names {did}: {error}")))?;
181        Ok((*did).to_owned())
182    }
183
184    /// The server holding `did`'s repository, and the key its commits are
185    /// signed with.
186    async fn identity(&self, did: &str) -> Result<(String, CommitKey), Unresolved> {
187        let url = didbot_identity::document_url(did)
188            .map_err(|error| Unresolved::NoAuthority(error.to_string()))?;
189        let body = self.get(&url, didbot_http::MAX_JSON_BODY).await?;
190        let document: DidDocument = serde_json::from_slice(&body)
191            .map_err(|error| Unresolved::Invalid(format!("parsing {url}: {error}")))?;
192        if document.id != did {
193            return Err(Unresolved::Invalid(format!(
194                "the document at {url} is {:?}'s, not {did:?}'s",
195                document.id
196            )));
197        }
198        let endpoint = document
199            .pds_endpoint()
200            .ok_or_else(|| Unresolved::Invalid(format!("{did} names no #atproto_pds service")))?;
201        let key = document
202            .signing_key_multibase()
203            .ok_or_else(|| Unresolved::Invalid(format!("{did} names no #atproto key")))?;
204        let key = CommitKey::from_multibase(key)
205            .map_err(|error| Unresolved::Invalid(format!("{did}'s #atproto key: {error}")))?;
206        Ok((endpoint.to_owned(), key))
207    }
208
209    /// The set's `permissions`, out of the proof `endpoint` answers with.
210    async fn record(
211        &self,
212        endpoint: &str,
213        did: &str,
214        key: &CommitKey,
215        nsid: &str,
216    ) -> Result<Vec<Value>, Unresolved> {
217        let mut url = url::Url::parse(&format!(
218            "{}/xrpc/com.atproto.sync.getRecord",
219            endpoint.trim_end_matches('/')
220        ))
221        .map_err(|error| Unresolved::Invalid(format!("{did}'s server {endpoint}: {error}")))?;
222        url.query_pairs_mut()
223            .append_pair("did", did)
224            .append_pair("collection", SCHEMA_COLLECTION)
225            .append_pair("rkey", nsid);
226        let absent =
227            || Unresolved::Invalid(format!("{did}'s repository holds no lexicon at {nsid}"));
228        let response = self.send(url.as_str()).await?;
229        if response.status() == reqwest::StatusCode::BAD_REQUEST {
230            let body = didbot_http::read_bounded(response, MAX_ERROR_BYTES)
231                .await
232                .unwrap_or_default();
233            let error = serde_json::from_slice::<Value>(&body)
234                .ok()
235                .and_then(|body| body.get("error")?.as_str().map(str::to_owned));
236            return Err(match error.as_deref() {
237                Some(RECORD_NOT_FOUND) => absent(),
238                _ => Unresolved::Unreachable(format!("{url} answered 400 {error:?}")),
239            });
240        }
241        let proof = Self::body(url.as_str(), response, MAX_PROOF_BYTES).await?;
242        let record = didbot_repo::read_record(&proof, did, key, SCHEMA_COLLECTION, nsid)
243            .map_err(|error| Unresolved::Invalid(format!("{did}'s proof of {nsid}: {error}")))?
244            .ok_or_else(absent)?;
245        permissions_of(record, nsid)
246    }
247
248    /// A GET through a client pinned to `url`'s own addresses, following no
249    /// redirect, with its body read against `max_bytes`.
250    async fn get(&self, url: &str, max_bytes: usize) -> Result<Vec<u8>, Unresolved> {
251        let response = self.send(url).await?;
252        Self::body(url, response, max_bytes).await
253    }
254
255    /// The same GET, with the status left to the caller.
256    async fn send(&self, url: &str) -> Result<reqwest::Response, Unresolved> {
257        let unreachable = Unresolved::Unreachable;
258        let parsed =
259            url::Url::parse(url).map_err(|error| unreachable(format!("{url}: {error}")))?;
260        let client = didbot_http::pinned_client(&parsed, self.private)
261            .await
262            .map_err(|error| unreachable(error.to_string()))?;
263        client
264            .get(parsed)
265            .send()
266            .await
267            .map_err(|error| unreachable(format!("fetching {url}: {error}")))
268    }
269
270    /// A success's body, read against `max_bytes`.
271    async fn body(
272        url: &str,
273        response: reqwest::Response,
274        max_bytes: usize,
275    ) -> Result<Vec<u8>, Unresolved> {
276        let unreachable = Unresolved::Unreachable;
277        let response = response
278            .error_for_status()
279            .map_err(|error| unreachable(format!("{url} answered: {error}")))?;
280        didbot_http::read_bounded(response, max_bytes)
281            .await
282            .map_err(|error| unreachable(format!("reading {url}: {error}")))
283    }
284}
285
286impl Resolve for Network {
287    fn resolve<'a>(&'a self, nsid: &'a str) -> ResolveFuture<'a> {
288        Box::pin(async move {
289            let did = self.authority(nsid).await?;
290            let (endpoint, key) = self.identity(&did).await?;
291            self.record(&endpoint, &did, &key, nsid).await
292        })
293    }
294}
295
296/// Where `nsid`'s authority publishes its DID: `_lexicon.`, then the NSID
297/// less its name, reversed.
298fn lexicon_name(nsid: &str) -> String {
299    let (authority, _) = nsid.rsplit_once('.').unwrap_or((nsid, ""));
300    let reversed: Vec<&str> = authority.rsplit('.').collect();
301    format!("_lexicon.{}", reversed.join("."))
302}
303
304/// The `permissions` of the `permission-set` a lexicon record defines as
305/// `main`, or why it defines none.
306fn permissions_of(record: Value, nsid: &str) -> Result<Vec<Value>, Unresolved> {
307    let lexicon: LexiconDoc = serde_json::from_value(record)
308        .map_err(|error| Unresolved::Invalid(format!("{nsid} is not a lexicon: {error}")))?;
309    if lexicon.lexicon != 1 || lexicon.id != nsid {
310        return Err(Unresolved::Invalid(format!(
311            "the record at {nsid} is lexicon {}, version {}",
312            lexicon.id, lexicon.lexicon
313        )));
314    }
315    if lexicon.main_type() != Some("permission-set") {
316        return Err(Unresolved::Invalid(format!(
317            "{nsid} defines no permission-set as main"
318        )));
319    }
320    lexicon
321        .main()
322        .and_then(|main| main.get("permissions"))
323        .and_then(Value::as_array)
324        .cloned()
325        .ok_or_else(|| Unresolved::Invalid(format!("{nsid} lists no permissions")))
326}
327
328/// What the cache knows about one set.
329struct Entry {
330    /// The last copy that resolved, and when.
331    copy: Option<(Arc<Vec<Value>>, Instant)>,
332    /// When resolving the set was last tried.
333    tried: Instant,
334    /// Why that try failed, if it did.
335    failed: Option<Unresolved>,
336}
337
338impl Entry {
339    /// The last copy, while it is young enough to stand in for the set.
340    fn kept(&self, now: Instant) -> Option<Arc<Vec<Value>>> {
341        self.copy
342            .as_ref()
343            .filter(|(_, resolved)| now.duration_since(*resolved) < KEEP_FOR)
344            .map(|(copy, _)| copy.clone())
345    }
346}
347
348/// Every set this deployment has resolved, shared by every account.
349pub struct PermissionSets {
350    resolver: Arc<dyn Resolve>,
351    cache: Mutex<lru::LruCache<String, Entry>>,
352}
353
354impl PermissionSets {
355    /// Sets resolved by `resolver`.
356    pub fn new(resolver: Arc<dyn Resolve>) -> Self {
357        Self {
358            resolver,
359            cache: Mutex::new(lru::LruCache::new(
360                NonZeroUsize::new(CACHE_ENTRIES).expect("a constant above zero"),
361            )),
362        }
363    }
364
365    /// Sets resolved over the network; see [`Network::new`].
366    pub fn over_network(doh: impl Into<String>, private: PrivateAddresses) -> Self {
367        Self::new(Arc::new(Network::new(doh, private)))
368    }
369
370    /// `requested` with each `include:` replaced by the atoms its set
371    /// grants.
372    ///
373    /// An `include:` grants nothing when it names no set, when its set does
374    /// not resolve, and when its atoms would take the request past what one
375    /// grant may hold ([`MAX_GRANT_ATOMS`], [`MAX_GRANT_BYTES`]). Every
376    /// other atom is kept as it was asked for.
377    pub async fn expand(&self, requested: &ScopeSet) -> ScopeSet {
378        let started = Instant::now();
379        let mut atoms: Vec<Scope> = requested
380            .0
381            .iter()
382            .filter(|atom| !matches!(atom, Scope::Include { .. }))
383            .cloned()
384            .collect();
385        let mut bytes: usize = atoms.iter().map(printed_len).sum();
386        let mut sets = 0;
387        for atom in &requested.0 {
388            let Scope::Include { nsid, aud } = atom else {
389                continue;
390            };
391            let granted = match self.include(nsid, aud.as_deref(), started, &mut sets).await {
392                Ok(granted) => granted,
393                Err(why) => {
394                    tracing::debug!(include = %atom, reason = %why, "an included permission set grants nothing");
395                    continue;
396                }
397            };
398            let added: usize = granted.iter().map(printed_len).sum();
399            if atoms.len() + granted.len() > MAX_GRANT_ATOMS || bytes + added > MAX_GRANT_BYTES {
400                tracing::debug!(
401                    include = %atom,
402                    atoms = granted.len(),
403                    "an included permission set grants nothing: its atoms would pass what one grant may hold"
404                );
405                continue;
406            }
407            bytes += added;
408            atoms.extend(granted);
409        }
410        ScopeSet::new(atoms)
411    }
412
413    /// What one `include:` grants, or why it grants nothing.
414    async fn include(
415        &self,
416        nsid: &str,
417        aud: Option<&str>,
418        started: Instant,
419        sets: &mut usize,
420    ) -> Result<Vec<Scope>, String> {
421        let include = Include::new(nsid, aud).map_err(|error| error.to_string())?;
422        *sets += 1;
423        if *sets > MAX_SETS {
424            return Err(format!("the request names more than {MAX_SETS} sets"));
425        }
426        let left = EXPANSION_DEADLINE.saturating_sub(started.elapsed());
427        if left.is_zero() {
428            return Err("the request's other sets used its time".to_owned());
429        }
430        let permissions = self
431            .permissions(include.nsid(), left.min(SET_DEADLINE))
432            .await
433            .map_err(|error| error.to_string())?;
434        Ok(include.grants(&permissions))
435    }
436
437    /// The published `permissions` of the set `nsid` names, from the cache
438    /// or resolved within `budget`.
439    async fn permissions(
440        &self,
441        nsid: &str,
442        budget: Duration,
443    ) -> Result<Arc<Vec<Value>>, Unresolved> {
444        if let Some(answer) = self.cached(nsid, Instant::now()) {
445            return answer;
446        }
447        let began = Instant::now();
448        let outcome = tokio::time::timeout(budget, self.resolver.resolve(nsid)).await;
449        let resolved = match outcome {
450            Ok(resolved) => resolved,
451            // This request ran out of time, not the set: nothing was learned
452            // about the set, so nothing is remembered.
453            Err(_) if budget < SET_DEADLINE => {
454                return self
455                    .lock()
456                    .peek(nsid)
457                    .and_then(|entry| entry.kept(Instant::now()))
458                    .ok_or(Unresolved::Deadline);
459            }
460            Err(_) => Err(Unresolved::Deadline),
461        };
462        tracing::debug!(
463            nsid,
464            outcome = resolved
465                .as_ref()
466                .map_or_else(ToString::to_string, |_| "resolved".to_owned()),
467            latency_ms = began.elapsed().as_millis(),
468            "resolved a permission set"
469        );
470        self.remember(nsid, resolved)
471    }
472
473    /// The cache's answer for `nsid` at `now`, if it has one that needs no
474    /// resolution: a copy still fresh, or a failure too recent to retry.
475    fn cached(&self, nsid: &str, now: Instant) -> Option<Result<Arc<Vec<Value>>, Unresolved>> {
476        let mut cache = self.lock();
477        let entry = cache.get(nsid)?;
478        let since = now.duration_since(entry.tried);
479        match &entry.failed {
480            None if since < FRESH_FOR => entry.kept(now).map(Ok),
481            Some(why) if since < RETRY_AFTER => Some(entry.kept(now).ok_or_else(|| why.clone())),
482            _ => None,
483        }
484    }
485
486    /// Stores what resolving `nsid` just established, and answers with what
487    /// a sign-in should use: the new copy, or the last one while the
488    /// authority cannot be reached and the copy is kept.
489    fn remember(
490        &self,
491        nsid: &str,
492        resolved: Result<Vec<Value>, Unresolved>,
493    ) -> Result<Arc<Vec<Value>>, Unresolved> {
494        let now = Instant::now();
495        let mut cache = self.lock();
496        let previous = cache.pop(nsid);
497        let (entry, answer) = match resolved {
498            Ok(permissions) => {
499                let permissions = Arc::new(permissions);
500                let entry = Entry {
501                    copy: Some((permissions.clone(), now)),
502                    tried: now,
503                    failed: None,
504                };
505                (entry, Ok(permissions))
506            }
507            Err(why) => {
508                let copy = previous
509                    .and_then(|entry| entry.copy)
510                    .filter(|(_, resolved)| {
511                        !why.answered() && now.duration_since(*resolved) < KEEP_FOR
512                    });
513                let answer = copy
514                    .as_ref()
515                    .map(|(copy, _)| copy.clone())
516                    .ok_or_else(|| why.clone());
517                let entry = Entry {
518                    copy,
519                    tried: now,
520                    failed: Some(why),
521                };
522                (entry, answer)
523            }
524        };
525        cache.put(nsid.to_owned(), entry);
526        answer
527    }
528
529    fn lock(&self) -> std::sync::MutexGuard<'_, lru::LruCache<String, Entry>> {
530        self.cache
531            .lock()
532            .unwrap_or_else(|poisoned| poisoned.into_inner())
533    }
534}
535
536impl Default for PermissionSets {
537    /// Sets resolved over the public internet, through Cloudflare's
538    /// resolver, `didbot_http::doh::CLOUDFLARE`.
539    fn default() -> Self {
540        Self::over_network(didbot_http::doh::CLOUDFLARE, PrivateAddresses::Refused)
541    }
542}
543
544/// How many bytes `atom` adds to a grant's scope string, its separator
545/// included.
546fn printed_len(atom: &Scope) -> usize {
547    atom.to_string().len() + 1
548}
549
550#[cfg(test)]
551mod tests {
552    use std::collections::HashMap;
553    use std::sync::atomic::{AtomicUsize, Ordering};
554
555    use serde_json::json;
556
557    use super::*;
558
559    /// A [`Resolve`] answering from a script and counting what it was asked.
560    struct Script {
561        answers: Mutex<HashMap<String, Result<Vec<Value>, Unresolved>>>,
562        calls: AtomicUsize,
563    }
564
565    impl Script {
566        fn new() -> Arc<Self> {
567            Arc::new(Self {
568                answers: Mutex::new(HashMap::new()),
569                calls: AtomicUsize::new(0),
570            })
571        }
572
573        fn answer(&self, nsid: &str, answer: Result<Vec<Value>, Unresolved>) {
574            self.answers
575                .lock()
576                .expect("answers")
577                .insert(nsid.to_owned(), answer);
578        }
579
580        fn calls(&self) -> usize {
581            self.calls.load(Ordering::SeqCst)
582        }
583    }
584
585    impl Resolve for Script {
586        fn resolve<'a>(&'a self, nsid: &'a str) -> ResolveFuture<'a> {
587            Box::pin(async move {
588                self.calls.fetch_add(1, Ordering::SeqCst);
589                self.answers
590                    .lock()
591                    .expect("answers")
592                    .get(nsid)
593                    .cloned()
594                    .unwrap_or_else(|| Err(Unresolved::NoAuthority(nsid.to_owned())))
595            })
596        }
597    }
598
599    fn repo(collection: &str) -> Vec<Value> {
600        vec![json!({
601            "type": "permission",
602            "resource": "repo",
603            "collection": [collection],
604        })]
605    }
606
607    fn scope(s: &str) -> ScopeSet {
608        ScopeSet::parse(s).expect("a scope")
609    }
610
611    #[test]
612    fn the_authority_is_the_nsid_less_its_name_reversed() {
613        assert_eq!(lexicon_name("app.bsky.authFullApp"), "_lexicon.bsky.app");
614        assert_eq!(
615            lexicon_name("edu.university.dept.lab.blogging.getBlogPost"),
616            "_lexicon.blogging.lab.dept.university.edu"
617        );
618    }
619
620    /// The rest of the request is judged as asked, whatever happens to the
621    /// sets beside it.
622    #[tokio::test]
623    async fn a_set_that_does_not_resolve_grants_nothing_and_fails_nothing() {
624        let script = Script::new();
625        script.answer("com.example.authFull", Ok(repo("com.example.post")));
626        let sets = PermissionSets::new(script.clone());
627        let expanded = sets
628            .expand(&scope(
629                "atproto include:com.example.authFull include:org.example.authGone \
630                 include:not-an-nsid rpc:com.example.get?aud=*",
631            ))
632            .await;
633        assert_eq!(
634            expanded.to_string(),
635            "atproto repo:com.example.post rpc:com.example.get?aud=*"
636        );
637        assert_eq!(
638            script.calls(),
639            2,
640            "an include naming no NSID is not resolved"
641        );
642    }
643
644    #[tokio::test(start_paused = true)]
645    async fn a_resolved_set_is_reused_until_it_is_stale() {
646        let script = Script::new();
647        script.answer("com.example.authFull", Ok(repo("com.example.post")));
648        let sets = PermissionSets::new(script.clone());
649        let request = scope("include:com.example.authFull");
650
651        sets.expand(&request).await;
652        sets.expand(&request).await;
653        assert_eq!(script.calls(), 1);
654
655        script.answer("com.example.authFull", Ok(repo("com.example.like")));
656        tokio::time::advance(FRESH_FOR).await;
657        assert_eq!(
658            sets.expand(&request).await.to_string(),
659            "repo:com.example.like"
660        );
661        assert_eq!(script.calls(), 2, "a stale set is resolved again");
662    }
663
664    /// The spec: "if resolution fails, the previously existing stale
665    /// values can be used". A failure is remembered, so a set whose
666    /// authority is down costs one lookup per interval.
667    #[tokio::test(start_paused = true)]
668    async fn a_failed_refresh_keeps_the_last_copy_and_is_retried_later() {
669        let script = Script::new();
670        script.answer("com.example.authFull", Ok(repo("com.example.post")));
671        let sets = PermissionSets::new(script.clone());
672        let request = scope("include:com.example.authFull");
673        sets.expand(&request).await;
674
675        script.answer(
676            "com.example.authFull",
677            Err(Unresolved::Unreachable("down".to_owned())),
678        );
679        tokio::time::advance(FRESH_FOR).await;
680        assert_eq!(
681            sets.expand(&request).await.to_string(),
682            "repo:com.example.post"
683        );
684        assert_eq!(
685            sets.expand(&request).await.to_string(),
686            "repo:com.example.post"
687        );
688        assert_eq!(script.calls(), 2, "the failure is not retried at once");
689
690        tokio::time::advance(RETRY_AFTER).await;
691        sets.expand(&request).await;
692        assert_eq!(script.calls(), 3, "and is retried after a while");
693
694        tokio::time::advance(KEEP_FOR).await;
695        assert_eq!(
696            sets.expand(&request).await.to_string(),
697            "",
698            "a copy past its keep grants nothing"
699        );
700    }
701
702    /// The last copy stands in only while the authority cannot be reached.
703    /// An authority that answers, and not with the set, ends it at once.
704    #[tokio::test(start_paused = true)]
705    async fn an_authority_that_withdraws_a_set_ends_its_last_copy() {
706        let script = Script::new();
707        script.answer("com.example.authFull", Ok(repo("com.example.post")));
708        let sets = PermissionSets::new(script.clone());
709        let request = scope("include:com.example.authFull");
710        sets.expand(&request).await;
711
712        script.answer(
713            "com.example.authFull",
714            Err(Unresolved::Invalid("no record there".to_owned())),
715        );
716        tokio::time::advance(FRESH_FOR).await;
717        assert_eq!(sets.expand(&request).await.to_string(), "");
718        assert_eq!(
719            sets.expand(&request).await.to_string(),
720            "",
721            "nor while the answer is remembered"
722        );
723    }
724
725    #[tokio::test(start_paused = true)]
726    async fn a_set_that_never_resolved_is_not_asked_for_again_at_once() {
727        let script = Script::new();
728        let sets = PermissionSets::new(script.clone());
729        let request = scope("include:org.example.authGone");
730        sets.expand(&request).await;
731        sets.expand(&request).await;
732        assert_eq!(script.calls(), 1);
733        tokio::time::advance(RETRY_AFTER).await;
734        sets.expand(&request).await;
735        assert_eq!(script.calls(), 2);
736    }
737
738    /// A slow authority is remembered as failed, and costs a request no
739    /// more than its deadline.
740    #[tokio::test(start_paused = true)]
741    async fn a_slow_set_is_cut_off_at_its_deadline() {
742        struct Slow;
743        impl Resolve for Slow {
744            fn resolve<'a>(&'a self, _nsid: &'a str) -> ResolveFuture<'a> {
745                Box::pin(async {
746                    tokio::time::sleep(Duration::from_secs(3600)).await;
747                    Ok(Vec::new())
748                })
749            }
750        }
751        let sets = PermissionSets::new(Arc::new(Slow));
752        let started = Instant::now();
753        let expanded = sets
754            .expand(&scope(
755                "atproto include:com.example.authOne include:com.example.authTwo \
756                 include:com.example.authThree",
757            ))
758            .await;
759        assert_eq!(expanded.to_string(), "atproto");
760        assert_eq!(started.elapsed(), EXPANSION_DEADLINE);
761    }
762
763    #[tokio::test]
764    async fn one_request_resolves_at_most_its_bound_of_sets() {
765        let script = Script::new();
766        let names: Vec<String> = (0..MAX_SETS + 4)
767            .map(|i| format!("include:com.example.auth{i}"))
768            .collect();
769        PermissionSets::new(script.clone())
770            .expand(&scope(&names.join(" ")))
771            .await;
772        assert_eq!(script.calls(), MAX_SETS);
773    }
774
775    /// A set that would take the request past what a grant may hold grants
776    /// nothing, and the sets that fit are still granted.
777    #[tokio::test]
778    async fn a_set_past_what_a_grant_holds_grants_nothing() {
779        let script = Script::new();
780        let methods: Vec<String> = (0..MAX_GRANT_ATOMS)
781            .map(|i| format!("com.example.get{i}"))
782            .collect();
783        script.answer(
784            "com.example.authHuge",
785            Ok(vec![json!({
786                "type": "permission",
787                "resource": "rpc",
788                "lxm": methods,
789                "aud": "*",
790            })]),
791        );
792        script.answer("org.example.authSmall", Ok(repo("org.example.post")));
793        let expanded = PermissionSets::new(script)
794            .expand(&scope(
795                "atproto include:com.example.authHuge include:org.example.authSmall",
796            ))
797            .await;
798        assert_eq!(expanded.to_string(), "atproto repo:org.example.post");
799    }
800}