use std::sync::Arc;
use anyhow::{Context, Result};
use reqwest::header::{HeaderName, HeaderValue, AUTHORIZATION};
use reqwest::{Client, StatusCode};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::lexicon::{self, Folder, ReadState, Saved, Subscription};
pub const DEFAULT_PLC_DIRECTORY: &str = "https://plc.directory";
pub const DEFAULT_RESOLVER_HOST: &str = "https://bsky.social";
const MAX_LIST_PAGES: usize = 200;
const MAX_LIST_RECORDS: usize = 20_000;
pub(crate) const MAX_LARGE_RECORDS: usize = 2_000;
pub(crate) const AT_URI_PREFIX: &str = "at://";
pub(crate) fn strip_at_prefix(url: &str) -> Option<&str> {
url.get(..AT_URI_PREFIX.len())
.filter(|p| p.eq_ignore_ascii_case(AT_URI_PREFIX))
.map(|p| &url[p.len()..])
}
pub(crate) fn is_valid_rkey(rkey: &str) -> bool {
!rkey.is_empty()
&& rkey.len() <= 512
&& rkey != "."
&& rkey != ".."
&& rkey
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '~' | '-'))
}
pub(crate) fn extend_truncating(
out: &mut Vec<RecordEntry>,
page: Vec<RecordEntry>,
max: usize,
) -> bool {
let room = max.saturating_sub(out.len());
let dropped = page.len() > room;
out.extend(page.into_iter().take(room));
dropped
}
#[derive(Debug)]
pub struct RecordWalk {
pub records: Vec<RecordEntry>,
pub complete: bool,
pub malformed: usize,
}
impl RecordWalk {
fn complete(records: Vec<RecordEntry>) -> Self {
Self {
records,
complete: true,
malformed: 0,
}
}
fn partial(records: Vec<RecordEntry>) -> Self {
Self {
records,
complete: false,
malformed: 0,
}
}
}
pub(crate) const MAX_LIST_BYTES: usize = 128 * 1024 * 1024;
pub(crate) fn approx_bytes(entry: &RecordEntry) -> usize {
2 * std::mem::size_of::<RecordEntry>()
+ entry.uri.len()
+ entry.cid.as_ref().map_or(0, String::len)
+ json_bytes(&entry.value)
}
fn json_bytes(v: &serde_json::Value) -> usize {
const NODE: usize = std::mem::size_of::<serde_json::Value>();
const SLOT: usize = 2 * NODE;
const MAP_ENTRY: usize = 104;
const MAP_NODE: usize = 512;
match v {
serde_json::Value::Array(a) => 4 * NODE + a.iter().map(json_bytes).sum::<usize>(),
serde_json::Value::Object(o) => {
MAP_NODE
+ o.iter()
.map(|(k, v)| MAP_ENTRY + k.len().max(NODE / 2) + SLOT + json_bytes(v))
.sum::<usize>()
}
serde_json::Value::String(s) => SLOT + s.len(),
_ => SLOT,
}
}
pub(crate) struct ByteBudget {
used: usize,
max: usize,
}
impl ByteBudget {
pub(crate) fn new(max: usize) -> Self {
Self { used: 0, max }
}
pub(crate) fn admit(&mut self, page: &[RecordEntry]) -> bool {
let cost: usize = page.iter().map(approx_bytes).sum();
match self.used.checked_add(cost) {
Some(total) if total <= self.max => {
self.used = total;
true
}
_ => false,
}
}
pub(crate) fn charge(&mut self, bytes: usize) -> bool {
match self.used.checked_add(bytes) {
Some(total) if total <= self.max => {
self.used = total;
true
}
_ => false,
}
}
pub(crate) fn used(&self) -> usize {
self.used
}
pub(crate) fn max(&self) -> usize {
self.max
}
pub(crate) fn remaining(&self) -> usize {
self.max.saturating_sub(self.used)
}
}
pub(crate) fn extend_bounded(
out: &mut Vec<RecordEntry>,
page: Vec<RecordEntry>,
max: usize,
collection: &str,
) -> Result<()> {
if out.len() + page.len() > max {
anyhow::bail!(
"listRecords for {collection} exceeded the {max}-record cap \
({} held, {} more offered) — refusing to accumulate further",
out.len(),
page.len(),
);
}
out.extend(page);
Ok(())
}
#[derive(Debug, thiserror::Error)]
pub enum AtProtoError {
#[error("atproto transport error: {0}")]
Transport(#[from] reqwest::Error),
#[error("atproto XRPC error {status}: {error}{}", .message.as_deref().map(|m| format!(" — {m}")).unwrap_or_default())]
Xrpc {
status: StatusCode,
error: String,
message: Option<String>,
},
#[error("could not resolve handle {handle:?} to a DID")]
HandleResolution {
handle: String,
},
#[error("could not resolve DID {did:?} to a PDS endpoint: {reason}")]
DidResolution {
did: String,
reason: String,
cause: DidResolutionCause,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum DidResolutionCause {
UnsupportedMethod,
Status,
NoPdsEndpoint,
NotAPublicTarget,
}
pub fn is_invalid_swap(err: &anyhow::Error) -> bool {
let wrapped = ApplyWritesIncomplete::of(err).map(|p| p.cause().chain());
err.chain()
.chain(wrapped.into_iter().flatten())
.any(|cause| {
matches!(
cause.downcast_ref::<AtProtoError>(),
Some(AtProtoError::Xrpc { error, .. }) if error == "InvalidSwap"
)
})
}
impl AtProtoError {
pub fn is_record_not_found(&self) -> bool {
matches!(
self,
AtProtoError::Xrpc { error, .. } if error == "RecordNotFound"
)
}
}
#[allow(async_fn_in_trait)]
pub trait TokenSource: Send + Sync {
async fn access_token(&self) -> Result<String>;
}
#[derive(Clone)]
pub enum Auth {
Session(SessionAuth),
Oauth(OauthPlaceholder),
Anonymous,
}
impl Auth {
pub fn bearer(&self) -> Result<&str> {
match self {
Auth::Session(s) => Ok(&s.access_jwt),
Auth::Oauth(_) => anyhow::bail!(
"the direct PdsClient does not carry OAuth tokens — atproto OAuth is \
handled by the @atproto/oauth-client sidecar (SidecarClient); \
use Auth::Session (app-password) for the direct-PDS path"
),
Auth::Anonymous => anyhow::bail!(
"this PdsClient is anonymous (unauthenticated public read) and carries no \
bearer token — authenticated repo writes require Auth::Session or the \
SidecarClient"
),
}
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct SessionAuth {
pub did: String,
#[serde(default)]
pub handle: Option<String>,
#[serde(rename = "accessJwt")]
pub access_jwt: String,
#[serde(rename = "refreshJwt", default)]
pub refresh_jwt: Option<String>,
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct OauthPlaceholder {}
pub async fn resolve_handle(client: &Client, resolver_base: &str, handle: &str) -> Result<String> {
let url = format!(
"{}/xrpc/com.atproto.identity.resolveHandle?handle={}",
resolver_base.trim_end_matches('/'),
urlencode(handle)
);
#[derive(Deserialize)]
struct ResolveHandleOut {
did: String,
}
let resp = crate::net::guarded_get_no_privacy(client, &url, &[]).await?;
if !resp.status().is_success() {
let err = xrpc_error_from(resp).await;
if let AtProtoError::Xrpc { status, .. } = &err {
if *status == StatusCode::BAD_REQUEST || *status == StatusCode::NOT_FOUND {
return Err(AtProtoError::HandleResolution {
handle: handle.to_string(),
}
.into());
}
}
return Err(err.into());
}
let raw = crate::net::read_capped(resp).await?;
let out: ResolveHandleOut =
serde_json::from_slice(&raw).context("parsing resolveHandle response")?;
Ok(out.did)
}
pub async fn resolve_did_to_pds(client: &Client, plc_directory: &str, did: &str) -> Result<String> {
let doc_url = if let Some(rest) = did.strip_prefix("did:web:") {
let host = rest.replace(':', "/");
format!("https://{host}/.well-known/did.json")
} else if did.starts_with("did:plc:") {
format!("{}/{}", plc_directory.trim_end_matches('/'), did)
} else {
return Err(AtProtoError::DidResolution {
did: did.to_string(),
reason: "unsupported DID method (only did:plc and did:web are handled)".to_string(),
cause: DidResolutionCause::UnsupportedMethod,
}
.into());
};
let resp = crate::net::guarded_get_no_privacy(client, &doc_url, &[]).await?;
if !resp.status().is_success() {
return Err(AtProtoError::DidResolution {
did: did.to_string(),
reason: format!("DID document fetch returned {}", resp.status()),
cause: DidResolutionCause::Status,
}
.into());
}
let raw = crate::net::read_capped(resp).await?;
let doc: DidDocument = serde_json::from_slice(&raw).context("parsing DID document")?;
let endpoint = doc
.pds_endpoint()
.ok_or_else(|| AtProtoError::DidResolution {
did: did.to_string(),
reason: "DID document has no #atproto_pds service endpoint".to_string(),
cause: DidResolutionCause::NoPdsEndpoint,
})?;
crate::net::assert_public_target(&endpoint)
.await
.map_err(|e| AtProtoError::DidResolution {
did: did.to_string(),
reason: format!("PDS serviceEndpoint is not a public target: {e}"),
cause: DidResolutionCause::NotAPublicTarget,
})?;
Ok(endpoint)
}
#[derive(Debug, Clone, Deserialize)]
pub struct DidDocument {
#[serde(default)]
pub id: String,
#[serde(default)]
pub service: Vec<DidService>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DidService {
pub id: String,
#[serde(rename = "type", default)]
pub r#type: String,
#[serde(rename = "serviceEndpoint")]
pub service_endpoint: String,
}
impl DidDocument {
pub fn pds_endpoint(&self) -> Option<String> {
self.service
.iter()
.find(|s| s.id.ends_with("#atproto_pds"))
.map(|s| s.service_endpoint.trim_end_matches('/').to_string())
}
}
pub async fn login_with_app_password(
client: &Client,
pds_base: &str,
identifier: &str,
app_password: &str,
) -> Result<SessionAuth> {
let url = format!(
"{}/xrpc/com.atproto.server.createSession",
pds_base.trim_end_matches('/')
);
let body = serde_json::to_vec(&json!({ "identifier": identifier, "password": app_password }))
.context("serializing createSession request")?;
let resp = crate::net::guarded_post_json(client, &url, &[], body).await?;
if !resp.status().is_success() {
return Err(xrpc_error_from(resp).await.into());
}
let raw = crate::net::read_capped(resp).await?;
serde_json::from_slice(&raw).context("parsing createSession response")
}
#[derive(Clone)]
pub struct PdsClient {
http: Client,
pds_base: Arc<str>,
did: Arc<str>,
auth: Auth,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RecordEntry {
pub uri: String,
#[serde(default)]
pub cid: Option<String>,
pub value: Value,
}
impl RecordEntry {
pub fn rkey(&self) -> Option<&str> {
self.uri.rsplit('/').next()
}
pub fn parse<T: DeserializeOwned>(&self) -> Result<T> {
serde_json::from_value(self.value.clone())
.with_context(|| format!("deserializing record {}", self.uri))
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ListRecordsResponse {
#[serde(default)]
pub records: Vec<RecordEntry>,
#[serde(default)]
pub cursor: Option<String>,
#[serde(skip)]
pub malformed: usize,
#[serde(skip)]
pub wire_bytes: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MalformedRecords {
pub collection: String,
pub count: usize,
}
impl std::fmt::Display for MalformedRecords {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} record(s) in {} have a malformed envelope; refusing the listing rather than \
dropping them",
self.count, self.collection
)
}
}
impl std::error::Error for MalformedRecords {}
#[derive(Debug, Clone, Deserialize)]
pub struct WriteResult {
pub uri: String,
#[serde(default)]
pub cid: Option<String>,
}
impl WriteResult {
pub fn rkey(&self) -> Option<&str> {
self.uri.rsplit('/').next()
}
pub fn into_rkey(self) -> String {
self.rkey().unwrap_or_default().to_string()
}
}
impl PdsClient {
pub fn new(
http: Client,
pds_base: impl Into<String>,
did: impl Into<String>,
auth: Auth,
) -> Self {
Self {
http,
pds_base: Arc::from(pds_base.into().trim_end_matches('/')),
did: Arc::from(did.into()),
auth,
}
}
pub fn anonymous(http: Client, pds_base: impl Into<String>, did: impl Into<String>) -> Self {
Self::new(http, pds_base, did, Auth::Anonymous)
}
pub async fn login(
http: Client,
handle: &str,
app_password: &str,
resolver_base: Option<&str>,
plc_directory: Option<&str>,
) -> Result<Self> {
let resolver = resolver_base.unwrap_or(DEFAULT_RESOLVER_HOST);
let plc = plc_directory.unwrap_or(DEFAULT_PLC_DIRECTORY);
let did = resolve_handle(&http, resolver, handle).await?;
let pds_base = resolve_did_to_pds(&http, plc, &did).await?;
let session = login_with_app_password(&http, &pds_base, &did, app_password).await?;
Ok(Self::new(
http,
pds_base,
session.did.clone(),
Auth::Session(session),
))
}
pub fn did(&self) -> &str {
&self.did
}
pub fn pds_base(&self) -> &str {
&self.pds_base
}
fn authed_headers(&self) -> Result<Vec<(HeaderName, HeaderValue)>> {
let bearer = self.auth.bearer()?;
let mut value = HeaderValue::from_str(&format!("Bearer {bearer}"))
.context("building Authorization header")?;
value.set_sensitive(true);
Ok(vec![(AUTHORIZATION, value)])
}
fn xrpc_url(&self, method: &str) -> String {
format!("{}/xrpc/{}", self.pds_base, method)
}
pub async fn list_records(
&self,
collection: &str,
limit: Option<u32>,
cursor: Option<&str>,
) -> Result<ListRecordsResponse> {
let mut url = format!(
"{}?repo={}&collection={}",
self.xrpc_url("com.atproto.repo.listRecords"),
urlencode(&self.did),
urlencode(collection),
);
if let Some(limit) = limit {
url.push_str(&format!("&limit={limit}"));
}
if let Some(cursor) = cursor {
url.push_str(&format!("&cursor={}", urlencode(cursor)));
}
let mut headers: Vec<(reqwest::header::HeaderName, HeaderValue)> = Vec::new();
if let Auth::Session(s) = &self.auth {
let mut value = HeaderValue::from_str(&format!("Bearer {}", s.access_jwt))
.context("building Authorization header")?;
value.set_sensitive(true);
headers.push((AUTHORIZATION, value));
}
let resp = crate::net::guarded_get_no_privacy(&self.http, &url, &headers).await?;
if !resp.status().is_success() {
return Err(xrpc_error_from(resp).await.into());
}
let body = crate::net::read_capped(resp).await?;
parse_list_records(&body)
}
pub async fn list_all_records(&self, collection: &str) -> Result<Vec<RecordEntry>> {
self.list_all_records_within(collection, &mut ByteBudget::new(MAX_LIST_BYTES))
.await
}
pub(crate) async fn list_all_records_within(
&self,
collection: &str,
budget: &mut ByteBudget,
) -> Result<Vec<RecordEntry>> {
self.walk_all_within(collection, budget, OnMalformed::Refuse)
.await
.map(|(records, _)| records)
}
pub(crate) async fn list_all_records_skipping_within(
&self,
collection: &str,
budget: &mut ByteBudget,
) -> Result<(Vec<RecordEntry>, usize)> {
self.walk_all_within(collection, budget, OnMalformed::Skip)
.await
}
async fn walk_all_within(
&self,
collection: &str,
budget: &mut ByteBudget,
on_malformed: OnMalformed,
) -> Result<(Vec<RecordEntry>, usize)> {
let mut out = Vec::new();
let max_bytes = budget.max();
let mut cursor: Option<String> = None;
let mut more_offered = false;
let mut malformed = 0usize;
for _ in 0..MAX_LIST_PAGES {
let page = self
.list_records(collection, Some(100), cursor.as_deref())
.await?;
if on_malformed == OnMalformed::Refuse {
refuse_malformed(&page, collection)?;
}
if page.malformed > 0
&& !budget.charge(
page.wire_bytes
.max(page.records.iter().map(approx_bytes).sum()),
)
{
anyhow::bail!(
"listRecords for {collection} exceeded the {max_bytes}-byte cap on pages \
of malformed records — refusing to read further"
);
}
malformed += page.malformed;
let got = page.records.len() + page.malformed;
if page.malformed == 0 && !budget.admit(&page.records) {
anyhow::bail!(
"listRecords for {collection} exceeded the {max_bytes}-byte cap \
({} held, {} bytes charged) — refusing to accumulate further",
out.len(),
budget.used(),
);
}
extend_bounded(&mut out, page.records, MAX_LIST_RECORDS, collection)?;
match page.cursor {
Some(next) if got > 0 && Some(&next) != cursor.as_ref() => {
cursor = Some(next);
more_offered = true;
}
_ => {
more_offered = false;
break;
}
}
}
if more_offered {
anyhow::bail!(
"listRecords for {collection} did not finish within {MAX_LIST_PAGES} pages \
({} held, and the PDS still offered more) — refusing a short list",
out.len(),
);
}
Ok((out, malformed))
}
pub async fn list_recent_matching(
&self,
collection: &str,
max_records: usize,
page_size: u32,
keep: impl FnMut(&RecordEntry) -> bool,
) -> Result<RecordWalk> {
self.list_recent_matching_within(
collection,
max_records,
&mut ByteBudget::new(MAX_LIST_BYTES),
page_size,
keep,
)
.await
}
pub(crate) async fn list_recent_matching_within(
&self,
collection: &str,
max_records: usize,
budget: &mut ByteBudget,
page_size: u32,
mut keep: impl FnMut(&RecordEntry) -> bool,
) -> Result<RecordWalk> {
let mut out = Vec::new();
let mut cursor: Option<String> = None;
let mut malformed = 0usize;
let walk = |mut w: RecordWalk, malformed: usize| {
w.malformed = malformed;
w
};
for _ in 0..MAX_LIST_PAGES {
let page = self
.list_records(collection, Some(page_size), cursor.as_deref())
.await?;
let wire_charged = page.malformed > 0;
malformed += page.malformed;
let got = page.records.len() + page.malformed;
let more =
matches!(&page.cursor, Some(next) if got > 0 && Some(next) != cursor.as_ref());
let mut page_cost: usize = page.records.iter().map(approx_bytes).sum();
if wire_charged {
page_cost = page_cost.max(page.wire_bytes);
}
if page_cost > budget.remaining() {
return Ok(walk(RecordWalk::partial(out), malformed));
}
let kept: Vec<RecordEntry> = page.records.into_iter().filter(|r| keep(r)).collect();
let charged = if wire_charged {
budget.charge(page.wire_bytes.max(kept.iter().map(approx_bytes).sum()))
} else {
budget.admit(&kept)
};
debug_assert!(charged, "the page charge already proved this fits");
if extend_truncating(&mut out, kept, max_records) {
return Ok(walk(RecordWalk::partial(out), malformed));
}
if out.len() >= max_records {
return Ok(RecordWalk {
complete: !more,
records: out,
malformed,
});
}
if !more {
return Ok(walk(RecordWalk::complete(out), malformed));
}
cursor = page.cursor;
}
Ok(walk(RecordWalk::partial(out), malformed))
}
async fn create_record<T: Serialize>(
&self,
collection: &str,
record: &T,
) -> Result<WriteResult> {
let body = json!({
"repo": self.did.as_ref(),
"collection": collection,
"record": record,
});
self.repo_write("com.atproto.repo.createRecord", body).await
}
async fn put_record<T: Serialize>(
&self,
collection: &str,
rkey: &str,
record: &T,
swap_record: Option<&str>,
) -> Result<WriteResult> {
let mut body = json!({
"repo": self.did.as_ref(),
"collection": collection,
"rkey": rkey,
"record": record,
});
if let Some(cid) = swap_record {
body["swapRecord"] = json!(cid);
}
self.repo_write("com.atproto.repo.putRecord", body).await
}
async fn guarded_post(&self, url: &str, body: &Value) -> Result<reqwest::Response> {
let headers = self.authed_headers()?;
let payload = serde_json::to_vec(body).context("serializing XRPC request body")?;
crate::net::guarded_post_json(&self.http, url, &headers, payload).await
}
pub async fn delete_record(&self, collection: &str, rkey: &str) -> Result<()> {
let url = self.xrpc_url("com.atproto.repo.deleteRecord");
let body = json!({
"repo": self.did.as_ref(),
"collection": collection,
"rkey": rkey,
});
let resp = self.guarded_post(&url, &body).await?;
if !resp.status().is_success() {
return Err(xrpc_error_from(resp).await.into());
}
Ok(())
}
async fn apply_writes(&self, writes: &[WriteOp]) -> Result<()> {
apply_writes_chunked(writes, |chunk| self.apply_writes_once(chunk)).await
}
async fn apply_writes_once(&self, writes: &[WriteOp]) -> Result<()> {
let url = self.xrpc_url("com.atproto.repo.applyWrites");
let ops: Vec<Value> = writes.iter().map(WriteOp::to_json).collect();
let body = json!({
"repo": self.did.as_ref(),
"writes": ops,
});
let resp = self.guarded_post(&url, &body).await?;
if !resp.status().is_success() {
return Err(xrpc_error_from(resp).await.into());
}
Ok(())
}
async fn repo_write(&self, method: &str, body: Value) -> Result<WriteResult> {
let url = self.xrpc_url(method);
let resp = self.guarded_post(&url, &body).await?;
if !resp.status().is_success() {
return Err(xrpc_error_from(resp).await.into());
}
let raw = crate::net::read_capped(resp).await?;
serde_json::from_slice(&raw).with_context(|| format!("parsing {method} response"))
}
pub async fn list_subscriptions(&self) -> Result<Vec<(String, Subscription)>> {
self.list_typed(lexicon::nsid::SUBSCRIPTION).await
}
pub async fn list_subscriptions_with_cids(
&self,
) -> Result<Vec<(String, Option<String>, Subscription)>> {
self.list_typed_with_cids(lexicon::nsid::SUBSCRIPTION).await
}
pub async fn create_subscription(
&self,
sub: &crate::vetted::VettedSubscription,
) -> Result<WriteResult> {
self.create_record(lexicon::nsid::SUBSCRIPTION, sub).await
}
pub async fn list_folders(&self) -> Result<Vec<(String, Folder)>> {
self.list_typed(lexicon::nsid::FOLDER).await
}
pub async fn list_folders_with_cids(&self) -> Result<Vec<(String, Option<String>, Folder)>> {
self.list_typed_with_cids(lexicon::nsid::FOLDER).await
}
pub async fn create_folder(&self, folder: &Folder) -> Result<WriteResult> {
self.create_record(lexicon::nsid::FOLDER, folder).await
}
pub async fn list_saved(&self) -> Result<Vec<(String, Saved)>> {
self.list_typed(lexicon::nsid::SAVED).await
}
pub async fn create_saved(&self, saved: &crate::vetted::VettedSaved) -> Result<WriteResult> {
self.create_record(lexicon::nsid::SAVED, saved).await
}
pub async fn list_read_states(&self) -> Result<Vec<(String, ReadState)>> {
self.list_typed(lexicon::nsid::READ_STATE).await
}
pub async fn put_read_state(&self, rkey: &str, state: &ReadState) -> Result<WriteResult> {
self.put_record(lexicon::nsid::READ_STATE, rkey, state, None)
.await
}
pub async fn flush_read_states(&self, cursors: &[(String, ReadState, bool)]) -> Result<()> {
if cursors.is_empty() {
return Ok(());
}
let writes = read_state_write_ops(cursors)?;
self.apply_writes(&writes).await
}
async fn list_typed<T: DeserializeOwned>(&self, collection: &str) -> Result<Vec<(String, T)>> {
Ok(self
.list_typed_with_cids(collection)
.await?
.into_iter()
.map(|(rkey, _cid, value)| (rkey, value))
.collect())
}
async fn list_typed_with_cids<T: DeserializeOwned>(
&self,
collection: &str,
) -> Result<Vec<(String, Option<String>, T)>> {
let records = self.list_all_records(collection).await?;
let mut out = Vec::with_capacity(records.len());
for rec in records {
let rkey = rec.rkey().unwrap_or_default().to_string();
match rec.parse::<T>() {
Ok(value) => out.push((rkey, rec.cid, value)),
Err(e) => tracing::warn!(
collection,
uri = %rec.uri,
error = %e,
"skipping unparseable record in collection"
),
}
}
Ok(out)
}
}
#[derive(Clone)]
pub struct SidecarClient {
http: Client,
public_url: Arc<str>,
internal_url: Arc<str>,
internal_secret: Arc<str>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SidecarSession {
pub did: String,
#[serde(default)]
pub handle: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RevokeResult {
#[serde(default)]
pub did: String,
#[serde(default)]
pub revoked: bool,
#[serde(default, rename = "hadSession")]
pub had_session: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RepoAction {
List,
Create,
Put,
Delete,
ApplyWrites,
}
impl RepoAction {
fn as_str(self) -> &'static str {
match self {
RepoAction::List => "list",
RepoAction::Create => "create",
RepoAction::Put => "put",
RepoAction::Delete => "delete",
RepoAction::ApplyWrites => "applyWrites",
}
}
}
#[derive(Debug, Deserialize)]
struct RepoOk {
#[serde(default)]
data: Value,
}
#[derive(Debug, Deserialize)]
struct RepoOkList {
#[serde(default)]
ok: Option<bool>,
#[serde(default)]
error: Option<Value>,
#[serde(default)]
data: Option<ListRecordsBody>,
}
#[derive(Debug, Deserialize)]
struct RepoErr {
#[serde(default)]
error: Option<String>,
#[serde(default)]
message: Option<String>,
#[serde(default)]
status: Option<u16>,
}
impl SidecarClient {
pub fn new(
http: Client,
public_url: impl Into<String>,
internal_url: impl Into<String>,
internal_secret: impl Into<String>,
) -> Self {
Self {
http,
public_url: Arc::from(public_url.into().trim_end_matches('/')),
internal_url: Arc::from(internal_url.into().trim_end_matches('/')),
internal_secret: Arc::from(internal_secret.into()),
}
}
pub fn login_url(&self, handle: &str, return_to: Option<&str>) -> String {
let mut url = format!("{}/login?handle={}", self.public_url, urlencode(handle));
if let Some(r) = return_to {
url.push_str(&format!("&return={}", urlencode(r)));
}
url
}
pub async fn resolve_session(&self, session_id: &str) -> Result<Option<SidecarSession>> {
let url = format!(
"{}/internal/session/{}",
self.internal_url,
urlencode(session_id)
);
let resp = self
.http
.get(&url)
.header("X-Internal-Secret", self.internal_secret.as_ref())
.send()
.await?;
if resp.status() == StatusCode::NOT_FOUND {
return Ok(None);
}
if !resp.status().is_success() {
return Err(xrpc_error_from(resp).await.into());
}
let raw = crate::net::read_capped(resp).await?;
let session: SidecarSession =
serde_json::from_slice(&raw).context("parsing /internal/session response")?;
Ok(Some(session))
}
pub async fn revoke_session(&self, did: &str) -> Result<RevokeResult> {
let url = format!("{}/internal/revoke", self.internal_url);
let resp = self
.http
.post(&url)
.header("X-Internal-Secret", self.internal_secret.as_ref())
.json(&json!({ "did": did }))
.send()
.await?;
if !resp.status().is_success() {
return Err(xrpc_error_from(resp).await.into());
}
let raw = crate::net::read_capped(resp).await?;
let result: RevokeResult =
serde_json::from_slice(&raw).context("parsing /internal/revoke response")?;
Ok(result)
}
async fn repo(&self, body: Value) -> Result<Value> {
let raw = self.repo_bytes(body).await?;
refuse_a_structure_explosion(&raw, "the /internal/repo body")?;
let ok: RepoOk = serde_json::from_slice(&raw).context("parsing /internal/repo ok body")?;
Ok(ok.data)
}
async fn repo_bytes(&self, body: Value) -> Result<Vec<u8>> {
let url = format!("{}/internal/repo", self.internal_url);
let resp = self
.http
.post(&url)
.header("X-Internal-Secret", self.internal_secret.as_ref())
.json(&body)
.send()
.await?;
let status = resp.status();
let raw = crate::net::read_capped(resp).await;
if status.is_success() {
return raw;
}
let err: RepoErr = raw
.ok()
.and_then(|body| serde_json::from_slice(&body).ok())
.unwrap_or(RepoErr {
error: None,
message: None,
status: None,
});
let mapped = err
.status
.and_then(|s| StatusCode::from_u16(s).ok())
.unwrap_or(status);
Err(AtProtoError::Xrpc {
status: mapped,
error: err.error.unwrap_or_else(|| "Unknown".to_string()),
message: err.message,
}
.into())
}
pub async fn list_records(
&self,
did: &str,
collection: &str,
limit: Option<u32>,
cursor: Option<&str>,
) -> Result<ListRecordsResponse> {
let mut body = json!({
"did": did,
"action": RepoAction::List.as_str(),
"collection": collection,
});
if let Some(limit) = limit {
body["limit"] = json!(limit);
}
if let Some(cursor) = cursor {
body["cursor"] = json!(cursor);
}
let raw = self.repo_bytes(body).await?;
refuse_a_structure_explosion(&raw, "the sidecar listRecords body")?;
let envelope: RepoOkList =
serde_json::from_slice(&raw).context("parsing sidecar listRecords data")?;
if envelope.ok == Some(false) {
let name = envelope
.error
.as_ref()
.and_then(envelope_error_name)
.unwrap_or_else(|| "unspecified".to_string());
anyhow::bail!("the sidecar answered 2xx with ok:false ({name})");
}
if let Some(name) = envelope.error.as_ref().and_then(envelope_error_name) {
anyhow::bail!("the sidecar answered 2xx with an error envelope: {name}");
}
let Some(data) = envelope.data else {
anyhow::bail!("listRecords returned no records field (empty or unexpected body)");
};
page_from_body(data).context("parsing sidecar listRecords data")
}
pub async fn list_all_records(&self, did: &str, collection: &str) -> Result<Vec<RecordEntry>> {
self.list_all_records_within(did, collection, &mut ByteBudget::new(MAX_LIST_BYTES))
.await
}
pub(crate) async fn list_all_records_within(
&self,
did: &str,
collection: &str,
budget: &mut ByteBudget,
) -> Result<Vec<RecordEntry>> {
let mut out = Vec::new();
let max_bytes = budget.max();
let mut cursor: Option<String> = None;
let mut more_offered = false;
for _ in 0..MAX_LIST_PAGES {
let page = self
.list_records(did, collection, Some(100), cursor.as_deref())
.await?;
refuse_malformed(&page, collection)?;
let got = page.records.len();
if !budget.admit(&page.records) {
anyhow::bail!(
"listRecords for {collection} exceeded the {max_bytes}-byte cap \
({} held, {} bytes charged) — refusing to accumulate further",
out.len(),
budget.used(),
);
}
extend_bounded(&mut out, page.records, MAX_LIST_RECORDS, collection)?;
match page.cursor {
Some(next) if got > 0 && Some(&next) != cursor.as_ref() => {
cursor = Some(next);
more_offered = true;
}
_ => {
more_offered = false;
break;
}
}
}
if more_offered {
anyhow::bail!(
"listRecords for {collection} did not finish within {MAX_LIST_PAGES} pages \
({} held, and the PDS still offered more) — refusing a short list",
out.len(),
);
}
Ok(out)
}
async fn create_record<T: Serialize>(
&self,
did: &str,
collection: &str,
record: &T,
) -> Result<WriteResult> {
let body = json!({
"did": did,
"action": RepoAction::Create.as_str(),
"collection": collection,
"record": record,
});
let data = self.repo(body).await?;
serde_json::from_value(data).context("parsing sidecar createRecord data")
}
async fn put_record<T: Serialize>(
&self,
did: &str,
collection: &str,
rkey: &str,
record: &T,
swap_record: Option<&str>,
) -> Result<WriteResult> {
let mut body = json!({
"did": did,
"action": RepoAction::Put.as_str(),
"collection": collection,
"rkey": rkey,
"record": record,
});
if let Some(cid) = swap_record {
body["swapRecord"] = json!(cid);
}
let data = self.repo(body).await?;
serde_json::from_value(data).context("parsing sidecar putRecord data")
}
pub async fn delete_record(&self, did: &str, collection: &str, rkey: &str) -> Result<()> {
let body = json!({
"did": did,
"action": RepoAction::Delete.as_str(),
"collection": collection,
"rkey": rkey,
});
self.repo(body)
.await
.and_then(|data| reject_error_envelope(&data))?;
Ok(())
}
async fn apply_writes(&self, did: &str, writes: &[WriteOp]) -> Result<()> {
apply_writes_chunked(writes, |chunk| self.apply_writes_once(did, chunk)).await
}
async fn apply_writes_once(&self, did: &str, writes: &[WriteOp]) -> Result<()> {
let ops: Vec<Value> = writes.iter().map(WriteOp::to_sidecar_json).collect();
let body = json!({
"did": did,
"action": RepoAction::ApplyWrites.as_str(),
"writes": ops,
});
self.repo(body)
.await
.and_then(|data| reject_error_envelope(&data))?;
Ok(())
}
pub async fn list_subscriptions(&self, did: &str) -> Result<Vec<(String, Subscription)>> {
self.list_typed(did, lexicon::nsid::SUBSCRIPTION).await
}
pub async fn list_subscriptions_with_cids(
&self,
did: &str,
) -> Result<Vec<(String, Option<String>, Subscription)>> {
self.list_typed_with_cids(did, lexicon::nsid::SUBSCRIPTION)
.await
}
pub async fn create_subscription(
&self,
did: &str,
sub: &crate::vetted::VettedSubscription,
) -> Result<WriteResult> {
self.create_record(did, lexicon::nsid::SUBSCRIPTION, sub)
.await
}
pub async fn delete_subscription(&self, did: &str, rkey: &str) -> Result<()> {
self.delete_record(did, lexicon::nsid::SUBSCRIPTION, rkey)
.await
}
pub async fn list_folders(&self, did: &str) -> Result<Vec<(String, Folder)>> {
self.list_typed(did, lexicon::nsid::FOLDER).await
}
pub async fn list_folders_with_cids(
&self,
did: &str,
) -> Result<Vec<(String, Option<String>, Folder)>> {
self.list_typed_with_cids(did, lexicon::nsid::FOLDER).await
}
pub async fn list_saved(&self, did: &str) -> Result<Vec<(String, Saved)>> {
self.list_typed(did, lexicon::nsid::SAVED).await
}
pub async fn list_read_states(&self, did: &str) -> Result<Vec<(String, ReadState)>> {
self.list_typed(did, lexicon::nsid::READ_STATE).await
}
pub async fn put_read_state(
&self,
did: &str,
rkey: &str,
state: &ReadState,
) -> Result<WriteResult> {
self.put_record(did, lexicon::nsid::READ_STATE, rkey, state, None)
.await
}
pub async fn flush_read_states(
&self,
did: &str,
cursors: &[(String, ReadState, bool)],
) -> Result<()> {
if cursors.is_empty() {
return Ok(());
}
let writes = read_state_write_ops(cursors)?;
self.apply_writes(did, &writes).await
}
pub async fn add_subscription(
&self,
did: &str,
sub: &crate::vetted::VettedSubscription,
) -> Result<String> {
Ok(self.create_subscription(did, sub).await?.into_rkey())
}
pub async fn remove_subscription(&self, did: &str, rkey: &str) -> Result<()> {
self.delete_subscription(did, rkey).await
}
pub async fn update_subscription(
&self,
did: &str,
rkey: &str,
sub: &crate::vetted::VettedSubscription,
swap_record: Option<&str>,
) -> Result<WriteResult> {
self.put_record(did, lexicon::nsid::SUBSCRIPTION, rkey, sub, swap_record)
.await
}
pub async fn list_subscriptions_sorted(
&self,
did: &str,
) -> Result<Vec<(String, Subscription)>> {
let mut subs = self.list_subscriptions(did).await?;
subs.sort_by(lexicon::sort::subscriptions);
Ok(subs)
}
pub async fn add_subscriptions_bulk(
&self,
did: &str,
subs: &[crate::vetted::VettedSubscription],
) -> Result<Vec<String>> {
let mut gen = TidGenerator::new();
let mut rkeys = Vec::with_capacity(subs.len());
let mut writes = Vec::with_capacity(subs.len());
for sub in subs {
let rkey = gen.next();
writes.push(WriteOp::Create {
collection: lexicon::nsid::SUBSCRIPTION.to_string(),
rkey: Some(rkey.clone()),
value: serde_json::to_value(sub)?,
});
rkeys.push(rkey);
}
self.apply_writes(did, &writes).await?;
Ok(rkeys)
}
pub async fn add_folder(&self, did: &str, folder: &Folder) -> Result<String> {
Ok(self
.create_record(did, lexicon::nsid::FOLDER, folder)
.await?
.into_rkey())
}
pub async fn remove_folder(&self, did: &str, rkey: &str) -> Result<()> {
self.delete_record(did, lexicon::nsid::FOLDER, rkey).await
}
pub async fn rename_folder(
&self,
did: &str,
rkey: &str,
folder: &Folder,
swap_record: Option<&str>,
) -> Result<WriteResult> {
self.put_record(did, lexicon::nsid::FOLDER, rkey, folder, swap_record)
.await
}
pub async fn list_folders_sorted(&self, did: &str) -> Result<Vec<(String, Folder)>> {
let mut folders = self.list_folders(did).await?;
folders.sort_by(lexicon::sort::folders);
Ok(folders)
}
pub async fn add_saved(&self, did: &str, saved: &crate::vetted::VettedSaved) -> Result<String> {
Ok(self
.create_record(did, lexicon::nsid::SAVED, saved)
.await?
.into_rkey())
}
pub async fn remove_saved(&self, did: &str, rkey: &str) -> Result<()> {
self.delete_record(did, lexicon::nsid::SAVED, rkey).await
}
pub async fn list_saved_sorted(&self, did: &str) -> Result<Vec<(String, Saved)>> {
let mut saved = self.list_saved(did).await?;
saved.sort_by(lexicon::sort::saved);
Ok(saved)
}
async fn list_typed<T: DeserializeOwned>(
&self,
did: &str,
collection: &str,
) -> Result<Vec<(String, T)>> {
Ok(self
.list_typed_with_cids(did, collection)
.await?
.into_iter()
.map(|(rkey, _cid, value)| (rkey, value))
.collect())
}
async fn list_typed_with_cids<T: DeserializeOwned>(
&self,
did: &str,
collection: &str,
) -> Result<Vec<(String, Option<String>, T)>> {
let records = self.list_all_records(did, collection).await?;
let mut out = Vec::with_capacity(records.len());
for rec in records {
let rkey = rec.rkey().unwrap_or_default().to_string();
match rec.parse::<T>() {
Ok(value) => out.push((rkey, rec.cid, value)),
Err(e) => tracing::warn!(
collection,
uri = %rec.uri,
error = %e,
"skipping unparseable record in collection"
),
}
}
Ok(out)
}
}
pub(crate) fn read_state_write_ops(cursors: &[(String, ReadState, bool)]) -> Result<Vec<WriteOp>> {
cursors
.iter()
.map(|(rkey, state, pds_created)| {
let value = serde_json::to_value(state)?;
Ok(if *pds_created {
WriteOp::Update {
collection: lexicon::nsid::READ_STATE.to_string(),
rkey: rkey.clone(),
value,
}
} else {
WriteOp::Create {
collection: lexicon::nsid::READ_STATE.to_string(),
rkey: Some(rkey.clone()),
value,
}
})
})
.collect()
}
pub const APPLY_WRITES_MAX_OPS: usize = 200;
pub const APPLY_WRITES_MAX_BYTES: usize = 128 * 1024;
pub(crate) fn chunk_writes(writes: &[WriteOp]) -> Vec<std::ops::Range<usize>> {
let mut chunks = Vec::new();
let mut start = 0;
let mut bytes = 0;
for (i, op) in writes.iter().enumerate() {
let size = op.to_json().to_string().len() + 1;
let held = i - start;
if held > 0 && (held == APPLY_WRITES_MAX_OPS || bytes + size > APPLY_WRITES_MAX_BYTES) {
chunks.push(start..i);
start = i;
bytes = 0;
}
bytes += size;
}
if start < writes.len() {
chunks.push(start..writes.len());
}
chunks
}
pub(crate) async fn apply_writes_chunked<'a, F, Fut>(
writes: &'a [WriteOp],
mut send: F,
) -> Result<()>
where
F: FnMut(&'a [WriteOp]) -> Fut,
Fut: std::future::Future<Output = Result<()>>,
{
let chunks = chunk_writes(writes);
let count = chunks.len();
for (i, range) in chunks.into_iter().enumerate() {
let (landed, in_doubt) = (range.start, range.len());
if let Err(cause) = send(&writes[range]).await {
return Err(ApplyWritesIncomplete {
landed,
in_doubt,
total: writes.len(),
chunk: i + 1,
chunks: count,
cause,
}
.into());
}
}
Ok(())
}
#[derive(Debug)]
pub struct ApplyWritesIncomplete {
pub landed: usize,
pub in_doubt: usize,
pub total: usize,
chunk: usize,
chunks: usize,
cause: anyhow::Error,
}
impl ApplyWritesIncomplete {
pub fn of(err: &anyhow::Error) -> Option<&Self> {
err.chain().find_map(|e| e.downcast_ref::<Self>())
}
pub fn cause(&self) -> &anyhow::Error {
&self.cause
}
}
impl std::fmt::Display for ApplyWritesIncomplete {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.cause)?;
if self.chunks > 1 {
write!(
f,
" (applyWrites call {} of {}; {} of {} writes had landed)",
self.chunk, self.chunks, self.landed, self.total
)?;
}
Ok(())
}
}
impl std::error::Error for ApplyWritesIncomplete {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.cause.chain().nth(1)
}
}
#[derive(Debug, Clone)]
pub enum WriteOp {
Create {
collection: String,
rkey: Option<String>,
value: Value,
},
Update {
collection: String,
rkey: String,
value: Value,
},
Delete {
collection: String,
rkey: String,
},
}
impl WriteOp {
pub(crate) fn to_json(&self) -> Value {
match self {
WriteOp::Create {
collection,
rkey,
value,
} => {
let mut op = json!({
"$type": "com.atproto.repo.applyWrites#create",
"collection": collection,
"value": value,
});
if let Some(rkey) = rkey {
op["rkey"] = json!(rkey);
}
op
}
WriteOp::Update {
collection,
rkey,
value,
} => json!({
"$type": "com.atproto.repo.applyWrites#update",
"collection": collection,
"rkey": rkey,
"value": value,
}),
WriteOp::Delete { collection, rkey } => json!({
"$type": "com.atproto.repo.applyWrites#delete",
"collection": collection,
"rkey": rkey,
}),
}
}
fn to_sidecar_json(&self) -> Value {
match self {
WriteOp::Create {
collection,
rkey,
value,
} => {
let mut op = json!({
"action": "create",
"collection": collection,
"value": value,
});
if let Some(rkey) = rkey {
op["rkey"] = json!(rkey);
}
op
}
WriteOp::Update {
collection,
rkey,
value,
} => json!({
"action": "update",
"collection": collection,
"rkey": rkey,
"value": value,
}),
WriteOp::Delete { collection, rkey } => json!({
"action": "delete",
"collection": collection,
"rkey": rkey,
}),
}
}
}
const S32_ALPHABET: &[u8; 32] = b"234567abcdefghijklmnopqrstuvwxyz";
pub(crate) struct TidGenerator {
last: u64,
clock_id: u64,
}
impl TidGenerator {
pub(crate) fn new() -> Self {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64)
.unwrap_or(0);
Self {
last: 0,
clock_id: nanos & 0x3ff,
}
}
pub(crate) fn next(&mut self) -> String {
let micros = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_micros() as u64)
.unwrap_or(0);
let mut raw = ((micros & 0x001f_ffff_ffff_ffff) << 10) | self.clock_id;
if raw <= self.last {
raw = self.last + 1;
}
self.last = raw;
encode_s32_tid(raw)
}
}
fn encode_s32_tid(mut v: u64) -> String {
let mut buf = [0u8; 13];
for slot in buf.iter_mut().rev() {
*slot = S32_ALPHABET[(v & 0x1f) as usize];
v >>= 5;
}
String::from_utf8(buf.to_vec()).unwrap_or_default()
}
const TID_FLOOR_MICROS: i64 = 1_577_836_800_000_000;
pub(crate) const CLOCK_SKEW_GRACE_SECS: i64 = 300;
pub(crate) fn decode_s32_tid(rkey: &str) -> Option<u64> {
if rkey.len() != 13 {
return None;
}
let mut v: u64 = 0;
for b in rkey.bytes() {
let digit = S32_ALPHABET.iter().position(|c| *c == b)? as u64;
v = v.checked_mul(32)?.checked_add(digit)?;
}
(v >> 63 == 0).then_some(v)
}
pub(crate) fn tid_timestamp(rkey: &str) -> Option<chrono::DateTime<chrono::Utc>> {
let micros = i64::try_from(decode_s32_tid(rkey)? >> 10).ok()?;
if micros < TID_FLOOR_MICROS {
return None;
}
let at = chrono::DateTime::from_timestamp_micros(micros)?;
let ceiling = chrono::Utc::now() + chrono::Duration::seconds(CLOCK_SKEW_GRACE_SECS);
(at <= ceiling).then_some(at)
}
pub(crate) fn urlencode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
pub(crate) const MAX_LIST_STRUCTURAL_CHARS: usize = 640_000;
pub(crate) fn count_structural_chars(body: &[u8]) -> usize {
let mut nodes = 0usize;
let mut in_string = false;
let mut escaped = false;
for &b in body {
if in_string {
if escaped {
escaped = false;
} else if b == b'\\' {
escaped = true;
} else if b == b'"' {
in_string = false;
}
continue;
}
match b {
b'"' => {
in_string = true;
nodes += 1;
}
b'{' | b'[' | b',' | b':' => nodes += 1,
_ => {}
}
}
nodes
}
pub(crate) fn refuse_a_structure_explosion(body: &[u8], what: &str) -> Result<()> {
let counted = count_structural_chars(body);
anyhow::ensure!(
counted <= MAX_LIST_STRUCTURAL_CHARS,
"{what} counts at least {counted} structural characters, over the \
{MAX_LIST_STRUCTURAL_CHARS} cap — refusing before parsing it"
);
Ok(())
}
pub(crate) fn parse_list_records(body: &[u8]) -> Result<ListRecordsResponse> {
if body.is_empty() {
anyhow::bail!("listRecords returned no records field (empty or unexpected body)");
}
refuse_a_structure_explosion(body, "the listRecords body")?;
let parsed: ListRecordsBody =
serde_json::from_slice(body).context("parsing listRecords response")?;
let mut page = page_from_body(parsed)?;
page.wire_bytes = body.len();
Ok(page)
}
fn page_from_body(parsed: ListRecordsBody) -> Result<ListRecordsResponse> {
if let Some(error) = parsed.error.as_ref().and_then(envelope_error_name) {
let message = parsed
.message
.as_ref()
.and_then(Value::as_str)
.map(|m| format!(" — {}", truncate_for_message(m)))
.unwrap_or_default();
anyhow::bail!("PDS answered 2xx with an error envelope: {error}{message}");
}
let entries = parsed.records.ok_or_else(|| {
anyhow::anyhow!("listRecords returned no records field (empty or unexpected body)")
})?;
let mut records = Vec::with_capacity(entries.len());
let mut malformed = 0;
for entry in entries {
match entry {
MaybeRecord::Record(r) => records.push(r),
MaybeRecord::Malformed(_) => malformed += 1,
}
}
Ok(ListRecordsResponse {
records,
cursor: parsed.cursor,
malformed,
wire_bytes: 0,
})
}
#[derive(Debug, Default)]
struct ListRecordsBody {
error: Option<Value>,
message: Option<Value>,
records: Option<Vec<MaybeRecord>>,
cursor: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum MaybeRecord {
Record(RecordEntry),
Malformed(serde::de::IgnoredAny),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OnMalformed {
Refuse,
Skip,
}
pub(crate) fn refuse_malformed(page: &ListRecordsResponse, collection: &str) -> Result<()> {
if page.malformed > 0 {
return Err(MalformedRecords {
collection: collection.to_string(),
count: page.malformed,
}
.into());
}
Ok(())
}
impl<'de> Deserialize<'de> for ListRecordsBody {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
struct AsMap;
impl<'de> serde::de::Visitor<'de> for AsMap {
type Value = ListRecordsBody;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a listRecords object")
}
fn visit_map<M: serde::de::MapAccess<'de>>(
self,
mut map: M,
) -> std::result::Result<ListRecordsBody, M::Error> {
use serde::de::Error;
let mut out = ListRecordsBody::default();
let (mut error, mut message, mut records, mut cursor) =
(false, false, false, false);
while let Some(key) = map.next_key::<String>()? {
let seen = match key.as_str() {
"error" => std::mem::replace(&mut error, true),
"message" => std::mem::replace(&mut message, true),
"records" => std::mem::replace(&mut records, true),
"cursor" => std::mem::replace(&mut cursor, true),
_ => false,
};
if seen {
return Err(M::Error::duplicate_field(match key.as_str() {
"error" => "error",
"message" => "message",
"records" => "records",
_ => "cursor",
}));
}
match key.as_str() {
"error" => out.error = Some(map.next_value()?),
"message" => out.message = Some(map.next_value()?),
"records" => out.records = Some(map.next_value()?),
"cursor" => out.cursor = map.next_value()?,
_ => {
let _validated: Value = map.next_value()?;
}
}
}
Ok(out)
}
}
d.deserialize_map(AsMap)
}
}
pub(crate) fn reject_error_envelope(value: &Value) -> Result<()> {
let Some(error) = value.get("error").and_then(envelope_error_name) else {
return Ok(());
};
let message = value
.get("message")
.and_then(Value::as_str)
.map(|m| format!(" — {}", truncate_for_message(m)))
.unwrap_or_default();
anyhow::bail!("PDS answered 2xx with an error envelope: {error}{message}")
}
fn envelope_error_name(error: &Value) -> Option<String> {
match error {
Value::Null | Value::Bool(false) => None,
Value::Number(n) if n.as_i64() == Some(0) || n.as_u64() == Some(0) => None,
Value::String(s) => Some(truncate_for_message(s)),
Value::Bool(_) => Some("<non-string error: bool>".to_string()),
Value::Number(_) => Some("<non-string error: number>".to_string()),
Value::Array(_) => Some("<non-string error: array>".to_string()),
Value::Object(_) => Some("<non-string error: object>".to_string()),
}
}
fn truncate_for_message(s: &str) -> String {
const MAX_BYTES: usize = 120;
if s.len() <= MAX_BYTES {
return s.to_string();
}
let cut = s
.char_indices()
.map(|(i, _)| i)
.take_while(|i| *i <= MAX_BYTES)
.last()
.unwrap_or(0);
format!("{}… ({} bytes)", &s[..cut], s.len())
}
#[derive(Debug, Deserialize)]
struct XrpcErrorBody {
#[serde(default)]
error: Option<String>,
#[serde(default)]
message: Option<String>,
}
async fn xrpc_error_from(resp: reqwest::Response) -> AtProtoError {
let status = resp.status();
let (error, message) = match crate::net::read_capped(resp).await {
Ok(raw) => match serde_json::from_slice::<XrpcErrorBody>(&raw) {
Ok(body) => (
body.error.unwrap_or_else(|| "Unknown".to_string()),
body.message,
),
Err(_) => ("Unknown".to_string(), None),
},
Err(_) => ("Unknown".to_string(), None),
};
AtProtoError::Xrpc {
status,
error,
message,
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
#[tokio::test]
async fn xrpc_error_body_is_capped() {
let filler = "x".repeat(crate::net::MAX_BODY_BYTES);
let big = format!(r#"{{"error":"TooBig","message":"{filler}"}}"#).into_bytes();
assert!(big.len() > crate::net::MAX_BODY_BYTES);
let base = crate::net::tests::serve_body(big).await;
let resp = reqwest::Client::builder()
.build()
.unwrap()
.get(&base)
.send()
.await
.unwrap();
match xrpc_error_from(resp).await {
AtProtoError::Xrpc { error, message, .. } => {
assert_eq!(error, "Unknown", "an over-cap error body must not parse");
assert!(message.is_none());
}
other => panic!("expected Xrpc, got {other:?}"),
}
}
#[tokio::test]
async fn xrpc_error_body_within_the_cap_still_parses() {
let base = crate::net::tests::serve_body(
br#"{"error":"InvalidRequest","message":"bad rkey"}"#.to_vec(),
)
.await;
let resp = reqwest::Client::builder()
.build()
.unwrap()
.get(&base)
.send()
.await
.unwrap();
match xrpc_error_from(resp).await {
AtProtoError::Xrpc { error, message, .. } => {
assert_eq!(error, "InvalidRequest");
assert_eq!(message.as_deref(), Some("bad rkey"));
}
other => panic!("expected Xrpc, got {other:?}"),
}
}
fn subscription_list_json() -> Value {
json!({
"records": [
{
"uri": "at://did:plc:abc123/community.lexicon.rss.subscription/3ksub0001",
"cid": "bafyreisubone",
"value": {
"$type": "community.lexicon.rss.subscription",
"url": "https://example.com/feed.xml",
"title": "Example Blog",
"siteUrl": "https://example.com/",
"fetchHint": "hourly",
"createdAt": "2026-07-12T00:00:00.000Z"
}
},
{
"uri": "at://did:plc:abc123/community.lexicon.rss.subscription/3ksub0002",
"cid": "bafyreisubtwo",
"value": {
"$type": "community.lexicon.rss.subscription",
"url": "https://blog.example.org/atom.xml",
"createdAt": "2026-07-11T12:00:00.000Z"
}
}
],
"cursor": "3ksub0002"
})
}
#[test]
fn a_reading_walk_truncates_where_the_sub_ref_walk_refuses() {
let page = |n: usize| -> Vec<RecordEntry> {
(0..n)
.map(|i| RecordEntry {
uri: format!("at://did:plc:x/c/{i}"),
cid: None,
value: Value::Null,
})
.collect()
};
let mut out = Vec::new();
assert!(!extend_truncating(&mut out, page(2), 3), "not full yet");
assert_eq!(out.len(), 2);
assert!(extend_truncating(&mut out, page(5), 3), "must report full");
assert_eq!(out.len(), 3, "a reading walk must keep what fits");
let mut refused = Vec::new();
assert!(extend_bounded(&mut refused, page(5), 3, "c").is_err());
assert!(refused.is_empty(), "a refusal must leave nothing behind");
}
#[tokio::test]
async fn the_cap_counts_matching_records_not_walked_ones() {
let records: Vec<Value> = (0..4)
.map(|i| {
serde_json::json!({
"uri": format!("at://did:plc:x/c/{i}"),
"value": {"mine": i == 3}
})
})
.collect();
let body = serde_json::json!({ "records": records, "cursor": serde_json::Value::Null })
.to_string();
let base = crate::net::tests::serve_body(body.into_bytes()).await;
let port: u16 = base
.trim_end_matches('/')
.rsplit(':')
.next()
.unwrap()
.parse()
.unwrap();
crate::net::test_host_override(
"matching-pds.test",
std::net::SocketAddr::from(([127, 0, 0, 1], port)),
);
let client = PdsClient::anonymous(
ssrf_test_client(),
format!("http://matching-pds.test:{port}"),
"did:plc:x",
);
let kept = client
.list_recent_matching("site.standard.document", 3, 100, |r| {
r.value
.get("mine")
.and_then(Value::as_bool)
.unwrap_or(false)
})
.await
.expect("walk failed")
.records;
assert_eq!(kept.len(), 1, "the filter ran after the cap, not before it");
}
#[tokio::test]
async fn a_walk_that_stops_early_reports_itself_incomplete() {
let records: Vec<Value> = (0..2)
.map(|i| serde_json::json!({"uri": format!("at://did:plc:x/c/{i}"), "value": {}}))
.collect();
let body = serde_json::json!({ "records": records, "cursor": "next" }).to_string();
let base = crate::net::tests::serve_body(body.into_bytes()).await;
let port: u16 = base
.trim_end_matches('/')
.rsplit(':')
.next()
.unwrap()
.parse()
.unwrap();
crate::net::test_host_override(
"incomplete-pds.test",
std::net::SocketAddr::from(([127, 0, 0, 1], port)),
);
let client = PdsClient::anonymous(
ssrf_test_client(),
format!("http://incomplete-pds.test:{port}"),
"did:plc:x",
);
let walk = client
.list_recent_matching("c", 2, 100, |_| true)
.await
.expect("walk failed");
assert_eq!(walk.records.len(), 2);
assert!(
!walk.complete,
"a walk that filled its cap with pages still to come called itself complete"
);
}
#[tokio::test]
async fn a_walk_that_exhausts_the_collection_reports_itself_complete() {
let body = serde_json::json!({
"records": [{"uri": "at://did:plc:x/c/1", "value": {}}]
})
.to_string();
let base = crate::net::tests::serve_body(body.into_bytes()).await;
let port: u16 = base
.trim_end_matches('/')
.rsplit(':')
.next()
.unwrap()
.parse()
.unwrap();
crate::net::test_host_override(
"complete-pds.test",
std::net::SocketAddr::from(([127, 0, 0, 1], port)),
);
let client = PdsClient::anonymous(
ssrf_test_client(),
format!("http://complete-pds.test:{port}"),
"did:plc:x",
);
let walk = client
.list_recent_matching("c", 100, 100, |_| true)
.await
.expect("walk failed");
assert_eq!(walk.records.len(), 1);
assert!(walk.complete, "an exhausted collection is a complete read");
}
#[test]
fn a_body_without_a_records_field_is_not_an_empty_page() {
let err =
parse_list_records(br#"{}"#).expect_err("`{}` was read as a page of zero records");
assert!(format!("{err:#}").contains("no records field"), "{err:#}");
let err = parse_list_records(br#"{"cursor":"c"}"#)
.expect_err("a cursor-only body was read as a page");
assert!(format!("{err:#}").contains("no records field"), "{err:#}");
let page = parse_list_records(br#"{"records":[]}"#).unwrap();
assert!(page.records.is_empty());
}
async fn serve_oversized(host: &str, shape: &str) -> String {
let filler = "x".repeat(crate::net::MAX_BODY_BYTES);
let body = shape.replace("PAD", &filler);
assert!(body.len() > crate::net::MAX_BODY_BYTES);
let base = crate::net::tests::serve_body(body.into_bytes()).await;
let port: u16 = base
.trim_end_matches('/')
.rsplit(':')
.next()
.unwrap()
.parse()
.unwrap();
crate::net::test_host_override(host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
format!("http://{host}:{port}")
}
#[tokio::test]
async fn the_did_document_read_is_capped() {
let base = serve_oversized(
"did-doc-cap.test",
r##"{"service":[{"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.example"}],"pad":"PAD"}"##,
)
.await;
let err = resolve_did_to_pds(
&ssrf_test_client(),
&base,
"did:plc:ohutz6x5acjmpuulp3x7wxxc",
)
.await
.expect_err("an oversized DID document was buffered whole");
assert!(
format!("{err:#}").contains("cap"),
"failed for the wrong reason: {err:#}"
);
}
#[tokio::test]
async fn the_resolve_handle_read_is_capped() {
let base = serve_oversized(
"resolve-handle-cap.test",
r#"{"did":"did:plc:ohutz6x5acjmpuulp3x7wxxc","pad":"PAD"}"#,
)
.await;
let err = resolve_handle(&ssrf_test_client(), &base, "alice.example.com")
.await
.expect_err("an oversized resolveHandle body was buffered whole");
assert!(
format!("{err:#}").contains("cap"),
"failed for the wrong reason: {err:#}"
);
}
#[tokio::test]
async fn the_sidecar_client_caps_the_body_it_will_buffer() {
let filler = "x".repeat(crate::net::MAX_BODY_BYTES);
let body = format!(r#"{{"ok":true,"data":{{"records":[],"pad":"{filler}"}}}}"#);
assert!(body.len() > crate::net::MAX_BODY_BYTES);
let base = crate::net::tests::serve_body(body.into_bytes()).await;
let client = SidecarClient::new(Client::new(), base.clone(), base, "secret");
let err = client
.list_records(
"did:plc:ewvi7nxzyoun6zhxrhs64oiz",
"app.feather.subscription",
None,
None,
)
.await
.expect_err("an oversized sidecar body was buffered whole");
assert!(
format!("{err:#}").contains("cap"),
"failed for the wrong reason: {err:#}"
);
}
#[tokio::test]
async fn the_sidecar_client_refuses_a_data_object_without_records() {
let base = crate::net::tests::serve_body(br#"{"ok":true,"data":{}}"#.to_vec()).await;
let client = SidecarClient::new(Client::new(), base.clone(), base, "secret");
let err = client
.list_records(
"did:plc:ewvi7nxzyoun6zhxrhs64oiz",
"app.feather.subscription",
None,
None,
)
.await
.expect_err("`data: {}` was read as an empty repo");
assert!(format!("{err:#}").contains("no records field"), "{err:#}");
}
#[test]
fn an_exactly_full_page_is_not_a_truncation() {
let page = |n: usize| -> Vec<RecordEntry> {
(0..n)
.map(|i| RecordEntry {
uri: format!("at://did:plc:x/c/{i}"),
cid: None,
value: Value::Null,
})
.collect()
};
let mut out = Vec::new();
assert!(
!extend_truncating(&mut out, page(3), 3),
"a page that exactly fills the cap dropped nothing"
);
assert_eq!(out.len(), 3);
assert!(
extend_truncating(&mut out, page(1), 3),
"one more IS a drop"
);
assert_eq!(out.len(), 3);
}
#[tokio::test]
async fn the_reading_walk_returns_a_truncated_archive_rather_than_an_error() {
let records: Vec<Value> = (0..5)
.map(|i| serde_json::json!({"uri": format!("at://did:plc:x/c/{i}"), "value": {}}))
.collect();
let body = serde_json::json!({ "records": records }).to_string();
let base = crate::net::tests::serve_body(body.into_bytes()).await;
let port: u16 = base
.trim_end_matches('/')
.rsplit(':')
.next()
.unwrap()
.parse()
.unwrap();
crate::net::test_host_override(
"truncating-pds.test",
std::net::SocketAddr::from(([127, 0, 0, 1], port)),
);
let client = PdsClient::anonymous(
ssrf_test_client(),
format!("http://truncating-pds.test:{port}"),
"did:plc:x",
);
let walk = client
.list_recent_matching("site.standard.document", 3, 100, |_| true)
.await
.expect("a big archive must be readable, not an error");
assert_eq!(
walk.records.len(),
3,
"the walk did not truncate to its cap"
);
assert!(
!walk.complete,
"a truncated walk must not report completeness"
);
let err = client
.list_all_records("community.lexicon.rss.subscription")
.await;
assert!(
err.is_ok() || format!("{:#}", err.unwrap_err()).contains("cap"),
"the sub_ref walk must keep its refusal"
);
}
#[tokio::test]
async fn the_sidecar_client_refuses_a_200_error_envelope_on_writes() {
let base = crate::net::tests::serve_body(
br#"{"ok":true,"data":{"error":"InvalidRequest","message":"nope"}}"#.to_vec(),
)
.await;
let client = SidecarClient::new(Client::new(), base.clone(), base, "secret");
let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
let err = client
.delete_subscription(did, "rk1")
.await
.expect_err("a failed delete was reported as success");
assert!(format!("{err:#}").contains("InvalidRequest"), "{err:#}");
let err = client
.apply_writes(
did,
&[WriteOp::Delete {
collection: lexicon::nsid::SUBSCRIPTION.to_string(),
rkey: "rk1".to_string(),
}],
)
.await
.expect_err("a failed batch was reported as success");
assert!(format!("{err:#}").contains("InvalidRequest"), "{err:#}");
}
#[tokio::test]
async fn the_sidecar_client_refuses_a_200_error_envelope() {
let base = crate::net::tests::serve_body(
br#"{"ok":true,"data":{"error":"InvalidRequest","message":"nope"}}"#.to_vec(),
)
.await;
let client = SidecarClient::new(Client::new(), base.clone(), base, "secret");
let err = client
.list_records(
"did:plc:ewvi7nxzyoun6zhxrhs64oiz",
"app.feather.subscription",
None,
None,
)
.await
.expect_err("an error envelope was read as an empty page");
assert!(
format!("{err:#}").contains("InvalidRequest"),
"failed for the wrong reason: {err:#}"
);
}
#[test]
fn an_error_envelope_is_refused_whatever_shape_it_arrives_in() {
let envelope = serde_json::json!({"error": "InvalidRequest", "message": "bad cursor"});
let err = reject_error_envelope(&envelope).expect_err("an envelope passed as data");
assert!(format!("{err:#}").contains("InvalidRequest"), "{err:#}");
reject_error_envelope(&serde_json::json!({"records": []})).expect("an empty page is data");
reject_error_envelope(&serde_json::json!({"records": [], "cursor": "c"})).unwrap();
}
#[test]
fn a_200_with_an_error_envelope_is_not_an_empty_page() {
let err = parse_list_records(br#"{"error":"InvalidRequest","message":"bad cursor"}"#)
.expect_err("an error envelope parsed as a page");
assert!(format!("{err:#}").contains("InvalidRequest"), "{err:#}");
let page = parse_list_records(br#"{"records":[]}"#).expect("an empty page is a page");
assert!(page.records.is_empty() && page.cursor.is_none());
}
#[test]
fn parsing_a_page_from_bytes_keeps_both_invariants() {
for (label, body, because) in [
(
"an error envelope on a 2xx",
&br#"{"error":"InvalidRequest","message":"bad cursor"}"#[..],
"error envelope",
),
(
"an envelope that also carries records",
&br#"{"error":"InvalidRequest","records":[]}"#[..],
"error envelope",
),
(
"a body with no records field",
&br#"{"cursor":"c"}"#[..],
"no records",
),
("a proxy's empty object", &br#"{}"#[..], "no records"),
("an empty body", &b""[..], "no records"),
] {
let err = parse_list_records(body)
.map(|p| panic!("{label} was read as a page of {} records", p.records.len()))
.unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains(because),
"{label} should have failed on {because:?}, got: {msg}"
);
}
let page = parse_list_records(br#"{"records":[],"cursor":"c"}"#)
.expect("a genuinely empty page is still a page");
assert!(page.records.is_empty());
assert_eq!(page.cursor.as_deref(), Some("c"));
}
#[test]
fn list_records_envelope_deserializes() {
let resp: ListRecordsResponse =
serde_json::from_value(subscription_list_json()).expect("envelope");
assert_eq!(resp.records.len(), 2);
assert_eq!(resp.cursor.as_deref(), Some("3ksub0002"));
assert_eq!(resp.records[0].cid.as_deref(), Some("bafyreisubone"));
}
#[test]
fn record_entry_rkey_is_last_uri_segment() {
let resp: ListRecordsResponse =
serde_json::from_value(subscription_list_json()).expect("envelope");
assert_eq!(resp.records[0].rkey(), Some("3ksub0001"));
assert_eq!(resp.records[1].rkey(), Some("3ksub0002"));
}
#[test]
fn record_value_parses_into_lexicon_subscription() {
let resp: ListRecordsResponse =
serde_json::from_value(subscription_list_json()).expect("envelope");
let full: Subscription = resp.records[0].parse().expect("parse full sub");
assert_eq!(full.r#type, lexicon::nsid::SUBSCRIPTION);
assert_eq!(full.url, "https://example.com/feed.xml");
assert_eq!(full.title.as_deref(), Some("Example Blog"));
assert_eq!(full.site_url.as_deref(), Some("https://example.com/"));
assert_eq!(full.fetch_hint, Some(lexicon::FetchHint::Hourly));
let minimal: Subscription = resp.records[1].parse().expect("parse minimal sub");
assert_eq!(minimal.url, "https://blog.example.org/atom.xml");
assert!(minimal.title.is_none());
}
fn ssrf_test_client() -> Client {
Client::builder()
.user_agent(crate::USER_AGENT)
.build()
.unwrap()
}
#[tokio::test]
async fn resolve_did_web_blocks_metadata_host() {
let client = ssrf_test_client();
let err = resolve_did_to_pds(&client, "https://plc.directory", "did:web:169.254.169.254")
.await
.unwrap_err()
.to_string();
assert!(
err.contains("forbidden") || err.contains("internal"),
"expected an SSRF refusal, got: {err}"
);
}
#[tokio::test]
async fn resolve_did_web_blocks_loopback_host() {
let client = ssrf_test_client();
let err = resolve_did_to_pds(&client, "https://plc.directory", "did:web:127.0.0.1")
.await
.unwrap_err()
.to_string();
assert!(
err.contains("forbidden") || err.contains("internal"),
"expected an SSRF refusal, got: {err}"
);
}
#[tokio::test]
async fn resolve_handle_blocks_metadata_resolver_base() {
let client = ssrf_test_client();
let err = resolve_handle(&client, "http://169.254.169.254", "alice.example.com")
.await
.unwrap_err()
.to_string();
assert!(
err.contains("forbidden") || err.contains("internal"),
"expected an SSRF refusal, got: {err}"
);
}
#[tokio::test]
async fn service_endpoint_internal_target_rejected() {
assert!(crate::net::assert_public_target("http://169.254.169.254/")
.await
.is_err());
assert!(crate::net::assert_public_target("http://127.0.0.1:3000/")
.await
.is_err());
assert!(crate::net::assert_public_target("https://1.1.1.1/")
.await
.is_ok());
}
fn internal_target_client(pds_base: &str) -> PdsClient {
PdsClient::new(
ssrf_test_client(),
pds_base,
"did:plc:victim",
Auth::Session(SessionAuth {
did: "did:plc:victim".to_string(),
handle: None,
access_jwt: "session-bearer-must-not-leak".to_string(),
refresh_jwt: None,
}),
)
}
#[tokio::test]
async fn every_repo_write_is_refused_against_an_internal_pds() {
for base in [
"http://169.254.169.254",
"http://127.0.0.1:9",
"http://[::1]",
] {
let client = internal_target_client(base);
let sub = Subscription::new("https://example.com/feed.xml", "2026-08-13T00:00:00Z");
let mut errors = vec![
client
.create_record(lexicon::nsid::SUBSCRIPTION, &sub)
.await
.unwrap_err()
.to_string(),
client
.put_record(lexicon::nsid::SUBSCRIPTION, "rkey", &sub, None)
.await
.unwrap_err()
.to_string(),
client
.delete_record(lexicon::nsid::SUBSCRIPTION, "rkey")
.await
.unwrap_err()
.to_string(),
];
errors.push(
client
.apply_writes(&[WriteOp::Delete {
collection: lexicon::nsid::SUBSCRIPTION.to_string(),
rkey: "rkey".to_string(),
}])
.await
.unwrap_err()
.to_string(),
);
for err in errors {
assert!(
err.contains("forbidden") || err.contains("internal"),
"{base}: expected an SSRF refusal, got: {err}"
);
}
}
}
#[tokio::test]
async fn app_password_login_is_refused_against_an_internal_pds() {
let client = ssrf_test_client();
for base in ["http://169.254.169.254", "http://127.0.0.1:9"] {
let err = login_with_app_password(&client, base, "alice.example.com", "hunter2-app-pw")
.await
.unwrap_err()
.to_string();
assert!(
err.contains("forbidden") || err.contains("internal"),
"{base}: expected an SSRF refusal, got: {err}"
);
}
}
#[tokio::test]
async fn anonymous_client_cannot_write() {
let client = PdsClient::anonymous(
ssrf_test_client(),
"https://pds.example.com",
"did:plc:stranger",
);
let err = client
.delete_record(lexicon::nsid::SUBSCRIPTION, "rkey")
.await
.unwrap_err()
.to_string();
assert!(
err.contains("no credentials") || err.contains("anonymous") || err.contains("bearer"),
"expected a fail-closed auth error, got: {err}"
);
}
#[test]
fn write_result_deserializes() {
let wr: WriteResult = serde_json::from_value(json!({
"uri": "at://did:plc:abc123/community.lexicon.rss.subscription/3ksubnew",
"cid": "bafyreinew"
}))
.expect("write result");
assert!(wr.uri.ends_with("3ksubnew"));
assert_eq!(wr.cid.as_deref(), Some("bafyreinew"));
}
#[test]
fn did_document_finds_pds_endpoint() {
let doc: DidDocument = serde_json::from_value(json!({
"id": "did:plc:abc123",
"service": [
{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://pds.example.com/"
}
]
}))
.expect("did doc");
assert_eq!(
doc.pds_endpoint().as_deref(),
Some("https://pds.example.com")
);
}
#[test]
fn did_document_without_pds_yields_none() {
let doc: DidDocument = serde_json::from_value(json!({
"id": "did:plc:abc123",
"service": []
}))
.expect("did doc");
assert!(doc.pds_endpoint().is_none());
}
#[test]
fn session_auth_deserializes_create_session_shape() {
let session: SessionAuth = serde_json::from_value(json!({
"did": "did:plc:abc123",
"handle": "alice.example.com",
"accessJwt": "eyJh...access",
"refreshJwt": "eyJh...refresh"
}))
.expect("session");
assert_eq!(session.did, "did:plc:abc123");
assert_eq!(session.handle.as_deref(), Some("alice.example.com"));
let auth = Auth::Session(session);
assert_eq!(auth.bearer().expect("bearer"), "eyJh...access");
}
#[test]
fn oauth_variant_carries_no_direct_bearer() {
let auth = Auth::Oauth(OauthPlaceholder::default());
assert!(
auth.bearer().is_err(),
"Auth::Oauth carries no direct bearer — the sidecar owns the OAuth path"
);
}
#[test]
fn anonymous_variant_carries_no_bearer() {
let err = Auth::Anonymous.bearer().unwrap_err().to_string();
assert!(
err.contains("anonymous"),
"the anonymous refusal must name itself, got: {err}"
);
}
#[test]
fn anonymous_client_targets_the_requested_repo() {
let client = PdsClient::anonymous(
ssrf_test_client(),
"https://pds.example.com/",
"did:plc:abc123",
);
assert_eq!(client.pds_base(), "https://pds.example.com");
assert_eq!(client.did(), "did:plc:abc123");
assert!(client.auth.bearer().is_err());
}
#[tokio::test]
async fn list_records_blocks_internal_pds_base() {
for base in ["http://169.254.169.254", "http://127.0.0.1:1"] {
let client = PdsClient::anonymous(ssrf_test_client(), base, "did:plc:x");
let err = client
.list_records(lexicon::nsid::SUBSCRIPTION, Some(1), None)
.await
.unwrap_err()
.to_string();
assert!(
err.contains("forbidden") || err.contains("internal"),
"expected an SSRF refusal for {base}, got: {err}"
);
}
}
#[tokio::test]
async fn list_records_guard_applies_to_authed_clients_too() {
let auth = Auth::Session(SessionAuth {
did: "did:plc:x".to_string(),
handle: None,
access_jwt: "x".to_string(),
refresh_jwt: None,
});
let client = PdsClient::new(
ssrf_test_client(),
"http://169.254.169.254",
"did:plc:x",
auth,
);
let err = client
.list_records(lexicon::nsid::SUBSCRIPTION, Some(1), None)
.await
.unwrap_err()
.to_string();
assert!(
err.contains("forbidden") || err.contains("internal"),
"expected an SSRF refusal, got: {err}"
);
}
#[test]
fn apply_writes_ops_render_tagged_union() {
let create = WriteOp::Create {
collection: lexicon::nsid::SUBSCRIPTION.to_string(),
rkey: None,
value: json!({"url": "https://example.com/feed.xml"}),
};
let update = WriteOp::Update {
collection: lexicon::nsid::READ_STATE.to_string(),
rkey: "feedhash01".to_string(),
value: json!({"feedUrl": "https://example.com/feed.xml"}),
};
let delete = WriteOp::Delete {
collection: lexicon::nsid::SAVED.to_string(),
rkey: "3ksaved01".to_string(),
};
assert_eq!(
create.to_json()["$type"],
json!("com.atproto.repo.applyWrites#create")
);
assert!(create.to_json().get("rkey").is_none());
assert_eq!(
update.to_json()["$type"],
json!("com.atproto.repo.applyWrites#update")
);
assert_eq!(update.to_json()["rkey"], json!("feedhash01"));
assert_eq!(
delete.to_json()["$type"],
json!("com.atproto.repo.applyWrites#delete")
);
assert_eq!(delete.to_json()["rkey"], json!("3ksaved01"));
}
#[test]
fn read_state_flush_creates_first_then_updates() {
let fresh = (
"rs-fresh".to_string(),
ReadState::new("https://a.example/feed.xml", None, "2026-07-12T00:00:00Z"),
false,
);
let existing = (
"rs-existing".to_string(),
ReadState::new(
"https://b.example/feed.xml",
Some("2026-07-11T00:00:00Z".to_string()),
"2026-07-12T00:00:00Z",
),
true,
);
let ops = read_state_write_ops(&[fresh, existing]).expect("build ops");
assert_eq!(ops.len(), 2);
let create = ops[0].to_json();
assert_eq!(
create["$type"],
json!("com.atproto.repo.applyWrites#create"),
"first flush of a new feed must CREATE its readState record"
);
assert_eq!(create["rkey"], json!("rs-fresh"));
assert!(create["value"].get("readThrough").is_none());
let update = ops[1].to_json();
assert_eq!(
update["$type"],
json!("com.atproto.repo.applyWrites#update")
);
assert_eq!(update["rkey"], json!("rs-existing"));
assert_eq!(ops.len(), 2);
}
#[test]
fn urlencode_escapes_did_colons_and_keeps_unreserved() {
assert_eq!(urlencode("did:plc:abc123"), "did%3Aplc%3Aabc123");
assert_eq!(
urlencode("community.lexicon.rss.subscription"),
"community.lexicon.rss.subscription"
);
assert_eq!(urlencode("a b&c"), "a%20b%26c");
}
#[test]
fn xrpc_record_not_found_is_detected() {
let err = AtProtoError::Xrpc {
status: StatusCode::BAD_REQUEST,
error: "RecordNotFound".to_string(),
message: Some("Could not locate record".to_string()),
};
assert!(err.is_record_not_found());
}
#[test]
fn write_result_extracts_rkey_from_uri() {
let wr: WriteResult = serde_json::from_value(json!({
"uri": "at://did:plc:abc123/community.lexicon.rss.subscription/3ksubnew",
"cid": "bafyreinew"
}))
.expect("write result");
assert_eq!(wr.rkey(), Some("3ksubnew"));
assert_eq!(wr.into_rkey(), "3ksubnew");
}
#[tokio::test]
async fn bulk_subscribe_writes_client_assigned_ordered_rkeys_to_the_right_collection() {
let (base, log) =
crate::net::tests::serve_json_capturing(br#"{"ok":true,"data":{}}"#.to_vec()).await;
let client = SidecarClient::new(Client::new(), base.clone(), base, "secret");
let subs: Vec<crate::vetted::VettedSubscription> = (0..3)
.map(|i| {
crate::vetted::VettedSubscription::new(&lexicon::Subscription::new(
format!("https://f{i}.example/feed.xml"),
"2026-07-12T00:00:00.000Z",
))
})
.collect();
let rkeys = client
.add_subscriptions_bulk("did:plc:ewvi7nxzyoun6zhxrhs64oiz", &subs)
.await
.expect("bulk write failed");
let sent = log.lock().unwrap().clone();
assert_eq!(
sent.len(),
1,
"expected one applyWrites request, got {sent:?}"
);
let body: Value = serde_json::from_str(sent[0].split("\r\n\r\n").nth(1).unwrap())
.expect("request body is JSON");
let writes = body["writes"].as_array().expect("writes array");
assert_eq!(writes.len(), 3);
for (i, w) in writes.iter().enumerate() {
assert_eq!(
w["collection"],
lexicon::nsid::SUBSCRIPTION,
"write {i} went to the wrong collection"
);
assert_eq!(
w["rkey"].as_str(),
Some(rkeys[i].as_str()),
"write {i} does not carry the rkey the client returned"
);
}
let mut sorted = rkeys.clone();
sorted.sort();
assert_eq!(rkeys, sorted, "client-assigned rkeys must ascend");
assert_eq!(
rkeys.iter().collect::<std::collections::HashSet<_>>().len(),
3,
"rkeys must be distinct"
);
}
#[tokio::test]
async fn list_all_records_stops_on_a_repeated_cursor() {
let body = serde_json::json!({
"records": [{"uri": "at://did:plc:x/c/1", "value": {}}],
"cursor": "same-every-time"
})
.to_string();
let base = crate::net::tests::serve_body(body.into_bytes()).await;
let port: u16 = base
.trim_end_matches('/')
.rsplit(':')
.next()
.unwrap()
.parse()
.unwrap();
crate::net::test_host_override(
"repeated-cursor.test",
std::net::SocketAddr::from(([127, 0, 0, 1], port)),
);
let client = PdsClient::anonymous(
ssrf_test_client(),
format!("http://repeated-cursor.test:{port}"),
"did:plc:x",
);
let records = client.list_all_records("c").await.expect("walk failed");
assert_eq!(records.len(), 2, "a repeated cursor was followed");
}
#[test]
fn structure_inside_a_string_is_not_structure() {
let prose = format!(
r#"{{"records":[{{"uri":"at://d/c/r","value":{{"t":"{}"}}}}]}}"#,
"a,b,[c],{d}:e,".repeat(50_000)
);
let bound = count_structural_chars(prose.as_bytes());
assert!(
bound < 100,
"a page of prose full of punctuation was counted as {bound} nodes"
);
assert!(
parse_list_records(prose.as_bytes()).is_ok(),
"a legitimate page of prose was refused"
);
}
#[test]
fn a_node_explosion_is_refused_before_it_is_parsed() {
let mut body = String::from(r#"{"records":[{"uri":"at://d/c/r","value":["#);
for _ in 0..1_200_000 {
body.push_str("{},");
}
body.push_str(r#"{}]}]}"#);
assert!(
body.len() > 3_000_000,
"the probe body is {} bytes",
body.len()
);
let bound = count_structural_chars(body.as_bytes());
assert!(
bound > MAX_LIST_STRUCTURAL_CHARS,
"the attack shape was counted as only {bound} nodes"
);
let err = parse_list_records(body.as_bytes())
.expect_err("a node explosion was parsed rather than refused");
assert!(
format!("{err:#}").contains("structural characters"),
"failed for the wrong reason: {err:#}"
);
}
#[test]
fn a_full_read_state_page_fits_under_the_cap() {
let ids: Vec<String> = (0..crate::lexicon::ReadState::MAX_IDS)
.map(|i| format!("https://example.com/blog/post-{i}"))
.collect();
let records: Vec<serde_json::Value> = (0..100)
.map(|i| {
serde_json::json!({
"uri": format!("at://did:plc:ohutz6x5acjmpuulp3x7wxxc/community.lexicon.rss.readState/3lab{i}"),
"cid": "bafyreiabc123def456ghi789jkl012mno345pqr678stu901",
"value": {
"$type": "community.lexicon.rss.readState",
"feedUrl": "https://example.com/feed.xml",
"readThrough": "2026-07-11T09:30:00Z",
"readIds": ids,
"unreadIds": ids,
}
})
})
.collect();
let body = serde_json::json!({ "records": records }).to_string();
let bound = count_structural_chars(body.as_bytes());
assert!(
bound < MAX_LIST_STRUCTURAL_CHARS,
"the densest legitimate page counts {bound} nodes against a cap of {MAX_LIST_STRUCTURAL_CHARS}"
);
assert!(
bound > MAX_LIST_STRUCTURAL_CHARS / 2,
"this page counts only {bound} nodes, so it is no longer the floor \
`MAX_LIST_STRUCTURAL_CHARS` was measured against and a much tighter cap would \
pass it"
);
assert!(
parse_list_records(body.as_bytes()).is_ok(),
"a full read-state page was refused"
);
}
#[tokio::test]
async fn the_write_path_refuses_a_node_explosion() {
let mut data = String::from(r#"{"ok":true,"data":{"records":["#);
for _ in 0..700_000 {
data.push_str("{},");
}
data.push_str(r#"{}]}}"#);
let base = crate::net::tests::serve_body(data.into_bytes()).await;
let client = SidecarClient::new(Client::new(), base.clone(), base, "secret");
let err = client
.delete_record("did:plc:ewvi7nxzyoun6zhxrhs64oiz", "c", "r")
.await
.expect_err("a node explosion reached the parser on the write path");
assert!(
format!("{err:#}").contains("structural characters"),
"failed for the wrong reason: {err:#}"
);
}
#[test]
fn an_ordinary_page_is_nowhere_near_the_structure_bound() {
let records: Vec<serde_json::Value> = (0..100)
.map(|i| {
serde_json::json!({
"uri": format!("at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.document/3lab{i}"),
"cid": "bafyreiabc123def456ghi789jkl012mno345pqr678stu901",
"value": {
"$type": "site.standard.document",
"title": "A reasonably typical post title",
"path": format!("/posts/{i}"),
"site": "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab",
"publishedAt": "2026-07-11T09:30:00Z",
"description": "x".repeat(120),
"textContent": "y".repeat(15_000),
}
})
})
.collect();
let body = serde_json::json!({ "records": records }).to_string();
assert!(
body.len() > 1_000_000,
"the probe page is only {} bytes, so it is not a full page",
body.len(),
);
let counted = count_structural_chars(body.as_bytes());
assert!(
(3_500..4_500).contains(&counted),
"a page of 100 documents counted {counted}, not the ~4 003 the cap's \
doc comment claims — the ordinary-traffic end of the bracket moved",
);
assert!(
counted * 100 < MAX_LIST_STRUCTURAL_CHARS,
"ordinary traffic is within 100x of the cap ({counted} against \
{MAX_LIST_STRUCTURAL_CHARS}), which is not the headroom the cap claims",
);
}
#[test]
fn an_escaped_quote_does_not_end_the_string() {
let escaped = br#"{"records":[{"uri":"a\"b","value":{}}],"cursor":"x"}"#;
let plain = br#"{"records":[{"uri":"axb","value":{}}],"cursor":"x"}"#;
assert_eq!(
count_structural_chars(escaped),
count_structural_chars(plain),
"an escaped quote changed the structure count"
);
let backslash = br#"{"records":[],"cursor":"x\\"}"#;
let letter = br#"{"records":[],"cursor":"xy"}"#;
assert_eq!(
count_structural_chars(backslash),
count_structural_chars(letter),
"an escaped backslash changed the structure count"
);
}
#[test]
fn a_string_counts_as_a_node() {
assert!(
count_structural_chars(br#"["a","b","c"]"#) > count_structural_chars(br#"[1,1,1]"#),
"strings were not counted, so an array of them looks free"
);
}
#[tokio::test]
async fn the_sidecar_refuses_a_node_explosion_too() {
let mut data =
String::from(r#"{"ok":true,"data":{"records":[{"uri":"at://d/c/r","value":["#);
for _ in 0..1_200_000 {
data.push_str("{},");
}
data.push_str(r#"{}]}]}}"#);
let base = crate::net::tests::serve_body(data.into_bytes()).await;
let client = SidecarClient::new(Client::new(), base.clone(), base, "secret");
let err = client
.list_records("did:plc:ewvi7nxzyoun6zhxrhs64oiz", "c", None, None)
.await
.expect_err("a node explosion reached the parser");
assert!(
format!("{err:#}").contains("structural characters"),
"failed for the wrong reason: {err:#}"
);
}
fn node_count(v: &serde_json::Value) -> usize {
1 + match v {
serde_json::Value::Array(a) => a.iter().map(node_count).sum::<usize>(),
serde_json::Value::Object(o) => o.values().map(node_count).sum::<usize>(),
_ => 0,
}
}
fn record_of(value: serde_json::Value) -> RecordEntry {
RecordEntry {
uri: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/c/3lab".to_string(),
cid: Some("bafyreiabc123def456ghi789jkl012mno345pqr678stu901".to_string()),
value,
}
}
#[test]
fn the_estimate_charges_every_node_at_least_what_a_parsed_value_costs() {
let deep: serde_json::Value =
serde_json::from_str(&format!("{}{}", "[".repeat(100), "]".repeat(100))).unwrap();
let shapes: Vec<(&str, serde_json::Value)> = vec![
("100 nested empty arrays", deep),
(
"4096 empty arrays",
serde_json::json!(vec![serde_json::json!([]); 4096]),
),
("4096 empty strings", serde_json::json!(vec![""; 4096])),
(
"4096 nulls",
serde_json::json!(vec![serde_json::Value::Null; 4096]),
),
("4096 bools", serde_json::json!(vec![true; 4096])),
("4096 small numbers", serde_json::json!(vec![0; 4096])),
(
"object with short keys",
serde_json::Value::Object(
(0..4096)
.map(|i| (format!("k{i}"), serde_json::json!([])))
.collect(),
),
),
(
"a realistic document",
serde_json::json!({
"$type": "site.standard.document",
"title": "A post with a reasonably typical title",
"path": "/posts/one",
"publishedAt": "2026-07-11T09:30:00Z",
"textContent": "x".repeat(17_000),
}),
),
];
for (label, value) in shapes {
let entry = record_of(value);
let charged = approx_bytes(&entry);
let floor = node_count(&entry.value) * std::mem::size_of::<serde_json::Value>();
assert!(
charged >= floor,
"{label}: charged {charged} for {} nodes, which cannot cost less than {floor}",
node_count(&entry.value)
);
let wire = serde_json::to_vec(&entry.value).unwrap().len();
assert!(
charged >= wire,
"{label}: charged {charged}, under the {wire} bytes it takes on the wire alone"
);
}
}
#[test]
fn the_estimate_covers_shapes_measured_against_a_real_allocator() {
let many_small = serde_json::json!(vec![serde_json::json!({"a": 0}); 5000]);
let mut deep = serde_json::json!({"a": 0});
for _ in 0..99 {
deep = serde_json::json!({ "a": deep });
}
for (label, value, measured) in [
("5000 one-key objects", many_small, 3_430_000usize),
("a 100-deep chain of one-key objects", deep, 63_350),
] {
let charged = approx_bytes(&record_of(value));
assert!(
charged >= measured,
"{label}: charged {charged} against {measured} bytes actually held"
);
}
}
#[test]
fn the_estimate_counts_the_uri_and_cid_too() {
let bare = RecordEntry {
uri: String::new(),
cid: None,
value: serde_json::json!(null),
};
let addressed = record_of(serde_json::json!(null));
assert!(
approx_bytes(&addressed) > approx_bytes(&bare),
"a record's own identifiers are retained alongside its value"
);
}
#[test]
fn the_budget_admits_a_page_that_exactly_fills_it() {
let page = vec![record_of(serde_json::json!({"t": "x".repeat(1000)}))];
let exact: usize = page.iter().map(approx_bytes).sum();
assert!(
ByteBudget::new(exact).admit(&page),
"a page that exactly fits was refused; the fence-post is one byte out"
);
assert!(
!ByteBudget::new(exact - 1).admit(&page),
"a page one byte over the budget was admitted"
);
}
#[test]
fn a_refused_page_leaves_the_running_total_alone() {
let small = vec![record_of(serde_json::json!({"t": "x".repeat(100)}))];
let huge = vec![record_of(serde_json::json!({"t": "x".repeat(100_000)}))];
let cost: usize = small.iter().map(approx_bytes).sum();
let mut budget = ByteBudget::new(cost * 3);
assert!(budget.admit(&small), "the first page fits");
let after_one = budget.used();
assert!(after_one > 0, "an admitted page must be charged");
assert!(!budget.admit(&huge), "the oversized page must be refused");
assert_eq!(
budget.used(),
after_one,
"a refused page moved the total — either charged, or reset"
);
assert!(
budget.admit(&small),
"the walk could not continue against the total it had before the refusal"
);
}
pub(crate) fn paged_bodies(
pages: usize,
bytes: usize,
envelope: bool,
) -> (Vec<Vec<u8>>, usize) {
let record = |i: usize| {
serde_json::json!({
"uri": format!("at://did:plc:ohutz6x5acjmpuulp3x7wxxc/c/3lab{i}"),
"cid": "bafyreiabc123def456ghi789jkl012mno345pqr678stu901",
"value": { "t": "x".repeat(bytes) }
})
};
let bodies = (0..pages)
.map(|i| {
let mut page = serde_json::json!({ "records": [record(i)] });
if i + 1 < pages {
page["cursor"] = serde_json::json!(format!("p{}", i + 1));
}
if envelope {
page = serde_json::json!({ "ok": true, "data": page });
}
page.to_string().into_bytes()
})
.collect();
let entry: RecordEntry = serde_json::from_value(record(0)).unwrap();
(bodies, approx_bytes(&entry))
}
async fn host_for(bodies: Vec<Vec<u8>>, host: &str) -> (String, u16) {
let base = crate::net::tests::serve_bodies_in_sequence(bodies).await;
let port: u16 = base
.trim_end_matches('/')
.rsplit(':')
.next()
.unwrap()
.parse()
.unwrap();
crate::net::test_host_override(host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
(format!("http://{host}:{port}"), port)
}
fn malformed_page(cursor: Option<&str>) -> Vec<u8> {
let mut page = serde_json::json!({
"records": [
{ "uri": "at://did:plc:x/c/3labGOOD", "value": {} },
{ "cid": "bafy", "value": {} },
]
});
if let Some(c) = cursor {
page["cursor"] = serde_json::json!(c);
}
page.to_string().into_bytes()
}
#[test]
fn one_malformed_envelope_is_counted_not_fatal_to_the_page() {
let page = parse_list_records(&malformed_page(None))
.expect("one malformed envelope failed the whole page");
assert_eq!(page.records.len(), 1, "the good record was not kept");
assert_eq!(page.records[0].uri, "at://did:plc:x/c/3labGOOD");
assert_eq!(page.malformed, 1, "the malformed record was not counted");
}
#[tokio::test]
async fn the_skipping_walk_skips_malformed_records_and_keeps_paging() {
let only_bad = serde_json::json!({
"records": [{ "cid": "bafy", "value": {} }], "cursor": "p1"
})
.to_string()
.into_bytes();
let bodies = vec![
only_bad,
malformed_page(Some("p2")),
serde_json::json!({ "records": [] })
.to_string()
.into_bytes(),
];
let (base, _) = host_for(bodies, "skipping-walk-malformed.test").await;
let client = PdsClient::anonymous(ssrf_test_client(), base, "did:plc:x");
let (records, skipped) = client
.list_all_records_skipping_within("c", &mut ByteBudget::new(MAX_LIST_BYTES))
.await
.expect("a malformed record failed a stranger's walk");
assert_eq!(
records.len(),
1,
"the good record behind the bad page was lost"
);
assert_eq!(skipped, 2, "skipped records were not counted");
}
fn junk_pages(n: usize, junk: usize) -> Vec<Vec<u8>> {
(0..n)
.map(|i| {
serde_json::json!({
"records": [{ "cid": "bafy", "value": "x".repeat(junk) }],
"cursor": format!("p{}", i + 1),
})
.to_string()
.into_bytes()
})
.collect()
}
#[tokio::test]
async fn skipped_records_are_charged_against_the_budget() {
let (base, _) = host_for(junk_pages(40, 256 * 1024), "junk-recent.test").await;
let client = PdsClient::anonymous(ssrf_test_client(), base, "did:plc:x");
let mut budget = ByteBudget::new(1024 * 1024);
let walk = client
.list_recent_matching_within("c", 100, &mut budget, 25, |_| true)
.await
.unwrap();
assert!(
!walk.complete,
"40 pages of junk were walked to the end under a 1 MiB budget"
);
let (base, _) = host_for(junk_pages(40, 256 * 1024), "junk-skipping.test").await;
let client = PdsClient::anonymous(ssrf_test_client(), base, "did:plc:x");
client
.list_all_records_skipping_within("c", &mut ByteBudget::new(1024 * 1024))
.await
.expect_err("40 pages of junk were walked to the end under a 1 MiB budget");
}
#[tokio::test]
async fn a_skipped_record_does_not_double_charge_its_page() {
let page = |i: usize, with_bad: bool| {
let mut records = vec![serde_json::json!({
"uri": format!("at://did:plc:x/c/3lab{i}"), "value": "v".repeat(100_000)
})];
if with_bad {
records.push(serde_json::json!({ "cid": "b", "value": {} }));
}
let mut body = serde_json::json!({ "records": records });
if i < 4 {
body["cursor"] = serde_json::json!(format!("p{}", i + 1));
}
body.to_string().into_bytes()
};
let clean: Vec<_> = (0..5).map(|i| page(i, false)).collect();
let (base, _) = host_for(clean, "double-charge-clean.test").await;
let client = PdsClient::anonymous(ssrf_test_client(), base, "did:plc:x");
let mut budget = ByteBudget::new(MAX_LIST_BYTES);
let (clean_records, _) = client
.list_all_records_skipping_within("c", &mut budget)
.await
.unwrap();
let fits = budget.used() + budget.used() / 2;
let mixed: Vec<_> = (0..5).map(|i| page(i, true)).collect();
let (base, _) = host_for(mixed, "double-charge-mixed.test").await;
let client = PdsClient::anonymous(ssrf_test_client(), base, "did:plc:x");
let (records, skipped) = client
.list_all_records_skipping_within("c", &mut ByteBudget::new(fits))
.await
.expect("one tiny malformed record per page failed a walk that fits");
assert_eq!(records.len(), clean_records.len());
assert_eq!(skipped, 5);
let mixed: Vec<_> = (0..5).map(|i| page(i, true)).collect();
let (base, _) = host_for(mixed, "double-charge-recent.test").await;
let client = PdsClient::anonymous(ssrf_test_client(), base, "did:plc:x");
let walk = client
.list_recent_matching_within("c", 100, &mut ByteBudget::new(fits), 25, |_| true)
.await
.unwrap();
assert!(
walk.complete,
"one tiny malformed record per page cut short a walk that fits"
);
assert_eq!(walk.records.len(), clean_records.len());
}
#[tokio::test]
async fn a_page_that_skipped_a_record_still_pays_for_what_it_keeps() {
let dense = format!("[{}]", vec!["[]"; 2000].join(","));
let pages = |with_bad: bool| -> Vec<Vec<u8>> {
(0..3)
.map(|i| {
let mut records: Vec<String> = (0..99)
.map(|r| {
format!(r#"{{"uri":"at://did:plc:x/c/3l{i}x{r}","value":{dense}}}"#)
})
.collect();
if with_bad {
records.push(r#"{"cid":"b","value":{}}"#.to_string());
}
let cursor = if i < 2 {
format!(r#","cursor":"p{}""#, i + 1)
} else {
String::new()
};
format!(r#"{{"records":[{}]{cursor}}}"#, records.join(",")).into_bytes()
})
.collect()
};
const BUDGET: usize = 4 * 1024 * 1024;
let (base, _) = host_for(pages(false), "retained-control.test").await;
let client = PdsClient::anonymous(ssrf_test_client(), base, "did:plc:x");
client
.list_all_records_skipping_within("c", &mut ByteBudget::new(BUDGET))
.await
.expect_err("control: the clean pages fit a budget they exceed");
let (base, _) = host_for(pages(true), "retained-skipping.test").await;
let client = PdsClient::anonymous(ssrf_test_client(), base, "did:plc:x");
client
.list_all_records_skipping_within("c", &mut ByteBudget::new(BUDGET))
.await
.expect_err("one malformed record per page bought pages the budget refuses");
let small_dense = format!("[{}]", vec!["[]"; 120].join(","));
let fitting_pages: Vec<Vec<u8>> = (0..6)
.map(|i| {
let mut records: Vec<String> = (0..99)
.map(|r| {
format!(r#"{{"uri":"at://did:plc:x/c/3m{i}x{r}","value":{small_dense}}}"#)
})
.collect();
records.push(r#"{"cid":"b","value":{}}"#.to_string());
let cursor = if i < 5 {
format!(r#","cursor":"q{}""#, i + 1)
} else {
String::new()
};
format!(r#"{{"records":[{}]{cursor}}}"#, records.join(",")).into_bytes()
})
.collect();
let (base, _) = host_for(fitting_pages, "retained-recent.test").await;
let client = PdsClient::anonymous(ssrf_test_client(), base, "did:plc:x");
let walk = client
.list_recent_matching_within("c", 10_000, &mut ByteBudget::new(BUDGET), 100, |_| true)
.await
.unwrap();
let retained: usize = walk.records.iter().map(approx_bytes).sum();
assert!(
retained <= BUDGET,
"retained {retained} under a {BUDGET}-byte budget"
);
assert!(
!walk.complete,
"pages totalling more than the budget were all kept"
);
}
#[tokio::test]
async fn the_own_repo_walk_refuses_a_page_with_a_malformed_record() {
let (base, _) = host_for(vec![malformed_page(None)], "own-repo-malformed.test").await;
let client = PdsClient::anonymous(ssrf_test_client(), base, "did:plc:x");
let err = client
.list_all_records("c")
.await
.expect_err("a page with a malformed record was accepted");
let refused = err
.downcast_ref::<MalformedRecords>()
.unwrap_or_else(|| panic!("refused for the wrong reason: {err:#}"));
assert_eq!(refused.count, 1);
assert_eq!(refused.collection, "c");
}
#[tokio::test]
async fn the_sidecar_walk_refuses_a_page_with_a_malformed_record() {
let body = serde_json::json!({
"ok": true,
"data": { "records": [
{ "uri": "at://did:plc:x/c/3labGOOD", "value": {} },
{ "cid": "bafy", "value": {} },
]}
})
.to_string()
.into_bytes();
let base = crate::net::tests::serve_bodies_in_sequence(vec![body]).await;
let client = SidecarClient::new(Client::new(), base.clone(), base, "secret");
let err = client
.list_all_records("did:plc:x", "c")
.await
.expect_err("a page with a malformed record was accepted");
assert!(
err.downcast_ref::<MalformedRecords>().is_some(),
"refused for the wrong reason: {err:#}"
);
}
#[tokio::test]
async fn a_publication_walk_skips_malformed_records_and_keeps_paging() {
let only_bad = serde_json::json!({
"records": [{ "cid": "bafy", "value": {} }], "cursor": "p1"
})
.to_string()
.into_bytes();
let bodies = vec![
only_bad,
malformed_page(Some("p2")),
serde_json::json!({ "records": [] })
.to_string()
.into_bytes(),
];
let (base, _) = host_for(bodies, "publication-malformed.test").await;
let client = PdsClient::anonymous(ssrf_test_client(), base, "did:plc:x");
let mut budget = ByteBudget::new(MAX_LIST_BYTES);
let walk = client
.list_recent_matching_within("c", 100, &mut budget, 25, |_| true)
.await
.expect("a malformed record failed a stranger's publication walk");
assert_eq!(
walk.records.len(),
1,
"the good record behind the bad page was lost"
);
assert_eq!(walk.malformed, 2, "skipped records were not counted");
assert!(
walk.complete,
"the walk stopped at a page of only malformed records"
);
}
#[tokio::test]
async fn a_refusing_walk_spends_its_budget_across_pages() {
let (bodies, per_page) = paged_bodies(3, 4096, false);
let (base, _) = host_for(bodies, "budget-accumulate.test").await;
let client = PdsClient::anonymous(ssrf_test_client(), base, "did:plc:x");
let err = client
.list_all_records_within("c", &mut ByteBudget::new(per_page * 2))
.await
.expect_err("three pages cannot fit in a two-page budget");
let msg = format!("{err:#}");
assert!(msg.contains("byte cap"), "wrong bound reported: {msg}");
assert!(
msg.contains("2 held"),
"the walk did not keep exactly the two pages that fit: {msg}"
);
}
#[tokio::test]
async fn a_truncating_walk_keeps_the_pages_that_fit() {
let (bodies, per_page) = paged_bodies(3, 4096, false);
let (base, _) = host_for(bodies, "budget-accumulate-trunc.test").await;
let client = PdsClient::anonymous(ssrf_test_client(), base, "did:plc:x");
let walk = client
.list_recent_matching_within("c", 100, &mut ByteBudget::new(per_page * 2), 100, |_| {
true
})
.await
.expect("an additive walk truncates rather than failing");
assert_eq!(
walk.records.len(),
2,
"the pages that fit were not kept, or the refused one was"
);
assert!(
!walk.complete,
"a walk stopped by the budget called itself complete"
);
}
#[test]
fn a_full_subscription_repo_fits_the_budget_twice_over() {
let record = record_of(serde_json::json!({
"$type": "community.lexicon.rss.subscription",
"url": "https://example.com/blog/feed.xml",
"title": "Some Blog With A Longish Name",
"siteUrl": "https://example.com/blog",
"createdAt": "2026-07-11T09:30:00Z",
"folder": "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/community.lexicon.rss.folder/3lab999",
"fetchHint": "hourly",
}));
let per_record = approx_bytes(&record);
let full_repo = per_record * MAX_LIST_RECORDS;
assert!(
full_repo * 2 <= MAX_LIST_BYTES,
"a full repo charges {per_record} B x {MAX_LIST_RECORDS} = {} MB against a {} MB \
budget — too close for a walk whose verdict is a refusal",
full_repo / (1024 * 1024),
MAX_LIST_BYTES / (1024 * 1024)
);
}
#[tokio::test]
async fn a_walk_that_runs_out_of_pages_refuses_rather_than_truncating() {
let bodies: Vec<Vec<u8>> = (0..MAX_LIST_PAGES + 1)
.map(|i| {
serde_json::json!({
"records": [{ "uri": format!("at://did:plc:x/c/3lab{i}"), "value": {} }],
"cursor": format!("p{}", i + 1),
})
.to_string()
.into_bytes()
})
.collect();
let (base, _) = host_for(bodies, "pages-exhausted.test").await;
let client = PdsClient::anonymous(ssrf_test_client(), base, "did:plc:x");
let err = client
.list_all_records("c")
.await
.expect_err("a truncated list was returned as a complete one");
let msg = format!("{err:#}");
assert!(
msg.contains("did not finish"),
"failed for the wrong reason: {msg}"
);
}
#[tokio::test]
async fn the_sidecar_walk_that_finishes_cleanly_returns_the_records() {
let mut bodies: Vec<Vec<u8>> = (0..3)
.map(|i| {
serde_json::json!({
"ok": true,
"data": {
"records": [{ "uri": format!("at://did:plc:x/c/3lab{i}"), "value": {} }],
"cursor": format!("p{}", i + 1),
}
})
.to_string()
.into_bytes()
})
.collect();
bodies.push(
serde_json::json!({
"ok": true,
"data": { "records": [{ "uri": "at://did:plc:x/c/3labLAST", "value": {} }] }
})
.to_string()
.into_bytes(),
);
let base = crate::net::tests::serve_bodies_in_sequence(bodies).await;
let client = SidecarClient::new(Client::new(), base.clone(), base, "secret");
let records = client
.list_all_records("did:plc:ewvi7nxzyoun6zhxrhs64oiz", "c")
.await
.expect("a walk that ran out of records is not a short list");
assert_eq!(
records.len(),
4,
"the pages that were served were not all kept"
);
assert!(
records.iter().any(|r| r.uri.ends_with("3labLAST")),
"the LAST page's records were dropped — the walk kept the right \
count only because every page held one: {:?}",
records.iter().map(|r| r.uri.as_str()).collect::<Vec<_>>(),
);
}
#[tokio::test]
async fn a_sidecar_walk_that_terminates_on_its_last_allowed_page_succeeds() {
let mut bodies: Vec<Vec<u8>> = (0..MAX_LIST_PAGES - 1)
.map(|i| {
serde_json::json!({
"ok": true,
"data": {
"records": [{ "uri": format!("at://did:plc:x/c/3lab{i}"), "value": {} }],
"cursor": format!("p{}", i + 1),
}
})
.to_string()
.into_bytes()
})
.collect();
bodies.push(
serde_json::json!({
"ok": true,
"data": { "records": [{ "uri": "at://did:plc:x/c/3labLAST", "value": {} }] }
})
.to_string()
.into_bytes(),
);
assert_eq!(bodies.len(), MAX_LIST_PAGES);
let base = crate::net::tests::serve_bodies_in_sequence(bodies).await;
let client = SidecarClient::new(Client::new(), base.clone(), base, "secret");
let records = client
.list_all_records("did:plc:ewvi7nxzyoun6zhxrhs64oiz", "c")
.await
.expect("a walk that terminated inside its budget is not a short list");
assert_eq!(
records.len(),
MAX_LIST_PAGES,
"a walk that used its whole page budget and finished lost records",
);
}
#[tokio::test]
async fn a_direct_walk_that_terminates_on_its_last_allowed_page_succeeds() {
let mut bodies: Vec<Vec<u8>> = (0..MAX_LIST_PAGES - 1)
.map(|i| {
serde_json::json!({
"records": [{ "uri": format!("at://did:plc:x/c/3lab{i}"), "value": {} }],
"cursor": format!("p{}", i + 1),
})
.to_string()
.into_bytes()
})
.collect();
bodies.push(
serde_json::json!({
"records": [{ "uri": "at://did:plc:x/c/3labLAST", "value": {} }]
})
.to_string()
.into_bytes(),
);
assert_eq!(bodies.len(), MAX_LIST_PAGES);
let (base, _) = host_for(bodies, "last-allowed-page.test").await;
let client = PdsClient::anonymous(ssrf_test_client(), base, "did:plc:x");
let records = client
.list_all_records("c")
.await
.expect("a walk that terminated inside its budget is not a short list");
assert_eq!(
records.len(),
MAX_LIST_PAGES,
"a walk that used its whole page budget and finished lost records",
);
assert!(
records.iter().any(|r| r.uri.ends_with("3labLAST")),
"the LAST page's records were dropped",
);
}
#[tokio::test]
async fn a_truncating_walk_that_exhausts_on_its_last_allowed_page_is_complete() {
let mut bodies: Vec<Vec<u8>> = (0..MAX_LIST_PAGES - 1)
.map(|i| {
serde_json::json!({
"records": [{ "uri": format!("at://did:plc:x/c/3lab{i}"), "value": {} }],
"cursor": format!("p{}", i + 1),
})
.to_string()
.into_bytes()
})
.collect();
bodies.push(
serde_json::json!({
"records": [{ "uri": "at://did:plc:x/c/3labLAST", "value": {} }]
})
.to_string()
.into_bytes(),
);
assert_eq!(bodies.len(), MAX_LIST_PAGES);
let (base, _) = host_for(bodies, "last-allowed-page-truncating.test").await;
let client = PdsClient::anonymous(ssrf_test_client(), base, "did:plc:x");
let walk = client
.list_recent_matching("c", MAX_LIST_PAGES * 10, 1, |_| true)
.await
.expect("walk failed");
assert_eq!(
walk.records.len(),
MAX_LIST_PAGES,
"a walk that used its whole page budget and exhausted the collection \
lost records",
);
assert!(
walk.complete,
"a collection that ran out on the last allowed page was reported as a \
partial read, which is a starvation warning for a complete archive",
);
}
#[tokio::test]
async fn the_sidecar_walk_that_runs_out_of_pages_refuses() {
let bodies: Vec<Vec<u8>> = (0..MAX_LIST_PAGES + 1)
.map(|i| {
serde_json::json!({
"ok": true,
"data": {
"records": [{ "uri": format!("at://did:plc:x/c/3lab{i}"), "value": {} }],
"cursor": format!("p{}", i + 1),
}
})
.to_string()
.into_bytes()
})
.collect();
let base = crate::net::tests::serve_bodies_in_sequence(bodies).await;
let client = SidecarClient::new(Client::new(), base.clone(), base, "secret");
let err = client
.list_all_records("did:plc:ewvi7nxzyoun6zhxrhs64oiz", "c")
.await
.expect_err("a truncated list was returned as a complete one");
assert!(
format!("{err:#}").contains("did not finish"),
"failed for the wrong reason: {err:#}"
);
}
#[tokio::test]
async fn two_walks_sharing_a_budget_do_not_each_get_the_whole_of_it() {
let (bodies, per_page) = paged_bodies(4, 4096, false);
let (base, _) = host_for(bodies, "budget-shared.test").await;
let client = PdsClient::anonymous(ssrf_test_client(), base, "did:plc:x");
let mut budget = ByteBudget::new(per_page * 3);
let err = client
.list_all_records_within("c", &mut budget)
.await
.expect_err("four pages cannot fit a three-page budget");
assert!(format!("{err:#}").contains("3 held"), "{err:#}");
let err = client
.list_all_records_within("c", &mut budget)
.await
.expect_err("the second walk was handed a fresh ceiling");
assert!(
format!("{err:#}").contains("0 held"),
"the second walk kept something out of an exhausted budget: {err:#}"
);
}
#[tokio::test]
async fn a_transient_page_must_fit_what_is_left_not_the_ceiling() {
let (bodies, per_page) = paged_bodies(3, 4096, false);
let (base, _) = host_for(bodies, "budget-remaining.test").await;
let client = PdsClient::anonymous(ssrf_test_client(), base, "did:plc:x");
let mut budget = ByteBudget::new(per_page * 5 / 2);
let spent = vec![
record_of(serde_json::json!({ "t": "x".repeat(4096) })),
record_of(serde_json::json!({ "t": "x".repeat(4096) })),
];
assert!(budget.admit(&spent), "the pre-spend has to fit");
assert!(
budget.remaining() < per_page,
"and has to leave less than a page"
);
let walk = client
.list_recent_matching_within("c", 100, &mut budget, 100, |_| false)
.await
.expect("an additive walk truncates rather than failing");
assert!(
!walk.complete,
"a page larger than the remaining budget was walked past"
);
}
#[tokio::test]
async fn a_filter_that_keeps_nothing_still_cannot_outrun_the_budget() {
let (bodies, per_page) = paged_bodies(3, 4096, false);
let (base, _) = host_for(bodies, "budget-filtered.test").await;
let client = PdsClient::anonymous(ssrf_test_client(), base, "did:plc:x");
let walk = client
.list_recent_matching_within("c", 100, &mut ByteBudget::new(per_page / 2), 100, |_| {
false
})
.await
.expect("an additive walk truncates rather than failing");
assert!(
!walk.complete,
"a page too large to hold was walked past because the filter dropped it"
);
assert!(walk.records.is_empty(), "the filter kept nothing");
}
#[tokio::test]
async fn the_sidecar_walk_spends_its_budget_across_pages() {
let (bodies, per_page) = paged_bodies(3, 4096, true);
let base = crate::net::tests::serve_bodies_in_sequence(bodies).await;
let client = SidecarClient::new(Client::new(), base.clone(), base, "secret");
let err = client
.list_all_records_within(
"did:plc:ewvi7nxzyoun6zhxrhs64oiz",
"app.feather.subscription",
&mut ByteBudget::new(per_page * 2),
)
.await
.expect_err("the sidecar walk was the one with no budget at all");
let msg = format!("{err:#}");
assert!(msg.contains("byte cap"), "wrong bound reported: {msg}");
assert!(
msg.contains("2 held"),
"did not accumulate across pages: {msg}"
);
}
#[test]
fn a_page_that_is_not_a_listing_is_never_read_as_an_empty_one() {
for (label, body) in [
(
"a non-string error alongside records",
&br#"{"error":404,"records":[]}"#[..],
),
(
"an object error alongside records",
&br#"{"error":{"code":"x"},"records":[]}"#[..],
),
(
"a duplicated records key, the second one empty",
&br#"{"records":[{"uri":"at://d/c/r","value":{}}],"records":[]}"#[..],
),
("an explicit null records", &br#"{"records":null}"#[..]),
] {
assert!(
parse_list_records(body).is_err(),
"{label} was read as a page"
);
}
}
#[test]
fn a_non_string_error_is_reported_as_an_envelope() {
for body in [
&br#"{"error":404,"records":[]}"#[..],
&br#"{"error":{"code":"x"},"records":[]}"#[..],
&br#"{"error":[],"records":[]}"#[..],
&br#"{"error":true,"records":[]}"#[..],
] {
let err = parse_list_records(body)
.expect_err("a non-string error envelope was read as an empty page");
assert!(
format!("{err:#}").contains("error envelope"),
"{} failed for the wrong reason: {err:#}",
String::from_utf8_lossy(body)
);
}
for body in [
&br#"{"error":"","records":[]}"#[..],
&br#"{"error":1e-400,"records":[]}"#[..],
] {
let err = parse_list_records(body).expect_err("this is an envelope");
assert!(
format!("{err:#}").contains("error envelope"),
"{} failed for the wrong reason: {err:#}",
String::from_utf8_lossy(body)
);
}
for body in [
&br#"{"error":null,"records":[]}"#[..],
&br#"{"error":false,"records":[]}"#[..],
&br#"{"error":0,"records":[]}"#[..],
&br#"{"records":[]}"#[..],
] {
assert!(
parse_list_records(body).is_ok(),
"{} is not an error envelope",
String::from_utf8_lossy(body)
);
}
}
#[test]
fn a_structured_error_is_named_by_its_type_not_serialised() {
let payload = "s".repeat(20_000);
let body = format!(r#"{{"error":{{"deep":"{payload}"}},"records":[]}}"#);
let err = parse_list_records(body.as_bytes()).expect_err("an envelope is a refusal");
let msg = format!("{err:#}");
assert!(
!msg.contains("ssss"),
"the error's contents reached the message: {} chars",
msg.len()
);
assert!(
msg.contains("non-string error: object"),
"it should name the shape instead: {msg}"
);
}
#[tokio::test]
async fn the_sidecar_refuses_an_envelope_with_no_data() {
for body in [&br#"{"ok":true}"#[..], &br#"{"ok":true,"data":{}}"#[..]] {
let base = crate::net::tests::serve_body(body.to_vec()).await;
let client = SidecarClient::new(Client::new(), base.clone(), base, "secret");
let err = client
.list_records(
"did:plc:ewvi7nxzyoun6zhxrhs64oiz",
"app.feather.subscription",
None,
None,
)
.await
.expect_err("an envelope without a listing was read as an empty page");
assert!(
format!("{err:#}").contains("no records"),
"{} failed for the wrong reason: {err:#}",
String::from_utf8_lossy(body)
);
}
}
#[tokio::test]
async fn the_sidecar_refuses_a_duplicated_records_key() {
let base = crate::net::tests::serve_body(
br#"{"ok":true,"data":{"records":[{"uri":"at://d/c/r","value":{}}],"records":[]}}"#
.to_vec(),
)
.await;
let client = SidecarClient::new(Client::new(), base.clone(), base, "secret");
let err = client
.list_records(
"did:plc:ewvi7nxzyoun6zhxrhs64oiz",
"app.feather.subscription",
None,
None,
)
.await
.expect_err("a duplicated records key was read as an empty page");
assert!(
format!("{err:#}").contains("duplicate"),
"failed for the wrong reason: {err:#}"
);
}
#[test]
fn a_listing_that_is_not_an_object_is_not_a_page() {
for body in [
&b"[null,null,[]]"[..],
&b"[null,null,[],null]"[..],
&br#"[null,null,[{"uri":"at://d/c/r","value":{}}],"c"]"#[..],
&b"[]"[..],
&br#""a string""#[..],
&b"0"[..],
&b"true"[..],
] {
assert!(
parse_list_records(body).is_err(),
"{} was read as a page",
String::from_utf8_lossy(body)
);
}
}
#[test]
fn a_truncated_message_is_bounded_in_bytes() {
for (label, input) in [
("ascii", "e".repeat(50_000)),
("astral", "\u{1f600}".repeat(20_000)),
(
"mixed",
format!("{}{}", "e".repeat(200), "\u{1f600}".repeat(200)),
),
(
"just over in bytes, just under in chars",
"\u{1f600}".repeat(40),
),
] {
let out = truncate_for_message(&input);
assert!(
out.len() <= 200,
"{label}: rendered {} bytes from {} bytes of input",
out.len(),
input.len()
);
}
assert_eq!(truncate_for_message("Boom"), "Boom");
}
#[tokio::test]
async fn the_sidecar_refuses_its_own_error_envelope_on_a_2xx() {
for body in [
&br#"{"ok":false,"error":"ExpiredToken","data":{"records":[]}}"#[..],
&br#"{"ok":true,"error":"ExpiredToken","data":{"records":[]}}"#[..],
&br#"{"ok":false,"data":{"records":[]}}"#[..],
] {
let base = crate::net::tests::serve_body(body.to_vec()).await;
let client = SidecarClient::new(Client::new(), base.clone(), base, "secret");
let err = client
.list_records(
"did:plc:ewvi7nxzyoun6zhxrhs64oiz",
"app.feather.subscription",
None,
None,
)
.await
.expect_err("the sidecar's own envelope was read as a page");
let msg = format!("{err:#}");
assert!(
msg.contains("sidecar answered 2xx"),
"{} failed for the wrong reason: {msg}",
String::from_utf8_lossy(body)
);
}
}
#[test]
fn an_unknown_field_holding_invalid_json_is_not_a_page() {
for body in [
&b"{\"records\":[],\"x\":\"\xff\xfe\"}"[..],
&br#"{"records":[],"x":"\ud800"}"#[..],
] {
assert!(
parse_list_records(body).is_err(),
"{} was read as a page",
String::from_utf8_lossy(body)
);
}
}
#[test]
fn a_non_string_message_does_not_cost_the_page() {
let page = parse_list_records(br#"{"records":[],"message":5,"cursor":"c"}"#)
.expect("message carries no guard; typing it strictly failed whole listings");
assert_eq!(page.cursor.as_deref(), Some("c"));
}
#[test]
fn an_enormous_error_name_is_truncated_before_it_reaches_a_log() {
let huge = "e".repeat(50_000);
let body = format!(r#"{{"error":"{huge}","records":[]}}"#);
let err = parse_list_records(body.as_bytes()).expect_err("an envelope is a refusal");
let msg = format!("{err:#}");
assert!(
msg.len() < 400,
"the error message carried {} bytes of attacker-chosen text",
msg.len()
);
assert!(
msg.contains("50000 bytes"),
"it should say what it dropped: {msg}"
);
let wide = "\u{1f600}".repeat(20_000);
let body = format!(r#"{{"error":"{wide}","message":"{wide}","records":[]}}"#);
let err = parse_list_records(body.as_bytes()).expect_err("an envelope is a refusal");
let msg = format!("{err:#}");
assert!(
msg.len() < 400,
"a wide-character error rendered {} bytes",
msg.len()
);
}
#[test]
fn tid_rkeys_are_13_char_s32_and_monotonic() {
let mut gen = TidGenerator::new();
let mut prev: Option<String> = None;
for _ in 0..1000 {
let tid = gen.next();
assert_eq!(tid.len(), 13, "a TID is 13 s32 chars");
assert!(
tid.bytes().all(|b| S32_ALPHABET.contains(&b)),
"TID {tid} uses only the s32 alphabet"
);
if let Some(p) = &prev {
assert!(*p < tid, "TIDs must be strictly increasing ({p} < {tid})");
}
prev = Some(tid);
}
}
#[test]
fn tid_rkeys_are_valid_atproto_record_keys() {
let mut gen = TidGenerator::new();
let tid = gen.next();
assert!(is_valid_rkey(&tid), "{tid:?}");
assert!(tid
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'~' | b':' | b'-')));
}
#[test]
fn tid_values_round_trip_through_the_decoder() {
let max_tid = (0x001f_ffff_ffff_ffffu64 << 10) | 0x3ff;
for v in [0u64, 1, 31, 32, 1023, 1024, 1_000_000, max_tid] {
let encoded = encode_s32_tid(v);
assert_eq!(
decode_s32_tid(&encoded),
Some(v),
"{v} encoded to {encoded}, which did not decode back"
);
}
assert_eq!(
decode_s32_tid("3jzfcijpj2z2a"),
Some(1_728_652_679_052_295_174)
);
assert_eq!(encode_s32_tid(1_728_652_679_052_295_174), "3jzfcijpj2z2a");
assert_eq!(
decode_s32_tid("3jzfcijpj2z2a").map(|raw| raw >> 10),
Some(1_688_137_381_887_007),
"that key was written at 2023-06-30T15:03:01.887007Z"
);
}
#[test]
fn the_first_tid_of_a_generator_decodes_to_the_microsecond_it_was_minted() {
let micros = || {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_micros() as u64)
.unwrap_or(0)
};
let before = micros();
let tid = TidGenerator::new().next();
let after = micros();
let raw = decode_s32_tid(&tid).expect("a generated TID must decode");
let minted = raw >> 10;
assert!(
(before..=after).contains(&minted),
"TID {tid} decoded to {minted}, outside the {before}..={after} window it was minted in"
);
}
#[test]
fn the_decoder_rejects_strings_that_are_not_13_char_s32_values() {
for rkey in [
"", "self", "3jzfcijpj2z2", "3jzfcijpj2z2aa", "3jzfcijpj2z2A", "3jzfcijpj2z-a", "3jzfcijpj2z2!", "c222222222222", "k222222222222", "zzzzzzzzzzzzz", ] {
assert_eq!(
decode_s32_tid(rkey),
None,
"{rkey:?} is not a 13-character s32 value"
);
}
}
#[test]
fn a_slug_that_decodes_inside_the_window_is_read_as_a_date() {
for (slug, reads_as) in [
("3hoursinparis", "2020-11-24T08:17:26Z"),
("3ideasforjune", "2021-08-12T00:19:38Z"),
("3jokesaweekly", "2023-02-12T15:50:26Z"),
] {
assert_eq!(
tid_timestamp(slug).map(crate::feed::fmt_time),
Some(reads_as.to_string()),
"{slug} is indistinguishable from a record key written then"
);
}
}
#[test]
fn a_tid_minted_slightly_ahead_of_our_clock_is_still_believed() {
let now = chrono::Utc::now();
let of = |at: chrono::DateTime<chrono::Utc>| {
encode_s32_tid((at.timestamp_micros() as u64) << 10)
};
assert!(
tid_timestamp(&of(now + chrono::Duration::seconds(2))).is_some(),
"a PDS two seconds fast must not leave a fresh document undated"
);
assert_eq!(
tid_timestamp(&of(now + chrono::Duration::hours(1))),
None,
"an hour ahead is a broken clock or a slug, not skew"
);
}
#[test]
fn a_tid_timestamp_is_bounded_at_both_ends() {
let now = chrono::Utc::now();
let of = |micros: i64| encode_s32_tid((micros as u64) << 10);
let fresh = TidGenerator::new().next();
let dated = tid_timestamp(&fresh).expect("a freshly minted TID has a timestamp");
assert!(
(now - chrono::Duration::minutes(1)..=now + chrono::Duration::minutes(1))
.contains(&dated),
"{fresh} dated to {dated}, not to now ({now})"
);
assert_eq!(
tid_timestamp(&of(TID_FLOOR_MICROS - 1)),
None,
"a TID predating atproto must not date an entry"
);
assert!(
tid_timestamp(&of(TID_FLOOR_MICROS)).is_some(),
"the floor itself is a real instant"
);
let far_future = (now + chrono::Duration::days(365)).timestamp_micros();
assert_eq!(
tid_timestamp(&of(far_future)),
None,
"a TID from the future must not date an entry"
);
assert_eq!(
tid_timestamp("abcdefghijklm"),
None,
"a 13-character slug decodes to the year 2192; it is not a date"
);
}
#[test]
fn s32_encoding_is_ascending_for_ascending_values() {
assert!(encode_s32_tid(1) < encode_s32_tid(2));
assert!(encode_s32_tid(31) < encode_s32_tid(32));
assert!(encode_s32_tid(1_000_000) < encode_s32_tid(1_000_001));
let max_tid = (0x001f_ffff_ffff_ffffu64 << 10) | 0x3ff;
assert!(encode_s32_tid(max_tid - 1) < encode_s32_tid(max_tid));
}
#[test]
fn exceeding_the_record_cap_is_an_error_not_a_truncation() {
let page = |n: usize| -> Vec<RecordEntry> {
(0..n)
.map(|i| RecordEntry {
uri: format!("at://did:plc:x/c/{i}"),
cid: None,
value: serde_json::Value::Null,
})
.collect()
};
let mut out = page(90);
let err = extend_bounded(&mut out, page(20), 100, "c")
.expect_err("a page past the cap was accepted");
let msg = format!("{err:#}");
assert!(msg.contains("100"), "the cap is not named: {msg}");
assert_eq!(
out.len(),
90,
"the partial page was kept — a truncated list must not survive the error"
);
}
#[test]
fn accumulating_within_the_cap_succeeds() {
let page = |n: usize| -> Vec<RecordEntry> {
(0..n)
.map(|i| RecordEntry {
uri: format!("at://did:plc:x/c/{i}"),
cid: None,
value: serde_json::Value::Null,
})
.collect()
};
let mut out = Vec::new();
extend_bounded(&mut out, page(60), 100, "c").unwrap();
extend_bounded(&mut out, page(40), 100, "c").unwrap();
assert_eq!(out.len(), 100, "exactly the cap must be allowed");
}
pub(crate) type ApplyWritesLog = Arc<std::sync::Mutex<Vec<Vec<Value>>>>;
pub(crate) async fn serve_apply_writes(fail_call: Option<usize>) -> (String, ApplyWritesLog) {
use axum::body::Bytes;
use axum::http::{StatusCode as Status, Uri};
use axum::response::IntoResponse;
const PRE_4989_JSON_LIMIT: usize = 150 * 1024;
let log: ApplyWritesLog = Arc::default();
let sink = Arc::clone(&log);
let app = axum::Router::new()
.fallback(move |uri: Uri, body: Bytes| {
let sink = Arc::clone(&sink);
async move {
let sidecar = uri.path() == "/internal/repo";
let reply = |status: Status, error: &str, message: &str| {
let body = if sidecar {
json!({ "ok": false, "error": error, "message": message, "status": status.as_u16() })
} else {
json!({ "error": error, "message": message })
};
(status, axum::Json(body)).into_response()
};
let parsed: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
let Some(writes) = parsed["writes"].as_array().cloned() else {
let empty = json!({ "records": [] });
return if sidecar {
axum::Json(json!({ "ok": true, "data": empty })).into_response()
} else {
axum::Json(empty).into_response()
};
};
let call = {
let mut calls = sink.lock().unwrap();
calls.push(writes.clone());
calls.len()
};
if body.len() > PRE_4989_JSON_LIMIT {
return reply(
Status::PAYLOAD_TOO_LARGE,
"PayloadTooLarge",
"request entity too large",
);
}
if writes.len() > 200 {
return reply(
Status::BAD_REQUEST,
"InvalidRequest",
"Too many writes. Max: 200",
);
}
if fail_call == Some(call) {
return reply(
Status::INTERNAL_SERVER_ERROR,
"InternalServerError",
"boom",
);
}
let data =
json!({ "commit": { "cid": "bafycommit", "rev": "3l" }, "results": [] });
if sidecar {
axum::Json(json!({ "ok": true, "data": data })).into_response()
} else {
axum::Json(data).into_response()
}
}
})
.layer(axum::extract::DefaultBodyLimit::disable());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(format!("http://{addr}"), log)
}
fn vetted_subs(n: usize) -> Vec<crate::vetted::VettedSubscription> {
(0..n)
.map(|i| {
crate::vetted::VettedSubscription::new(&lexicon::Subscription::new(
format!("https://f{i}.example/feed.xml"),
"2026-07-12T00:00:00.000Z",
))
})
.collect()
}
pub(crate) fn sent_rkeys(log: &ApplyWritesLog) -> Vec<String> {
log.lock()
.unwrap()
.iter()
.flatten()
.map(|w| w["rkey"].as_str().unwrap_or_default().to_string())
.collect()
}
pub(crate) fn call_sizes(log: &ApplyWritesLog) -> Vec<usize> {
log.lock().unwrap().iter().map(Vec::len).collect()
}
const CHUNK_DID: &str = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
fn chunk_sidecar(base: &str) -> SidecarClient {
SidecarClient::new(Client::new(), base, base, "secret")
}
#[tokio::test]
async fn sidecar_bulk_add_of_201_is_two_calls_in_order() {
let (base, log) = serve_apply_writes(None).await;
let rkeys = chunk_sidecar(&base)
.add_subscriptions_bulk(CHUNK_DID, &vetted_subs(201))
.await
.expect("a 201-feed import must succeed against a PDS that caps at 200");
assert_eq!(call_sizes(&log), vec![200, 1]);
let urls: Vec<String> = log
.lock()
.unwrap()
.iter()
.flatten()
.map(|w| w["value"]["url"].as_str().unwrap().to_string())
.collect();
let expected: Vec<String> = (0..201)
.map(|i| format!("https://f{i}.example/feed.xml"))
.collect();
assert_eq!(urls, expected, "ops must keep input order across chunks");
assert_eq!(
sent_rkeys(&log),
rkeys,
"the returned rkeys are the ones written, in order"
);
}
#[tokio::test]
async fn sidecar_bulk_add_of_500_is_three_calls() {
let (base, log) = serve_apply_writes(None).await;
chunk_sidecar(&base)
.add_subscriptions_bulk(CHUNK_DID, &vetted_subs(500))
.await
.expect("bulk write");
assert_eq!(call_sizes(&log), vec![200, 200, 100]);
}
#[tokio::test]
async fn sidecar_bulk_add_of_exactly_200_is_one_call() {
let (base, log) = serve_apply_writes(None).await;
chunk_sidecar(&base)
.add_subscriptions_bulk(CHUNK_DID, &vetted_subs(200))
.await
.expect("bulk write");
assert_eq!(call_sizes(&log), vec![200]);
}
#[tokio::test]
async fn sidecar_bulk_add_of_nothing_sends_nothing() {
let (base, log) = serve_apply_writes(None).await;
let rkeys = chunk_sidecar(&base)
.add_subscriptions_bulk(CHUNK_DID, &[])
.await
.expect("an empty import is not an error");
assert!(rkeys.is_empty());
assert!(
call_sizes(&log).is_empty(),
"an empty batch must not be sent"
);
}
#[tokio::test]
async fn sidecar_bulk_add_stops_at_the_first_failed_chunk() {
let (base, log) = serve_apply_writes(Some(2)).await;
let err = chunk_sidecar(&base)
.add_subscriptions_bulk(CHUNK_DID, &vetted_subs(500))
.await
.expect_err("a failed chunk must fail the call");
assert_eq!(call_sizes(&log), vec![200, 200], "chunk 3 must NOT be sent");
assert!(
format!("{err:#}").contains("boom"),
"the PDS's reason was lost: {err:#}"
);
let progress = ApplyWritesIncomplete::of(&err).expect("the error says how far it got");
assert_eq!(
(progress.landed, progress.in_doubt, progress.total),
(200, 200, 500),
"chunk 1 landed, chunk 2 is in doubt, chunk 3 was never sent"
);
assert!(err.to_string().contains("boom"), "{err}");
}
#[tokio::test]
async fn a_single_chunk_failure_reads_as_it_always_did() {
let (base, _log) = serve_apply_writes(Some(1)).await;
let err = chunk_sidecar(&base)
.add_subscriptions_bulk(CHUNK_DID, &vetted_subs(3))
.await
.expect_err("refused");
let progress = ApplyWritesIncomplete::of(&err).expect("progress");
assert_eq!((progress.landed, progress.in_doubt), (0, 3));
assert!(
!err.to_string().contains("applyWrites call"),
"a one-call batch has no progress to report: {err}"
);
assert_eq!(
format!("{err:#}").matches("boom").count(),
1,
"the cause must not print twice in the chain: {err:#}"
);
}
fn create_ops(n: usize, value_bytes: usize) -> Vec<WriteOp> {
(0..n)
.map(|i| WriteOp::Create {
collection: lexicon::nsid::SUBSCRIPTION.to_string(),
rkey: Some(format!("rk{i:05}")),
value: json!({ "pad": "x".repeat(value_bytes) }),
})
.collect()
}
#[test]
#[allow(clippy::single_range_in_vec_init)]
fn chunks_split_at_200_ops_and_not_before() {
for (n, want) in [
(0, vec![]),
(1, vec![0..1]),
(200, vec![0..200]),
(201, vec![0..200, 200..201]),
(500, vec![0..200, 200..400, 400..500]),
] {
assert_eq!(chunk_writes(&create_ops(n, 8)), want, "{n} ops");
}
}
#[test]
fn chunks_split_on_bytes_and_each_fits() {
let ops = create_ops(40, 10_000);
let chunks = chunk_writes(&ops);
assert!(chunks.len() > 1, "400 KB went out as {chunks:?}");
let mut next = 0;
for range in &chunks {
assert_eq!(range.start, next, "chunks must be consecutive: {chunks:?}");
next = range.end;
let body = json!({
"repo": CHUNK_DID,
"writes": ops[range.clone()].iter().map(WriteOp::to_json).collect::<Vec<_>>(),
})
.to_string();
assert!(
body.len() <= APPLY_WRITES_MAX_BYTES + 200,
"a {}-byte body for {range:?}",
body.len()
);
}
assert_eq!(next, ops.len(), "every op, once");
}
#[test]
fn a_realistic_200_feed_import_is_one_call() {
let ops: Vec<WriteOp> = (0..200)
.map(|i| {
let mut sub = lexicon::Subscription::new(
format!("https://www.example-blog-{i:03}.com/feeds/posts/default.xml"),
"2026-07-12T00:00:00.000Z",
);
sub.title = Some(format!("An Example Blog With A Fairly Long Title {i}"));
sub.site_url = Some(format!("https://www.example-blog-{i:03}.com/"));
WriteOp::Create {
collection: lexicon::nsid::SUBSCRIPTION.to_string(),
rkey: Some(format!("3lab2c4d5e{i:03}")),
value: serde_json::to_value(&sub).unwrap(),
}
})
.collect();
let bytes: usize = ops.iter().map(|op| op.to_json().to_string().len()).sum();
assert_eq!(chunk_writes(&ops), vec![0..200], "{bytes} bytes");
}
#[test]
fn an_oversized_op_goes_alone() {
let mut ops = create_ops(3, 8);
ops.insert(1, create_ops(1, APPLY_WRITES_MAX_BYTES + 1).remove(0));
assert_eq!(chunk_writes(&ops), vec![0..1, 1..2, 2..4]);
}
fn big_cursors(n: usize) -> Vec<(String, ReadState, bool)> {
(0..n)
.map(|i| {
let mut state = ReadState::new(
format!("https://f{i}.example/feed.xml"),
None,
"2026-07-12T00:00:00.000Z",
);
state.read_ids = (0..ReadState::MAX_IDS)
.map(|j| format!("https://f{i}.example/posts/{j:04}/an-entry-permalink"))
.collect();
(format!("rk{i:04}"), state, i % 2 == 0)
})
.collect()
}
#[tokio::test]
async fn sidecar_read_state_flush_splits_on_bytes_under_200_ops() {
let (base, log) = serve_apply_writes(None).await;
let cursors = big_cursors(10);
chunk_sidecar(&base)
.flush_read_states(CHUNK_DID, &cursors)
.await
.expect("a byte-heavy flush must succeed in chunks");
let sizes = call_sizes(&log);
assert!(
sizes.len() > 1,
"a ~500 KB flush went out as one call: {sizes:?}"
);
let want: Vec<String> = cursors.iter().map(|(rkey, _, _)| rkey.clone()).collect();
assert_eq!(sent_rkeys(&log), want, "every cursor, once, in order");
}
#[tokio::test]
async fn direct_client_read_state_flush_of_201_is_two_calls() {
let (base, log) = serve_apply_writes(None).await;
let port: u16 = base.rsplit(':').next().unwrap().parse().unwrap();
let host = format!("chunk-direct-{port}.test");
crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
let client = PdsClient::new(
ssrf_test_client(),
format!("http://{host}:{port}"),
CHUNK_DID,
Auth::Session(SessionAuth {
did: CHUNK_DID.to_string(),
handle: None,
access_jwt: "jwt".to_string(),
refresh_jwt: None,
}),
);
let cursors: Vec<(String, ReadState, bool)> = (0..201)
.map(|i| {
let feed = format!("https://f{i}.example/feed.xml");
let state = ReadState::new(feed, None, "2026-07-12T00:00:00.000Z");
(format!("rk{i:04}"), state, true)
})
.collect();
client.flush_read_states(&cursors).await.expect("flush");
assert_eq!(call_sizes(&log), vec![200, 1]);
let want: Vec<String> = cursors.iter().map(|(rkey, _, _)| rkey.clone()).collect();
assert_eq!(sent_rkeys(&log), want);
}
pub(crate) const SWAP_DID: &str = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
pub(crate) const OLD_CID: &str = "bafyreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy";
pub(crate) async fn serve_status_json(
status: u16,
body: Value,
) -> (String, String, Arc<std::sync::Mutex<Vec<Value>>>) {
use axum::response::IntoResponse as _;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let host = format!("swap-{}.atproto.test", addr.port());
crate::net::test_host_override(&host, addr);
let log: Arc<std::sync::Mutex<Vec<Value>>> = Arc::default();
let sink = Arc::clone(&log);
let app = axum::Router::new().fallback(move |raw: axum::body::Bytes| {
let sink = Arc::clone(&sink);
let body = body.clone();
async move {
sink.lock()
.unwrap()
.push(serde_json::from_slice(&raw).unwrap_or(Value::Null));
(
axum::http::StatusCode::from_u16(status).unwrap(),
axum::Json(body),
)
.into_response()
}
});
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(
format!("http://{addr}"),
format!("http://{host}:{}", addr.port()),
log,
)
}
fn swap_direct_client(pds: &str) -> PdsClient {
PdsClient::new(
ssrf_test_client(),
pds,
SWAP_DID,
Auth::Session(SessionAuth {
did: SWAP_DID.to_string(),
handle: None,
access_jwt: "jwt".to_string(),
refresh_jwt: None,
}),
)
}
pub(crate) fn swap_sub() -> crate::vetted::VettedSubscription {
crate::vetted::VettedSubscription::new(&Subscription::new(
"https://example.com/feed.xml",
"2024-03-01T00:00:00.000Z",
))
}
pub(crate) fn write_ok() -> Value {
json!({
"uri": format!("at://{SWAP_DID}/{}/rk", lexicon::nsid::SUBSCRIPTION),
"cid": "bafyreiafter",
})
}
#[tokio::test]
async fn direct_put_record_sends_swap_record_only_when_given() {
let (_, pds, log) = serve_status_json(200, write_ok()).await;
let client = swap_direct_client(&pds);
client
.put_record(
lexicon::nsid::SUBSCRIPTION,
"rk",
&swap_sub(),
Some(OLD_CID),
)
.await
.expect("put with a swap");
client
.put_record(lexicon::nsid::SUBSCRIPTION, "rk", &swap_sub(), None)
.await
.expect("put without a swap");
let sent = log.lock().unwrap().clone();
assert_eq!(sent.len(), 2, "{sent:?}");
assert_eq!(sent[0]["rkey"], "rk", "captured no usable body: {sent:?}");
assert_eq!(
sent[0]["swapRecord"], OLD_CID,
"the CID the caller read never reached the PDS: {}",
sent[0]
);
assert_eq!(sent[1]["rkey"], "rk");
assert!(
sent[1].get("swapRecord").is_none(),
"no swap was asked for, so none may be sent: {}",
sent[1]
);
}
#[tokio::test]
async fn sidecar_put_sends_swap_record_only_when_given() {
let (base, _, log) =
serve_status_json(200, json!({ "ok": true, "data": write_ok() })).await;
let client = SidecarClient::new(Client::new(), &base, &base, "secret");
client
.update_subscription(SWAP_DID, "rk", &swap_sub(), Some(OLD_CID))
.await
.expect("put with a swap");
client
.update_subscription(SWAP_DID, "rk", &swap_sub(), None)
.await
.expect("put without a swap");
let sent = log.lock().unwrap().clone();
assert_eq!(sent.len(), 2, "{sent:?}");
assert_eq!(
sent[0]["action"], "put",
"captured no usable body: {sent:?}"
);
assert_eq!(sent[0]["swapRecord"], OLD_CID, "{}", sent[0]);
assert_eq!(sent[1]["action"], "put");
assert!(sent[1].get("swapRecord").is_none(), "{}", sent[1]);
}
pub(crate) fn invalid_swap_xrpc() -> Value {
json!({ "error": "InvalidSwap", "message": format!("Record was at {OLD_CID}") })
}
pub(crate) fn invalid_swap_sidecar() -> Value {
json!({
"ok": false,
"error": "InvalidSwap",
"message": format!("Record was at {OLD_CID}"),
"status": 400,
})
}
#[tokio::test]
async fn an_invalid_swap_is_recognised_from_both_clients_errors() {
let (_, pds, _) = serve_status_json(400, invalid_swap_xrpc()).await;
let err = swap_direct_client(&pds)
.put_record(
lexicon::nsid::SUBSCRIPTION,
"rk",
&swap_sub(),
Some(OLD_CID),
)
.await
.expect_err("the PDS refused the swap");
assert!(is_invalid_swap(&err), "direct client: {err:#}");
let (base, _, _) = serve_status_json(400, invalid_swap_sidecar()).await;
let err = SidecarClient::new(Client::new(), &base, &base, "secret")
.update_subscription(SWAP_DID, "rk", &swap_sub(), Some(OLD_CID))
.await
.expect_err("the PDS refused the swap");
assert!(is_invalid_swap(&err), "sidecar client: {err:#}");
}
#[tokio::test]
async fn other_failures_are_not_an_invalid_swap() {
for (status, body) in [
(
400,
json!({ "error": "InvalidRequest", "message": "bad record" }),
),
(400, json!({ "error": "RecordNotFound" })),
(500, json!({ "error": "InternalServerError" })),
(401, json!({ "error": "AuthRequired" })),
(
400,
json!({ "error": "InvalidRequest", "message": "InvalidSwap" }),
),
] {
let (_, pds, _) = serve_status_json(status, body.clone()).await;
let err = swap_direct_client(&pds)
.put_record(
lexicon::nsid::SUBSCRIPTION,
"rk",
&swap_sub(),
Some(OLD_CID),
)
.await
.expect_err("refused");
assert!(!is_invalid_swap(&err), "{status} {body}: {err:#}");
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let dead = format!("http://{}", listener.local_addr().unwrap());
drop(listener);
let err = SidecarClient::new(Client::new(), &dead, &dead, "secret")
.update_subscription(SWAP_DID, "rk", &swap_sub(), Some(OLD_CID))
.await
.expect_err("nothing is listening");
assert!(!is_invalid_swap(&err), "transport: {err:#}");
assert!(!is_invalid_swap(&anyhow::anyhow!("InvalidSwap")));
}
#[tokio::test]
async fn an_invalid_swap_is_found_under_context_and_a_split_batch() {
let refusal = || AtProtoError::Xrpc {
status: StatusCode::BAD_REQUEST,
error: "InvalidSwap".to_string(),
message: None,
};
assert!(is_invalid_swap(&anyhow::Error::new(refusal())));
assert!(is_invalid_swap(
&anyhow::Error::new(refusal()).context("com.atproto.repo.putRecord failed")
));
let writes: Vec<WriteOp> = (0..3)
.map(|i| WriteOp::Delete {
collection: lexicon::nsid::SUBSCRIPTION.to_string(),
rkey: format!("rk{i}"),
})
.collect();
let err = apply_writes_chunked(&writes, |_| async { Err(refusal().into()) })
.await
.expect_err("the only call failed");
assert!(ApplyWritesIncomplete::of(&err).is_some(), "{err:#}");
assert!(is_invalid_swap(&err), "{err:#}");
}
pub(crate) fn two_subs_page() -> Value {
let rec = |rkey: &str, cid: &str, url: &str| {
json!({
"uri": format!("at://{SWAP_DID}/{}/{rkey}", lexicon::nsid::SUBSCRIPTION),
"cid": cid,
"value": {
"$type": lexicon::nsid::SUBSCRIPTION,
"url": url,
"createdAt": "2024-03-01T00:00:00.000Z",
},
})
};
json!({ "records": [
rec("rk-a", "bafyreiaaaaaaaaaa", "https://a.example/feed.xml"),
rec("rk-b", "bafyreibbbbbbbbbb", "https://b.example/feed.xml"),
] })
}
pub(crate) fn assert_listed_with_cids(listed: &[(String, Option<String>, Subscription)]) {
let got: Vec<(&str, Option<&str>, &str)> = listed
.iter()
.map(|(rkey, cid, sub)| (rkey.as_str(), cid.as_deref(), sub.url.as_str()))
.collect();
assert_eq!(
got,
vec![
(
"rk-a",
Some("bafyreiaaaaaaaaaa"),
"https://a.example/feed.xml"
),
(
"rk-b",
Some("bafyreibbbbbbbbbb"),
"https://b.example/feed.xml"
),
],
"each record must come back with the CID it was listed at"
);
}
pub(crate) fn two_folders_page() -> Value {
let rec = |rkey: &str, cid: &str, name: &str| {
json!({
"uri": format!("at://{SWAP_DID}/{}/{rkey}", lexicon::nsid::FOLDER),
"cid": cid,
"value": {
"$type": lexicon::nsid::FOLDER,
"name": name,
"position": 3,
"createdAt": "2024-01-01T00:00:00.000Z",
"color": "#abc",
},
})
};
json!({ "records": [
rec("fk-a", "bafyreifolderaaaa", "Tech"),
rec("fk-b", "bafyreifolderbbbb", "News"),
] })
}
pub(crate) fn assert_folders_listed_with_cids(listed: &[(String, Option<String>, Folder)]) {
let got: Vec<(&str, Option<&str>, &str)> = listed
.iter()
.map(|(rkey, cid, f)| (rkey.as_str(), cid.as_deref(), f.name.as_str()))
.collect();
assert_eq!(
got,
vec![
("fk-a", Some("bafyreifolderaaaa"), "Tech"),
("fk-b", Some("bafyreifolderbbbb"), "News"),
],
"each folder must come back with the CID it was listed at"
);
for (_, _, folder) in listed {
assert_eq!(folder.position, Some(3));
assert_eq!(folder.created_at, "2024-01-01T00:00:00.000Z");
assert_eq!(folder.extra.get("color"), Some(&json!("#abc")));
}
}
#[tokio::test]
async fn both_clients_list_folders_with_the_cid_each_was_read_at() {
let (_, pds, _) = serve_status_json(200, two_folders_page()).await;
let listed = swap_direct_client(&pds)
.list_folders_with_cids()
.await
.expect("direct listing");
assert_folders_listed_with_cids(&listed);
let (base, _, _) =
serve_status_json(200, json!({ "ok": true, "data": two_folders_page() })).await;
let listed = SidecarClient::new(Client::new(), &base, &base, "secret")
.list_folders_with_cids(SWAP_DID)
.await
.expect("sidecar listing");
assert_folders_listed_with_cids(&listed);
}
#[tokio::test]
async fn both_clients_list_subscriptions_with_the_cid_each_was_read_at() {
let (_, pds, _) = serve_status_json(200, two_subs_page()).await;
let listed = swap_direct_client(&pds)
.list_subscriptions_with_cids()
.await
.expect("direct listing");
assert_listed_with_cids(&listed);
let (base, _, _) =
serve_status_json(200, json!({ "ok": true, "data": two_subs_page() })).await;
let listed = SidecarClient::new(Client::new(), &base, &base, "secret")
.list_subscriptions_with_cids(SWAP_DID)
.await
.expect("sidecar listing");
assert_listed_with_cids(&listed);
}
}