Skip to main content

didbot_serve/
policy_poll.rs

1//! The policy poll: the operator's `bot.did.policy` and
2//! `bot.did.policyBinding` records, read on the operator poll's tick and
3//! built into one policy set.
4//!
5//! # List the collection, not track individual records
6//!
7//! `com.atproto.repo.listRecords`, walked via its cursor every tick -- not
8//! `getRecord` against a set of rkeys this server remembers from before.
9//! This was an explicit design question and the owner answered it directly:
10//! list the collection. Ideally this would subscribe rather than poll, but
11//! there is no efficient way to subscribe to one collection in one foreign
12//! repository, so polling `listRecords` on a timer is what this ships with.
13//!
14//! One tick lists both collections to their last page from the endpoint
15//! the operator poll resolved, builds the whole set from them with
16//! [`didbot_pds::policy_bindings::resolve`], and swaps the tree into the
17//! gate every write and grant is judged by. A listing that fails at any page
18//! builds nothing, so the gate keeps the set it has; a record the build
19//! cannot honour is refused on its own, keeping the record in force at its
20//! key where there is one, so neither an unreachable repository nor a
21//! mid-edit one widens what an account may do.
22//!
23//! # Across a restart
24//!
25//! Every set installed is written to [`didbot_pds::PolicyStore`], and
26//! [`PolicyPoll::restore`] reads it back into the gate at boot, before the
27//! first tick runs. Without that, a restart is a window in which the
28//! operator's denials are not in force — which would make restarting the
29//! server a way around them. A file that is there and cannot be used stops
30//! the boot; see that module's own doc comment for why that direction.
31//!
32//! What the enforced set holds, and what the newest build refused, are
33//! published on `bot.did.stats` — see
34//! [`didbot_pds::policy_bindings::EnforcedSet`].
35
36use std::collections::{BTreeMap, HashMap};
37use std::sync::Arc;
38
39use didbot_pds::policy_records::{BINDING_COLLECTION, POLICY_COLLECTION};
40use didbot_pds::policy_tree::TreePolicyGate;
41use didbot_pds::{
42    ApplyEvent, AtUri, Boundary, Coverage, EmptiedSet, EnforcedBinding, EnforcedPolicy,
43    EnforcedSet, Observed, PolicyRevision, PolicySet, PolicyStore, PolicyStoreError, Registry,
44    RejectedBuild, ServerIdentity,
45};
46use didbot_policy_records::AcrossBuilds;
47use serde::Deserialize;
48use tracing::{debug, error, info, warn};
49
50/// How many records `listRecords` is asked for per page. The maximum
51/// `com.atproto.repo.listRecords` allows.
52const PAGE_SIZE: u32 = 100;
53
54/// The most pages one listing follows before giving up.
55///
56/// Each page is bounded in bytes; the number of them was not. A PDS that
57/// answers every cursor with another full page keeps the loop running and
58/// the map growing for as long as it cares to, which is an unbounded read
59/// against a host this deployment does not run. At a hundred records a page
60/// this is room for a hundred thousand policy records, which is far past any
61/// operator's set and far short of a problem.
62const MAX_PAGES: u32 = 200;
63
64/// Why a listing could not complete. Every variant means the caller has no
65/// records at all from this attempt -- see this module's doc comment on why
66/// a failed listing must never apply a partial result.
67#[derive(Debug, thiserror::Error, Clone)]
68pub enum PollError {
69    /// A network or HTTP-level failure fetching some page of the listing.
70    #[error("fetching {url}: {message}")]
71    Fetch {
72        /// The URL that failed.
73        url: String,
74        /// What went wrong.
75        message: String,
76    },
77    /// A page's body was not the shape `listRecords` promises.
78    #[error("parsing response from {url}: {message}")]
79    BadResponse {
80        /// The URL whose response did not parse.
81        url: String,
82        /// What went wrong.
83        message: String,
84    },
85    /// A listing kept answering with another full page, past the cap on how
86    /// many one listing follows.
87    #[error("listing {url} was still paging after {pages} pages")]
88    TooManyPages {
89        /// The endpoint that would not stop.
90        url: String,
91        /// The cap it ran into.
92        pages: u32,
93    },
94}
95
96#[derive(Debug, Deserialize)]
97struct ListRecordsResponse {
98    records: Vec<ListedRecord>,
99    cursor: Option<String>,
100}
101
102#[derive(Debug, Deserialize)]
103struct ListedRecord {
104    uri: String,
105    value: serde_json::Value,
106}
107
108/// Page through `operator_did`'s `collection` at `endpoint` to completion,
109/// returning every record found keyed by at-uri.
110///
111/// `Err` on the first failure of any kind -- an unreachable endpoint, a
112/// malformed page -- deliberately with no partial result: a caller reading
113/// two collections as one revision relies on that to apply neither when
114/// either fails. Takes an already resolved `endpoint`, so one resolution of
115/// the operator's document serves every listing a tick makes.
116pub async fn list_records(
117    client: &reqwest::Client,
118    endpoint: &str,
119    operator_did: &str,
120    collection: &str,
121) -> Result<HashMap<String, serde_json::Value>, PollError> {
122    let list_url = format!("{endpoint}/xrpc/com.atproto.repo.listRecords");
123    let mut out = HashMap::new();
124    let mut cursor: Option<String> = None;
125    let mut pages: u32 = 0;
126    loop {
127        let mut query = vec![
128            ("repo".to_string(), operator_did.to_string()),
129            ("collection".to_string(), collection.to_string()),
130            ("limit".to_string(), PAGE_SIZE.to_string()),
131        ];
132        if let Some(cursor) = &cursor {
133            query.push(("cursor".to_string(), cursor.clone()));
134        }
135        let response = client
136            .get(&list_url)
137            .query(&query)
138            .send()
139            .await
140            .map_err(|error| PollError::Fetch {
141                url: list_url.clone(),
142                message: error.to_string(),
143            })?
144            .error_for_status()
145            .map_err(|error| PollError::Fetch {
146                url: list_url.clone(),
147                message: error.to_string(),
148            })?;
149        let body = didbot_http::read_bounded(response, didbot_http::MAX_JSON_BODY)
150            .await
151            .map_err(|error| PollError::BadResponse {
152                url: list_url.clone(),
153                message: error.to_string(),
154            })?;
155        let page: ListRecordsResponse =
156            serde_json::from_slice(&body).map_err(|error| PollError::BadResponse {
157                url: list_url.clone(),
158                message: error.to_string(),
159            })?;
160
161        let page_len = page.records.len();
162        for record in page.records {
163            out.insert(record.uri, record.value);
164        }
165
166        match page.cursor {
167            // atproto's convention: an empty page, or a page shorter than
168            // the requested limit, means there is nothing more -- some
169            // implementations still echo a cursor on the final page, so
170            // relying on cursor-presence alone can loop forever against
171            // one of those.
172            Some(next) if page_len as u32 == PAGE_SIZE => cursor = Some(next),
173            _ => break,
174        }
175        pages += 1;
176        if pages >= MAX_PAGES {
177            // A failed listing, not a short one. The all-or-nothing
178            // contract is what keeps the set last built serving, and
179            // answering with the pages that did arrive would install a set
180            // the operator never wrote.
181            return Err(PollError::TooManyPages {
182                url: list_url,
183                pages: MAX_PAGES,
184            });
185        }
186    }
187    Ok(out)
188}
189
190/// Why a tick installed nothing. The gate keeps the set it had.
191#[derive(Debug, thiserror::Error)]
192pub enum PolicyPollError {
193    /// One collection could not be listed to completion.
194    #[error("listing {collection}: {source}")]
195    Listing {
196        /// The collection whose listing failed.
197        collection: &'static str,
198        /// What went wrong.
199        #[source]
200        source: PollError,
201    },
202    /// The listing would have taken away every operator denial at once.
203    #[error(transparent)]
204    Emptied(#[from] EmptiedSet),
205}
206
207/// Why a boot could not put the kept set back in force.
208#[derive(Debug, thiserror::Error)]
209pub enum RestoreError {
210    /// The file is there and cannot be used.
211    #[error(transparent)]
212    Unusable(#[from] PolicyStoreError),
213    /// The file is a set this binary cannot build whole: an engine it no
214    /// longer carries, an action it no longer answers.
215    #[error(
216        "the policy set kept in the data directory holds record(s) this build cannot honour, \
217         and starting without them would widen what an agent may do: {refused}"
218    )]
219    Unbuildable {
220        /// Every record this build could not honour, and why.
221        refused: RejectedBuild,
222    },
223    /// The kept set was listed from a repository that is not this run's
224    /// operator.
225    #[error(
226        "the policy set kept in the data directory was listed from {held} and this run's \
227         operator is {configured}: one of the two is wrong. Point --owner back at {held}, or \
228         delete the kept set to start from {configured}'s repository alone"
229    )]
230    AnotherOperator {
231        /// The repository the kept set was listed from.
232        held: String,
233        /// The operator this run is configured for.
234        configured: String,
235    },
236}
237
238/// The operator's policy records as this deployment reads and enforces
239/// them, across ticks.
240pub struct PolicyPoll {
241    operator_did: String,
242    this_pds: String,
243    gate: Arc<TreePolicyGate>,
244    set: PolicySet,
245    /// Where the installed set is kept so a restart comes back with it.
246    /// `None` for a run with no data directory, which has nowhere durable
247    /// to put one.
248    store: Option<PolicyStore>,
249    /// Held here, not per tick, so a policy whose new revision will not
250    /// compile has the revision that did to fall back to.
251    across: AcrossBuilds,
252    /// The lookups of the set in force, swapped beside the tree so the
253    /// write path reads what the policies it will be judged by declare.
254    /// Shared with the router; see [`crate::DeclaredLookups`].
255    declared_lookups: Arc<crate::DeclaredLookups>,
256}
257
258impl PolicyPoll {
259    /// A poll of `operator_did`'s repository on behalf of the server whose
260    /// service DID is `this_pds`, swapping each set it builds into `gate`
261    /// and keeping it in `store`. `declared_lookups` is the router's, so
262    /// the set that judges a write and the records read for it are the
263    /// same set.
264    pub fn new(
265        operator_did: impl Into<String>,
266        this_pds: impl Into<String>,
267        gate: Arc<TreePolicyGate>,
268        store: Option<PolicyStore>,
269        declared_lookups: Arc<crate::DeclaredLookups>,
270    ) -> Self {
271        Self {
272            operator_did: operator_did.into(),
273            this_pds: this_pds.into(),
274            gate,
275            set: PolicySet::new(),
276            store,
277            across: AcrossBuilds::new(),
278            declared_lookups,
279        }
280    }
281
282    /// Puts the kept set back in force, before any tick has run.
283    ///
284    /// Answers the revision restored, or `None` for a deployment that has
285    /// never installed one.
286    ///
287    /// # Errors
288    ///
289    /// [`RestoreError`] for a kept set this binary cannot read or cannot
290    /// build whole. Both stop the boot: a server that comes online without
291    /// denials it had in force is the one outcome this must not produce.
292    pub fn restore(
293        &self,
294        registry: &dyn Registry,
295    ) -> Result<Option<Arc<PolicyRevision>>, RestoreError> {
296        let Some(store) = &self.store else {
297            return Ok(None);
298        };
299        let Some(held) = store.load()? else {
300            return Ok(None);
301        };
302        if held.observed.author != self.operator_did {
303            return Err(RestoreError::AnotherOperator {
304                held: held.observed.author,
305                configured: self.operator_did.clone(),
306            });
307        }
308        // Nothing is in force yet, so there is no set for a listing to
309        // empty: the only refusal a restore can meet is a record.
310        let bound = self
311            .build(&held.observed, registry, &PolicyRevision::default())
312            .expect("a build against nothing in force cannot empty a set");
313        if !bound.refused.is_empty() {
314            return Err(RestoreError::Unbuildable {
315                refused: RejectedBuild::of(bound.refused),
316            });
317        }
318        store.agrees(&held.digest, &bound.revision)?;
319        let (revision, _) = self.set.install(bound.revision, Vec::new());
320        let enforced = self.summarise(&revision, registry, Vec::new(), Vec::new());
321        info!(
322            digest = %revision.digest,
323            policies = revision.policies.len(),
324            bindings = revision.bindings.len(),
325            path = %store.path().display(),
326            "put the kept operator policy set back in force before the first poll"
327        );
328        self.declared_lookups.publish(bound.lookups);
329        self.gate.swap_bound(bound.tree, bound.bindings, enforced);
330        Ok(Some(revision))
331    }
332
333    /// What the newest observation refused, if it refused anything.
334    pub fn rejected(&self) -> Option<Arc<RejectedBuild>> {
335        self.set.rejected()
336    }
337
338    /// What the gate enforces, as an operator reads it back.
339    pub fn enforced_set(&self) -> Option<Arc<EnforcedSet>> {
340        self.gate.enforced_set()
341    }
342
343    /// One tick against the operator's repository at `endpoint`: list both
344    /// collections, build the set, and swap it in. `registry` answers the
345    /// admission tree a binding's subjects are checked against.
346    pub async fn poll_once(
347        &self,
348        client: &reqwest::Client,
349        endpoint: &str,
350        registry: &dyn Registry,
351    ) -> Result<Arc<PolicyRevision>, PolicyPollError> {
352        let mut listed = Vec::with_capacity(2);
353        for collection in [POLICY_COLLECTION, BINDING_COLLECTION] {
354            let listing = list_records(client, endpoint, &self.operator_did, collection)
355                .await
356                .map_err(|source| PolicyPollError::Listing { collection, source })?;
357            listed.push(by_record_key(listing));
358        }
359        let bindings = listed.pop().unwrap_or_default();
360        let policies = listed.pop().unwrap_or_default();
361        let observed = Observed {
362            author: self.operator_did.clone(),
363            policies,
364            bindings,
365        };
366
367        let in_force = self.set.current();
368        let bound = match self.build(&observed, registry, &in_force) {
369            Ok(bound) => bound,
370            Err(emptied) => {
371                warn!(
372                    collection = emptied.collection,
373                    held = emptied.held,
374                    "the operator's repository listed no records where a set is in force; \
375                     nothing installed and the set in force keeps serving"
376                );
377                self.set
378                    .reject(RejectedBuild::of(vec![didbot_pds::BuildFailure {
379                        collection: emptied.collection,
380                        key: String::new(),
381                        reason: emptied.to_string(),
382                    }]));
383                return Err(PolicyPollError::Emptied(emptied));
384            }
385        };
386
387        for failure in &bound.carried {
388            warn!(
389                collection = failure.collection,
390                key = %failure.key,
391                reason = %failure.reason,
392                "a policy record was refused; the record in force at its key keeps serving"
393            );
394        }
395        for failure in &bound.refused {
396            warn!(
397                collection = failure.collection,
398                key = %failure.key,
399                reason = %failure.reason,
400                "a policy record was refused and nothing was in force at its key; every record \
401                 beside it is installed"
402            );
403        }
404
405        let digest = bound.revision.digest.clone();
406        let refused = bound.refused.clone();
407        let carried = bound.carried.clone();
408        let (revision, events) = self.set.install(bound.revision, bound.refused);
409        for event in &events {
410            match event {
411                ApplyEvent::Added { collection, key }
412                | ApplyEvent::Updated { collection, key }
413                | ApplyEvent::Removed { collection, key } => {
414                    debug!(collection, %key, ?event, "operator policy poll observed a change");
415                }
416            }
417        }
418        for key in &bound.report.unbound {
419            debug!(%key, "a policy no binding names reaches nobody");
420        }
421        for (binding, subject) in &bound.report.foreign_subjects {
422            debug!(%binding, %subject, "a binding names a subject that is not an account here; ignored");
423        }
424        for key in &bound.report.spares_held_tokens {
425            warn!(
426                %key,
427                "a policy denies a client before the write and not at it; tokens it already \
428                 holds keep writing until they expire"
429            );
430        }
431        for fallback in &bound.load.last_good_fallbacks {
432            warn!(
433                id = %fallback.id,
434                enforced = ?fallback.enforced_revision,
435                failed = ?fallback.failed_revision,
436                reason = %fallback.reason,
437                "a policy's new revision will not compile; the revision that did keeps being \
438                 enforced"
439            );
440        }
441        for warning in &bound.load.warnings {
442            warn!(
443                ?warning,
444                "a policy compiled with something the operator should see"
445            );
446        }
447        info!(
448            %digest,
449            policies = revision.policies.len(),
450            bindings = revision.bindings.len(),
451            bound = bound.bindings.len(),
452            refused = refused.len(),
453            carried = carried.len(),
454            "installed the operator's policy set"
455        );
456        let enforced = self.summarise(&revision, registry, refused, carried);
457        self.declared_lookups.publish(bound.lookups);
458        self.gate.swap_bound(bound.tree, bound.bindings, enforced);
459
460        // Kept only once it is in force, and only when it is not already
461        // what is on disk: a tick that changes nothing rewrites nothing.
462        //
463        // A set that will not write down is still in force for this run, so
464        // the tick stands rather than being thrown away. What it costs is a
465        // restart coming back on the set before it, which is why this is an
466        // error rather than a warning.
467        if let Some(store) = &self.store {
468            if in_force.digest != revision.digest {
469                match store.save(&self.operator_did, &revision) {
470                    Ok(()) => {
471                        debug!(%digest, path = %store.path().display(), "kept the policy set in force");
472                    }
473                    Err(err) => error!(
474                        %err,
475                        %digest,
476                        "the policy set in force could not be written down; a restart would come \
477                         back on the set before it"
478                    ),
479                }
480            }
481        }
482        Ok(revision)
483    }
484
485    /// Builds `observed` against `in_force`, with this server's identity and
486    /// the registry's admission tree.
487    fn build(
488        &self,
489        observed: &Observed,
490        registry: &dyn Registry,
491        in_force: &PolicyRevision,
492    ) -> Result<didbot_pds::BoundTree, EmptiedSet> {
493        let chain_of = |did: &str| registry.boundary(did).ok();
494        didbot_pds::policy_bindings::resolve(
495            observed,
496            ServerIdentity {
497                service_did: &self.this_pds,
498                operator_did: &self.operator_did,
499            },
500            &chain_of,
501            didbot_policy_records::builtin_policies(),
502            &HashMap::new(),
503            &self.across,
504            in_force,
505        )
506    }
507
508    /// The set as an operator reads it back, with each binding's reach
509    /// resolved against the accounts this server holds right now.
510    ///
511    /// One pass over the accounts per installed set, which is why it is
512    /// built here — on the poll's tick — and handed to the gate, rather
513    /// than computed on each `bot.did.stats` call.
514    fn summarise(
515        &self,
516        revision: &PolicyRevision,
517        registry: &dyn Registry,
518        refused: Vec<didbot_pds::BuildFailure>,
519        carried: Vec<didbot_pds::BuildFailure>,
520    ) -> EnforcedSet {
521        let boundaries: Vec<(String, Boundary)> = registry
522            .accounts()
523            .into_iter()
524            .map(|account| {
525                let did = account.did.as_str().to_owned();
526                let chain = registry
527                    .boundary(&did)
528                    .unwrap_or_else(|_| vec![did.clone()]);
529                (did, Boundary::of(chain))
530            })
531            .collect();
532        EnforcedSet {
533            digest: revision.digest.clone(),
534            policies: revision
535                .policies
536                .iter()
537                .map(|(key, record)| EnforcedPolicy {
538                    key: key.clone(),
539                    actions: record.actions.iter().map(ToString::to_string).collect(),
540                    document: record.document.type_name().to_owned(),
541                })
542                .collect(),
543            bindings: revision
544                .bindings
545                .iter()
546                .map(|(key, record)| {
547                    let coverage = Coverage::of(record);
548                    EnforcedBinding {
549                        key: key.clone(),
550                        policies: record
551                            .policies
552                            .iter()
553                            .map(|policy| policy.key.clone())
554                            .collect(),
555                        accounts: boundaries
556                            .iter()
557                            .filter(|(_, boundary)| coverage.covers(boundary, &self.this_pds))
558                            .map(|(did, _)| did.clone())
559                            .collect(),
560                    }
561                })
562                .collect(),
563            refused,
564            carried,
565        }
566    }
567}
568
569/// A listing keyed by at-uri, re-keyed by record key: the key a binding
570/// names a policy by, and the one a refusal names a record by.
571fn by_record_key(
572    listing: HashMap<String, serde_json::Value>,
573) -> BTreeMap<String, serde_json::Value> {
574    listing
575        .into_iter()
576        .map(|(uri, value)| {
577            let key = AtUri::parse(&uri)
578                .ok()
579                .and_then(AtUri::rkey)
580                .unwrap_or_else(|| uri.rsplit('/').next().unwrap_or(&uri))
581                .to_owned();
582            (key, value)
583        })
584        .collect()
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590    use serde_json::json;
591    use std::sync::Arc;
592
593    const COLLECTION: &str = "bot.did.policy";
594
595    fn value(revision: u64) -> serde_json::Value {
596        json!({
597            "language": "regex",
598            "revision": revision,
599            "applicability": { "write": { "collections": ["c1"] } },
600            "predicate": {}
601        })
602    }
603
604    /// An operator PDS that answers one prepared page per request, echoing
605    /// a cursor until the last one.
606    async fn fake_operator_pds(
607        pages: Vec<Vec<(&'static str, serde_json::Value)>>,
608    ) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
609        use axum::extract::Query;
610        use axum::response::IntoResponse;
611        use axum::routing::get;
612        use axum::{Json, Router};
613        use std::sync::atomic::{AtomicUsize, Ordering};
614
615        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
616            .await
617            .expect("bind");
618        let addr = listener.local_addr().expect("local addr");
619
620        let pages = Arc::new(pages);
621        let page_index = Arc::new(AtomicUsize::new(0));
622
623        let router = Router::new().route(
624            "/xrpc/com.atproto.repo.listRecords",
625            get(move |Query(_params): Query<HashMap<String, String>>| {
626                let pages = pages.clone();
627                let page_index = page_index.clone();
628                async move {
629                    let i = page_index.fetch_add(1, Ordering::SeqCst);
630                    let page = pages.get(i).cloned().unwrap_or_default();
631                    let records: Vec<_> = page
632                        .iter()
633                        .map(
634                            |(uri, value)| json!({ "uri": uri, "cid": "bafyfake", "value": value }),
635                        )
636                        .collect();
637                    let has_more = i + 1 < pages.len();
638                    let body = if has_more {
639                        json!({ "records": records, "cursor": format!("page-{}", i + 1) })
640                    } else {
641                        json!({ "records": records })
642                    };
643                    Json(body).into_response()
644                }
645            }),
646        );
647
648        let handle = tokio::spawn(async move {
649            axum::serve(listener, router).await.ok();
650        });
651        (addr, handle)
652    }
653
654    /// An operator whose host accepts the connection and never answers
655    /// costs the listing exactly `didbot_http::DEFAULT_TIMEOUT`, then a
656    /// fetch error.
657    #[tokio::test(start_paused = true)]
658    async fn an_operator_host_that_never_answers_is_a_fetch_error_after_the_client_timeout() {
659        let addr = didbot_http::test_support::hung_server();
660        let endpoint = format!("http://{addr}");
661        let client = didbot_http::client();
662        let started = tokio::time::Instant::now();
663        let listing = list_records(&client, &endpoint, "did:web:op.example", COLLECTION);
664        let err = tokio::time::timeout(didbot_http::DEFAULT_TIMEOUT * 2, listing)
665            .await
666            .expect("the client's own timeout fires first")
667            .expect_err("nothing ever answers");
668        assert!(matches!(err, PollError::Fetch { .. }), "{err:?}");
669        assert_eq!(started.elapsed(), didbot_http::DEFAULT_TIMEOUT);
670    }
671
672    #[tokio::test]
673    async fn a_single_page_is_fetched_in_full() {
674        let (addr, server) =
675            fake_operator_pds(vec![vec![("at://op/bot.did.policy/a", value(1))]]).await;
676        let endpoint = format!("http://{addr}");
677        let client = didbot_http::client();
678
679        let listing = list_records(&client, &endpoint, "did:web:op.example", COLLECTION)
680            .await
681            .expect("should succeed");
682        assert_eq!(listing.len(), 1);
683        server.abort();
684    }
685
686    #[tokio::test]
687    async fn a_full_page_followed_by_a_short_page_is_paginated_through() {
688        let full_page: Vec<_> = (0..PAGE_SIZE)
689            .map(|i| {
690                let uri: &'static str =
691                    Box::leak(format!("at://op/bot.did.policy/{i}").into_boxed_str());
692                (uri, value(1))
693            })
694            .collect();
695        let second_page = vec![("at://op/bot.did.policy/extra", value(1))];
696        let (addr, server) = fake_operator_pds(vec![full_page, second_page]).await;
697        let endpoint = format!("http://{addr}");
698        let client = didbot_http::client();
699
700        let listing = list_records(&client, &endpoint, "did:web:op.example", COLLECTION)
701            .await
702            .expect("should succeed");
703        assert_eq!(listing.len() as u32, PAGE_SIZE + 1);
704        server.abort();
705    }
706
707    /// A PDS that answers every cursor with another full page. Each page is
708    /// bounded in bytes and the number of them was not, so this loop ran and
709    /// this map grew for as long as the host cared to keep answering.
710    async fn endless_operator_pds() -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
711        use axum::extract::Query;
712        use axum::routing::get;
713        use axum::{Json, Router};
714        use std::sync::atomic::{AtomicU32, Ordering};
715
716        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
717            .await
718            .expect("bind");
719        let addr = listener.local_addr().expect("local addr");
720        let served = Arc::new(AtomicU32::new(0));
721        let router = Router::new().route(
722            "/xrpc/com.atproto.repo.listRecords",
723            get(move |Query(_): Query<HashMap<String, String>>| {
724                let counted = served.clone();
725                async move {
726                    let page = counted.fetch_add(1, Ordering::SeqCst);
727                    let records: Vec<_> = (0..PAGE_SIZE)
728                        .map(|n| {
729                            json!({
730                                "uri": format!("at://op/bot.did.policy/p{page}-{n}"),
731                                "cid": "bafyfake",
732                                "value": value(1),
733                            })
734                        })
735                        .collect();
736                    Json(json!({ "records": records, "cursor": format!("page-{}", page + 1) }))
737                }
738            }),
739        );
740        let handle = tokio::spawn(async move {
741            axum::serve(listener, router).await.ok();
742        });
743        (addr, handle)
744    }
745
746    /// The listing stops at the cap and reports a failure rather than an
747    /// answer. A short listing would install a set the operator never wrote;
748    /// a failure leaves the set last built serving.
749    #[tokio::test]
750    async fn a_listing_that_never_ends_stops_at_the_cap_and_fails() {
751        let (addr, server) = endless_operator_pds().await;
752        let endpoint = format!("http://{addr}");
753        let client = didbot_http::client();
754        let error = list_records(&client, &endpoint, "did:web:op.example", COLLECTION)
755            .await
756            .expect_err("a listing that never ends is not an answer");
757        match error {
758            PollError::TooManyPages { pages, .. } => assert_eq!(pages, MAX_PAGES),
759            other => panic!("{other:?}"),
760        }
761        server.abort();
762    }
763}