···1515http = "1"
1616jacquard-api = { version = "0.9.5", default-features = false, features = ["com_atproto", "streaming"] }
1717jacquard-axum = { version = "0.9.6", default-features = false, features = ["tracing"] }
1818-jacquard-common = { version = "0.9.5", features = ["websocket", "reqwest-client"] }
1818+jacquard-common = { version = "0.9.5", features = ["websocket", "reqwest-client", "streaming"] }
1919+n0-future = "0.1"
1920jacquard-derive = "0.9.5"
2021jacquard-identity = "0.9.5"
2122jacquard-lexicon = "0.9.5"
···2425metrics-exporter-prometheus = { version = "0.18.1", features = ["http-listener"] }
2526mini-moka = "0.10"
2627reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
2727-repo-stream = { version = "0.5.0-alpha.2", features = ["jacquard"] }
2828+repo-stream = { version = "0.5.0-alpha.3", features = ["jacquard"] }
2829rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs"] }
2930rustversion = "1"
3031serde = { version = "1", features = ["derive"] }
3132serde_json = "1"
3233thiserror = "2.0.18"
3334tokio = { version = "1.49.0", features = ["full"] }
3434-tokio-util = { version = "0.7", features = ["rt"] }
3535+tokio-util = { version = "0.7", features = ["rt", "io-util"] }
3536tracing = "0.1.44"
3637tracing-subscriber = { version = "0.3", features = ["env-filter"] }
3738
+2-1
hacking.md
···7373 - [x] noop if rev/cid are unchanged
7474 - [-] drop any preceeding #commits from the queue? (tricky with noop)
7575- [ ] "deep crawl" mode for relays that listHosts -> listRepos on host instead of relying on relay listRepos
7676+ - [ ] defensive loop-cursor handling
7677- [~] actually firehose-index!!
7778 - [x] extract collections-added/removed directly from CAR slice
7879 - [ ] (spend some time on tests here)
···125126126127 this is not nice because the response has neither the MST root signature nor the repo's `rev`, so we have to do extra work.
127128128128-the extra work for `describeRepo` currently entails calling `com.atproto.sync.getLatestCommit` first, to get a `rev` and MST root CID. this isn't sync-1.1 level robust: we're racing in between the two requests against potential new collections being added or removed. but doing cutover from a latest-commit *before* describeRepo was called should make the list converge to the correct state.
129129+the extra work for `describeRepo` currently entails ~~calling `com.atproto.sync.getLatestCommit` first~~ er, calling `com.atproto.sync.getRecord` with an arbitrary key first, to get a `rev` and MST root CID. this isn't sync-1.1 level robust: we're racing in between the two requests against potential new collections being added or removed. but doing cutover from a latest-commit *before* describeRepo was called should make the list converge to the correct state.
129130130131there are a few other ways we might get a fully-authenticated collections list, see [./authenticated-collection-list.md](./authenticated-collection-list.md) for some possible approaches.
131132
···11-//! Extract adjacent MST keys from a CAR slice.
22-//!
33-//! Given the raw blocks included in a `getRecord` CAR slice, this module
44-//! uses `jacquard-repo` primitives to find neighbouring record keys and detect
55-//! collection boundaries.
66-//!
77-//! `getRecord` CAR slices are *partial* snapshots of the MST: they include only
88-//! the blocks on the path from the root to the queried key. Sibling subtrees
99-//! are referenced by CID but their blocks are absent. The cursor walks only
1010-//! the blocks that are present, skipping any subtree whose block is missing.
1111-1212-use std::sync::Arc;
1313-1414-use jacquard_repo::{
1515- MemoryBlockStore, Mst,
1616- car::parse_car_bytes,
1717- commit::Commit,
1818- mst::{CursorPosition, MstCursor},
1919-};
2020-2121-use crate::error::{Error, Result};
2222-2323-/// The adjacent keys returned by the MST for a given probe key.
2424-#[derive(Debug, Clone)]
2525-pub struct AdjacentKeys {
2626- /// The key immediately before the probe key in the MST, if any.
2727- pub prev: Option<String>,
2828- /// The key immediately after the probe key in the MST, if any.
2929- pub next: Option<String>,
3030-}
3131-3232-/// Extract adjacent keys for `probe_key` from the given CAR block bytes.
3333-///
3434-/// Parses the MST from the CAR, then walks the tree in sorted order using
3535-/// `MstCursor`, collecting the largest visible key less than `probe_key`
3636-/// (prev) and the smallest visible key greater than `probe_key` (next).
3737-///
3838-/// Subtrees whose blocks are absent from the CAR are silently skipped; this
3939-/// is expected for `getRecord` slices, which contain only the proof path.
4040-pub async fn extract_adjacent(car_bytes: &[u8], probe_key: &str) -> Result<AdjacentKeys> {
4141- // Parse the CAR bytes into a root CID + block map.
4242- let parsed = parse_car_bytes(car_bytes)
4343- .await
4444- .map_err(|e| Error::Other(e.to_string()))?;
4545-4646- // The CAR root is the signed commit; its `data` field is the MST root CID.
4747- let mst_root = {
4848- let commit_bytes = parsed
4949- .blocks
5050- .get(&parsed.root)
5151- .ok_or_else(|| Error::Other("getRecord CAR has no commit block".into()))?;
5252- let commit = Commit::from_cbor(commit_bytes.as_ref())
5353- .map_err(|e| Error::Other(format!("bad commit in getRecord CAR: {}", e)))?;
5454- *commit.data()
5555- };
5656-5757- // Load all CAR blocks into an in-memory store and mount the MST lazily.
5858- let storage = Arc::new(MemoryBlockStore::new_from_blocks(parsed.blocks));
5959- let mst = Mst::load(storage, mst_root, None);
6060-6161- // Walk the tree with a cursor in sorted key order.
6262- // The cursor starts pointing at the MST root (a Tree position); we process
6363- // each position as we encounter it.
6464- let mut cursor = MstCursor::new(mst);
6565- let mut prev: Option<String> = None;
6666- let mut next: Option<String> = None;
6767-6868- loop {
6969- match cursor.current() {
7070- CursorPosition::End => break,
7171-7272- CursorPosition::Leaf { key, .. } => {
7373- let k = key.as_str();
7474- if k < probe_key {
7575- // This is a candidate for prev; keep the latest one seen.
7676- prev = Some(k.to_owned());
7777- // Advance past this leaf (step_over; never fails).
7878- if cursor.advance().await.is_err() {
7979- break;
8080- }
8181- } else if k > probe_key {
8282- // First key greater than probe_key in sorted order.
8383- next = Some(k.to_owned());
8484- break;
8585- } else {
8686- // k == probe_key: the record actually exists; skip it.
8787- if cursor.advance().await.is_err() {
8888- break;
8989- }
9090- }
9191- }
9292-9393- CursorPosition::Tree { .. } => {
9494- // Descend into the subtree. If the block is absent from the
9595- // CAR (the cursor returns Err), skip the subtree instead.
9696- match cursor.advance().await {
9797- Ok(()) => {}
9898- Err(_) => {
9999- if cursor.skip_subtree().await.is_err() {
100100- break;
101101- }
102102- }
103103- }
104104- }
105105- }
106106- }
107107-108108- Ok(AdjacentKeys { prev, next })
109109-}
110110-111111-/// Estimate whether this is a small repo by inspecting the MST level of the
112112-/// highest-level node present in `car_bytes`.
113113-///
114114-/// Uses `jacquard_repo::mst::util::layer_for_key()` on the highest-level key
115115-/// found in the partial MST slice. Small repos are statistically unlikely to
116116-/// contain high-level keys, since every CAR slice must include all maximum-level
117117-/// keys of the repository.
118118-pub fn is_small_repo(_car_bytes: &[u8], _threshold_level: u8) -> crate::error::Result<bool> {
119119- todo!("parse MST nodes from CAR blocks and check maximum key level")
120120-}
+72
src/mst/collections.rs
···11+use jacquard_common::types::string::Nsid;
22+use repo_stream::MemCar;
33+use std::collections::HashSet;
44+55+#[derive(Debug, thiserror::Error)]
66+pub enum MstCollectionsError {
77+ #[error("repo-stream WalkError: {0}")]
88+ WalkError(#[from] repo_stream::WalkError),
99+ #[error("bad repo path: {0}")]
1010+ BadPath(String),
1111+}
1212+1313+type Result<T> = std::result::Result<T, MstCollectionsError>;
1414+1515+/// get whatever collections can be found from a possibly-partial CAR
1616+///
1717+/// linear scan of all present keys, no seeks -- expects a small CAR slice
1818+pub fn extract_from_slice(car: &mut MemCar) -> Result<HashSet<Nsid<'static>>> {
1919+ let mut collections = HashSet::new();
2020+ while let Some((path, _)) = car.next_keys()? {
2121+ let (nsid_str, _) = path.split_once('/').ok_or(MstCollectionsError::BadPath(
2222+ "missing '/' separator".to_string(),
2323+ ))?;
2424+ let collection = nsid_str
2525+ .parse()
2626+ .map_err(|e| MstCollectionsError::BadPath(format!("nsid parse: {e}")))?;
2727+ collections.insert(collection);
2828+ }
2929+ Ok(collections)
3030+}
3131+3232+/// get all collections from a full repo export CAR
3333+///
3434+/// uses repo-stream's `seek` to skip over unnecesary subtrees
3535+///
3636+/// ships the work out via spawn_blocking because large repos can take a bit,
3737+/// and disk-spilled repos (eventually) will be even more blocking
3838+///
3939+/// TODO: have repo_stream include some size estimate so we can avoid
4040+/// spawn_blocking for small repos
4141+pub async fn extract_from_full(car: MemCar) -> Result<Vec<Nsid<'static>>> {
4242+ tokio::task::spawn_blocking(move || extract_full_blocking_impl(car))
4343+ .await
4444+ .unwrap() // don't suppress tokio-level errors we can't handle
4545+}
4646+4747+/// skim the repo along collection boundaries to extract them all
4848+///
4949+/// seeks past each collection using a sentinel that sorts strictly after any
5050+/// valid key in the collection. Rkeys in atproto are capped at 512 chars; 513
5151+/// tildes exceeds that maximum, so `collection/<513 tildes>` can never equal an
5252+/// actual record key and is guaranteed to be greater than
5353+/// `collection/<512 tildes>` (the max valid key).
5454+///
5555+/// returns a lex-sorted list of all collections in the repo
5656+fn extract_full_blocking_impl(mut car: MemCar) -> Result<Vec<Nsid<'static>>> {
5757+ let tilde_max = "~".repeat(513);
5858+5959+ let mut collections = Vec::new();
6060+ while let Some((path, _)) = car.next_keys_strict()? {
6161+ let (nsid_str, _) = path.split_once('/').ok_or(MstCollectionsError::BadPath(
6262+ "missing '/' separator".to_string(),
6363+ ))?;
6464+ let collection = nsid_str
6565+ .parse()
6666+ .map_err(|e| MstCollectionsError::BadPath(format!("nsid parse: {e}")))?;
6767+ collections.push(collection);
6868+ car.seek(&format!("{nsid_str}/{tilde_max}")).unwrap();
6969+ }
7070+ collections.sort_unstable();
7171+ Ok(collections)
7272+}
+2-7
src/mst/mod.rs
···11-//! MST (Merkle Search Tree) utilities.
22-//!
33-//! Thin wrappers around `jacquard-repo` primitives for:
44-//! - extracting adjacent keys from CAR slices
55-//! - detecting collection boundaries
66-//! - small-repo heuristic via MST level inspection
11+//! mst (merkle search tree, the atproto repo structure) utils
7288-pub mod adjacent;
33+pub mod collections;
···11-//! Enumerate a repository's collections via `com.atproto.repo.describeRepo`.
11+//! list repo collections with `com.atproto.repo.describeRepo`
22+//!
33+//! describeRepo includes a list of collections in the repo, so we can avoid
44+//! downloading full repo contents just to build our tiny index if we use it.
55+//!
66+//! there are two downsides:
77+//!
88+//! 1. the list is not authenticated in the AT sense, so we are trusting the PDS
99+//! to generate it correctly,
1010+//! 2. the response does not include the repo's `rev` or `data` (mst root hash)
1111+//! that we would need to assert that the next commit we see from them on the
1212+//! firehose actually follows from the describeRepo response without gaps.
213//!
33-//! The PDS returns its collection list directly in the response — one request,
44-//! no CAR parsing. This is tried first because it is cheap and usually works.
55-//! A second request to `com.atproto.sync.getLatestCommit` fetches the commit
66-//! CID and rev that the `get_repo` path gets for free from the CAR.
1414+//! for the second, we try to separately obtain a usable `rev`+`data` pair
1515+//! separately from the `describeRepo` request, by calling `sync.getRecord`.
1616+//! the neat thing is, we don't need to know about an existing record to
1717+//! getRecord! PDSs return "proof-of-nonexistence" if your key doesn't point to
1818+//! anything, so we get a CAR with the Commit Object that we need, whether or
1919+//! not we asked for an existing record.
2020+//!
2121+//! as a bonus, we'll receive a small number of real MST keys in the CAR slice,
2222+//! which we can assert against the `describeRepo` collections as a first-line
2323+//! check of the PDS describeRepo response.
2424+//!
2525+//! getting the rev/data from `getRecord` before `describeRepo` is meant to help
2626+//! to our local state converge to the correct state in case there is an update
2727+//! between our two calls:
2828+//!
2929+//! - if a new collection was introduced: we'll "accidentally" add it early.
3030+//! when it re-arrives on the firehose, re-adding it is harmless.
3131+//! - similar with deletes: deleting what wasn't there is a db no-op.
3232+3333+use jacquard_api::com_atproto::{
3434+ repo::describe_repo::DescribeRepo,
3535+ sync::get_record::{GetRecord, GetRecordError},
3636+};
3737+use jacquard_common::{
3838+ IntoStatic,
3939+ error::ClientErrorKind,
4040+ http_client::HttpClient,
4141+ types::string::{Cid, Did, Nsid, RecordKey, Rkey, Tid},
4242+ xrpc::{XrpcError, XrpcExt},
4343+};
4444+use repo_stream::{DriverBuilder, LoadError};
4545+use std::collections::HashSet;
74688-use jacquard_api::com_atproto::repo::describe_repo::DescribeRepo;
99-use jacquard_api::com_atproto::sync::get_latest_commit::GetLatestCommit;
1010-use jacquard_common::error::ClientErrorKind;
1111-use jacquard_common::http_client::HttpClient;
1212-use jacquard_common::types::cid::Cid;
1313-use jacquard_common::types::nsid::Nsid;
1414-use jacquard_common::types::string::Did;
1515-use jacquard_common::xrpc::XrpcExt;
4747+use super::{GetCollectionsError, RepoGoneReason, RepoSnapshot};
16481717-use super::{GetCollectionsError, RepoSnapshot};
4949+/// for `getRecord` CAR slice (commit block + small MST proof path)
5050+const GET_RECORD_MEM_LIMIT_MB: usize = 1;
18511919-/// Ask the PDS for its collection list and latest commit metadata.
5252+/// get repo collections the cheap (and less-authenticated-data-y) way
2053///
2121-/// Makes two requests: `describeRepo` for the collection list, then
2222-/// `getLatestCommit` for the commit CID and revision.
5454+/// - does a phony `getRecord` for a Commit (-> rev/data sync anchor) and a
5555+/// small set of proof keys we can use to check
5656+/// - the `describeRepo`'s collection list from the repo
2357pub async fn fetch_collections<C>(
2458 client: &C,
2559 base: &jacquard_common::url::Url,
···2862where
2963 C: HttpClient + Sync,
3064{
3131- // Step 1: commit CID + rev from getLatestCommit.
3232- //
3333- // We intentionally fetch commit metadata *before* the collection list.
3434- // If a commit lands between the two requests, we end up with a rev that
3535- // is slightly behind the collections we observed — our index may contain
3636- // a collection that didn't exist at the stored rev (a false positive),
3737- // but it will never *miss* a collection relative to that rev (a false
3838- // negative). False positives self-correct: the firehose delivers events
3939- // from our stored rev onward, so the racing commit will arrive and
4040- // reconcile the index. With the opposite order we'd store a rev ahead of
4141- // our collection snapshot; the firehose would then skip the racing commit
4242- // entirely, leaving the index persistently stale.
4343- //
4444- // TODO: think about this more. we really want to avoid adding a collection
4545- // twice so that we can safely do fjall weak deletes. if we can't assure
4646- // that then we should do full regular tombstone deletes.
4747- let req = GetLatestCommit { did: did.clone() };
4848- let host = base.host_str().unwrap_or("").to_string();
4949- let resp = client
6565+ // step 1: commit rev + data CID from getRecord, plus a few collections
6666+ let req = GetRecord {
6767+ collection: Nsid::new_static("blue.microcosm.lightrail.probe").unwrap(),
6868+ did: did.clone(),
6969+ rkey: RecordKey(Rkey::new_static("any").unwrap()),
7070+ };
7171+ let host = base
7272+ .host_str()
7373+ .ok_or(GetCollectionsError::Request(format!(
7474+ "failed to get host from {base:?}"
7575+ )))?
7676+ .to_string();
7777+7878+ let output = client
5079 .xrpc(base.clone())
5180 .send(&req)
5281 .await
5353- .map_err(|e| classify_send_error(e, &host))?;
5454- let (rev, commit) = match resp.parse() {
5555- Ok(output) => {
5656- let commit = output.cid.to_ipld().map(Cid::ipld).map_err(|e| {
5757- GetCollectionsError::InvalidData(format!("invalid commit CID: {e}"))
5858- })?;
5959- (output.rev, commit)
6060- }
6161- Err(e) => return Err(GetCollectionsError::Request(e.to_string())),
6262- };
8282+ .map_err(|e| classify_send_error(e, &host))?
8383+ .parse()
8484+ .map_err(|e| match e {
8585+ XrpcError::Xrpc(e) => classify_get_record_error(e),
8686+ e => GetCollectionsError::Request(e.to_string()),
8787+ })?;
8888+8989+ let reader = tokio::io::BufReader::new(std::io::Cursor::new(output.body));
9090+ let mut mem_car = DriverBuilder::new()
9191+ .with_mem_limit_mb(GET_RECORD_MEM_LIMIT_MB)
9292+ .load_car(reader)
9393+ .await
9494+ .map_err(|e| match e {
9595+ LoadError::MemoryLimitReached(_) => GetCollectionsError::InvalidData(format!(
9696+ "getRecord CAR exceeds {GET_RECORD_MEM_LIMIT_MB} MiB limit"
9797+ )),
9898+ e => GetCollectionsError::InvalidData(format!("getRecord CAR error: {e}")),
9999+ })?;
100100+101101+ let rev = Tid::new(&mem_car.commit.rev)
102102+ .map_err(|e| GetCollectionsError::InvalidData(format!("bad rev in commit: {e}")))?;
103103+104104+ // shuffle repo-stream raw cid => jacquard cid wrapper
105105+ let mst_root = Cid::ipld(mem_car.commit.data.clone().into());
106106+107107+ let proven_collections =
108108+ crate::mst::collections::extract_from_slice(&mut mem_car).map_err(|e| {
109109+ GetCollectionsError::InvalidData(format!("failed to extract collections from CAR: {e}"))
110110+ })?;
631116464- // Step 2: collection list from describeRepo.
6565- let req = DescribeRepo {
6666- repo: did.clone().into(),
6767- };
6868- let resp = client
112112+ // step 2: collection list from describeRepo.
113113+ let collections: HashSet<Nsid<'_>> = client
69114 .xrpc(base.clone())
7070- .send(&req)
115115+ .send(&DescribeRepo {
116116+ repo: did.clone().into(),
117117+ })
71118 .await
7272- .map_err(|e| classify_send_error(e, &host))?;
7373- let mut collections: Vec<Nsid<'static>> = match resp.parse() {
7474- Ok(output) => output
7575- .collections
7676- .iter()
7777- .map(|c| {
7878- Nsid::new_owned(c.as_str()).map_err(|e| {
7979- GetCollectionsError::InvalidData(format!("invalid collection NSID: {e}"))
8080- })
8181- })
8282- .collect::<Result<Vec<_>, _>>()?,
8383- Err(e) => return Err(GetCollectionsError::Request(e.to_string())),
8484- };
119119+ .map_err(|e| classify_send_error(e, &host))?
120120+ .parse()
121121+ .map_err(|e| GetCollectionsError::Request(e.to_string()))? // Request => get_repo fallback
122122+ .collections
123123+ .into_iter()
124124+ .map(IntoStatic::into_static)
125125+ .collect();
126126+127127+ // post-processing check: collections we know from getRecord present
128128+ if !collections.is_superset(&proven_collections) {
129129+ return Err(GetCollectionsError::InvalidData(
130130+ "describeRepo inconsistent collection set".to_string(),
131131+ ));
132132+ }
133133+134134+ // final shape and order
135135+ let mut collections = Vec::from_iter(collections);
85136 collections.sort_unstable();
8613787138 Ok(RepoSnapshot {
88139 collections,
89140 rev,
9090- commit,
141141+ commit: Some(mst_root),
91142 })
92143}
93144145145+fn classify_get_record_error(e: GetRecordError<'_>) -> GetCollectionsError {
146146+ match e {
147147+ GetRecordError::RepoNotFound(_) => GetCollectionsError::RepoNotFound,
148148+ GetRecordError::RepoTakendown(_) => {
149149+ GetCollectionsError::RepoGone(RepoGoneReason::Takendown)
150150+ }
151151+ GetRecordError::RepoSuspended(_) => {
152152+ GetCollectionsError::RepoGone(RepoGoneReason::Suspended)
153153+ }
154154+ GetRecordError::RepoDeactivated(_) => {
155155+ GetCollectionsError::RepoGone(RepoGoneReason::Deactivated)
156156+ }
157157+ // RecordNotFound or unknown: treat as transient; getRepo fallback will be attempted.
158158+ e => GetCollectionsError::Request(e.to_string()),
159159+ }
160160+}
161161+94162fn classify_send_error(e: jacquard_common::error::ClientError, host: &str) -> GetCollectionsError {
95163 if matches!(e.kind(), ClientErrorKind::Http { status } if status.as_u16() == 429) {
96164 GetCollectionsError::RateLimited(host.to_string())
···102170#[cfg(test)]
103171mod tests {
104172 use super::*;
105105- use wiremock::matchers::{method, path, query_param};
106106- use wiremock::{Mock, MockServer, ResponseTemplate};
173173+ use std::sync::Arc;
107174175175+ use bytes::Bytes;
108176 use jacquard_common::types::nsid::Nsid;
109177 use jacquard_common::types::string::Did;
178178+ use jacquard_common::types::tid::Tid;
179179+ use jacquard_repo::commit::Commit;
180180+ use jacquard_repo::{BlockStore, MemoryBlockStore, Mst, car::write_car_bytes};
181181+ use wiremock::matchers::{method, path, query_param};
182182+ use wiremock::{Mock, MockServer, ResponseTemplate};
110183111184 fn nsid(s: &str) -> Nsid<'static> {
112185 Nsid::new_owned(s).unwrap()
113186 }
114187115188 const DID: &str = "did:web:example.com";
116116- // A plausible commit CID and rev for mock responses.
117117- const TEST_CID: &str = "bafyreib2rxk3rybk3aobmv5cjuql3bm2twh4jo5uxgf5kpqcsgz7soitae";
118118- const TEST_REV: &str = "3lczouzaqmo2e";
119189120190 fn did() -> Did<'static> {
121191 Did::new_owned(DID).unwrap()
122192 }
123193194194+ /// Build a minimal valid CAR for use as a `getRecord` response.
195195+ ///
196196+ /// Returns `(car_bytes, mst_root_cid_string, rev_string)`.
197197+ async fn make_car(keys: &[&str]) -> (Vec<u8>, String, String) {
198198+ let storage = Arc::new(MemoryBlockStore::new());
199199+ let mut mst = Mst::new(storage.clone());
200200+201201+ let dummy_cid = storage.put(b"record").await.unwrap();
202202+ for key in keys {
203203+ mst = mst.add(key, dummy_cid).await.unwrap();
204204+ }
205205+206206+ let (mst_root, mut blocks) = mst.collect_blocks().await.unwrap();
207207+ let mst_root_str = mst_root.to_string();
208208+209209+ let commit = Commit {
210210+ did: Did::new_owned(DID).unwrap(),
211211+ version: 3,
212212+ data: mst_root,
213213+ rev: Tid::now_0(),
214214+ prev: None,
215215+ sig: Bytes::from(vec![0u8; 64]),
216216+ };
217217+ let commit_cid = commit.to_cid().unwrap();
218218+ let rev_str = commit.rev.to_string();
219219+ let commit_cbor = Bytes::from(commit.to_cbor().unwrap());
220220+ blocks.insert(commit_cid, commit_cbor);
221221+222222+ let car = write_car_bytes(commit_cid, blocks).await.unwrap();
223223+ (car, mst_root_str, rev_str)
224224+ }
225225+124226 fn describe_repo_response(collections: &[&str]) -> serde_json::Value {
125227 serde_json::json!({
126228 "did": DID,
···131233 })
132234 }
133235134134- fn latest_commit_response() -> serde_json::Value {
135135- serde_json::json!({ "cid": TEST_CID, "rev": TEST_REV })
136136- }
137137-138236 /// Mount mocks for both endpoints on a single server.
139237 async fn mock_server(
140238 describe_template: ResponseTemplate,
141141- latest_template: ResponseTemplate,
239239+ get_record_car: Vec<u8>,
142240 ) -> MockServer {
143241 let server = MockServer::start().await;
144242 Mock::given(method("GET"))
···148246 .mount(&server)
149247 .await;
150248 Mock::given(method("GET"))
151151- .and(path("/xrpc/com.atproto.sync.getLatestCommit"))
249249+ .and(path("/xrpc/com.atproto.sync.getRecord"))
152250 .and(query_param("did", DID))
153153- .respond_with(latest_template)
251251+ .and(query_param("collection", "blue.microcosm.lightrail.probe"))
252252+ .and(query_param("rkey", "any"))
253253+ .respond_with(
254254+ ResponseTemplate::new(200)
255255+ .insert_header("content-type", "application/vnd.ipld.car")
256256+ .set_body_bytes(get_record_car),
257257+ )
154258 .mount(&server)
155259 .await;
156260 server
···158262159263 #[tokio::test]
160264 async fn returns_collections_sorted() {
265265+ let (car, _, _) = make_car(&["app.bsky.actor.profile/self"]).await;
161266 let server = mock_server(
162267 ResponseTemplate::new(200).set_body_json(describe_repo_response(&[
163268 "app.bsky.feed.post",
164269 "app.bsky.actor.profile",
165270 ])),
166166- ResponseTemplate::new(200).set_body_json(latest_commit_response()),
271271+ car,
167272 )
168273 .await;
169274···181286 #[tokio::test]
182287 async fn sorts_server_order() {
183288 // Server returns collections in reverse lexicographic order.
289289+ let (car, _, _) = make_car(&["app.bsky.actor.profile/self"]).await;
184290 let server = mock_server(
185291 ResponseTemplate::new(200).set_body_json(describe_repo_response(&[
186292 "app.bsky.graph.follow",
187293 "app.bsky.feed.post",
188294 "app.bsky.actor.profile",
189295 ])),
190190- ResponseTemplate::new(200).set_body_json(latest_commit_response()),
296296+ car,
191297 )
192298 .await;
193299···207313208314 #[tokio::test]
209315 async fn returns_empty_vec_when_no_collections() {
316316+ let (car, _, _) = make_car(&[]).await;
210317 let server = mock_server(
211318 ResponseTemplate::new(200).set_body_json(describe_repo_response(&[])),
212212- ResponseTemplate::new(200).set_body_json(latest_commit_response()),
319319+ car,
213320 )
214321 .await;
215322···221328 }
222329223330 #[tokio::test]
224224- async fn returns_commit_cid_and_rev() {
331331+ async fn returns_mst_root_cid_and_rev() {
332332+ // The MST root CID (commit.data) and rev round-trip correctly.
333333+ let (car, expected_mst_root, expected_rev) =
334334+ make_car(&["app.bsky.actor.profile/self"]).await;
225335 let server = mock_server(
226336 ResponseTemplate::new(200)
227227- .set_body_json(describe_repo_response(&["app.bsky.feed.post"])),
228228- ResponseTemplate::new(200).set_body_json(latest_commit_response()),
337337+ .set_body_json(describe_repo_response(&["app.bsky.actor.profile"])),
338338+ car,
229339 )
230340 .await;
231341···233343 let base = server.uri().parse().unwrap();
234344 let snapshot = fetch_collections(&client, &base, did()).await.unwrap();
235345236236- assert_eq!(snapshot.commit.as_str(), TEST_CID);
237237- assert_eq!(snapshot.rev.as_str(), TEST_REV);
346346+ assert_eq!(snapshot.commit.unwrap().as_str(), expected_mst_root);
347347+ assert_eq!(snapshot.rev.as_str(), expected_rev);
238348 }
239349240350 #[tokio::test]
241241- async fn errors_on_latest_commit_failure() {
242242- // getLatestCommit fails; describeRepo is never reached.
351351+ async fn errors_on_get_record_failure() {
352352+ // getRecord fails; describeRepo is never reached.
243353 let server = MockServer::start().await;
244354 Mock::given(method("GET"))
245245- .and(path("/xrpc/com.atproto.sync.getLatestCommit"))
355355+ .and(path("/xrpc/com.atproto.sync.getRecord"))
246356 .respond_with(ResponseTemplate::new(500))
247357 .mount(&server)
248358 .await;
···256366257367 #[tokio::test]
258368 async fn errors_on_describe_repo_failure() {
259259- // getLatestCommit succeeds but describeRepo returns an XRPC error.
369369+ // getRecord succeeds but describeRepo returns an XRPC error.
370370+ let (car, _, _) = make_car(&["app.bsky.actor.profile/self"]).await;
260371 let server = mock_server(
261372 ResponseTemplate::new(400).set_body_json(serde_json::json!({
262373 "error": "InvalidRequest",
263374 "message": "bad request",
264375 })),
265265- ResponseTemplate::new(200).set_body_json(latest_commit_response()),
376376+ car,
266377 )
267378 .await;
268379···276387 #[tokio::test]
277388 async fn errors_on_server_error() {
278389 // A 5xx on describeRepo is a transient failure; surfaces as Err.
279279- let server = mock_server(
280280- ResponseTemplate::new(500),
281281- ResponseTemplate::new(200).set_body_json(latest_commit_response()),
282282- )
283283- .await;
390390+ let (car, _, _) = make_car(&["app.bsky.actor.profile/self"]).await;
391391+ let server = mock_server(ResponseTemplate::new(500), car).await;
284392285393 let client = reqwest::Client::new();
286394 let base = server.uri().parse().unwrap();
+4-10
src/sync/resync/dispatcher.rs
···417417}
418418419419fn backoff_secs(retry_count: u16) -> u64 {
420420- // 60 s, 120 s, 240 s, 480 s, 960 s, 1920 s, 3600 s (capped)
420420+ // 60s, 120s, 240s, 480s, 960s, 1920s, 3600s (capped)
421421 let exp = (retry_count as u32).saturating_sub(1).min(5);
422422 (60u64 * (1u64 << exp)).min(3600)
423423}
···427427/// Returns `None` when we've exhausted retries — the `RepoState::NotFound`
428428/// written by `index_repo` is the terminal state; future firehose activity
429429/// for the DID will re-trigger processing normally.
430430-///
431431-/// Schedule (retry_count after incrementing):
432432-/// 1 → 6 h (covers transient mid-migration windows)
433433-/// 2 → 24 h (covers stale DID→PDS cache expiry)
434434-/// 3 → 24 h (one more check before giving up)
435435-/// ≥4 → None
436430fn not_found_backoff_secs(retry_count: u16) -> Option<u64> {
437431 match retry_count {
438438- 1 => Some(6 * 3600), // 6 h
439439- 2 => Some(24 * 3600), // 24 h
440440- 3 => Some(24 * 3600), // 24 h
432432+ 1 => Some(6 * 3600), // 6h
433433+ 2 => Some(24 * 3600), // 24h
434434+ 3 => Some(24 * 3600), // 24h
441435 _ => None,
442436 }
443437}
+95-124
src/sync/resync/get_repo.rs
···11-//! Enumerate a repository's collections via `com.atproto.sync.getRepo`.
11+//! list repo collections with `com.atproto.sync.getRepo`
22//!
33-//! Fetches the full repository CAR and walks every MST leaf to collect all
44-//! collection NSIDs. Used as a fallback when `describeRepo` is unavailable or
55-//! returns an empty list.
33+//! hooks jacquard's streaming response body up with repo-stream's async read
44+//! input so that repo-stream's memory limit will stop a malicious server from
55+//! sending back an unlimited stream of garbage CAR blocks (eventually).
6677-use bytes::Bytes;
88-use jacquard_api::com_atproto::sync::get_repo::{GetRepo, GetRepoError};
99-use jacquard_common::error::ClientErrorKind;
1010-use jacquard_common::http_client::HttpClient;
1111-use jacquard_common::types::cid::Cid;
1212-use jacquard_common::types::nsid::Nsid;
77+use jacquard_api::com_atproto::sync::get_repo::GetRepo;
88+use jacquard_common::http_client::{HttpClient, HttpClientExt};
99+use jacquard_common::stream::ByteStream;
1310use jacquard_common::types::string::Did;
1414-use jacquard_common::xrpc::{XrpcError, XrpcExt};
1515-use jacquard_repo::{car::parse_car_bytes, commit::Commit};
1616-use repo_stream::DriverBuilder;
1111+use jacquard_common::types::tid::Tid;
1212+use jacquard_common::xrpc::XrpcExt;
1313+use repo_stream::{DriverBuilder, LoadError};
17141815use super::{GetCollectionsError, RepoGoneReason, RepoSnapshot};
19162020-async fn fetch_repo_car<C>(
1717+/// limit for in-memory CAR loading, MiB.
1818+const CAR_MEM_LIMIT_MB: usize = 1024;
1919+2020+/// collections from full repo car with repo-stream
2121+pub async fn fetch_collections<C>(
2122 client: &C,
2223 base: &jacquard_common::url::Url,
2324 did: Did<'_>,
2424-) -> std::result::Result<Bytes, GetCollectionsError>
2525+) -> std::result::Result<RepoSnapshot, GetCollectionsError>
2526where
2626- C: HttpClient + Sync,
2727+ C: HttpClient + HttpClientExt + Sync,
2728{
2829 let req = GetRepo { did, since: None };
2929-3030 let host = base.host_str().unwrap_or("").to_string();
3131- let resp = client.xrpc(base.clone()).send(&req).await.map_err(|e| {
3232- if matches!(e.kind(), ClientErrorKind::Http { status } if status.as_u16() == 429) {
3333- GetCollectionsError::RateLimited(host)
3434- } else {
3535- GetCollectionsError::Request(e.to_string())
3636- }
3737- })?;
38313939- match resp.parse() {
4040- Ok(output) => Ok(output.body),
4141- Err(XrpcError::Xrpc(err)) => match err {
4242- GetRepoError::RepoNotFound(_) => Err(GetCollectionsError::RepoNotFound),
4343- GetRepoError::RepoTakendown(_) => {
4444- Err(GetCollectionsError::RepoGone(RepoGoneReason::Takendown))
4545- }
4646- GetRepoError::RepoSuspended(_) => {
4747- Err(GetCollectionsError::RepoGone(RepoGoneReason::Suspended))
4848- }
4949- GetRepoError::RepoDeactivated(_) => {
5050- Err(GetCollectionsError::RepoGone(RepoGoneReason::Deactivated))
5151- }
5252- GetRepoError::Unknown(ref data) => {
5353- // Some PDSes send {"error":"NotFound"} instead of the
5454- // standardised "RepoNotFound" XRPC error code.
5555- let is_not_found = data
5656- .as_object()
5757- .and_then(|obj| obj.get("error"))
5858- .and_then(|v| v.as_str())
5959- == Some("NotFound");
6060- if is_not_found {
6161- Err(GetCollectionsError::RepoNotFound)
6262- } else {
6363- Err(GetCollectionsError::UnexpectedXrpc(format!("{data:?}")))
6464- }
6565- }
6666- },
6767- Err(e) => Err(GetCollectionsError::Request(e.to_string())),
3232+ let response = client
3333+ .xrpc(base.clone())
3434+ .download(&req)
3535+ .await
3636+ .map_err(|e| GetCollectionsError::Request(e.to_string()))?;
3737+3838+ let status = response.status();
3939+ let (_, body) = response.into_parts();
4040+4141+ if status.as_u16() == 429 {
4242+ return Err(GetCollectionsError::RateLimited(host));
4343+ }
4444+ if !status.is_success() {
4545+ return Err(classify_xrpc_error(body, status).await);
6846 }
6969-}
70477171-/// Fetch the full repo CAR and walk every MST leaf to collect all collections.
7272-pub async fn fetch_collections<C>(
7373- client: &C,
7474- base: &jacquard_common::url::Url,
7575- did: Did<'_>,
7676-) -> std::result::Result<RepoSnapshot, GetCollectionsError>
7777-where
7878- C: HttpClient + Sync,
7979-{
8080- let car = fetch_repo_car(client, base, did).await?;
4848+ // Bridge ByteStream → AsyncRead.
4949+ // n0_future::Stream is futures_lite::Stream which re-exports
5050+ // futures_core::stream::Stream, so StreamReader accepts the inner stream.
5151+ use futures::StreamExt as _;
5252+ use tokio_util::io::StreamReader;
5353+5454+ let reader = tokio::io::BufReader::new(StreamReader::new(
5555+ body.into_inner().map(|r| r.map_err(std::io::Error::other)),
5656+ ));
81578282- let parsed = parse_car_bytes(&car)
5858+ let mem_car = DriverBuilder::new()
5959+ .with_mem_limit_mb(CAR_MEM_LIMIT_MB)
6060+ .load_car(reader)
8361 .await
8484- .map_err(|e| GetCollectionsError::InvalidData(format!("CAR parse error: {e}")))?;
6262+ .map_err(|e| match e {
6363+ LoadError::MemoryLimitReached(_) => GetCollectionsError::InvalidData(format!(
6464+ "repo CAR exceeds {CAR_MEM_LIMIT_MB} MiB memory limit"
6565+ )),
6666+ e => GetCollectionsError::InvalidData(format!("CAR load error: {e}")),
6767+ })?;
85688686- // IpldCid is Copy so we can capture the commit CID before moving `parsed`.
8787- let commit = Cid::ipld(parsed.root);
6969+ let rev = Tid::new(&mem_car.commit.rev)
7070+ .map_err(|e| GetCollectionsError::InvalidData(format!("bad rev in commit: {e}")))?;
88718989- let rev = {
9090- let commit_bytes = parsed.blocks.get(&parsed.root).ok_or_else(|| {
9191- GetCollectionsError::InvalidData("getRepo CAR has no commit block".into())
9292- })?;
9393- Commit::from_cbor(commit_bytes.as_ref())
9494- .map_err(|e| {
9595- GetCollectionsError::InvalidData(format!("bad commit in getRepo CAR: {e}"))
9696- })?
9797- .rev
9898- };
7272+ let collections = crate::mst::collections::extract_from_full(mem_car)
7373+ .await
7474+ .map_err(|e| GetCollectionsError::InvalidData(format!("bad CAR walk: {e}")))?;
7575+7676+ Ok(RepoSnapshot {
7777+ collections,
7878+ rev,
7979+ commit: None, // load_car does not expose the CAR root (commit) CID
8080+ })
8181+}
9982100100- // Load into repo-stream for MST walking (lighter than jacquard's Mst/MstCursor).
101101- let mut mem_car = DriverBuilder::new()
102102- .load_jacquard_parsed_car(parsed)
103103- .map_err(|e| GetCollectionsError::InvalidData(format!("CAR load error: {e}")))?;
8383+/// Read and parse a non-2xx response body as an XRPC error object.
8484+async fn classify_xrpc_error(body: ByteStream, status: http::StatusCode) -> GetCollectionsError {
8585+ use futures::StreamExt as _;
8686+ use tokio::io::AsyncReadExt as _;
8787+ use tokio_util::io::StreamReader;
10488105105- // MST keys are `<collection>/<rkey>` in lexicographic order. next_keys()
106106- // yields only record leaves (MST nodes are traversed but not emitted).
107107- // When one collection name is a prefix of another (e.g. `sh.tangled.repo`
108108- // and `sh.tangled.repo.issue`), `.` (46) < `/` (47) means the longer name
109109- // sorts first; we sort after collecting to guarantee lexicographic output.
110110- let mut collections: Vec<Nsid<'static>> = Vec::new();
111111- let mut last_col: Option<String> = None;
8989+ let mut reader = StreamReader::new(body.into_inner().map(|r| r.map_err(std::io::Error::other)));
9090+ let mut bytes = Vec::new();
9191+ if let Err(e) = reader.read_to_end(&mut bytes).await {
9292+ return GetCollectionsError::Request(e.to_string());
9393+ };
11294113113- while let Some((key, _)) = mem_car
114114- .next_keys()
115115- .map_err(|e| GetCollectionsError::InvalidData(format!("MST traversal error: {e}")))?
116116- {
117117- if let Some(col) = key.split_once('/').map(|(c, _)| c)
118118- && last_col.as_deref() != Some(col)
119119- {
120120- let nsid = Nsid::new_owned(col).map_err(|e| {
121121- GetCollectionsError::InvalidData(format!("invalid collection NSID in MST: {e}"))
122122- })?;
123123- last_col = Some(col.to_owned());
124124- collections.push(nsid);
9595+ // TODO: really we should be parsing directly to a type from jacquard here
9696+ if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&bytes) {
9797+ match val.get("error").and_then(serde_json::Value::as_str) {
9898+ Some("RepoNotFound" | "NotFound") => return GetCollectionsError::RepoNotFound,
9999+ Some("RepoTakendown") => {
100100+ return GetCollectionsError::RepoGone(RepoGoneReason::Takendown);
101101+ }
102102+ Some("RepoSuspended") => {
103103+ return GetCollectionsError::RepoGone(RepoGoneReason::Suspended);
104104+ }
105105+ Some("RepoDeactivated") => {
106106+ return GetCollectionsError::RepoGone(RepoGoneReason::Deactivated);
107107+ }
108108+ Some(other) => return GetCollectionsError::UnexpectedXrpc(other.to_owned()),
109109+ None => {}
125110 }
126111 }
127112128128- collections.sort_unstable();
129129- Ok(RepoSnapshot {
130130- collections,
131131- rev,
132132- commit,
133133- })
113113+ GetCollectionsError::Request(format!("HTTP {status}"))
134114}
135115136116#[cfg(test)]
···159139160140 /// Build a minimal valid getRepo CAR from a list of MST keys.
161141 ///
162162- /// Each key should be in `<collection>/<rkey>` format. All records point
163163- /// to a single dummy CID — only the key structure matters for these tests.
164164- /// The commit is unsigned (zero-filled sig); `fetch_collections` does not
165165- /// verify signatures.
166166- ///
167167- /// Returns `(car_bytes, commit_cid_string, rev_string)` so tests can assert
168168- /// that `fetch_collections` round-trips the commit metadata correctly.
142142+ /// Returns `(car_bytes, commit_cid_string, rev_string)`.
169143 async fn make_car(keys: &[&str]) -> (Vec<u8>, String, String) {
170144 let storage = Arc::new(MemoryBlockStore::new());
171145 let mut mst = Mst::new(storage.clone());
172146173173- // All records share one dummy CID — we only care about key structure.
174147 let dummy_cid = storage.put(b"record").await.unwrap();
175148 for key in keys {
176149 mst = mst.add(key, dummy_cid).await.unwrap();
···239212240213 #[tokio::test]
241214 async fn sorts_collections_when_one_is_prefix_of_another() {
242242- // `sh.tangled.repo` is a prefix of `sh.tangled.repo.issue` and
243243- // `sh.tangled.repo.pull`. In the MST, `.` (46) < `/` (47) means
244244- // `sh.tangled.repo.issue/<rkey>` sorts before `sh.tangled.repo/<rkey>`,
245245- // so without an explicit sort the output would be in reverse order.
215215+ // `sh.tangled.repo` is a prefix of `sh.tangled.repo.issue`. In the MST,
216216+ // `.` (46) < `/` (47) so `sh.tangled.repo.issue/<rkey>` sorts before
217217+ // `sh.tangled.repo/<rkey>`. Output must still be sorted lexicographically.
246218 let (car, _, _) = make_car(&[
247219 "sh.tangled.repo/self",
248220 "sh.tangled.repo.issue/abc123",
···278250 }
279251280252 #[tokio::test]
281281- async fn returns_commit_cid_and_rev() {
282282- // Verify that the commit CID and rev round-trip correctly through the CAR.
283283- let (car, expected_commit, expected_rev) = make_car(&["app.bsky.feed.post/abc123"]).await;
253253+ async fn returns_rev() {
254254+ // Rev round-trips correctly; commit CID is unavailable via load_car.
255255+ let (car, _, expected_rev) = make_car(&["app.bsky.feed.post/abc123"]).await;
284256 let server = serve_car(car).await;
285257286258 let client = reqwest::Client::new();
287259 let base = server.uri().parse().unwrap();
288260 let snapshot = fetch_collections(&client, &base, did()).await.unwrap();
289261290290- assert_eq!(snapshot.commit.as_str(), expected_commit);
291262 assert_eq!(snapshot.rev.to_string(), expected_rev);
263263+ assert!(snapshot.commit.is_none());
292264 }
293265294266 #[tokio::test]
···313285 #[tokio::test]
314286 async fn errors_on_server_error() {
315287 // A 5xx is a transient failure, not a signal that the repo is gone.
316316- // It should surface as Err so the caller can decide whether to retry.
317288 let server = MockServer::start().await;
318289 Mock::given(method("GET"))
319290 .and(path("/xrpc/com.atproto.sync.getRepo"))
+9-5
src/sync/resync/mod.rs
···2222use std::time::Duration;
23232424use jacquard_common::IntoStatic;
2525-use jacquard_common::http_client::HttpClient;
2525+use jacquard_common::http_client::{HttpClient, HttpClientExt};
2626use jacquard_common::types::{cid::Cid, nsid::Nsid, string::Did, tid::Tid};
2727use tracing::info;
2828···5959 pub collections: Vec<Nsid<'static>>,
6060 /// Revision TID of the latest commit.
6161 pub rev: Tid,
6262- /// CID of the latest commit (as returned by `getRepo` or `getLatestCommit`).
6363- pub commit: Cid<'static>,
6262+ /// CID of the latest commit, when available.
6363+ ///
6464+ /// Present when fetched via `describeRepo` + `getLatestCommit`. `None`
6565+ /// when determined from a streaming `getRepo` CAR (repo-stream's `load_car`
6666+ /// does not expose the CAR root CID).
6767+ pub commit: Option<Cid<'static>>,
6468}
65696670/// Why fetching a repository's collection list failed.
···113117 get_repo_timeout: Duration,
114118) -> Result<()>
115119where
116116- C: HttpClient + Sync,
120120+ C: HttpClient + HttpClientExt + Sync,
117121{
118122 // Own the DID for the duration of the function so we can move it into
119123 // spawn_blocking closures (which require 'static captures).
···241245 get_repo_timeout: Duration,
242246) -> std::result::Result<RepoSnapshot, GetCollectionsError>
243247where
244244- C: HttpClient + Sync,
248248+ C: HttpClient + HttpClientExt + Sync,
245249{
246250 let describe_result = tokio::time::timeout(
247251 describe_timeout,