use std::fmt;
use async_trait::async_trait;
use base64::Engine as _;
use url::Url;
use crate::pds_admin::backend::{
BackendActionId, BackendError, BackendInitError, PdsAdminBackend, ProbeReport,
};
use crate::pds_admin::config::{AdminPassword, OzoneBackendConfig};
use crate::pds_admin::types::Subject;
#[derive(Clone)]
pub struct OzoneBackend {
client: reqwest::Client,
base_url: Url,
admin_password: AdminPassword,
}
impl OzoneBackend {
pub fn new(config: &OzoneBackendConfig) -> Result<Self, BackendInitError> {
let client = reqwest::Client::builder()
.timeout(config.request_timeout)
.user_agent(concat!("cairn-mod/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| BackendInitError::HttpClient(e.to_string()))?;
Ok(Self {
client,
base_url: config.pds_url.clone(),
admin_password: config.admin_password.clone(),
})
}
pub(crate) fn xrpc_url(&self, nsid: &str) -> Result<Url, BackendError> {
let mut base = self.base_url.clone();
if !base.path().ends_with('/') {
base.set_path(&format!("{}/", base.path()));
}
base.join(&format!("xrpc/{nsid}"))
.map_err(|e| BackendError::Validation(format!("malformed nsid {nsid:?}: {e}")))
}
pub(crate) fn basic_auth_header(&self) -> String {
let creds = format!("admin:{}", self.admin_password.as_str());
let encoded = base64::engine::general_purpose::STANDARD.encode(creds);
format!("Basic {encoded}")
}
pub(crate) fn map_reqwest_error(err: reqwest::Error) -> BackendError {
if err.is_timeout() || err.is_connect() {
BackendError::Network(err.to_string())
} else if err.is_status() {
let code = err
.status()
.map(|s| s.as_u16().to_string())
.unwrap_or_default();
BackendError::RemoteError {
code,
message: err.to_string(),
}
} else {
BackendError::Network(err.to_string())
}
}
}
impl fmt::Debug for OzoneBackend {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OzoneBackend")
.field("base_url", &self.base_url.as_str())
.field("admin_password", &"<redacted>")
.finish()
}
}
fn synthesize_action_id(did: &str, precipitating_action_id: i64) -> BackendActionId {
BackendActionId::new(format!("ozone:{did}:{precipitating_action_id}"))
}
fn decode_xrpc_error_envelope(body: &[u8]) -> Option<(String, String)> {
let v: serde_json::Value = serde_json::from_slice(body).ok()?;
let code = v.get("error")?.as_str()?.to_string();
let message = v
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("")
.to_string();
Some((code, message))
}
fn parse_retry_after_seconds(header: &reqwest::header::HeaderValue) -> Option<u32> {
header.to_str().ok()?.trim().parse::<u32>().ok()
}
pub(crate) fn map_status_to_backend_error(
status: reqwest::StatusCode,
body: &[u8],
retry_after_seconds: Option<u32>,
) -> BackendError {
let envelope = decode_xrpc_error_envelope(body);
let lossy_body = || String::from_utf8_lossy(body).into_owned();
match status.as_u16() {
400 => {
let (code, message) =
envelope.unwrap_or_else(|| ("InvalidRequest".to_string(), lossy_body()));
if code == "InvalidRequest" {
let lower = message.to_ascii_lowercase();
if lower.contains("subject") || lower.contains("did") {
return BackendError::Validation(message);
}
}
BackendError::RemoteError { code, message }
}
401 | 403 => {
let message = envelope.map(|(_, m)| m).unwrap_or_else(lossy_body);
BackendError::Auth(format!("HTTP {}: {}", status.as_u16(), message))
}
429 => {
let message = envelope
.map(|(_, m)| m)
.unwrap_or_else(|| "rate limited".to_string());
BackendError::RateLimited {
message,
retry_after_seconds,
}
}
500 | 502 | 503 | 504 => BackendError::Network(format!(
"HTTP {}: {}",
status.as_u16(),
envelope.map(|(_, m)| m).unwrap_or_else(lossy_body)
)),
_ => match envelope {
Some((code, message)) => BackendError::RemoteError { code, message },
None => BackendError::RemoteError {
code: status.as_u16().to_string(),
message: lossy_body(),
},
},
}
}
#[async_trait]
impl PdsAdminBackend for OzoneBackend {
async fn takedown_account(
&self,
did: &str,
reason: &str,
notes: Option<&str>,
precipitating_action_id: i64,
) -> Result<BackendActionId, BackendError> {
let _ = notes;
let url = self.xrpc_url("com.atproto.admin.updateSubjectStatus")?;
let body = serde_json::json!({
"subject": {
"$type": "com.atproto.admin.defs#repoRef",
"did": did,
},
"takedown": {
"applied": true,
"ref": format!("cairn-mod:action_id={precipitating_action_id}:reason={reason}"),
},
});
let response = self
.client
.post(url)
.header(reqwest::header::AUTHORIZATION, self.basic_auth_header())
.json(&body)
.send()
.await
.map_err(Self::map_reqwest_error)?;
let status = response.status();
if status.is_success() {
return Ok(synthesize_action_id(did, precipitating_action_id));
}
let retry_after = response
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(parse_retry_after_seconds);
let body_bytes = response.bytes().await.unwrap_or_default();
Err(map_status_to_backend_error(
status,
&body_bytes,
retry_after,
))
}
async fn suspend_account(
&self,
did: &str,
reason: &str,
duration_days: Option<u32>,
notes: Option<&str>,
precipitating_action_id: i64,
) -> Result<BackendActionId, BackendError> {
let _ = notes;
let url = self.xrpc_url("com.atproto.admin.updateSubjectStatus")?;
let duration_token = match duration_days {
Some(n) => format!("{n}"),
None => "indef".to_string(),
};
let body = serde_json::json!({
"subject": {
"$type": "com.atproto.admin.defs#repoRef",
"did": did,
},
"takedown": {
"applied": true,
"ref": format!(
"cairn-mod:action_id={precipitating_action_id}:reason={reason}:duration_days={duration_token}"
),
},
});
let response = self
.client
.post(url)
.header(reqwest::header::AUTHORIZATION, self.basic_auth_header())
.json(&body)
.send()
.await
.map_err(Self::map_reqwest_error)?;
let status = response.status();
if status.is_success() {
return Ok(synthesize_action_id(did, precipitating_action_id));
}
let retry_after = response
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(parse_retry_after_seconds);
let body_bytes = response.bytes().await.unwrap_or_default();
Err(map_status_to_backend_error(
status,
&body_bytes,
retry_after,
))
}
async fn restore_account(
&self,
did: &str,
prior_action_id: &BackendActionId,
reason: &str,
) -> Result<(), BackendError> {
let url = self.xrpc_url("com.atproto.admin.updateSubjectStatus")?;
let body = serde_json::json!({
"subject": {
"$type": "com.atproto.admin.defs#repoRef",
"did": did,
},
"takedown": {
"applied": false,
"ref": format!(
"cairn-mod:restore:prior_action_id={}:reason={reason}",
prior_action_id.as_str()
),
},
});
let response = self
.client
.post(url)
.header(reqwest::header::AUTHORIZATION, self.basic_auth_header())
.json(&body)
.send()
.await
.map_err(Self::map_reqwest_error)?;
let status = response.status();
if status.is_success() {
return Ok(());
}
let retry_after = response
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(parse_retry_after_seconds);
let body_bytes = response.bytes().await.unwrap_or_default();
Err(map_status_to_backend_error(
status,
&body_bytes,
retry_after,
))
}
async fn apply_label(
&self,
_subject: &Subject,
_val: &str,
_expires_days: Option<u32>,
) -> Result<(), BackendError> {
Err(BackendError::Unsupported(
"OzoneBackend does not implement apply_label; \
cairn-mod's subscribeLabels (§F4) is the label distribution surface \
(see §A5 in v1.7 architectural decisions)",
))
}
async fn negate_label(&self, _subject: &Subject, _val: &str) -> Result<(), BackendError> {
Err(BackendError::Unsupported(
"OzoneBackend does not implement negate_label; \
cairn-mod's subscribeLabels (§F4) is the label distribution surface \
(see §A5 in v1.7 architectural decisions)",
))
}
async fn probe(&self) -> Result<ProbeReport, BackendError> {
let url = self.xrpc_url("com.atproto.server.describeServer")?;
let response = self
.client
.get(url)
.header(reqwest::header::AUTHORIZATION, self.basic_auth_header())
.send()
.await
.map_err(Self::map_reqwest_error)?;
let status = response.status();
if !status.is_success() {
let retry_after = response
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(parse_retry_after_seconds);
let body_bytes = response.bytes().await.unwrap_or_default();
return Err(map_status_to_backend_error(
status,
&body_bytes,
retry_after,
));
}
let body_bytes = response.bytes().await.map_err(Self::map_reqwest_error)?;
match serde_json::from_slice::<serde_json::Value>(&body_bytes) {
Ok(serde_json::Value::Object(_)) => Ok(ProbeReport {
backend_name: "ozone",
pds_url: self.base_url.as_str().to_string(),
detected_version: None,
capabilities: Vec::new(),
}),
Ok(_) | Err(_) => Err(BackendError::RemoteError {
code: "InvalidResponse".to_string(),
message: "describeServer returned a 200 with non-JSON-object body; \
configured pds_url likely doesn't point at a bsky-PDS"
.to_string(),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn fixture_config(base_url: &str) -> OzoneBackendConfig {
OzoneBackendConfig {
pds_url: Url::parse(base_url).unwrap(),
admin_password: AdminPassword::new("hunter2".into()),
request_timeout: Duration::from_secs(10),
}
}
fn fixture_backend(base_url: &str) -> OzoneBackend {
OzoneBackend::new(&fixture_config(base_url)).unwrap()
}
#[test]
fn new_constructs_from_valid_config() {
let backend = OzoneBackend::new(&fixture_config("https://bsky.example.com")).unwrap();
assert_eq!(backend.base_url.as_str(), "https://bsky.example.com/");
}
#[test]
fn debug_redacts_admin_password() {
let backend = fixture_backend("https://bsky.example.com");
let debug = format!("{backend:?}");
assert!(
!debug.contains("hunter2"),
"Debug must not leak admin password"
);
assert!(
debug.contains("<redacted>"),
"Debug must surface the redaction marker"
);
}
#[test]
fn xrpc_url_appends_xrpc_path_to_root_base() {
let backend = fixture_backend("https://bsky.example.com");
let url = backend
.xrpc_url("com.atproto.admin.updateSubjectStatus")
.unwrap();
assert_eq!(
url.as_str(),
"https://bsky.example.com/xrpc/com.atproto.admin.updateSubjectStatus"
);
}
#[test]
fn xrpc_url_handles_base_with_trailing_slash() {
let backend = fixture_backend("https://bsky.example.com/");
let url = backend
.xrpc_url("com.atproto.admin.updateSubjectStatus")
.unwrap();
assert_eq!(
url.as_str(),
"https://bsky.example.com/xrpc/com.atproto.admin.updateSubjectStatus"
);
}
#[test]
fn xrpc_url_preserves_subpath_with_trailing_slash() {
let backend = fixture_backend("https://example.com/pds/");
let url = backend
.xrpc_url("com.atproto.server.describeServer")
.unwrap();
assert_eq!(
url.as_str(),
"https://example.com/pds/xrpc/com.atproto.server.describeServer"
);
}
#[test]
fn xrpc_url_fixes_up_subpath_without_trailing_slash() {
let backend = fixture_backend("https://example.com/pds");
let url = backend
.xrpc_url("com.atproto.server.describeServer")
.unwrap();
assert_eq!(
url.as_str(),
"https://example.com/pds/xrpc/com.atproto.server.describeServer"
);
}
#[test]
fn basic_auth_header_encodes_admin_credentials() {
let backend = fixture_backend("https://bsky.example.com");
let header = backend.basic_auth_header();
assert_eq!(header, "Basic YWRtaW46aHVudGVyMg==");
}
#[test]
fn basic_auth_header_handles_password_with_colon_and_special_chars() {
let cfg = OzoneBackendConfig {
pds_url: Url::parse("https://bsky.example.com").unwrap(),
admin_password: AdminPassword::new("p@ss:w/ord+special".into()),
request_timeout: Duration::from_secs(10),
};
let backend = OzoneBackend::new(&cfg).unwrap();
let header = backend.basic_auth_header();
let prefix = "Basic ";
assert!(header.starts_with(prefix));
let encoded = &header[prefix.len()..];
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.unwrap();
assert_eq!(decoded, b"admin:p@ss:w/ord+special");
}
#[tokio::test]
async fn apply_label_returns_unsupported() {
let backend = fixture_backend("https://bsky.example.com");
let res = backend
.apply_label(&Subject::account("did:plc:abc"), "spam", None)
.await;
match res {
Err(BackendError::Unsupported(msg)) => {
assert!(
msg.contains("apply_label"),
"message names the method: {msg}"
);
assert!(
msg.contains("§A5") || msg.contains("A5"),
"cites §A5: {msg}"
);
}
other => panic!("expected Unsupported, got {other:?}"),
}
}
#[tokio::test]
async fn negate_label_returns_unsupported() {
let backend = fixture_backend("https://bsky.example.com");
let res = backend
.negate_label(&Subject::account("did:plc:abc"), "spam")
.await;
match res {
Err(BackendError::Unsupported(msg)) => {
assert!(
msg.contains("negate_label"),
"message names the method: {msg}"
);
assert!(
msg.contains("§A5") || msg.contains("A5"),
"cites §A5: {msg}"
);
}
other => panic!("expected Unsupported, got {other:?}"),
}
}
#[test]
fn synthesize_action_id_format() {
let id = synthesize_action_id("did:plc:abc", 42);
assert_eq!(id.as_str(), "ozone:did:plc:abc:42");
}
#[test]
fn synthesize_action_id_handles_negative_action_id() {
let id = synthesize_action_id("did:plc:x", -1);
assert_eq!(id.as_str(), "ozone:did:plc:x:-1");
}
#[test]
fn decode_xrpc_envelope_extracts_code_and_message() {
let body = br#"{"error": "InvalidRequest", "message": "subject DID malformed"}"#;
let (code, message) = decode_xrpc_error_envelope(body).unwrap();
assert_eq!(code, "InvalidRequest");
assert_eq!(message, "subject DID malformed");
}
#[test]
fn decode_xrpc_envelope_message_optional() {
let body = br#"{"error": "Foo"}"#;
let (code, message) = decode_xrpc_error_envelope(body).unwrap();
assert_eq!(code, "Foo");
assert_eq!(message, "");
}
#[test]
fn decode_xrpc_envelope_returns_none_for_malformed_body() {
assert!(decode_xrpc_error_envelope(b"not json").is_none());
assert!(decode_xrpc_error_envelope(b"{}").is_none());
assert!(decode_xrpc_error_envelope(b"[]").is_none());
assert!(decode_xrpc_error_envelope(b"").is_none());
}
#[test]
fn map_status_400_invalid_request_with_subject_message_is_validation() {
let body =
br#"{"error": "InvalidRequest", "message": "subject DID is malformed"}"#.as_slice();
let err = map_status_to_backend_error(reqwest::StatusCode::BAD_REQUEST, body, None);
match err {
BackendError::Validation(msg) => assert!(msg.contains("subject")),
other => panic!("expected Validation, got {other:?}"),
}
}
#[test]
fn map_status_400_invalid_request_without_subject_is_remote_error() {
let body = br#"{"error": "InvalidRequest", "message": "rate limit exceeded"}"#.as_slice();
let err = map_status_to_backend_error(reqwest::StatusCode::BAD_REQUEST, body, None);
match err {
BackendError::RemoteError { code, .. } => assert_eq!(code, "InvalidRequest"),
other => panic!("expected RemoteError, got {other:?}"),
}
}
#[test]
fn map_status_401_is_auth() {
let body = br#"{"error": "AuthenticationRequired", "message": "bad password"}"#.as_slice();
let err = map_status_to_backend_error(reqwest::StatusCode::UNAUTHORIZED, body, None);
match err {
BackendError::Auth(msg) => {
assert!(msg.contains("401"));
assert!(msg.contains("bad password"));
}
other => panic!("expected Auth, got {other:?}"),
}
}
#[test]
fn map_status_403_is_auth() {
let err = map_status_to_backend_error(reqwest::StatusCode::FORBIDDEN, b"forbidden", None);
assert!(matches!(err, BackendError::Auth(_)));
}
#[test]
fn map_status_429_carries_retry_after() {
let body = br#"{"error": "RateLimitExceeded", "message": "calm down"}"#.as_slice();
let err =
map_status_to_backend_error(reqwest::StatusCode::TOO_MANY_REQUESTS, body, Some(30));
match err {
BackendError::RateLimited {
message,
retry_after_seconds,
} => {
assert_eq!(retry_after_seconds, Some(30));
assert_eq!(message, "calm down");
}
other => panic!("expected RateLimited, got {other:?}"),
}
}
#[test]
fn map_status_5xx_transient_is_network() {
for code in [500u16, 502, 503, 504] {
let status = reqwest::StatusCode::from_u16(code).unwrap();
let err = map_status_to_backend_error(status, b"oops", None);
assert!(
matches!(err, BackendError::Network(_)),
"{code} should be Network"
);
}
}
#[test]
fn map_status_other_5xx_is_remote_error() {
let err = map_status_to_backend_error(
reqwest::StatusCode::from_u16(599).unwrap(),
b"unrecognized server failure",
None,
);
assert!(matches!(err, BackendError::RemoteError { .. }));
}
#[test]
fn parse_retry_after_seconds_handles_integer() {
let header = reqwest::header::HeaderValue::from_static("30");
assert_eq!(parse_retry_after_seconds(&header), Some(30));
}
#[test]
fn parse_retry_after_seconds_rejects_http_date() {
let header = reqwest::header::HeaderValue::from_static("Wed, 21 Oct 2026 07:28:00 GMT");
assert_eq!(parse_retry_after_seconds(&header), None);
}
#[test]
fn parse_retry_after_seconds_handles_whitespace() {
let header = reqwest::header::HeaderValue::from_static(" 120 ");
assert_eq!(parse_retry_after_seconds(&header), Some(120));
}
}