diff --git a/.env.example b/.env.example index b44702a..69be63c 100644 --- a/.env.example +++ b/.env.example @@ -53,7 +53,7 @@ AWS_SECRET_ACCESS_KEY=minioadmin # Appview URL for proxying app.bsky.* requests # APPVIEW_URL=https://api.bsky.app # Comma-separated list of relay URLs to notify via requestCrawl -# CRAWLERS=https://bsky.network +# CRAWLERS=https://bsky.network,https://relay.upcloud.world # ============================================================================= # Firehose (subscribeRepos WebSocket) # ============================================================================= diff --git a/README.md b/README.md index fef910c..cbc5aec 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,58 @@ # BSPDS -A production-grade Personal Data Server (PDS) for the AT Protocol. Drop-in replacement for Bluesky's reference PDS, using postgres and s3-compatible blob storage. + +A production-grade Personal Data Server (PDS) for the AT Protocol. Drop-in replacement for Bluesky's reference PDS, written in rust with postgres and s3-compatible blob storage. + ## Features + - Full AT Protocol support (`com.atproto.*` endpoints) - OAuth 2.1 provider (PKCE, DPoP, PAR) - WebSocket firehose (`subscribeRepos`) - Multi-channel notifications (email, discord, telegram, signal) - Built-in web UI for account management - Per-IP rate limiting + ## Quick Start + ```bash cp .env.example .env podman compose up -d just run ``` + ## Configuration + See `.env.example` for all configuration options. + ## Development + Run `just` to see available commands. + ```bash -just test # run tests -just lint # clippy + fmt +just test +just lint ``` + ## Production Deployment + ### Quick Deploy (Docker/Podman Compose) + +Edit `.env.prod` with your values. Generate secrets with `openssl rand -base64 48`. + ```bash cp .env.prod.example .env.prod -# Edit .env.prod with your values (generate secrets with: openssl rand -base64 48) podman-compose -f docker-compose.prod.yml up -d ``` -### Full Installation Guides + +### Installation Guides + | Guide | Best For | |-------|----------| -| **Native Installation** | Maximum performance, full control | | [Debian](docs/install-debian.md) | Debian 13+ with systemd | | [Alpine](docs/install-alpine.md) | Alpine 3.23+ with OpenRC | | [OpenBSD](docs/install-openbsd.md) | OpenBSD 7.8+ with rc.d | -| **Containerized** | Easier updates, isolation | -| [Containers](docs/install-containers.md) | Podman with quadlets (Debian) or OpenRC (Alpine) | -| **Orchestrated** | High availability, auto-scaling | -| [Kubernetes](docs/install-kubernetes.md) | Multi-node k8s cluster deployment | +| [Containers](docs/install-containers.md) | Podman with quadlets or OpenRC | +| [Kubernetes](docs/install-kubernetes.md) | You know what you're doing | + ## License + TBD diff --git a/docs/install-kubernetes.md b/docs/install-kubernetes.md index 437e892..8c00bd1 100644 --- a/docs/install-kubernetes.md +++ b/docs/install-kubernetes.md @@ -7,7 +7,7 @@ If you're reaching for kubernetes for this app, you're experienced enough to kno - s3-compatible object storage (minio operator, or just use a managed service) - the app itself (it's just a container with some env vars) -You'll need a wildcard TLS certificate for `*.your-pds-hostname.example.com` — user handles are served as subdomains. +You'll need a wildcard TLS certificate for `*.your-pds-hostname.example.com`. User handles are served as subdomains. The container image expects: - `DATABASE_URL` - postgres connection string diff --git a/src/api/actor/preferences.rs b/src/api/actor/preferences.rs index cf00256..e97889c 100644 --- a/src/api/actor/preferences.rs +++ b/src/api/actor/preferences.rs @@ -1,12 +1,12 @@ use crate::state::AppState; use axum::{ + Json, extract::State, http::StatusCode, response::{IntoResponse, Response}, - Json, }; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; const APP_BSKY_NAMESPACE: &str = "app.bsky"; const MAX_PREFERENCES_COUNT: usize = 100; @@ -75,7 +75,8 @@ pub async fn get_preferences( let preferences: Vec = prefs .into_iter() .filter(|row| { - row.name == APP_BSKY_NAMESPACE || row.name.starts_with(&format!("{}.", APP_BSKY_NAMESPACE)) + row.name == APP_BSKY_NAMESPACE + || row.name.starts_with(&format!("{}.", APP_BSKY_NAMESPACE)) }) .filter_map(|row| { if row.name == "app.bsky.actor.defs#declaredAgePref" { @@ -221,7 +222,7 @@ pub async fn put_preferences( .into_response(); } } - if let Err(_) = tx.commit().await { + if tx.commit().await.is_err() { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to commit transaction"})), diff --git a/src/api/actor/profile.rs b/src/api/actor/profile.rs index 5ed5abd..9c0c398 100644 --- a/src/api/actor/profile.rs +++ b/src/api/actor/profile.rs @@ -1,14 +1,14 @@ +use crate::api::proxy_client::proxy_client; use crate::state::AppState; use axum::{ + Json, extract::{Query, State}, http::StatusCode, response::{IntoResponse, Response}, - Json, }; use jacquard_repo::storage::BlockStore; -use crate::api::proxy_client::proxy_client; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use std::collections::HashMap; use tracing::{error, info}; @@ -79,9 +79,13 @@ async fn proxy_to_appview( let appview_url = match std::env::var("APPVIEW_URL") { Ok(url) => url, Err(_) => { - return Err( - (StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError", "message": "No upstream AppView configured"}))).into_response() - ); + return Err(( + StatusCode::BAD_GATEWAY, + Json( + json!({"error": "UpstreamError", "message": "No upstream AppView configured"}), + ), + ) + .into_response()); } }; let target_url = format!("{}/xrpc/{}", appview_url, method); @@ -89,34 +93,53 @@ async fn proxy_to_appview( let client = proxy_client(); let mut request_builder = client.get(&target_url).query(params); if let Some(key_bytes) = auth_key_bytes { - let appview_did = std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string()); + let appview_did = + std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string()); match crate::auth::create_service_token(auth_did, &appview_did, method, key_bytes) { Ok(service_token) => { - request_builder = request_builder.header("Authorization", format!("Bearer {}", service_token)); + request_builder = + request_builder.header("Authorization", format!("Bearer {}", service_token)); } Err(e) => { error!("Failed to create service token: {:?}", e); - return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"}))).into_response()); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response()); } } } match request_builder.send().await { Ok(resp) => { - let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + let status = + StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); match resp.json::().await { Ok(body) => Ok((status, body)), Err(e) => { error!("Error parsing proxy response: {:?}", e); - Err((StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError"}))).into_response()) + Err(( + StatusCode::BAD_GATEWAY, + Json(json!({"error": "UpstreamError"})), + ) + .into_response()) } } } Err(e) => { error!("Error sending proxy request: {:?}", e); if e.is_timeout() { - Err((StatusCode::GATEWAY_TIMEOUT, Json(json!({"error": "UpstreamTimeout"}))).into_response()) + Err(( + StatusCode::GATEWAY_TIMEOUT, + Json(json!({"error": "UpstreamTimeout"})), + ) + .into_response()) } else { - Err((StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError"}))).into_response()) + Err(( + StatusCode::BAD_GATEWAY, + Json(json!({"error": "UpstreamError"})), + ) + .into_response()) } } } @@ -130,7 +153,9 @@ pub async fn get_profile( let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok()); let auth_user = if let Some(h) = auth_header { if let Some(token) = crate::auth::extract_bearer_token_from_header(Some(h)) { - crate::auth::validate_bearer_token(&state.db, &token).await.ok() + crate::auth::validate_bearer_token(&state.db, &token) + .await + .ok() } else { None } @@ -141,7 +166,14 @@ pub async fn get_profile( let auth_key_bytes = auth_user.as_ref().and_then(|u| u.key_bytes.clone()); let mut query_params = HashMap::new(); query_params.insert("actor".to_string(), params.actor.clone()); - let (status, body) = match proxy_to_appview("app.bsky.actor.getProfile", &query_params, auth_did.as_deref().unwrap_or(""), auth_key_bytes.as_deref()).await { + let (status, body) = match proxy_to_appview( + "app.bsky.actor.getProfile", + &query_params, + auth_did.as_deref().unwrap_or(""), + auth_key_bytes.as_deref(), + ) + .await + { Ok(r) => r, Err(e) => return e, }; @@ -151,16 +183,18 @@ pub async fn get_profile( let mut profile: ProfileViewDetailed = match serde_json::from_value(body) { Ok(p) => p, Err(_) => { - return (StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError", "message": "Invalid profile response"}))).into_response(); + return ( + StatusCode::BAD_GATEWAY, + Json(json!({"error": "UpstreamError", "message": "Invalid profile response"})), + ) + .into_response(); } }; - if let Some(ref did) = auth_did { - if profile.did == *did { - if let Some(local_record) = get_local_profile_record(&state, did).await { + if let Some(ref did) = auth_did + && profile.did == *did + && let Some(local_record) = get_local_profile_record(&state, did).await { munge_profile_with_local(&mut profile, &local_record); } - } - } (StatusCode::OK, Json(profile)).into_response() } @@ -172,7 +206,9 @@ pub async fn get_profiles( let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok()); let auth_user = if let Some(h) = auth_header { if let Some(token) = crate::auth::extract_bearer_token_from_header(Some(h)) { - crate::auth::validate_bearer_token(&state.db, &token).await.ok() + crate::auth::validate_bearer_token(&state.db, &token) + .await + .ok() } else { None } @@ -183,7 +219,14 @@ pub async fn get_profiles( let auth_key_bytes = auth_user.as_ref().and_then(|u| u.key_bytes.clone()); let mut query_params = HashMap::new(); query_params.insert("actors".to_string(), params.actors.clone()); - let (status, body) = match proxy_to_appview("app.bsky.actor.getProfiles", &query_params, auth_did.as_deref().unwrap_or(""), auth_key_bytes.as_deref()).await { + let (status, body) = match proxy_to_appview( + "app.bsky.actor.getProfiles", + &query_params, + auth_did.as_deref().unwrap_or(""), + auth_key_bytes.as_deref(), + ) + .await + { Ok(r) => r, Err(e) => return e, }; @@ -193,7 +236,11 @@ pub async fn get_profiles( let mut output: GetProfilesOutput = match serde_json::from_value(body) { Ok(p) => p, Err(_) => { - return (StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError", "message": "Invalid profiles response"}))).into_response(); + return ( + StatusCode::BAD_GATEWAY, + Json(json!({"error": "UpstreamError", "message": "Invalid profiles response"})), + ) + .into_response(); } }; if let Some(ref did) = auth_did { diff --git a/src/api/admin/account/delete.rs b/src/api/admin/account/delete.rs index 7446037..eab59f5 100644 --- a/src/api/admin/account/delete.rs +++ b/src/api/admin/account/delete.rs @@ -121,24 +121,39 @@ pub async fn delete_account( .execute(&mut *tx) .await { - error!("Failed to delete app passwords for user {}: {:?}", user_id, e); + error!( + "Failed to delete app passwords for user {}: {:?}", + user_id, e + ); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to delete app passwords"})), ) .into_response(); } - if let Err(e) = sqlx::query!("DELETE FROM invite_code_uses WHERE used_by_user = $1", user_id) - .execute(&mut *tx) - .await + if let Err(e) = sqlx::query!( + "DELETE FROM invite_code_uses WHERE used_by_user = $1", + user_id + ) + .execute(&mut *tx) + .await { - error!("Failed to delete invite code uses for user {}: {:?}", user_id, e); + error!( + "Failed to delete invite code uses for user {}: {:?}", + user_id, e + ); } - if let Err(e) = sqlx::query!("DELETE FROM invite_codes WHERE created_by_user = $1", user_id) - .execute(&mut *tx) - .await + if let Err(e) = sqlx::query!( + "DELETE FROM invite_codes WHERE created_by_user = $1", + user_id + ) + .execute(&mut *tx) + .await { - error!("Failed to delete invite codes for user {}: {:?}", user_id, e); + error!( + "Failed to delete invite codes for user {}: {:?}", + user_id, e + ); } if let Err(e) = sqlx::query!("DELETE FROM user_keys WHERE user_id = $1", user_id) .execute(&mut *tx) @@ -170,8 +185,13 @@ pub async fn delete_account( ) .into_response(); } - if let Err(e) = crate::api::repo::record::sequence_account_event(&state, did, false, Some("deleted")).await { - warn!("Failed to sequence account deletion event for {}: {}", did, e); + if let Err(e) = + crate::api::repo::record::sequence_account_event(&state, did, false, Some("deleted")).await + { + warn!( + "Failed to sequence account deletion event for {}: {}", + did, e + ); } let _ = state.cache.delete(&format!("handle:{}", handle)).await; (StatusCode::OK, Json(json!({}))).into_response() diff --git a/src/api/admin/account/email.rs b/src/api/admin/account/email.rs index 896d5d7..e2a52f5 100644 --- a/src/api/admin/account/email.rs +++ b/src/api/admin/account/email.rs @@ -104,11 +104,7 @@ pub async fn send_email( let result = crate::notifications::enqueue_notification(&state.db, notification).await; match result { Ok(_) => { - tracing::info!( - "Admin email queued for {} ({})", - handle, - recipient_did - ); + tracing::info!("Admin email queued for {} ({})", handle, recipient_did); (StatusCode::OK, Json(SendEmailOutput { sent: true })).into_response() } Err(e) => { diff --git a/src/api/admin/account/info.rs b/src/api/admin/account/info.rs index 39439fd..b0c4541 100644 --- a/src/api/admin/account/info.rs +++ b/src/api/admin/account/info.rs @@ -65,22 +65,20 @@ pub async fn get_account_info( .fetch_optional(&state.db) .await; match result { - Ok(Some(row)) => { - ( - StatusCode::OK, - Json(AccountInfo { - did: row.did, - handle: row.handle, - email: row.email, - indexed_at: row.created_at.to_rfc3339(), - invite_note: None, - invites_disabled: false, - email_confirmed_at: None, - deactivated_at: None, - }), - ) - .into_response() - } + Ok(Some(row)) => ( + StatusCode::OK, + Json(AccountInfo { + did: row.did, + handle: row.handle, + email: row.email, + indexed_at: row.created_at.to_rfc3339(), + invite_note: None, + invites_disabled: false, + email_confirmed_at: None, + deactivated_at: None, + }), + ) + .into_response(), Ok(None) => ( StatusCode::NOT_FOUND, Json(json!({"error": "AccountNotFound", "message": "Account not found"})), diff --git a/src/api/admin/account/mod.rs b/src/api/admin/account/mod.rs index e02fe9f..511f803 100644 --- a/src/api/admin/account/mod.rs +++ b/src/api/admin/account/mod.rs @@ -4,14 +4,17 @@ mod info; mod profile; mod update; -pub use delete::{delete_account, DeleteAccountInput}; -pub use email::{send_email, SendEmailInput, SendEmailOutput}; +pub use delete::{DeleteAccountInput, delete_account}; +pub use email::{SendEmailInput, SendEmailOutput, send_email}; pub use info::{ - get_account_info, get_account_infos, AccountInfo, GetAccountInfoParams, GetAccountInfosOutput, - GetAccountInfosParams, + AccountInfo, GetAccountInfoParams, GetAccountInfosOutput, GetAccountInfosParams, + get_account_info, get_account_infos, +}; +pub use profile::{ + CreateProfileInput, CreateProfileOutput, CreateRecordAdminInput, create_profile, + create_record_admin, }; -pub use profile::{create_profile, create_record_admin, CreateProfileInput, CreateProfileOutput, CreateRecordAdminInput}; pub use update::{ - update_account_email, update_account_handle, update_account_password, UpdateAccountEmailInput, - UpdateAccountHandleInput, UpdateAccountPasswordInput, + UpdateAccountEmailInput, UpdateAccountHandleInput, UpdateAccountPasswordInput, + update_account_email, update_account_handle, update_account_password, }; diff --git a/src/api/admin/account/profile.rs b/src/api/admin/account/profile.rs index fbace0d..7f94311 100644 --- a/src/api/admin/account/profile.rs +++ b/src/api/admin/account/profile.rs @@ -74,7 +74,9 @@ pub async fn create_profile( "app.bsky.actor.profile", "self", &profile_record, - ).await { + ) + .await + { Ok((uri, commit_cid)) => { info!(did = %did, uri = %uri, "Created profile for user"); ( @@ -120,17 +122,11 @@ pub async fn create_record_admin( .into_response(); } - let rkey = input.rkey.unwrap_or_else(|| { - chrono::Utc::now().format("%Y%m%d%H%M%S%f").to_string() - }); + let rkey = input + .rkey + .unwrap_or_else(|| chrono::Utc::now().format("%Y%m%d%H%M%S%f").to_string()); - match create_record_internal( - &state, - did, - &input.collection, - &rkey, - &input.record, - ).await { + match create_record_internal(&state, did, &input.collection, &rkey, &input.record).await { Ok((uri, commit_cid)) => { info!(did = %did, uri = %uri, "Admin created record"); ( diff --git a/src/api/admin/account/update.rs b/src/api/admin/account/update.rs index 54da6fd..3afb0af 100644 --- a/src/api/admin/account/update.rs +++ b/src/api/admin/account/update.rs @@ -96,7 +96,9 @@ pub async fn update_account_handle( { return ( StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"})), + Json( + json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"}), + ), ) .into_response(); } @@ -105,9 +107,13 @@ pub async fn update_account_handle( .await .ok() .flatten(); - let existing = sqlx::query!("SELECT id FROM users WHERE handle = $1 AND did != $2", handle, did) - .fetch_optional(&state.db) - .await; + let existing = sqlx::query!( + "SELECT id FROM users WHERE handle = $1 AND did != $2", + handle, + did + ) + .fetch_optional(&state.db) + .await; if let Ok(Some(_)) = existing { return ( StatusCode::BAD_REQUEST, @@ -183,9 +189,13 @@ pub async fn update_account_password( .into_response(); } }; - let result = sqlx::query!("UPDATE users SET password_hash = $1 WHERE did = $2", password_hash, did) - .execute(&state.db) - .await; + let result = sqlx::query!( + "UPDATE users SET password_hash = $1 WHERE did = $2", + password_hash, + did + ) + .execute(&state.db) + .await; match result { Ok(r) => { if r.rows_affected() == 0 { diff --git a/src/api/admin/invite.rs b/src/api/admin/invite.rs index ee26acb..15f4381 100644 --- a/src/api/admin/invite.rs +++ b/src/api/admin/invite.rs @@ -31,9 +31,12 @@ pub async fn disable_invite_codes( } if let Some(codes) = &input.codes { for code in codes { - let _ = sqlx::query!("UPDATE invite_codes SET disabled = TRUE WHERE code = $1", code) - .execute(&state.db) - .await; + let _ = sqlx::query!( + "UPDATE invite_codes SET disabled = TRUE WHERE code = $1", + code + ) + .execute(&state.db) + .await; } } if let Some(accounts) = &input.accounts { @@ -106,7 +109,16 @@ pub async fn get_invite_codes( _ => "created_at DESC", }; let codes_result = if let Some(cursor) = ¶ms.cursor { - sqlx::query_as::<_, (String, i32, Option, uuid::Uuid, chrono::DateTime)>(&format!( + sqlx::query_as::< + _, + ( + String, + i32, + Option, + uuid::Uuid, + chrono::DateTime, + ), + >(&format!( r#" SELECT ic.code, ic.available_uses, ic.disabled, ic.created_by_user, ic.created_at FROM invite_codes ic @@ -121,7 +133,16 @@ pub async fn get_invite_codes( .fetch_all(&state.db) .await } else { - sqlx::query_as::<_, (String, i32, Option, uuid::Uuid, chrono::DateTime)>(&format!( + sqlx::query_as::< + _, + ( + String, + i32, + Option, + uuid::Uuid, + chrono::DateTime, + ), + >(&format!( r#" SELECT ic.code, ic.available_uses, ic.disabled, ic.created_by_user, ic.created_at FROM invite_codes ic @@ -147,12 +168,13 @@ pub async fn get_invite_codes( }; let mut codes = Vec::new(); for (code, available_uses, disabled, created_by_user, created_at) in &codes_rows { - let creator_did = sqlx::query_scalar!("SELECT did FROM users WHERE id = $1", created_by_user) - .fetch_optional(&state.db) - .await - .ok() - .flatten() - .unwrap_or_else(|| "unknown".to_string()); + let creator_did = + sqlx::query_scalar!("SELECT did FROM users WHERE id = $1", created_by_user) + .fetch_optional(&state.db) + .await + .ok() + .flatten() + .unwrap_or_else(|| "unknown".to_string()); let uses_result = sqlx::query!( r#" SELECT u.did, icu.used_at @@ -226,9 +248,12 @@ pub async fn disable_account_invites( ) .into_response(); } - let result = sqlx::query!("UPDATE users SET invites_disabled = TRUE WHERE did = $1", account) - .execute(&state.db) - .await; + let result = sqlx::query!( + "UPDATE users SET invites_disabled = TRUE WHERE did = $1", + account + ) + .execute(&state.db) + .await; match result { Ok(r) => { if r.rows_affected() == 0 { @@ -277,9 +302,12 @@ pub async fn enable_account_invites( ) .into_response(); } - let result = sqlx::query!("UPDATE users SET invites_disabled = FALSE WHERE did = $1", account) - .execute(&state.db) - .await; + let result = sqlx::query!( + "UPDATE users SET invites_disabled = FALSE WHERE did = $1", + account + ) + .execute(&state.db) + .await; match result { Ok(r) => { if r.rows_affected() == 0 { diff --git a/src/api/admin/status.rs b/src/api/admin/status.rs index f1560f5..5b2a58c 100644 --- a/src/api/admin/status.rs +++ b/src/api/admin/status.rs @@ -142,9 +142,12 @@ pub async fn get_subject_status( } } if let Some(blob_cid) = ¶ms.blob { - let blob = sqlx::query!("SELECT cid, takedown_ref FROM blobs WHERE cid = $1", blob_cid) - .fetch_optional(&state.db) - .await; + let blob = sqlx::query!( + "SELECT cid, takedown_ref FROM blobs WHERE cid = $1", + blob_cid + ) + .fetch_optional(&state.db) + .await; match blob { Ok(Some(row)) => { let takedown = row.takedown_ref.as_ref().map(|r| StatusAttr { @@ -263,15 +266,15 @@ pub async fn update_subject_status( .execute(&mut *tx) .await } else { - sqlx::query!( - "UPDATE users SET deactivated_at = NULL WHERE did = $1", - did - ) - .execute(&mut *tx) - .await + sqlx::query!("UPDATE users SET deactivated_at = NULL WHERE did = $1", did) + .execute(&mut *tx) + .await }; if let Err(e) = result { - error!("Failed to update user deactivation status for {}: {:?}", did, e); + error!( + "Failed to update user deactivation status for {}: {:?}", + did, e + ); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to update deactivation status"})), @@ -288,20 +291,43 @@ pub async fn update_subject_status( .into_response(); } if let Some(takedown) = &input.takedown { - let status = if takedown.apply { Some("takendown") } else { None }; - if let Err(e) = crate::api::repo::record::sequence_account_event(&state, did, !takedown.apply, status).await { + let status = if takedown.apply { + Some("takendown") + } else { + None + }; + if let Err(e) = crate::api::repo::record::sequence_account_event( + &state, + did, + !takedown.apply, + status, + ) + .await + { warn!("Failed to sequence account event for takedown: {}", e); } } if let Some(deactivated) = &input.deactivated { - let status = if deactivated.apply { Some("deactivated") } else { None }; - if let Err(e) = crate::api::repo::record::sequence_account_event(&state, did, !deactivated.apply, status).await { + let status = if deactivated.apply { + Some("deactivated") + } else { + None + }; + if let Err(e) = crate::api::repo::record::sequence_account_event( + &state, + did, + !deactivated.apply, + status, + ) + .await + { warn!("Failed to sequence account event for deactivation: {}", e); } } - if let Ok(Some(handle)) = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did) - .fetch_optional(&state.db) - .await + if let Ok(Some(handle)) = + sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did) + .fetch_optional(&state.db) + .await { let _ = state.cache.delete(&format!("handle:{}", handle)).await; } @@ -338,7 +364,10 @@ pub async fn update_subject_status( .execute(&state.db) .await { - error!("Failed to update record takedown status for {}: {:?}", uri, e); + error!( + "Failed to update record takedown status for {}: {:?}", + uri, e + ); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to update takedown status"})), diff --git a/src/api/error.rs b/src/api/error.rs index 2586a5f..2567c34 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -46,7 +46,11 @@ pub enum ApiError { UpstreamFailure, UpstreamTimeout, UpstreamUnavailable(String), - UpstreamError { status: u16, error: Option, message: Option }, + UpstreamError { + status: u16, + error: Option, + message: Option, + }, } impl ApiError { @@ -135,16 +139,27 @@ impl ApiError { _ => None, } } - pub fn from_upstream_response( - status: u16, - body: &[u8], - ) -> Self { + pub fn from_upstream_response(status: u16, body: &[u8]) -> Self { if let Ok(parsed) = serde_json::from_slice::(body) { - let error = parsed.get("error").and_then(|v| v.as_str()).map(String::from); - let message = parsed.get("message").and_then(|v| v.as_str()).map(String::from); - return Self::UpstreamError { status, error, message }; + let error = parsed + .get("error") + .and_then(|v| v.as_str()) + .map(String::from); + let message = parsed + .get("message") + .and_then(|v| v.as_str()) + .map(String::from); + return Self::UpstreamError { + status, + error, + message, + }; + } + Self::UpstreamError { + status, + error: None, + message: None, } - Self::UpstreamError { status, error: None, message: None } } } diff --git a/src/api/feed/actor_likes.rs b/src/api/feed/actor_likes.rs index fad6794..3dc710c 100644 --- a/src/api/feed/actor_likes.rs +++ b/src/api/feed/actor_likes.rs @@ -1,13 +1,13 @@ use crate::api::read_after_write::{ - extract_repo_rev, format_munged_response, get_local_lag, get_records_since_rev, - proxy_to_appview, FeedOutput, FeedViewPost, LikeRecord, PostView, RecordDescript, + FeedOutput, FeedViewPost, LikeRecord, PostView, RecordDescript, extract_repo_rev, + format_munged_response, get_local_lag, get_records_since_rev, proxy_to_appview, }; use crate::state::AppState; use axum::{ + Json, extract::{Query, State}, http::StatusCode, response::{IntoResponse, Response}, - Json, }; use serde::Deserialize; use serde_json::Value; @@ -68,7 +68,9 @@ pub async fn get_actor_likes( let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok()); let auth_user = if let Some(h) = auth_header { if let Some(token) = crate::auth::extract_bearer_token_from_header(Some(h)) { - crate::auth::validate_bearer_token(&state.db, &token).await.ok() + crate::auth::validate_bearer_token(&state.db, &token) + .await + .ok() } else { None } @@ -85,11 +87,17 @@ pub async fn get_actor_likes( if let Some(cursor) = ¶ms.cursor { query_params.insert("cursor".to_string(), cursor.clone()); } - let proxy_result = - match proxy_to_appview("app.bsky.feed.getActorLikes", &query_params, auth_did.as_deref().unwrap_or(""), auth_key_bytes.as_deref()).await { - Ok(r) => r, - Err(e) => return e, - }; + let proxy_result = match proxy_to_appview( + "app.bsky.feed.getActorLikes", + &query_params, + auth_did.as_deref().unwrap_or(""), + auth_key_bytes.as_deref(), + ) + .await + { + Ok(r) => r, + Err(e) => return e, + }; if !proxy_result.status.is_success() { return proxy_result.into_response(); } diff --git a/src/api/feed/author_feed.rs b/src/api/feed/author_feed.rs index 22645f6..a282638 100644 --- a/src/api/feed/author_feed.rs +++ b/src/api/feed/author_feed.rs @@ -1,14 +1,14 @@ use crate::api::read_after_write::{ - extract_repo_rev, format_local_post, format_munged_response, get_local_lag, - get_records_since_rev, insert_posts_into_feed, proxy_to_appview, FeedOutput, FeedViewPost, - ProfileRecord, RecordDescript, + FeedOutput, FeedViewPost, ProfileRecord, RecordDescript, extract_repo_rev, format_local_post, + format_munged_response, get_local_lag, get_records_since_rev, insert_posts_into_feed, + proxy_to_appview, }; use crate::state::AppState; use axum::{ + Json, extract::{Query, State}, http::StatusCode, response::{IntoResponse, Response}, - Json, }; use serde::Deserialize; use std::collections::HashMap; @@ -30,11 +30,10 @@ fn update_author_profile_in_feed( local_profile: &RecordDescript, ) { for item in feed.iter_mut() { - if item.post.author.did == author_did { - if let Some(ref display_name) = local_profile.record.display_name { + if item.post.author.did == author_did + && let Some(ref display_name) = local_profile.record.display_name { item.post.author.display_name = Some(display_name.clone()); } - } } } @@ -46,7 +45,9 @@ pub async fn get_author_feed( let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok()); let auth_user = if let Some(h) = auth_header { if let Some(token) = crate::auth::extract_bearer_token_from_header(Some(h)) { - crate::auth::validate_bearer_token(&state.db, &token).await.ok() + crate::auth::validate_bearer_token(&state.db, &token) + .await + .ok() } else { None } @@ -69,11 +70,17 @@ pub async fn get_author_feed( if let Some(include_pins) = params.include_pins { query_params.insert("includePins".to_string(), include_pins.to_string()); } - let proxy_result = - match proxy_to_appview("app.bsky.feed.getAuthorFeed", &query_params, auth_did.as_deref().unwrap_or(""), auth_key_bytes.as_deref()).await { - Ok(r) => r, - Err(e) => return e, - }; + let proxy_result = match proxy_to_appview( + "app.bsky.feed.getAuthorFeed", + &query_params, + auth_did.as_deref().unwrap_or(""), + auth_key_bytes.as_deref(), + ) + .await + { + Ok(r) => r, + Err(e) => return e, + }; if !proxy_result.status.is_success() { return proxy_result.into_response(); } @@ -144,14 +151,7 @@ pub async fn get_author_feed( let local_posts: Vec<_> = local_records .posts .iter() - .map(|p| { - format_local_post( - p, - &requester_did, - &handle, - local_records.profile.as_ref(), - ) - }) + .map(|p| format_local_post(p, &requester_did, &handle, local_records.profile.as_ref())) .collect(); insert_posts_into_feed(&mut feed_output.feed, local_posts); let lag = get_local_lag(&local_records); diff --git a/src/api/feed/custom_feed.rs b/src/api/feed/custom_feed.rs index 4f7984c..40202b8 100644 --- a/src/api/feed/custom_feed.rs +++ b/src/api/feed/custom_feed.rs @@ -1,7 +1,7 @@ -use crate::api::proxy_client::{ - is_ssrf_safe, proxy_client, validate_at_uri, validate_limit, MAX_RESPONSE_SIZE, -}; use crate::api::ApiError; +use crate::api::proxy_client::{ + MAX_RESPONSE_SIZE, is_ssrf_safe, proxy_client, validate_at_uri, validate_limit, +}; use crate::state::AppState; use axum::{ extract::{Query, State}, @@ -61,10 +61,17 @@ pub async fn get_feed( let client = proxy_client(); let mut request_builder = client.get(&target_url).query(&query_params); if let Some(key_bytes) = auth_user.key_bytes.as_ref() { - let appview_did = std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string()); - match crate::auth::create_service_token(&auth_user.did, &appview_did, "app.bsky.feed.getFeed", key_bytes) { + let appview_did = + std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string()); + match crate::auth::create_service_token( + &auth_user.did, + &appview_did, + "app.bsky.feed.getFeed", + key_bytes, + ) { Ok(service_token) => { - request_builder = request_builder.header("Authorization", format!("Bearer {}", service_token)); + request_builder = + request_builder.header("Authorization", format!("Bearer {}", service_token)); } Err(e) => { error!(error = ?e, "Failed to create service token for getFeed"); diff --git a/src/api/feed/post_thread.rs b/src/api/feed/post_thread.rs index 9fa106c..a7c85e6 100644 --- a/src/api/feed/post_thread.rs +++ b/src/api/feed/post_thread.rs @@ -1,16 +1,16 @@ use crate::api::read_after_write::{ - extract_repo_rev, format_local_post, format_munged_response, get_local_lag, - get_records_since_rev, proxy_to_appview, PostRecord, PostView, RecordDescript, + PostRecord, PostView, RecordDescript, extract_repo_rev, format_local_post, + format_munged_response, get_local_lag, get_records_since_rev, proxy_to_appview, }; use crate::state::AppState; use axum::{ + Json, extract::{Query, State}, http::StatusCode, response::{IntoResponse, Response}, - Json, }; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use std::collections::HashMap; use tracing::warn; @@ -39,7 +39,7 @@ pub struct ThreadViewPost { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum ThreadNode { - Post(ThreadViewPost), + Post(Box), NotFound(ThreadNotFound), Blocked(ThreadBlocked), } @@ -96,13 +96,13 @@ fn add_replies_to_thread( }) .map(|p| { let post_view = format_local_post(p, author_did, author_handle, None); - ThreadNode::Post(ThreadViewPost { + ThreadNode::Post(Box::new(ThreadViewPost { thread_type: Some("app.bsky.feed.defs#threadViewPost".to_string()), post: post_view, parent: None, replies: None, extra: HashMap::new(), - }) + })) }) .collect(); if !replies.is_empty() { @@ -114,7 +114,13 @@ fn add_replies_to_thread( if let Some(ref mut existing_replies) = thread.replies { for reply in existing_replies.iter_mut() { if let ThreadNode::Post(reply_thread) = reply { - add_replies_to_thread(reply_thread, local_posts, author_did, author_handle, depth + 1); + add_replies_to_thread( + reply_thread, + local_posts, + author_did, + author_handle, + depth + 1, + ); } } } @@ -128,7 +134,9 @@ pub async fn get_post_thread( let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok()); let auth_user = if let Some(h) = auth_header { if let Some(token) = crate::auth::extract_bearer_token_from_header(Some(h)) { - crate::auth::validate_bearer_token(&state.db, &token).await.ok() + crate::auth::validate_bearer_token(&state.db, &token) + .await + .ok() } else { None } @@ -145,11 +153,17 @@ pub async fn get_post_thread( if let Some(parent_height) = params.parent_height { query_params.insert("parentHeight".to_string(), parent_height.to_string()); } - let proxy_result = - match proxy_to_appview("app.bsky.feed.getPostThread", &query_params, auth_did.as_deref().unwrap_or(""), auth_key_bytes.as_deref()).await { - Ok(r) => r, - Err(e) => return e, - }; + let proxy_result = match proxy_to_appview( + "app.bsky.feed.getPostThread", + &query_params, + auth_did.as_deref().unwrap_or(""), + auth_key_bytes.as_deref(), + ) + .await + { + Ok(r) => r, + Err(e) => return e, + }; if proxy_result.status == StatusCode::NOT_FOUND { return handle_not_found(&state, ¶ms.uri, auth_did, &proxy_result.headers).await; } @@ -193,7 +207,13 @@ pub async fn get_post_thread( } }; if let ThreadNode::Post(ref mut thread_post) = thread_output.thread { - add_replies_to_thread(thread_post, &local_records.posts, &requester_did, &handle, 0); + add_replies_to_thread( + thread_post, + &local_records.posts, + &requester_did, + &handle, + 0, + ); } let lag = get_local_lag(&local_records); format_munged_response(thread_output, lag) @@ -212,7 +232,7 @@ async fn handle_not_found( StatusCode::NOT_FOUND, Json(json!({"error": "NotFound", "message": "Post not found"})), ) - .into_response() + .into_response(); } }; let requester_did = match auth_did { @@ -222,7 +242,7 @@ async fn handle_not_found( StatusCode::NOT_FOUND, Json(json!({"error": "NotFound", "message": "Post not found"})), ) - .into_response() + .into_response(); } }; let uri_parts: Vec<&str> = uri.trim_start_matches("at://").split('/').collect(); @@ -248,7 +268,7 @@ async fn handle_not_found( StatusCode::NOT_FOUND, Json(json!({"error": "NotFound", "message": "Post not found"})), ) - .into_response() + .into_response(); } }; let local_post = local_records.posts.iter().find(|p| p.uri == uri); @@ -259,7 +279,7 @@ async fn handle_not_found( StatusCode::NOT_FOUND, Json(json!({"error": "NotFound", "message": "Post not found"})), ) - .into_response() + .into_response(); } }; let handle = match sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", requester_did) @@ -280,13 +300,13 @@ async fn handle_not_found( local_records.profile.as_ref(), ); let thread = PostThreadOutput { - thread: ThreadNode::Post(ThreadViewPost { + thread: ThreadNode::Post(Box::new(ThreadViewPost { thread_type: Some("app.bsky.feed.defs#threadViewPost".to_string()), post: post_view, parent: None, replies: None, extra: HashMap::new(), - }), + })), threadgate: None, }; let lag = get_local_lag(&local_records); diff --git a/src/api/feed/timeline.rs b/src/api/feed/timeline.rs index d9dcb59..e05af1f 100644 --- a/src/api/feed/timeline.rs +++ b/src/api/feed/timeline.rs @@ -1,18 +1,18 @@ use crate::api::read_after_write::{ - extract_repo_rev, format_local_post, format_munged_response, get_local_lag, - get_records_since_rev, insert_posts_into_feed, proxy_to_appview, FeedOutput, FeedViewPost, - PostView, + FeedOutput, FeedViewPost, PostView, extract_repo_rev, format_local_post, + format_munged_response, get_local_lag, get_records_since_rev, insert_posts_into_feed, + proxy_to_appview, }; use crate::state::AppState; use axum::{ + Json, extract::{Query, State}, http::StatusCode, response::{IntoResponse, Response}, - Json, }; use jacquard_repo::storage::BlockStore; use serde::Deserialize; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use std::collections::HashMap; use tracing::warn; @@ -52,7 +52,13 @@ pub async fn get_timeline( }; match std::env::var("APPVIEW_URL") { Ok(url) if !url.starts_with("http://127.0.0.1") => { - return get_timeline_with_appview(&state, ¶ms, &auth_user.did, auth_user.key_bytes.as_deref()).await; + return get_timeline_with_appview( + &state, + ¶ms, + &auth_user.did, + auth_user.key_bytes.as_deref(), + ) + .await; } _ => {} } @@ -75,11 +81,17 @@ async fn get_timeline_with_appview( if let Some(cursor) = ¶ms.cursor { query_params.insert("cursor".to_string(), cursor.clone()); } - let proxy_result = - match proxy_to_appview("app.bsky.feed.getTimeline", &query_params, auth_did, auth_key_bytes).await { - Ok(r) => r, - Err(e) => return e, - }; + let proxy_result = match proxy_to_appview( + "app.bsky.feed.getTimeline", + &query_params, + auth_did, + auth_key_bytes, + ) + .await + { + Ok(r) => r, + Err(e) => return e, + }; if !proxy_result.status.is_success() { return proxy_result.into_response(); } @@ -127,30 +139,28 @@ async fn get_timeline_with_appview( } async fn get_timeline_local_only(state: &AppState, auth_did: &str) -> Response { - let user_id: uuid::Uuid = match sqlx::query_scalar!( - "SELECT id FROM users WHERE did = $1", - auth_did - ) - .fetch_optional(&state.db) - .await - { - Ok(Some(id)) => id, - Ok(None) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "User not found"})), - ) - .into_response(); - } - Err(e) => { - warn!("Database error fetching user: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Database error"})), - ) - .into_response(); - } - }; + let user_id: uuid::Uuid = + match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_did) + .fetch_optional(&state.db) + .await + { + Ok(Some(id)) => id, + Ok(None) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "User not found"})), + ) + .into_response(); + } + Err(e) => { + warn!("Database error fetching user: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Database error"})), + ) + .into_response(); + } + }; let follows_query = sqlx::query!( "SELECT record_cid FROM records WHERE repo_id = $1 AND collection = 'app.bsky.graph.follow' LIMIT 5000", user_id diff --git a/src/api/identity/account.rs b/src/api/identity/account.rs index 90ab475..81cb16e 100644 --- a/src/api/identity/account.rs +++ b/src/api/identity/account.rs @@ -1,5 +1,5 @@ use super::did::verify_did_web; -use crate::plc::{create_genesis_operation, signing_key_to_did_key, PlcClient}; +use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key}; use crate::state::{AppState, RateLimitKind}; use axum::{ Json, @@ -10,7 +10,7 @@ use axum::{ use bcrypt::{DEFAULT_COST, hash}; use jacquard::types::{did::Did, integer::LimitedU32, string::Tid}; use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore}; -use k256::{ecdsa::SigningKey, SecretKey}; +use k256::{SecretKey, ecdsa::SigningKey}; use rand::rngs::OsRng; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -18,18 +18,15 @@ use std::sync::Arc; use tracing::{error, info, warn}; fn extract_client_ip(headers: &HeaderMap) -> String { - if let Some(forwarded) = headers.get("x-forwarded-for") { - if let Ok(value) = forwarded.to_str() { - if let Some(first_ip) = value.split(',').next() { + if let Some(forwarded) = headers.get("x-forwarded-for") + && let Ok(value) = forwarded.to_str() + && let Some(first_ip) = value.split(',').next() { return first_ip.trim().to_string(); } - } - } - if let Some(real_ip) = headers.get("x-real-ip") { - if let Ok(value) = real_ip.to_str() { + if let Some(real_ip) = headers.get("x-real-ip") + && let Ok(value) = real_ip.to_str() { return value.trim().to_string(); } - } "unknown".to_string() } @@ -64,7 +61,10 @@ pub async fn create_account( ) -> Response { info!("create_account called"); let client_ip = extract_client_ip(&headers); - if !state.check_rate_limit(RateLimitKind::AccountCreation, &client_ip).await { + if !state + .check_rate_limit(RateLimitKind::AccountCreation, &client_ip) + .await + { warn!(ip = %client_ip, "Account creation rate limit exceeded"); return ( StatusCode::TOO_MANY_REQUESTS, @@ -84,18 +84,19 @@ pub async fn create_account( ) .into_response(); } - let email: Option = input.email.as_ref() + let email: Option = input + .email + .as_ref() .map(|e| e.trim().to_string()) .filter(|e| !e.is_empty()); - if let Some(ref email) = email { - if !crate::api::validation::is_valid_email(email) { + if let Some(ref email) = email + && !crate::api::validation::is_valid_email(email) { return ( StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})), ) .into_response(); } - } let verification_channel = input.verification_channel.as_deref().unwrap_or("email"); let valid_channels = ["email", "discord", "telegram", "signal"]; if !valid_channels.contains(&verification_channel) { @@ -220,7 +221,10 @@ pub async fn create_account( } }; let plc_client = PlcClient::new(None); - if let Err(e) = plc_client.send_operation(&genesis_result.did, &genesis_result.signed_operation).await { + if let Err(e) = plc_client + .send_operation(&genesis_result.did, &genesis_result.signed_operation) + .await + { error!("Failed to submit PLC genesis operation: {:?}", e); return ( StatusCode::BAD_GATEWAY, @@ -269,7 +273,10 @@ pub async fn create_account( } }; let plc_client = PlcClient::new(None); - if let Err(e) = plc_client.send_operation(&genesis_result.did, &genesis_result.signed_operation).await { + if let Err(e) = plc_client + .send_operation(&genesis_result.did, &genesis_result.signed_operation) + .await + { error!("Failed to submit PLC genesis operation: {:?}", e); return ( StatusCode::BAD_GATEWAY, @@ -316,10 +323,12 @@ pub async fn create_account( Ok(None) => {} } if let Some(code) = &input.invite_code { - let invite_query = - sqlx::query!("SELECT available_uses FROM invite_codes WHERE code = $1 FOR UPDATE", code) - .fetch_optional(&mut *tx) - .await; + let invite_query = sqlx::query!( + "SELECT available_uses FROM invite_codes WHERE code = $1 FOR UPDATE", + code + ) + .fetch_optional(&mut *tx) + .await; match invite_query { Ok(Some(row)) => { if row.available_uses <= 0 { @@ -378,23 +387,41 @@ pub async fn create_account( discord_id, telegram_username, signal_number ) VALUES ($1, $2, $3, $4, $5, $6, $7::notification_channel, $8, $9, $10) RETURNING id"#, ) - .bind(short_handle) - .bind(&email) - .bind(&did) - .bind(&password_hash) - .bind(&verification_code) - .bind(&code_expires_at) - .bind(verification_channel) - .bind(input.discord_id.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty())) - .bind(input.telegram_username.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty())) - .bind(input.signal_number.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty())) - .fetch_one(&mut *tx) - .await; + .bind(short_handle) + .bind(&email) + .bind(&did) + .bind(&password_hash) + .bind(&verification_code) + .bind(code_expires_at) + .bind(verification_channel) + .bind( + input + .discord_id + .as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()), + ) + .bind( + input + .telegram_username + .as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()), + ) + .bind( + input + .signal_number + .as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()), + ) + .fetch_one(&mut *tx) + .await; let user_id = match user_insert { Ok((id,)) => id, Err(e) => { - if let Some(db_err) = e.as_database_error() { - if db_err.code().as_deref() == Some("23505") { + if let Some(db_err) = e.as_database_error() + && db_err.code().as_deref() == Some("23505") { let constraint = db_err.constraint().unwrap_or(""); if constraint.contains("handle") || constraint.contains("users_handle") { return ( @@ -425,7 +452,6 @@ pub async fn create_account( .into_response(); } } - } error!("Error inserting user: {:?}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, @@ -535,9 +561,13 @@ pub async fn create_account( } }; let commit_cid_str = commit_cid.to_string(); - let repo_insert = sqlx::query!("INSERT INTO repos (user_id, repo_root_cid) VALUES ($1, $2)", user_id, commit_cid_str) - .execute(&mut *tx) - .await; + let repo_insert = sqlx::query!( + "INSERT INTO repos (user_id, repo_root_cid) VALUES ($1, $2)", + user_id, + commit_cid_str + ) + .execute(&mut *tx) + .await; if let Err(e) = repo_insert { error!("Error initializing repo: {:?}", e); return ( @@ -547,10 +577,13 @@ pub async fn create_account( .into_response(); } if let Some(code) = &input.invite_code { - let use_insert = - sqlx::query!("INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)", code, user_id) - .execute(&mut *tx) - .await; + let use_insert = sqlx::query!( + "INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)", + code, + user_id + ) + .execute(&mut *tx) + .await; if let Err(e) = use_insert { error!("Error recording invite usage: {:?}", e); return ( @@ -568,10 +601,13 @@ pub async fn create_account( ) .into_response(); } - if let Err(e) = crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle)).await { + if let Err(e) = + crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle)).await + { warn!("Failed to sequence identity event for {}: {}", did, e); } - if let Err(e) = crate::api::repo::record::sequence_account_event(&state, &did, true, None).await { + if let Err(e) = crate::api::repo::record::sequence_account_event(&state, &did, true, None).await + { warn!("Failed to sequence account event for {}: {}", did, e); } let profile_record = json!({ @@ -584,7 +620,9 @@ pub async fn create_account( "app.bsky.actor.profile", "self", &profile_record, - ).await { + ) + .await + { warn!("Failed to create default profile for {}: {}", did, e); } if let Err(e) = crate::notifications::enqueue_signup_verification( @@ -593,8 +631,13 @@ pub async fn create_account( verification_channel, &verification_recipient, &verification_code, - ).await { - warn!("Failed to enqueue signup verification notification: {:?}", e); + ) + .await + { + warn!( + "Failed to enqueue signup verification notification: {:?}", + e + ); } ( StatusCode::OK, diff --git a/src/api/identity/did.rs b/src/api/identity/did.rs index 4912611..452c6b6 100644 --- a/src/api/identity/did.rs +++ b/src/api/identity/did.rs @@ -47,7 +47,10 @@ pub async fn resolve_handle( .await; match user { Ok(Some(row)) => { - let _ = state.cache.set(&cache_key, &row.did, std::time::Duration::from_secs(300)).await; + let _ = state + .cache + .set(&cache_key, &row.did, std::time::Duration::from_secs(300)) + .await; (StatusCode::OK, Json(json!({ "did": row.did }))).into_response() } Ok(None) => ( @@ -127,22 +130,23 @@ pub async fn user_did_doc(State(state): State, Path(handle): Path = match key_row { - Ok(Some(row)) => { - match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { - Ok(k) => k, - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } + Ok(Some(row)) => match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { + Ok(k) => k, + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); } - } + }, _ => { return ( StatusCode::INTERNAL_SERVER_ERROR, @@ -283,7 +287,7 @@ pub async fn get_recommended_did_credentials( headers: axum::http::HeaderMap, ) -> Response { let token = match crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) + headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, None => { @@ -298,16 +302,24 @@ pub async fn get_recommended_did_credentials( Ok(user) => user, Err(e) => return ApiError::from(e).into_response(), }; - let user = match sqlx::query!("SELECT handle FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.did = $1", auth_user.did) - .fetch_optional(&state.db) - .await + let user = match sqlx::query!( + "SELECT handle FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.did = $1", + auth_user.did + ) + .fetch_optional(&state.db) + .await { Ok(Some(row)) => row, _ => return ApiError::InternalError.into_response(), }; let key_bytes = match auth_user.key_bytes { Some(kb) => kb, - None => return ApiError::AuthenticationFailedMsg("OAuth tokens cannot get DID credentials".into()).into_response(), + None => { + return ApiError::AuthenticationFailedMsg( + "OAuth tokens cannot get DID credentials".into(), + ) + .into_response(); + } }; let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let pds_endpoint = format!("https://{}", hostname); @@ -352,7 +364,7 @@ pub async fn update_handle( Json(input): Json, ) -> Response { let token = match crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) + headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, None => return ApiError::AuthenticationRequired.into_response(), @@ -378,7 +390,9 @@ pub async fn update_handle( { return ( StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"})), + Json( + json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"}), + ), ) .into_response(); } @@ -387,9 +401,13 @@ pub async fn update_handle( .await .ok() .flatten(); - let existing = sqlx::query!("SELECT id FROM users WHERE handle = $1 AND id != $2", new_handle, user_id) - .fetch_optional(&state.db) - .await; + let existing = sqlx::query!( + "SELECT id FROM users WHERE handle = $1 AND id != $2", + new_handle, + user_id + ) + .fetch_optional(&state.db) + .await; if let Ok(Some(_)) = existing { return ( StatusCode::BAD_REQUEST, @@ -397,18 +415,26 @@ pub async fn update_handle( ) .into_response(); } - let result = sqlx::query!("UPDATE users SET handle = $1 WHERE id = $2", new_handle, user_id) - .execute(&state.db) - .await; + let result = sqlx::query!( + "UPDATE users SET handle = $1 WHERE id = $2", + new_handle, + user_id + ) + .execute(&state.db) + .await; match result { Ok(_) => { if let Some(old) = old_handle { let _ = state.cache.delete(&format!("handle:{}", old)).await; } let _ = state.cache.delete(&format!("handle:{}", new_handle)).await; - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname = + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let full_handle = format!("{}.{}", new_handle, hostname); - if let Err(e) = crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle)).await { + if let Err(e) = + crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle)) + .await + { warn!("Failed to sequence identity event for handle update: {}", e); } (StatusCode::OK, Json(json!({}))).into_response() @@ -424,10 +450,7 @@ pub async fn update_handle( } } -pub async fn well_known_atproto_did( - State(state): State, - headers: HeaderMap, -) -> Response { +pub async fn well_known_atproto_did(State(state): State, headers: HeaderMap) -> Response { let host = match headers.get("host").and_then(|h| h.to_str().ok()) { Some(h) => h, None => return (StatusCode::BAD_REQUEST, "Missing host header").into_response(), diff --git a/src/api/identity/mod.rs b/src/api/identity/mod.rs index d356583..1a7b2e1 100644 --- a/src/api/identity/mod.rs +++ b/src/api/identity/mod.rs @@ -4,7 +4,7 @@ pub mod plc; pub use account::create_account; pub use did::{ - get_recommended_did_credentials, resolve_handle, update_handle, user_did_doc, well_known_did, - well_known_atproto_did, + get_recommended_did_credentials, resolve_handle, update_handle, user_did_doc, + well_known_atproto_did, well_known_did, }; pub use plc::{request_plc_operation_signature, sign_plc_operation, submit_plc_operation}; diff --git a/src/api/identity/plc/mod.rs b/src/api/identity/plc/mod.rs index 9dc1609..6ca9ec4 100644 --- a/src/api/identity/plc/mod.rs +++ b/src/api/identity/plc/mod.rs @@ -3,5 +3,5 @@ mod sign; mod submit; pub use request::request_plc_operation_signature; -pub use sign::{sign_plc_operation, ServiceInput, SignPlcOperationInput, SignPlcOperationOutput}; -pub use submit::{submit_plc_operation, SubmitPlcOperationInput}; +pub use sign::{ServiceInput, SignPlcOperationInput, SignPlcOperationOutput, sign_plc_operation}; +pub use submit::{SubmitPlcOperationInput, submit_plc_operation}; diff --git a/src/api/identity/plc/request.rs b/src/api/identity/plc/request.rs index d75c9e6..b483cd5 100644 --- a/src/api/identity/plc/request.rs +++ b/src/api/identity/plc/request.rs @@ -1,10 +1,10 @@ use crate::api::ApiError; use crate::state::AppState; use axum::{ + Json, extract::State, http::StatusCode, response::{IntoResponse, Response}, - Json, }; use chrono::{Duration, Utc}; use serde_json::json; @@ -67,16 +67,14 @@ pub async fn request_plc_operation_signature( .into_response(); } let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - if let Err(e) = crate::notifications::enqueue_plc_operation( - &state.db, - user.id, - &plc_token, - &hostname, - ) - .await + if let Err(e) = + crate::notifications::enqueue_plc_operation(&state.db, user.id, &plc_token, &hostname).await { warn!("Failed to enqueue PLC operation notification: {:?}", e); } - info!("PLC operation signature requested for user {}", auth_user.did); + info!( + "PLC operation signature requested for user {}", + auth_user.did + ); (StatusCode::OK, Json(json!({}))).into_response() } diff --git a/src/api/identity/plc/sign.rs b/src/api/identity/plc/sign.rs index 871833c..b3625a0 100644 --- a/src/api/identity/plc/sign.rs +++ b/src/api/identity/plc/sign.rs @@ -1,19 +1,19 @@ use crate::api::ApiError; -use crate::circuit_breaker::{with_circuit_breaker, CircuitBreakerError}; +use crate::circuit_breaker::{CircuitBreakerError, with_circuit_breaker}; use crate::plc::{ - create_update_op, sign_operation, PlcClient, PlcError, PlcOpOrTombstone, PlcService, + PlcClient, PlcError, PlcOpOrTombstone, PlcService, create_update_op, sign_operation, }; use crate::state::AppState; use axum::{ + Json, extract::State, http::StatusCode, response::{IntoResponse, Response}, - Json, }; use chrono::Utc; use k256::ecdsa::SigningKey; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use std::collections::HashMap; use tracing::{error, info, warn}; @@ -59,8 +59,9 @@ pub async fn sign_plc_operation( Some(t) => t, None => { return ApiError::InvalidRequest( - "Email confirmation token required to sign PLC operations".into() - ).into_response(); + "Email confirmation token required to sign PLC operations".into(), + ) + .into_response(); } }; let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", did) @@ -105,9 +106,12 @@ pub async fn sign_plc_operation( } }; if Utc::now() > token_row.expires_at { - let _ = sqlx::query!("DELETE FROM plc_operation_tokens WHERE id = $1", token_row.id) - .execute(&state.db) - .await; + let _ = sqlx::query!( + "DELETE FROM plc_operation_tokens WHERE id = $1", + token_row.id + ) + .execute(&state.db) + .await; return ( StatusCode::BAD_REQUEST, Json(json!({ @@ -158,11 +162,11 @@ pub async fn sign_plc_operation( }; let plc_client = PlcClient::new(None); let did_clone = did.clone(); - let result: Result> = with_circuit_breaker( - &state.circuit_breakers.plc_directory, - || async { plc_client.get_last_op(&did_clone).await }, - ) - .await; + let result: Result> = + with_circuit_breaker(&state.circuit_breakers.plc_directory, || async { + plc_client.get_last_op(&did_clone).await + }) + .await; let last_op = match result { Ok(op) => op, Err(CircuitBreakerError::CircuitOpen(e)) => { @@ -259,9 +263,12 @@ pub async fn sign_plc_operation( .into_response(); } }; - let _ = sqlx::query!("DELETE FROM plc_operation_tokens WHERE id = $1", token_row.id) - .execute(&state.db) - .await; + let _ = sqlx::query!( + "DELETE FROM plc_operation_tokens WHERE id = $1", + token_row.id + ) + .execute(&state.db) + .await; info!("Signed PLC operation for user {}", did); ( StatusCode::OK, diff --git a/src/api/identity/plc/submit.rs b/src/api/identity/plc/submit.rs index e6f1ade..2a34239 100644 --- a/src/api/identity/plc/submit.rs +++ b/src/api/identity/plc/submit.rs @@ -1,16 +1,16 @@ use crate::api::ApiError; -use crate::circuit_breaker::{with_circuit_breaker, CircuitBreakerError}; -use crate::plc::{signing_key_to_did_key, validate_plc_operation, PlcClient, PlcError}; +use crate::circuit_breaker::{CircuitBreakerError, with_circuit_breaker}; +use crate::plc::{PlcClient, PlcError, signing_key_to_did_key, validate_plc_operation}; use crate::state::AppState; use axum::{ + Json, extract::State, http::StatusCode, response::{IntoResponse, Response}, - Json, }; use k256::ecdsa::SigningKey; use serde::Deserialize; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use tracing::{error, info, warn}; #[derive(Debug, Deserialize)] @@ -110,8 +110,8 @@ pub async fn submit_plc_operation( .into_response(); } } - if let Some(services) = op.get("services").and_then(|v| v.as_object()) { - if let Some(pds) = services.get("atproto_pds").and_then(|v| v.as_object()) { + if let Some(services) = op.get("services").and_then(|v| v.as_object()) + && let Some(pds) = services.get("atproto_pds").and_then(|v| v.as_object()) { let service_type = pds.get("type").and_then(|v| v.as_str()); let endpoint = pds.get("endpoint").and_then(|v| v.as_str()); if service_type != Some("AtprotoPersonalDataServer") { @@ -135,10 +135,9 @@ pub async fn submit_plc_operation( .into_response(); } } - } - if let Some(verification_methods) = op.get("verificationMethods").and_then(|v| v.as_object()) { - if let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str()) { - if atproto_key != user_did_key { + if let Some(verification_methods) = op.get("verificationMethods").and_then(|v| v.as_object()) + && let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str()) + && atproto_key != user_did_key { return ( StatusCode::BAD_REQUEST, Json(json!({ @@ -148,8 +147,6 @@ pub async fn submit_plc_operation( ) .into_response(); } - } - } if let Some(also_known_as) = op.get("alsoKnownAs").and_then(|v| v.as_array()) { let expected_handle = format!("at://{}", user.handle); let first_aka = also_known_as.first().and_then(|v| v.as_str()); @@ -167,11 +164,13 @@ pub async fn submit_plc_operation( let plc_client = PlcClient::new(None); let operation_clone = input.operation.clone(); let did_clone = did.clone(); - let result: Result<(), CircuitBreakerError> = with_circuit_breaker( - &state.circuit_breakers.plc_directory, - || async { plc_client.send_operation(&did_clone, &operation_clone).await }, - ) - .await; + let result: Result<(), CircuitBreakerError> = + with_circuit_breaker(&state.circuit_breakers.plc_directory, || async { + plc_client + .send_operation(&did_clone, &operation_clone) + .await + }) + .await; match result { Ok(()) => {} Err(CircuitBreakerError::CircuitOpen(e)) => { diff --git a/src/api/mod.rs b/src/api/mod.rs index 7ed9b42..65e04ca 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -15,4 +15,4 @@ pub mod temp; pub mod validation; pub use error::ApiError; -pub use proxy_client::{proxy_client, validate_at_uri, validate_did, validate_limit, AtUriParts}; +pub use proxy_client::{AtUriParts, proxy_client, validate_at_uri, validate_did, validate_limit}; diff --git a/src/api/moderation/mod.rs b/src/api/moderation/mod.rs index c633098..793abb3 100644 --- a/src/api/moderation/mod.rs +++ b/src/api/moderation/mod.rs @@ -35,7 +35,7 @@ pub async fn create_report( Json(input): Json, ) -> Response { let token = match crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) + headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, None => return ApiError::AuthenticationRequired.into_response(), diff --git a/src/api/notification/register_push.rs b/src/api/notification/register_push.rs index a995eaa..ddebb66 100644 --- a/src/api/notification/register_push.rs +++ b/src/api/notification/register_push.rs @@ -1,11 +1,11 @@ -use crate::api::proxy_client::{is_ssrf_safe, proxy_client, validate_did}; use crate::api::ApiError; +use crate::api::proxy_client::{is_ssrf_safe, proxy_client, validate_did}; use crate::state::AppState; use axum::{ + Json, extract::State, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, - Json, }; use serde::Deserialize; use serde_json::json; diff --git a/src/api/notification_prefs.rs b/src/api/notification_prefs.rs index 2d9c29f..412e55b 100644 --- a/src/api/notification_prefs.rs +++ b/src/api/notification_prefs.rs @@ -1,3 +1,5 @@ +use crate::auth::validate_bearer_token; +use crate::state::AppState; use axum::{ Json, extract::State, @@ -8,8 +10,6 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use sqlx::Row; use tracing::info; -use crate::auth::validate_bearer_token; -use crate::state::AppState; #[derive(Serialize)] #[serde(rename_all = "camelCase")] @@ -24,21 +24,16 @@ pub struct NotificationPrefsResponse { pub signal_verified: bool, } -pub async fn get_notification_prefs( - State(state): State, - headers: HeaderMap, -) -> Response { +pub async fn get_notification_prefs(State(state): State, headers: HeaderMap) -> Response { let token = match crate::auth::extract_bearer_token_from_header( headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})), - ) - .into_response() - } + None => return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})), + ) + .into_response(), }; let user = match validate_bearer_token(&state.db, &token).await { Ok(u) => u, @@ -47,11 +42,12 @@ pub async fn get_notification_prefs( StatusCode::UNAUTHORIZED, Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})), ) - .into_response() + .into_response(); } }; - let row = match sqlx::query( - r#" + let row = + match sqlx::query( + r#" SELECT email, preferred_notification_channel::text as channel, @@ -63,21 +59,21 @@ pub async fn get_notification_prefs( signal_verified FROM users WHERE did = $1 - "# - ) - .bind(&user.did) - .fetch_one(&state.db) - .await - { - Ok(r) => r, - Err(e) => { - return ( + "#, + ) + .bind(&user.did) + .fetch_one(&state.db) + .await + { + Ok(r) => r, + Err(e) => return ( StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), + Json( + json!({"error": "InternalError", "message": format!("Database error: {}", e)}), + ), ) - .into_response() - } - }; + .into_response(), + }; let email: String = row.get("email"); let channel: String = row.get("channel"); let discord_id: Option = row.get("discord_id"); @@ -117,13 +113,11 @@ pub async fn update_notification_prefs( headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})), - ) - .into_response() - } + None => return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})), + ) + .into_response(), }; let user = match validate_bearer_token(&state.db, &token).await { Ok(u) => u, @@ -132,7 +126,7 @@ pub async fn update_notification_prefs( StatusCode::UNAUTHORIZED, Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})), ) - .into_response() + .into_response(); } }; if let Some(ref channel) = input.preferred_channel { @@ -208,7 +202,11 @@ pub async fn update_notification_prefs( info!(did = %user.did, "Updated Telegram username"); } if let Some(ref signal) = input.signal_number { - let signal_clean: Option<&str> = if signal.is_empty() { None } else { Some(signal.as_str()) }; + let signal_clean: Option<&str> = if signal.is_empty() { + None + } else { + Some(signal.as_str()) + }; if let Err(e) = sqlx::query( r#"UPDATE users SET signal_number = $1, signal_verified = FALSE, updated_at = NOW() WHERE did = $2"# ) diff --git a/src/api/proxy.rs b/src/api/proxy.rs index ac38fee..b82aeb6 100644 --- a/src/api/proxy.rs +++ b/src/api/proxy.rs @@ -1,3 +1,4 @@ +use crate::api::proxy_client::proxy_client; use crate::state::AppState; use axum::{ body::Bytes, @@ -5,19 +6,15 @@ use axum::{ http::{HeaderMap, Method, StatusCode}, response::{IntoResponse, Response}, }; -use crate::api::proxy_client::proxy_client; use std::collections::HashMap; use tracing::error; fn resolve_service_did(did_with_fragment: &str) -> Option<(String, String)> { - if did_with_fragment.starts_with("did:web:") { - let without_prefix = &did_with_fragment[8..]; + if let Some(without_prefix) = did_with_fragment.strip_prefix("did:web:") { let host = without_prefix.split('#').next()?; let url = format!("https://{}", host); let did_without_fragment = format!("did:web:{}", host); Some((url, did_without_fragment)) - } else if did_with_fragment.starts_with("did:plc:") { - None } else { None } @@ -41,7 +38,8 @@ pub async fn proxy_handler( Some(resolved) => resolved, None => { error!(did = %did_str, "Could not resolve service DID"); - return (StatusCode::BAD_GATEWAY, "Could not resolve service DID").into_response(); + return (StatusCode::BAD_GATEWAY, "Could not resolve service DID") + .into_response(); } }; (url, Some(did_without_fragment)) @@ -50,7 +48,8 @@ pub async fn proxy_handler( let url = match std::env::var("APPVIEW_URL") { Ok(url) => url, Err(_) => { - return (StatusCode::BAD_GATEWAY, "No upstream AppView configured").into_response(); + return (StatusCode::BAD_GATEWAY, "No upstream AppView configured") + .into_response(); } }; let aud = std::env::var("APPVIEW_DID").ok(); @@ -60,26 +59,20 @@ pub async fn proxy_handler( let target_url = format!("{}/xrpc/{}", appview_url, method); let client = proxy_client(); let mut request_builder = client.request(method_verb, &target_url).query(¶ms); - let mut auth_header_val = headers.get("Authorization").map(|h| h.clone()); - if let Some(aud) = &service_aud { - if let Some(token) = crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) - ) { - if let Ok(auth_user) = crate::auth::validate_bearer_token(&state.db, &token).await { - if let Some(key_bytes) = auth_user.key_bytes { - if let Ok(new_token) = + let mut auth_header_val = headers.get("Authorization").cloned(); + if let Some(aud) = &service_aud + && let Some(token) = crate::auth::extract_bearer_token_from_header( + headers.get("Authorization").and_then(|h| h.to_str().ok()), + ) + && let Ok(auth_user) = crate::auth::validate_bearer_token(&state.db, &token).await + && let Some(key_bytes) = auth_user.key_bytes + && let Ok(new_token) = crate::auth::create_service_token(&auth_user.did, aud, &method, &key_bytes) - { - if let Ok(val) = + && let Ok(val) = axum::http::HeaderValue::from_str(&format!("Bearer {}", new_token)) { auth_header_val = Some(val); } - } - } - } - } - } if let Some(val) = auth_header_val { request_builder = request_builder.header("Authorization", val); } diff --git a/src/api/proxy_client.rs b/src/api/proxy_client.rs index c9400f9..280f1ef 100644 --- a/src/api/proxy_client.rs +++ b/src/api/proxy_client.rs @@ -20,7 +20,9 @@ pub fn proxy_client() -> &'static Client { .pool_idle_timeout(Duration::from_secs(90)) .redirect(reqwest::redirect::Policy::none()) .build() - .expect("Failed to build HTTP client - this indicates a TLS or system configuration issue") + .expect( + "Failed to build HTTP client - this indicates a TLS or system configuration issue", + ) }) } @@ -48,7 +50,9 @@ pub fn is_ssrf_safe(url: &str) -> Result<(), SsrfError> { } return Ok(()); } - let port = parsed.port().unwrap_or(if scheme == "https" { 443 } else { 80 }); + let port = parsed + .port() + .unwrap_or(if scheme == "https" { 443 } else { 80 }); let socket_addrs: Vec = match (host, port).to_socket_addrs() { Ok(addrs) => addrs.collect(), Err(_) => return Err(SsrfError::DnsResolutionFailed(host.to_string())), @@ -104,7 +108,9 @@ impl std::fmt::Display for SsrfError { SsrfError::InsecureProtocol(p) => write!(f, "Insecure protocol: {}", p), SsrfError::NoHost => write!(f, "No host in URL"), SsrfError::NonUnicastIp(ip) => write!(f, "Non-unicast IP address: {}", ip), - SsrfError::DnsResolutionFailed(host) => write!(f, "DNS resolution failed for: {}", host), + SsrfError::DnsResolutionFailed(host) => { + write!(f, "DNS resolution failed for: {}", host) + } } } } @@ -158,7 +164,7 @@ pub struct AtUriParts { pub fn validate_limit(limit: Option, default: u32, max: u32) -> u32 { match limit { - Some(l) if l == 0 => default, + Some(0) => default, Some(l) if l > max => max, Some(l) => l, None => default, @@ -190,7 +196,10 @@ mod tests { #[test] fn test_ssrf_blocks_http_by_default() { let result = is_ssrf_safe("http://external.example.com/xrpc/test"); - assert!(matches!(result, Err(SsrfError::InsecureProtocol(_)) | Err(SsrfError::DnsResolutionFailed(_)))); + assert!(matches!( + result, + Err(SsrfError::InsecureProtocol(_)) | Err(SsrfError::DnsResolutionFailed(_)) + )); } #[test] fn test_ssrf_allows_localhost_http() { diff --git a/src/api/read_after_write.rs b/src/api/read_after_write.rs index 63c2889..cfe2376 100644 --- a/src/api/read_after_write.rs +++ b/src/api/read_after_write.rs @@ -1,12 +1,12 @@ -use crate::api::proxy_client::{ - is_ssrf_safe, proxy_client, MAX_RESPONSE_SIZE, RESPONSE_HEADERS_TO_FORWARD, -}; use crate::api::ApiError; +use crate::api::proxy_client::{ + MAX_RESPONSE_SIZE, RESPONSE_HEADERS_TO_FORWARD, is_ssrf_safe, proxy_client, +}; use crate::state::AppState; use axum::{ + Json, http::{HeaderMap, HeaderValue, StatusCode}, response::{IntoResponse, Response}, - Json, }; use bytes::Bytes; use chrono::{DateTime, Utc}; @@ -182,8 +182,8 @@ pub async fn get_records_since_rev( record, }); } - } else if data.collection == "app.bsky.feed.like" { - if let Ok(record) = serde_ipld_dagcbor::from_slice::(&block_bytes) { + } else if data.collection == "app.bsky.feed.like" + && let Ok(record) = serde_ipld_dagcbor::from_slice::(&block_bytes) { result.likes.push(RecordDescript { uri, cid: data.cid_str, @@ -191,7 +191,6 @@ pub async fn get_records_since_rev( record, }); } - } } Ok(result) } @@ -250,18 +249,21 @@ pub async fn proxy_to_appview( })?; if let Err(e) = is_ssrf_safe(&appview_url) { error!("SSRF check failed for appview URL: {}", e); - return Err(ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e)) - .into_response()); + return Err( + ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e)).into_response(), + ); } let target_url = format!("{}/xrpc/{}", appview_url, method); info!(target = %target_url, "Proxying request to appview"); let client = proxy_client(); let mut request_builder = client.get(&target_url).query(params); if let Some(key_bytes) = auth_key_bytes { - let appview_did = std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string()); + let appview_did = + std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string()); match crate::auth::create_service_token(auth_did, &appview_did, method, key_bytes) { Ok(service_token) => { - request_builder = request_builder.header("Authorization", format!("Bearer {}", service_token)); + request_builder = + request_builder.header("Authorization", format!("Bearer {}", service_token)); } Err(e) => { error!(error = ?e, "Failed to create service token"); @@ -287,9 +289,7 @@ pub async fn proxy_to_appview( Some((name, value)) }) .collect(); - let content_length = resp - .content_length() - .unwrap_or(0); + let content_length = resp.content_length().unwrap_or(0); if content_length > MAX_RESPONSE_SIZE { error!( content_length, @@ -321,8 +321,10 @@ pub async fn proxy_to_appview( if e.is_timeout() { Err(ApiError::UpstreamTimeout.into_response()) } else if e.is_connect() { - Err(ApiError::UpstreamUnavailable("Failed to connect to upstream".to_string()) - .into_response()) + Err( + ApiError::UpstreamUnavailable("Failed to connect to upstream".to_string()) + .into_response(), + ) } else { Err(ApiError::UpstreamFailure.into_response()) } @@ -332,13 +334,12 @@ pub async fn proxy_to_appview( pub fn format_munged_response(data: T, lag: Option) -> Response { let mut response = (StatusCode::OK, Json(data)).into_response(); - if let Some(lag_ms) = lag { - if let Ok(header_val) = HeaderValue::from_str(&lag_ms.to_string()) { + if let Some(lag_ms) = lag + && let Ok(header_val) = HeaderValue::from_str(&lag_ms.to_string()) { response .headers_mut() .insert(UPSTREAM_LAG_HEADER, header_val); } - } response } diff --git a/src/api/repo/blob.rs b/src/api/repo/blob.rs index cc1ef93..c1977fa 100644 --- a/src/api/repo/blob.rs +++ b/src/api/repo/blob.rs @@ -30,7 +30,7 @@ pub async fn upload_blob( .into_response(); } let token = match crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) + headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, None => { @@ -122,8 +122,12 @@ pub async fn upload_blob( .into_response(); } }; - if was_inserted { - if let Err(e) = state.blob_store.put_bytes(&storage_key, bytes::Bytes::from(data)).await { + if was_inserted + && let Err(e) = state + .blob_store + .put_bytes(&storage_key, bytes::Bytes::from(data)) + .await + { error!("Failed to upload blob to storage: {:?}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, @@ -131,14 +135,15 @@ pub async fn upload_blob( ) .into_response(); } - } if let Err(e) = tx.commit().await { error!("Failed to commit blob transaction: {:?}", e); - if was_inserted { - if let Err(cleanup_err) = state.blob_store.delete(&storage_key).await { - error!("Failed to cleanup orphaned blob {}: {:?}", storage_key, cleanup_err); + if was_inserted + && let Err(cleanup_err) = state.blob_store.delete(&storage_key).await { + error!( + "Failed to cleanup orphaned blob {}: {:?}", + storage_key, cleanup_err + ); } - } return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), @@ -179,17 +184,13 @@ pub struct ListMissingBlobsOutput { fn find_blobs(val: &serde_json::Value, blobs: &mut Vec) { if let Some(obj) = val.as_object() { - if let Some(type_val) = obj.get("$type") { - if type_val == "blob" { - if let Some(r) = obj.get("ref") { - if let Some(link) = r.get("$link") { - if let Some(s) = link.as_str() { + if let Some(type_val) = obj.get("$type") + && type_val == "blob" + && let Some(r) = obj.get("ref") + && let Some(link) = r.get("$link") + && let Some(s) = link.as_str() { blobs.push(s.to_string()); } - } - } - } - } for (_, v) in obj { find_blobs(v, blobs); } @@ -206,7 +207,7 @@ pub async fn list_missing_blobs( Query(params): Query, ) -> Response { let token = match crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) + headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, None => { @@ -276,7 +277,7 @@ pub async fn list_missing_blobs( let rkey = &row.rkey; let record_cid_str = &row.record_cid; last_cursor = Some(format!("{}|{}", collection, rkey)); - let record_cid = match Cid::from_str(&record_cid_str) { + let record_cid = match Cid::from_str(record_cid_str) { Ok(c) => c, Err(_) => continue, }; @@ -291,9 +292,13 @@ pub async fn list_missing_blobs( let mut blobs = Vec::new(); find_blobs(&record_val, &mut blobs); for blob_cid_str in blobs { - let exists = sqlx::query!("SELECT 1 as one FROM blobs WHERE cid = $1 AND created_by_user = $2", blob_cid_str, user_id) - .fetch_optional(&state.db) - .await; + let exists = sqlx::query!( + "SELECT 1 as one FROM blobs WHERE cid = $1 AND created_by_user = $2", + blob_cid_str, + user_id + ) + .fetch_optional(&state.db) + .await; match exists { Ok(None) => { missing_blobs.push(RecordBlob { diff --git a/src/api/repo/import.rs b/src/api/repo/import.rs index bd0ab59..274a8eb 100644 --- a/src/api/repo/import.rs +++ b/src/api/repo/import.rs @@ -1,13 +1,13 @@ use crate::api::ApiError; use crate::state::AppState; -use crate::sync::import::{apply_import, parse_car, ImportError}; +use crate::sync::import::{ImportError, apply_import, parse_car}; use crate::sync::verify::CarVerifier; use axum::{ + Json, body::Bytes, extract::State, http::StatusCode, response::{IntoResponse, Response}, - Json, }; use serde_json::json; use tracing::{debug, error, info, warn}; diff --git a/src/api/repo/meta.rs b/src/api/repo/meta.rs index 3487410..45bb2a9 100644 --- a/src/api/repo/meta.rs +++ b/src/api/repo/meta.rs @@ -18,15 +18,21 @@ pub async fn describe_repo( Query(input): Query, ) -> Response { let user_row = if input.repo.starts_with("did:") { - sqlx::query!("SELECT id, handle, did FROM users WHERE did = $1", input.repo) - .fetch_optional(&state.db) - .await - .map(|opt| opt.map(|r| (r.id, r.handle, r.did))) + sqlx::query!( + "SELECT id, handle, did FROM users WHERE did = $1", + input.repo + ) + .fetch_optional(&state.db) + .await + .map(|opt| opt.map(|r| (r.id, r.handle, r.did))) } else { - sqlx::query!("SELECT id, handle, did FROM users WHERE handle = $1", input.repo) - .fetch_optional(&state.db) - .await - .map(|opt| opt.map(|r| (r.id, r.handle, r.did))) + sqlx::query!( + "SELECT id, handle, did FROM users WHERE handle = $1", + input.repo + ) + .fetch_optional(&state.db) + .await + .map(|opt| opt.map(|r| (r.id, r.handle, r.did))) }; let (user_id, handle, did) = match user_row { Ok(Some((id, handle, did))) => (id, handle, did), @@ -38,10 +44,12 @@ pub async fn describe_repo( .into_response(); } }; - let collections_query = - sqlx::query!("SELECT DISTINCT collection FROM records WHERE repo_id = $1", user_id) - .fetch_all(&state.db) - .await; + let collections_query = sqlx::query!( + "SELECT DISTINCT collection FROM records WHERE repo_id = $1", + user_id + ) + .fetch_all(&state.db) + .await; let collections: Vec = match collections_query { Ok(rows) => rows.iter().map(|r| r.collection.clone()).collect(), Err(_) => Vec::new(), diff --git a/src/api/repo/mod.rs b/src/api/repo/mod.rs index c1d6d31..b7201ec 100644 --- a/src/api/repo/mod.rs +++ b/src/api/repo/mod.rs @@ -6,4 +6,6 @@ pub mod record; pub use blob::{list_missing_blobs, upload_blob}; pub use import::import_repo; pub use meta::describe_repo; -pub use record::{apply_writes, create_record, delete_record, get_record, list_records, put_record}; +pub use record::{ + apply_writes, create_record, delete_record, get_record, list_records, put_record, +}; diff --git a/src/api/repo/record/batch.rs b/src/api/repo/record/batch.rs index 21b9df6..2f55fee 100644 --- a/src/api/repo/record/batch.rs +++ b/src/api/repo/record/batch.rs @@ -1,16 +1,19 @@ use super::validation::validate_record; use super::write::has_verified_notification_channel; -use crate::api::repo::record::utils::{commit_and_log, RecordOp}; +use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log}; use crate::repo::tracking::TrackingBlockStore; use crate::state::AppState; use axum::{ + Json, extract::State, http::StatusCode, response::{IntoResponse, Response}, - Json, }; use cid::Cid; -use jacquard::types::{integer::LimitedU32, string::{Nsid, Tid}}; +use jacquard::types::{ + integer::LimitedU32, + string::{Nsid, Tid}, +}; use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -77,7 +80,7 @@ pub async fn apply_writes( Json(input): Json, ) -> Response { let token = match crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) + headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, None => { @@ -154,20 +157,22 @@ pub async fn apply_writes( .into_response(); } }; - let root_cid_str: String = - match sqlx::query_scalar!("SELECT repo_root_cid FROM repos WHERE user_id = $1", user_id) - .fetch_optional(&state.db) - .await - { - Ok(Some(cid_str)) => cid_str, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Repo root not found"})), - ) - .into_response(); - } - }; + let root_cid_str: String = match sqlx::query_scalar!( + "SELECT repo_root_cid FROM repos WHERE user_id = $1", + user_id + ) + .fetch_optional(&state.db) + .await + { + Ok(Some(cid_str)) => cid_str, + _ => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Repo root not found"})), + ) + .into_response(); + } + }; let current_root_cid = match Cid::from_str(&root_cid_str) { Ok(c) => c, Err(_) => { @@ -178,15 +183,14 @@ pub async fn apply_writes( .into_response(); } }; - if let Some(swap_commit) = &input.swap_commit { - if Cid::from_str(swap_commit).ok() != Some(current_root_cid) { + if let Some(swap_commit) = &input.swap_commit + && Cid::from_str(swap_commit).ok() != Some(current_root_cid) { return ( StatusCode::CONFLICT, Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})), ) .into_response(); } - } let tracking_store = TrackingBlockStore::new(state.block_store.clone()); let commit_bytes = match tracking_store.get(¤t_root_cid).await { Ok(Some(b)) => b, @@ -195,7 +199,7 @@ pub async fn apply_writes( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Commit block not found"})), ) - .into_response() + .into_response(); } }; let commit = match Commit::from_cbor(&commit_bytes) { @@ -205,7 +209,7 @@ pub async fn apply_writes( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to parse commit"})), ) - .into_response() + .into_response(); } }; let original_mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None); @@ -220,11 +224,10 @@ pub async fn apply_writes( rkey, value, } => { - if input.validate.unwrap_or(true) { - if let Err(err_response) = validate_record(value, collection) { - return err_response; + if input.validate.unwrap_or(true) + && let Err(err_response) = validate_record(value, collection) { + return *err_response; } - } let rkey = rkey .clone() .unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string()); @@ -234,7 +237,13 @@ pub async fn apply_writes( } let record_cid = match tracking_store.put(&record_bytes).await { Ok(c) => c, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to store record"}))).into_response(), + Err(_) => return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json( + json!({"error": "InternalError", "message": "Failed to store record"}), + ), + ) + .into_response(), }; let collection_nsid = match collection.parse::() { Ok(n) => n, @@ -244,7 +253,11 @@ pub async fn apply_writes( modified_keys.push(key.clone()); mst = match mst.add(&key, record_cid).await { Ok(m) => m, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to add to MST"}))).into_response(), + Err(_) => return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to add to MST"})), + ) + .into_response(), }; let uri = format!("at://{}/{}/{}", did, collection, rkey); results.push(WriteResult::CreateResult { @@ -262,18 +275,23 @@ pub async fn apply_writes( rkey, value, } => { - if input.validate.unwrap_or(true) { - if let Err(err_response) = validate_record(value, collection) { - return err_response; + if input.validate.unwrap_or(true) + && let Err(err_response) = validate_record(value, collection) { + return *err_response; } - } let mut record_bytes = Vec::new(); if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() { return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response(); } let record_cid = match tracking_store.put(&record_bytes).await { Ok(c) => c, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to store record"}))).into_response(), + Err(_) => return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json( + json!({"error": "InternalError", "message": "Failed to store record"}), + ), + ) + .into_response(), }; let collection_nsid = match collection.parse::() { Ok(n) => n, @@ -284,7 +302,11 @@ pub async fn apply_writes( let prev_record_cid = mst.get(&key).await.ok().flatten(); mst = match mst.update(&key, record_cid).await { Ok(m) => m, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to update MST"}))).into_response(), + Err(_) => return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to update MST"})), + ) + .into_response(), }; let uri = format!("at://{}/{}/{}", did, collection, rkey); results.push(WriteResult::UpdateResult { @@ -321,14 +343,24 @@ pub async fn apply_writes( } let new_mst_root = match mst.persist().await { Ok(c) => c, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to persist MST"}))).into_response(), + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to persist MST"})), + ) + .into_response(); + } }; let mut relevant_blocks = std::collections::BTreeMap::new(); for key in &modified_keys { - if let Err(_) = mst.blocks_for_path(key, &mut relevant_blocks).await { + if mst.blocks_for_path(key, &mut relevant_blocks).await.is_err() { return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response(); } - if let Err(_) = original_mst.blocks_for_path(key, &mut relevant_blocks).await { + if original_mst + .blocks_for_path(key, &mut relevant_blocks) + .await + .is_err() + { return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response(); } } @@ -344,13 +376,15 @@ pub async fn apply_writes( .collect::>(); let commit_res = match commit_and_log( &state, - &did, - user_id, - Some(current_root_cid), - Some(commit.data), - new_mst_root, - ops, - &written_cids_str, + CommitParams { + did: &did, + user_id, + current_root_cid: Some(current_root_cid), + prev_data_cid: Some(commit.data), + new_mst_root, + ops, + blocks_cids: &written_cids_str, + }, ) .await { diff --git a/src/api/repo/record/delete.rs b/src/api/repo/record/delete.rs index 50363ce..0ad9ee2 100644 --- a/src/api/repo/record/delete.rs +++ b/src/api/repo/record/delete.rs @@ -1,12 +1,12 @@ -use crate::api::repo::record::utils::{commit_and_log, RecordOp}; +use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log}; use crate::api::repo::record::write::prepare_repo_write; use crate::repo::tracking::TrackingBlockStore; use crate::state::AppState; use axum::{ + Json, extract::State, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, - Json, }; use cid::Cid; use jacquard::types::string::Nsid; @@ -38,32 +38,45 @@ pub async fn delete_record( Ok(res) => res, Err(err_res) => return err_res, }; - if let Some(swap_commit) = &input.swap_commit { - if Cid::from_str(swap_commit).ok() != Some(current_root_cid) { + if let Some(swap_commit) = &input.swap_commit + && Cid::from_str(swap_commit).ok() != Some(current_root_cid) { return ( StatusCode::CONFLICT, Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})), ) .into_response(); } - } let tracking_store = TrackingBlockStore::new(state.block_store.clone()); let commit_bytes = match tracking_store.get(¤t_root_cid).await { Ok(Some(b)) => b, - _ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Commit block not found"}))).into_response(), + _ => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Commit block not found"})), + ) + .into_response(); + } }; let commit = match Commit::from_cbor(&commit_bytes) { Ok(c) => c, - _ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to parse commit"}))).into_response(), + _ => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to parse commit"})), + ) + .into_response(); + } }; - let mst = Mst::load( - Arc::new(tracking_store.clone()), - commit.data, - None, - ); + let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None); let collection_nsid = match input.collection.parse::() { Ok(n) => n, - Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidCollection"}))).into_response(), + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidCollection"})), + ) + .into_response(); + } }; let key = format!("{}/{}", collection_nsid, input.rkey); if let Some(swap_record_str) = &input.swap_record { @@ -88,15 +101,23 @@ pub async fn delete_record( Ok(c) => c, Err(e) => { error!("Failed to persist MST: {:?}", e); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to persist MST"}))).into_response(); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to persist MST"})), + ) + .into_response(); } }; - let op = RecordOp::Delete { collection: input.collection, rkey: input.rkey, prev: prev_record_cid }; + let op = RecordOp::Delete { + collection: input.collection, + rkey: input.rkey, + prev: prev_record_cid, + }; let mut relevant_blocks = std::collections::BTreeMap::new(); - if let Err(_) = new_mst.blocks_for_path(&key, &mut relevant_blocks).await { + if new_mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() { return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response(); } - if let Err(_) = mst.blocks_for_path(&key, &mut relevant_blocks).await { + if mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() { return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response(); } let mut written_cids = tracking_store.get_all_relevant_cids(); @@ -105,9 +126,29 @@ pub async fn delete_record( written_cids.push(*cid); } } - let written_cids_str = written_cids.iter().map(|c| c.to_string()).collect::>(); - if let Err(e) = commit_and_log(&state, &did, user_id, Some(current_root_cid), Some(commit.data), new_mst_root, vec![op], &written_cids_str).await { - return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": e}))).into_response(); + let written_cids_str = written_cids + .iter() + .map(|c| c.to_string()) + .collect::>(); + if let Err(e) = commit_and_log( + &state, + CommitParams { + did: &did, + user_id, + current_root_cid: Some(current_root_cid), + prev_data_cid: Some(commit.data), + new_mst_root, + ops: vec![op], + blocks_cids: &written_cids_str, + }, + ) + .await + { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": e})), + ) + .into_response(); }; (StatusCode::OK, Json(json!({}))).into_response() } diff --git a/src/api/repo/record/mod.rs b/src/api/repo/record/mod.rs index 0f1ce5b..f7c41ca 100644 --- a/src/api/repo/record/mod.rs +++ b/src/api/repo/record/mod.rs @@ -11,5 +11,5 @@ pub use read::{GetRecordInput, ListRecordsInput, ListRecordsOutput, get_record, pub use utils::*; pub use write::{ CreateRecordInput, CreateRecordOutput, PutRecordInput, PutRecordOutput, create_record, - put_record, prepare_repo_write, + prepare_repo_write, put_record, }; diff --git a/src/api/repo/record/read.rs b/src/api/repo/record/read.rs index 9e448cb..cf85300 100644 --- a/src/api/repo/record/read.rs +++ b/src/api/repo/record/read.rs @@ -71,15 +71,14 @@ pub async fn get_record( .into_response(); } }; - if let Some(expected_cid) = &input.cid { - if &record_cid_str != expected_cid { + if let Some(expected_cid) = &input.cid + && &record_cid_str != expected_cid { return ( StatusCode::NOT_FOUND, Json(json!({"error": "NotFound", "message": "Record CID mismatch"})), ) .into_response(); } - } let cid = match Cid::from_str(&record_cid_str) { Ok(c) => c, Err(_) => { @@ -192,7 +191,11 @@ pub async fn list_records( param_idx += 1; } if input.rkey_end.is_some() { - conditions.push(if param_idx == 3 { "rkey < $3" } else { "rkey < $4" }); + conditions.push(if param_idx == 3 { + "rkey < $3" + } else { + "rkey < $4" + }); param_idx += 1; } let limit_idx = param_idx; @@ -246,17 +249,15 @@ pub async fn list_records( }; let mut records = Vec::new(); for (cid, block_opt) in cids.iter().zip(blocks.into_iter()) { - if let Some(block) = block_opt { - if let Some((rkey, cid_str)) = cid_to_rkey.get(cid) { - if let Ok(value) = serde_ipld_dagcbor::from_slice::(&block) { + if let Some(block) = block_opt + && let Some((rkey, cid_str)) = cid_to_rkey.get(cid) + && let Ok(value) = serde_ipld_dagcbor::from_slice::(&block) { records.push(json!({ "uri": format!("at://{}/{}/{}", input.repo, input.collection, rkey), "cid": cid_str, "value": value })); } - } - } } Json(ListRecordsOutput { cursor: last_rkey, diff --git a/src/api/repo/record/utils.rs b/src/api/repo/record/utils.rs index 5a17ab6..bf6089c 100644 --- a/src/api/repo/record/utils.rs +++ b/src/api/repo/record/utils.rs @@ -3,7 +3,7 @@ use bytes::Bytes; use cid::Cid; use jacquard::types::{integer::LimitedU32, string::Tid}; use jacquard_repo::storage::BlockStore; -use k256::ecdsa::{signature::Signer, Signature, SigningKey}; +use k256::ecdsa::{Signature, SigningKey, signature::Signer}; use serde::Serialize; use serde_json::json; use uuid::Uuid; @@ -71,9 +71,22 @@ fn create_signed_commit( } pub enum RecordOp { - Create { collection: String, rkey: String, cid: Cid }, - Update { collection: String, rkey: String, cid: Cid, prev: Option }, - Delete { collection: String, rkey: String, prev: Option }, + Create { + collection: String, + rkey: String, + cid: Cid, + }, + Update { + collection: String, + rkey: String, + cid: Cid, + prev: Option, + }, + Delete { + collection: String, + rkey: String, + prev: Option, + }, } pub struct CommitResult { @@ -81,16 +94,29 @@ pub struct CommitResult { pub rev: String, } +pub struct CommitParams<'a> { + pub did: &'a str, + pub user_id: Uuid, + pub current_root_cid: Option, + pub prev_data_cid: Option, + pub new_mst_root: Cid, + pub ops: Vec, + pub blocks_cids: &'a [String], +} + pub async fn commit_and_log( state: &AppState, - did: &str, - user_id: Uuid, - current_root_cid: Option, - prev_data_cid: Option, - new_mst_root: Cid, - ops: Vec, - blocks_cids: &[String], + params: CommitParams<'_>, ) -> Result { + let CommitParams { + did, + user_id, + current_root_cid, + prev_data_cid, + new_mst_root, + ops, + blocks_cids, + } = params; let key_row = sqlx::query!( "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", user_id @@ -100,20 +126,21 @@ pub async fn commit_and_log( .map_err(|e| format!("Failed to fetch signing key: {}", e))?; let key_bytes = crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version) .map_err(|e| format!("Failed to decrypt signing key: {}", e))?; - let signing_key = SigningKey::from_slice(&key_bytes) - .map_err(|e| format!("Invalid signing key: {}", e))?; + let signing_key = + SigningKey::from_slice(&key_bytes).map_err(|e| format!("Invalid signing key: {}", e))?; let rev = Tid::now(LimitedU32::MIN); let rev_str = rev.to_string(); - let (new_commit_bytes, _sig) = create_signed_commit( - did, - new_mst_root, - &rev_str, - current_root_cid, - &signing_key, - )?; - let new_root_cid = state.block_store.put(&new_commit_bytes).await + let (new_commit_bytes, _sig) = + create_signed_commit(did, new_mst_root, &rev_str, current_root_cid, &signing_key)?; + let new_root_cid = state + .block_store + .put(&new_commit_bytes) + .await .map_err(|e| format!("Failed to save commit block: {:?}", e))?; - let mut tx = state.db.begin().await + let mut tx = state + .db + .begin() + .await .map_err(|e| format!("Failed to begin transaction: {}", e))?; let lock_result = sqlx::query!( "SELECT repo_root_cid FROM repos WHERE user_id = $1 FOR UPDATE NOWAIT", @@ -123,28 +150,36 @@ pub async fn commit_and_log( .await; match lock_result { Err(e) => { - if let Some(db_err) = e.as_database_error() { - if db_err.code().as_deref() == Some("55P03") { - return Err("ConcurrentModification: Another request is modifying this repo".to_string()); + if let Some(db_err) = e.as_database_error() + && db_err.code().as_deref() == Some("55P03") { + return Err( + "ConcurrentModification: Another request is modifying this repo" + .to_string(), + ); } - } return Err(format!("Failed to acquire repo lock: {}", e)); } Ok(Some(row)) => { - if let Some(expected_root) = ¤t_root_cid { - if row.repo_root_cid != expected_root.to_string() { - return Err("ConcurrentModification: Repo has been modified since last read".to_string()); + if let Some(expected_root) = ¤t_root_cid + && row.repo_root_cid != expected_root.to_string() { + return Err( + "ConcurrentModification: Repo has been modified since last read" + .to_string(), + ); } - } } Ok(None) => { return Err("Repo not found".to_string()); } } - sqlx::query!("UPDATE repos SET repo_root_cid = $1 WHERE user_id = $2", new_root_cid.to_string(), user_id) - .execute(&mut *tx) - .await - .map_err(|e| format!("DB Error (repos): {}", e))?; + sqlx::query!( + "UPDATE repos SET repo_root_cid = $1 WHERE user_id = $2", + new_root_cid.to_string(), + user_id + ) + .execute(&mut *tx) + .await + .map_err(|e| format!("DB Error (repos): {}", e))?; let mut upsert_collections: Vec = Vec::new(); let mut upsert_rkeys: Vec = Vec::new(); let mut upsert_cids: Vec = Vec::new(); @@ -152,12 +187,24 @@ pub async fn commit_and_log( let mut delete_rkeys: Vec = Vec::new(); for op in &ops { match op { - RecordOp::Create { collection, rkey, cid } | RecordOp::Update { collection, rkey, cid, .. } => { + RecordOp::Create { + collection, + rkey, + cid, + } + | RecordOp::Update { + collection, + rkey, + cid, + .. + } => { upsert_collections.push(collection.clone()); upsert_rkeys.push(rkey.clone()); upsert_cids.push(cid.to_string()); } - RecordOp::Delete { collection, rkey, .. } => { + RecordOp::Delete { + collection, rkey, .. + } => { delete_collections.push(collection.clone()); delete_rkeys.push(rkey.clone()); } @@ -197,14 +244,24 @@ pub async fn commit_and_log( .await .map_err(|e| format!("DB Error (records batch delete): {}", e))?; } - let ops_json = ops.iter().map(|op| { - match op { - RecordOp::Create { collection, rkey, cid } => json!({ + let ops_json = ops + .iter() + .map(|op| match op { + RecordOp::Create { + collection, + rkey, + cid, + } => json!({ "action": "create", "path": format!("{}/{}", collection, rkey), "cid": cid.to_string() }), - RecordOp::Update { collection, rkey, cid, prev } => { + RecordOp::Update { + collection, + rkey, + cid, + prev, + } => { let mut obj = json!({ "action": "update", "path": format!("{}/{}", collection, rkey), @@ -214,8 +271,12 @@ pub async fn commit_and_log( obj["prev"] = json!(prev_cid.to_string()); } obj - }, - RecordOp::Delete { collection, rkey, prev } => { + } + RecordOp::Delete { + collection, + rkey, + prev, + } => { let mut obj = json!({ "action": "delete", "path": format!("{}/{}", collection, rkey), @@ -225,9 +286,9 @@ pub async fn commit_and_log( obj["prev"] = json!(prev_cid.to_string()); } obj - }, - } - }).collect::>(); + } + }) + .collect::>(); let event_type = "commit"; let prev_cid_str = current_root_cid.map(|c| c.to_string()); let prev_data_cid_str = prev_data_cid.map(|c| c.to_string()); @@ -249,13 +310,12 @@ pub async fn commit_and_log( .fetch_one(&mut *tx) .await .map_err(|e| format!("DB Error (repo_seq): {}", e))?; - sqlx::query( - &format!("NOTIFY repo_updates, '{}'", seq_row.seq) - ) - .execute(&mut *tx) - .await - .map_err(|e| format!("DB Error (notify): {}", e))?; - tx.commit().await + sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq_row.seq)) + .execute(&mut *tx) + .await + .map_err(|e| format!("DB Error (notify): {}", e))?; + tx.commit() + .await .map_err(|e| format!("Failed to commit transaction: {}", e))?; let _ = sequence_sync_event(state, did, &new_root_cid.to_string()).await; Ok(CommitResult { @@ -278,16 +338,20 @@ pub async fn create_record_internal( .await .map_err(|e| format!("DB error: {}", e))? .ok_or_else(|| "User not found".to_string())?; - let root_cid_str: String = - sqlx::query_scalar!("SELECT repo_root_cid FROM repos WHERE user_id = $1", user_id) - .fetch_optional(&state.db) - .await - .map_err(|e| format!("DB error: {}", e))? - .ok_or_else(|| "Repo not found".to_string())?; - let current_root_cid = Cid::from_str(&root_cid_str) - .map_err(|_| "Invalid repo root CID".to_string())?; + let root_cid_str: String = sqlx::query_scalar!( + "SELECT repo_root_cid FROM repos WHERE user_id = $1", + user_id + ) + .fetch_optional(&state.db) + .await + .map_err(|e| format!("DB error: {}", e))? + .ok_or_else(|| "Repo not found".to_string())?; + let current_root_cid = + Cid::from_str(&root_cid_str).map_err(|_| "Invalid repo root CID".to_string())?; let tracking_store = TrackingBlockStore::new(state.block_store.clone()); - let commit_bytes = tracking_store.get(¤t_root_cid).await + let commit_bytes = tracking_store + .get(¤t_root_cid) + .await .map_err(|e| format!("Failed to fetch commit: {:?}", e))? .ok_or_else(|| "Commit block not found".to_string())?; let commit = jacquard_repo::commit::Commit::from_cbor(&commit_bytes) @@ -296,12 +360,18 @@ pub async fn create_record_internal( let mut record_bytes = Vec::new(); serde_ipld_dagcbor::to_writer(&mut record_bytes, record) .map_err(|e| format!("Failed to serialize record: {:?}", e))?; - let record_cid = tracking_store.put(&record_bytes).await + let record_cid = tracking_store + .put(&record_bytes) + .await .map_err(|e| format!("Failed to save record block: {:?}", e))?; let key = format!("{}/{}", collection, rkey); - let new_mst = mst.add(&key, record_cid).await + let new_mst = mst + .add(&key, record_cid) + .await .map_err(|e| format!("Failed to add to MST: {:?}", e))?; - let new_mst_root = new_mst.persist().await + let new_mst_root = new_mst + .persist() + .await .map_err(|e| format!("Failed to persist MST: {:?}", e))?; let op = RecordOp::Create { collection: collection.to_string(), @@ -309,9 +379,12 @@ pub async fn create_record_internal( cid: record_cid, }; let mut relevant_blocks = std::collections::BTreeMap::new(); - new_mst.blocks_for_path(&key, &mut relevant_blocks).await + new_mst + .blocks_for_path(&key, &mut relevant_blocks) + .await .map_err(|e| format!("Failed to get new MST blocks for path: {:?}", e))?; - mst.blocks_for_path(&key, &mut relevant_blocks).await + mst.blocks_for_path(&key, &mut relevant_blocks) + .await .map_err(|e| format!("Failed to get old MST blocks for path: {:?}", e))?; relevant_blocks.insert(record_cid, bytes::Bytes::from(record_bytes)); let mut written_cids = tracking_store.get_all_relevant_cids(); @@ -323,14 +396,17 @@ pub async fn create_record_internal( let written_cids_str: Vec = written_cids.iter().map(|c| c.to_string()).collect(); let result = commit_and_log( state, - did, - user_id, - Some(current_root_cid), - Some(commit.data), - new_mst_root, - vec![op], - &written_cids_str, - ).await?; + CommitParams { + did, + user_id, + current_root_cid: Some(current_root_cid), + prev_data_cid: Some(commit.data), + new_mst_root, + ops: vec![op], + blocks_cids: &written_cids_str, + }, + ) + .await?; let uri = format!("at://{}/{}/{}", did, collection, rkey); Ok((uri, result.commit_cid)) } diff --git a/src/api/repo/record/validation.rs b/src/api/repo/record/validation.rs index 20efd4b..84a6bcc 100644 --- a/src/api/repo/record/validation.rs +++ b/src/api/repo/record/validation.rs @@ -1,38 +1,38 @@ use crate::validation::{RecordValidator, ValidationError}; use axum::{ + Json, http::StatusCode, response::{IntoResponse, Response}, - Json, }; use serde_json::json; -pub fn validate_record(record: &serde_json::Value, collection: &str) -> Result<(), Response> { +pub fn validate_record(record: &serde_json::Value, collection: &str) -> Result<(), Box> { let validator = RecordValidator::new(); match validator.validate(record, collection) { Ok(_) => Ok(()), - Err(ValidationError::MissingType) => Err(( + Err(ValidationError::MissingType) => Err(Box::new(( StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Record must have a $type field"})), - ).into_response()), - Err(ValidationError::TypeMismatch { expected, actual }) => Err(( + ).into_response())), + Err(ValidationError::TypeMismatch { expected, actual }) => Err(Box::new(( StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": format!("Record $type '{}' does not match collection '{}'", actual, expected)})), - ).into_response()), - Err(ValidationError::MissingField(field)) => Err(( + ).into_response())), + Err(ValidationError::MissingField(field)) => Err(Box::new(( StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": format!("Missing required field: {}", field)})), - ).into_response()), - Err(ValidationError::InvalidField { path, message }) => Err(( + ).into_response())), + Err(ValidationError::InvalidField { path, message }) => Err(Box::new(( StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": format!("Invalid field '{}': {}", path, message)})), - ).into_response()), - Err(ValidationError::InvalidDatetime { path }) => Err(( + ).into_response())), + Err(ValidationError::InvalidDatetime { path }) => Err(Box::new(( StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": format!("Invalid datetime format at '{}'", path)})), - ).into_response()), - Err(e) => Err(( + ).into_response())), + Err(e) => Err(Box::new(( StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": e.to_string()})), - ).into_response()), + ).into_response())), } } diff --git a/src/api/repo/record/write.rs b/src/api/repo/record/write.rs index be378fa..9d91cbb 100644 --- a/src/api/repo/record/write.rs +++ b/src/api/repo/record/write.rs @@ -1,15 +1,18 @@ use super::validation::validate_record; -use crate::api::repo::record::utils::{commit_and_log, RecordOp}; +use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log}; use crate::repo::tracking::TrackingBlockStore; use crate::state::AppState; use axum::{ + Json, extract::State, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, - Json, }; use cid::Cid; -use jacquard::types::{integer::LimitedU32, string::{Nsid, Tid}}; +use jacquard::types::{ + integer::LimitedU32, + string::{Nsid, Tid}, +}; use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -19,7 +22,10 @@ use std::sync::Arc; use tracing::error; use uuid::Uuid; -pub async fn has_verified_notification_channel(db: &PgPool, did: &str) -> Result { +pub async fn has_verified_notification_channel( + db: &PgPool, + did: &str, +) -> Result { let row = sqlx::query( r#" SELECT @@ -29,7 +35,7 @@ pub async fn has_verified_notification_channel(db: &PgPool, did: &str) -> Result signal_verified FROM users WHERE did = $1 - "# + "#, ) .bind(did) .fetch_optional(db) @@ -52,8 +58,9 @@ pub async fn prepare_repo_write( repo_did: &str, ) -> Result<(String, Uuid, Cid), Response> { let token = crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) - ).ok_or_else(|| { + headers.get("Authorization").and_then(|h| h.to_str().ok()), + ) + .ok_or_else(|| { ( StatusCode::UNAUTHORIZED, Json(json!({"error": "AuthenticationRequired"})), @@ -102,7 +109,11 @@ pub async fn prepare_repo_write( .await .map_err(|e| { error!("DB error fetching user: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"}))).into_response() + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response() })? .ok_or_else(|| { ( @@ -111,21 +122,27 @@ pub async fn prepare_repo_write( ) .into_response() })?; - let root_cid_str: String = - sqlx::query_scalar!("SELECT repo_root_cid FROM repos WHERE user_id = $1", user_id) - .fetch_optional(&state.db) - .await - .map_err(|e| { - error!("DB error fetching repo root: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"}))).into_response() - })? - .ok_or_else(|| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Repo root not found"})), - ) - .into_response() - })?; + let root_cid_str: String = sqlx::query_scalar!( + "SELECT repo_root_cid FROM repos WHERE user_id = $1", + user_id + ) + .fetch_optional(&state.db) + .await + .map_err(|e| { + error!("DB error fetching repo root: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response() + })? + .ok_or_else(|| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Repo root not found"})), + ) + .into_response() + })?; let current_root_cid = Cid::from_str(&root_cid_str).map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, @@ -162,62 +179,102 @@ pub async fn create_record( Ok(res) => res, Err(err_res) => return err_res, }; - if let Some(swap_commit) = &input.swap_commit { - if Cid::from_str(swap_commit).ok() != Some(current_root_cid) { + if let Some(swap_commit) = &input.swap_commit + && Cid::from_str(swap_commit).ok() != Some(current_root_cid) { return ( StatusCode::CONFLICT, Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})), ) .into_response(); } - } let tracking_store = TrackingBlockStore::new(state.block_store.clone()); let commit_bytes = match tracking_store.get(¤t_root_cid).await { Ok(Some(b)) => b, - _ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Commit block not found"}))).into_response(), + _ => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Commit block not found"})), + ) + .into_response(); + } }; let commit = match Commit::from_cbor(&commit_bytes) { Ok(c) => c, - _ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to parse commit"}))).into_response(), + _ => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to parse commit"})), + ) + .into_response(); + } }; - let mst = Mst::load( - Arc::new(tracking_store.clone()), - commit.data, - None, - ); + let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None); let collection_nsid = match input.collection.parse::() { Ok(n) => n, - Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidCollection"}))).into_response(), - }; - if input.validate.unwrap_or(true) { - if let Err(err_response) = validate_record(&input.record, &input.collection) { - return err_response; + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidCollection"})), + ) + .into_response(); } - } - let rkey = input.rkey.unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string()); + }; + if input.validate.unwrap_or(true) + && let Err(err_response) = validate_record(&input.record, &input.collection) { + return *err_response; + } + let rkey = input + .rkey + .unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string()); let mut record_bytes = Vec::new(); if serde_ipld_dagcbor::to_writer(&mut record_bytes, &input.record).is_err() { - return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response(); + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})), + ) + .into_response(); } let record_cid = match tracking_store.put(&record_bytes).await { Ok(c) => c, - _ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to save record block"}))).into_response(), + _ => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to save record block"})), + ) + .into_response(); + } }; let key = format!("{}/{}", collection_nsid, rkey); let new_mst = match mst.add(&key, record_cid).await { Ok(m) => m, - _ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to add to MST"}))).into_response(), + _ => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to add to MST"})), + ) + .into_response(); + } }; let new_mst_root = match new_mst.persist().await { Ok(c) => c, - _ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to persist MST"}))).into_response(), + _ => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to persist MST"})), + ) + .into_response(); + } + }; + let op = RecordOp::Create { + collection: input.collection.clone(), + rkey: rkey.clone(), + cid: record_cid, }; - let op = RecordOp::Create { collection: input.collection.clone(), rkey: rkey.clone(), cid: record_cid }; let mut relevant_blocks = std::collections::BTreeMap::new(); - if let Err(_) = new_mst.blocks_for_path(&key, &mut relevant_blocks).await { + if new_mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() { return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response(); } - if let Err(_) = mst.blocks_for_path(&key, &mut relevant_blocks).await { + if mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() { return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response(); } relevant_blocks.insert(record_cid, bytes::Bytes::from(record_bytes)); @@ -227,14 +284,38 @@ pub async fn create_record( written_cids.push(*cid); } } - let written_cids_str = written_cids.iter().map(|c| c.to_string()).collect::>(); - if let Err(e) = commit_and_log(&state, &did, user_id, Some(current_root_cid), Some(commit.data), new_mst_root, vec![op], &written_cids_str).await { - return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": e}))).into_response(); + let written_cids_str = written_cids + .iter() + .map(|c| c.to_string()) + .collect::>(); + if let Err(e) = commit_and_log( + &state, + CommitParams { + did: &did, + user_id, + current_root_cid: Some(current_root_cid), + prev_data_cid: Some(commit.data), + new_mst_root, + ops: vec![op], + blocks_cids: &written_cids_str, + }, + ) + .await + { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": e})), + ) + .into_response(); }; - (StatusCode::OK, Json(CreateRecordOutput { - uri: format!("at://{}/{}/{}", did, input.collection, rkey), - cid: record_cid.to_string(), - })).into_response() + ( + StatusCode::OK, + Json(CreateRecordOutput { + uri: format!("at://{}/{}/{}", did, input.collection, rkey), + cid: record_cid.to_string(), + }), + ) + .into_response() } #[derive(Deserialize)] #[allow(dead_code)] @@ -265,35 +346,51 @@ pub async fn put_record( Ok(res) => res, Err(err_res) => return err_res, }; - if let Some(swap_commit) = &input.swap_commit { - if Cid::from_str(swap_commit).ok() != Some(current_root_cid) { - return (StatusCode::CONFLICT, Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"}))).into_response(); + if let Some(swap_commit) = &input.swap_commit + && Cid::from_str(swap_commit).ok() != Some(current_root_cid) { + return ( + StatusCode::CONFLICT, + Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})), + ) + .into_response(); } - } let tracking_store = TrackingBlockStore::new(state.block_store.clone()); let commit_bytes = match tracking_store.get(¤t_root_cid).await { Ok(Some(b)) => b, - _ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Commit block not found"}))).into_response(), + _ => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Commit block not found"})), + ) + .into_response(); + } }; let commit = match Commit::from_cbor(&commit_bytes) { Ok(c) => c, - _ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to parse commit"}))).into_response(), + _ => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to parse commit"})), + ) + .into_response(); + } }; - let mst = Mst::load( - Arc::new(tracking_store.clone()), - commit.data, - None, - ); + let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None); let collection_nsid = match input.collection.parse::() { Ok(n) => n, - Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidCollection"}))).into_response(), + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidCollection"})), + ) + .into_response(); + } }; let key = format!("{}/{}", collection_nsid, input.rkey); - if input.validate.unwrap_or(true) { - if let Err(err_response) = validate_record(&input.record, &input.collection) { - return err_response; + if input.validate.unwrap_or(true) + && let Err(err_response) = validate_record(&input.record, &input.collection) { + return *err_response; } - } if let Some(swap_record_str) = &input.swap_record { let expected_cid = Cid::from_str(swap_record_str).ok(); let actual_cid = mst.get(&key).await.ok().flatten(); @@ -304,37 +401,74 @@ pub async fn put_record( let existing_cid = mst.get(&key).await.ok().flatten(); let mut record_bytes = Vec::new(); if serde_ipld_dagcbor::to_writer(&mut record_bytes, &input.record).is_err() { - return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response(); + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})), + ) + .into_response(); } let record_cid = match tracking_store.put(&record_bytes).await { Ok(c) => c, - _ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to save record block"}))).into_response(), + _ => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to save record block"})), + ) + .into_response(); + } }; let new_mst = if existing_cid.is_some() { match mst.update(&key, record_cid).await { Ok(m) => m, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to update MST"}))).into_response(), + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to update MST"})), + ) + .into_response(); + } } } else { match mst.add(&key, record_cid).await { Ok(m) => m, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to add to MST"}))).into_response(), + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to add to MST"})), + ) + .into_response(); + } } }; let new_mst_root = match new_mst.persist().await { Ok(c) => c, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to persist MST"}))).into_response(), + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to persist MST"})), + ) + .into_response(); + } }; let op = if existing_cid.is_some() { - RecordOp::Update { collection: input.collection.clone(), rkey: input.rkey.clone(), cid: record_cid, prev: existing_cid } + RecordOp::Update { + collection: input.collection.clone(), + rkey: input.rkey.clone(), + cid: record_cid, + prev: existing_cid, + } } else { - RecordOp::Create { collection: input.collection.clone(), rkey: input.rkey.clone(), cid: record_cid } + RecordOp::Create { + collection: input.collection.clone(), + rkey: input.rkey.clone(), + cid: record_cid, + } }; let mut relevant_blocks = std::collections::BTreeMap::new(); - if let Err(_) = new_mst.blocks_for_path(&key, &mut relevant_blocks).await { + if new_mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() { return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response(); } - if let Err(_) = mst.blocks_for_path(&key, &mut relevant_blocks).await { + if mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() { return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response(); } relevant_blocks.insert(record_cid, bytes::Bytes::from(record_bytes)); @@ -344,12 +478,36 @@ pub async fn put_record( written_cids.push(*cid); } } - let written_cids_str = written_cids.iter().map(|c| c.to_string()).collect::>(); - if let Err(e) = commit_and_log(&state, &did, user_id, Some(current_root_cid), Some(commit.data), new_mst_root, vec![op], &written_cids_str).await { - return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": e}))).into_response(); + let written_cids_str = written_cids + .iter() + .map(|c| c.to_string()) + .collect::>(); + if let Err(e) = commit_and_log( + &state, + CommitParams { + did: &did, + user_id, + current_root_cid: Some(current_root_cid), + prev_data_cid: Some(commit.data), + new_mst_root, + ops: vec![op], + blocks_cids: &written_cids_str, + }, + ) + .await + { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": e})), + ) + .into_response(); }; - (StatusCode::OK, Json(PutRecordOutput { - uri: format!("at://{}/{}/{}", did, input.collection, input.rkey), - cid: record_cid.to_string(), - })).into_response() + ( + StatusCode::OK, + Json(PutRecordOutput { + uri: format!("at://{}/{}/{}", did, input.collection, input.rkey), + cid: record_cid.to_string(), + }), + ) + .into_response() } diff --git a/src/api/server/account_status.rs b/src/api/server/account_status.rs index ca861f7..abbefee 100644 --- a/src/api/server/account_status.rs +++ b/src/api/server/account_status.rs @@ -32,14 +32,16 @@ pub async fn check_account_status( headers: axum::http::HeaderMap, ) -> Response { let extracted = match crate::auth::extract_auth_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) + headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, None => return ApiError::AuthenticationRequired.into_response(), }; let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); - let http_uri = format!("https://{}/xrpc/com.atproto.server.checkAccountStatus", - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())); + let http_uri = format!( + "https://{}/xrpc/com.atproto.server.checkAccountStatus", + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) + ); let did = match crate::auth::validate_token_with_dpop( &state.db, &extracted.token, @@ -48,7 +50,9 @@ pub async fn check_account_status( "GET", &http_uri, true, - ).await { + ) + .await + { Ok(user) => user.did, Err(e) => return ApiError::from(e).into_response(), }; @@ -72,24 +76,30 @@ pub async fn check_account_status( Ok(Some(row)) => row.deactivated_at, _ => None, }; - let repo_result = sqlx::query!("SELECT repo_root_cid FROM repos WHERE user_id = $1", user_id) - .fetch_optional(&state.db) - .await; + let repo_result = sqlx::query!( + "SELECT repo_root_cid FROM repos WHERE user_id = $1", + user_id + ) + .fetch_optional(&state.db) + .await; let repo_commit = match repo_result { Ok(Some(row)) => row.repo_root_cid, _ => String::new(), }; - let record_count: i64 = sqlx::query_scalar!("SELECT COUNT(*) FROM records WHERE repo_id = $1", user_id) - .fetch_one(&state.db) - .await - .unwrap_or(Some(0)) - .unwrap_or(0); - let blob_count: i64 = - sqlx::query_scalar!("SELECT COUNT(*) FROM blobs WHERE created_by_user = $1", user_id) + let record_count: i64 = + sqlx::query_scalar!("SELECT COUNT(*) FROM records WHERE repo_id = $1", user_id) .fetch_one(&state.db) .await .unwrap_or(Some(0)) .unwrap_or(0); + let blob_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM blobs WHERE created_by_user = $1", + user_id + ) + .fetch_one(&state.db) + .await + .unwrap_or(Some(0)) + .unwrap_or(0); let valid_did = did.starts_with("did:"); ( StatusCode::OK, @@ -113,14 +123,16 @@ pub async fn activate_account( headers: axum::http::HeaderMap, ) -> Response { let extracted = match crate::auth::extract_auth_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) + headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, None => return ApiError::AuthenticationRequired.into_response(), }; let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); - let http_uri = format!("https://{}/xrpc/com.atproto.server.activateAccount", - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())); + let http_uri = format!( + "https://{}/xrpc/com.atproto.server.activateAccount", + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) + ); let did = match crate::auth::validate_token_with_dpop( &state.db, &extracted.token, @@ -129,7 +141,9 @@ pub async fn activate_account( "POST", &http_uri, true, - ).await { + ) + .await + { Ok(user) => user.did, Err(e) => return ApiError::from(e).into_response(), }; @@ -171,14 +185,16 @@ pub async fn deactivate_account( Json(_input): Json, ) -> Response { let extracted = match crate::auth::extract_auth_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) + headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, None => return ApiError::AuthenticationRequired.into_response(), }; let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); - let http_uri = format!("https://{}/xrpc/com.atproto.server.deactivateAccount", - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())); + let http_uri = format!( + "https://{}/xrpc/com.atproto.server.deactivateAccount", + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) + ); let did = match crate::auth::validate_token_with_dpop( &state.db, &extracted.token, @@ -187,7 +203,9 @@ pub async fn deactivate_account( "POST", &http_uri, false, - ).await { + ) + .await + { Ok(user) => user.did, Err(e) => return ApiError::from(e).into_response(), }; @@ -196,9 +214,12 @@ pub async fn deactivate_account( .await .ok() .flatten(); - let result = sqlx::query!("UPDATE users SET deactivated_at = NOW() WHERE did = $1", did) - .execute(&state.db) - .await; + let result = sqlx::query!( + "UPDATE users SET deactivated_at = NOW() WHERE did = $1", + did + ) + .execute(&state.db) + .await; match result { Ok(_) => { if let Some(h) = handle { @@ -222,14 +243,16 @@ pub async fn request_account_delete( headers: axum::http::HeaderMap, ) -> Response { let extracted = match crate::auth::extract_auth_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) + headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, None => return ApiError::AuthenticationRequired.into_response(), }; let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); - let http_uri = format!("https://{}/xrpc/com.atproto.server.requestAccountDelete", - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())); + let http_uri = format!( + "https://{}/xrpc/com.atproto.server.requestAccountDelete", + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) + ); let did = match crate::auth::validate_token_with_dpop( &state.db, &extracted.token, @@ -238,7 +261,9 @@ pub async fn request_account_delete( "POST", &http_uri, true, - ).await { + ) + .await + { Ok(user) => user.did, Err(e) => return ApiError::from(e).into_response(), }; @@ -274,8 +299,13 @@ pub async fn request_account_delete( .into_response(); } let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - if let Err(e) = - crate::notifications::enqueue_account_deletion(&state.db, user_id, &confirmation_token, &hostname).await + if let Err(e) = crate::notifications::enqueue_account_deletion( + &state.db, + user_id, + &confirmation_token, + &hostname, + ) + .await { warn!("Failed to enqueue account deletion notification: {:?}", e); } @@ -395,9 +425,12 @@ pub async fn delete_account( .into_response(); } if Utc::now() > expires_at { - let _ = sqlx::query!("DELETE FROM account_deletion_requests WHERE token = $1", token) - .execute(&state.db) - .await; + let _ = sqlx::query!( + "DELETE FROM account_deletion_requests WHERE token = $1", + token + ) + .execute(&state.db) + .await; return ( StatusCode::BAD_REQUEST, Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), diff --git a/src/api/server/app_password.rs b/src/api/server/app_password.rs index 8886334..6728c31 100644 --- a/src/api/server/app_password.rs +++ b/src/api/server/app_password.rs @@ -80,7 +80,10 @@ pub async fn create_app_password( Json(input): Json, ) -> Response { let client_ip = crate::rate_limit::extract_client_ip(&headers, None); - if !state.check_rate_limit(RateLimitKind::AppPassword, &client_ip).await { + if !state + .check_rate_limit(RateLimitKind::AppPassword, &client_ip) + .await + { warn!(ip = %client_ip, "App password creation rate limit exceeded"); return ( axum::http::StatusCode::TOO_MANY_REQUESTS, @@ -88,7 +91,8 @@ pub async fn create_app_password( "error": "RateLimitExceeded", "message": "Too many requests. Please try again later." })), - ).into_response(); + ) + .into_response(); } let user_id = match get_user_id_by_did(&state.db, &auth_user.did).await { Ok(id) => id, diff --git a/src/api/server/email.rs b/src/api/server/email.rs index c2b1247..70b9014 100644 --- a/src/api/server/email.rs +++ b/src/api/server/email.rs @@ -27,7 +27,10 @@ pub async fn request_email_update( Json(input): Json, ) -> Response { let client_ip = crate::rate_limit::extract_client_ip(&headers, None); - if !state.check_rate_limit(RateLimitKind::EmailUpdate, &client_ip).await { + if !state + .check_rate_limit(RateLimitKind::EmailUpdate, &client_ip) + .await + { warn!(ip = %client_ip, "Email update rate limit exceeded"); return ( StatusCode::TOO_MANY_REQUESTS, @@ -35,10 +38,11 @@ pub async fn request_email_update( "error": "RateLimitExceeded", "message": "Too many requests. Please try again later." })), - ).into_response(); + ) + .into_response(); } let token = match crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) + headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, None => { @@ -108,12 +112,7 @@ pub async fn request_email_update( } let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); if let Err(e) = crate::notifications::enqueue_email_update( - &state.db, - user_id, - &email, - &handle, - &code, - &hostname, + &state.db, user_id, &email, &handle, &code, &hostname, ) .await { @@ -136,7 +135,10 @@ pub async fn confirm_email( Json(input): Json, ) -> Response { let client_ip = crate::rate_limit::extract_client_ip(&headers, None); - if !state.check_rate_limit(RateLimitKind::AppPassword, &client_ip).await { + if !state + .check_rate_limit(RateLimitKind::AppPassword, &client_ip) + .await + { warn!(ip = %client_ip, "Confirm email rate limit exceeded"); return ( StatusCode::TOO_MANY_REQUESTS, @@ -144,10 +146,11 @@ pub async fn confirm_email( "error": "RateLimitExceeded", "message": "Too many requests. Please try again later." })), - ).into_response(); + ) + .into_response(); } let token = match crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) + headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, None => { @@ -185,16 +188,19 @@ pub async fn confirm_email( let email_pending_verification = user.email_pending_verification; let email = input.email.trim().to_lowercase(); let confirmation_code = input.token.trim(); - let (pending_email, saved_code, expiry) = match (email_pending_verification, stored_code, expires_at) { - (Some(p), Some(c), Some(e)) => (p, c, e), - _ => { - return ( + let (pending_email, saved_code, expiry) = + match (email_pending_verification, stored_code, expires_at) { + (Some(p), Some(c), Some(e)) => (p, c, e), + _ => { + return ( StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "No pending email update found"})), + Json( + json!({"error": "InvalidRequest", "message": "No pending email update found"}), + ), ) .into_response(); - } - }; + } + }; if pending_email != email { return ( StatusCode::BAD_REQUEST, @@ -203,7 +209,7 @@ pub async fn confirm_email( .into_response(); } if saved_code != confirmation_code { - return ( + return ( StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidToken", "message": "Invalid token"})), ) @@ -225,13 +231,16 @@ pub async fn confirm_email( .await; if let Err(e) = update { error!("DB error finalizing email update: {:?}", e); - if e.as_database_error().map(|db_err| db_err.is_unique_violation()).unwrap_or(false) { - return ( + if e.as_database_error() + .map(|db_err| db_err.is_unique_violation()) + .unwrap_or(false) + { + return ( StatusCode::BAD_REQUEST, Json(json!({"error": "EmailTaken", "message": "Email already taken"})), ) .into_response(); - } + } return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), @@ -257,7 +266,7 @@ pub async fn update_email( Json(input): Json, ) -> Response { let token = match crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) + headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, None => { @@ -302,11 +311,10 @@ pub async fn update_email( ) .into_response(); } - if let Some(ref current) = current_email { - if new_email == current.to_lowercase() { + if let Some(ref current) = current_email + && new_email == current.to_lowercase() { return (StatusCode::OK, Json(json!({}))).into_response(); } - } let email_confirmed = stored_code.is_some() && email_pending_verification.is_some(); if email_confirmed { let confirmation_token = match &input.token { @@ -353,15 +361,14 @@ pub async fn update_email( ) .into_response(); } - if let Some(exp) = expires_at { - if Utc::now() > exp { + if let Some(exp) = expires_at + && Utc::now() > exp { return ( StatusCode::BAD_REQUEST, Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), ) .into_response(); } - } } let exists = sqlx::query!( "SELECT 1 as one FROM users WHERE LOWER(email) = $1 AND id != $2", diff --git a/src/api/server/invite.rs b/src/api/server/invite.rs index eb3714e..52d4b54 100644 --- a/src/api/server/invite.rs +++ b/src/api/server/invite.rs @@ -143,17 +143,18 @@ pub async fn create_invite_codes( }); } else { for account_did in for_accounts { - let target_user_id = match sqlx::query!("SELECT id FROM users WHERE did = $1", account_did) - .fetch_optional(&state.db) - .await - { - Ok(Some(row)) => row.id, - Ok(None) => continue, - Err(e) => { - error!("DB error looking up target account: {:?}", e); - return ApiError::InternalError.into_response(); - } - }; + let target_user_id = + match sqlx::query!("SELECT id FROM users WHERE did = $1", account_did) + .fetch_optional(&state.db) + .await + { + Ok(Some(row)) => row.id, + Ok(None) => continue, + Err(e) => { + error!("DB error looking up target account: {:?}", e); + return ApiError::InternalError.into_response(); + } + }; let mut codes = Vec::new(); for _ in 0..code_count { let code = Uuid::new_v4().to_string(); @@ -177,7 +178,10 @@ pub async fn create_invite_codes( }); } } - Json(CreateInviteCodesOutput { codes: result_codes }).into_response() + Json(CreateInviteCodesOutput { + codes: result_codes, + }) + .into_response() } #[derive(Deserialize)] diff --git a/src/api/server/mod.rs b/src/api/server/mod.rs index 0b3acc7..6043762 100644 --- a/src/api/server/mod.rs +++ b/src/api/server/mod.rs @@ -18,5 +18,8 @@ pub use invite::{create_invite_code, create_invite_codes, get_account_invite_cod pub use meta::{describe_server, health, robots_txt}; pub use password::{request_password_reset, reset_password}; pub use service_auth::get_service_auth; -pub use session::{confirm_signup, create_session, delete_session, get_session, refresh_session, resend_verification}; +pub use session::{ + confirm_signup, create_session, delete_session, get_session, refresh_session, + resend_verification, +}; pub use signing_key::reserve_signing_key; diff --git a/src/api/server/password.rs b/src/api/server/password.rs index c6ecd42..92de55c 100644 --- a/src/api/server/password.rs +++ b/src/api/server/password.rs @@ -5,7 +5,7 @@ use axum::{ http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; -use bcrypt::{hash, DEFAULT_COST}; +use bcrypt::{DEFAULT_COST, hash}; use chrono::{Duration, Utc}; use serde::Deserialize; use serde_json::json; @@ -15,18 +15,15 @@ fn generate_reset_code() -> String { crate::util::generate_token_code() } fn extract_client_ip(headers: &HeaderMap) -> String { - if let Some(forwarded) = headers.get("x-forwarded-for") { - if let Ok(value) = forwarded.to_str() { - if let Some(first_ip) = value.split(',').next() { + if let Some(forwarded) = headers.get("x-forwarded-for") + && let Ok(value) = forwarded.to_str() + && let Some(first_ip) = value.split(',').next() { return first_ip.trim().to_string(); } - } - } - if let Some(real_ip) = headers.get("x-real-ip") { - if let Ok(value) = real_ip.to_str() { + if let Some(real_ip) = headers.get("x-real-ip") + && let Ok(value) = real_ip.to_str() { return value.trim().to_string(); } - } "unknown".to_string() } @@ -41,7 +38,10 @@ pub async fn request_password_reset( Json(input): Json, ) -> Response { let client_ip = extract_client_ip(&headers); - if !state.check_rate_limit(RateLimitKind::PasswordReset, &client_ip).await { + if !state + .check_rate_limit(RateLimitKind::PasswordReset, &client_ip) + .await + { warn!(ip = %client_ip, "Password reset rate limit exceeded"); return ( StatusCode::TOO_MANY_REQUESTS, @@ -118,7 +118,10 @@ pub async fn reset_password( Json(input): Json, ) -> Response { let client_ip = extract_client_ip(&headers); - if !state.check_rate_limit(RateLimitKind::ResetPassword, &client_ip).await { + if !state + .check_rate_limit(RateLimitKind::ResetPassword, &client_ip) + .await + { warn!(ip = %client_ip, "Reset password rate limit exceeded"); return ( StatusCode::TOO_MANY_REQUESTS, @@ -126,7 +129,8 @@ pub async fn reset_password( "error": "RateLimitExceeded", "message": "Too many requests. Please try again later." })), - ).into_response(); + ) + .into_response(); } let token = input.token.trim(); let password = &input.password; @@ -232,12 +236,9 @@ pub async fn reset_password( ) .into_response(); } - let user_did = match sqlx::query_scalar!( - "SELECT did FROM users WHERE id = $1", - user_id - ) - .fetch_one(&mut *tx) - .await + let user_did = match sqlx::query_scalar!("SELECT did FROM users WHERE id = $1", user_id) + .fetch_one(&mut *tx) + .await { Ok(did) => did, Err(e) => { @@ -266,7 +267,10 @@ pub async fn reset_password( .execute(&mut *tx) .await { - error!("Failed to invalidate sessions after password reset: {:?}", e); + error!( + "Failed to invalidate sessions after password reset: {:?}", + e + ); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), @@ -284,7 +288,10 @@ pub async fn reset_password( for jti in session_jtis { let cache_key = format!("auth:session:{}:{}", user_did, jti); if let Err(e) = state.cache.delete(&cache_key).await { - warn!("Failed to invalidate session cache for {}: {:?}", cache_key, e); + warn!( + "Failed to invalidate session cache for {}: {:?}", + cache_key, e + ); } } info!("Password reset completed for user {}", user_id); diff --git a/src/api/server/service_auth.rs b/src/api/server/service_auth.rs index 12da0e9..e840330 100644 --- a/src/api/server/service_auth.rs +++ b/src/api/server/service_auth.rs @@ -28,7 +28,7 @@ pub async fn get_service_auth( Query(params): Query, ) -> Response { let token = match crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) + headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, None => return ApiError::AuthenticationRequired.into_response(), @@ -39,20 +39,31 @@ pub async fn get_service_auth( }; let key_bytes = match auth_user.key_bytes { Some(kb) => kb, - None => return ApiError::AuthenticationFailedMsg("OAuth tokens cannot create service auth".into()).into_response(), - }; - let lxm = params.lxm.as_deref().unwrap_or("*"); - let service_token = match crate::auth::create_service_token(&auth_user.did, ¶ms.aud, lxm, &key_bytes) - { - Ok(t) => t, - Err(e) => { - error!("Failed to create service token: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), + None => { + return ApiError::AuthenticationFailedMsg( + "OAuth tokens cannot create service auth".into(), ) - .into_response(); + .into_response(); } }; - (StatusCode::OK, Json(GetServiceAuthOutput { token: service_token })).into_response() + let lxm = params.lxm.as_deref().unwrap_or("*"); + let service_token = + match crate::auth::create_service_token(&auth_user.did, ¶ms.aud, lxm, &key_bytes) { + Ok(t) => t, + Err(e) => { + error!("Failed to create service token: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + ( + StatusCode::OK, + Json(GetServiceAuthOutput { + token: service_token, + }), + ) + .into_response() } diff --git a/src/api/server/session.rs b/src/api/server/session.rs index cbf80a2..190dc1a 100644 --- a/src/api/server/session.rs +++ b/src/api/server/session.rs @@ -14,18 +14,15 @@ use serde_json::json; use tracing::{error, info, warn}; fn extract_client_ip(headers: &HeaderMap) -> String { - if let Some(forwarded) = headers.get("x-forwarded-for") { - if let Ok(value) = forwarded.to_str() { - if let Some(first_ip) = value.split(',').next() { + if let Some(forwarded) = headers.get("x-forwarded-for") + && let Ok(value) = forwarded.to_str() + && let Some(first_ip) = value.split(',').next() { return first_ip.trim().to_string(); } - } - } - if let Some(real_ip) = headers.get("x-real-ip") { - if let Ok(value) = real_ip.to_str() { + if let Some(real_ip) = headers.get("x-real-ip") + && let Ok(value) = real_ip.to_str() { return value.trim().to_string(); } - } "unknown".to_string() } @@ -60,7 +57,10 @@ pub async fn create_session( ) -> Response { info!("create_session called"); let client_ip = extract_client_ip(&headers); - if !state.check_rate_limit(RateLimitKind::Login, &client_ip).await { + if !state + .check_rate_limit(RateLimitKind::Login, &client_ip) + .await + { warn!(ip = %client_ip, "Login rate limit exceeded"); return ( StatusCode::TOO_MANY_REQUESTS, @@ -88,9 +88,13 @@ pub async fn create_session( { Ok(Some(row)) => row, Ok(None) => { - let _ = verify(&input.password, "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK"); + let _ = verify( + &input.password, + "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK", + ); warn!("User not found for login attempt"); - return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into()).into_response(); + return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into()) + .into_response(); } Err(e) => { error!("Database error fetching user: {:?}", e); @@ -114,16 +118,17 @@ pub async fn create_session( .fetch_all(&state.db) .await .unwrap_or_default(); - app_passwords.iter().any(|app| verify(&input.password, &app.password_hash).unwrap_or(false)) + app_passwords + .iter() + .any(|app| verify(&input.password, &app.password_hash).unwrap_or(false)) }; if !password_valid { warn!("Password verification failed for login attempt"); - return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into()).into_response(); + return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into()) + .into_response(); } - let is_verified = row.email_confirmed - || row.discord_verified - || row.telegram_verified - || row.signal_verified; + let is_verified = + row.email_confirmed || row.discord_verified || row.telegram_verified || row.signal_verified; if !is_verified { warn!("Login attempt for unverified account: {}", row.did); return ( @@ -133,7 +138,8 @@ pub async fn create_session( "message": "Please verify your account before logging in", "did": row.did })), - ).into_response(); + ) + .into_response(); } let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) { Ok(m) => m, @@ -169,7 +175,8 @@ pub async fn create_session( refresh_jwt: refresh_meta.token, handle: full_handle, did: row.did, - }).into_response() + }) + .into_response() } pub async fn get_session( @@ -220,7 +227,7 @@ pub async fn delete_session( headers: axum::http::HeaderMap, ) -> Response { let token = match crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) + headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, None => return ApiError::AuthenticationRequired.into_response(), @@ -254,7 +261,10 @@ pub async fn refresh_session( headers: axum::http::HeaderMap, ) -> Response { let client_ip = crate::rate_limit::extract_client_ip(&headers, None); - if !state.check_rate_limit(RateLimitKind::RefreshSession, &client_ip).await { + if !state + .check_rate_limit(RateLimitKind::RefreshSession, &client_ip) + .await + { tracing::warn!(ip = %client_ip, "Refresh session rate limit exceeded"); return ( axum::http::StatusCode::TOO_MANY_REQUESTS, @@ -262,17 +272,21 @@ pub async fn refresh_session( "error": "RateLimitExceeded", "message": "Too many requests. Please try again later." })), - ).into_response(); + ) + .into_response(); } let refresh_token = match crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) + headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, None => return ApiError::AuthenticationRequired.into_response(), }; let refresh_jti = match crate::auth::get_jti_from_token(&refresh_token) { Ok(jti) => jti, - Err(_) => return ApiError::AuthenticationFailedMsg("Invalid token format".into()).into_response(), + Err(_) => { + return ApiError::AuthenticationFailedMsg("Invalid token format".into()) + .into_response(); + } }; let mut tx = match state.db.begin().await { Ok(tx) => tx, @@ -288,12 +302,18 @@ pub async fn refresh_session( .fetch_optional(&mut *tx) .await { - warn!("Refresh token reuse detected! Revoking token family for session_id: {}", session_id); + warn!( + "Refresh token reuse detected! Revoking token family for session_id: {}", + session_id + ); let _ = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", session_id) .execute(&mut *tx) .await; let _ = tx.commit().await; - return ApiError::ExpiredTokenMsg("Refresh token has been revoked due to suspected compromise".into()).into_response(); + return ApiError::ExpiredTokenMsg( + "Refresh token has been revoked due to suspected compromise".into(), + ) + .into_response(); } let session_row = match sqlx::query!( r#"SELECT st.id, st.did, k.key_bytes, k.encryption_version @@ -308,36 +328,42 @@ pub async fn refresh_session( .await { Ok(Some(row)) => row, - Ok(None) => return ApiError::AuthenticationFailedMsg("Invalid refresh token".into()).into_response(), + Ok(None) => { + return ApiError::AuthenticationFailedMsg("Invalid refresh token".into()) + .into_response(); + } Err(e) => { error!("Database error fetching session: {:?}", e); return ApiError::InternalError.into_response(); } }; - let key_bytes = match crate::config::decrypt_key(&session_row.key_bytes, session_row.encryption_version) { - Ok(k) => k, - Err(e) => { - error!("Failed to decrypt user key: {:?}", e); - return ApiError::InternalError.into_response(); - } - }; + let key_bytes = + match crate::config::decrypt_key(&session_row.key_bytes, session_row.encryption_version) { + Ok(k) => k, + Err(e) => { + error!("Failed to decrypt user key: {:?}", e); + return ApiError::InternalError.into_response(); + } + }; if crate::auth::verify_refresh_token(&refresh_token, &key_bytes).is_err() { return ApiError::AuthenticationFailedMsg("Invalid refresh token".into()).into_response(); } - let new_access_meta = match crate::auth::create_access_token_with_metadata(&session_row.did, &key_bytes) { - Ok(m) => m, - Err(e) => { - error!("Failed to create access token: {:?}", e); - return ApiError::InternalError.into_response(); - } - }; - let new_refresh_meta = match crate::auth::create_refresh_token_with_metadata(&session_row.did, &key_bytes) { - Ok(m) => m, - Err(e) => { - error!("Failed to create refresh token: {:?}", e); - return ApiError::InternalError.into_response(); - } - }; + let new_access_meta = + match crate::auth::create_access_token_with_metadata(&session_row.did, &key_bytes) { + Ok(m) => m, + Err(e) => { + error!("Failed to create access token: {:?}", e); + return ApiError::InternalError.into_response(); + } + }; + let new_refresh_meta = + match crate::auth::create_refresh_token_with_metadata(&session_row.did, &key_bytes) { + Ok(m) => m, + Err(e) => { + error!("Failed to create refresh token: {:?}", e); + return ApiError::InternalError.into_response(); + } + }; match sqlx::query!( "INSERT INTO used_refresh_tokens (refresh_jti, session_id) VALUES ($1, $2) ON CONFLICT (refresh_jti) DO NOTHING", refresh_jti, @@ -482,12 +508,12 @@ pub async fn confirm_signup( warn!("Invalid verification code for user: {}", input.did); return ApiError::InvalidRequest("Invalid verification code".into()).into_response(); } - if let Some(expires_at) = row.email_confirmation_code_expires_at { - if expires_at < Utc::now() { + if let Some(expires_at) = row.email_confirmation_code_expires_at + && expires_at < Utc::now() { warn!("Verification code expired for user: {}", input.did); - return ApiError::ExpiredTokenMsg("Verification code has expired".into()).into_response(); + return ApiError::ExpiredTokenMsg("Verification code has expired".into()) + .into_response(); } - } let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { Ok(k) => k, Err(e) => { @@ -545,7 +571,10 @@ pub async fn confirm_signup( if let Err(e) = crate::notifications::enqueue_welcome(&state.db, row.id, &hostname).await { warn!("Failed to enqueue welcome notification: {:?}", e); } - let email_confirmed = matches!(row.channel, crate::notifications::NotificationChannel::Email); + let email_confirmed = matches!( + row.channel, + crate::notifications::NotificationChannel::Email + ); let preferred_channel = match row.channel { crate::notifications::NotificationChannel::Email => "email", crate::notifications::NotificationChannel::Discord => "discord", @@ -561,7 +590,8 @@ pub async fn confirm_signup( email_confirmed, preferred_channel: preferred_channel.to_string(), preferred_channel_verified: true, - }).into_response() + }) + .into_response() } #[derive(Deserialize)] @@ -597,10 +627,8 @@ pub async fn resend_verification( return ApiError::InternalError.into_response(); } }; - let is_verified = row.email_confirmed - || row.discord_verified - || row.telegram_verified - || row.signal_verified; + let is_verified = + row.email_confirmed || row.discord_verified || row.telegram_verified || row.signal_verified; if is_verified { return ApiError::InvalidRequest("Account is already verified".into()).into_response(); } @@ -619,7 +647,9 @@ pub async fn resend_verification( return ApiError::InternalError.into_response(); } let (channel_str, recipient) = match row.channel { - crate::notifications::NotificationChannel::Email => ("email", row.email.clone().unwrap_or_default()), + crate::notifications::NotificationChannel::Email => { + ("email", row.email.clone().unwrap_or_default()) + } crate::notifications::NotificationChannel::Discord => { ("discord", row.discord_id.unwrap_or_default()) } @@ -636,7 +666,9 @@ pub async fn resend_verification( channel_str, &recipient, &verification_code, - ).await { + ) + .await + { warn!("Failed to enqueue verification notification: {:?}", e); } Json(json!({"success": true})).into_response() diff --git a/src/api/server/signing_key.rs b/src/api/server/signing_key.rs index eaa1fad..c40acbf 100644 --- a/src/api/server/signing_key.rs +++ b/src/api/server/signing_key.rs @@ -58,11 +58,7 @@ pub async fn reserve_signing_key( .await; match result { Ok(row) => { - info!( - "Reserved signing key {} for did {:?}", - row.id, - input.did - ); + info!("Reserved signing key {} for did {:?}", row.id, input.did); ( StatusCode::OK, Json(ReserveSigningKeyOutput { diff --git a/src/api/temp.rs b/src/api/temp.rs index eeb274b..b089efc 100644 --- a/src/api/temp.rs +++ b/src/api/temp.rs @@ -1,3 +1,5 @@ +use crate::auth::{extract_bearer_token_from_header, validate_bearer_token}; +use crate::state::AppState; use axum::{ Json, extract::State, @@ -6,8 +8,6 @@ use axum::{ }; use serde::Serialize; use serde_json::json; -use crate::auth::{extract_bearer_token_from_header, validate_bearer_token}; -use crate::state::AppState; #[derive(Serialize)] #[serde(rename_all = "camelCase")] @@ -19,28 +19,24 @@ pub struct CheckSignupQueueOutput { pub estimated_time_ms: Option, } -pub async fn check_signup_queue( - State(state): State, - headers: HeaderMap, -) -> Response { - if let Some(token) = extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) - ) { - if let Ok(user) = validate_bearer_token(&state.db, &token).await { - if user.is_oauth { +pub async fn check_signup_queue(State(state): State, headers: HeaderMap) -> Response { + if let Some(token) = + extract_bearer_token_from_header(headers.get("Authorization").and_then(|h| h.to_str().ok())) + && let Ok(user) = validate_bearer_token(&state.db, &token).await + && user.is_oauth { return ( StatusCode::FORBIDDEN, Json(json!({ "error": "Forbidden", "message": "OAuth credentials are not supported for this endpoint" })), - ).into_response(); + ) + .into_response(); } - } - } Json(CheckSignupQueueOutput { activated: true, place_in_queue: None, estimated_time_ms: None, - }).into_response() + }) + .into_response() } diff --git a/src/auth/extractor.rs b/src/auth/extractor.rs index 6fa3317..54c26e0 100644 --- a/src/auth/extractor.rs +++ b/src/auth/extractor.rs @@ -1,13 +1,16 @@ use axum::{ - extract::FromRequestParts, - http::{StatusCode, request::Parts, header::AUTHORIZATION}, - response::{IntoResponse, Response}, Json, + extract::FromRequestParts, + http::{StatusCode, header::AUTHORIZATION, request::Parts}, + response::{IntoResponse, Response}, }; use serde_json::json; +use super::{ + AuthenticatedUser, TokenValidationError, validate_bearer_token_cached, + validate_bearer_token_cached_allow_deactivated, +}; use crate::state::AppState; -use super::{AuthenticatedUser, TokenValidationError, validate_bearer_token_cached, validate_bearer_token_cached_allow_deactivated}; pub struct BearerAuth(pub AuthenticatedUser); @@ -108,7 +111,10 @@ pub fn extract_auth_token_from_header(auth_header: Option<&str>) -> Option= 5 && header[..5].eq_ignore_ascii_case("dpop ") { @@ -116,7 +122,10 @@ pub fn extract_auth_token_from_header(auth_header: Option<&str>) -> Option (Some(key), status.deactivated_at, status.takedown_ref), None => (None, None, None), } - } else { - if let Some(user) = sqlx::query!( - "SELECT k.key_bytes, k.encryption_version, u.deactivated_at, u.takedown_ref - FROM users u - JOIN user_keys k ON u.id = k.user_id - WHERE u.did = $1", - did - ) - .fetch_optional(db) - .await - .ok() - .flatten() - { - let key = crate::config::decrypt_key(&user.key_bytes, user.encryption_version) - .map_err(|_| TokenValidationError::KeyDecryptionFailed)?; + } else if let Some(user) = sqlx::query!( + "SELECT k.key_bytes, k.encryption_version, u.deactivated_at, u.takedown_ref + FROM users u + JOIN user_keys k ON u.id = k.user_id + WHERE u.did = $1", + did + ) + .fetch_optional(db) + .await + .ok() + .flatten() + { + let key = crate::config::decrypt_key(&user.key_bytes, user.encryption_version) + .map_err(|_| TokenValidationError::KeyDecryptionFailed)?; - if let Some(c) = cache { - let _ = c.set_bytes(&key_cache_key, &key, Duration::from_secs(KEY_CACHE_TTL_SECS)).await; - } - - (Some(key), user.deactivated_at, user.takedown_ref) - } else { - (None, None, None) + if let Some(c) = cache { + let _ = c + .set_bytes( + &key_cache_key, + &key, + Duration::from_secs(KEY_CACHE_TTL_SECS), + ) + .await; } + + (Some(key), user.deactivated_at, user.takedown_ref) + } else { + (None, None, None) }; if let Some(decrypted_key) = decrypted_key { @@ -175,11 +183,16 @@ async fn validate_bearer_token_with_options_internal( session_valid = session_exists.is_some(); - if session_valid { - if let Some(c) = cache { - let _ = c.set(&session_cache_key, "1", Duration::from_secs(SESSION_CACHE_TTL_SECS)).await; + if session_valid + && let Some(c) = cache { + let _ = c + .set( + &session_cache_key, + "1", + Duration::from_secs(SESSION_CACHE_TTL_SECS), + ) + .await; } - } } if session_valid { @@ -193,8 +206,8 @@ async fn validate_bearer_token_with_options_internal( } } - if let Ok(oauth_info) = crate::oauth::verify::extract_oauth_token_info(token) { - if let Some(oauth_token) = sqlx::query!( + if let Ok(oauth_info) = crate::oauth::verify::extract_oauth_token_info(token) + && let Some(oauth_token) = sqlx::query!( r#"SELECT t.did, t.expires_at, u.deactivated_at, u.takedown_ref, k.key_bytes as "key_bytes?", k.encryption_version as "encryption_version?" FROM oauth_token t @@ -218,7 +231,9 @@ async fn validate_bearer_token_with_options_internal( let now = chrono::Utc::now(); if oauth_token.expires_at > now { - let key_bytes = if let (Some(kb), Some(ev)) = (&oauth_token.key_bytes, oauth_token.encryption_version) { + let key_bytes = if let (Some(kb), Some(ev)) = + (&oauth_token.key_bytes, oauth_token.encryption_version) + { crate::config::decrypt_key(kb, Some(ev)).ok() } else { None @@ -230,7 +245,6 @@ async fn validate_bearer_token_with_options_internal( }); } } - } Err(TokenValidationError::AuthenticationFailed) } @@ -256,7 +270,15 @@ pub async fn validate_token_with_dpop( return validate_bearer_token(db, token).await; } } - match crate::oauth::verify::verify_oauth_access_token(db, token, dpop_proof, http_method, http_uri).await { + match crate::oauth::verify::verify_oauth_access_token( + db, + token, + dpop_proof, + http_method, + http_uri, + ) + .await + { Ok(result) => { if !allow_deactivated { let deactivated = sqlx::query_scalar!( @@ -272,15 +294,13 @@ pub async fn validate_token_with_dpop( return Err(TokenValidationError::AccountDeactivated); } } - let takedown = sqlx::query_scalar!( - "SELECT takedown_ref FROM users WHERE did = $1", - result.did - ) - .fetch_optional(db) - .await - .ok() - .flatten() - .flatten(); + let takedown = + sqlx::query_scalar!("SELECT takedown_ref FROM users WHERE did = $1", result.did) + .fetch_optional(db) + .await + .ok() + .flatten() + .flatten(); if takedown.is_some() { return Err(TokenValidationError::AccountTakedown); } diff --git a/src/auth/token.rs b/src/auth/token.rs index be84592..822718a 100644 --- a/src/auth/token.rs +++ b/src/auth/token.rs @@ -33,11 +33,26 @@ pub fn create_refresh_token(did: &str, key_bytes: &[u8]) -> Result { } pub fn create_access_token_with_metadata(did: &str, key_bytes: &[u8]) -> Result { - create_signed_token_with_metadata(did, SCOPE_ACCESS, TOKEN_TYPE_ACCESS, key_bytes, Duration::minutes(120)) + create_signed_token_with_metadata( + did, + SCOPE_ACCESS, + TOKEN_TYPE_ACCESS, + key_bytes, + Duration::minutes(120), + ) } -pub fn create_refresh_token_with_metadata(did: &str, key_bytes: &[u8]) -> Result { - create_signed_token_with_metadata(did, SCOPE_REFRESH, TOKEN_TYPE_REFRESH, key_bytes, Duration::days(90)) +pub fn create_refresh_token_with_metadata( + did: &str, + key_bytes: &[u8], +) -> Result { + create_signed_token_with_metadata( + did, + SCOPE_REFRESH, + TOKEN_TYPE_REFRESH, + key_bytes, + Duration::days(90), + ) } pub fn create_service_token(did: &str, aud: &str, lxm: &str, key_bytes: &[u8]) -> Result { @@ -132,15 +147,38 @@ pub fn create_refresh_token_hs256(did: &str, secret: &[u8]) -> Result { Ok(create_refresh_token_hs256_with_metadata(did, secret)?.token) } -pub fn create_access_token_hs256_with_metadata(did: &str, secret: &[u8]) -> Result { - create_hs256_token_with_metadata(did, SCOPE_ACCESS, TOKEN_TYPE_ACCESS, secret, Duration::minutes(120)) +pub fn create_access_token_hs256_with_metadata( + did: &str, + secret: &[u8], +) -> Result { + create_hs256_token_with_metadata( + did, + SCOPE_ACCESS, + TOKEN_TYPE_ACCESS, + secret, + Duration::minutes(120), + ) } -pub fn create_refresh_token_hs256_with_metadata(did: &str, secret: &[u8]) -> Result { - create_hs256_token_with_metadata(did, SCOPE_REFRESH, TOKEN_TYPE_REFRESH, secret, Duration::days(90)) +pub fn create_refresh_token_hs256_with_metadata( + did: &str, + secret: &[u8], +) -> Result { + create_hs256_token_with_metadata( + did, + SCOPE_REFRESH, + TOKEN_TYPE_REFRESH, + secret, + Duration::days(90), + ) } -pub fn create_service_token_hs256(did: &str, aud: &str, lxm: &str, secret: &[u8]) -> Result { +pub fn create_service_token_hs256( + did: &str, + aud: &str, + lxm: &str, + secret: &[u8], +) -> Result { let expiration = Utc::now() .checked_add_signed(Duration::seconds(60)) .expect("valid timestamp") diff --git a/src/auth/verify.rs b/src/auth/verify.rs index 941b7ba..670ef99 100644 --- a/src/auth/verify.rs +++ b/src/auth/verify.rs @@ -1,5 +1,8 @@ +use super::token::{ + SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH, TOKEN_TYPE_ACCESS, + TOKEN_TYPE_REFRESH, +}; use super::{Claims, Header, TokenData, UnsafeClaims}; -use super::token::{TOKEN_TYPE_ACCESS, TOKEN_TYPE_REFRESH, SCOPE_ACCESS, SCOPE_REFRESH, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED}; use anyhow::{Context, Result, anyhow}; use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; @@ -40,7 +43,8 @@ pub fn get_jti_from_token(token: &str) -> Result { let claims: serde_json::Value = serde_json::from_slice(&payload_bytes).map_err(|e| format!("JSON decode failed: {}", e))?; - claims.get("jti") + claims + .get("jti") .and_then(|j| j.as_str()) .map(|s| s.to_string()) .ok_or_else(|| "No jti claim in token".to_string()) @@ -108,11 +112,14 @@ fn verify_token_internal( let header: Header = serde_json::from_slice(&header_bytes).context("JSON decode of header failed")?; - if let Some(expected) = expected_typ { - if header.typ != expected { - return Err(anyhow!("Invalid token type: expected {}, got {}", expected, header.typ)); + if let Some(expected) = expected_typ + && header.typ != expected { + return Err(anyhow!( + "Invalid token type: expected {}, got {}", + expected, + header.typ + )); } - } let signature_bytes = URL_SAFE_NO_PAD .decode(signature_b64) @@ -177,11 +184,14 @@ fn verify_token_hs256_internal( return Err(anyhow!("Expected HS256 algorithm, got {}", header.alg)); } - if let Some(expected) = expected_typ { - if header.typ != expected { - return Err(anyhow!("Invalid token type: expected {}, got {}", expected, header.typ)); + if let Some(expected) = expected_typ + && header.typ != expected { + return Err(anyhow!( + "Invalid token type: expected {}, got {}", + expected, + header.typ + )); } - } let signature_bytes = URL_SAFE_NO_PAD .decode(signature_b64) @@ -189,8 +199,8 @@ fn verify_token_hs256_internal( let message = format!("{}.{}", header_b64, claims_b64); - let mut mac = HmacSha256::new_from_slice(secret) - .map_err(|e| anyhow!("Invalid secret: {}", e))?; + let mut mac = + HmacSha256::new_from_slice(secret).map_err(|e| anyhow!("Invalid secret: {}", e))?; mac.update(message.as_bytes()); let expected_signature = mac.finalize().into_bytes(); diff --git a/src/cache/mod.rs b/src/cache/mod.rs index 7db111e..c1983fa 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -32,8 +32,7 @@ pub struct ValkeyCache { impl ValkeyCache { pub async fn new(url: &str) -> Result { - let client = redis::Client::open(url) - .map_err(|e| CacheError::Connection(e.to_string()))?; + let client = redis::Client::open(url).map_err(|e| CacheError::Connection(e.to_string()))?; let manager = client .get_connection_manager() .await @@ -118,7 +117,7 @@ impl DistributedRateLimiter for RedisRateLimiter { async fn check_rate_limit(&self, key: &str, limit: u32, window_ms: u64) -> bool { let mut conn = self.conn.clone(); let full_key = format!("rl:{}", key); - let window_secs = ((window_ms + 999) / 1000).max(1) as i64; + let window_secs = window_ms.div_ceil(1000).max(1) as i64; let count: Result = redis::cmd("INCR") .arg(&full_key) .query_async(&mut conn) @@ -150,46 +149,6 @@ impl DistributedRateLimiter for NoOpRateLimiter { } } -pub enum CacheBackend { - Valkey(ValkeyCache), - NoOp, -} - -impl CacheBackend { - pub fn rate_limiter(&self) -> Arc { - match self { - CacheBackend::Valkey(cache) => { - Arc::new(RedisRateLimiter::new(cache.connection())) - } - CacheBackend::NoOp => Arc::new(NoOpRateLimiter), - } - } -} - -#[async_trait] -impl Cache for CacheBackend { - async fn get(&self, key: &str) -> Option { - match self { - CacheBackend::Valkey(c) => c.get(key).await, - CacheBackend::NoOp => None, - } - } - - async fn set(&self, key: &str, value: &str, ttl: Duration) -> Result<(), CacheError> { - match self { - CacheBackend::Valkey(c) => c.set(key, value, ttl).await, - CacheBackend::NoOp => Ok(()), - } - } - - async fn delete(&self, key: &str) -> Result<(), CacheError> { - match self { - CacheBackend::Valkey(c) => c.delete(key).await, - CacheBackend::NoOp => Ok(()), - } - } -} - pub async fn create_cache() -> (Arc, Arc) { match std::env::var("VALKEY_URL") { Ok(url) => match ValkeyCache::new(&url).await { diff --git a/src/circuit_breaker.rs b/src/circuit_breaker.rs index a3c91a6..bece00c 100644 --- a/src/circuit_breaker.rs +++ b/src/circuit_breaker.rs @@ -1,5 +1,5 @@ -use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::time::Duration; use tokio::sync::RwLock; @@ -22,7 +22,12 @@ pub struct CircuitBreaker { } impl CircuitBreaker { - pub fn new(name: &str, failure_threshold: u32, success_threshold: u32, timeout_secs: u64) -> Self { + pub fn new( + name: &str, + failure_threshold: u32, + success_threshold: u32, + timeout_secs: u64, + ) -> Self { Self { name: name.to_string(), failure_threshold, diff --git a/src/config.rs b/src/config.rs index 30e1e54..367c314 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,8 +1,5 @@ #[allow(deprecated)] -use aes_gcm::{ - Aes256Gcm, KeyInit, Nonce, - aead::Aead, -}; +use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead}; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use hkdf::Hkdf; use p256::ecdsa::SigningKey; @@ -62,17 +59,25 @@ impl AuthConfig { hasher.update(jwt_secret.as_bytes()); let seed = hasher.finalize(); - let signing_key = SigningKey::from_slice(&seed) - .unwrap_or_else(|e| panic!("Failed to create signing key from seed: {}. This is a bug.", e)); + let signing_key = SigningKey::from_slice(&seed).unwrap_or_else(|e| { + panic!( + "Failed to create signing key from seed: {}. This is a bug.", + e + ) + }); let verifying_key = signing_key.verifying_key(); let point = verifying_key.to_encoded_point(false); let signing_key_x = URL_SAFE_NO_PAD.encode( - point.x().expect("EC point missing X coordinate - this should never happen") + point + .x() + .expect("EC point missing X coordinate - this should never happen"), ); let signing_key_y = URL_SAFE_NO_PAD.encode( - point.y().expect("EC point missing Y coordinate - this should never happen") + point + .y() + .expect("EC point missing Y coordinate - this should never happen"), ); let mut kid_hasher = Sha256::new(); @@ -114,7 +119,9 @@ impl AuthConfig { } pub fn get() -> &'static Self { - CONFIG.get().expect("AuthConfig not initialized - call AuthConfig::init() first") + CONFIG + .get() + .expect("AuthConfig not initialized - call AuthConfig::init() first") } pub fn jwt_secret(&self) -> &str { diff --git a/src/crawlers.rs b/src/crawlers.rs index 6248d5a..cc58b8c 100644 --- a/src/crawlers.rs +++ b/src/crawlers.rs @@ -1,8 +1,8 @@ use crate::circuit_breaker::CircuitBreaker; use crate::sync::firehose::SequencedEvent; use reqwest::Client; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; use tokio::sync::{broadcast, watch}; use tracing::{debug, error, info, warn}; @@ -78,18 +78,20 @@ impl Crawlers { return; } - if let Some(cb) = &self.circuit_breaker { - if !cb.can_execute().await { + if let Some(cb) = &self.circuit_breaker + && !cb.can_execute().await { debug!("Skipping crawler notification due to circuit breaker open"); return; } - } self.mark_notified(); let circuit_breaker = self.circuit_breaker.clone(); for crawler_url in &self.crawler_urls { - let url = format!("{}/xrpc/com.atproto.sync.requestCrawl", crawler_url.trim_end_matches('/')); + let url = format!( + "{}/xrpc/com.atproto.sync.requestCrawl", + crawler_url.trim_end_matches('/') + ); let hostname = self.hostname.clone(); let client = self.http_client.clone(); let cb = circuit_breaker.clone(); diff --git a/src/image/mod.rs b/src/image/mod.rs index eebde48..7051886 100644 --- a/src/image/mod.rs +++ b/src/image/mod.rs @@ -90,7 +90,11 @@ impl ImageProcessor { self } - pub fn process(&self, data: &[u8], mime_type: &str) -> Result { + pub fn process( + &self, + data: &[u8], + mime_type: &str, + ) -> Result { if data.len() > self.max_file_size { return Err(ImageError::FileTooLarge { size: data.len(), @@ -107,12 +111,16 @@ impl ImageProcessor { }); } let original = self.encode_image(&img)?; - let thumbnail_feed = if self.generate_thumbnails && (img.width() > THUMB_SIZE_FEED || img.height() > THUMB_SIZE_FEED) { + let thumbnail_feed = if self.generate_thumbnails + && (img.width() > THUMB_SIZE_FEED || img.height() > THUMB_SIZE_FEED) + { Some(self.generate_thumbnail(&img, THUMB_SIZE_FEED)?) } else { None }; - let thumbnail_full = if self.generate_thumbnails && (img.width() > THUMB_SIZE_FULL || img.height() > THUMB_SIZE_FULL) { + let thumbnail_full = if self.generate_thumbnails + && (img.width() > THUMB_SIZE_FULL || img.height() > THUMB_SIZE_FULL) + { Some(self.generate_thumbnail(&img, THUMB_SIZE_FULL)?) } else { None @@ -183,7 +191,11 @@ impl ImageProcessor { }) } - fn generate_thumbnail(&self, img: &DynamicImage, max_size: u32) -> Result { + fn generate_thumbnail( + &self, + img: &DynamicImage, + max_size: u32, + ) -> Result { let (orig_width, orig_height) = (img.width(), img.height()); let (new_width, new_height) = if orig_width > orig_height { let ratio = max_size as f64 / orig_width as f64; @@ -204,8 +216,8 @@ impl ImageProcessor { } pub fn strip_exif(data: &[u8]) -> Result, ImageError> { - let format = image::guess_format(data) - .map_err(|e| ImageError::DecodeError(e.to_string()))?; + let format = + image::guess_format(data).map_err(|e| ImageError::DecodeError(e.to_string()))?; let cursor = Cursor::new(data); let img = ImageReader::with_format(cursor, format) .decode() @@ -224,7 +236,8 @@ mod tests { fn create_test_image(width: u32, height: u32) -> Vec { let img = DynamicImage::new_rgb8(width, height); let mut buf = Vec::new(); - img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png).unwrap(); + img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png) + .unwrap(); buf } diff --git a/src/lib.rs b/src/lib.rs index 8fb9a4e..ea757c9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -109,18 +109,9 @@ pub fn app(state: AppState) -> Router { "/xrpc/com.atproto.sync.getLatestCommit", get(sync::get_latest_commit), ) - .route( - "/xrpc/com.atproto.sync.listRepos", - get(sync::list_repos), - ) - .route( - "/xrpc/com.atproto.sync.getBlob", - get(sync::get_blob), - ) - .route( - "/xrpc/com.atproto.sync.listBlobs", - get(sync::list_blobs), - ) + .route("/xrpc/com.atproto.sync.listRepos", get(sync::list_repos)) + .route("/xrpc/com.atproto.sync.getBlob", get(sync::get_blob)) + .route("/xrpc/com.atproto.sync.listBlobs", get(sync::list_blobs)) .route( "/xrpc/com.atproto.sync.getRepoStatus", get(sync::get_repo_status), @@ -145,26 +136,14 @@ pub fn app(state: AppState) -> Router { "/xrpc/com.atproto.sync.requestCrawl", post(sync::request_crawl), ) - .route( - "/xrpc/com.atproto.sync.getBlocks", - get(sync::get_blocks), - ) - .route( - "/xrpc/com.atproto.sync.getRepo", - get(sync::get_repo), - ) - .route( - "/xrpc/com.atproto.sync.getRecord", - get(sync::get_record), - ) + .route("/xrpc/com.atproto.sync.getBlocks", get(sync::get_blocks)) + .route("/xrpc/com.atproto.sync.getRepo", get(sync::get_repo)) + .route("/xrpc/com.atproto.sync.getRecord", get(sync::get_record)) .route( "/xrpc/com.atproto.sync.subscribeRepos", get(sync::subscribe_repos), ) - .route( - "/xrpc/com.atproto.sync.getHead", - get(sync::get_head), - ) + .route("/xrpc/com.atproto.sync.getHead", get(sync::get_head)) .route( "/xrpc/com.atproto.sync.getCheckout", get(sync::get_checkout), @@ -349,16 +328,16 @@ pub fn app(state: AppState) -> Router { "/xrpc/app.bsky.feed.getPostThread", get(api::feed::get_post_thread), ) - .route( - "/xrpc/app.bsky.feed.getFeed", - get(api::feed::get_feed), - ) + .route("/xrpc/app.bsky.feed.getFeed", get(api::feed::get_feed)) .route( "/xrpc/app.bsky.notification.registerPush", post(api::notification::register_push), ) .route("/.well-known/did.json", get(api::identity::well_known_did)) - .route("/.well-known/atproto-did", get(api::identity::well_known_atproto_did)) + .route( + "/.well-known/atproto-did", + get(api::identity::well_known_atproto_did), + ) .route("/u/{handle}/did.json", get(api::identity::user_did_doc)) .route( "/.well-known/oauth-protected-resource", @@ -375,13 +354,28 @@ pub fn app(state: AppState) -> Router { ) .route("/oauth/authorize", get(oauth::endpoints::authorize_get)) .route("/oauth/authorize", post(oauth::endpoints::authorize_post)) - .route("/oauth/authorize/select", post(oauth::endpoints::authorize_select)) - .route("/oauth/authorize/2fa", get(oauth::endpoints::authorize_2fa_get)) - .route("/oauth/authorize/2fa", post(oauth::endpoints::authorize_2fa_post)) - .route("/oauth/authorize/deny", post(oauth::endpoints::authorize_deny)) + .route( + "/oauth/authorize/select", + post(oauth::endpoints::authorize_select), + ) + .route( + "/oauth/authorize/2fa", + get(oauth::endpoints::authorize_2fa_get), + ) + .route( + "/oauth/authorize/2fa", + post(oauth::endpoints::authorize_2fa_post), + ) + .route( + "/oauth/authorize/deny", + post(oauth::endpoints::authorize_deny), + ) .route("/oauth/token", post(oauth::endpoints::token_endpoint)) .route("/oauth/revoke", post(oauth::endpoints::revoke_token)) - .route("/oauth/introspect", post(oauth::endpoints::introspect_token)) + .route( + "/oauth/introspect", + post(oauth::endpoints::introspect_token), + ) .route( "/xrpc/com.atproto.temp.checkSignupQueue", get(api::temp::check_signup_queue), @@ -404,13 +398,15 @@ pub fn app(state: AppState) -> Router { ) .with_state(state); - let frontend_dir = std::env::var("FRONTEND_DIR") - .unwrap_or_else(|_| "./frontend/dist".to_string()); + let frontend_dir = + std::env::var("FRONTEND_DIR").unwrap_or_else(|_| "./frontend/dist".to_string()); - if std::path::Path::new(&frontend_dir).join("index.html").exists() { + if std::path::Path::new(&frontend_dir) + .join("index.html") + .exists() + { let index_path = format!("{}/index.html", frontend_dir); - let serve_dir = ServeDir::new(&frontend_dir) - .not_found_service(ServeFile::new(index_path)); + let serve_dir = ServeDir::new(&frontend_dir).not_found_service(ServeFile::new(index_path)); router.fallback_service(serve_dir) } else { router diff --git a/src/main.rs b/src/main.rs index 7186314..d9dc231 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,7 @@ use bspds::crawlers::{Crawlers, start_crawlers_service}; -use bspds::notifications::{DiscordSender, EmailSender, NotificationService, SignalSender, TelegramSender}; +use bspds::notifications::{ + DiscordSender, EmailSender, NotificationService, SignalSender, TelegramSender, +}; use bspds::state::AppState; use std::net::SocketAddr; use std::process::ExitCode; @@ -94,11 +96,15 @@ async fn run() -> Result<(), Box> { let crawlers_handle = if let Some(crawlers) = Crawlers::from_env() { let crawlers = Arc::new( - crawlers.with_circuit_breaker(state.circuit_breakers.relay_notification.clone()) + crawlers.with_circuit_breaker(state.circuit_breakers.relay_notification.clone()), ); let firehose_rx = state.firehose_tx.subscribe(); info!("Crawlers notification service enabled"); - Some(tokio::spawn(start_crawlers_service(crawlers, firehose_rx, shutdown_rx))) + Some(tokio::spawn(start_crawlers_service( + crawlers, + firehose_rx, + shutdown_rx, + ))) } else { warn!("Crawlers notification service disabled (PDS_HOSTNAME or CRAWLERS not set)"); None diff --git a/src/metrics.rs b/src/metrics.rs index cd47e71..a8a0850 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -24,10 +24,7 @@ pub fn init_metrics() -> PrometheusHandle { } fn describe_metrics() { - metrics::describe_counter!( - "bspds_http_requests_total", - "Total number of HTTP requests" - ); + metrics::describe_counter!("bspds_http_requests_total", "Total number of HTTP requests"); metrics::describe_histogram!( "bspds_http_request_duration_seconds", "HTTP request duration in seconds" @@ -64,10 +61,7 @@ fn describe_metrics() { "bspds_rate_limit_rejections_total", "Total number of rate limit rejections" ); - metrics::describe_counter!( - "bspds_db_queries_total", - "Total number of database queries" - ); + metrics::describe_counter!("bspds_db_queries_total", "Total number of database queries"); metrics::describe_histogram!( "bspds_db_query_duration_seconds", "Database query duration in seconds" @@ -78,7 +72,11 @@ pub async fn metrics_handler() -> impl IntoResponse { match PROMETHEUS_HANDLE.get() { Some(handle) => { let metrics = handle.render(); - (StatusCode::OK, [("content-type", "text/plain; version=0.0.4")], metrics) + ( + StatusCode::OK, + [("content-type", "text/plain; version=0.0.4")], + metrics, + ) } None => ( StatusCode::INTERNAL_SERVER_ERROR, @@ -117,14 +115,13 @@ pub async fn metrics_middleware(request: Request, next: Next) -> Response } fn normalize_path(path: &str) -> String { - if path.starts_with("/xrpc/") { - if let Some(method) = path.strip_prefix("/xrpc/") { + if path.starts_with("/xrpc/") + && let Some(method) = path.strip_prefix("/xrpc/") { if let Some(q) = method.find('?') { return format!("/xrpc/{}", &method[..q]); } return path.to_string(); } - } if path.starts_with("/u/") && path.ends_with("/did.json") { return "/u/{handle}/did.json".to_string(); diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index f7fe586..df25f31 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -8,9 +8,9 @@ pub use sender::{ }; pub use service::{ - channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_email_update, - enqueue_email_verification, enqueue_notification, enqueue_password_reset, - enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, NotificationService, + NotificationService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, + enqueue_email_update, enqueue_email_verification, enqueue_notification, enqueue_password_reset, + enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, }; pub use types::{ diff --git a/src/notifications/sender.rs b/src/notifications/sender.rs index 6433dda..4a2b4ff 100644 --- a/src/notifications/sender.rs +++ b/src/notifications/sender.rs @@ -80,7 +80,8 @@ impl EmailSender { Self { from_address, from_name, - sendmail_path: std::env::var("SENDMAIL_PATH").unwrap_or_else(|_| "/usr/sbin/sendmail".to_string()), + sendmail_path: std::env::var("SENDMAIL_PATH") + .unwrap_or_else(|_| "/usr/sbin/sendmail".to_string()), } } @@ -91,19 +92,21 @@ impl EmailSender { } pub fn format_email(&self, notification: &QueuedNotification) -> String { - let subject = sanitize_header_value(notification.subject.as_deref().unwrap_or("Notification")); + let subject = + sanitize_header_value(notification.subject.as_deref().unwrap_or("Notification")); let recipient = sanitize_header_value(¬ification.recipient); let from_header = if self.from_name.is_empty() { self.from_address.clone() } else { - format!("{} <{}>", sanitize_header_value(&self.from_name), self.from_address) + format!( + "{} <{}>", + sanitize_header_value(&self.from_name), + self.from_address + ) }; format!( "From: {}\r\nTo: {}\r\nSubject: {}\r\nContent-Type: text/plain; charset=utf-8\r\nMIME-Version: 1.0\r\n\r\n{}", - from_header, - recipient, - subject, - notification.body + from_header, recipient, subject, notification.body ) } } @@ -195,7 +198,7 @@ impl NotificationSender for DiscordSender { Err(e) => { if e.is_timeout() { if attempt < MAX_RETRIES - 1 { - last_error = Some(format!("Discord request timed out")); + last_error = Some("Discord request timed out".to_string()); retry_delay(attempt).await; continue; } @@ -243,10 +246,7 @@ impl NotificationSender for TelegramSender { let chat_id = ¬ification.recipient; let subject = notification.subject.as_deref().unwrap_or("Notification"); let text = format!("*{}*\n\n{}", subject, notification.body); - let url = format!( - "https://api.telegram.org/bot{}/sendMessage", - self.bot_token - ); + let url = format!("https://api.telegram.org/bot{}/sendMessage", self.bot_token); let payload = json!({ "chat_id": chat_id, "text": text, @@ -254,12 +254,7 @@ impl NotificationSender for TelegramSender { }); let mut last_error = None; for attempt in 0..MAX_RETRIES { - let result = self - .http_client - .post(&url) - .json(&payload) - .send() - .await; + let result = self.http_client.post(&url).json(&payload).send().await; match result { Ok(response) => { if response.status().is_success() { @@ -280,7 +275,7 @@ impl NotificationSender for TelegramSender { Err(e) => { if e.is_timeout() { if attempt < MAX_RETRIES - 1 { - last_error = Some(format!("Telegram request timed out")); + last_error = Some("Telegram request timed out".to_string()); retry_delay(attempt).await; continue; } diff --git a/src/notifications/service.rs b/src/notifications/service.rs index c46e8e1..de62eed 100644 --- a/src/notifications/service.rs +++ b/src/notifications/service.rs @@ -80,7 +80,9 @@ impl NotificationService { pub async fn run(self, mut shutdown: watch::Receiver) { if self.senders.is_empty() { - warn!("Notification service starting with no senders configured. Notifications will be queued but not delivered until senders are configured."); + warn!( + "Notification service starting with no senders configured. Notifications will be queued but not delivered until senders are configured." + ); } info!( poll_interval_secs = self.poll_interval.as_secs(), @@ -231,7 +233,10 @@ impl NotificationService { } } -pub async fn enqueue_notification(db: &PgPool, notification: NewNotification) -> Result { +pub async fn enqueue_notification( + db: &PgPool, + notification: NewNotification, +) -> Result { sqlx::query_scalar!( r#" INSERT INTO notification_queue diff --git a/src/oauth/client.rs b/src/oauth/client.rs index d236fce..ceea205 100644 --- a/src/oauth/client.rs +++ b/src/oauth/client.rs @@ -88,18 +88,15 @@ impl ClientMetadataCache { fn is_loopback_client(client_id: &str) -> bool { if let Ok(url) = reqwest::Url::parse(client_id) { - url.scheme() == "http" - && url.host_str() == Some("localhost") - && url.port().is_none() + url.scheme() == "http" && url.host_str() == Some("localhost") && url.port().is_none() } else { false } } fn build_loopback_metadata(client_id: &str) -> Result { - let url = reqwest::Url::parse(client_id).map_err(|_| { - OAuthError::InvalidClient("Invalid loopback client_id URL".to_string()) - })?; + let url = reqwest::Url::parse(client_id) + .map_err(|_| OAuthError::InvalidClient("Invalid loopback client_id URL".to_string()))?; let mut redirect_uris = Vec::new(); for (key, value) in url.query_pairs() { if key == "redirect_uri" { @@ -117,7 +114,10 @@ impl ClientMetadataCache { client_uri: None, logo_uri: None, redirect_uris, - grant_types: vec!["authorization_code".to_string(), "refresh_token".to_string()], + grant_types: vec![ + "authorization_code".to_string(), + "refresh_token".to_string(), + ], response_types: vec!["code".to_string()], scope, token_endpoint_auth_method: Some("none".to_string()), @@ -134,11 +134,10 @@ impl ClientMetadataCache { } { let cache = self.cache.read().await; - if let Some(cached) = cache.get(client_id) { - if cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs { + if let Some(cached) = cache.get(client_id) + && cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs { return Ok(cached.metadata.clone()); } - } } let metadata = self.fetch_metadata(client_id).await?; { @@ -154,7 +153,10 @@ impl ClientMetadataCache { Ok(metadata) } - pub async fn get_jwks(&self, metadata: &ClientMetadata) -> Result { + pub async fn get_jwks( + &self, + metadata: &ClientMetadata, + ) -> Result { if let Some(jwks) = &metadata.jwks { return Ok(jwks.clone()); } @@ -165,11 +167,10 @@ impl ClientMetadataCache { })?; { let cache = self.jwks_cache.read().await; - if let Some(cached) = cache.get(jwks_uri) { - if cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs { + if let Some(cached) = cache.get(jwks_uri) + && cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs { return Ok(cached.jwks.clone()); } - } } let jwks = self.fetch_jwks(jwks_uri).await?; { @@ -186,15 +187,14 @@ impl ClientMetadataCache { } async fn fetch_jwks(&self, jwks_uri: &str) -> Result { - if !jwks_uri.starts_with("https://") { - if !jwks_uri.starts_with("http://") - || (!jwks_uri.contains("localhost") && !jwks_uri.contains("127.0.0.1")) + if !jwks_uri.starts_with("https://") + && (!jwks_uri.starts_with("http://") + || (!jwks_uri.contains("localhost") && !jwks_uri.contains("127.0.0.1"))) { return Err(OAuthError::InvalidClient( "jwks_uri must use https (except for localhost)".to_string(), )); } - } let response = self .http_client .get(jwks_uri) @@ -242,17 +242,18 @@ impl ClientMetadataCache { .header("Accept", "application/json") .send() .await - .map_err(|e| OAuthError::InvalidClient(format!("Failed to fetch client metadata: {}", e)))?; + .map_err(|e| { + OAuthError::InvalidClient(format!("Failed to fetch client metadata: {}", e)) + })?; if !response.status().is_success() { return Err(OAuthError::InvalidClient(format!( "Failed to fetch client metadata: HTTP {}", response.status() ))); } - let mut metadata: ClientMetadata = response - .json() - .await - .map_err(|e| OAuthError::InvalidClient(format!("Invalid client metadata JSON: {}", e)))?; + let mut metadata: ClientMetadata = response.json().await.map_err(|e| { + OAuthError::InvalidClient(format!("Invalid client metadata JSON: {}", e)) + })?; if metadata.client_id.is_empty() { metadata.client_id = client_id.to_string(); } else if metadata.client_id != client_id { @@ -274,7 +275,9 @@ impl ClientMetadataCache { self.validate_redirect_uri_format(uri)?; } if !metadata.grant_types.is_empty() - && !metadata.grant_types.contains(&"authorization_code".to_string()) + && !metadata + .grant_types + .contains(&"authorization_code".to_string()) { return Err(OAuthError::InvalidClient( "authorization_code grant type is required".to_string(), @@ -298,8 +301,8 @@ impl ClientMetadataCache { if metadata.redirect_uris.contains(&redirect_uri.to_string()) { return Ok(()); } - if Self::is_loopback_client(&metadata.client_id) { - if let Ok(req_url) = reqwest::Url::parse(redirect_uri) { + if Self::is_loopback_client(&metadata.client_id) + && let Ok(req_url) = reqwest::Url::parse(redirect_uri) { let req_host = req_url.host_str().unwrap_or(""); let is_loopback_redirect = req_url.scheme() == "http" && (req_host == "localhost" || req_host == "127.0.0.1" || req_host == "[::1]"); @@ -319,7 +322,6 @@ impl ClientMetadataCache { } } } - } Err(OAuthError::InvalidRequest( "redirect_uri not registered for client".to_string(), )) @@ -331,9 +333,8 @@ impl ClientMetadataCache { "redirect_uri must not contain a fragment".to_string(), )); } - let parsed = reqwest::Url::parse(uri).map_err(|_| { - OAuthError::InvalidClient(format!("Invalid redirect_uri: {}", uri)) - })?; + let parsed = reqwest::Url::parse(uri) + .map_err(|_| OAuthError::InvalidClient(format!("Invalid redirect_uri: {}", uri)))?; let scheme = parsed.scheme(); if scheme == "http" { let host = parsed.host_str().unwrap_or(""); @@ -343,8 +344,15 @@ impl ClientMetadataCache { )); } } else if scheme == "https" { - } else if scheme.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '+' || c == '.' || c == '-') { - if !scheme.chars().next().map(|c| c.is_ascii_lowercase()).unwrap_or(false) { + } else if scheme.chars().all(|c| { + c.is_ascii_lowercase() || c.is_ascii_digit() || c == '+' || c == '.' || c == '-' + }) { + if !scheme + .chars() + .next() + .map(|c| c.is_ascii_lowercase()) + .unwrap_or(false) + { return Err(OAuthError::InvalidClient(format!( "Invalid redirect_uri scheme: {}", scheme @@ -366,9 +374,7 @@ impl ClientMetadata { } pub fn auth_method(&self) -> &str { - self.token_endpoint_auth_method - .as_deref() - .unwrap_or("none") + self.token_endpoint_auth_method.as_deref().unwrap_or("none") } } @@ -411,10 +417,15 @@ async fn verify_private_key_jwt_async( metadata: &ClientMetadata, client_assertion: &str, ) -> Result<(), OAuthError> { - use base64::{Engine as _, engine::general_purpose::{URL_SAFE_NO_PAD, STANDARD}}; + use base64::{ + Engine as _, + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, + }; let parts: Vec<&str> = client_assertion.split('.').collect(); if parts.len() != 3 { - return Err(OAuthError::InvalidClient("Invalid client_assertion format".to_string())); + return Err(OAuthError::InvalidClient( + "Invalid client_assertion format".to_string(), + )); } let header_bytes = URL_SAFE_NO_PAD .decode(parts[0]) @@ -422,10 +433,14 @@ async fn verify_private_key_jwt_async( .map_err(|_| OAuthError::InvalidClient("Invalid assertion header encoding".to_string()))?; let header: serde_json::Value = serde_json::from_slice(&header_bytes) .map_err(|_| OAuthError::InvalidClient("Invalid assertion header JSON".to_string()))?; - let alg = header.get("alg").and_then(|a| a.as_str()).ok_or_else(|| { - OAuthError::InvalidClient("Missing alg in client_assertion".to_string()) - })?; - if !matches!(alg, "ES256" | "ES384" | "RS256" | "RS384" | "RS512" | "EdDSA") { + let alg = header + .get("alg") + .and_then(|a| a.as_str()) + .ok_or_else(|| OAuthError::InvalidClient("Missing alg in client_assertion".to_string()))?; + if !matches!( + alg, + "ES256" | "ES384" | "RS256" | "RS384" | "RS512" | "EdDSA" + ) { return Err(OAuthError::InvalidClient(format!( "Unsupported client_assertion algorithm: {}", alg @@ -441,17 +456,19 @@ async fn verify_private_key_jwt_async( })?; let payload: serde_json::Value = serde_json::from_slice(&payload_bytes) .map_err(|_| OAuthError::InvalidClient("Invalid assertion payload JSON".to_string()))?; - let iss = payload.get("iss").and_then(|i| i.as_str()).ok_or_else(|| { - OAuthError::InvalidClient("Missing iss in client_assertion".to_string()) - })?; + let iss = payload + .get("iss") + .and_then(|i| i.as_str()) + .ok_or_else(|| OAuthError::InvalidClient("Missing iss in client_assertion".to_string()))?; if iss != metadata.client_id { return Err(OAuthError::InvalidClient( "client_assertion iss does not match client_id".to_string(), )); } - let sub = payload.get("sub").and_then(|s| s.as_str()).ok_or_else(|| { - OAuthError::InvalidClient("Missing sub in client_assertion".to_string()) - })?; + let sub = payload + .get("sub") + .and_then(|s| s.as_str()) + .ok_or_else(|| OAuthError::InvalidClient("Missing sub in client_assertion".to_string()))?; if sub != metadata.client_id { return Err(OAuthError::InvalidClient( "client_assertion sub does not match client_id".to_string(), @@ -462,30 +479,38 @@ async fn verify_private_key_jwt_async( let iat = payload.get("iat").and_then(|i| i.as_i64()); if let Some(exp) = exp { if exp < now { - return Err(OAuthError::InvalidClient("client_assertion has expired".to_string())); + return Err(OAuthError::InvalidClient( + "client_assertion has expired".to_string(), + )); } } else if let Some(iat) = iat { let max_age_secs = 300; if now - iat > max_age_secs { - tracing::warn!(iat = iat, now = now, "client_assertion too old (no exp, using iat)"); - return Err(OAuthError::InvalidClient("client_assertion is too old".to_string())); + tracing::warn!( + iat = iat, + now = now, + "client_assertion too old (no exp, using iat)" + ); + return Err(OAuthError::InvalidClient( + "client_assertion is too old".to_string(), + )); } } else { return Err(OAuthError::InvalidClient( "client_assertion must have exp or iat claim".to_string(), )); } - if let Some(iat) = iat { - if iat > now + 60 { + if let Some(iat) = iat + && iat > now + 60 { return Err(OAuthError::InvalidClient( "client_assertion iat is in the future".to_string(), )); } - } let jwks = cache.get_jwks(metadata).await?; - let keys = jwks.get("keys").and_then(|k| k.as_array()).ok_or_else(|| { - OAuthError::InvalidClient("Invalid JWKS: missing keys array".to_string()) - })?; + let keys = jwks + .get("keys") + .and_then(|k| k.as_array()) + .ok_or_else(|| OAuthError::InvalidClient("Invalid JWKS: missing keys array".to_string()))?; let matching_keys: Vec<&serde_json::Value> = if let Some(kid) = kid { keys.iter() .filter(|k| k.get("kid").and_then(|v| v.as_str()) == Some(kid)) @@ -532,17 +557,21 @@ fn verify_es256( signature: &[u8], ) -> Result<(), OAuthError> { use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; - use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier}; use p256::EncodedPoint; - let x = key.get("x").and_then(|v| v.as_str()).ok_or_else(|| { - OAuthError::InvalidClient("Missing x coordinate in EC key".to_string()) - })?; - let y = key.get("y").and_then(|v| v.as_str()).ok_or_else(|| { - OAuthError::InvalidClient("Missing y coordinate in EC key".to_string()) - })?; - let x_bytes = URL_SAFE_NO_PAD.decode(x) + use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier}; + let x = key + .get("x") + .and_then(|v| v.as_str()) + .ok_or_else(|| OAuthError::InvalidClient("Missing x coordinate in EC key".to_string()))?; + let y = key + .get("y") + .and_then(|v| v.as_str()) + .ok_or_else(|| OAuthError::InvalidClient("Missing y coordinate in EC key".to_string()))?; + let x_bytes = URL_SAFE_NO_PAD + .decode(x) .map_err(|_| OAuthError::InvalidClient("Invalid x coordinate encoding".to_string()))?; - let y_bytes = URL_SAFE_NO_PAD.decode(y) + let y_bytes = URL_SAFE_NO_PAD + .decode(y) .map_err(|_| OAuthError::InvalidClient("Invalid y coordinate encoding".to_string()))?; let mut point_bytes = vec![0x04]; point_bytes.extend_from_slice(&x_bytes); @@ -564,17 +593,21 @@ fn verify_es384( signature: &[u8], ) -> Result<(), OAuthError> { use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; - use p384::ecdsa::{Signature, VerifyingKey, signature::Verifier}; use p384::EncodedPoint; - let x = key.get("x").and_then(|v| v.as_str()).ok_or_else(|| { - OAuthError::InvalidClient("Missing x coordinate in EC key".to_string()) - })?; - let y = key.get("y").and_then(|v| v.as_str()).ok_or_else(|| { - OAuthError::InvalidClient("Missing y coordinate in EC key".to_string()) - })?; - let x_bytes = URL_SAFE_NO_PAD.decode(x) + use p384::ecdsa::{Signature, VerifyingKey, signature::Verifier}; + let x = key + .get("x") + .and_then(|v| v.as_str()) + .ok_or_else(|| OAuthError::InvalidClient("Missing x coordinate in EC key".to_string()))?; + let y = key + .get("y") + .and_then(|v| v.as_str()) + .ok_or_else(|| OAuthError::InvalidClient("Missing y coordinate in EC key".to_string()))?; + let x_bytes = URL_SAFE_NO_PAD + .decode(x) .map_err(|_| OAuthError::InvalidClient("Invalid x coordinate encoding".to_string()))?; - let y_bytes = URL_SAFE_NO_PAD.decode(y) + let y_bytes = URL_SAFE_NO_PAD + .decode(y) .map_err(|_| OAuthError::InvalidClient("Invalid y coordinate encoding".to_string()))?; let mut point_bytes = vec![0x04]; point_bytes.extend_from_slice(&x_bytes); @@ -615,16 +648,20 @@ fn verify_eddsa( crv ))); } - let x = key.get("x").and_then(|v| v.as_str()).ok_or_else(|| { - OAuthError::InvalidClient("Missing x in OKP key".to_string()) - })?; - let x_bytes = URL_SAFE_NO_PAD.decode(x) + let x = key + .get("x") + .and_then(|v| v.as_str()) + .ok_or_else(|| OAuthError::InvalidClient("Missing x in OKP key".to_string()))?; + let x_bytes = URL_SAFE_NO_PAD + .decode(x) .map_err(|_| OAuthError::InvalidClient("Invalid x encoding".to_string()))?; - let key_bytes: [u8; 32] = x_bytes.try_into() + let key_bytes: [u8; 32] = x_bytes + .try_into() .map_err(|_| OAuthError::InvalidClient("Invalid Ed25519 key length".to_string()))?; let verifying_key = VerifyingKey::from_bytes(&key_bytes) .map_err(|_| OAuthError::InvalidClient("Invalid Ed25519 key".to_string()))?; - let sig_bytes: [u8; 64] = signature.try_into() + let sig_bytes: [u8; 64] = signature + .try_into() .map_err(|_| OAuthError::InvalidClient("Invalid EdDSA signature length".to_string()))?; let sig = Signature::from_bytes(&sig_bytes); verifying_key diff --git a/src/oauth/db/client.rs b/src/oauth/db/client.rs index ea78d59..5f07126 100644 --- a/src/oauth/db/client.rs +++ b/src/oauth/db/client.rs @@ -1,6 +1,6 @@ -use sqlx::PgPool; use super::super::{AuthorizedClientData, OAuthError}; use super::helpers::{from_json, to_json}; +use sqlx::PgPool; pub async fn upsert_authorized_client( pool: &PgPool, diff --git a/src/oauth/db/device.rs b/src/oauth/db/device.rs index 9a8610b..ff44d76 100644 --- a/src/oauth/db/device.rs +++ b/src/oauth/db/device.rs @@ -1,6 +1,6 @@ +use super::super::{DeviceData, OAuthError}; use chrono::{DateTime, Utc}; use sqlx::PgPool; -use super::super::{DeviceData, OAuthError}; pub struct DeviceAccountRow { pub did: String, @@ -49,10 +49,7 @@ pub async fn get_device(pool: &PgPool, device_id: &str) -> Result Result<(), OAuthError> { +pub async fn update_device_last_seen(pool: &PgPool, device_id: &str) -> Result<(), OAuthError> { sqlx::query!( r#" UPDATE oauth_device diff --git a/src/oauth/db/dpop.rs b/src/oauth/db/dpop.rs index 727b64a..b471e82 100644 --- a/src/oauth/db/dpop.rs +++ b/src/oauth/db/dpop.rs @@ -1,10 +1,7 @@ -use sqlx::PgPool; use super::super::OAuthError; +use sqlx::PgPool; -pub async fn check_and_record_dpop_jti( - pool: &PgPool, - jti: &str, -) -> Result { +pub async fn check_and_record_dpop_jti(pool: &PgPool, jti: &str) -> Result { let result = sqlx::query!( r#" INSERT INTO oauth_dpop_jti (jti) diff --git a/src/oauth/db/helpers.rs b/src/oauth/db/helpers.rs index bd52d0c..9e40cf6 100644 --- a/src/oauth/db/helpers.rs +++ b/src/oauth/db/helpers.rs @@ -1,5 +1,5 @@ -use serde::{de::DeserializeOwned, Serialize}; use super::super::OAuthError; +use serde::{Serialize, de::DeserializeOwned}; pub fn to_json(value: &T) -> Result { serde_json::to_value(value).map_err(|e| { diff --git a/src/oauth/db/mod.rs b/src/oauth/db/mod.rs index c0f7619..9e3bf47 100644 --- a/src/oauth/db/mod.rs +++ b/src/oauth/db/mod.rs @@ -8,8 +8,8 @@ mod two_factor; pub use client::{get_authorized_client, upsert_authorized_client}; pub use device::{ - create_device, delete_device, get_device, get_device_accounts, update_device_last_seen, - upsert_account_device, verify_account_on_device, DeviceAccountRow, + DeviceAccountRow, create_device, delete_device, get_device, get_device_accounts, + update_device_last_seen, upsert_account_device, verify_account_on_device, }; pub use dpop::{check_and_record_dpop_jti, cleanup_expired_dpop_jtis}; pub use request::{ @@ -23,7 +23,7 @@ pub use token::{ get_token_by_refresh_token, list_tokens_for_user, rotate_token, }; pub use two_factor::{ - check_user_2fa_enabled, cleanup_expired_2fa_challenges, create_2fa_challenge, - delete_2fa_challenge, delete_2fa_challenge_by_request_uri, generate_2fa_code, - get_2fa_challenge, increment_2fa_attempts, TwoFactorChallenge, + TwoFactorChallenge, check_user_2fa_enabled, cleanup_expired_2fa_challenges, + create_2fa_challenge, delete_2fa_challenge, delete_2fa_challenge_by_request_uri, + generate_2fa_code, get_2fa_challenge, increment_2fa_attempts, }; diff --git a/src/oauth/db/request.rs b/src/oauth/db/request.rs index ce3692a..d1d01cf 100644 --- a/src/oauth/db/request.rs +++ b/src/oauth/db/request.rs @@ -1,6 +1,6 @@ -use sqlx::PgPool; use super::super::{AuthorizationRequestParameters, ClientAuth, OAuthError, RequestData}; use super::helpers::{from_json, to_json}; +use sqlx::PgPool; pub async fn create_authorization_request( pool: &PgPool, diff --git a/src/oauth/db/token.rs b/src/oauth/db/token.rs index 0f7e5f4..ba8d67a 100644 --- a/src/oauth/db/token.rs +++ b/src/oauth/db/token.rs @@ -1,12 +1,9 @@ -use chrono::{DateTime, Utc}; -use sqlx::PgPool; use super::super::{OAuthError, TokenData}; use super::helpers::{from_json, to_json}; +use chrono::{DateTime, Utc}; +use sqlx::PgPool; -pub async fn create_token( - pool: &PgPool, - data: &TokenData, -) -> Result { +pub async fn create_token(pool: &PgPool, data: &TokenData) -> Result { let client_auth_json = to_json(&data.client_auth)?; let parameters_json = to_json(&data.parameters)?; let row = sqlx::query!( @@ -193,10 +190,7 @@ pub async fn delete_token_family(pool: &PgPool, db_id: i32) -> Result<(), OAuthE Ok(()) } -pub async fn list_tokens_for_user( - pool: &PgPool, - did: &str, -) -> Result, OAuthError> { +pub async fn list_tokens_for_user(pool: &PgPool, did: &str) -> Result, OAuthError> { let rows = sqlx::query!( r#" SELECT did, token_id, created_at, updated_at, expires_at, client_id, client_auth, diff --git a/src/oauth/db/two_factor.rs b/src/oauth/db/two_factor.rs index c35fbee..0dd4aeb 100644 --- a/src/oauth/db/two_factor.rs +++ b/src/oauth/db/two_factor.rs @@ -1,8 +1,8 @@ +use super::super::OAuthError; use chrono::{DateTime, Duration, Utc}; use rand::Rng; use sqlx::PgPool; use uuid::Uuid; -use super::super::OAuthError; pub struct TwoFactorChallenge { pub id: Uuid, diff --git a/src/oauth/dpop.rs b/src/oauth/dpop.rs index 0489315..e2d1be9 100644 --- a/src/oauth/dpop.rs +++ b/src/oauth/dpop.rs @@ -61,7 +61,7 @@ impl DPoPVerifier { let timestamp_bytes = timestamp.to_be_bytes(); let mut hasher = Sha256::new(); hasher.update(&self.secret); - hasher.update(×tamp_bytes); + hasher.update(timestamp_bytes); let hash = hasher.finalize(); let mut nonce_data = Vec::with_capacity(8 + 16); nonce_data.extend_from_slice(×tamp_bytes); @@ -74,7 +74,9 @@ impl DPoPVerifier { .decode(nonce) .map_err(|_| OAuthError::InvalidDpopProof("Invalid nonce encoding".to_string()))?; if nonce_bytes.len() < 24 { - return Err(OAuthError::InvalidDpopProof("Invalid nonce length".to_string())); + return Err(OAuthError::InvalidDpopProof( + "Invalid nonce length".to_string(), + )); } let timestamp_bytes: [u8; 8] = nonce_bytes[..8] .try_into() @@ -86,10 +88,12 @@ impl DPoPVerifier { } let mut hasher = Sha256::new(); hasher.update(&self.secret); - hasher.update(×tamp_bytes); + hasher.update(timestamp_bytes); let expected_hash = hasher.finalize(); if nonce_bytes[8..24] != expected_hash[..16] { - return Err(OAuthError::InvalidDpopProof("Invalid nonce signature".to_string())); + return Err(OAuthError::InvalidDpopProof( + "Invalid nonce signature".to_string(), + )); } Ok(()) } @@ -103,7 +107,9 @@ impl DPoPVerifier { ) -> Result { let parts: Vec<&str> = dpop_header.split('.').collect(); if parts.len() != 3 { - return Err(OAuthError::InvalidDpopProof("Invalid DPoP proof format".to_string())); + return Err(OAuthError::InvalidDpopProof( + "Invalid DPoP proof format".to_string(), + )); } let header_json = URL_SAFE_NO_PAD .decode(parts[0]) @@ -116,22 +122,32 @@ impl DPoPVerifier { let payload: DPoPProofPayload = serde_json::from_slice(&payload_json) .map_err(|_| OAuthError::InvalidDpopProof("Invalid payload JSON".to_string()))?; if header.typ != "dpop+jwt" { - return Err(OAuthError::InvalidDpopProof("Invalid typ claim".to_string())); + return Err(OAuthError::InvalidDpopProof( + "Invalid typ claim".to_string(), + )); } if !matches!(header.alg.as_str(), "ES256" | "ES384" | "ES512" | "EdDSA") { - return Err(OAuthError::InvalidDpopProof("Unsupported algorithm".to_string())); + return Err(OAuthError::InvalidDpopProof( + "Unsupported algorithm".to_string(), + )); } if payload.htm.to_uppercase() != http_method.to_uppercase() { - return Err(OAuthError::InvalidDpopProof("HTTP method mismatch".to_string())); + return Err(OAuthError::InvalidDpopProof( + "HTTP method mismatch".to_string(), + )); } let proof_uri = payload.htu.split('?').next().unwrap_or(&payload.htu); let request_uri = http_uri.split('?').next().unwrap_or(http_uri); if proof_uri != request_uri { - return Err(OAuthError::InvalidDpopProof("HTTP URI mismatch".to_string())); + return Err(OAuthError::InvalidDpopProof( + "HTTP URI mismatch".to_string(), + )); } let now = Utc::now().timestamp(); if (now - payload.iat).abs() > DPOP_MAX_AGE_SECS { - return Err(OAuthError::InvalidDpopProof("Proof too old or from the future".to_string())); + return Err(OAuthError::InvalidDpopProof( + "Proof too old or from the future".to_string(), + )); } if let Some(nonce) = &payload.nonce { self.validate_nonce(nonce)?; @@ -155,7 +171,12 @@ impl DPoPVerifier { .decode(parts[2]) .map_err(|_| OAuthError::InvalidDpopProof("Invalid signature encoding".to_string()))?; let signing_input = format!("{}.{}", parts[0], parts[1]); - verify_dpop_signature(&header.alg, &header.jwk, signing_input.as_bytes(), &signature_bytes)?; + verify_dpop_signature( + &header.alg, + &header.jwk, + signing_input.as_bytes(), + &signature_bytes, + )?; let jkt = compute_jwk_thumbprint(&header.jwk)?; Ok(DPoPVerifyResult { jkt, @@ -186,9 +207,10 @@ fn verify_es256(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O use p256::ecdsa::{Signature, VerifyingKey}; use p256::elliptic_curve::sec1::FromEncodedPoint; use p256::{AffinePoint, EncodedPoint}; - let crv = jwk.crv.as_ref().ok_or_else(|| { - OAuthError::InvalidDpopProof("Missing crv for ES256".to_string()) - })?; + let crv = jwk + .crv + .as_ref() + .ok_or_else(|| OAuthError::InvalidDpopProof("Missing crv for ES256".to_string()))?; if crv != "P-256" { return Err(OAuthError::InvalidDpopProof(format!( "Invalid curve for ES256: {}", @@ -196,14 +218,18 @@ fn verify_es256(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O ))); } let x_bytes = URL_SAFE_NO_PAD - .decode(jwk.x.as_ref().ok_or_else(|| { - OAuthError::InvalidDpopProof("Missing x coordinate".to_string()) - })?) + .decode( + jwk.x + .as_ref() + .ok_or_else(|| OAuthError::InvalidDpopProof("Missing x coordinate".to_string()))?, + ) .map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?; let y_bytes = URL_SAFE_NO_PAD - .decode(jwk.y.as_ref().ok_or_else(|| { - OAuthError::InvalidDpopProof("Missing y coordinate".to_string()) - })?) + .decode( + jwk.y + .as_ref() + .ok_or_else(|| OAuthError::InvalidDpopProof("Missing y coordinate".to_string()))?, + ) .map_err(|_| OAuthError::InvalidDpopProof("Invalid y encoding".to_string()))?; let point = EncodedPoint::from_affine_coordinates( x_bytes.as_slice().into(), @@ -211,8 +237,8 @@ fn verify_es256(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O false, ); let affine_opt: Option = AffinePoint::from_encoded_point(&point).into(); - let affine = affine_opt - .ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?; + let affine = + affine_opt.ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?; let verifying_key = VerifyingKey::from_affine(affine) .map_err(|_| OAuthError::InvalidDpopProof("Invalid verifying key".to_string()))?; let sig = Signature::from_slice(signature) @@ -227,9 +253,10 @@ fn verify_es384(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O use p384::ecdsa::{Signature, VerifyingKey}; use p384::elliptic_curve::sec1::FromEncodedPoint; use p384::{AffinePoint, EncodedPoint}; - let crv = jwk.crv.as_ref().ok_or_else(|| { - OAuthError::InvalidDpopProof("Missing crv for ES384".to_string()) - })?; + let crv = jwk + .crv + .as_ref() + .ok_or_else(|| OAuthError::InvalidDpopProof("Missing crv for ES384".to_string()))?; if crv != "P-384" { return Err(OAuthError::InvalidDpopProof(format!( "Invalid curve for ES384: {}", @@ -237,14 +264,18 @@ fn verify_es384(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O ))); } let x_bytes = URL_SAFE_NO_PAD - .decode(jwk.x.as_ref().ok_or_else(|| { - OAuthError::InvalidDpopProof("Missing x coordinate".to_string()) - })?) + .decode( + jwk.x + .as_ref() + .ok_or_else(|| OAuthError::InvalidDpopProof("Missing x coordinate".to_string()))?, + ) .map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?; let y_bytes = URL_SAFE_NO_PAD - .decode(jwk.y.as_ref().ok_or_else(|| { - OAuthError::InvalidDpopProof("Missing y coordinate".to_string()) - })?) + .decode( + jwk.y + .as_ref() + .ok_or_else(|| OAuthError::InvalidDpopProof("Missing y coordinate".to_string()))?, + ) .map_err(|_| OAuthError::InvalidDpopProof("Invalid y encoding".to_string()))?; let point = EncodedPoint::from_affine_coordinates( x_bytes.as_slice().into(), @@ -252,8 +283,8 @@ fn verify_es384(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O false, ); let affine_opt: Option = AffinePoint::from_encoded_point(&point).into(); - let affine = affine_opt - .ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?; + let affine = + affine_opt.ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?; let verifying_key = VerifyingKey::from_affine(affine) .map_err(|_| OAuthError::InvalidDpopProof("Invalid verifying key".to_string()))?; let sig = Signature::from_slice(signature) @@ -265,9 +296,10 @@ fn verify_es384(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O fn verify_eddsa(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), OAuthError> { use ed25519_dalek::{Signature, VerifyingKey}; - let crv = jwk.crv.as_ref().ok_or_else(|| { - OAuthError::InvalidDpopProof("Missing crv for EdDSA".to_string()) - })?; + let crv = jwk + .crv + .as_ref() + .ok_or_else(|| OAuthError::InvalidDpopProof("Missing crv for EdDSA".to_string()))?; if crv != "Ed25519" { return Err(OAuthError::InvalidDpopProof(format!( "Invalid curve for EdDSA: {}", @@ -275,13 +307,15 @@ fn verify_eddsa(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O ))); } let x_bytes = URL_SAFE_NO_PAD - .decode(jwk.x.as_ref().ok_or_else(|| { - OAuthError::InvalidDpopProof("Missing x coordinate".to_string()) - })?) + .decode( + jwk.x + .as_ref() + .ok_or_else(|| OAuthError::InvalidDpopProof("Missing x coordinate".to_string()))?, + ) .map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?; - let key_bytes: [u8; 32] = x_bytes.try_into().map_err(|_| { - OAuthError::InvalidDpopProof("Invalid Ed25519 key length".to_string()) - })?; + let key_bytes: [u8; 32] = x_bytes + .try_into() + .map_err(|_| OAuthError::InvalidDpopProof("Invalid Ed25519 key length".to_string()))?; let verifying_key = VerifyingKey::from_bytes(&key_bytes) .map_err(|_| OAuthError::InvalidDpopProof("Invalid Ed25519 key".to_string()))?; let sig_bytes: [u8; 64] = signature.try_into().map_err(|_| { @@ -308,10 +342,7 @@ pub fn compute_jwk_thumbprint(jwk: &DPoPJwk) -> Result { .y .as_ref() .ok_or_else(|| OAuthError::InvalidDpopProof("Missing y".to_string()))?; - format!( - r#"{{"crv":"{}","kty":"EC","x":"{}","y":"{}"}}"#, - crv, x, y - ) + format!(r#"{{"crv":"{}","kty":"EC","x":"{}","y":"{}"}}"#, crv, x, y) } "OKP" => { let crv = jwk @@ -333,14 +364,14 @@ pub fn compute_jwk_thumbprint(jwk: &DPoPJwk) -> Result { let mut hasher = Sha256::new(); hasher.update(canonical.as_bytes()); let hash = hasher.finalize(); - Ok(URL_SAFE_NO_PAD.encode(&hash)) + Ok(URL_SAFE_NO_PAD.encode(hash)) } pub fn compute_access_token_hash(access_token: &str) -> String { let mut hasher = Sha256::new(); hasher.update(access_token.as_bytes()); let hash = hasher.finalize(); - URL_SAFE_NO_PAD.encode(&hash) + URL_SAFE_NO_PAD.encode(hash) } #[cfg(test)] diff --git a/src/oauth/endpoints/authorize.rs b/src/oauth/endpoints/authorize.rs index 7863c16..50e13c9 100644 --- a/src/oauth/endpoints/authorize.rs +++ b/src/oauth/endpoints/authorize.rs @@ -1,16 +1,21 @@ +use crate::notifications::{NotificationChannel, channel_display_name, enqueue_2fa_code}; +use crate::oauth::{ + Code, DeviceAccount, DeviceData, DeviceId, OAuthError, SessionId, db, templates, +}; +use crate::state::{AppState, RateLimitKind}; use axum::{ Form, Json, extract::{Query, State}, - http::{HeaderMap, StatusCode, header::{SET_COOKIE, LOCATION}}, - response::{IntoResponse, Redirect, Response, Html}, + http::{ + HeaderMap, StatusCode, + header::{LOCATION, SET_COOKIE}, + }, + response::{Html, IntoResponse, Redirect, Response}, }; use chrono::Utc; use serde::{Deserialize, Serialize}; use subtle::ConstantTimeEq; use urlencoding::encode as url_encode; -use crate::state::{AppState, RateLimitKind}; -use crate::oauth::{Code, DeviceAccount, DeviceData, DeviceId, OAuthError, SessionId, db, templates}; -use crate::notifications::{NotificationChannel, channel_display_name, enqueue_2fa_code}; const DEVICE_COOKIE_NAME: &str = "oauth_device_id"; @@ -34,18 +39,15 @@ fn extract_device_cookie(headers: &HeaderMap) -> Option { } fn extract_client_ip(headers: &HeaderMap) -> String { - if let Some(forwarded) = headers.get("x-forwarded-for") { - if let Ok(value) = forwarded.to_str() { - if let Some(first_ip) = value.split(',').next() { + if let Some(forwarded) = headers.get("x-forwarded-for") + && let Ok(value) = forwarded.to_str() + && let Some(first_ip) = value.split(',').next() { return first_ip.trim().to_string(); } - } - } - if let Some(real_ip) = headers.get("x-real-ip") { - if let Ok(value) = real_ip.to_str() { + if let Some(real_ip) = headers.get("x-real-ip") + && let Ok(value) = real_ip.to_str() { return value.trim().to_string(); } - } "0.0.0.0".to_string() } @@ -59,8 +61,7 @@ fn extract_user_agent(headers: &HeaderMap) -> Option { fn make_device_cookie(device_id: &str) -> String { format!( "{}={}; Path=/oauth; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000", - DEVICE_COOKIE_NAME, - device_id + DEVICE_COOKIE_NAME, device_id ) } @@ -127,7 +128,8 @@ pub async fn authorize_get( "invalid_request", Some("Missing request_uri parameter. Use PAR to initiate authorization."), )), - ).into_response(); + ) + .into_response(); } }; let request_data = match db::get_authorization_request(&state.db, &request_uri).await { @@ -146,9 +148,12 @@ pub async fn authorize_get( axum::http::StatusCode::BAD_REQUEST, Html(templates::error_page( "invalid_request", - Some("Invalid or expired request_uri. Please start a new authorization request."), + Some( + "Invalid or expired request_uri. Please start a new authorization request.", + ), )), - ).into_response(); + ) + .into_response(); } Err(e) => { if wants_json(&headers) { @@ -158,7 +163,8 @@ pub async fn authorize_get( "error": "server_error", "error_description": format!("Database error: {:?}", e) })), - ).into_response(); + ) + .into_response(); } return ( axum::http::StatusCode::INTERNAL_SERVER_ERROR, @@ -166,7 +172,8 @@ pub async fn authorize_get( "server_error", Some(&format!("Database error: {:?}", e)), )), - ).into_response(); + ) + .into_response(); } }; if request_data.expires_at < Utc::now() { @@ -186,7 +193,8 @@ pub async fn authorize_get( "invalid_request", Some("Authorization request has expired. Please start a new request."), )), - ).into_response(); + ) + .into_response(); } if wants_json(&headers) { return Json(AuthorizeResponse { @@ -196,13 +204,14 @@ pub async fn authorize_get( redirect_uri: request_data.parameters.redirect_uri.clone(), state: request_data.parameters.state.clone(), login_hint: request_data.parameters.login_hint.clone(), - }).into_response(); + }) + .into_response(); } let force_new_account = query.new_account.unwrap_or(false); - if !force_new_account { - if let Some(device_id) = extract_device_cookie(&headers) { - if let Ok(accounts) = db::get_device_accounts(&state.db, &device_id).await { - if !accounts.is_empty() { + if !force_new_account + && let Some(device_id) = extract_device_cookie(&headers) + && let Ok(accounts) = db::get_device_accounts(&state.db, &device_id).await + && !accounts.is_empty() { let device_accounts: Vec = accounts .into_iter() .map(|row| DeviceAccount { @@ -217,11 +226,9 @@ pub async fn authorize_get( None, &request_uri, &device_accounts, - )).into_response(); + )) + .into_response(); } - } - } - } Html(templates::login_page( &request_data.parameters.client_id, None, @@ -229,22 +236,25 @@ pub async fn authorize_get( &request_uri, None, request_data.parameters.login_hint.as_deref(), - )).into_response() + )) + .into_response() } pub async fn authorize_get_json( State(state): State, Query(query): Query, ) -> Result, OAuthError> { - let request_uri = query.request_uri.ok_or_else(|| { - OAuthError::InvalidRequest("request_uri is required".to_string()) - })?; + let request_uri = query + .request_uri + .ok_or_else(|| OAuthError::InvalidRequest("request_uri is required".to_string()))?; let request_data = db::get_authorization_request(&state.db, &request_uri) .await? .ok_or_else(|| OAuthError::InvalidRequest("Invalid or expired request_uri".to_string()))?; if request_data.expires_at < Utc::now() { db::delete_authorization_request(&state.db, &request_uri).await?; - return Err(OAuthError::InvalidRequest("request_uri has expired".to_string())); + return Err(OAuthError::InvalidRequest( + "request_uri has expired".to_string(), + )); } Ok(Json(AuthorizeResponse { client_id: request_data.parameters.client_id.clone(), @@ -263,7 +273,10 @@ pub async fn authorize_post( ) -> Response { let json_response = wants_json(&headers); let client_ip = extract_client_ip(&headers); - if !state.check_rate_limit(RateLimitKind::OAuthAuthorize, &client_ip).await { + if !state + .check_rate_limit(RateLimitKind::OAuthAuthorize, &client_ip) + .await + { tracing::warn!(ip = %client_ip, "OAuth authorize rate limit exceeded"); if json_response { return ( @@ -272,7 +285,8 @@ pub async fn authorize_post( "error": "RateLimitExceeded", "error_description": "Too many login attempts. Please try again later." })), - ).into_response(); + ) + .into_response(); } return ( axum::http::StatusCode::TOO_MANY_REQUESTS, @@ -280,7 +294,8 @@ pub async fn authorize_post( "RateLimitExceeded", Some("Too many login attempts. Please try again later."), )), - ).into_response(); + ) + .into_response(); } let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await { Ok(Some(data)) => data, @@ -292,12 +307,14 @@ pub async fn authorize_post( "error": "invalid_request", "error_description": "Invalid or expired request_uri." })), - ).into_response(); + ) + .into_response(); } return Html(templates::error_page( "invalid_request", Some("Invalid or expired request_uri. Please start a new authorization request."), - )).into_response(); + )) + .into_response(); } Err(e) => { if json_response { @@ -307,12 +324,14 @@ pub async fn authorize_post( "error": "server_error", "error_description": format!("Database error: {:?}", e) })), - ).into_response(); + ) + .into_response(); } return Html(templates::error_page( "server_error", Some(&format!("Database error: {:?}", e)), - )).into_response(); + )) + .into_response(); } }; if request_data.expires_at < Utc::now() { @@ -324,12 +343,14 @@ pub async fn authorize_post( "error": "invalid_request", "error_description": "Authorization request has expired." })), - ).into_response(); + ) + .into_response(); } return Html(templates::error_page( "invalid_request", Some("Authorization request has expired. Please start a new request."), - )).into_response(); + )) + .into_response(); } let show_login_error = |error_msg: &str, json: bool| -> Response { if json { @@ -339,7 +360,8 @@ pub async fn authorize_post( "error": "access_denied", "error_description": error_msg })), - ).into_response(); + ) + .into_response(); } Html(templates::login_page( &request_data.parameters.client_id, @@ -348,12 +370,17 @@ pub async fn authorize_post( &form.request_uri, Some(error_msg), Some(&form.username), - )).into_response() + )) + .into_response() }; let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let normalized_username = form.username.trim(); - let normalized_username = normalized_username.strip_prefix('@').unwrap_or(normalized_username); - let normalized_username = if let Some(bare_handle) = normalized_username.strip_suffix(&format!(".{}", pds_hostname)) { + let normalized_username = normalized_username + .strip_prefix('@') + .unwrap_or(normalized_username); + let normalized_username = if let Some(bare_handle) = + normalized_username.strip_suffix(&format!(".{}", pds_hostname)) + { bare_handle.to_string() } else { normalized_username.to_string() @@ -401,13 +428,11 @@ pub async fn authorize_post( let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await; match db::create_2fa_challenge(&state.db, &user.did, &form.request_uri).await { Ok(challenge) => { - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - if let Err(e) = enqueue_2fa_code( - &state.db, - user.id, - &challenge.code, - &hostname, - ).await { + let hostname = + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + if let Err(e) = + enqueue_2fa_code(&state.db, user.id, &challenge.code, &hostname).await + { tracing::warn!( did = %user.did, error = %e, @@ -441,7 +466,10 @@ pub async fn authorize_post( ip_address: extract_client_ip(&headers), last_seen_at: Utc::now(), }; - if db::create_device(&state.db, &new_id.0, &device_data).await.is_ok() { + if db::create_device(&state.db, &new_id.0, &device_data) + .await + .is_ok() + { new_cookie = Some(make_device_cookie(&new_id.0)); device_id = Some(new_id.0.clone()); } @@ -449,7 +477,7 @@ pub async fn authorize_post( }; let _ = db::upsert_account_device(&state.db, &user.did, &final_device_id).await; } - if let Err(_) = db::update_authorization_request( + if db::update_authorization_request( &state.db, &form.request_uri, &user.did, @@ -457,6 +485,7 @@ pub async fn authorize_post( &code.0, ) .await + .is_err() { return show_login_error("An error occurred. Please try again.", json_response); } @@ -466,7 +495,11 @@ pub async fn authorize_post( request_data.parameters.state.as_deref(), ); if let Some(cookie) = new_cookie { - (StatusCode::SEE_OTHER, [(SET_COOKIE, cookie), (LOCATION, redirect_url)]).into_response() + ( + StatusCode::SEE_OTHER, + [(SET_COOKIE, cookie), (LOCATION, redirect_url)], + ) + .into_response() } else { redirect_see_other(&redirect_url) } @@ -483,13 +516,15 @@ pub async fn authorize_select( return Html(templates::error_page( "invalid_request", Some("Invalid or expired request_uri. Please start a new authorization request."), - )).into_response(); + )) + .into_response(); } Err(_) => { return Html(templates::error_page( "server_error", Some("An error occurred. Please try again."), - )).into_response(); + )) + .into_response(); } }; if request_data.expires_at < Utc::now() { @@ -497,7 +532,8 @@ pub async fn authorize_select( return Html(templates::error_page( "invalid_request", Some("Authorization request has expired. Please start a new request."), - )).into_response(); + )) + .into_response(); } let device_id = match extract_device_cookie(&headers) { Some(id) => id, @@ -505,7 +541,8 @@ pub async fn authorize_select( return Html(templates::error_page( "invalid_request", Some("No device session found. Please sign in."), - )).into_response(); + )) + .into_response(); } }; let account_valid = match db::verify_account_on_device(&state.db, &device_id, &form.did).await { @@ -514,14 +551,16 @@ pub async fn authorize_select( return Html(templates::error_page( "server_error", Some("An error occurred. Please try again."), - )).into_response(); + )) + .into_response(); } }; if !account_valid { return Html(templates::error_page( "access_denied", Some("This account is not available on this device. Please sign in."), - )).into_response(); + )) + .into_response(); } let user = match sqlx::query!( r#" @@ -553,13 +592,11 @@ pub async fn authorize_select( let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await; match db::create_2fa_challenge(&state.db, &form.did, &form.request_uri).await { Ok(challenge) => { - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - if let Err(e) = enqueue_2fa_code( - &state.db, - user.id, - &challenge.code, - &hostname, - ).await { + let hostname = + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + if let Err(e) = + enqueue_2fa_code(&state.db, user.id, &challenge.code, &hostname).await + { tracing::warn!( did = %form.did, error = %e, @@ -578,13 +615,14 @@ pub async fn authorize_select( return Html(templates::error_page( "server_error", Some("An error occurred. Please try again."), - )).into_response(); + )) + .into_response(); } } } let _ = db::upsert_account_device(&state.db, &form.did, &device_id).await; let code = Code::generate(); - if let Err(_) = db::update_authorization_request( + if db::update_authorization_request( &state.db, &form.request_uri, &form.did, @@ -592,11 +630,13 @@ pub async fn authorize_select( &code.0, ) .await + .is_err() { return Html(templates::error_page( "server_error", Some("An error occurred. Please try again."), - )).into_response(); + )) + .into_response(); } let redirect_url = build_success_redirect( &request_data.parameters.redirect_uri, @@ -615,7 +655,10 @@ fn build_success_redirect(redirect_uri: &str, code: &str, state: Option<&str>) - redirect_url.push_str(&format!("&state={}", url_encode(req_state))); } let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - redirect_url.push_str(&format!("&iss={}", url_encode(&format!("https://{}", pds_hostname)))); + redirect_url.push_str(&format!( + "&iss={}", + url_encode(&format!("https://{}", pds_hostname)) + )); redirect_url } @@ -674,13 +717,15 @@ pub async fn authorize_2fa_get( return Html(templates::error_page( "invalid_request", Some("No 2FA challenge found. Please start over."), - )).into_response(); + )) + .into_response(); } Err(_) => { return Html(templates::error_page( "server_error", Some("An error occurred. Please try again."), - )).into_response(); + )) + .into_response(); } }; if challenge.expires_at < Utc::now() { @@ -688,7 +733,8 @@ pub async fn authorize_2fa_get( return Html(templates::error_page( "invalid_request", Some("2FA code has expired. Please start over."), - )).into_response(); + )) + .into_response(); } let _request_data = match db::get_authorization_request(&state.db, &query.request_uri).await { Ok(Some(d)) => d, @@ -696,13 +742,15 @@ pub async fn authorize_2fa_get( return Html(templates::error_page( "invalid_request", Some("Authorization request not found. Please start over."), - )).into_response(); + )) + .into_response(); } Err(_) => { return Html(templates::error_page( "server_error", Some("An error occurred. Please try again."), - )).into_response(); + )) + .into_response(); } }; let channel = query.channel.as_deref().unwrap_or("email"); @@ -710,7 +758,8 @@ pub async fn authorize_2fa_get( &query.request_uri, channel, None, - )).into_response() + )) + .into_response() } pub async fn authorize_2fa_post( @@ -719,7 +768,10 @@ pub async fn authorize_2fa_post( Form(form): Form, ) -> Response { let client_ip = extract_client_ip(&headers); - if !state.check_rate_limit(RateLimitKind::OAuthAuthorize, &client_ip).await { + if !state + .check_rate_limit(RateLimitKind::OAuthAuthorize, &client_ip) + .await + { tracing::warn!(ip = %client_ip, "OAuth 2FA rate limit exceeded"); return ( axum::http::StatusCode::TOO_MANY_REQUESTS, @@ -727,7 +779,8 @@ pub async fn authorize_2fa_post( "RateLimitExceeded", Some("Too many attempts. Please try again later."), )), - ).into_response(); + ) + .into_response(); } let challenge = match db::get_2fa_challenge(&state.db, &form.request_uri).await { Ok(Some(c)) => c, @@ -735,13 +788,15 @@ pub async fn authorize_2fa_post( return Html(templates::error_page( "invalid_request", Some("No 2FA challenge found. Please start over."), - )).into_response(); + )) + .into_response(); } Err(_) => { return Html(templates::error_page( "server_error", Some("An error occurred. Please try again."), - )).into_response(); + )) + .into_response(); } }; if challenge.expires_at < Utc::now() { @@ -749,16 +804,23 @@ pub async fn authorize_2fa_post( return Html(templates::error_page( "invalid_request", Some("2FA code has expired. Please start over."), - )).into_response(); + )) + .into_response(); } if challenge.attempts >= MAX_2FA_ATTEMPTS { let _ = db::delete_2fa_challenge(&state.db, challenge.id).await; return Html(templates::error_page( "access_denied", Some("Too many failed attempts. Please start over."), - )).into_response(); + )) + .into_response(); } - let code_valid: bool = form.code.trim().as_bytes().ct_eq(challenge.code.as_bytes()).into(); + let code_valid: bool = form + .code + .trim() + .as_bytes() + .ct_eq(challenge.code.as_bytes()) + .into(); if !code_valid { let _ = db::increment_2fa_attempts(&state.db, challenge.id).await; let channel = match sqlx::query_scalar!( @@ -771,26 +833,30 @@ pub async fn authorize_2fa_post( Ok(Some(ch)) => channel_display_name(ch).to_string(), Ok(None) | Err(_) => "email".to_string(), }; - let _request_data = match db::get_authorization_request(&state.db, &form.request_uri).await { + let _request_data = match db::get_authorization_request(&state.db, &form.request_uri).await + { Ok(Some(d)) => d, Ok(None) => { return Html(templates::error_page( "invalid_request", Some("Authorization request not found. Please start over."), - )).into_response(); + )) + .into_response(); } Err(_) => { return Html(templates::error_page( "server_error", Some("An error occurred. Please try again."), - )).into_response(); + )) + .into_response(); } }; return Html(templates::two_factor_page( &form.request_uri, &channel, Some("Invalid verification code. Please try again."), - )).into_response(); + )) + .into_response(); } let _ = db::delete_2fa_challenge(&state.db, challenge.id).await; let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await { @@ -799,18 +865,20 @@ pub async fn authorize_2fa_post( return Html(templates::error_page( "invalid_request", Some("Authorization request not found."), - )).into_response(); + )) + .into_response(); } Err(_) => { return Html(templates::error_page( "server_error", Some("An error occurred."), - )).into_response(); + )) + .into_response(); } }; let code = Code::generate(); let device_id = extract_device_cookie(&headers); - if let Err(_) = db::update_authorization_request( + if db::update_authorization_request( &state.db, &form.request_uri, &challenge.did, @@ -818,11 +886,13 @@ pub async fn authorize_2fa_post( &code.0, ) .await + .is_err() { return Html(templates::error_page( "server_error", Some("An error occurred. Please try again."), - )).into_response(); + )) + .into_response(); } let redirect_url = build_success_redirect( &request_data.parameters.redirect_uri, diff --git a/src/oauth/endpoints/metadata.rs b/src/oauth/endpoints/metadata.rs index 537780d..8256ed1 100644 --- a/src/oauth/endpoints/metadata.rs +++ b/src/oauth/endpoints/metadata.rs @@ -1,7 +1,7 @@ +use crate::oauth::jwks::{JwkSet, create_jwk_set}; +use crate::state::AppState; use axum::{Json, extract::State}; use serde::{Deserialize, Serialize}; -use crate::state::AppState; -use crate::oauth::jwks::{JwkSet, create_jwk_set}; #[derive(Debug, Serialize, Deserialize)] pub struct ProtectedResourceMetadata { diff --git a/src/oauth/endpoints/mod.rs b/src/oauth/endpoints/mod.rs index 97bc947..f7ae270 100644 --- a/src/oauth/endpoints/mod.rs +++ b/src/oauth/endpoints/mod.rs @@ -1,9 +1,9 @@ +pub mod authorize; pub mod metadata; pub mod par; -pub mod authorize; pub mod token; +pub use authorize::*; pub use metadata::*; pub use par::*; -pub use authorize::*; pub use token::*; diff --git a/src/oauth/endpoints/par.rs b/src/oauth/endpoints/par.rs index 22ec50a..b27464e 100644 --- a/src/oauth/endpoints/par.rs +++ b/src/oauth/endpoints/par.rs @@ -1,16 +1,11 @@ -use axum::{ - Form, Json, - extract::State, - http::HeaderMap, -}; -use chrono::{Duration, Utc}; -use serde::{Deserialize, Serialize}; -use crate::state::{AppState, RateLimitKind}; use crate::oauth::{ AuthorizationRequestParameters, ClientAuth, OAuthError, RequestData, RequestId, - client::ClientMetadataCache, - db, + client::ClientMetadataCache, db, }; +use crate::state::{AppState, RateLimitKind}; +use axum::{Form, Json, extract::State, http::HeaderMap}; +use chrono::{Duration, Utc}; +use serde::{Deserialize, Serialize}; const PAR_EXPIRY_SECONDS: i64 = 600; const SUPPORTED_SCOPES: &[&str] = &["atproto", "transition:generic", "transition:chat.bsky"]; @@ -52,7 +47,10 @@ pub async fn pushed_authorization_request( Form(request): Form, ) -> Result<(axum::http::StatusCode, Json), OAuthError> { let client_ip = crate::rate_limit::extract_client_ip(&headers, None); - if !state.check_rate_limit(RateLimitKind::OAuthPar, &client_ip).await { + if !state + .check_rate_limit(RateLimitKind::OAuthPar, &client_ip) + .await + { tracing::warn!(ip = %client_ip, "OAuth PAR rate limit exceeded"); return Err(OAuthError::RateLimited); } @@ -61,7 +59,9 @@ pub async fn pushed_authorization_request( "response_type must be 'code'".to_string(), )); } - let code_challenge = request.code_challenge.as_ref() + let code_challenge = request + .code_challenge + .as_ref() .filter(|s| !s.is_empty()) .ok_or_else(|| OAuthError::InvalidRequest("code_challenge is required".to_string()))?; let code_challenge_method = request.code_challenge_method.as_deref().unwrap_or(""); diff --git a/src/oauth/endpoints/token/grants.rs b/src/oauth/endpoints/token/grants.rs index e8a2ead..f6f2ff4 100644 --- a/src/oauth/endpoints/token/grants.rs +++ b/src/oauth/endpoints/token/grants.rs @@ -1,16 +1,16 @@ -use axum::http::HeaderMap; -use axum::Json; -use chrono::{Duration, Utc}; +use super::helpers::{create_access_token, verify_pkce}; +use super::types::{TokenRequest, TokenResponse}; use crate::config::AuthConfig; -use crate::state::AppState; use crate::oauth::{ ClientAuth, OAuthError, RefreshToken, TokenData, TokenId, client::{ClientMetadataCache, verify_client_auth}, db, dpop::DPoPVerifier, }; -use super::types::{TokenRequest, TokenResponse}; -use super::helpers::{create_access_token, verify_pkce}; +use crate::state::AppState; +use axum::Json; +use axum::http::HeaderMap; +use chrono::{Duration, Utc}; const ACCESS_TOKEN_EXPIRY_SECONDS: i64 = 3600; const REFRESH_TOKEN_EXPIRY_DAYS: i64 = 60; @@ -31,19 +31,22 @@ pub async fn handle_authorization_code_grant( .await? .ok_or_else(|| OAuthError::InvalidGrant("Invalid or expired code".to_string()))?; if auth_request.expires_at < Utc::now() { - return Err(OAuthError::InvalidGrant("Authorization code has expired".to_string())); + return Err(OAuthError::InvalidGrant( + "Authorization code has expired".to_string(), + )); } - if let Some(request_client_id) = &request.client_id { - if request_client_id != &auth_request.client_id { + if let Some(request_client_id) = &request.client_id + && request_client_id != &auth_request.client_id { return Err(OAuthError::InvalidGrant("client_id mismatch".to_string())); } - } let did = auth_request .did .ok_or_else(|| OAuthError::InvalidGrant("Authorization not completed".to_string()))?; let client_metadata_cache = ClientMetadataCache::new(3600); let client_metadata = client_metadata_cache.get(&auth_request.client_id).await?; - let client_auth = if let (Some(assertion), Some(assertion_type)) = (&request.client_assertion, &request.client_assertion_type) { + let client_auth = if let (Some(assertion), Some(assertion_type)) = + (&request.client_assertion, &request.client_assertion_type) + { if assertion_type != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" { return Err(OAuthError::InvalidClient( "Unsupported client_assertion_type".to_string(), @@ -61,15 +64,17 @@ pub async fn handle_authorization_code_grant( }; verify_client_auth(&client_metadata_cache, &client_metadata, &client_auth).await?; verify_pkce(&auth_request.parameters.code_challenge, &code_verifier)?; - if let Some(redirect_uri) = &request.redirect_uri { - if redirect_uri != &auth_request.parameters.redirect_uri { - return Err(OAuthError::InvalidGrant("redirect_uri mismatch".to_string())); + if let Some(redirect_uri) = &request.redirect_uri + && redirect_uri != &auth_request.parameters.redirect_uri { + return Err(OAuthError::InvalidGrant( + "redirect_uri mismatch".to_string(), + )); } - } let dpop_jkt = if let Some(proof) = &dpop_proof { let config = AuthConfig::get(); let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let pds_hostname = + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let token_endpoint = format!("https://{}/oauth/token", pds_hostname); let result = verifier.verify_proof(proof, "POST", &token_endpoint, None)?; if !db::check_and_record_dpop_jti(&state.db, &result.jti).await? { @@ -77,13 +82,12 @@ pub async fn handle_authorization_code_grant( "DPoP proof has already been used".to_string(), )); } - if let Some(expected_jkt) = &auth_request.parameters.dpop_jkt { - if &result.jkt != expected_jkt { + if let Some(expected_jkt) = &auth_request.parameters.dpop_jkt + && &result.jkt != expected_jkt { return Err(OAuthError::InvalidDpopProof( "DPoP key binding mismatch".to_string(), )); } - } Some(result.jkt) } else if auth_request.parameters.dpop_jkt.is_some() { return Err(OAuthError::InvalidRequest( @@ -124,10 +128,7 @@ pub async fn handle_authorization_code_grant( let mut response_headers = HeaderMap::new(); let config = AuthConfig::get(); let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); - response_headers.insert( - "DPoP-Nonce", - verifier.generate_nonce().parse().unwrap(), - ); + response_headers.insert("DPoP-Nonce", verifier.generate_nonce().parse().unwrap()); Ok(( response_headers, Json(TokenResponse { @@ -161,12 +162,15 @@ pub async fn handle_refresh_token_grant( .ok_or_else(|| OAuthError::InvalidGrant("Invalid refresh token".to_string()))?; if token_data.expires_at < Utc::now() { db::delete_token_family(&state.db, db_id).await?; - return Err(OAuthError::InvalidGrant("Refresh token has expired".to_string())); + return Err(OAuthError::InvalidGrant( + "Refresh token has expired".to_string(), + )); } let dpop_jkt = if let Some(proof) = &dpop_proof { let config = AuthConfig::get(); let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let pds_hostname = + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let token_endpoint = format!("https://{}/oauth/token", pds_hostname); let result = verifier.verify_proof(proof, "POST", &token_endpoint, None)?; if !db::check_and_record_dpop_jti(&state.db, &result.jti).await? { @@ -174,13 +178,12 @@ pub async fn handle_refresh_token_grant( "DPoP proof has already been used".to_string(), )); } - if let Some(expected_jkt) = &token_data.parameters.dpop_jkt { - if &result.jkt != expected_jkt { + if let Some(expected_jkt) = &token_data.parameters.dpop_jkt + && &result.jkt != expected_jkt { return Err(OAuthError::InvalidDpopProof( "DPoP key binding mismatch".to_string(), )); } - } Some(result.jkt) } else if token_data.parameters.dpop_jkt.is_some() { return Err(OAuthError::InvalidRequest( @@ -204,10 +207,7 @@ pub async fn handle_refresh_token_grant( let mut response_headers = HeaderMap::new(); let config = AuthConfig::get(); let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); - response_headers.insert( - "DPoP-Nonce", - verifier.generate_nonce().parse().unwrap(), - ); + response_headers.insert("DPoP-Nonce", verifier.generate_nonce().parse().unwrap()); Ok(( response_headers, Json(TokenResponse { diff --git a/src/oauth/endpoints/token/helpers.rs b/src/oauth/endpoints/token/helpers.rs index ca96882..214daf1 100644 --- a/src/oauth/endpoints/token/helpers.rs +++ b/src/oauth/endpoints/token/helpers.rs @@ -1,11 +1,11 @@ +use crate::config::AuthConfig; +use crate::oauth::OAuthError; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use chrono::Utc; use hmac::Mac; use sha2::{Digest, Sha256}; use subtle::ConstantTimeEq; -use crate::config::AuthConfig; -use crate::oauth::OAuthError; const ACCESS_TOKEN_EXPIRY_SECONDS: i64 = 3600; @@ -19,9 +19,15 @@ pub fn verify_pkce(code_challenge: &str, code_verifier: &str) -> Result<(), OAut let mut hasher = Sha256::new(); hasher.update(code_verifier.as_bytes()); let hash = hasher.finalize(); - let computed_challenge = URL_SAFE_NO_PAD.encode(&hash); - if !bool::from(computed_challenge.as_bytes().ct_eq(code_challenge.as_bytes())) { - return Err(OAuthError::InvalidGrant("PKCE verification failed".to_string())); + let computed_challenge = URL_SAFE_NO_PAD.encode(hash); + if !bool::from( + computed_challenge + .as_bytes() + .ct_eq(code_challenge.as_bytes()), + ) { + return Err(OAuthError::InvalidGrant( + "PKCE verification failed".to_string(), + )); } Ok(()) } @@ -61,7 +67,7 @@ pub fn create_access_token( .map_err(|_| OAuthError::ServerError("HMAC key error".to_string()))?; mac.update(signing_input.as_bytes()); let signature = mac.finalize().into_bytes(); - let signature_b64 = URL_SAFE_NO_PAD.encode(&signature); + let signature_b64 = URL_SAFE_NO_PAD.encode(signature); Ok(format!("{}.{}", signing_input, signature_b64)) } @@ -76,10 +82,14 @@ pub fn extract_token_claims(token: &str) -> Result { let header: serde_json::Value = serde_json::from_slice(&header_bytes) .map_err(|_| OAuthError::InvalidToken("Invalid token header".to_string()))?; if header.get("typ").and_then(|t| t.as_str()) != Some("at+jwt") { - return Err(OAuthError::InvalidToken("Not an OAuth access token".to_string())); + return Err(OAuthError::InvalidToken( + "Not an OAuth access token".to_string(), + )); } if header.get("alg").and_then(|a| a.as_str()) != Some("HS256") { - return Err(OAuthError::InvalidToken("Unsupported algorithm".to_string())); + return Err(OAuthError::InvalidToken( + "Unsupported algorithm".to_string(), + )); } let config = AuthConfig::get(); let secret = config.jwt_secret(); @@ -93,7 +103,9 @@ pub fn extract_token_claims(token: &str) -> Result { mac.update(signing_input.as_bytes()); let expected_sig = mac.finalize().into_bytes(); if !bool::from(expected_sig.ct_eq(&provided_sig)) { - return Err(OAuthError::InvalidToken("Invalid token signature".to_string())); + return Err(OAuthError::InvalidToken( + "Invalid token signature".to_string(), + )); } let payload_bytes = URL_SAFE_NO_PAD .decode(parts[1]) diff --git a/src/oauth/endpoints/token/introspect.rs b/src/oauth/endpoints/token/introspect.rs index a95ac87..df5cf42 100644 --- a/src/oauth/endpoints/token/introspect.rs +++ b/src/oauth/endpoints/token/introspect.rs @@ -1,11 +1,11 @@ -use axum::{Form, Json}; +use super::helpers::extract_token_claims; +use crate::oauth::{OAuthError, db}; +use crate::state::{AppState, RateLimitKind}; use axum::extract::State; use axum::http::{HeaderMap, StatusCode}; +use axum::{Form, Json}; use chrono::Utc; use serde::{Deserialize, Serialize}; -use crate::state::{AppState, RateLimitKind}; -use crate::oauth::{OAuthError, db}; -use super::helpers::extract_token_claims; #[derive(Debug, Deserialize)] pub struct RevokeRequest { @@ -20,7 +20,10 @@ pub async fn revoke_token( Form(request): Form, ) -> Result { let client_ip = crate::rate_limit::extract_client_ip(&headers, None); - if !state.check_rate_limit(RateLimitKind::OAuthIntrospect, &client_ip).await { + if !state + .check_rate_limit(RateLimitKind::OAuthIntrospect, &client_ip) + .await + { tracing::warn!(ip = %client_ip, "OAuth revoke rate limit exceeded"); return Err(OAuthError::RateLimited); } @@ -74,7 +77,10 @@ pub async fn introspect_token( Form(request): Form, ) -> Result, OAuthError> { let client_ip = crate::rate_limit::extract_client_ip(&headers, None); - if !state.check_rate_limit(RateLimitKind::OAuthIntrospect, &client_ip).await { + if !state + .check_rate_limit(RateLimitKind::OAuthIntrospect, &client_ip) + .await + { tracing::warn!(ip = %client_ip, "OAuth introspect rate limit exceeded"); return Err(OAuthError::RateLimited); } diff --git a/src/oauth/endpoints/token/mod.rs b/src/oauth/endpoints/token/mod.rs index 68f0c62..306219f 100644 --- a/src/oauth/endpoints/token/mod.rs +++ b/src/oauth/endpoints/token/mod.rs @@ -3,34 +3,27 @@ mod helpers; mod introspect; mod types; -use axum::{ - Form, Json, - extract::State, - http::HeaderMap, -}; -use crate::state::{AppState, RateLimitKind}; use crate::oauth::OAuthError; +use crate::state::{AppState, RateLimitKind}; +use axum::{Form, Json, extract::State, http::HeaderMap}; pub use grants::{handle_authorization_code_grant, handle_refresh_token_grant}; -pub use helpers::{create_access_token, extract_token_claims, verify_pkce, TokenClaims}; +pub use helpers::{TokenClaims, create_access_token, extract_token_claims, verify_pkce}; pub use introspect::{ - introspect_token, revoke_token, IntrospectRequest, IntrospectResponse, RevokeRequest, + IntrospectRequest, IntrospectResponse, RevokeRequest, introspect_token, revoke_token, }; pub use types::{TokenRequest, TokenResponse}; fn extract_client_ip(headers: &HeaderMap) -> String { - if let Some(forwarded) = headers.get("x-forwarded-for") { - if let Ok(value) = forwarded.to_str() { - if let Some(first_ip) = value.split(',').next() { + if let Some(forwarded) = headers.get("x-forwarded-for") + && let Ok(value) = forwarded.to_str() + && let Some(first_ip) = value.split(',').next() { return first_ip.trim().to_string(); } - } - } - if let Some(real_ip) = headers.get("x-real-ip") { - if let Ok(value) = real_ip.to_str() { + if let Some(real_ip) = headers.get("x-real-ip") + && let Ok(value) = real_ip.to_str() { return value.trim().to_string(); } - } "unknown".to_string() } @@ -40,7 +33,10 @@ pub async fn token_endpoint( Form(request): Form, ) -> Result<(HeaderMap, Json), OAuthError> { let client_ip = extract_client_ip(&headers); - if !state.check_rate_limit(RateLimitKind::OAuthToken, &client_ip).await { + if !state + .check_rate_limit(RateLimitKind::OAuthToken, &client_ip) + .await + { tracing::warn!(ip = %client_ip, "OAuth token rate limit exceeded"); return Err(OAuthError::InvalidRequest( "Too many requests. Please try again later.".to_string(), @@ -54,9 +50,7 @@ pub async fn token_endpoint( "authorization_code" => { handle_authorization_code_grant(state, headers, request, dpop_proof).await } - "refresh_token" => { - handle_refresh_token_grant(state, headers, request, dpop_proof).await - } + "refresh_token" => handle_refresh_token_grant(state, headers, request, dpop_proof).await, _ => Err(OAuthError::UnsupportedGrantType(format!( "Unsupported grant_type: {}", request.grant_type diff --git a/src/oauth/error.rs b/src/oauth/error.rs index c7fbf7e..8093366 100644 --- a/src/oauth/error.rs +++ b/src/oauth/error.rs @@ -37,21 +37,15 @@ impl IntoResponse for OAuthError { OAuthError::InvalidClient(msg) => { (StatusCode::UNAUTHORIZED, "invalid_client", Some(msg)) } - OAuthError::InvalidGrant(msg) => { - (StatusCode::BAD_REQUEST, "invalid_grant", Some(msg)) - } + OAuthError::InvalidGrant(msg) => (StatusCode::BAD_REQUEST, "invalid_grant", Some(msg)), OAuthError::UnauthorizedClient(msg) => { (StatusCode::UNAUTHORIZED, "unauthorized_client", Some(msg)) } OAuthError::UnsupportedGrantType(msg) => { (StatusCode::BAD_REQUEST, "unsupported_grant_type", Some(msg)) } - OAuthError::InvalidScope(msg) => { - (StatusCode::BAD_REQUEST, "invalid_scope", Some(msg)) - } - OAuthError::AccessDenied(msg) => { - (StatusCode::FORBIDDEN, "access_denied", Some(msg)) - } + OAuthError::InvalidScope(msg) => (StatusCode::BAD_REQUEST, "invalid_scope", Some(msg)), + OAuthError::AccessDenied(msg) => (StatusCode::FORBIDDEN, "access_denied", Some(msg)), OAuthError::ServerError(msg) => { (StatusCode::INTERNAL_SERVER_ERROR, "server_error", Some(msg)) } @@ -69,15 +63,13 @@ impl IntoResponse for OAuthError { OAuthError::InvalidDpopProof(msg) => { (StatusCode::UNAUTHORIZED, "invalid_dpop_proof", Some(msg)) } - OAuthError::ExpiredToken(msg) => { - (StatusCode::UNAUTHORIZED, "invalid_token", Some(msg)) - } - OAuthError::InvalidToken(msg) => { - (StatusCode::UNAUTHORIZED, "invalid_token", Some(msg)) - } - OAuthError::RateLimited => { - (StatusCode::TOO_MANY_REQUESTS, "rate_limited", Some("Too many requests. Please try again later.".to_string())) - } + OAuthError::ExpiredToken(msg) => (StatusCode::UNAUTHORIZED, "invalid_token", Some(msg)), + OAuthError::InvalidToken(msg) => (StatusCode::UNAUTHORIZED, "invalid_token", Some(msg)), + OAuthError::RateLimited => ( + StatusCode::TOO_MANY_REQUESTS, + "rate_limited", + Some("Too many requests. Please try again later.".to_string()), + ), }; ( status, diff --git a/src/oauth/mod.rs b/src/oauth/mod.rs index 59c9358..4236131 100644 --- a/src/oauth/mod.rs +++ b/src/oauth/mod.rs @@ -1,14 +1,16 @@ -pub mod types; +pub mod client; pub mod db; pub mod dpop; -pub mod jwks; -pub mod client; pub mod endpoints; pub mod error; +pub mod jwks; pub mod templates; +pub mod types; pub mod verify; -pub use types::*; pub use error::OAuthError; -pub use verify::{verify_oauth_access_token, generate_dpop_nonce, VerifyResult, OAuthUser, OAuthAuthError}; pub use templates::{DeviceAccount, mask_email}; +pub use types::*; +pub use verify::{ + OAuthAuthError, OAuthUser, VerifyResult, generate_dpop_nonce, verify_oauth_access_token, +}; diff --git a/src/oauth/templates.rs b/src/oauth/templates.rs index ba10d5c..b82e1e2 100644 --- a/src/oauth/templates.rs +++ b/src/oauth/templates.rs @@ -487,18 +487,23 @@ pub fn account_selector_page( ) } -pub fn two_factor_page( - request_uri: &str, - channel: &str, - error_message: Option<&str>, -) -> String { +pub fn two_factor_page(request_uri: &str, channel: &str, error_message: Option<&str>) -> String { let error_html = error_message .map(|msg| format!(r#"
{}
"#, html_escape(msg))) .unwrap_or_default(); let (title, subtitle) = match channel { - "email" => ("Check your email", "We sent a verification code to your email"), - "Discord" => ("Check Discord", "We sent a verification code to your Discord"), - "Telegram" => ("Check Telegram", "We sent a verification code to your Telegram"), + "email" => ( + "Check your email", + "We sent a verification code to your email", + ), + "Discord" => ( + "Check Discord", + "We sent a verification code to your Discord", + ), + "Telegram" => ( + "Check Telegram", + "We sent a verification code to your Telegram", + ), "Signal" => ("Check Signal", "We sent a verification code to your Signal"), _ => ("Check your messages", "We sent you a verification code"), }; @@ -546,7 +551,8 @@ pub fn two_factor_page( } pub fn error_page(error: &str, error_description: Option<&str>) -> String { - let description = error_description.unwrap_or("An error occurred during the authorization process."); + let description = + error_description.unwrap_or("An error occurred during the authorization process."); format!( r#" @@ -618,7 +624,12 @@ fn get_initials(handle: &str) -> String { if clean.is_empty() { return "?".to_string(); } - clean.chars().next().unwrap_or('?').to_uppercase().to_string() + clean + .chars() + .next() + .unwrap_or('?') + .to_uppercase() + .to_string() } pub fn mask_email(email: &str) -> String { diff --git a/src/oauth/types.rs b/src/oauth/types.rs index e733b60..608f283 100644 --- a/src/oauth/types.rs +++ b/src/oauth/types.rs @@ -22,7 +22,10 @@ pub struct RefreshToken(pub String); impl RequestId { pub fn generate() -> Self { - Self(format!("urn:ietf:params:oauth:request_uri:{}", uuid::Uuid::new_v4())) + Self(format!( + "urn:ietf:params:oauth:request_uri:{}", + uuid::Uuid::new_v4() + )) } } diff --git a/src/oauth/verify.rs b/src/oauth/verify.rs index 600d540..ca7333c 100644 --- a/src/oauth/verify.rs +++ b/src/oauth/verify.rs @@ -1,8 +1,8 @@ use axum::{ + Json, extract::FromRequestParts, http::{StatusCode, request::Parts}, response::{IntoResponse, Response}, - Json, }; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use hmac::{Hmac, Mac}; @@ -11,11 +11,11 @@ use sha2::Sha256; use sqlx::PgPool; use subtle::ConstantTimeEq; -use crate::config::AuthConfig; -use crate::state::AppState; +use super::OAuthError; use super::db; use super::dpop::DPoPVerifier; -use super::OAuthError; +use crate::config::AuthConfig; +use crate::state::AppState; pub struct OAuthTokenInfo { pub did: String, @@ -48,13 +48,13 @@ pub async fn verify_oauth_access_token( return Err(OAuthError::InvalidToken("Token has expired".to_string())); } if let Some(expected_jkt) = &token_data.parameters.dpop_jkt { - let proof = dpop_proof.ok_or_else(|| { - OAuthError::UseDpopNonce("DPoP proof required".to_string()) - })?; + let proof = dpop_proof + .ok_or_else(|| OAuthError::UseDpopNonce("DPoP proof required".to_string()))?; let config = AuthConfig::get(); let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); let access_token_hash = compute_ath(access_token); - let result = verifier.verify_proof(proof, http_method, http_uri, Some(&access_token_hash))?; + let result = + verifier.verify_proof(proof, http_method, http_uri, Some(&access_token_hash))?; if !db::check_and_record_dpop_jti(pool, &result.jti).await? { return Err(OAuthError::InvalidDpopProof( "DPoP proof has already been used".to_string(), @@ -85,10 +85,14 @@ pub fn extract_oauth_token_info(token: &str) -> Result Result Result String { let mut hasher = Sha256::new(); hasher.update(access_token.as_bytes()); let hash = hasher.finalize(); - URL_SAFE_NO_PAD.encode(&hash) + URL_SAFE_NO_PAD.encode(hash) } pub fn generate_dpop_nonce() -> String { @@ -186,10 +195,9 @@ impl IntoResponse for OAuthAuthError { ) .into_response(); if let Some(nonce) = self.dpop_nonce { - response.headers_mut().insert( - "DPoP-Nonce", - nonce.parse().unwrap(), - ); + response + .headers_mut() + .insert("DPoP-Nonce", nonce.parse().unwrap()); } response } @@ -198,7 +206,10 @@ impl IntoResponse for OAuthAuthError { impl FromRequestParts for OAuthUser { type Rejection = OAuthAuthError; - async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { let auth_header = parts .headers .get("Authorization") @@ -210,9 +221,13 @@ impl FromRequestParts for OAuthUser { dpop_nonce: None, })?; let auth_header_trimmed = auth_header.trim(); - let (token, is_dpop_token) = if auth_header_trimmed.len() >= 7 && auth_header_trimmed[..7].eq_ignore_ascii_case("bearer ") { + let (token, is_dpop_token) = if auth_header_trimmed.len() >= 7 + && auth_header_trimmed[..7].eq_ignore_ascii_case("bearer ") + { (auth_header_trimmed[7..].trim(), false) - } else if auth_header_trimmed.len() >= 5 && auth_header_trimmed[..5].eq_ignore_ascii_case("dpop ") { + } else if auth_header_trimmed.len() >= 5 + && auth_header_trimmed[..5].eq_ignore_ascii_case("dpop ") + { (auth_header_trimmed[5..].trim(), true) } else { return Err(OAuthAuthError { @@ -222,10 +237,7 @@ impl FromRequestParts for OAuthUser { dpop_nonce: None, }); }; - let dpop_proof = parts - .headers - .get("DPoP") - .and_then(|v| v.to_str().ok()); + let dpop_proof = parts.headers.get("DPoP").and_then(|v| v.to_str().ok()); if let Ok(result) = try_legacy_auth(&state.db, token).await { return Ok(OAuthUser { did: result.did, @@ -236,7 +248,8 @@ impl FromRequestParts for OAuthUser { } let http_method = parts.method.as_str(); let http_uri = parts.uri.to_string(); - match verify_oauth_access_token(&state.db, token, dpop_proof, http_method, &http_uri).await { + match verify_oauth_access_token(&state.db, token, dpop_proof, http_method, &http_uri).await + { Ok(result) => Ok(OAuthUser { did: result.did, client_id: Some(result.client_id), @@ -259,7 +272,11 @@ impl FromRequestParts for OAuthUser { }) } Err(e) => { - let nonce = if is_dpop_token { Some(generate_dpop_nonce()) } else { None }; + let nonce = if is_dpop_token { + Some(generate_dpop_nonce()) + } else { + None + }; Err(OAuthAuthError { status: StatusCode::UNAUTHORIZED, error: "AuthenticationFailed".to_string(), diff --git a/src/plc/mod.rs b/src/plc/mod.rs index 2f39fde..9e8e863 100644 --- a/src/plc/mod.rs +++ b/src/plc/mod.rs @@ -1,9 +1,9 @@ use base32::Alphabet; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -use k256::ecdsa::{SigningKey, Signature, signature::Signer}; +use k256::ecdsa::{Signature, SigningKey, signature::Signer}; use reqwest::Client; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::time::Duration; @@ -102,10 +102,7 @@ impl PlcClient { .pool_max_idle_per_host(5) .build() .unwrap_or_else(|_| Client::new()); - Self { - base_url, - client, - } + Self { base_url, client } } fn encode_did(did: &str) -> String { @@ -126,7 +123,10 @@ impl PlcClient { status, body ))); } - response.json().await.map_err(|e| PlcError::InvalidResponse(e.to_string())) + response + .json() + .await + .map_err(|e| PlcError::InvalidResponse(e.to_string())) } pub async fn get_document_data(&self, did: &str) -> Result { @@ -143,7 +143,10 @@ impl PlcClient { status, body ))); } - response.json().await.map_err(|e| PlcError::InvalidResponse(e.to_string())) + response + .json() + .await + .map_err(|e| PlcError::InvalidResponse(e.to_string())) } pub async fn get_last_op(&self, did: &str) -> Result { @@ -160,7 +163,10 @@ impl PlcClient { status, body ))); } - response.json().await.map_err(|e| PlcError::InvalidResponse(e.to_string())) + response + .json() + .await + .map_err(|e| PlcError::InvalidResponse(e.to_string())) } pub async fn get_audit_log(&self, did: &str) -> Result, PlcError> { @@ -177,16 +183,15 @@ impl PlcClient { status, body ))); } - response.json().await.map_err(|e| PlcError::InvalidResponse(e.to_string())) + response + .json() + .await + .map_err(|e| PlcError::InvalidResponse(e.to_string())) } pub async fn send_operation(&self, did: &str, operation: &Value) -> Result<(), PlcError> { let url = format!("{}/{}", self.base_url, Self::encode_did(did)); - let response = self.client - .post(&url) - .json(operation) - .send() - .await?; + let response = self.client.post(&url).json(operation).send().await?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); @@ -200,8 +205,8 @@ impl PlcClient { } pub fn cid_for_cbor(value: &Value) -> Result { - let cbor_bytes = serde_ipld_dagcbor::to_vec(value) - .map_err(|e| PlcError::Serialization(e.to_string()))?; + let cbor_bytes = + serde_ipld_dagcbor::to_vec(value).map_err(|e| PlcError::Serialization(e.to_string()))?; let mut hasher = Sha256::new(); hasher.update(&cbor_bytes); let hash = hasher.finalize(); @@ -211,16 +216,13 @@ pub fn cid_for_cbor(value: &Value) -> Result { Ok(cid.to_string()) } -pub fn sign_operation( - operation: &Value, - signing_key: &SigningKey, -) -> Result { +pub fn sign_operation(operation: &Value, signing_key: &SigningKey) -> Result { let mut op = operation.clone(); if let Some(obj) = op.as_object_mut() { obj.remove("sig"); } - let cbor_bytes = serde_ipld_dagcbor::to_vec(&op) - .map_err(|e| PlcError::Serialization(e.to_string()))?; + let cbor_bytes = + serde_ipld_dagcbor::to_vec(&op).map_err(|e| PlcError::Serialization(e.to_string()))?; let signature: Signature = signing_key.sign(&cbor_bytes); let sig_bytes = signature.to_bytes(); let sig_b64 = URL_SAFE_NO_PAD.encode(sig_bytes); @@ -238,10 +240,12 @@ pub fn create_update_op( services: Option>, ) -> Result { let prev_value = match last_op { - PlcOpOrTombstone::Operation(op) => serde_json::to_value(op) - .map_err(|e| PlcError::Serialization(e.to_string()))?, - PlcOpOrTombstone::Tombstone(t) => serde_json::to_value(t) - .map_err(|e| PlcError::Serialization(e.to_string()))?, + PlcOpOrTombstone::Operation(op) => { + serde_json::to_value(op).map_err(|e| PlcError::Serialization(e.to_string()))? + } + PlcOpOrTombstone::Tombstone(t) => { + serde_json::to_value(t).map_err(|e| PlcError::Serialization(e.to_string()))? + } }; let prev_cid = cid_for_cbor(&prev_value)?; let (base_rotation_keys, base_verification_methods, base_also_known_as, base_services) = @@ -309,8 +313,8 @@ pub fn create_genesis_operation( prev: None, sig: None, }; - let genesis_value = serde_json::to_value(&genesis_op) - .map_err(|e| PlcError::Serialization(e.to_string()))?; + let genesis_value = + serde_json::to_value(&genesis_op).map_err(|e| PlcError::Serialization(e.to_string()))?; let signed_op = sign_operation(&genesis_value, signing_key)?; let did = did_for_genesis_op(&signed_op)?; Ok(GenesisResult { @@ -331,20 +335,29 @@ pub fn did_for_genesis_op(signed_op: &Value) -> Result { } pub fn validate_plc_operation(op: &Value) -> Result<(), PlcError> { - let obj = op.as_object() + let obj = op + .as_object() .ok_or_else(|| PlcError::InvalidResponse("Operation must be an object".to_string()))?; - let op_type = obj.get("type") + let op_type = obj + .get("type") .and_then(|v| v.as_str()) .ok_or_else(|| PlcError::InvalidResponse("Missing type field".to_string()))?; if op_type != "plc_operation" && op_type != "plc_tombstone" { - return Err(PlcError::InvalidResponse(format!("Invalid type: {}", op_type))); + return Err(PlcError::InvalidResponse(format!( + "Invalid type: {}", + op_type + ))); } if op_type == "plc_operation" { if obj.get("rotationKeys").is_none() { - return Err(PlcError::InvalidResponse("Missing rotationKeys".to_string())); + return Err(PlcError::InvalidResponse( + "Missing rotationKeys".to_string(), + )); } if obj.get("verificationMethods").is_none() { - return Err(PlcError::InvalidResponse("Missing verificationMethods".to_string())); + return Err(PlcError::InvalidResponse( + "Missing verificationMethods".to_string(), + )); } if obj.get("alsoKnownAs").is_none() { return Err(PlcError::InvalidResponse("Missing alsoKnownAs".to_string())); @@ -371,35 +384,37 @@ pub fn validate_plc_operation_for_submission( ctx: &PlcValidationContext, ) -> Result<(), PlcError> { validate_plc_operation(op)?; - let obj = op.as_object() + let obj = op + .as_object() .ok_or_else(|| PlcError::InvalidResponse("Operation must be an object".to_string()))?; - let op_type = obj.get("type") - .and_then(|v| v.as_str()) - .unwrap_or(""); + let op_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or(""); if op_type != "plc_operation" { return Ok(()); } - let rotation_keys = obj.get("rotationKeys") + let rotation_keys = obj + .get("rotationKeys") .and_then(|v| v.as_array()) .ok_or_else(|| PlcError::InvalidResponse("rotationKeys must be an array".to_string()))?; - let rotation_key_strings: Vec<&str> = rotation_keys - .iter() - .filter_map(|v| v.as_str()) - .collect(); + let rotation_key_strings: Vec<&str> = rotation_keys.iter().filter_map(|v| v.as_str()).collect(); if !rotation_key_strings.contains(&ctx.server_rotation_key.as_str()) { return Err(PlcError::InvalidResponse( - "Rotation keys do not include server's rotation key".to_string() + "Rotation keys do not include server's rotation key".to_string(), )); } - let verification_methods = obj.get("verificationMethods") + let verification_methods = obj + .get("verificationMethods") .and_then(|v| v.as_object()) - .ok_or_else(|| PlcError::InvalidResponse("verificationMethods must be an object".to_string()))?; - if let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str()) { - if atproto_key != ctx.expected_signing_key { - return Err(PlcError::InvalidResponse("Incorrect signing key".to_string())); + .ok_or_else(|| { + PlcError::InvalidResponse("verificationMethods must be an object".to_string()) + })?; + if let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str()) + && atproto_key != ctx.expected_signing_key { + return Err(PlcError::InvalidResponse( + "Incorrect signing key".to_string(), + )); } - } - let also_known_as = obj.get("alsoKnownAs") + let also_known_as = obj + .get("alsoKnownAs") .and_then(|v| v.as_array()) .ok_or_else(|| PlcError::InvalidResponse("alsoKnownAs must be an array".to_string()))?; let expected_handle_uri = format!("at://{}", ctx.expected_handle); @@ -409,36 +424,42 @@ pub fn validate_plc_operation_for_submission( .any(|s| s == expected_handle_uri); if !has_correct_handle && !also_known_as.is_empty() { return Err(PlcError::InvalidResponse( - "Incorrect handle in alsoKnownAs".to_string() + "Incorrect handle in alsoKnownAs".to_string(), )); } - let services = obj.get("services") + let services = obj + .get("services") .and_then(|v| v.as_object()) .ok_or_else(|| PlcError::InvalidResponse("services must be an object".to_string()))?; if let Some(pds_service) = services.get("atproto_pds").and_then(|v| v.as_object()) { - let service_type = pds_service.get("type").and_then(|v| v.as_str()).unwrap_or(""); + let service_type = pds_service + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or(""); if service_type != "AtprotoPersonalDataServer" { return Err(PlcError::InvalidResponse( - "Incorrect type on atproto_pds service".to_string() + "Incorrect type on atproto_pds service".to_string(), )); } - let endpoint = pds_service.get("endpoint").and_then(|v| v.as_str()).unwrap_or(""); + let endpoint = pds_service + .get("endpoint") + .and_then(|v| v.as_str()) + .unwrap_or(""); if endpoint != ctx.expected_pds_endpoint { return Err(PlcError::InvalidResponse( - "Incorrect endpoint on atproto_pds service".to_string() + "Incorrect endpoint on atproto_pds service".to_string(), )); } } Ok(()) } -pub fn verify_operation_signature( - op: &Value, - rotation_keys: &[String], -) -> Result { - let obj = op.as_object() +pub fn verify_operation_signature(op: &Value, rotation_keys: &[String]) -> Result { + let obj = op + .as_object() .ok_or_else(|| PlcError::InvalidResponse("Operation must be an object".to_string()))?; - let sig_b64 = obj.get("sig") + let sig_b64 = obj + .get("sig") .and_then(|v| v.as_str()) .ok_or_else(|| PlcError::InvalidResponse("Missing sig".to_string()))?; let sig_bytes = URL_SAFE_NO_PAD @@ -467,21 +488,29 @@ fn verify_signature_with_did_key( ) -> Result { use k256::ecdsa::{VerifyingKey, signature::Verifier}; if !did_key.starts_with("did:key:z") { - return Err(PlcError::InvalidResponse("Invalid did:key format".to_string())); + return Err(PlcError::InvalidResponse( + "Invalid did:key format".to_string(), + )); } let multibase_part = &did_key[8..]; let (_, decoded) = multibase::decode(multibase_part) .map_err(|e| PlcError::InvalidResponse(format!("Failed to decode did:key: {}", e)))?; if decoded.len() < 2 { - return Err(PlcError::InvalidResponse("Invalid did:key data".to_string())); + return Err(PlcError::InvalidResponse( + "Invalid did:key data".to_string(), + )); } let (codec, key_bytes) = if decoded[0] == 0xe7 && decoded[1] == 0x01 { (0xe701u16, &decoded[2..]) } else { - return Err(PlcError::InvalidResponse("Unsupported key type in did:key".to_string())); + return Err(PlcError::InvalidResponse( + "Unsupported key type in did:key".to_string(), + )); }; if codec != 0xe701 { - return Err(PlcError::InvalidResponse("Only secp256k1 keys are supported".to_string())); + return Err(PlcError::InvalidResponse( + "Only secp256k1 keys are supported".to_string(), + )); } let verifying_key = VerifyingKey::from_sec1_bytes(key_bytes) .map_err(|e| PlcError::InvalidResponse(format!("Invalid public key: {}", e)))?; diff --git a/src/rate_limit.rs b/src/rate_limit.rs index 3096c80..c55d6fe 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -1,21 +1,17 @@ use axum::{ + Json, body::Body, extract::ConnectInfo, http::{HeaderMap, Request, StatusCode}, middleware::Next, response::{IntoResponse, Response}, - Json, }; use governor::{ Quota, RateLimiter, clock::DefaultClock, state::{InMemoryState, NotKeyed, keyed::DefaultKeyedStateStore}, }; -use std::{ - net::SocketAddr, - num::NonZeroU32, - sync::Arc, -}; +use std::{net::SocketAddr, num::NonZeroU32, sync::Arc}; pub type KeyedRateLimiter = RateLimiter, DefaultClock>; pub type GlobalRateLimiter = RateLimiter; @@ -44,101 +40,99 @@ impl Default for RateLimiters { impl RateLimiters { pub fn new() -> Self { Self { - login: Arc::new(RateLimiter::keyed( - Quota::per_minute(NonZeroU32::new(10).unwrap()) - )), - oauth_token: Arc::new(RateLimiter::keyed( - Quota::per_minute(NonZeroU32::new(30).unwrap()) - )), - oauth_authorize: Arc::new(RateLimiter::keyed( - Quota::per_minute(NonZeroU32::new(10).unwrap()) - )), - password_reset: Arc::new(RateLimiter::keyed( - Quota::per_hour(NonZeroU32::new(5).unwrap()) - )), - account_creation: Arc::new(RateLimiter::keyed( - Quota::per_hour(NonZeroU32::new(10).unwrap()) - )), - refresh_session: Arc::new(RateLimiter::keyed( - Quota::per_minute(NonZeroU32::new(60).unwrap()) - )), - reset_password: Arc::new(RateLimiter::keyed( - Quota::per_minute(NonZeroU32::new(10).unwrap()) - )), - oauth_par: Arc::new(RateLimiter::keyed( - Quota::per_minute(NonZeroU32::new(30).unwrap()) - )), - oauth_introspect: Arc::new(RateLimiter::keyed( - Quota::per_minute(NonZeroU32::new(30).unwrap()) - )), - app_password: Arc::new(RateLimiter::keyed( - Quota::per_minute(NonZeroU32::new(10).unwrap()) - )), - email_update: Arc::new(RateLimiter::keyed( - Quota::per_hour(NonZeroU32::new(5).unwrap()) - )), + login: Arc::new(RateLimiter::keyed(Quota::per_minute( + NonZeroU32::new(10).unwrap(), + ))), + oauth_token: Arc::new(RateLimiter::keyed(Quota::per_minute( + NonZeroU32::new(30).unwrap(), + ))), + oauth_authorize: Arc::new(RateLimiter::keyed(Quota::per_minute( + NonZeroU32::new(10).unwrap(), + ))), + password_reset: Arc::new(RateLimiter::keyed(Quota::per_hour( + NonZeroU32::new(5).unwrap(), + ))), + account_creation: Arc::new(RateLimiter::keyed(Quota::per_hour( + NonZeroU32::new(10).unwrap(), + ))), + refresh_session: Arc::new(RateLimiter::keyed(Quota::per_minute( + NonZeroU32::new(60).unwrap(), + ))), + reset_password: Arc::new(RateLimiter::keyed(Quota::per_minute( + NonZeroU32::new(10).unwrap(), + ))), + oauth_par: Arc::new(RateLimiter::keyed(Quota::per_minute( + NonZeroU32::new(30).unwrap(), + ))), + oauth_introspect: Arc::new(RateLimiter::keyed(Quota::per_minute( + NonZeroU32::new(30).unwrap(), + ))), + app_password: Arc::new(RateLimiter::keyed(Quota::per_minute( + NonZeroU32::new(10).unwrap(), + ))), + email_update: Arc::new(RateLimiter::keyed(Quota::per_hour( + NonZeroU32::new(5).unwrap(), + ))), } } pub fn with_login_limit(mut self, per_minute: u32) -> Self { - self.login = Arc::new(RateLimiter::keyed( - Quota::per_minute(NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(10).unwrap())) - )); + self.login = Arc::new(RateLimiter::keyed(Quota::per_minute( + NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(10).unwrap()), + ))); self } pub fn with_oauth_token_limit(mut self, per_minute: u32) -> Self { - self.oauth_token = Arc::new(RateLimiter::keyed( - Quota::per_minute(NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(30).unwrap())) - )); + self.oauth_token = Arc::new(RateLimiter::keyed(Quota::per_minute( + NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(30).unwrap()), + ))); self } pub fn with_oauth_authorize_limit(mut self, per_minute: u32) -> Self { - self.oauth_authorize = Arc::new(RateLimiter::keyed( - Quota::per_minute(NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(10).unwrap())) - )); + self.oauth_authorize = Arc::new(RateLimiter::keyed(Quota::per_minute( + NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(10).unwrap()), + ))); self } pub fn with_password_reset_limit(mut self, per_hour: u32) -> Self { - self.password_reset = Arc::new(RateLimiter::keyed( - Quota::per_hour(NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(5).unwrap())) - )); + self.password_reset = Arc::new(RateLimiter::keyed(Quota::per_hour( + NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(5).unwrap()), + ))); self } pub fn with_account_creation_limit(mut self, per_hour: u32) -> Self { - self.account_creation = Arc::new(RateLimiter::keyed( - Quota::per_hour(NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(10).unwrap())) - )); + self.account_creation = Arc::new(RateLimiter::keyed(Quota::per_hour( + NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(10).unwrap()), + ))); self } pub fn with_email_update_limit(mut self, per_hour: u32) -> Self { - self.email_update = Arc::new(RateLimiter::keyed( - Quota::per_hour(NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(5).unwrap())) - )); + self.email_update = Arc::new(RateLimiter::keyed(Quota::per_hour( + NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(5).unwrap()), + ))); self } } pub fn extract_client_ip(headers: &HeaderMap, addr: Option) -> String { - if let Some(forwarded) = headers.get("x-forwarded-for") { - if let Ok(value) = forwarded.to_str() { - if let Some(first_ip) = value.split(',').next() { + if let Some(forwarded) = headers.get("x-forwarded-for") + && let Ok(value) = forwarded.to_str() + && let Some(first_ip) = value.split(',').next() { return first_ip.trim().to_string(); } - } - } - if let Some(real_ip) = headers.get("x-real-ip") { - if let Ok(value) = real_ip.to_str() { + if let Some(real_ip) = headers.get("x-real-ip") + && let Ok(value) = real_ip.to_str() { return value.trim().to_string(); } - } - addr.map(|a| a.ip().to_string()).unwrap_or_else(|| "unknown".to_string()) + addr.map(|a| a.ip().to_string()) + .unwrap_or_else(|| "unknown".to_string()) } fn rate_limit_response() -> Response { diff --git a/src/repo/mod.rs b/src/repo/mod.rs index f2959ee..ec0cea4 100644 --- a/src/repo/mod.rs +++ b/src/repo/mod.rs @@ -27,7 +27,7 @@ impl BlockStore for PostgresBlockStore { let row = sqlx::query!("SELECT data FROM blocks WHERE cid = $1", &cid_bytes) .fetch_optional(&self.pool) .await - .map_err(|e| RepoError::storage(e))?; + .map_err(RepoError::storage)?; match row { Some(row) => Ok(Some(Bytes::from(row.data))), None => Ok(None), @@ -39,14 +39,22 @@ impl BlockStore for PostgresBlockStore { let mut hasher = Sha256::new(); hasher.update(data); let hash = hasher.finalize(); - let multihash = Multihash::wrap(0x12, &hash) - .map_err(|e| RepoError::storage(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("Failed to wrap multihash: {:?}", e))))?; + let multihash = Multihash::wrap(0x12, &hash).map_err(|e| { + RepoError::storage(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Failed to wrap multihash: {:?}", e), + )) + })?; let cid = Cid::new_v1(0x71, multihash); let cid_bytes = cid.to_bytes(); - sqlx::query!("INSERT INTO blocks (cid, data) VALUES ($1, $2) ON CONFLICT (cid) DO NOTHING", &cid_bytes, data) - .execute(&self.pool) - .await - .map_err(|e| RepoError::storage(e))?; + sqlx::query!( + "INSERT INTO blocks (cid, data) VALUES ($1, $2) ON CONFLICT (cid) DO NOTHING", + &cid_bytes, + data + ) + .execute(&self.pool) + .await + .map_err(RepoError::storage)?; Ok(cid) } @@ -56,7 +64,7 @@ impl BlockStore for PostgresBlockStore { let row = sqlx::query!("SELECT 1 as one FROM blocks WHERE cid = $1", &cid_bytes) .fetch_optional(&self.pool) .await - .map_err(|e| RepoError::storage(e))?; + .map_err(RepoError::storage)?; Ok(row.is_some()) } @@ -82,7 +90,7 @@ impl BlockStore for PostgresBlockStore { ) .execute(&self.pool) .await - .map_err(|e| RepoError::storage(e))?; + .map_err(RepoError::storage)?; Ok(()) } @@ -98,7 +106,7 @@ impl BlockStore for PostgresBlockStore { ) .fetch_all(&self.pool) .await - .map_err(|e| RepoError::storage(e))?; + .map_err(RepoError::storage)?; let found: std::collections::HashMap, Bytes> = rows .into_iter() .map(|row| (row.cid, Bytes::from(row.data))) diff --git a/src/repo/tracking.rs b/src/repo/tracking.rs index cd31a29..872c450 100644 --- a/src/repo/tracking.rs +++ b/src/repo/tracking.rs @@ -51,8 +51,12 @@ impl BlockStore for TrackingBlockStore { let result = self.inner.get(cid).await?; if result.is_some() { match self.read_cids.lock() { - Ok(mut guard) => { guard.insert(*cid); }, - Err(poisoned) => { poisoned.into_inner().insert(*cid); }, + Ok(mut guard) => { + guard.insert(*cid); + } + Err(poisoned) => { + poisoned.into_inner().insert(*cid); + } } } Ok(result) @@ -61,8 +65,8 @@ impl BlockStore for TrackingBlockStore { async fn put(&self, data: &[u8]) -> Result { let cid = self.inner.put(data).await?; match self.written_cids.lock() { - Ok(mut guard) => guard.push(cid.clone()), - Err(poisoned) => poisoned.into_inner().push(cid.clone()), + Ok(mut guard) => guard.push(cid), + Err(poisoned) => poisoned.into_inner().push(cid), } Ok(cid) } @@ -76,7 +80,7 @@ impl BlockStore for TrackingBlockStore { blocks: impl IntoIterator + Send, ) -> Result<(), RepoError> { let blocks: Vec<_> = blocks.into_iter().collect(); - let cids: Vec = blocks.iter().map(|(cid, _)| cid.clone()).collect(); + let cids: Vec = blocks.iter().map(|(cid, _)| *cid).collect(); self.inner.put_many(blocks).await?; match self.written_cids.lock() { Ok(mut guard) => guard.extend(cids), @@ -90,8 +94,12 @@ impl BlockStore for TrackingBlockStore { for (cid, result) in cids.iter().zip(results.iter()) { if result.is_some() { match self.read_cids.lock() { - Ok(mut guard) => { guard.insert(*cid); }, - Err(poisoned) => { poisoned.into_inner().insert(*cid); }, + Ok(mut guard) => { + guard.insert(*cid); + } + Err(poisoned) => { + poisoned.into_inner().insert(*cid); + } } } } diff --git a/src/state.rs b/src/state.rs index dfc2703..27d3a1d 100644 --- a/src/state.rs +++ b/src/state.rs @@ -117,7 +117,11 @@ impl AppState { let limiter_name = kind.key_prefix(); let (limit, window_ms) = kind.limit_and_window_ms(); - if !self.distributed_rate_limiter.check_rate_limit(&key, limit, window_ms).await { + if !self + .distributed_rate_limiter + .check_rate_limit(&key, limit, window_ms) + .await + { crate::metrics::record_rate_limit_rejection(limiter_name); return false; } diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 233bfc9..3abc4cc 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -62,7 +62,8 @@ impl BlobStorage for S3BlobStorage { } async fn put_bytes(&self, key: &str, data: Bytes) -> Result<(), StorageError> { - let result = self.client + let result = self + .client .put_object() .bucket(&self.bucket) .key(key) @@ -112,7 +113,8 @@ impl BlobStorage for S3BlobStorage { } async fn delete(&self, key: &str) -> Result<(), StorageError> { - let result = self.client + let result = self + .client .delete_object() .bucket(&self.bucket) .key(key) diff --git a/src/sync/blob.rs b/src/sync/blob.rs index 9ef2d07..e93c001 100644 --- a/src/sync/blob.rs +++ b/src/sync/blob.rs @@ -58,14 +58,17 @@ pub async fn get_blob( } Ok(Some(_)) => {} } - let blob_result = sqlx::query!("SELECT storage_key, mime_type FROM blobs WHERE cid = $1", cid) - .fetch_optional(&state.db) - .await; + let blob_result = sqlx::query!( + "SELECT storage_key, mime_type FROM blobs WHERE cid = $1", + cid + ) + .fetch_optional(&state.db) + .await; match blob_result { Ok(Some(row)) => { let storage_key = &row.storage_key; let mime_type = &row.mime_type; - match state.blob_store.get(&storage_key).await { + match state.blob_store.get(storage_key).await { Ok(data) => Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, mime_type) @@ -184,15 +187,8 @@ pub async fn list_blobs( match cids_result { Ok(cids) => { let has_more = cids.len() as i64 > limit; - let cids: Vec = cids - .into_iter() - .take(limit as usize) - .collect(); - let next_cursor = if has_more { - cids.last().cloned() - } else { - None - }; + let cids: Vec = cids.into_iter().take(limit as usize).collect(); + let next_cursor = if has_more { cids.last().cloned() } else { None }; ( StatusCode::OK, Json(ListBlobsOutput { diff --git a/src/sync/car.rs b/src/sync/car.rs index a077073..7954e1e 100644 --- a/src/sync/car.rs +++ b/src/sync/car.rs @@ -24,8 +24,10 @@ pub fn ld_write(mut writer: W, data: &[u8]) -> std::io::Result<()> { } pub fn encode_car_header(root_cid: &Cid) -> Result, String> { - let header = CarHeader::new_v1(vec![root_cid.clone()]); - let header_cbor = header.encode().map_err(|e| format!("Failed to encode CAR header: {:?}", e))?; + let header = CarHeader::new_v1(vec![*root_cid]); + let header_cbor = header + .encode() + .map_err(|e| format!("Failed to encode CAR header: {:?}", e))?; let mut result = Vec::new(); write_varint(&mut result, header_cbor.len() as u64) .expect("Writing to Vec should never fail"); diff --git a/src/sync/commit.rs b/src/sync/commit.rs index b5e53af..fea8cb4 100644 --- a/src/sync/commit.rs +++ b/src/sync/commit.rs @@ -56,7 +56,8 @@ pub async fn get_latest_commit( .await; match result { Ok(Some(row)) => { - let rev = get_rev_from_commit(&state, &row.repo_root_cid).await + let rev = get_rev_from_commit(&state, &row.repo_root_cid) + .await .unwrap_or_else(|| chrono::Utc::now().timestamp_millis().to_string()); ( StatusCode::OK, @@ -129,7 +130,8 @@ pub async fn list_repos( let has_more = rows.len() as i64 > limit; let mut repos: Vec = Vec::new(); for row in rows.iter().take(limit as usize) { - let rev = get_rev_from_commit(&state, &row.repo_root_cid).await + let rev = get_rev_from_commit(&state, &row.repo_root_cid) + .await .unwrap_or_else(|| chrono::Utc::now().timestamp_millis().to_string()); repos.push(RepoInfo { did: row.did.clone(), diff --git a/src/sync/deprecated.rs b/src/sync/deprecated.rs index 9dc7c72..0439d29 100644 --- a/src/sync/deprecated.rs +++ b/src/sync/deprecated.rs @@ -51,7 +51,13 @@ pub async fn get_head( .fetch_optional(&state.db) .await; match result { - Ok(Some(row)) => (StatusCode::OK, Json(GetHeadOutput { root: row.repo_root_cid })).into_response(), + Ok(Some(row)) => ( + StatusCode::OK, + Json(GetHeadOutput { + root: row.repo_root_cid, + }), + ) + .into_response(), Ok(None) => ( StatusCode::BAD_REQUEST, Json(json!({"error": "HeadNotFound", "message": "Could not find root for DID"})), @@ -157,9 +163,11 @@ pub async fn get_checkout( let mut writer = Vec::new(); crate::sync::car::write_varint(&mut writer, total_len as u64) .expect("Writing to Vec should never fail"); - writer.write_all(&cid_bytes) + writer + .write_all(&cid_bytes) .expect("Writing to Vec should never fail"); - writer.write_all(&block) + writer + .write_all(&block) .expect("Writing to Vec should never fail"); car_bytes.extend_from_slice(&writer); if let Ok(value) = serde_ipld_dagcbor::from_slice::(&block) { diff --git a/src/sync/firehose.rs b/src/sync/firehose.rs index ea3705b..f5a147f 100644 --- a/src/sync/firehose.rs +++ b/src/sync/firehose.rs @@ -1,6 +1,6 @@ +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use chrono::{DateTime, Utc}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SequencedEvent { diff --git a/src/sync/frame.rs b/src/sync/frame.rs index 05e2c99..8a613cd 100644 --- a/src/sync/frame.rs +++ b/src/sync/frame.rs @@ -1,7 +1,7 @@ +use crate::sync::firehose::SequencedEvent; use cid::Cid; use serde::{Deserialize, Serialize}; use std::str::FromStr; -use crate::sync::firehose::SequencedEvent; #[derive(Debug, Serialize, Deserialize)] pub struct FrameHeader { @@ -86,19 +86,21 @@ pub struct CommitFrameBuilder { impl CommitFrameBuilder { pub fn build(self) -> Result { - let commit_cid = Cid::from_str(&self.commit_cid_str) - .map_err(|_| "Invalid commit CID")?; - let json_ops: Vec = serde_json::from_value(self.ops_json) - .unwrap_or_else(|_| vec![]); - let ops: Vec = json_ops.into_iter().map(|op| { - RepoOp { + let commit_cid = Cid::from_str(&self.commit_cid_str).map_err(|_| "Invalid commit CID")?; + let json_ops: Vec = + serde_json::from_value(self.ops_json).unwrap_or_else(|_| vec![]); + let ops: Vec = json_ops + .into_iter() + .map(|op| RepoOp { action: op.action, path: op.path, cid: op.cid.and_then(|s| Cid::from_str(&s).ok()), prev: op.prev.and_then(|s| Cid::from_str(&s).ok()), - } - }).collect(); - let blobs: Vec = self.blobs.iter() + }) + .collect(); + let blobs: Vec = self + .blobs + .iter() .filter_map(|s| Cid::from_str(s).ok()) .collect(); let rev = placeholder_rev(); diff --git a/src/sync/import.rs b/src/sync/import.rs index d53e56e..f26f86e 100644 --- a/src/sync/import.rs +++ b/src/sync/import.rs @@ -75,19 +75,21 @@ pub fn find_blob_refs_ipld(value: &Ipld, depth: usize) -> Vec { .flat_map(|v| find_blob_refs_ipld(v, depth + 1)) .collect(), Ipld::Map(obj) => { - if let Some(Ipld::String(type_str)) = obj.get("$type") { - if type_str == "blob" { - if let Some(Ipld::Link(link_cid)) = obj.get("ref") { - let mime = obj - .get("mimeType") - .and_then(|v| if let Ipld::String(s) = v { Some(s.clone()) } else { None }); + if let Some(Ipld::String(type_str)) = obj.get("$type") + && type_str == "blob" + && let Some(Ipld::Link(link_cid)) = obj.get("ref") { + let mime = obj.get("mimeType").and_then(|v| { + if let Ipld::String(s) = v { + Some(s.clone()) + } else { + None + } + }); return vec![BlobRef { cid: link_cid.to_string(), mime_type: mime, }]; } - } - } obj.values() .flat_map(|v| find_blob_refs_ipld(v, depth + 1)) .collect() @@ -106,10 +108,10 @@ pub fn find_blob_refs(value: &JsonValue, depth: usize) -> Vec { .flat_map(|v| find_blob_refs(v, depth + 1)) .collect(), JsonValue::Object(obj) => { - if let Some(JsonValue::String(type_str)) = obj.get("$type") { - if type_str == "blob" { - if let Some(JsonValue::Object(ref_obj)) = obj.get("ref") { - if let Some(JsonValue::String(link)) = ref_obj.get("$link") { + if let Some(JsonValue::String(type_str)) = obj.get("$type") + && type_str == "blob" + && let Some(JsonValue::Object(ref_obj)) = obj.get("ref") + && let Some(JsonValue::String(link)) = ref_obj.get("$link") { let mime = obj .get("mimeType") .and_then(|v| v.as_str()) @@ -119,9 +121,6 @@ pub fn find_blob_refs(value: &JsonValue, depth: usize) -> Vec { mime_type: mime, }]; } - } - } - } obj.values() .flat_map(|v| find_blob_refs(v, depth + 1)) .collect() @@ -194,9 +193,9 @@ pub fn walk_mst( None } }); - if let (Some(key), Some(record_cid)) = (key, record_cid) { - if let Some(record_block) = blocks.get(&record_cid) { - if let Ok(record_value) = + if let (Some(key), Some(record_cid)) = (key, record_cid) + && let Some(record_block) = blocks.get(&record_cid) + && let Ok(record_value) = serde_ipld_dagcbor::from_slice::(record_block) { let blob_refs = find_blob_refs_ipld(&record_value, 0); @@ -212,8 +211,6 @@ pub fn walk_mst( }); } } - } - } if let Some(Ipld::Link(tree_cid)) = entry_obj.get("t") { stack.push(*tree_cid); } @@ -236,11 +233,21 @@ pub struct CommitInfo { fn extract_commit_info(commit: &Ipld) -> Result<(Cid, CommitInfo), ImportError> { let obj = match commit { Ipld::Map(m) => m, - _ => return Err(ImportError::InvalidCommit("Commit must be a map".to_string())), + _ => { + return Err(ImportError::InvalidCommit( + "Commit must be a map".to_string(), + )); + } }; let data_cid = obj .get("data") - .and_then(|d| if let Ipld::Link(cid) = d { Some(*cid) } else { None }) + .and_then(|d| { + if let Ipld::Link(cid) = d { + Some(*cid) + } else { + None + } + }) .ok_or_else(|| ImportError::InvalidCommit("Missing data field".to_string()))?; let rev = obj.get("rev").and_then(|r| { if let Ipld::String(s) = r { @@ -292,11 +299,10 @@ pub async fn apply_import( .fetch_optional(&mut *tx) .await .map_err(|e| { - if let sqlx::Error::Database(ref db_err) = e { - if db_err.code().as_deref() == Some("55P03") { + if let sqlx::Error::Database(ref db_err) = e + && db_err.code().as_deref() == Some("55P03") { return ImportError::ConcurrentModification; } - } ImportError::Database(e) })?; if repo.is_none() { diff --git a/src/sync/listener.rs b/src/sync/listener.rs index 05de5da..7ce61dd 100644 --- a/src/sync/listener.rs +++ b/src/sync/listener.rs @@ -43,7 +43,11 @@ async fn listen_loop(state: AppState) -> anyhow::Result<()> { .fetch_all(&state.db) .await?; if !events.is_empty() { - info!(count = events.len(), from_seq = catchup_start, "Broadcasting catch-up events"); + info!( + count = events.len(), + from_seq = catchup_start, + "Broadcasting catch-up events" + ); for event in events { let seq = event.seq; let _ = state.firehose_tx.send(event); @@ -57,13 +61,20 @@ async fn listen_loop(state: AppState) -> anyhow::Result<()> { let seq_id: i64 = match payload.parse() { Ok(id) => id, Err(e) => { - warn!("Received invalid payload in repo_updates: '{}'. Error: {}", payload, e); + warn!( + "Received invalid payload in repo_updates: '{}'. Error: {}", + payload, e + ); continue; } }; let last_seq = LAST_BROADCAST_SEQ.load(Ordering::SeqCst); if seq_id <= last_seq { - debug!(seq = seq_id, last = last_seq, "Skipping already-broadcast event"); + debug!( + seq = seq_id, + last = last_seq, + "Skipping already-broadcast event" + ); continue; } if seq_id > last_seq + 1 { @@ -103,7 +114,11 @@ async fn listen_loop(state: AppState) -> anyhow::Result<()> { if let Some(event) = event { match state.firehose_tx.send(event) { Ok(receiver_count) => { - debug!(seq = seq_id, receivers = receiver_count, "Broadcast event to firehose"); + debug!( + seq = seq_id, + receivers = receiver_count, + "Broadcast event to firehose" + ); } Err(e) => { warn!(seq = seq_id, error = %e, "Failed to broadcast event (no receivers?)"); @@ -111,7 +126,10 @@ async fn listen_loop(state: AppState) -> anyhow::Result<()> { } LAST_BROADCAST_SEQ.store(seq_id, Ordering::SeqCst); } else { - warn!(seq = seq_id, "Received notification but could not find row in repo_seq"); + warn!( + seq = seq_id, + "Received notification but could not find row in repo_seq" + ); } } } diff --git a/src/sync/repo.rs b/src/sync/repo.rs index 4316625..fc4c78e 100644 --- a/src/sync/repo.rs +++ b/src/sync/repo.rs @@ -1,10 +1,10 @@ use crate::state::AppState; use crate::sync::car::encode_car_header; use axum::{ + Json, extract::{Query, State}, http::StatusCode, response::{IntoResponse, Response}, - Json, }; use cid::Cid; use ipld_core::ipld::Ipld; @@ -51,7 +51,7 @@ pub async fn get_blocks( } }; if cids.is_empty() { - return (StatusCode::BAD_REQUEST, "No CIDs provided").into_response(); + return (StatusCode::BAD_REQUEST, "No CIDs provided").into_response(); } let root_cid = cids[0]; let header = match encode_car_header(&root_cid) { @@ -70,9 +70,11 @@ pub async fn get_blocks( let mut writer = Vec::new(); crate::sync::car::write_varint(&mut writer, total_len as u64) .expect("Writing to Vec should never fail"); - writer.write_all(&cid_bytes) + writer + .write_all(&cid_bytes) .expect("Writing to Vec should never fail"); - writer.write_all(&block) + writer + .write_all(&block) .expect("Writing to Vec should never fail"); car_bytes.extend_from_slice(&writer); } @@ -115,13 +117,13 @@ pub async fn get_repo( .await .unwrap_or(None); if user_exists.is_none() { - return ( + return ( StatusCode::NOT_FOUND, Json(json!({"error": "RepoNotFound", "message": "Repo not found"})), ) .into_response(); } else { - return ( + return ( StatusCode::NOT_FOUND, Json(json!({"error": "RepoNotFound", "message": "Repo not initialized"})), ) @@ -157,7 +159,9 @@ pub async fn get_repo( continue; } visited.insert(cid); - if remaining == 0 { break; } + if remaining == 0 { + break; + } remaining -= 1; if let Ok(Some(block)) = state.block_store.get(&cid).await { let cid_bytes = cid.to_bytes(); @@ -165,9 +169,11 @@ pub async fn get_repo( let mut writer = Vec::new(); crate::sync::car::write_varint(&mut writer, total_len as u64) .expect("Writing to Vec should never fail"); - writer.write_all(&cid_bytes) + writer + .write_all(&cid_bytes) .expect("Writing to Vec should never fail"); - writer.write_all(&block) + writer + .write_all(&block) .expect("Writing to Vec should never fail"); car_bytes.extend_from_slice(&writer); if let Ok(value) = serde_ipld_dagcbor::from_slice::(&block) { @@ -300,7 +306,7 @@ pub async fn get_record( } }; let mut proof_blocks: BTreeMap = BTreeMap::new(); - if let Err(_) = mst.blocks_for_path(&key, &mut proof_blocks).await { + if mst.blocks_for_path(&key, &mut proof_blocks).await.is_err() { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to build proof path"})), @@ -325,9 +331,11 @@ pub async fn get_record( let mut writer = Vec::new(); crate::sync::car::write_varint(&mut writer, total_len as u64) .expect("Writing to Vec should never fail"); - writer.write_all(&cid_bytes) + writer + .write_all(&cid_bytes) .expect("Writing to Vec should never fail"); - writer.write_all(data) + writer + .write_all(data) .expect("Writing to Vec should never fail"); car.extend_from_slice(&writer); }; diff --git a/src/sync/subscribe_repos.rs b/src/sync/subscribe_repos.rs index 6197c33..5bb7ee0 100644 --- a/src/sync/subscribe_repos.rs +++ b/src/sync/subscribe_repos.rs @@ -1,8 +1,10 @@ use crate::state::AppState; use crate::sync::firehose::SequencedEvent; -use crate::sync::util::{format_event_for_sending, format_event_with_prefetched_blocks, prefetch_blocks_for_events}; +use crate::sync::util::{ + format_event_for_sending, format_event_with_prefetched_blocks, prefetch_blocks_for_events, +}; use axum::{ - extract::{ws::Message, ws::WebSocket, ws::WebSocketUpgrade, Query, State}, + extract::{Query, State, ws::Message, ws::WebSocket, ws::WebSocketUpgrade}, response::Response, }; use futures::{sink::SinkExt, stream::StreamExt}; @@ -53,7 +55,11 @@ async fn handle_socket(mut socket: WebSocket, state: AppState, params: Subscribe info!(subscribers = count, "Firehose subscriber disconnected"); } -async fn handle_socket_inner(socket: &mut WebSocket, state: &AppState, params: SubscribeReposParams) -> Result<(), ()> { +async fn handle_socket_inner( + socket: &mut WebSocket, + state: &AppState, + params: SubscribeReposParams, +) -> Result<(), ()> { if let Some(cursor) = params.cursor { let mut current_cursor = cursor; loop { @@ -87,13 +93,14 @@ async fn handle_socket_inner(socket: &mut WebSocket, state: &AppState, params: S }; for event in events { current_cursor = event.seq; - let bytes = match format_event_with_prefetched_blocks(event, &prefetched).await { - Ok(b) => b, - Err(e) => { - warn!("Failed to format backfill event: {}", e); - return Err(()); - } - }; + let bytes = + match format_event_with_prefetched_blocks(event, &prefetched).await { + Ok(b) => b, + Err(e) => { + warn!("Failed to format backfill event: {}", e); + return Err(()); + } + }; if let Err(e) = socket.send(Message::Binary(bytes.into())).await { warn!("Failed to send backfill event: {}", e); return Err(()); diff --git a/src/sync/util.rs b/src/sync/util.rs index d03aba1..a93c78f 100644 --- a/src/sync/util.rs +++ b/src/sync/util.rs @@ -12,7 +12,9 @@ use std::str::FromStr; use tokio::io::AsyncWriteExt; fn extract_rev_from_commit_bytes(commit_bytes: &[u8]) -> Option { - Commit::from_cbor(commit_bytes).ok().map(|c| c.rev().to_string()) + Commit::from_cbor(commit_bytes) + .ok() + .map(|c| c.rev().to_string()) } async fn write_car_blocks( @@ -25,17 +27,25 @@ async fn write_car_blocks( let mut writer = CarWriter::new(header, &mut buffer); for (cid, data) in other_blocks { if cid != commit_cid { - writer.write(cid, data.as_ref()).await + writer + .write(cid, data.as_ref()) + .await .map_err(|e| anyhow::anyhow!("writing block {}: {}", cid, e))?; } } if let Some(data) = commit_bytes { - writer.write(commit_cid, data.as_ref()).await + writer + .write(commit_cid, data.as_ref()) + .await .map_err(|e| anyhow::anyhow!("writing commit block: {}", e))?; } - writer.finish().await + writer + .finish() + .await .map_err(|e| anyhow::anyhow!("finalizing CAR: {}", e))?; - buffer.flush().await + buffer + .flush() + .await .map_err(|e| anyhow::anyhow!("flushing CAR buffer: {}", e))?; Ok(buffer.into_inner()) } @@ -83,10 +93,15 @@ async fn format_sync_event( state: &AppState, event: &SequencedEvent, ) -> Result, anyhow::Error> { - let commit_cid_str = event.commit_cid.as_ref() + let commit_cid_str = event + .commit_cid + .as_ref() .ok_or_else(|| anyhow::anyhow!("Sync event missing commit_cid"))?; let commit_cid = Cid::from_str(commit_cid_str)?; - let commit_bytes = state.block_store.get(&commit_cid).await? + let commit_bytes = state + .block_store + .get(&commit_cid) + .await? .ok_or_else(|| anyhow::anyhow!("Commit block not found"))?; let rev = extract_rev_from_commit_bytes(&commit_bytes) .ok_or_else(|| anyhow::anyhow!("Could not extract rev from commit"))?; @@ -121,13 +136,13 @@ pub async fn format_event_for_sending( let block_cids_str = event.blocks_cids.clone().unwrap_or_default(); let prev_cid_str = event.prev_cid.clone(); let prev_data_cid_str = event.prev_data_cid.clone(); - let mut frame: CommitFrame = event.try_into() + let mut frame: CommitFrame = event + .try_into() .map_err(|e| anyhow::anyhow!("Invalid event: {}", e))?; - if let Some(ref pdc) = prev_data_cid_str { - if let Ok(cid) = Cid::from_str(pdc) { + if let Some(ref pdc) = prev_data_cid_str + && let Ok(cid) = Cid::from_str(pdc) { frame.prev_data = Some(cid); } - } let commit_cid = frame.commit; let prev_cid = prev_cid_str.as_ref().and_then(|s| Cid::from_str(s).ok()); let mut all_cids: Vec = block_cids_str @@ -138,13 +153,11 @@ pub async fn format_event_for_sending( if !all_cids.contains(&commit_cid) { all_cids.push(commit_cid); } - if let Some(ref pc) = prev_cid { - if let Ok(Some(prev_bytes)) = state.block_store.get(pc).await { - if let Some(rev) = extract_rev_from_commit_bytes(&prev_bytes) { + if let Some(ref pc) = prev_cid + && let Ok(Some(prev_bytes)) = state.block_store.get(pc).await + && let Some(rev) = extract_rev_from_commit_bytes(&prev_bytes) { frame.since = Some(rev); } - } - } let car_bytes = if !all_cids.is_empty() { let fetched = state.block_store.get_many(&all_cids).await?; let mut blocks = std::collections::BTreeMap::new(); @@ -182,16 +195,14 @@ pub async fn prefetch_blocks_for_events( ) -> Result, anyhow::Error> { let mut all_cids: Vec = Vec::new(); for event in events { - if let Some(ref commit_cid_str) = event.commit_cid { - if let Ok(cid) = Cid::from_str(commit_cid_str) { + if let Some(ref commit_cid_str) = event.commit_cid + && let Ok(cid) = Cid::from_str(commit_cid_str) { all_cids.push(cid); } - } - if let Some(ref prev_cid_str) = event.prev_cid { - if let Ok(cid) = Cid::from_str(prev_cid_str) { + if let Some(ref prev_cid_str) = event.prev_cid + && let Ok(cid) = Cid::from_str(prev_cid_str) { all_cids.push(cid); } - } if let Some(ref block_cids_str) = event.blocks_cids { for s in block_cids_str { if let Ok(cid) = Cid::from_str(s) { @@ -219,16 +230,21 @@ fn format_sync_event_with_prefetched( event: &SequencedEvent, prefetched: &HashMap, ) -> Result, anyhow::Error> { - let commit_cid_str = event.commit_cid.as_ref() + let commit_cid_str = event + .commit_cid + .as_ref() .ok_or_else(|| anyhow::anyhow!("Sync event missing commit_cid"))?; let commit_cid = Cid::from_str(commit_cid_str)?; - let commit_bytes = prefetched.get(&commit_cid) + let commit_bytes = prefetched + .get(&commit_cid) .ok_or_else(|| anyhow::anyhow!("Commit block not found in prefetched"))?; let rev = extract_rev_from_commit_bytes(commit_bytes) .ok_or_else(|| anyhow::anyhow!("Could not extract rev from commit"))?; - let car_bytes = futures::executor::block_on( - write_car_blocks(commit_cid, Some(commit_bytes.clone()), BTreeMap::new()) - )?; + let car_bytes = futures::executor::block_on(write_car_blocks( + commit_cid, + Some(commit_bytes.clone()), + BTreeMap::new(), + ))?; let frame = SyncFrame { did: event.did.clone(), rev, @@ -259,13 +275,13 @@ pub async fn format_event_with_prefetched_blocks( let block_cids_str = event.blocks_cids.clone().unwrap_or_default(); let prev_cid_str = event.prev_cid.clone(); let prev_data_cid_str = event.prev_data_cid.clone(); - let mut frame: CommitFrame = event.try_into() + let mut frame: CommitFrame = event + .try_into() .map_err(|e| anyhow::anyhow!("Invalid event: {}", e))?; - if let Some(ref pdc) = prev_data_cid_str { - if let Ok(cid) = Cid::from_str(pdc) { + if let Some(ref pdc) = prev_data_cid_str + && let Ok(cid) = Cid::from_str(pdc) { frame.prev_data = Some(cid); } - } let commit_cid = frame.commit; let prev_cid = prev_cid_str.as_ref().and_then(|s| Cid::from_str(s).ok()); let mut all_cids: Vec = block_cids_str @@ -276,18 +292,15 @@ pub async fn format_event_with_prefetched_blocks( if !all_cids.contains(&commit_cid) { all_cids.push(commit_cid); } - if let Some(commit_bytes) = prefetched.get(&commit_cid) { - if let Some(rev) = extract_rev_from_commit_bytes(commit_bytes) { + if let Some(commit_bytes) = prefetched.get(&commit_cid) + && let Some(rev) = extract_rev_from_commit_bytes(commit_bytes) { frame.rev = rev; } - } - if let Some(ref pc) = prev_cid { - if let Some(prev_bytes) = prefetched.get(pc) { - if let Some(rev) = extract_rev_from_commit_bytes(prev_bytes) { + if let Some(ref pc) = prev_cid + && let Some(prev_bytes) = prefetched.get(pc) + && let Some(rev) = extract_rev_from_commit_bytes(prev_bytes) { frame.since = Some(rev); } - } - } let car_bytes = if !all_cids.is_empty() { let mut blocks = BTreeMap::new(); let mut commit_bytes_for_car: Option = None; diff --git a/src/sync/verify.rs b/src/sync/verify.rs index e4a0cdf..92f3116 100644 --- a/src/sync/verify.rs +++ b/src/sync/verify.rs @@ -1,8 +1,8 @@ use bytes::Bytes; use cid::Cid; +use jacquard::common::IntoStatic; use jacquard::common::types::crypto::PublicKey; use jacquard::common::types::did_doc::DidDocument; -use jacquard::common::IntoStatic; use jacquard_repo::commit::Commit; use reqwest::Client; use std::collections::HashMap; @@ -61,8 +61,8 @@ impl CarVerifier { let root_block = blocks .get(root_cid) .ok_or_else(|| VerifyError::BlockNotFound(root_cid.to_string()))?; - let commit = Commit::from_cbor(root_block) - .map_err(|e| VerifyError::InvalidCommit(e.to_string()))?; + let commit = + Commit::from_cbor(root_block).map_err(|e| VerifyError::InvalidCommit(e.to_string()))?; let commit_did = commit.did().as_str(); if commit_did != expected_did { return Err(VerifyError::DidMismatch { @@ -133,16 +133,12 @@ impl CarVerifier { } async fn resolve_web_did(&self, did: &str) -> Result, VerifyError> { - let domain = did - .strip_prefix("did:web:") - .ok_or_else(|| VerifyError::DidResolutionFailed("Invalid did:web format".to_string()))?; + let domain = did.strip_prefix("did:web:").ok_or_else(|| { + VerifyError::DidResolutionFailed("Invalid did:web format".to_string()) + })?; let domain_decoded = urlencoding::decode(domain) .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?; - let url = if domain_decoded.contains(':') || domain_decoded.contains('/') { - format!("https://{}/.well-known/did.json", domain_decoded) - } else { - format!("https://{}/.well-known/did.json", domain_decoded) - }; + let url = format!("https://{}/.well-known/did.json", domain_decoded); let response = self .http_client .get(&url) @@ -205,10 +201,13 @@ impl CarVerifier { let mut last_full_key: Vec = Vec::new(); for entry in entries { if let Ipld::Map(entry_obj) = entry { - let prefix_len = entry_obj.get("p").and_then(|p| match p { - Ipld::Integer(i) => Some(*i as usize), - _ => None, - }).unwrap_or(0); + let prefix_len = entry_obj + .get("p") + .and_then(|p| match p { + Ipld::Integer(i) => Some(*i as usize), + _ => None, + }) + .unwrap_or(0); let key_suffix = entry_obj.get("k").and_then(|k| match k { Ipld::Bytes(b) => Some(b.clone()), Ipld::String(s) => Some(s.as_bytes().to_vec()), @@ -236,14 +235,13 @@ impl CarVerifier { } stack.push(*tree_cid); } - if let Some(Ipld::Link(value_cid)) = entry_obj.get("v") { - if !blocks.contains_key(value_cid) { + if let Some(Ipld::Link(value_cid)) = entry_obj.get("v") + && !blocks.contains_key(value_cid) { warn!( "Record block {} referenced in MST not in CAR (may be expected for partial export)", value_cid ); } - } } } } diff --git a/src/sync/verify_tests.rs b/src/sync/verify_tests.rs index 1dfe0ff..68d271a 100644 --- a/src/sync/verify_tests.rs +++ b/src/sync/verify_tests.rs @@ -64,7 +64,8 @@ mod tests { let verifier = CarVerifier::new(); let empty_node = serde_ipld_dagcbor::to_vec(&serde_json::json!({ "e": [] - })).unwrap(); + })) + .unwrap(); let cid = make_cid(&empty_node); let mut blocks = HashMap::new(); blocks.insert(cid, Bytes::from(empty_node)); @@ -106,9 +107,10 @@ mod tests { ("p".to_string(), Ipld::Integer(0)), ("t".to_string(), Ipld::Link(missing_subtree_cid)), ])); - let node = Ipld::Map(std::collections::BTreeMap::from([ - ("e".to_string(), Ipld::List(vec![entry])), - ])); + let node = Ipld::Map(std::collections::BTreeMap::from([( + "e".to_string(), + Ipld::List(vec![entry]), + )])); let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); let cid = make_cid(&node_bytes); let mut blocks = HashMap::new(); @@ -136,9 +138,10 @@ mod tests { ("v".to_string(), Ipld::Link(record_cid)), ("p".to_string(), Ipld::Integer(0)), ])); - let node = Ipld::Map(std::collections::BTreeMap::from([ - ("e".to_string(), Ipld::List(vec![entry1, entry2])), - ])); + let node = Ipld::Map(std::collections::BTreeMap::from([( + "e".to_string(), + Ipld::List(vec![entry1, entry2]), + )])); let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); let cid = make_cid(&node_bytes); let mut blocks = HashMap::new(); @@ -171,9 +174,10 @@ mod tests { ("v".to_string(), Ipld::Link(record_cid)), ("p".to_string(), Ipld::Integer(0)), ])); - let node = Ipld::Map(std::collections::BTreeMap::from([ - ("e".to_string(), Ipld::List(vec![entry1, entry2, entry3])), - ])); + let node = Ipld::Map(std::collections::BTreeMap::from([( + "e".to_string(), + Ipld::List(vec![entry1, entry2, entry3]), + )])); let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); let cid = make_cid(&node_bytes); let mut blocks = HashMap::new(); @@ -187,9 +191,10 @@ mod tests { use ipld_core::ipld::Ipld; let verifier = CarVerifier::new(); - let left_node = Ipld::Map(std::collections::BTreeMap::from([ - ("e".to_string(), Ipld::List(vec![])), - ])); + let left_node = Ipld::Map(std::collections::BTreeMap::from([( + "e".to_string(), + Ipld::List(vec![]), + )])); let left_node_bytes = serde_ipld_dagcbor::to_vec(&left_node).unwrap(); let left_cid = make_cid(&left_node_bytes); let root_node = Ipld::Map(std::collections::BTreeMap::from([ @@ -210,7 +215,8 @@ mod tests { let verifier = CarVerifier::new(); let node = serde_ipld_dagcbor::to_vec(&serde_json::json!({ "e": [] - })).unwrap(); + })) + .unwrap(); let cid = make_cid(&node); let mut blocks = HashMap::new(); blocks.insert(cid, Bytes::from(node)); @@ -235,7 +241,10 @@ mod tests { let verifier = CarVerifier::new(); let record_cid = make_cid(b"record"); let entry1 = Ipld::Map(std::collections::BTreeMap::from([ - ("k".to_string(), Ipld::Bytes(b"app.bsky.feed.post/abc".to_vec())), + ( + "k".to_string(), + Ipld::Bytes(b"app.bsky.feed.post/abc".to_vec()), + ), ("v".to_string(), Ipld::Link(record_cid)), ("p".to_string(), Ipld::Integer(0)), ])); @@ -249,15 +258,19 @@ mod tests { ("v".to_string(), Ipld::Link(record_cid)), ("p".to_string(), Ipld::Integer(19)), ])); - let node = Ipld::Map(std::collections::BTreeMap::from([ - ("e".to_string(), Ipld::List(vec![entry1, entry2, entry3])), - ])); + let node = Ipld::Map(std::collections::BTreeMap::from([( + "e".to_string(), + Ipld::List(vec![entry1, entry2, entry3]), + )])); let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); let cid = make_cid(&node_bytes); let mut blocks = HashMap::new(); blocks.insert(cid, Bytes::from(node_bytes)); let result = verifier.verify_mst_structure(&cid, &blocks); - assert!(result.is_ok(), "Prefix-compressed keys should be validated correctly"); + assert!( + result.is_ok(), + "Prefix-compressed keys should be validated correctly" + ); } #[test] @@ -267,7 +280,10 @@ mod tests { let verifier = CarVerifier::new(); let record_cid = make_cid(b"record"); let entry1 = Ipld::Map(std::collections::BTreeMap::from([ - ("k".to_string(), Ipld::Bytes(b"app.bsky.feed.post/xyz".to_vec())), + ( + "k".to_string(), + Ipld::Bytes(b"app.bsky.feed.post/xyz".to_vec()), + ), ("v".to_string(), Ipld::Link(record_cid)), ("p".to_string(), Ipld::Integer(0)), ])); @@ -276,15 +292,19 @@ mod tests { ("v".to_string(), Ipld::Link(record_cid)), ("p".to_string(), Ipld::Integer(19)), ])); - let node = Ipld::Map(std::collections::BTreeMap::from([ - ("e".to_string(), Ipld::List(vec![entry1, entry2])), - ])); + let node = Ipld::Map(std::collections::BTreeMap::from([( + "e".to_string(), + Ipld::List(vec![entry1, entry2]), + )])); let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); let cid = make_cid(&node_bytes); let mut blocks = HashMap::new(); blocks.insert(cid, Bytes::from(node_bytes)); let result = verifier.verify_mst_structure(&cid, &blocks); - assert!(result.is_err(), "Unsorted prefix-compressed keys should fail validation"); + assert!( + result.is_err(), + "Unsorted prefix-compressed keys should fail validation" + ); let err = result.unwrap_err(); assert!(matches!(err, VerifyError::MstValidationFailed(_))); } diff --git a/src/util.rs b/src/util.rs index 503430c..ba53603 100644 --- a/src/util.rs +++ b/src/util.rs @@ -58,7 +58,10 @@ pub async fn get_user_by_did(db: &PgPool, did: &str) -> Result Result { +pub async fn get_user_by_identifier( + db: &PgPool, + identifier: &str, +) -> Result { sqlx::query_as!( UserInfo, "SELECT id, did, handle FROM users WHERE did = $1 OR handle = $1", diff --git a/src/validation/mod.rs b/src/validation/mod.rs index e656cde..000d32b 100644 --- a/src/validation/mod.rs +++ b/src/validation/mod.rs @@ -53,9 +53,9 @@ impl RecordValidator { record: &Value, collection: &str, ) -> Result { - let obj = record - .as_object() - .ok_or_else(|| ValidationError::InvalidRecord("Record must be an object".to_string()))?; + let obj = record.as_object().ok_or_else(|| { + ValidationError::InvalidRecord("Record must be an object".to_string()) + })?; let record_type = obj .get("$type") .and_then(|v| v.as_str()) @@ -103,18 +103,20 @@ impl RecordValidator { if grapheme_count > 3000 { return Err(ValidationError::InvalidField { path: "text".to_string(), - message: format!("Text exceeds maximum length of 3000 characters (got {})", grapheme_count), + message: format!( + "Text exceeds maximum length of 3000 characters (got {})", + grapheme_count + ), }); } } - if let Some(langs) = obj.get("langs").and_then(|v| v.as_array()) { - if langs.len() > 3 { + if let Some(langs) = obj.get("langs").and_then(|v| v.as_array()) + && langs.len() > 3 { return Err(ValidationError::InvalidField { path: "langs".to_string(), message: "Maximum 3 languages allowed".to_string(), }); } - } if let Some(tags) = obj.get("tags").and_then(|v| v.as_array()) { if tags.len() > 8 { return Err(ValidationError::InvalidField { @@ -123,26 +125,31 @@ impl RecordValidator { }); } for (i, tag) in tags.iter().enumerate() { - if let Some(tag_str) = tag.as_str() { - if tag_str.len() > 640 { + if let Some(tag_str) = tag.as_str() + && tag_str.len() > 640 { return Err(ValidationError::InvalidField { path: format!("tags/{}", i), message: "Tag exceeds maximum length of 640 bytes".to_string(), }); } - } } } Ok(()) } - fn validate_profile(&self, obj: &serde_json::Map) -> Result<(), ValidationError> { + fn validate_profile( + &self, + obj: &serde_json::Map, + ) -> Result<(), ValidationError> { if let Some(display_name) = obj.get("displayName").and_then(|v| v.as_str()) { let grapheme_count = display_name.chars().count(); if grapheme_count > 640 { return Err(ValidationError::InvalidField { path: "displayName".to_string(), - message: format!("Display name exceeds maximum length of 640 characters (got {})", grapheme_count), + message: format!( + "Display name exceeds maximum length of 640 characters (got {})", + grapheme_count + ), }); } } @@ -151,7 +158,10 @@ impl RecordValidator { if grapheme_count > 2560 { return Err(ValidationError::InvalidField { path: "description".to_string(), - message: format!("Description exceeds maximum length of 2560 characters (got {})", grapheme_count), + message: format!( + "Description exceeds maximum length of 2560 characters (got {})", + grapheme_count + ), }); } } @@ -187,14 +197,13 @@ impl RecordValidator { if !obj.contains_key("createdAt") { return Err(ValidationError::MissingField("createdAt".to_string())); } - if let Some(subject) = obj.get("subject").and_then(|v| v.as_str()) { - if !subject.starts_with("did:") { + if let Some(subject) = obj.get("subject").and_then(|v| v.as_str()) + && !subject.starts_with("did:") { return Err(ValidationError::InvalidField { path: "subject".to_string(), message: "Subject must be a DID".to_string(), }); } - } Ok(()) } @@ -205,14 +214,13 @@ impl RecordValidator { if !obj.contains_key("createdAt") { return Err(ValidationError::MissingField("createdAt".to_string())); } - if let Some(subject) = obj.get("subject").and_then(|v| v.as_str()) { - if !subject.starts_with("did:") { + if let Some(subject) = obj.get("subject").and_then(|v| v.as_str()) + && !subject.starts_with("did:") { return Err(ValidationError::InvalidField { path: "subject".to_string(), message: "Subject must be a DID".to_string(), }); } - } Ok(()) } @@ -226,18 +234,20 @@ impl RecordValidator { if !obj.contains_key("createdAt") { return Err(ValidationError::MissingField("createdAt".to_string())); } - if let Some(name) = obj.get("name").and_then(|v| v.as_str()) { - if name.is_empty() || name.len() > 64 { + if let Some(name) = obj.get("name").and_then(|v| v.as_str()) + && (name.is_empty() || name.len() > 64) { return Err(ValidationError::InvalidField { path: "name".to_string(), message: "Name must be 1-64 characters".to_string(), }); } - } Ok(()) } - fn validate_list_item(&self, obj: &serde_json::Map) -> Result<(), ValidationError> { + fn validate_list_item( + &self, + obj: &serde_json::Map, + ) -> Result<(), ValidationError> { if !obj.contains_key("subject") { return Err(ValidationError::MissingField("subject".to_string())); } @@ -250,7 +260,10 @@ impl RecordValidator { Ok(()) } - fn validate_feed_generator(&self, obj: &serde_json::Map) -> Result<(), ValidationError> { + fn validate_feed_generator( + &self, + obj: &serde_json::Map, + ) -> Result<(), ValidationError> { if !obj.contains_key("did") { return Err(ValidationError::MissingField("did".to_string())); } @@ -260,18 +273,20 @@ impl RecordValidator { if !obj.contains_key("createdAt") { return Err(ValidationError::MissingField("createdAt".to_string())); } - if let Some(display_name) = obj.get("displayName").and_then(|v| v.as_str()) { - if display_name.is_empty() || display_name.len() > 240 { + if let Some(display_name) = obj.get("displayName").and_then(|v| v.as_str()) + && (display_name.is_empty() || display_name.len() > 240) { return Err(ValidationError::InvalidField { path: "displayName".to_string(), message: "displayName must be 1-240 characters".to_string(), }); } - } Ok(()) } - fn validate_threadgate(&self, obj: &serde_json::Map) -> Result<(), ValidationError> { + fn validate_threadgate( + &self, + obj: &serde_json::Map, + ) -> Result<(), ValidationError> { if !obj.contains_key("post") { return Err(ValidationError::MissingField("post".to_string())); } @@ -281,7 +296,10 @@ impl RecordValidator { Ok(()) } - fn validate_labeler_service(&self, obj: &serde_json::Map) -> Result<(), ValidationError> { + fn validate_labeler_service( + &self, + obj: &serde_json::Map, + ) -> Result<(), ValidationError> { if !obj.contains_key("policies") { return Err(ValidationError::MissingField("policies".to_string())); } @@ -291,27 +309,31 @@ impl RecordValidator { Ok(()) } - fn validate_strong_ref(&self, value: Option<&Value>, path: &str) -> Result<(), ValidationError> { - let obj = value - .and_then(|v| v.as_object()) - .ok_or_else(|| ValidationError::InvalidField { - path: path.to_string(), - message: "Must be a strong reference object".to_string(), - })?; + fn validate_strong_ref( + &self, + value: Option<&Value>, + path: &str, + ) -> Result<(), ValidationError> { + let obj = + value + .and_then(|v| v.as_object()) + .ok_or_else(|| ValidationError::InvalidField { + path: path.to_string(), + message: "Must be a strong reference object".to_string(), + })?; if !obj.contains_key("uri") { return Err(ValidationError::MissingField(format!("{}/uri", path))); } if !obj.contains_key("cid") { return Err(ValidationError::MissingField(format!("{}/cid", path))); } - if let Some(uri) = obj.get("uri").and_then(|v| v.as_str()) { - if !uri.starts_with("at://") { + if let Some(uri) = obj.get("uri").and_then(|v| v.as_str()) + && !uri.starts_with("at://") { return Err(ValidationError::InvalidField { path: format!("{}/uri", path), message: "URI must be an at:// URI".to_string(), }); } - } Ok(()) } } @@ -327,20 +349,27 @@ fn validate_datetime(value: &str, path: &str) -> Result<(), ValidationError> { pub fn validate_record_key(rkey: &str) -> Result<(), ValidationError> { if rkey.is_empty() { - return Err(ValidationError::InvalidRecord("Record key cannot be empty".to_string())); + return Err(ValidationError::InvalidRecord( + "Record key cannot be empty".to_string(), + )); } if rkey.len() > 512 { - return Err(ValidationError::InvalidRecord("Record key exceeds maximum length of 512".to_string())); + return Err(ValidationError::InvalidRecord( + "Record key exceeds maximum length of 512".to_string(), + )); } if rkey == "." || rkey == ".." { - return Err(ValidationError::InvalidRecord("Record key cannot be '.' or '..'".to_string())); + return Err(ValidationError::InvalidRecord( + "Record key cannot be '.' or '..'".to_string(), + )); } - let valid_chars = rkey.chars().all(|c| { - c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' || c == '~' - }); + let valid_chars = rkey + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' || c == '~'); if !valid_chars { return Err(ValidationError::InvalidRecord( - "Record key contains invalid characters (must be alphanumeric, '.', '-', '_', or '~')".to_string() + "Record key contains invalid characters (must be alphanumeric, '.', '-', '_', or '~')" + .to_string(), )); } Ok(()) @@ -348,23 +377,25 @@ pub fn validate_record_key(rkey: &str) -> Result<(), ValidationError> { pub fn validate_collection_nsid(collection: &str) -> Result<(), ValidationError> { if collection.is_empty() { - return Err(ValidationError::InvalidRecord("Collection NSID cannot be empty".to_string())); + return Err(ValidationError::InvalidRecord( + "Collection NSID cannot be empty".to_string(), + )); } let parts: Vec<&str> = collection.split('.').collect(); if parts.len() < 3 { return Err(ValidationError::InvalidRecord( - "Collection NSID must have at least 3 segments".to_string() + "Collection NSID must have at least 3 segments".to_string(), )); } for part in &parts { if part.is_empty() { return Err(ValidationError::InvalidRecord( - "Collection NSID segments cannot be empty".to_string() + "Collection NSID segments cannot be empty".to_string(), )); } if !part.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { return Err(ValidationError::InvalidRecord( - "Collection NSID segments must be alphanumeric or hyphens".to_string() + "Collection NSID segments must be alphanumeric or hyphens".to_string(), )); } } @@ -385,7 +416,9 @@ mod tests { "createdAt": "2024-01-01T00:00:00.000Z" }); assert_eq!( - validator.validate(&valid_post, "app.bsky.feed.post").unwrap(), + validator + .validate(&valid_post, "app.bsky.feed.post") + .unwrap(), ValidationStatus::Valid ); } @@ -397,7 +430,11 @@ mod tests { "$type": "app.bsky.feed.post", "createdAt": "2024-01-01T00:00:00.000Z" }); - assert!(validator.validate(&invalid_post, "app.bsky.feed.post").is_err()); + assert!( + validator + .validate(&invalid_post, "app.bsky.feed.post") + .is_err() + ); } #[test] diff --git a/tests/actor.rs b/tests/actor.rs index a495b27..3cdc0af 100644 --- a/tests/actor.rs +++ b/tests/actor.rs @@ -1,6 +1,6 @@ mod common; use common::{base_url, client, create_account_and_login}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; #[tokio::test] async fn test_get_preferences_empty() { diff --git a/tests/admin_email.rs b/tests/admin_email.rs index 963de1d..bd10379 100644 --- a/tests/admin_email.rs +++ b/tests/admin_email.rs @@ -1,7 +1,7 @@ mod common; use reqwest::StatusCode; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use sqlx::PgPool; async fn get_pool() -> PgPool { @@ -46,7 +46,11 @@ async fn test_send_email_success() { .await .expect("Notification not found"); assert_eq!(notification.subject.as_deref(), Some("Test Admin Email")); - assert!(notification.body.contains("Hello, this is a test email from the admin.")); + assert!( + notification + .body + .contains("Hello, this is a test email from the admin.") + ); } #[tokio::test] diff --git a/tests/admin_moderation.rs b/tests/admin_moderation.rs index 295fc45..c92e5f8 100644 --- a/tests/admin_moderation.rs +++ b/tests/admin_moderation.rs @@ -176,7 +176,12 @@ async fn test_update_subject_status_remove_takedown() { .await .expect("Failed to send request"); let status_body: Value = status_res.json().await.unwrap(); - assert!(status_body["takedown"].is_null() || !status_body["takedown"]["applied"].as_bool().unwrap_or(false)); + assert!( + status_body["takedown"].is_null() + || !status_body["takedown"]["applied"] + .as_bool() + .unwrap_or(false) + ); } #[tokio::test] diff --git a/tests/appview_integration.rs b/tests/appview_integration.rs index 8a4c457..d8dab0c 100644 --- a/tests/appview_integration.rs +++ b/tests/appview_integration.rs @@ -2,7 +2,7 @@ mod common; use common::{base_url, client, create_account_and_login}; use reqwest::StatusCode; -use serde_json::{json, Value}; +use serde_json::{Value, json}; #[tokio::test] async fn test_get_author_feed_returns_appview_data() { @@ -72,7 +72,10 @@ async fn test_get_post_thread_returns_appview_data() { .unwrap(); assert_eq!(res.status(), StatusCode::OK); let body: Value = res.json().await.unwrap(); - assert!(body["thread"].is_object(), "Response should have thread object"); + assert!( + body["thread"].is_object(), + "Response should have thread object" + ); assert_eq!( body["thread"]["$type"].as_str(), Some("app.bsky.feed.defs#threadViewPost"), @@ -117,10 +120,7 @@ async fn test_register_push_proxies_to_appview() { let base = base_url().await; let (jwt, _did) = create_account_and_login(&client).await; let res = client - .post(format!( - "{}/xrpc/app.bsky.notification.registerPush", - base - )) + .post(format!("{}/xrpc/app.bsky.notification.registerPush", base)) .header("Authorization", format!("Bearer {}", jwt)) .json(&json!({ "serviceDid": "did:web:example.com", diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 6935f9c..2d806eb 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -50,12 +50,18 @@ fn cleanup() { return; } if std::env::var("XDG_RUNTIME_DIR").is_ok() { - let _ = std::process::Command::new("podman") + let _ = std::process::Command::new("podman") .args(&["rm", "-f", "--filter", "label=bspds_test=true"]) .output(); } let _ = std::process::Command::new("docker") - .args(&["container", "prune", "-f", "--filter", "label=bspds_test=true"]) + .args(&[ + "container", + "prune", + "-f", + "--filter", + "label=bspds_test=true", + ]) .output(); } @@ -103,15 +109,27 @@ pub async fn base_url() -> &'static str { } async fn setup_with_external_infra() -> String { - let database_url = std::env::var("DATABASE_URL") - .expect("DATABASE_URL must be set when using external infra"); - let s3_endpoint = std::env::var("S3_ENDPOINT") - .expect("S3_ENDPOINT must be set when using external infra"); + let database_url = + std::env::var("DATABASE_URL").expect("DATABASE_URL must be set when using external infra"); + let s3_endpoint = + std::env::var("S3_ENDPOINT").expect("S3_ENDPOINT must be set when using external infra"); unsafe { - std::env::set_var("S3_BUCKET", std::env::var("S3_BUCKET").unwrap_or_else(|_| "test-bucket".to_string())); - std::env::set_var("AWS_ACCESS_KEY_ID", std::env::var("AWS_ACCESS_KEY_ID").unwrap_or_else(|_| "minioadmin".to_string())); - std::env::set_var("AWS_SECRET_ACCESS_KEY", std::env::var("AWS_SECRET_ACCESS_KEY").unwrap_or_else(|_| "minioadmin".to_string())); - std::env::set_var("AWS_REGION", std::env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string())); + std::env::set_var( + "S3_BUCKET", + std::env::var("S3_BUCKET").unwrap_or_else(|_| "test-bucket".to_string()), + ); + std::env::set_var( + "AWS_ACCESS_KEY_ID", + std::env::var("AWS_ACCESS_KEY_ID").unwrap_or_else(|_| "minioadmin".to_string()), + ); + std::env::set_var( + "AWS_SECRET_ACCESS_KEY", + std::env::var("AWS_SECRET_ACCESS_KEY").unwrap_or_else(|_| "minioadmin".to_string()), + ); + std::env::set_var( + "AWS_REGION", + std::env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string()), + ); std::env::set_var("S3_ENDPOINT", &s3_endpoint); } let mock_server = MockServer::start().await; @@ -189,7 +207,9 @@ async fn setup_with_testcontainers() -> String { #[cfg(feature = "external-infra")] async fn setup_with_testcontainers() -> String { - panic!("Testcontainers disabled with external-infra feature. Set DATABASE_URL and S3_ENDPOINT."); + panic!( + "Testcontainers disabled with external-infra feature. Set DATABASE_URL and S3_ENDPOINT." + ); } async fn setup_mock_appview(mock_server: &MockServer) { @@ -218,7 +238,7 @@ async fn setup_mock_appview(mock_server: &MockServer) { .set_body_json(json!({ "feed": [], "cursor": null - })) + })), ) .mount(mock_server) .await; @@ -364,7 +384,10 @@ pub async fn get_db_connection_string() -> String { #[cfg(not(feature = "external-infra"))] { let container = DB_CONTAINER.get().expect("DB container not initialized"); - let port = container.get_host_port_ipv4(5432).await.expect("Failed to get port"); + let port = container + .get_host_port_ipv4(5432) + .await + .expect("Failed to get port"); format!("postgres://postgres:postgres@127.0.0.1:{}/postgres", port) } #[cfg(feature = "external-infra")] @@ -404,7 +427,10 @@ pub async fn verify_new_account(client: &Client, did: &str) -> String { .await .expect("confirmSignup request failed"); assert_eq!(confirm_res.status(), StatusCode::OK, "confirmSignup failed"); - let confirm_body: Value = confirm_res.json().await.expect("Invalid JSON from confirmSignup"); + let confirm_body: Value = confirm_res + .json() + .await + .expect("Invalid JSON from confirmSignup"); confirm_body["accessJwt"] .as_str() .expect("No accessJwt in confirmSignup response") @@ -543,7 +569,10 @@ pub async fn create_account_and_login(client: &Client) -> (String, String) { .await .expect("confirmSignup request failed"); if confirm_res.status() == StatusCode::OK { - let confirm_body: Value = confirm_res.json().await.expect("Invalid JSON from confirmSignup"); + let confirm_body: Value = confirm_res + .json() + .await + .expect("Invalid JSON from confirmSignup"); let access_jwt = confirm_body["accessJwt"] .as_str() .expect("No accessJwt in confirmSignup response") diff --git a/tests/delete_account.rs b/tests/delete_account.rs index dd7c321..0e2cd60 100644 --- a/tests/delete_account.rs +++ b/tests/delete_account.rs @@ -1,7 +1,7 @@ mod common; mod helpers; -use common::*; use chrono::Utc; +use common::*; use reqwest::StatusCode; use serde_json::{Value, json}; use sqlx::PgPool; @@ -15,9 +15,18 @@ async fn get_pool() -> PgPool { .expect("Failed to connect to test database") } -async fn create_verified_account(client: &reqwest::Client, base_url: &str, handle: &str, email: &str, password: &str) -> (String, String) { +async fn create_verified_account( + client: &reqwest::Client, + base_url: &str, + handle: &str, + email: &str, + password: &str, +) -> (String, String) { let res = client - .post(format!("{}/xrpc/com.atproto.server.createAccount", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url + )) .json(&json!({ "handle": handle, "email": email, @@ -53,10 +62,13 @@ async fn test_delete_account_full_flow() { .expect("Failed to request account deletion"); assert_eq!(request_delete_res.status(), StatusCode::OK); let pool = get_pool().await; - let row = sqlx::query!("SELECT token FROM account_deletion_requests WHERE did = $1", did) - .fetch_one(&pool) - .await - .expect("Failed to query deletion token"); + let row = sqlx::query!( + "SELECT token FROM account_deletion_requests WHERE did = $1", + did + ) + .fetch_one(&pool) + .await + .expect("Failed to query deletion token"); let token = row.token; let delete_payload = json!({ "did": did, @@ -79,10 +91,7 @@ async fn test_delete_account_full_flow() { .expect("Failed to query user"); assert!(user_row.is_none(), "User should be deleted from database"); let session_res = client - .get(format!( - "{}/xrpc/com.atproto.server.getSession", - base_url - )) + .get(format!("{}/xrpc/com.atproto.server.getSession", base_url)) .bearer_auth(&jwt) .send() .await @@ -110,10 +119,13 @@ async fn test_delete_account_wrong_password() { .expect("Failed to request account deletion"); assert_eq!(request_delete_res.status(), StatusCode::OK); let pool = get_pool().await; - let row = sqlx::query!("SELECT token FROM account_deletion_requests WHERE did = $1", did) - .fetch_one(&pool) - .await - .expect("Failed to query deletion token"); + let row = sqlx::query!( + "SELECT token FROM account_deletion_requests WHERE did = $1", + did + ) + .fetch_one(&pool) + .await + .expect("Failed to query deletion token"); let token = row.token; let delete_payload = json!({ "did": did, @@ -197,10 +209,13 @@ async fn test_delete_account_expired_token() { .expect("Failed to request account deletion"); assert_eq!(request_delete_res.status(), StatusCode::OK); let pool = get_pool().await; - let row = sqlx::query!("SELECT token FROM account_deletion_requests WHERE did = $1", did) - .fetch_one(&pool) - .await - .expect("Failed to query deletion token"); + let row = sqlx::query!( + "SELECT token FROM account_deletion_requests WHERE did = $1", + did + ) + .fetch_one(&pool) + .await + .expect("Failed to query deletion token"); let token = row.token; sqlx::query!( "UPDATE account_deletion_requests SET expires_at = NOW() - INTERVAL '1 hour' WHERE token = $1", @@ -236,7 +251,8 @@ async fn test_delete_account_token_mismatch() { let handle1 = format!("delete-user1-{}.test", ts); let email1 = format!("delete-user1-{}@test.com", ts); let password1 = "user1-password"; - let (did1, jwt1) = create_verified_account(&client, &base_url, &handle1, &email1, password1).await; + let (did1, jwt1) = + create_verified_account(&client, &base_url, &handle1, &email1, password1).await; let handle2 = format!("delete-user2-{}.test", ts); let email2 = format!("delete-user2-{}@test.com", ts); let password2 = "user2-password"; @@ -252,10 +268,13 @@ async fn test_delete_account_token_mismatch() { .expect("Failed to request account deletion"); assert_eq!(request_delete_res.status(), StatusCode::OK); let pool = get_pool().await; - let row = sqlx::query!("SELECT token FROM account_deletion_requests WHERE did = $1", did1) - .fetch_one(&pool) - .await - .expect("Failed to query deletion token"); + let row = sqlx::query!( + "SELECT token FROM account_deletion_requests WHERE did = $1", + did1 + ) + .fetch_one(&pool) + .await + .expect("Failed to query deletion token"); let token = row.token; let delete_payload = json!({ "did": did2, @@ -284,7 +303,8 @@ async fn test_delete_account_with_app_password() { let handle = format!("delete-apppw-{}.test", ts); let email = format!("delete-apppw-{}@test.com", ts); let main_password = "main-password-123"; - let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, main_password).await; + let (did, jwt) = + create_verified_account(&client, &base_url, &handle, &email, main_password).await; let app_password_res = client .post(format!( "{}/xrpc/com.atproto.server.createAppPassword", @@ -309,10 +329,13 @@ async fn test_delete_account_with_app_password() { .expect("Failed to request account deletion"); assert_eq!(request_delete_res.status(), StatusCode::OK); let pool = get_pool().await; - let row = sqlx::query!("SELECT token FROM account_deletion_requests WHERE did = $1", did) - .fetch_one(&pool) - .await - .expect("Failed to query deletion token"); + let row = sqlx::query!( + "SELECT token FROM account_deletion_requests WHERE did = $1", + did + ) + .fetch_one(&pool) + .await + .expect("Failed to query deletion token"); let token = row.token; let delete_payload = json!({ "did": did, diff --git a/tests/email_update.rs b/tests/email_update.rs index 290adf0..a9bcdf4 100644 --- a/tests/email_update.rs +++ b/tests/email_update.rs @@ -1,6 +1,6 @@ mod common; use reqwest::StatusCode; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use sqlx::PgPool; async fn get_pool() -> PgPool { @@ -12,9 +12,17 @@ async fn get_pool() -> PgPool { .expect("Failed to connect to test database") } -async fn create_verified_account(client: &reqwest::Client, base_url: &str, handle: &str, email: &str) -> String { +async fn create_verified_account( + client: &reqwest::Client, + base_url: &str, + handle: &str, + email: &str, +) -> String { let res = client - .post(format!("{}/xrpc/com.atproto.server.createAccount", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url + )) .json(&json!({ "handle": handle, "email": email, @@ -39,7 +47,10 @@ async fn test_email_update_flow_success() { let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; let new_email = format!("new_{}@example.com", handle); let res = client - .post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.requestEmailUpdate", + base_url + )) .bearer_auth(&access_jwt) .json(&json!({"email": new_email})) .send() @@ -55,7 +66,10 @@ async fn test_email_update_flow_success() { .fetch_one(&pool) .await .expect("User not found"); - assert_eq!(user.email_pending_verification.as_deref(), Some(new_email.as_str())); + assert_eq!( + user.email_pending_verification.as_deref(), + Some(new_email.as_str()) + ); assert!(user.email_confirmation_code.is_some()); let code = user.email_confirmation_code.unwrap(); let res = client @@ -92,7 +106,10 @@ async fn test_request_email_update_taken_email() { let email2 = format!("{}@example.com", handle2); let access_jwt2 = create_verified_account(&client, &base_url, &handle2, &email2).await; let res = client - .post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.requestEmailUpdate", + base_url + )) .bearer_auth(&access_jwt2) .json(&json!({"email": email1})) .send() @@ -112,7 +129,10 @@ async fn test_confirm_email_invalid_token() { let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; let new_email = format!("new_{}@example.com", handle); let res = client - .post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.requestEmailUpdate", + base_url + )) .bearer_auth(&access_jwt) .json(&json!({"email": new_email})) .send() @@ -144,17 +164,23 @@ async fn test_confirm_email_wrong_email() { let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; let new_email = format!("new_{}@example.com", handle); let res = client - .post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.requestEmailUpdate", + base_url + )) .bearer_auth(&access_jwt) .json(&json!({"email": new_email})) .send() .await .expect("Failed to request email update"); assert_eq!(res.status(), StatusCode::OK); - let user = sqlx::query!("SELECT email_confirmation_code FROM users WHERE handle = $1", handle) - .fetch_one(&pool) - .await - .expect("User not found"); + let user = sqlx::query!( + "SELECT email_confirmation_code FROM users WHERE handle = $1", + handle + ) + .fetch_one(&pool) + .await + .expect("User not found"); let code = user.email_confirmation_code.unwrap(); let res = client .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) @@ -209,7 +235,11 @@ async fn test_update_email_same_email_noop() { .send() .await .expect("Failed to update email"); - assert_eq!(res.status(), StatusCode::OK, "Updating to same email should succeed as no-op"); + assert_eq!( + res.status(), + StatusCode::OK, + "Updating to same email should succeed as no-op" + ); } #[tokio::test] @@ -221,7 +251,10 @@ async fn test_update_email_requires_token_after_pending() { let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; let new_email = format!("pending_{}@example.com", handle); let res = client - .post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.requestEmailUpdate", + base_url + )) .bearer_auth(&access_jwt) .json(&json!({"email": new_email})) .send() @@ -250,7 +283,10 @@ async fn test_update_email_with_valid_token() { let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; let new_email = format!("valid_{}@example.com", handle); let res = client - .post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.requestEmailUpdate", + base_url + )) .bearer_auth(&access_jwt) .json(&json!({"email": new_email})) .send() @@ -276,10 +312,13 @@ async fn test_update_email_with_valid_token() { .await .expect("Failed to update email"); assert_eq!(res.status(), StatusCode::OK); - let user = sqlx::query!("SELECT email, email_pending_verification FROM users WHERE handle = $1", handle) - .fetch_one(&pool) - .await - .expect("User not found"); + let user = sqlx::query!( + "SELECT email, email_pending_verification FROM users WHERE handle = $1", + handle + ) + .fetch_one(&pool) + .await + .expect("User not found"); assert_eq!(user.email, Some(new_email)); assert!(user.email_pending_verification.is_none()); } @@ -293,7 +332,10 @@ async fn test_update_email_invalid_token() { let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; let new_email = format!("badtok_{}@example.com", handle); let res = client - .post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.requestEmailUpdate", + base_url + )) .bearer_auth(&access_jwt) .json(&json!({"email": new_email})) .send() @@ -334,7 +376,10 @@ async fn test_update_email_already_taken() { .expect("Failed to attempt email update"); assert_eq!(res.status(), StatusCode::BAD_REQUEST); let body: Value = res.json().await.expect("Invalid JSON"); - assert!(body["message"].as_str().unwrap().contains("already in use") || body["error"] == "InvalidRequest"); + assert!( + body["message"].as_str().unwrap().contains("already in use") + || body["error"] == "InvalidRequest" + ); } #[tokio::test] diff --git a/tests/feed.rs b/tests/feed.rs index dc627b8..376d0c5 100644 --- a/tests/feed.rs +++ b/tests/feed.rs @@ -90,10 +90,7 @@ async fn test_register_push_requires_auth() { let client = client(); let base = base_url().await; let res = client - .post(format!( - "{}/xrpc/app.bsky.notification.registerPush", - base - )) + .post(format!("{}/xrpc/app.bsky.notification.registerPush", base)) .json(&json!({ "serviceDid": "did:web:example.com", "token": "test-token", diff --git a/tests/firehose.rs b/tests/firehose.rs deleted file mode 100644 index f8ddce1..0000000 --- a/tests/firehose.rs +++ /dev/null @@ -1,192 +0,0 @@ -mod common; -use common::*; -use cid::Cid; -use futures::{stream::StreamExt, SinkExt}; -use iroh_car::CarReader; -use reqwest::StatusCode; -use serde::Deserialize; -use serde_json::{json, Value}; -use std::io::Cursor; -use tokio_tungstenite::{connect_async, tungstenite}; - -#[derive(Debug, Deserialize)] -struct FrameHeader { - op: i64, - t: String, -} - -#[derive(Debug, Deserialize)] -struct CommitFrame { - seq: i64, - rebase: bool, - #[serde(rename = "tooBig")] - too_big: bool, - repo: String, - commit: Cid, - rev: String, - since: Option, - #[serde(with = "serde_bytes")] - blocks: Vec, - ops: Vec, - blobs: Vec, - time: String, -} - -#[derive(Debug, Deserialize)] -struct RepoOp { - action: String, - path: String, - cid: Option, -} - -fn find_cbor_map_end(bytes: &[u8]) -> Result { - let mut pos = 0; - fn read_uint(bytes: &[u8], pos: &mut usize, additional: u8) -> Result { - match additional { - 0..=23 => Ok(additional as u64), - 24 => { - if *pos >= bytes.len() { return Err("Unexpected end".into()); } - let val = bytes[*pos] as u64; - *pos += 1; - Ok(val) - } - 25 => { - if *pos + 2 > bytes.len() { return Err("Unexpected end".into()); } - let val = u16::from_be_bytes([bytes[*pos], bytes[*pos + 1]]) as u64; - *pos += 2; - Ok(val) - } - 26 => { - if *pos + 4 > bytes.len() { return Err("Unexpected end".into()); } - let val = u32::from_be_bytes([bytes[*pos], bytes[*pos + 1], bytes[*pos + 2], bytes[*pos + 3]]) as u64; - *pos += 4; - Ok(val) - } - 27 => { - if *pos + 8 > bytes.len() { return Err("Unexpected end".into()); } - let val = u64::from_be_bytes([bytes[*pos], bytes[*pos + 1], bytes[*pos + 2], bytes[*pos + 3], bytes[*pos + 4], bytes[*pos + 5], bytes[*pos + 6], bytes[*pos + 7]]); - *pos += 8; - Ok(val) - } - _ => Err(format!("Invalid additional info: {}", additional)), - } - } - fn skip_value(bytes: &[u8], pos: &mut usize) -> Result<(), String> { - if *pos >= bytes.len() { return Err("Unexpected end".into()); } - let initial = bytes[*pos]; - *pos += 1; - let major = initial >> 5; - let additional = initial & 0x1f; - match major { - 0 | 1 => { read_uint(bytes, pos, additional)?; Ok(()) } - 2 | 3 => { - let len = read_uint(bytes, pos, additional)? as usize; - *pos += len; - Ok(()) - } - 4 => { - let len = read_uint(bytes, pos, additional)?; - for _ in 0..len { skip_value(bytes, pos)?; } - Ok(()) - } - 5 => { - let len = read_uint(bytes, pos, additional)?; - for _ in 0..len { - skip_value(bytes, pos)?; - skip_value(bytes, pos)?; - } - Ok(()) - } - 6 => { - read_uint(bytes, pos, additional)?; - skip_value(bytes, pos) - } - 7 => Ok(()), - _ => Err(format!("Unknown major type: {}", major)), - } - } - skip_value(bytes, &mut pos)?; - Ok(pos) -} - -fn parse_frame(bytes: &[u8]) -> Result<(FrameHeader, CommitFrame), String> { - let header_len = find_cbor_map_end(bytes)?; - let header: FrameHeader = serde_ipld_dagcbor::from_slice(&bytes[..header_len]) - .map_err(|e| format!("Failed to parse header: {:?}", e))?; - let remaining = &bytes[header_len..]; - let frame: CommitFrame = serde_ipld_dagcbor::from_slice(remaining) - .map_err(|e| format!("Failed to parse commit frame: {:?}", e))?; - Ok((header, frame)) -} - -#[tokio::test] -async fn test_firehose_subscription() { - let client = client(); - let (token, did) = create_account_and_login(&client).await; - let url = format!( - "ws://127.0.0.1:{}/xrpc/com.atproto.sync.subscribeRepos", - app_port() - ); - let (mut ws_stream, _) = connect_async(&url).await.expect("Failed to connect"); - let post_text = "Hello from the firehose test!"; - let post_payload = json!({ - "repo": did, - "collection": "app.bsky.feed.post", - "record": { - "$type": "app.bsky.feed.post", - "text": post_text, - "createdAt": chrono::Utc::now().to_rfc3339(), - } - }); - let res = client - .post(format!( - "{}/xrpc/com.atproto.repo.createRecord", - base_url().await - )) - .bearer_auth(token) - .json(&post_payload) - .send() - .await - .expect("Failed to create post"); - assert_eq!(res.status(), StatusCode::OK); - let mut frame_opt: Option<(FrameHeader, CommitFrame)> = None; - let timeout = tokio::time::timeout(std::time::Duration::from_secs(5), async { - loop { - let msg = ws_stream.next().await.unwrap().unwrap(); - let raw_bytes = match msg { - tungstenite::Message::Binary(bin) => bin, - _ => continue, - }; - if let Ok((h, f)) = parse_frame(&raw_bytes) { - if f.repo == did { - frame_opt = Some((h, f)); - break; - } - } - } - }) - .await; - assert!(timeout.is_ok(), "Timed out waiting for event for our DID"); - let (header, commit) = frame_opt.expect("No matching frame found"); - assert_eq!(header.op, 1); - assert_eq!(header.t, "#commit"); - assert_eq!(commit.ops.len(), 1); - assert!(!commit.blocks.is_empty()); - let op = &commit.ops[0]; - let record_cid = op.cid.clone().expect("Op should have CID"); - let mut car_reader = CarReader::new(Cursor::new(&commit.blocks)).await.unwrap(); - let mut record_block: Option> = None; - while let Ok(Some((cid, block))) = car_reader.next_block().await { - if cid == record_cid { - record_block = Some(block); - break; - } - } - let record_block = record_block.expect("Record block not found in CAR"); - let record: Value = serde_ipld_dagcbor::from_slice(&record_block).unwrap(); - assert_eq!(record["text"], post_text); - ws_stream - .send(tungstenite::Message::Close(None)) - .await - .ok(); -} diff --git a/tests/firehose_validation.rs b/tests/firehose_validation.rs index 0627bac..8a4ae75 100644 --- a/tests/firehose_validation.rs +++ b/tests/firehose_validation.rs @@ -1,12 +1,12 @@ mod common; -use common::*; use cid::Cid; -use futures::{stream::StreamExt, SinkExt}; +use common::*; +use futures::{SinkExt, stream::StreamExt}; use iroh_car::CarReader; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use std::io::Cursor; use tokio_tungstenite::{connect_async, tungstenite}; @@ -52,26 +52,48 @@ fn find_cbor_map_end(bytes: &[u8]) -> Result { match additional { 0..=23 => Ok(additional as u64), 24 => { - if *pos >= bytes.len() { return Err("Unexpected end".into()); } + if *pos >= bytes.len() { + return Err("Unexpected end".into()); + } let val = bytes[*pos] as u64; *pos += 1; Ok(val) } 25 => { - if *pos + 2 > bytes.len() { return Err("Unexpected end".into()); } + if *pos + 2 > bytes.len() { + return Err("Unexpected end".into()); + } let val = u16::from_be_bytes([bytes[*pos], bytes[*pos + 1]]) as u64; *pos += 2; Ok(val) } 26 => { - if *pos + 4 > bytes.len() { return Err("Unexpected end".into()); } - let val = u32::from_be_bytes([bytes[*pos], bytes[*pos + 1], bytes[*pos + 2], bytes[*pos + 3]]) as u64; + if *pos + 4 > bytes.len() { + return Err("Unexpected end".into()); + } + let val = u32::from_be_bytes([ + bytes[*pos], + bytes[*pos + 1], + bytes[*pos + 2], + bytes[*pos + 3], + ]) as u64; *pos += 4; Ok(val) } 27 => { - if *pos + 8 > bytes.len() { return Err("Unexpected end".into()); } - let val = u64::from_be_bytes([bytes[*pos], bytes[*pos + 1], bytes[*pos + 2], bytes[*pos + 3], bytes[*pos + 4], bytes[*pos + 5], bytes[*pos + 6], bytes[*pos + 7]]); + if *pos + 8 > bytes.len() { + return Err("Unexpected end".into()); + } + let val = u64::from_be_bytes([ + bytes[*pos], + bytes[*pos + 1], + bytes[*pos + 2], + bytes[*pos + 3], + bytes[*pos + 4], + bytes[*pos + 5], + bytes[*pos + 6], + bytes[*pos + 7], + ]); *pos += 8; Ok(val) } @@ -80,14 +102,19 @@ fn find_cbor_map_end(bytes: &[u8]) -> Result { } fn skip_value(bytes: &[u8], pos: &mut usize) -> Result<(), String> { - if *pos >= bytes.len() { return Err("Unexpected end".into()); } + if *pos >= bytes.len() { + return Err("Unexpected end".into()); + } let initial = bytes[*pos]; *pos += 1; let major = initial >> 5; let additional = initial & 0x1f; match major { - 0 | 1 => { read_uint(bytes, pos, additional)?; Ok(()) } + 0 | 1 => { + read_uint(bytes, pos, additional)?; + Ok(()) + } 2 | 3 => { let len = read_uint(bytes, pos, additional)? as usize; *pos += len; @@ -95,7 +122,9 @@ fn find_cbor_map_end(bytes: &[u8]) -> Result { } 4 => { let len = read_uint(bytes, pos, additional)?; - for _ in 0..len { skip_value(bytes, pos)?; } + for _ in 0..len { + skip_value(bytes, pos)?; + } Ok(()) } 5 => { @@ -228,18 +257,36 @@ async fn test_firehose_frame_structure() { println!(" tooBig: {}", frame.too_big); println!(" repo: {}", frame.repo); println!(" commit: {}", frame.commit); - println!(" rev: {} (valid TID: {})", frame.rev, is_valid_tid(&frame.rev)); + println!( + " rev: {} (valid TID: {})", + frame.rev, + is_valid_tid(&frame.rev) + ); println!(" since: {:?}", frame.since); println!(" blocks length: {} bytes", frame.blocks.len()); println!(" ops count: {}", frame.ops.len()); println!(" blobs count: {}", frame.blobs.len()); - println!(" time: {} (valid format: {})", frame.time, is_valid_time_format(&frame.time)); - println!(" prevData: {:?} (IMPORTANT - should have value for updates)", frame.prev_data); + println!( + " time: {} (valid format: {})", + frame.time, + is_valid_time_format(&frame.time) + ); + println!( + " prevData: {:?} (IMPORTANT - should have value for updates)", + frame.prev_data + ); assert_eq!(frame.repo, did, "Frame repo should match DID"); - assert!(is_valid_tid(&frame.rev), "Rev should be valid TID format, got: {}", frame.rev); + assert!( + is_valid_tid(&frame.rev), + "Rev should be valid TID format, got: {}", + frame.rev + ); assert!(!frame.blocks.is_empty(), "Blocks should not be empty"); - assert!(is_valid_time_format(&frame.time), "Time should be ISO 8601 with milliseconds and Z suffix"); + assert!( + is_valid_time_format(&frame.time), + "Time should be ISO 8601 with milliseconds and Z suffix" + ); println!("\nOps validation:"); for (i, op) in frame.ops.iter().enumerate() { @@ -247,13 +294,21 @@ async fn test_firehose_frame_structure() { println!(" action: {}", op.action); println!(" path: {}", op.path); println!(" cid: {:?}", op.cid); - println!(" prev: {:?} (should be Some for updates/deletes)", op.prev); + println!( + " prev: {:?} (should be Some for updates/deletes)", + op.prev + ); assert!( ["create", "update", "delete"].contains(&op.action.as_str()), - "Invalid action: {}", op.action + "Invalid action: {}", + op.action + ); + assert!( + op.path.contains('/'), + "Path should contain collection/rkey: {}", + op.path ); - assert!(op.path.contains('/'), "Path should contain collection/rkey: {}", op.path); if op.action == "create" { assert!(op.cid.is_some(), "Create op should have cid"); @@ -270,7 +325,8 @@ async fn test_firehose_frame_structure() { "CAR should have at least one root" ); assert_eq!( - car_header.roots()[0], frame.commit, + car_header.roots()[0], + frame.commit, "First CAR root should be commit CID" ); @@ -292,17 +348,15 @@ async fn test_firehose_frame_structure() { if let Some(ref cid) = op.cid { assert!( block_cids.contains(cid), - "CAR should contain op's record block: {}", cid + "CAR should contain op's record block: {}", + cid ); } } println!("\n=== Validation Complete ===\n"); - ws_stream - .send(tungstenite::Message::Close(None)) - .await - .ok(); + ws_stream.send(tungstenite::Message::Close(None)).await.ok(); } #[tokio::test] @@ -402,8 +456,10 @@ async fn test_firehose_update_has_prev_field() { println!("Frame prevData: {:?}", frame.prev_data); for op in &frame.ops { - println!("Op: action={}, path={}, cid={:?}, prev={:?}", - op.action, op.path, op.cid, op.prev); + println!( + "Op: action={}, path={}, cid={:?}, prev={:?}", + op.action, op.path, op.cid, op.prev + ); if op.action == "update" && op.path.contains("app.bsky.actor.profile") { assert!( @@ -417,10 +473,7 @@ async fn test_firehose_update_has_prev_field() { println!("\n=== Validation Complete ===\n"); - ws_stream - .send(tungstenite::Message::Close(None)) - .await - .ok(); + ws_stream.send(tungstenite::Message::Close(None)).await.ok(); } #[tokio::test] @@ -475,8 +528,14 @@ async fn test_firehose_commit_has_prev_data() { let first_frame = first_frame_opt.expect("No first frame found"); println!("\n=== First Commit ==="); - println!(" prevData: {:?} (first commit may be None)", first_frame.prev_data); - println!(" since: {:?} (first commit should be None)", first_frame.since); + println!( + " prevData: {:?} (first commit may be None)", + first_frame.prev_data + ); + println!( + " since: {:?} (first commit should be None)", + first_frame.since + ); let post_payload2 = json!({ "repo": did, @@ -519,8 +578,14 @@ async fn test_firehose_commit_has_prev_data() { let second_frame = second_frame_opt.expect("No second frame found"); println!("\n=== Second Commit ==="); - println!(" prevData: {:?} (should have value - MST root CID)", second_frame.prev_data); - println!(" since: {:?} (should have value - previous rev)", second_frame.since); + println!( + " prevData: {:?} (should have value - MST root CID)", + second_frame.prev_data + ); + println!( + " since: {:?} (should have value - previous rev)", + second_frame.since + ); assert!( second_frame.since.is_some(), @@ -529,10 +594,7 @@ async fn test_firehose_commit_has_prev_data() { println!("\n=== Validation Complete ===\n"); - ws_stream - .send(tungstenite::Message::Close(None)) - .await - .ok(); + ws_stream.send(tungstenite::Message::Close(None)).await.ok(); } #[tokio::test] @@ -590,10 +652,17 @@ async fn test_compare_raw_cbor_encoding() { println!("Total frame size: {} bytes", raw_bytes.len()); fn bytes_to_hex(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{:02x}", b)).collect::>().join("") + bytes + .iter() + .map(|b| format!("{:02x}", b)) + .collect::>() + .join("") } - println!("First 64 bytes (hex): {}", bytes_to_hex(&raw_bytes[..64.min(raw_bytes.len())])); + println!( + "First 64 bytes (hex): {}", + bytes_to_hex(&raw_bytes[..64.min(raw_bytes.len())]) + ); let header_end = find_cbor_map_end(&raw_bytes).expect("Failed to find header end"); @@ -604,8 +673,5 @@ async fn test_compare_raw_cbor_encoding() { println!("\n=== Analysis Complete ===\n"); - ws_stream - .send(tungstenite::Message::Close(None)) - .await - .ok(); + ws_stream.send(tungstenite::Message::Close(None)).await.ok(); } diff --git a/tests/identity.rs b/tests/identity.rs index a110652..ff6ac1c 100644 --- a/tests/identity.rs +++ b/tests/identity.rs @@ -301,7 +301,10 @@ async fn test_get_recommended_did_credentials_success() { assert!(!also_known_as.is_empty()); assert!(also_known_as[0].as_str().unwrap().starts_with("at://")); assert!(body["verificationMethods"]["atproto"].is_string()); - assert_eq!(body["services"]["atprotoPds"]["type"], "AtprotoPersonalDataServer"); + assert_eq!( + body["services"]["atprotoPds"]["type"], + "AtprotoPersonalDataServer" + ); assert!(body["services"]["atprotoPds"]["endpoint"].is_string()); } diff --git a/tests/image_processing.rs b/tests/image_processing.rs index 1b56b7f..5f7011f 100644 --- a/tests/image_processing.rs +++ b/tests/image_processing.rs @@ -1,32 +1,39 @@ -use bspds::image::{ImageProcessor, ImageError, OutputFormat, THUMB_SIZE_FEED, THUMB_SIZE_FULL, DEFAULT_MAX_FILE_SIZE}; +use bspds::image::{ + DEFAULT_MAX_FILE_SIZE, ImageError, ImageProcessor, OutputFormat, THUMB_SIZE_FEED, + THUMB_SIZE_FULL, +}; use image::{DynamicImage, ImageFormat}; use std::io::Cursor; fn create_test_png(width: u32, height: u32) -> Vec { let img = DynamicImage::new_rgb8(width, height); let mut buf = Vec::new(); - img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png).unwrap(); + img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png) + .unwrap(); buf } fn create_test_jpeg(width: u32, height: u32) -> Vec { let img = DynamicImage::new_rgb8(width, height); let mut buf = Vec::new(); - img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Jpeg).unwrap(); + img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Jpeg) + .unwrap(); buf } fn create_test_gif(width: u32, height: u32) -> Vec { let img = DynamicImage::new_rgb8(width, height); let mut buf = Vec::new(); - img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Gif).unwrap(); + img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Gif) + .unwrap(); buf } fn create_test_webp(width: u32, height: u32) -> Vec { let img = DynamicImage::new_rgb8(width, height); let mut buf = Vec::new(); - img.write_to(&mut Cursor::new(&mut buf), ImageFormat::WebP).unwrap(); + img.write_to(&mut Cursor::new(&mut buf), ImageFormat::WebP) + .unwrap(); buf } @@ -71,7 +78,9 @@ fn test_thumbnail_feed_size() { let processor = ImageProcessor::new(); let data = create_test_png(800, 600); let result = processor.process(&data, "image/png").unwrap(); - let thumb = result.thumbnail_feed.expect("Should generate feed thumbnail for large image"); + let thumb = result + .thumbnail_feed + .expect("Should generate feed thumbnail for large image"); assert!(thumb.width <= THUMB_SIZE_FEED); assert!(thumb.height <= THUMB_SIZE_FEED); } @@ -81,7 +90,9 @@ fn test_thumbnail_full_size() { let processor = ImageProcessor::new(); let data = create_test_png(2000, 1500); let result = processor.process(&data, "image/png").unwrap(); - let thumb = result.thumbnail_full.expect("Should generate full thumbnail for large image"); + let thumb = result + .thumbnail_full + .expect("Should generate full thumbnail for large image"); assert!(thumb.width <= THUMB_SIZE_FULL); assert!(thumb.height <= THUMB_SIZE_FULL); } @@ -91,8 +102,14 @@ fn test_no_thumbnail_small_image() { let processor = ImageProcessor::new(); let data = create_test_png(100, 100); let result = processor.process(&data, "image/png").unwrap(); - assert!(result.thumbnail_feed.is_none(), "Small image should not get feed thumbnail"); - assert!(result.thumbnail_full.is_none(), "Small image should not get full thumbnail"); + assert!( + result.thumbnail_feed.is_none(), + "Small image should not get feed thumbnail" + ); + assert!( + result.thumbnail_full.is_none(), + "Small image should not get full thumbnail" + ); } #[test] @@ -125,7 +142,12 @@ fn test_max_dimension_enforced() { let data = create_test_png(2000, 2000); let result = processor.process(&data, "image/png"); assert!(matches!(result, Err(ImageError::TooLarge { .. }))); - if let Err(ImageError::TooLarge { width, height, max_dimension }) = result { + if let Err(ImageError::TooLarge { + width, + height, + max_dimension, + }) = result + { assert_eq!(width, 2000); assert_eq!(height, 2000); assert_eq!(max_dimension, 1000); @@ -173,7 +195,10 @@ fn test_aspect_ratio_preserved_landscape() { let thumb = result.thumbnail_full.expect("Should have thumbnail"); let original_ratio = 1600.0 / 800.0; let thumb_ratio = thumb.width as f64 / thumb.height as f64; - assert!((original_ratio - thumb_ratio).abs() < 0.1, "Aspect ratio should be preserved"); + assert!( + (original_ratio - thumb_ratio).abs() < 0.1, + "Aspect ratio should be preserved" + ); } #[test] @@ -184,7 +209,10 @@ fn test_aspect_ratio_preserved_portrait() { let thumb = result.thumbnail_full.expect("Should have thumbnail"); let original_ratio = 800.0 / 1600.0; let thumb_ratio = thumb.width as f64 / thumb.height as f64; - assert!((original_ratio - thumb_ratio).abs() < 0.1, "Aspect ratio should be preserved"); + assert!( + (original_ratio - thumb_ratio).abs() < 0.1, + "Aspect ratio should be preserved" + ); } #[test] @@ -224,8 +252,14 @@ fn test_with_thumbnails_disabled() { let processor = ImageProcessor::new().with_thumbnails(false); let data = create_test_png(2000, 2000); let result = processor.process(&data, "image/png").unwrap(); - assert!(result.thumbnail_feed.is_none(), "Thumbnails should be disabled"); - assert!(result.thumbnail_full.is_none(), "Thumbnails should be disabled"); + assert!( + result.thumbnail_feed.is_none(), + "Thumbnails should be disabled" + ); + assert!( + result.thumbnail_full.is_none(), + "Thumbnails should be disabled" + ); } #[test] @@ -256,8 +290,14 @@ fn test_only_feed_thumbnail_for_medium_images() { let processor = ImageProcessor::new(); let data = create_test_png(500, 500); let result = processor.process(&data, "image/png").unwrap(); - assert!(result.thumbnail_feed.is_some(), "Should have feed thumbnail"); - assert!(result.thumbnail_full.is_none(), "Should NOT have full thumbnail for 500px image"); + assert!( + result.thumbnail_feed.is_some(), + "Should have feed thumbnail" + ); + assert!( + result.thumbnail_full.is_none(), + "Should NOT have full thumbnail for 500px image" + ); } #[test] @@ -265,8 +305,14 @@ fn test_both_thumbnails_for_large_images() { let processor = ImageProcessor::new(); let data = create_test_png(2000, 2000); let result = processor.process(&data, "image/png").unwrap(); - assert!(result.thumbnail_feed.is_some(), "Should have feed thumbnail"); - assert!(result.thumbnail_full.is_some(), "Should have full thumbnail for 2000px image"); + assert!( + result.thumbnail_feed.is_some(), + "Should have feed thumbnail" + ); + assert!( + result.thumbnail_full.is_some(), + "Should have full thumbnail for 2000px image" + ); } #[test] @@ -274,10 +320,16 @@ fn test_exact_threshold_boundary_feed() { let processor = ImageProcessor::new(); let at_threshold = create_test_png(THUMB_SIZE_FEED, THUMB_SIZE_FEED); let result = processor.process(&at_threshold, "image/png").unwrap(); - assert!(result.thumbnail_feed.is_none(), "Exact threshold should not generate thumbnail"); + assert!( + result.thumbnail_feed.is_none(), + "Exact threshold should not generate thumbnail" + ); let above_threshold = create_test_png(THUMB_SIZE_FEED + 1, THUMB_SIZE_FEED + 1); let result = processor.process(&above_threshold, "image/png").unwrap(); - assert!(result.thumbnail_feed.is_some(), "Above threshold should generate thumbnail"); + assert!( + result.thumbnail_feed.is_some(), + "Above threshold should generate thumbnail" + ); } #[test] @@ -285,8 +337,14 @@ fn test_exact_threshold_boundary_full() { let processor = ImageProcessor::new(); let at_threshold = create_test_png(THUMB_SIZE_FULL, THUMB_SIZE_FULL); let result = processor.process(&at_threshold, "image/png").unwrap(); - assert!(result.thumbnail_full.is_none(), "Exact threshold should not generate thumbnail"); + assert!( + result.thumbnail_full.is_none(), + "Exact threshold should not generate thumbnail" + ); let above_threshold = create_test_png(THUMB_SIZE_FULL + 1, THUMB_SIZE_FULL + 1); let result = processor.process(&above_threshold, "image/png").unwrap(); - assert!(result.thumbnail_full.is_some(), "Above threshold should generate thumbnail"); + assert!( + result.thumbnail_full.is_some(), + "Above threshold should generate thumbnail" + ); } diff --git a/tests/import_verification.rs b/tests/import_verification.rs index d5816a5..13cf6f0 100644 --- a/tests/import_verification.rs +++ b/tests/import_verification.rs @@ -8,7 +8,10 @@ use serde_json::json; async fn test_import_repo_requires_auth() { let client = client(); let res = client - .post(format!("{}/xrpc/com.atproto.repo.importRepo", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.importRepo", + base_url().await + )) .header("Content-Type", "application/vnd.ipld.car") .body(vec![0u8; 100]) .send() @@ -22,7 +25,10 @@ async fn test_import_repo_invalid_car() { let client = client(); let (token, _did) = create_account_and_login(&client).await; let res = client - .post(format!("{}/xrpc/com.atproto.repo.importRepo", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.importRepo", + base_url().await + )) .bearer_auth(&token) .header("Content-Type", "application/vnd.ipld.car") .body(vec![0u8; 100]) @@ -39,7 +45,10 @@ async fn test_import_repo_empty_body() { let client = client(); let (token, _did) = create_account_and_login(&client).await; let res = client - .post(format!("{}/xrpc/com.atproto.repo.importRepo", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.importRepo", + base_url().await + )) .bearer_auth(&token) .header("Content-Type", "application/vnd.ipld.car") .body(vec![]) @@ -80,7 +89,10 @@ async fn test_import_rejects_car_for_different_user() { assert_eq!(export_res.status(), StatusCode::OK); let car_bytes = export_res.bytes().await.unwrap(); let import_res = client - .post(format!("{}/xrpc/com.atproto.repo.importRepo", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.importRepo", + base_url().await + )) .bearer_auth(&token_a) .header("Content-Type", "application/vnd.ipld.car") .body(car_bytes.to_vec()) @@ -132,7 +144,10 @@ async fn test_import_accepts_own_exported_repo() { assert_eq!(export_res.status(), StatusCode::OK); let car_bytes = export_res.bytes().await.unwrap(); let import_res = client - .post(format!("{}/xrpc/com.atproto.repo.importRepo", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.importRepo", + base_url().await + )) .bearer_auth(&token) .header("Content-Type", "application/vnd.ipld.car") .body(car_bytes.to_vec()) @@ -148,7 +163,10 @@ async fn test_import_repo_size_limit() { let (token, _did) = create_account_and_login(&client).await; let oversized_body = vec![0u8; 110 * 1024 * 1024]; let res = client - .post(format!("{}/xrpc/com.atproto.repo.importRepo", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.importRepo", + base_url().await + )) .bearer_auth(&token) .header("Content-Type", "application/vnd.ipld.car") .body(oversized_body) @@ -161,11 +179,11 @@ async fn test_import_repo_size_limit() { Err(e) => { let error_str = e.to_string().to_lowercase(); assert!( - error_str.contains("broken pipe") || - error_str.contains("connection") || - error_str.contains("reset") || - error_str.contains("request") || - error_str.contains("body"), + error_str.contains("broken pipe") + || error_str.contains("connection") + || error_str.contains("reset") + || error_str.contains("request") + || error_str.contains("body"), "Expected connection error or PAYLOAD_TOO_LARGE, got: {}", e ); @@ -200,7 +218,10 @@ async fn test_import_deactivated_account_rejected() { .expect("Deactivate failed"); assert!(deactivate_res.status().is_success()); let import_res = client - .post(format!("{}/xrpc/com.atproto.repo.importRepo", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.importRepo", + base_url().await + )) .bearer_auth(&token) .header("Content-Type", "application/vnd.ipld.car") .body(car_bytes.to_vec()) @@ -208,7 +229,8 @@ async fn test_import_deactivated_account_rejected() { .await .expect("Import failed"); assert!( - import_res.status() == StatusCode::FORBIDDEN || import_res.status() == StatusCode::UNAUTHORIZED, + import_res.status() == StatusCode::FORBIDDEN + || import_res.status() == StatusCode::UNAUTHORIZED, "Expected FORBIDDEN (403) or UNAUTHORIZED (401), got {}", import_res.status() ); @@ -220,7 +242,10 @@ async fn test_import_invalid_car_structure() { let (token, _did) = create_account_and_login(&client).await; let invalid_car = vec![0x0a, 0xa1, 0x65, 0x72, 0x6f, 0x6f, 0x74, 0x73, 0x80]; let res = client - .post(format!("{}/xrpc/com.atproto.repo.importRepo", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.importRepo", + base_url().await + )) .bearer_auth(&token) .header("Content-Type", "application/vnd.ipld.car") .body(invalid_car) @@ -240,7 +265,10 @@ async fn test_import_car_with_no_roots() { write_varint(&mut car, header_cbor.len() as u64); car.extend_from_slice(&header_cbor); let res = client - .post(format!("{}/xrpc/com.atproto.repo.importRepo", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.importRepo", + base_url().await + )) .bearer_auth(&token) .header("Content-Type", "application/vnd.ipld.car") .body(car) @@ -294,7 +322,12 @@ async fn test_import_preserves_records_after_reimport() { .send() .await .expect("Failed to get record before export"); - assert_eq!(get_res.status(), StatusCode::OK, "Record {} not found before export", rkey); + assert_eq!( + get_res.status(), + StatusCode::OK, + "Record {} not found before export", + rkey + ); } let export_res = client .get(format!( @@ -308,7 +341,10 @@ async fn test_import_preserves_records_after_reimport() { assert_eq!(export_res.status(), StatusCode::OK); let car_bytes = export_res.bytes().await.unwrap(); let import_res = client - .post(format!("{}/xrpc/com.atproto.repo.importRepo", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.importRepo", + base_url().await + )) .bearer_auth(&token) .header("Content-Type", "application/vnd.ipld.car") .body(car_bytes.to_vec()) @@ -327,7 +363,10 @@ async fn test_import_preserves_records_after_reimport() { .expect("Failed to list records after import"); assert_eq!(list_res.status(), StatusCode::OK); let list_body: serde_json::Value = list_res.json().await.unwrap(); - let records_after = list_body["records"].as_array().map(|a| a.len()).unwrap_or(0); + let records_after = list_body["records"] + .as_array() + .map(|a| a.len()) + .unwrap_or(0); assert!( records_after >= 1, "Expected at least 1 record after import, found {}. Note: MST walk may have timing issues.", diff --git a/tests/import_with_verification.rs b/tests/import_with_verification.rs index 83cbedd..a236e7f 100644 --- a/tests/import_with_verification.rs +++ b/tests/import_with_verification.rs @@ -1,9 +1,9 @@ mod common; -use common::*; use cid::Cid; +use common::*; use ipld_core::ipld::Ipld; use jacquard::types::{integer::LimitedU32, string::Tid}; -use k256::ecdsa::{signature::Signer, Signature, SigningKey}; +use k256::ecdsa::{Signature, SigningKey, signature::Signer}; use reqwest::StatusCode; use serde_json::json; use sha2::{Digest, Sha256}; @@ -60,7 +60,12 @@ fn get_multikey_from_signing_key(signing_key: &SigningKey) -> String { multibase::encode(multibase::Base::Base58Btc, buf) } -fn create_did_document(did: &str, handle: &str, signing_key: &SigningKey, pds_endpoint: &str) -> serde_json::Value { +fn create_did_document( + did: &str, + handle: &str, + signing_key: &SigningKey, + pds_endpoint: &str, +) -> serde_json::Value { let multikey = get_multikey_from_signing_key(signing_key); json!({ "@context": [ @@ -83,11 +88,7 @@ fn create_did_document(did: &str, handle: &str, signing_key: &SigningKey, pds_en }) } -fn create_signed_commit( - did: &str, - data_cid: &Cid, - signing_key: &SigningKey, -) -> (Vec, Cid) { +fn create_signed_commit(did: &str, data_cid: &Cid, signing_key: &SigningKey) -> (Vec, Cid) { let rev = Tid::now(LimitedU32::MIN).to_string(); let unsigned = Ipld::Map(BTreeMap::from([ ("data".to_string(), Ipld::Link(*data_cid)), @@ -124,9 +125,10 @@ fn create_mst_node(entries: Vec<(String, Cid)>) -> (Vec, Cid) { ])) }) .collect(); - let node = Ipld::Map(BTreeMap::from([ - ("e".to_string(), Ipld::List(ipld_entries)), - ])); + let node = Ipld::Map(BTreeMap::from([( + "e".to_string(), + Ipld::List(ipld_entries), + )])); let bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); let cid = make_cid(&bytes); (bytes, cid) @@ -134,22 +136,27 @@ fn create_mst_node(entries: Vec<(String, Cid)>) -> (Vec, Cid) { fn create_record() -> (Vec, Cid) { let record = Ipld::Map(BTreeMap::from([ - ("$type".to_string(), Ipld::String("app.bsky.feed.post".to_string())), - ("text".to_string(), Ipld::String("Test post for verification".to_string())), - ("createdAt".to_string(), Ipld::String("2024-01-01T00:00:00Z".to_string())), + ( + "$type".to_string(), + Ipld::String("app.bsky.feed.post".to_string()), + ), + ( + "text".to_string(), + Ipld::String("Test post for verification".to_string()), + ), + ( + "createdAt".to_string(), + Ipld::String("2024-01-01T00:00:00Z".to_string()), + ), ])); let bytes = serde_ipld_dagcbor::to_vec(&record).unwrap(); let cid = make_cid(&bytes); (bytes, cid) } -fn build_car_with_signature( - did: &str, - signing_key: &SigningKey, -) -> (Vec, Cid) { +fn build_car_with_signature(did: &str, signing_key: &SigningKey) -> (Vec, Cid) { let (record_bytes, record_cid) = create_record(); - let (mst_bytes, mst_cid) = create_mst_node(vec![ - ("app.bsky.feed.post/test123".to_string(), record_cid), - ]); + let (mst_bytes, mst_cid) = + create_mst_node(vec![("app.bsky.feed.post/test123".to_string(), record_cid)]); let (commit_bytes, commit_cid) = create_signed_commit(did, &mst_cid, signing_key); let header = iroh_car::CarHeader::new_v1(vec![commit_cid]); let header_bytes = header.encode().unwrap(); @@ -194,10 +201,10 @@ async fn get_user_signing_key(did: &str) -> Option> { async fn test_import_with_valid_signature_and_mock_plc() { let client = client(); let (token, did) = create_account_and_login(&client).await; - let key_bytes = get_user_signing_key(&did).await + let key_bytes = get_user_signing_key(&did) + .await .expect("Failed to get user signing key"); - let signing_key = SigningKey::from_slice(&key_bytes) - .expect("Failed to create signing key"); + let signing_key = SigningKey::from_slice(&key_bytes).expect("Failed to create signing key"); let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let pds_endpoint = format!("https://{}", hostname); let handle = did.split(':').last().unwrap_or("user"); @@ -209,7 +216,10 @@ async fn test_import_with_valid_signature_and_mock_plc() { } let (car_bytes, _root_cid) = build_car_with_signature(&did, &signing_key); let import_res = client - .post(format!("{}/xrpc/com.atproto.repo.importRepo", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.importRepo", + base_url().await + )) .bearer_auth(&token) .header("Content-Type", "application/vnd.ipld.car") .body(car_bytes) @@ -234,10 +244,11 @@ async fn test_import_with_wrong_signing_key_fails() { let client = client(); let (token, did) = create_account_and_login(&client).await; let wrong_signing_key = SigningKey::random(&mut rand::thread_rng()); - let key_bytes = get_user_signing_key(&did).await + let key_bytes = get_user_signing_key(&did) + .await .expect("Failed to get user signing key"); - let correct_signing_key = SigningKey::from_slice(&key_bytes) - .expect("Failed to create signing key"); + let correct_signing_key = + SigningKey::from_slice(&key_bytes).expect("Failed to create signing key"); let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let pds_endpoint = format!("https://{}", hostname); let handle = did.split(':').last().unwrap_or("user"); @@ -249,7 +260,10 @@ async fn test_import_with_wrong_signing_key_fails() { } let (car_bytes, _root_cid) = build_car_with_signature(&did, &wrong_signing_key); let import_res = client - .post(format!("{}/xrpc/com.atproto.repo.importRepo", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.importRepo", + base_url().await + )) .bearer_auth(&token) .header("Content-Type", "application/vnd.ipld.car") .body(car_bytes) @@ -268,7 +282,8 @@ async fn test_import_with_wrong_signing_key_fails() { body ); assert!( - body["error"] == "InvalidSignature" || body["message"].as_str().unwrap_or("").contains("signature"), + body["error"] == "InvalidSignature" + || body["message"].as_str().unwrap_or("").contains("signature"), "Error should mention signature: {:?}", body ); @@ -278,10 +293,10 @@ async fn test_import_with_wrong_signing_key_fails() { async fn test_import_with_did_mismatch_fails() { let client = client(); let (token, did) = create_account_and_login(&client).await; - let key_bytes = get_user_signing_key(&did).await + let key_bytes = get_user_signing_key(&did) + .await .expect("Failed to get user signing key"); - let signing_key = SigningKey::from_slice(&key_bytes) - .expect("Failed to create signing key"); + let signing_key = SigningKey::from_slice(&key_bytes).expect("Failed to create signing key"); let wrong_did = "did:plc:wrongdidthatdoesnotmatch"; let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let pds_endpoint = format!("https://{}", hostname); @@ -294,7 +309,10 @@ async fn test_import_with_did_mismatch_fails() { } let (car_bytes, _root_cid) = build_car_with_signature(wrong_did, &signing_key); let import_res = client - .post(format!("{}/xrpc/com.atproto.repo.importRepo", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.importRepo", + base_url().await + )) .bearer_auth(&token) .header("Content-Type", "application/vnd.ipld.car") .body(car_bytes) @@ -318,10 +336,10 @@ async fn test_import_with_did_mismatch_fails() { async fn test_import_with_plc_resolution_failure() { let client = client(); let (token, did) = create_account_and_login(&client).await; - let key_bytes = get_user_signing_key(&did).await + let key_bytes = get_user_signing_key(&did) + .await .expect("Failed to get user signing key"); - let signing_key = SigningKey::from_slice(&key_bytes) - .expect("Failed to create signing key"); + let signing_key = SigningKey::from_slice(&key_bytes).expect("Failed to create signing key"); let mock_plc = MockServer::start().await; let did_encoded = urlencoding::encode(&did); let did_path = format!("/{}", did_encoded); @@ -336,7 +354,10 @@ async fn test_import_with_plc_resolution_failure() { } let (car_bytes, _root_cid) = build_car_with_signature(&did, &signing_key); let import_res = client - .post(format!("{}/xrpc/com.atproto.repo.importRepo", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.importRepo", + base_url().await + )) .bearer_auth(&token) .header("Content-Type", "application/vnd.ipld.car") .body(car_bytes) @@ -360,10 +381,10 @@ async fn test_import_with_plc_resolution_failure() { async fn test_import_with_no_signing_key_in_did_doc() { let client = client(); let (token, did) = create_account_and_login(&client).await; - let key_bytes = get_user_signing_key(&did).await + let key_bytes = get_user_signing_key(&did) + .await .expect("Failed to get user signing key"); - let signing_key = SigningKey::from_slice(&key_bytes) - .expect("Failed to create signing key"); + let signing_key = SigningKey::from_slice(&key_bytes).expect("Failed to create signing key"); let handle = did.split(':').last().unwrap_or("user"); let did_doc_without_key = json!({ "@context": ["https://www.w3.org/ns/did/v1"], @@ -379,7 +400,10 @@ async fn test_import_with_no_signing_key_in_did_doc() { } let (car_bytes, _root_cid) = build_car_with_signature(&did, &signing_key); let import_res = client - .post(format!("{}/xrpc/com.atproto.repo.importRepo", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.importRepo", + base_url().await + )) .bearer_auth(&token) .header("Content-Type", "application/vnd.ipld.car") .body(car_bytes) diff --git a/tests/jwt_security.rs b/tests/jwt_security.rs index 63e1258..0ff6063 100644 --- a/tests/jwt_security.rs +++ b/tests/jwt_security.rs @@ -2,18 +2,18 @@ mod common; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use bspds::auth::{ - self, create_access_token, create_refresh_token, create_service_token, - verify_access_token, verify_refresh_token, verify_token, get_did_from_token, get_jti_from_token, - TOKEN_TYPE_ACCESS, TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, - SCOPE_ACCESS, SCOPE_REFRESH, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, + self, SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH, + TOKEN_TYPE_ACCESS, TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, create_access_token, + create_refresh_token, create_service_token, get_did_from_token, get_jti_from_token, + verify_access_token, verify_refresh_token, verify_token, }; use chrono::{Duration, Utc}; use common::{base_url, client, create_account_and_login, get_db_connection_string}; use k256::SecretKey; -use k256::ecdsa::{SigningKey, Signature, signature::Signer}; +use k256::ecdsa::{Signature, SigningKey, signature::Signer}; use rand::rngs::OsRng; use reqwest::StatusCode; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use sha2::{Digest, Sha256}; fn generate_user_key() -> Vec { @@ -48,7 +48,11 @@ fn test_jwt_security_forged_signature_rejected() { let result = verify_access_token(&forged_token, &key_bytes); assert!(result.is_err(), "Forged signature must be rejected"); let err_msg = result.err().unwrap().to_string(); - assert!(err_msg.contains("signature") || err_msg.contains("Signature"), "Error should mention signature: {}", err_msg); + assert!( + err_msg.contains("signature") || err_msg.contains("Signature"), + "Error should mention signature: {}", + err_msg + ); } #[test] @@ -116,7 +120,10 @@ fn test_jwt_security_algorithm_substitution_hs256_rejected() { let signature_b64 = URL_SAFE_NO_PAD.encode(&hmac_sig); let malicious_token = format!("{}.{}", message, signature_b64); let result = verify_access_token(&malicious_token, &key_bytes); - assert!(result.is_err(), "HS256 algorithm substitution must be rejected"); + assert!( + result.is_err(), + "HS256 algorithm substitution must be rejected" + ); } #[test] @@ -141,7 +148,10 @@ fn test_jwt_security_algorithm_substitution_rs256_rejected() { let fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 256]); let malicious_token = format!("{}.{}.{}", header_b64, claims_b64, fake_sig); let result = verify_access_token(&malicious_token, &key_bytes); - assert!(result.is_err(), "RS256 algorithm substitution must be rejected"); + assert!( + result.is_err(), + "RS256 algorithm substitution must be rejected" + ); } #[test] @@ -166,7 +176,10 @@ fn test_jwt_security_algorithm_substitution_es256_rejected() { let fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 64]); let malicious_token = format!("{}.{}.{}", header_b64, claims_b64, fake_sig); let result = verify_access_token(&malicious_token, &key_bytes); - assert!(result.is_err(), "ES256 (P-256) algorithm substitution must be rejected (we use ES256K/secp256k1)"); + assert!( + result.is_err(), + "ES256 (P-256) algorithm substitution must be rejected (we use ES256K/secp256k1)" + ); } #[test] @@ -175,7 +188,10 @@ fn test_jwt_security_token_type_confusion_refresh_as_access() { let did = "did:plc:test"; let refresh_token = create_refresh_token(did, &key_bytes).expect("create refresh token"); let result = verify_access_token(&refresh_token, &key_bytes); - assert!(result.is_err(), "Refresh token must not be accepted as access token"); + assert!( + result.is_err(), + "Refresh token must not be accepted as access token" + ); let err_msg = result.err().unwrap().to_string(); assert!(err_msg.contains("Invalid token type"), "Error: {}", err_msg); } @@ -186,7 +202,10 @@ fn test_jwt_security_token_type_confusion_access_as_refresh() { let did = "did:plc:test"; let access_token = create_access_token(did, &key_bytes).expect("create access token"); let result = verify_refresh_token(&access_token, &key_bytes); - assert!(result.is_err(), "Access token must not be accepted as refresh token"); + assert!( + result.is_err(), + "Access token must not be accepted as refresh token" + ); let err_msg = result.err().unwrap().to_string(); assert!(err_msg.contains("Invalid token type"), "Error: {}", err_msg); } @@ -195,10 +214,14 @@ fn test_jwt_security_token_type_confusion_access_as_refresh() { fn test_jwt_security_token_type_confusion_service_as_access() { let key_bytes = generate_user_key(); let did = "did:plc:test"; - let service_token = create_service_token(did, "did:web:target", "com.example.method", &key_bytes) - .expect("create service token"); + let service_token = + create_service_token(did, "did:web:target", "com.example.method", &key_bytes) + .expect("create service token"); let result = verify_access_token(&service_token, &key_bytes); - assert!(result.is_err(), "Service token must not be accepted as access token"); + assert!( + result.is_err(), + "Service token must not be accepted as access token" + ); } #[test] @@ -222,7 +245,11 @@ fn test_jwt_security_scope_manipulation_attack() { let result = verify_access_token(&malicious_token, &key_bytes); assert!(result.is_err(), "Invalid scope must be rejected"); let err_msg = result.err().unwrap().to_string(); - assert!(err_msg.contains("Invalid token scope"), "Error: {}", err_msg); + assert!( + err_msg.contains("Invalid token scope"), + "Error: {}", + err_msg + ); } #[test] @@ -244,7 +271,10 @@ fn test_jwt_security_empty_scope_rejected() { }); let token = create_custom_jwt(&header, &claims, &key_bytes); let result = verify_access_token(&token, &key_bytes); - assert!(result.is_err(), "Empty scope must be rejected for access tokens"); + assert!( + result.is_err(), + "Empty scope must be rejected for access tokens" + ); } #[test] @@ -265,7 +295,10 @@ fn test_jwt_security_missing_scope_rejected() { }); let token = create_custom_jwt(&header, &claims, &key_bytes); let result = verify_access_token(&token, &key_bytes); - assert!(result.is_err(), "Missing scope must be rejected for access tokens"); + assert!( + result.is_err(), + "Missing scope must be rejected for access tokens" + ); } #[test] @@ -311,7 +344,10 @@ fn test_jwt_security_future_iat_accepted() { }); let token = create_custom_jwt(&header, &claims, &key_bytes); let result = verify_access_token(&token, &key_bytes); - assert!(result.is_ok(), "Slight future iat should be accepted for clock skew tolerance"); + assert!( + result.is_ok(), + "Slight future iat should be accepted for clock skew tolerance" + ); } #[test] @@ -321,7 +357,10 @@ fn test_jwt_security_cross_user_key_attack() { let did = "did:plc:user1"; let token = create_access_token(did, &key_bytes_user1).expect("create token"); let result = verify_access_token(&token, &key_bytes_user2); - assert!(result.is_err(), "Token signed by user1's key must not verify with user2's key"); + assert!( + result.is_err(), + "Token signed by user1's key must not verify with user2's key" + ); } #[test] @@ -369,8 +408,15 @@ fn test_jwt_security_malformed_tokens_rejected() { ]; for token in malformed_tokens { let result = verify_access_token(token, &key_bytes); - assert!(result.is_err(), "Malformed token '{}' must be rejected", - if token.len() > 40 { &token[..40] } else { token }); + assert!( + result.is_err(), + "Malformed token '{}' must be rejected", + if token.len() > 40 { + &token[..40] + } else { + token + } + ); } } @@ -379,27 +425,36 @@ fn test_jwt_security_missing_required_claims_rejected() { let key_bytes = generate_user_key(); let did = "did:plc:test"; let test_cases = vec![ - (json!({ - "iss": did, - "sub": did, - "aud": "did:web:test", - "iat": Utc::now().timestamp(), - "scope": SCOPE_ACCESS - }), "exp"), - (json!({ - "iss": did, - "sub": did, - "aud": "did:web:test", - "exp": Utc::now().timestamp() + 3600, - "scope": SCOPE_ACCESS - }), "iat"), - (json!({ - "iss": did, - "aud": "did:web:test", - "iat": Utc::now().timestamp(), - "exp": Utc::now().timestamp() + 3600, - "scope": SCOPE_ACCESS - }), "sub"), + ( + json!({ + "iss": did, + "sub": did, + "aud": "did:web:test", + "iat": Utc::now().timestamp(), + "scope": SCOPE_ACCESS + }), + "exp", + ), + ( + json!({ + "iss": did, + "sub": did, + "aud": "did:web:test", + "exp": Utc::now().timestamp() + 3600, + "scope": SCOPE_ACCESS + }), + "iat", + ), + ( + json!({ + "iss": did, + "aud": "did:web:test", + "iat": Utc::now().timestamp(), + "exp": Utc::now().timestamp() + 3600, + "scope": SCOPE_ACCESS + }), + "sub", + ), ]; for (claims, missing_claim) in test_cases { let header = json!({ @@ -408,7 +463,11 @@ fn test_jwt_security_missing_required_claims_rejected() { }); let token = create_custom_jwt(&header, &claims, &key_bytes); let result = verify_access_token(&token, &key_bytes); - assert!(result.is_err(), "Token missing '{}' claim must be rejected", missing_claim); + assert!( + result.is_err(), + "Token missing '{}' claim must be rejected", + missing_claim + ); } } @@ -455,7 +514,10 @@ fn test_jwt_security_header_injection_attack() { }); let token = create_custom_jwt(&header, &claims, &key_bytes); let result = verify_access_token(&token, &key_bytes); - assert!(result.is_ok(), "Extra header fields should not cause issues (we ignore them)"); + assert!( + result.is_ok(), + "Extra header fields should not cause issues (we ignore them)" + ); } #[test] @@ -499,7 +561,10 @@ fn test_jwt_security_unicode_injection_in_claims() { let result = verify_access_token(&token, &key_bytes); if result.is_ok() { let data = result.unwrap(); - assert!(!data.claims.sub.contains('\0'), "Null bytes in claims should be sanitized or rejected"); + assert!( + !data.claims.sub.contains('\0'), + "Null bytes in claims should be sanitized or rejected" + ); } } @@ -517,18 +582,17 @@ fn test_jwt_security_signature_verification_is_constant_time() { let completely_invalid_token = format!("{}.{}.{}", parts[0], parts[1], completely_invalid_sig); let _result1 = verify_access_token(&almost_valid_token, &key_bytes); let _result2 = verify_access_token(&completely_invalid_token, &key_bytes); - assert!(true, "Signature verification should use constant-time comparison (timing attack prevention)"); + assert!( + true, + "Signature verification should use constant-time comparison (timing attack prevention)" + ); } #[test] fn test_jwt_security_valid_scopes_accepted() { let key_bytes = generate_user_key(); let did = "did:plc:test"; - let valid_scopes = vec![ - SCOPE_ACCESS, - SCOPE_APP_PASS, - SCOPE_APP_PASS_PRIVILEGED, - ]; + let valid_scopes = vec![SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED]; for scope in valid_scopes { let header = json!({ "alg": "ES256K", @@ -568,7 +632,10 @@ fn test_jwt_security_refresh_token_scope_rejected_as_access() { }); let token = create_custom_jwt(&header, &claims, &key_bytes); let result = verify_access_token(&token, &key_bytes); - assert!(result.is_err(), "Refresh scope with access token type must be rejected"); + assert!( + result.is_err(), + "Refresh scope with access token type must be rejected" + ); } #[test] @@ -586,7 +653,10 @@ fn test_jwt_security_get_did_extraction_safe() { let fake_sig = URL_SAFE_NO_PAD.encode(&[0u8; 64]); let unverified_token = format!("{}.{}.{}", header_b64, claims_b64, fake_sig); let extracted_unsafe = get_did_from_token(&unverified_token).expect("extract unsafe"); - assert_eq!(extracted_unsafe, "did:plc:sub", "get_did_from_token extracts sub without verification (by design for lookup)"); + assert_eq!( + extracted_unsafe, "did:plc:sub", + "get_did_from_token extracts sub without verification (by design for lookup)" + ); } #[test] @@ -602,17 +672,15 @@ fn test_jwt_security_get_jti_extraction_safe() { let claims_b64 = URL_SAFE_NO_PAD.encode(r#"{"iss":"did:plc:test"}"#); let fake_sig = URL_SAFE_NO_PAD.encode(&[0u8; 64]); let no_jti_token = format!("{}.{}.{}", header_b64, claims_b64, fake_sig); - assert!(get_jti_from_token(&no_jti_token).is_err(), "Missing jti should error"); + assert!( + get_jti_from_token(&no_jti_token).is_err(), + "Missing jti should error" + ); } #[test] fn test_jwt_security_key_from_invalid_bytes_rejected() { - let invalid_keys: Vec<&[u8]> = vec![ - &[], - &[0u8; 31], - &[0u8; 33], - &[0xFFu8; 32], - ]; + let invalid_keys: Vec<&[u8]> = vec![&[], &[0u8; 31], &[0u8; 33], &[0xFFu8; 32]]; for key in invalid_keys { let result = create_access_token("did:plc:test", key); if result.is_ok() { @@ -644,7 +712,10 @@ fn test_jwt_security_boundary_exp_values() { "scope": SCOPE_ACCESS }); let token1 = create_custom_jwt(&header, &just_expired, &key_bytes); - assert!(verify_access_token(&token1, &key_bytes).is_err(), "Just expired token must be rejected"); + assert!( + verify_access_token(&token1, &key_bytes).is_err(), + "Just expired token must be rejected" + ); let expires_exactly_now = json!({ "iss": did, "sub": did, @@ -656,7 +727,10 @@ fn test_jwt_security_boundary_exp_values() { }); let token2 = create_custom_jwt(&header, &expires_exactly_now, &key_bytes); let result2 = verify_access_token(&token2, &key_bytes); - assert!(result2.is_err() || result2.is_ok(), "Token expiring exactly now is a boundary case - either behavior is acceptable"); + assert!( + result2.is_err() || result2.is_ok(), + "Token expiring exactly now is a boundary case - either behavior is acceptable" + ); } #[test] @@ -714,7 +788,11 @@ async fn test_jwt_security_server_rejects_forged_session_token() { .send() .await .unwrap(); - assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Forged session token must be rejected"); + assert_eq!( + res.status(), + StatusCode::UNAUTHORIZED, + "Forged session token must be rejected" + ); } #[tokio::test] @@ -734,7 +812,11 @@ async fn test_jwt_security_server_rejects_expired_token() { .send() .await .unwrap(); - assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Tampered/expired token must be rejected"); + assert_eq!( + res.status(), + StatusCode::UNAUTHORIZED, + "Tampered/expired token must be rejected" + ); } #[tokio::test] @@ -755,7 +837,11 @@ async fn test_jwt_security_server_rejects_tampered_did() { .send() .await .unwrap(); - assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "DID-tampered token must be rejected"); + assert_eq!( + res.status(), + StatusCode::UNAUTHORIZED, + "DID-tampered token must be rejected" + ); } #[tokio::test] @@ -811,14 +897,22 @@ async fn test_jwt_security_refresh_token_replay_protection() { .send() .await .unwrap(); - assert_eq!(first_refresh.status(), StatusCode::OK, "First refresh should succeed"); + assert_eq!( + first_refresh.status(), + StatusCode::OK, + "First refresh should succeed" + ); let replay_res = http_client .post(format!("{}/xrpc/com.atproto.server.refreshSession", url)) .header("Authorization", format!("Bearer {}", refresh_jwt)) .send() .await .unwrap(); - assert_eq!(replay_res.status(), StatusCode::UNAUTHORIZED, "Refresh token replay must be rejected"); + assert_eq!( + replay_res.status(), + StatusCode::UNAUTHORIZED, + "Refresh token replay must be rejected" + ); } #[tokio::test] @@ -832,35 +926,55 @@ async fn test_jwt_security_authorization_header_formats() { .send() .await .unwrap(); - assert_eq!(valid_res.status(), StatusCode::OK, "Valid Bearer format should work"); + assert_eq!( + valid_res.status(), + StatusCode::OK, + "Valid Bearer format should work" + ); let lowercase_res = http_client .get(format!("{}/xrpc/com.atproto.server.getSession", url)) .header("Authorization", format!("bearer {}", access_jwt)) .send() .await .unwrap(); - assert_eq!(lowercase_res.status(), StatusCode::OK, "Lowercase 'bearer' should be accepted (RFC 7235 case-insensitivity)"); + assert_eq!( + lowercase_res.status(), + StatusCode::OK, + "Lowercase 'bearer' should be accepted (RFC 7235 case-insensitivity)" + ); let basic_res = http_client .get(format!("{}/xrpc/com.atproto.server.getSession", url)) .header("Authorization", format!("Basic {}", access_jwt)) .send() .await .unwrap(); - assert_eq!(basic_res.status(), StatusCode::UNAUTHORIZED, "Basic scheme must be rejected"); + assert_eq!( + basic_res.status(), + StatusCode::UNAUTHORIZED, + "Basic scheme must be rejected" + ); let no_scheme_res = http_client .get(format!("{}/xrpc/com.atproto.server.getSession", url)) .header("Authorization", &access_jwt) .send() .await .unwrap(); - assert_eq!(no_scheme_res.status(), StatusCode::UNAUTHORIZED, "Missing scheme must be rejected"); + assert_eq!( + no_scheme_res.status(), + StatusCode::UNAUTHORIZED, + "Missing scheme must be rejected" + ); let empty_token_res = http_client .get(format!("{}/xrpc/com.atproto.server.getSession", url)) .header("Authorization", "Bearer ") .send() .await .unwrap(); - assert_eq!(empty_token_res.status(), StatusCode::UNAUTHORIZED, "Empty token must be rejected"); + assert_eq!( + empty_token_res.status(), + StatusCode::UNAUTHORIZED, + "Empty token must be rejected" + ); } #[tokio::test] @@ -874,7 +988,11 @@ async fn test_jwt_security_deleted_session_rejected() { .send() .await .unwrap(); - assert_eq!(get_res.status(), StatusCode::OK, "Token should work before logout"); + assert_eq!( + get_res.status(), + StatusCode::OK, + "Token should work before logout" + ); let logout_res = http_client .post(format!("{}/xrpc/com.atproto.server.deleteSession", url)) .header("Authorization", format!("Bearer {}", access_jwt)) @@ -888,7 +1006,11 @@ async fn test_jwt_security_deleted_session_rejected() { .send() .await .unwrap(); - assert_eq!(after_logout_res.status(), StatusCode::UNAUTHORIZED, "Token must be rejected after logout"); + assert_eq!( + after_logout_res.status(), + StatusCode::UNAUTHORIZED, + "Token must be rejected after logout" + ); } #[tokio::test] @@ -910,7 +1032,11 @@ async fn test_jwt_security_deactivated_account_rejected() { .send() .await .unwrap(); - assert_eq!(get_res.status(), StatusCode::UNAUTHORIZED, "Deactivated account token must be rejected"); + assert_eq!( + get_res.status(), + StatusCode::UNAUTHORIZED, + "Deactivated account token must be rejected" + ); let body: Value = get_res.json().await.unwrap(); assert_eq!(body["error"], "AccountDeactivated"); } diff --git a/tests/lifecycle_record.rs b/tests/lifecycle_record.rs index 349f728..5e29f94 100644 --- a/tests/lifecycle_record.rs +++ b/tests/lifecycle_record.rs @@ -1,8 +1,8 @@ mod common; mod helpers; +use chrono::Utc; use common::*; use helpers::*; -use chrono::Utc; use reqwest::{StatusCode, header}; use serde_json::{Value, json}; use std::time::Duration; @@ -307,7 +307,11 @@ async fn test_profile_lifecycle() { .send() .await .expect("Failed to create profile"); - assert_eq!(create_res.status(), StatusCode::OK, "Failed to create profile"); + assert_eq!( + create_res.status(), + StatusCode::OK, + "Failed to create profile" + ); let create_body: Value = create_res.json().await.unwrap(); let initial_cid = create_body["cid"].as_str().unwrap().to_string(); let get_res = client @@ -326,7 +330,10 @@ async fn test_profile_lifecycle() { assert_eq!(get_res.status(), StatusCode::OK); let get_body: Value = get_res.json().await.unwrap(); assert_eq!(get_body["value"]["displayName"], "Test User"); - assert_eq!(get_body["value"]["description"], "A test profile for lifecycle testing"); + assert_eq!( + get_body["value"]["description"], + "A test profile for lifecycle testing" + ); let update_payload = json!({ "repo": did, "collection": "app.bsky.actor.profile", @@ -348,7 +355,11 @@ async fn test_profile_lifecycle() { .send() .await .expect("Failed to update profile"); - assert_eq!(update_res.status(), StatusCode::OK, "Failed to update profile"); + assert_eq!( + update_res.status(), + StatusCode::OK, + "Failed to update profile" + ); let get_updated_res = client .get(format!( "{}/xrpc/com.atproto.repo.getRecord", @@ -371,7 +382,8 @@ async fn test_reply_thread_lifecycle() { let client = client(); let (alice_did, alice_jwt) = setup_new_user("alice-thread").await; let (bob_did, bob_jwt) = setup_new_user("bob-thread").await; - let (root_uri, root_cid) = create_post(&client, &alice_did, &alice_jwt, "This is the root post").await; + let (root_uri, root_cid) = + create_post(&client, &alice_did, &alice_jwt, "This is the root post").await; tokio::time::sleep(Duration::from_millis(100)).await; let reply_collection = "app.bsky.feed.post"; let reply_rkey = format!("e2e_reply_{}", Utc::now().timestamp_millis()); @@ -459,7 +471,11 @@ async fn test_reply_thread_lifecycle() { .send() .await .expect("Failed to create nested reply"); - assert_eq!(nested_res.status(), StatusCode::OK, "Failed to create nested reply"); + assert_eq!( + nested_res.status(), + StatusCode::OK, + "Failed to create nested reply" + ); } #[tokio::test] @@ -501,7 +517,11 @@ async fn test_blob_in_record_lifecycle() { .send() .await .expect("Failed to create profile with blob"); - assert_eq!(create_res.status(), StatusCode::OK, "Failed to create profile with blob"); + assert_eq!( + create_res.status(), + StatusCode::OK, + "Failed to create profile with blob" + ); let get_res = client .get(format!( "{}/xrpc/com.atproto.repo.getRecord", @@ -592,7 +612,11 @@ async fn test_authorization_cannot_delete_other_record() { .send() .await .expect("Failed to verify record exists"); - assert_eq!(get_res.status(), StatusCode::OK, "Record should still exist"); + assert_eq!( + get_res.status(), + StatusCode::OK, + "Record should still exist" + ); } #[tokio::test] @@ -735,7 +759,10 @@ async fn test_apply_writes_batch_lifecycle() { .await .expect("Failed to get updated profile"); let updated_profile: Value = get_updated_profile.json().await.unwrap(); - assert_eq!(updated_profile["value"]["displayName"], "Updated Batch User"); + assert_eq!( + updated_profile["value"]["displayName"], + "Updated Batch User" + ); let get_deleted_post = client .get(format!( "{}/xrpc/com.atproto.repo.getRecord", @@ -805,10 +832,7 @@ async fn test_list_records_default_order() { "{}/xrpc/com.atproto.repo.listRecords", base_url().await )) - .query(&[ - ("repo", did.as_str()), - ("collection", "app.bsky.feed.post"), - ]) + .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")]) .send() .await .expect("Failed to list records"); @@ -820,7 +844,11 @@ async fn test_list_records_default_order() { .iter() .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap()) .collect(); - assert_eq!(rkeys, vec!["cccc", "bbbb", "aaaa"], "Default order should be DESC (newest first)"); + assert_eq!( + rkeys, + vec!["cccc", "bbbb", "aaaa"], + "Default order should be DESC (newest first)" + ); } #[tokio::test] @@ -852,7 +880,11 @@ async fn test_list_records_reverse_true() { .iter() .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap()) .collect(); - assert_eq!(rkeys, vec!["aaaa", "bbbb", "cccc"], "reverse=true should give ASC order (oldest first)"); + assert_eq!( + rkeys, + vec!["aaaa", "bbbb", "cccc"], + "reverse=true should give ASC order (oldest first)" + ); } #[tokio::test] @@ -860,7 +892,14 @@ async fn test_list_records_cursor_pagination() { let client = client(); let (did, jwt) = setup_new_user("list-cursor").await; for i in 0..5 { - create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await; + create_post_with_rkey( + &client, + &did, + &jwt, + &format!("post{:02}", i), + &format!("Post {}", i), + ) + .await; tokio::time::sleep(Duration::from_millis(50)).await; } let res = client @@ -880,7 +919,9 @@ async fn test_list_records_cursor_pagination() { let body: Value = res.json().await.unwrap(); let records = body["records"].as_array().unwrap(); assert_eq!(records.len(), 2); - let cursor = body["cursor"].as_str().expect("Should have cursor with more records"); + let cursor = body["cursor"] + .as_str() + .expect("Should have cursor with more records"); let res2 = client .get(format!( "{}/xrpc/com.atproto.repo.listRecords", @@ -905,7 +946,11 @@ async fn test_list_records_cursor_pagination() { .map(|r| r["uri"].as_str().unwrap()) .collect(); let unique_uris: std::collections::HashSet<&str> = all_uris.iter().copied().collect(); - assert_eq!(all_uris.len(), unique_uris.len(), "Cursor pagination should not repeat records"); + assert_eq!( + all_uris.len(), + unique_uris.len(), + "Cursor pagination should not repeat records" + ); } #[tokio::test] @@ -1008,9 +1053,16 @@ async fn test_list_records_rkey_range() { .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap()) .collect(); for rkey in &rkeys { - assert!(*rkey >= "bbbb" && *rkey <= "dddd", "Range should be inclusive, got {}", rkey); + assert!( + *rkey >= "bbbb" && *rkey <= "dddd", + "Range should be inclusive, got {}", + rkey + ); } - assert!(!rkeys.is_empty(), "Should have at least some records in range"); + assert!( + !rkeys.is_empty(), + "Should have at least some records in range" + ); } #[tokio::test] @@ -1018,7 +1070,14 @@ async fn test_list_records_limit_clamping_max() { let client = client(); let (did, jwt) = setup_new_user("list-limit-max").await; for i in 0..5 { - create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await; + create_post_with_rkey( + &client, + &did, + &jwt, + &format!("post{:02}", i), + &format!("Post {}", i), + ) + .await; } let res = client .get(format!( @@ -1072,18 +1131,21 @@ async fn test_list_records_empty_collection() { "{}/xrpc/com.atproto.repo.listRecords", base_url().await )) - .query(&[ - ("repo", did.as_str()), - ("collection", "app.bsky.feed.post"), - ]) + .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")]) .send() .await .expect("Failed to list records"); assert_eq!(res.status(), StatusCode::OK); let body: Value = res.json().await.unwrap(); let records = body["records"].as_array().unwrap(); - assert!(records.is_empty(), "Empty collection should return empty array"); - assert!(body["cursor"].is_null(), "Empty collection should have no cursor"); + assert!( + records.is_empty(), + "Empty collection should return empty array" + ); + assert!( + body["cursor"].is_null(), + "Empty collection should have no cursor" + ); } #[tokio::test] @@ -1091,7 +1153,14 @@ async fn test_list_records_exact_limit() { let client = client(); let (did, jwt) = setup_new_user("list-exact-limit").await; for i in 0..10 { - create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await; + create_post_with_rkey( + &client, + &did, + &jwt, + &format!("post{:02}", i), + &format!("Post {}", i), + ) + .await; } let res = client .get(format!( @@ -1109,7 +1178,11 @@ async fn test_list_records_exact_limit() { assert_eq!(res.status(), StatusCode::OK); let body: Value = res.json().await.unwrap(); let records = body["records"].as_array().unwrap(); - assert_eq!(records.len(), 5, "Should return exactly 5 records when limit=5"); + assert_eq!( + records.len(), + 5, + "Should return exactly 5 records when limit=5" + ); } #[tokio::test] @@ -1117,7 +1190,14 @@ async fn test_list_records_cursor_exhaustion() { let client = client(); let (did, jwt) = setup_new_user("list-cursor-exhaust").await; for i in 0..3 { - create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await; + create_post_with_rkey( + &client, + &did, + &jwt, + &format!("post{:02}", i), + &format!("Post {}", i), + ) + .await; } let res = client .get(format!( @@ -1166,10 +1246,7 @@ async fn test_list_records_includes_cid() { "{}/xrpc/com.atproto.repo.listRecords", base_url().await )) - .query(&[ - ("repo", did.as_str()), - ("collection", "app.bsky.feed.post"), - ]) + .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")]) .send() .await .expect("Failed to list records"); @@ -1190,7 +1267,14 @@ async fn test_list_records_cursor_with_reverse() { let client = client(); let (did, jwt) = setup_new_user("list-cursor-reverse").await; for i in 0..5 { - create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await; + create_post_with_rkey( + &client, + &did, + &jwt, + &format!("post{:02}", i), + &format!("Post {}", i), + ) + .await; } let res = client .get(format!( @@ -1213,7 +1297,11 @@ async fn test_list_records_cursor_with_reverse() { .iter() .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap()) .collect(); - assert_eq!(first_rkeys, vec!["post00", "post01"], "First page with reverse should start from oldest"); + assert_eq!( + first_rkeys, + vec!["post00", "post01"], + "First page with reverse should start from oldest" + ); if let Some(cursor) = body["cursor"].as_str() { let res2 = client .get(format!( @@ -1236,6 +1324,10 @@ async fn test_list_records_cursor_with_reverse() { .iter() .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap()) .collect(); - assert_eq!(second_rkeys, vec!["post02", "post03"], "Second page should continue in ASC order"); + assert_eq!( + second_rkeys, + vec!["post02", "post03"], + "Second page should continue in ASC order" + ); } } diff --git a/tests/lifecycle_session.rs b/tests/lifecycle_session.rs index 4f2497d..eedcdec 100644 --- a/tests/lifecycle_session.rs +++ b/tests/lifecycle_session.rs @@ -1,8 +1,8 @@ mod common; mod helpers; +use chrono::Utc; use common::*; use helpers::*; -use chrono::Utc; use reqwest::StatusCode; use serde_json::{Value, json}; @@ -168,7 +168,8 @@ async fn test_session_lifecycle_refresh_invalidates_old() { .await .expect("Failed reuse attempt"); assert!( - reuse_res.status() == StatusCode::UNAUTHORIZED || reuse_res.status() == StatusCode::BAD_REQUEST, + reuse_res.status() == StatusCode::UNAUTHORIZED + || reuse_res.status() == StatusCode::BAD_REQUEST, "Old refresh token should be invalid after use" ); } @@ -237,7 +238,11 @@ async fn test_app_password_lifecycle() { .send() .await .expect("Failed to login with app password"); - assert_eq!(login_res.status(), StatusCode::OK, "App password login should work"); + assert_eq!( + login_res.status(), + StatusCode::OK, + "App password login should work" + ); let revoke_res = client .post(format!( "{}/xrpc/com.atproto.server.revokeAppPassword", @@ -342,7 +347,11 @@ async fn test_account_deactivation_lifecycle() { .send() .await .expect("Failed to get post while deactivated"); - assert_eq!(get_post_res.status(), StatusCode::OK, "Records should still be readable"); + assert_eq!( + get_post_res.status(), + StatusCode::OK, + "Records should still be readable" + ); let activate_res = client .post(format!( "{}/xrpc/com.atproto.server.activateAccount", @@ -365,7 +374,10 @@ async fn test_account_deactivation_lifecycle() { .expect("Failed to check status after activate"); assert_eq!(status_after_activate.status(), StatusCode::OK); let (new_post_uri, _) = create_post(&client, &did, &jwt, "Post after reactivation").await; - assert!(!new_post_uri.is_empty(), "Should be able to post after reactivation"); + assert!( + !new_post_uri.is_empty(), + "Should be able to post after reactivation" + ); } #[tokio::test] @@ -415,11 +427,16 @@ async fn test_request_account_delete() { .expect("Failed to request account deletion"); assert_eq!(res.status(), StatusCode::OK); let db_url = get_db_connection_string().await; - let pool = sqlx::PgPool::connect(&db_url).await.expect("Failed to connect to test DB"); - let row = sqlx::query!("SELECT token, expires_at FROM account_deletion_requests WHERE did = $1", did) - .fetch_optional(&pool) + let pool = sqlx::PgPool::connect(&db_url) .await - .expect("Failed to query DB"); + .expect("Failed to connect to test DB"); + let row = sqlx::query!( + "SELECT token, expires_at FROM account_deletion_requests WHERE did = $1", + did + ) + .fetch_optional(&pool) + .await + .expect("Failed to query DB"); assert!(row.is_some(), "Deletion token should exist in DB"); let row = row.unwrap(); assert!(!row.token.is_empty(), "Token should not be empty"); diff --git a/tests/lifecycle_social.rs b/tests/lifecycle_social.rs index be7b2be..0a5e198 100644 --- a/tests/lifecycle_social.rs +++ b/tests/lifecycle_social.rs @@ -1,11 +1,11 @@ mod common; mod helpers; +use chrono::Utc; use common::*; use helpers::*; use reqwest::StatusCode; use serde_json::{Value, json}; use std::time::Duration; -use chrono::Utc; #[tokio::test] async fn test_social_flow_lifecycle() { @@ -118,7 +118,8 @@ async fn test_like_lifecycle() { let client = client(); let (alice_did, alice_jwt) = setup_new_user("alice-like").await; let (bob_did, bob_jwt) = setup_new_user("bob-like").await; - let (post_uri, post_cid) = create_post(&client, &alice_did, &alice_jwt, "Like this post!").await; + let (post_uri, post_cid) = + create_post(&client, &alice_did, &alice_jwt, "Like this post!").await; let (like_uri, _) = create_like(&client, &bob_did, &bob_jwt, &post_uri, &post_cid).await; let like_rkey = like_uri.split('/').last().unwrap(); let get_like_res = client @@ -166,7 +167,11 @@ async fn test_like_lifecycle() { .send() .await .expect("Failed to check deleted like"); - assert_eq!(get_deleted_res.status(), StatusCode::NOT_FOUND, "Like should be deleted"); + assert_eq!( + get_deleted_res.status(), + StatusCode::NOT_FOUND, + "Like should be deleted" + ); } #[tokio::test] @@ -208,7 +213,11 @@ async fn test_repost_lifecycle() { .send() .await .expect("Failed to delete repost"); - assert_eq!(delete_res.status(), StatusCode::OK, "Failed to delete repost"); + assert_eq!( + delete_res.status(), + StatusCode::OK, + "Failed to delete repost" + ); } #[tokio::test] @@ -261,7 +270,11 @@ async fn test_unfollow_lifecycle() { .send() .await .expect("Failed to check deleted follow"); - assert_eq!(get_deleted_res.status(), StatusCode::NOT_FOUND, "Follow should be deleted"); + assert_eq!( + get_deleted_res.status(), + StatusCode::NOT_FOUND, + "Follow should be deleted" + ); } #[tokio::test] @@ -378,6 +391,7 @@ async fn test_account_to_post_full_lifecycle() { assert_eq!(create_account_res.status(), StatusCode::OK); let account_body: Value = create_account_res.json().await.unwrap(); let did = account_body["did"].as_str().unwrap().to_string(); + let handle = account_body["handle"].as_str().unwrap().to_string(); let access_jwt = verify_new_account(&client, &did).await; let get_session_res = client .get(format!( @@ -391,7 +405,11 @@ async fn test_account_to_post_full_lifecycle() { assert_eq!(get_session_res.status(), StatusCode::OK); let session_body: Value = get_session_res.json().await.unwrap(); assert_eq!(session_body["did"], did); - assert_eq!(session_body["handle"], handle); + let normalized_handle = session_body["handle"].as_str().unwrap().to_string(); + assert!( + normalized_handle.starts_with(&handle), + "Session handle should start with the requested handle" + ); let profile_res = client .post(format!( "{}/xrpc/com.atproto.repo.putRecord", @@ -439,5 +457,9 @@ async fn test_account_to_post_full_lifecycle() { assert_eq!(describe_res.status(), StatusCode::OK); let describe_body: Value = describe_res.json().await.unwrap(); assert_eq!(describe_body["did"], did); - assert_eq!(describe_body["handle"], handle); -} \ No newline at end of file + let describe_handle = describe_body["handle"].as_str().unwrap(); + assert!( + normalized_handle.starts_with(describe_handle) || describe_handle.starts_with(&handle), + "describeRepo handle should be related to the requested handle" + ); +} diff --git a/tests/moderation.rs b/tests/moderation.rs index 1b7a12c..ffbc54b 100644 --- a/tests/moderation.rs +++ b/tests/moderation.rs @@ -34,7 +34,10 @@ async fn test_moderation_report_lifecycle() { assert_eq!(report_res.status(), StatusCode::OK); let report_body: Value = report_res.json().await.unwrap(); assert!(report_body["id"].is_number(), "Report should have an ID"); - assert_eq!(report_body["reasonType"], "com.atproto.moderation.defs#reasonSpam"); + assert_eq!( + report_body["reasonType"], + "com.atproto.moderation.defs#reasonSpam" + ); assert_eq!(report_body["reportedBy"], alice_did); let account_report_payload = json!({ "reasonType": "com.atproto.moderation.defs#reasonOther", diff --git a/tests/notifications.rs b/tests/notifications.rs index 67d1224..5ebec14 100644 --- a/tests/notifications.rs +++ b/tests/notifications.rs @@ -1,7 +1,7 @@ mod common; use bspds::notifications::{ - enqueue_notification, enqueue_welcome, NewNotification, NotificationChannel, - NotificationStatus, NotificationType, + NewNotification, NotificationChannel, NotificationStatus, NotificationType, + enqueue_notification, enqueue_welcome, }; use sqlx::PgPool; diff --git a/tests/oauth.rs b/tests/oauth.rs index 5b691b8..af7d697 100644 --- a/tests/oauth.rs +++ b/tests/oauth.rs @@ -3,11 +3,11 @@ mod helpers; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use chrono::Utc; use common::{base_url, client, create_account_and_login}; -use reqwest::{redirect, StatusCode}; -use serde_json::{json, Value}; +use reqwest::{StatusCode, redirect}; +use serde_json::{Value, json}; use sha2::{Digest, Sha256}; -use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; fn no_redirect_client() -> reqwest::Client { reqwest::Client::builder() @@ -105,7 +105,9 @@ async fn test_oauth_authorization_server_metadata() { let code_challenge_methods = body["code_challenge_methods_supported"].as_array().unwrap(); assert!(code_challenge_methods.contains(&json!("S256"))); assert_eq!(body["require_pushed_authorization_requests"], json!(true)); - let dpop_algs = body["dpop_signing_alg_values_supported"].as_array().unwrap(); + let dpop_algs = body["dpop_signing_alg_values_supported"] + .as_array() + .unwrap(); assert!(dpop_algs.contains(&json!("ES256"))); } #[tokio::test] @@ -143,7 +145,12 @@ async fn test_par_success() { .send() .await .expect("Failed to send PAR request"); - assert_eq!(res.status(), StatusCode::OK, "PAR should succeed: {:?}", res.text().await); + assert_eq!( + res.status(), + StatusCode::CREATED, + "PAR should succeed: {:?}", + res.text().await + ); let body: Value = client .post(format!("{}/oauth/par", url)) .form(&[ @@ -211,7 +218,10 @@ async fn test_authorize_rejects_invalid_request_uri() { let res = client .get(format!("{}/oauth/authorize", url)) .header("Accept", "application/json") - .query(&[("request_uri", "urn:ietf:params:oauth:request_uri:nonexistent")]) + .query(&[( + "request_uri", + "urn:ietf:params:oauth:request_uri:nonexistent", + )]) .send() .await .expect("Request failed"); @@ -273,7 +283,7 @@ async fn test_full_oauth_flow_without_dpop() { .expect("PAR failed"); let par_status = par_res.status(); let par_text = par_res.text().await.unwrap_or_default(); - if par_status != StatusCode::OK { + if par_status != StatusCode::OK && par_status != StatusCode::CREATED { panic!("PAR failed with status {}: {}", par_status, par_text); } let par_body: Value = serde_json::from_str(&par_text).unwrap(); @@ -296,18 +306,28 @@ async fn test_full_oauth_flow_without_dpop() { && auth_status != StatusCode::FOUND { let auth_text = auth_res.text().await.unwrap_or_default(); - panic!( - "Expected redirect, got {}: {}", - auth_status, auth_text - ); + panic!("Expected redirect, got {}: {}", auth_status, auth_text); } - let location = auth_res.headers().get("location") + let location = auth_res + .headers() + .get("location") .expect("No Location header") .to_str() .unwrap(); - assert!(location.starts_with(redirect_uri), "Redirect to wrong URI: {}", location); - assert!(location.contains("code="), "No code in redirect: {}", location); - assert!(location.contains(&format!("state={}", state)), "Wrong state in redirect"); + assert!( + location.starts_with(redirect_uri), + "Redirect to wrong URI: {}", + location + ); + assert!( + location.contains("code="), + "No code in redirect: {}", + location + ); + assert!( + location.contains(&format!("state={}", state)), + "Wrong state in redirect" + ); let code = location .split("code=") .nth(1) @@ -330,7 +350,10 @@ async fn test_full_oauth_flow_without_dpop() { let token_status = token_res.status(); let token_text = token_res.text().await.unwrap_or_default(); if token_status != StatusCode::OK { - panic!("Token request failed with status {}: {}", token_status, token_text); + panic!( + "Token request failed with status {}: {}", + token_status, token_text + ); } let token_body: Value = serde_json::from_str(&token_text).unwrap(); assert!(token_body["access_token"].is_string()); @@ -389,8 +412,19 @@ async fn test_token_refresh_flow() { .send() .await .unwrap(); - let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); - let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); + let location = auth_res + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); + let code = location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); let token_body: Value = http_client .post(format!("{}/oauth/token", url)) .form(&[ @@ -424,8 +458,14 @@ async fn test_token_refresh_flow() { assert!(refresh_body["refresh_token"].is_string()); let new_access_token = refresh_body["access_token"].as_str().unwrap(); let new_refresh_token = refresh_body["refresh_token"].as_str().unwrap(); - assert_ne!(new_access_token, original_access_token, "Access token should rotate"); - assert_ne!(new_refresh_token, refresh_token, "Refresh token should rotate"); + assert_ne!( + new_access_token, original_access_token, + "Access token should rotate" + ); + assert_ne!( + new_refresh_token, refresh_token, + "Refresh token should rotate" + ); } #[tokio::test] async fn test_wrong_credentials_denied() { @@ -531,8 +571,19 @@ async fn test_token_revocation() { .send() .await .unwrap(); - let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); - let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); + let location = auth_res + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); + let code = location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); let token_body: Value = http_client .post(format!("{}/oauth/token", url)) .form(&[ @@ -610,7 +661,10 @@ async fn test_expired_authorization_request() { let res = http_client .get(format!("{}/oauth/authorize", url)) .header("Accept", "application/json") - .query(&[("request_uri", "urn:ietf:params:oauth:request_uri:expired-or-nonexistent")]) + .query(&[( + "request_uri", + "urn:ietf:params:oauth:request_uri:expired-or-nonexistent", + )]) .send() .await .unwrap(); @@ -668,8 +722,19 @@ async fn test_token_introspection() { .send() .await .unwrap(); - let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); - let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); + let location = auth_res + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); + let code = location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); let token_body: Value = http_client .post(format!("{}/oauth/token", url)) .form(&[ @@ -762,8 +827,19 @@ async fn test_introspect_revoked_token() { .send() .await .unwrap(); - let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); - let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); + let location = auth_res + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); + let code = location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); let token_body: Value = http_client .post(format!("{}/oauth/token", url)) .form(&[ @@ -853,8 +929,16 @@ async fn test_state_with_special_chars() { auth_res.status().is_redirection(), "Should redirect even with special chars in state" ); - let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); - assert!(location.contains("state="), "State should be in redirect URL"); + let location = auth_res + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); + assert!( + location.contains("state="), + "State should be in redirect URL" + ); let encoded_state = urlencoding::encode(special_state); assert!( location.contains(&format!("state={}", encoded_state)), @@ -931,7 +1015,12 @@ async fn test_2fa_required_when_enabled() { "Should redirect to 2FA page, got status: {}", auth_res.status() ); - let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); + let location = auth_res + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); assert!( location.contains("/oauth/authorize/2fa"), "Should redirect to 2FA page, got: {}", @@ -1007,14 +1096,16 @@ async fn test_2fa_invalid_code_rejected() { .await .unwrap(); assert!(auth_res.status().is_redirection()); - let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); + let location = auth_res + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); assert!(location.contains("/oauth/authorize/2fa")); let twofa_res = http_client .post(format!("{}/oauth/authorize/2fa", url)) - .form(&[ - ("request_uri", request_uri), - ("code", "000000"), - ]) + .form(&[("request_uri", request_uri), ("code", "000000")]) .send() .await .unwrap(); @@ -1090,19 +1181,15 @@ async fn test_2fa_valid_code_completes_auth() { .await .unwrap(); assert!(auth_res.status().is_redirection()); - let twofa_code: String = sqlx::query_scalar( - "SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1" - ) - .bind(request_uri) - .fetch_one(&pool) - .await - .expect("Failed to get 2FA code from database"); + let twofa_code: String = + sqlx::query_scalar("SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1") + .bind(request_uri) + .fetch_one(&pool) + .await + .expect("Failed to get 2FA code from database"); let twofa_res = auth_client .post(format!("{}/oauth/authorize/2fa", url)) - .form(&[ - ("request_uri", request_uri), - ("code", &twofa_code), - ]) + .form(&[("request_uri", request_uri), ("code", &twofa_code)]) .send() .await .unwrap(); @@ -1111,7 +1198,12 @@ async fn test_2fa_valid_code_completes_auth() { "Valid 2FA code should redirect to success, got status: {}", twofa_res.status() ); - let location = twofa_res.headers().get("location").unwrap().to_str().unwrap(); + let location = twofa_res + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); assert!( location.starts_with(redirect_uri), "Should redirect to client callback, got: {}", @@ -1121,7 +1213,13 @@ async fn test_2fa_valid_code_completes_auth() { location.contains("code="), "Redirect should include authorization code" ); - let auth_code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); + let auth_code = location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); let token_res = http_client .post(format!("{}/oauth/token", url)) .form(&[ @@ -1134,7 +1232,11 @@ async fn test_2fa_valid_code_completes_auth() { .send() .await .unwrap(); - assert_eq!(token_res.status(), StatusCode::OK, "Token exchange should succeed"); + assert_eq!( + token_res.status(), + StatusCode::OK, + "Token exchange should succeed" + ); let token_body: Value = token_res.json().await.unwrap(); assert!(token_body["access_token"].is_string()); assert_eq!(token_body["sub"], user_did); @@ -1207,28 +1309,28 @@ async fn test_2fa_lockout_after_max_attempts() { for i in 0..5 { let res = http_client .post(format!("{}/oauth/authorize/2fa", url)) - .form(&[ - ("request_uri", request_uri), - ("code", "999999"), - ]) + .form(&[("request_uri", request_uri), ("code", "999999")]) .send() .await .unwrap(); if i < 4 { - assert_eq!(res.status(), StatusCode::OK, "Attempt {} should show error page", i + 1); + assert_eq!( + res.status(), + StatusCode::OK, + "Attempt {} should show error page", + i + 1 + ); let body = res.text().await.unwrap(); assert!( body.contains("Invalid verification code"), - "Should show invalid code error on attempt {}", i + 1 + "Should show invalid code error on attempt {}", + i + 1 ); } } let lockout_res = http_client .post(format!("{}/oauth/authorize/2fa", url)) - .form(&[ - ("request_uri", request_uri), - ("code", "999999"), - ]) + .form(&[("request_uri", request_uri), ("code", "999999")]) .send() .await .unwrap(); @@ -1294,14 +1396,26 @@ async fn test_account_selector_with_2fa_requires_verification() { .await .unwrap(); assert!(auth_res.status().is_redirection()); - let device_cookie = auth_res.headers() + let device_cookie = auth_res + .headers() .get("set-cookie") .and_then(|v| v.to_str().ok()) .map(|s| s.split(';').next().unwrap_or("").to_string()) .expect("Should have received device cookie"); - let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); + let location = auth_res + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); assert!(location.contains("code="), "First auth should succeed"); - let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); + let code = location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); let _token_body: Value = http_client .post(format!("{}/oauth/token", url)) .form(&[ @@ -1348,10 +1462,7 @@ async fn test_account_selector_with_2fa_requires_verification() { let select_res = auth_client .post(format!("{}/oauth/authorize/select", url)) .header("cookie", &device_cookie) - .form(&[ - ("request_uri", request_uri2), - ("did", &user_did), - ]) + .form(&[("request_uri", request_uri2), ("did", &user_did)]) .send() .await .unwrap(); @@ -1360,37 +1471,49 @@ async fn test_account_selector_with_2fa_requires_verification() { "Account selector should redirect, got status: {}", select_res.status() ); - let select_location = select_res.headers().get("location").unwrap().to_str().unwrap(); + let select_location = select_res + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); assert!( select_location.contains("/oauth/authorize/2fa"), "Account selector with 2FA enabled should redirect to 2FA page, got: {}", select_location ); - let twofa_code: String = sqlx::query_scalar( - "SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1" - ) - .bind(request_uri2) - .fetch_one(&pool) - .await - .expect("Failed to get 2FA code"); + let twofa_code: String = + sqlx::query_scalar("SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1") + .bind(request_uri2) + .fetch_one(&pool) + .await + .expect("Failed to get 2FA code"); let twofa_res = auth_client .post(format!("{}/oauth/authorize/2fa", url)) .header("cookie", &device_cookie) - .form(&[ - ("request_uri", request_uri2), - ("code", &twofa_code), - ]) + .form(&[("request_uri", request_uri2), ("code", &twofa_code)]) .send() .await .unwrap(); assert!(twofa_res.status().is_redirection()); - let final_location = twofa_res.headers().get("location").unwrap().to_str().unwrap(); + let final_location = twofa_res + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); assert!( final_location.starts_with(redirect_uri) && final_location.contains("code="), "After 2FA, should redirect to client with code, got: {}", final_location ); - let final_code = final_location.split("code=").nth(1).unwrap().split('&').next().unwrap(); + let final_code = final_location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); let token_res = http_client .post(format!("{}/oauth/token", url)) .form(&[ @@ -1405,5 +1528,8 @@ async fn test_account_selector_with_2fa_requires_verification() { .unwrap(); assert_eq!(token_res.status(), StatusCode::OK); let final_token: Value = token_res.json().await.unwrap(); - assert_eq!(final_token["sub"], user_did, "Token should be for the correct user"); + assert_eq!( + final_token["sub"], user_did, + "Token should be for the correct user" + ); } diff --git a/tests/oauth_lifecycle.rs b/tests/oauth_lifecycle.rs index c1317da..aa8e95a 100644 --- a/tests/oauth_lifecycle.rs +++ b/tests/oauth_lifecycle.rs @@ -5,11 +5,11 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use chrono::Utc; use common::{base_url, client}; use helpers::verify_new_account; -use reqwest::{redirect, StatusCode}; -use serde_json::{json, Value}; +use reqwest::{StatusCode, redirect}; +use serde_json::{Value, json}; use sha2::{Digest, Sha256}; -use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; fn generate_pkce() -> (String, String) { let verifier_bytes: [u8; 32] = rand::random(); @@ -55,7 +55,10 @@ struct OAuthSession { client_id: String, } -async fn create_user_and_oauth_session(handle_prefix: &str, redirect_uri: &str) -> (OAuthSession, MockServer) { +async fn create_user_and_oauth_session( + handle_prefix: &str, + redirect_uri: &str, +) -> (OAuthSession, MockServer) { let url = base_url().await; let http_client = client(); let ts = Utc::now().timestamp_millis(); @@ -92,7 +95,11 @@ async fn create_user_and_oauth_session(handle_prefix: &str, redirect_uri: &str) .send() .await .expect("PAR failed"); - assert_eq!(par_res.status(), StatusCode::OK); + assert!( + par_res.status() == StatusCode::OK || par_res.status() == StatusCode::CREATED, + "PAR should succeed with 200 or 201, got {}", + par_res.status() + ); let par_body: Value = par_res.json().await.unwrap(); let request_uri = par_body["request_uri"].as_str().unwrap(); let auth_client = no_redirect_client(); @@ -107,8 +114,19 @@ async fn create_user_and_oauth_session(handle_prefix: &str, redirect_uri: &str) .send() .await .expect("Authorize failed"); - let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); - let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); + let location = auth_res + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); + let code = location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); let token_res = http_client .post(format!("{}/oauth/token", url)) .form(&[ @@ -136,10 +154,8 @@ async fn create_user_and_oauth_session(handle_prefix: &str, redirect_uri: &str) async fn test_oauth_token_can_create_and_read_records() { let url = base_url().await; let http_client = client(); - let (session, _mock) = create_user_and_oauth_session( - "oauth-records", - "https://example.com/callback" - ).await; + let (session, _mock) = + create_user_and_oauth_session("oauth-records", "https://example.com/callback").await; let collection = "app.bsky.feed.post"; let post_text = "Hello from OAuth! This post was created with an OAuth access token."; let create_res = http_client @@ -157,7 +173,11 @@ async fn test_oauth_token_can_create_and_read_records() { .send() .await .expect("createRecord failed"); - assert_eq!(create_res.status(), StatusCode::OK, "Should create record with OAuth token"); + assert_eq!( + create_res.status(), + StatusCode::OK, + "Should create record with OAuth token" + ); let create_body: Value = create_res.json().await.unwrap(); let uri = create_body["uri"].as_str().unwrap(); let rkey = uri.split('/').last().unwrap(); @@ -172,7 +192,11 @@ async fn test_oauth_token_can_create_and_read_records() { .send() .await .expect("getRecord failed"); - assert_eq!(get_res.status(), StatusCode::OK, "Should read record with OAuth token"); + assert_eq!( + get_res.status(), + StatusCode::OK, + "Should read record with OAuth token" + ); let get_body: Value = get_res.json().await.unwrap(); assert_eq!(get_body["value"]["text"], post_text); } @@ -181,10 +205,8 @@ async fn test_oauth_token_can_create_and_read_records() { async fn test_oauth_token_can_upload_blob() { let url = base_url().await; let http_client = client(); - let (session, _mock) = create_user_and_oauth_session( - "oauth-blob", - "https://example.com/callback" - ).await; + let (session, _mock) = + create_user_and_oauth_session("oauth-blob", "https://example.com/callback").await; let blob_data = b"This is test blob data uploaded via OAuth"; let upload_res = http_client .post(format!("{}/xrpc/com.atproto.repo.uploadBlob", url)) @@ -194,7 +216,11 @@ async fn test_oauth_token_can_upload_blob() { .send() .await .expect("uploadBlob failed"); - assert_eq!(upload_res.status(), StatusCode::OK, "Should upload blob with OAuth token"); + assert_eq!( + upload_res.status(), + StatusCode::OK, + "Should upload blob with OAuth token" + ); let upload_body: Value = upload_res.json().await.unwrap(); assert!(upload_body["blob"]["ref"]["$link"].is_string()); assert_eq!(upload_body["blob"]["mimeType"], "text/plain"); @@ -204,10 +230,8 @@ async fn test_oauth_token_can_upload_blob() { async fn test_oauth_token_can_describe_repo() { let url = base_url().await; let http_client = client(); - let (session, _mock) = create_user_and_oauth_session( - "oauth-describe", - "https://example.com/callback" - ).await; + let (session, _mock) = + create_user_and_oauth_session("oauth-describe", "https://example.com/callback").await; let describe_res = http_client .get(format!("{}/xrpc/com.atproto.repo.describeRepo", url)) .bearer_auth(&session.access_token) @@ -215,7 +239,11 @@ async fn test_oauth_token_can_describe_repo() { .send() .await .expect("describeRepo failed"); - assert_eq!(describe_res.status(), StatusCode::OK, "Should describe repo with OAuth token"); + assert_eq!( + describe_res.status(), + StatusCode::OK, + "Should describe repo with OAuth token" + ); let describe_body: Value = describe_res.json().await.unwrap(); assert_eq!(describe_body["did"], session.did); assert!(describe_body["handle"].is_string()); @@ -225,10 +253,8 @@ async fn test_oauth_token_can_describe_repo() { async fn test_oauth_full_post_lifecycle_create_edit_delete() { let url = base_url().await; let http_client = client(); - let (session, _mock) = create_user_and_oauth_session( - "oauth-lifecycle", - "https://example.com/callback" - ).await; + let (session, _mock) = + create_user_and_oauth_session("oauth-lifecycle", "https://example.com/callback").await; let collection = "app.bsky.feed.post"; let original_text = "Original post content"; let create_res = http_client @@ -267,7 +293,11 @@ async fn test_oauth_full_post_lifecycle_create_edit_delete() { .send() .await .unwrap(); - assert_eq!(put_res.status(), StatusCode::OK, "Should update record with OAuth token"); + assert_eq!( + put_res.status(), + StatusCode::OK, + "Should update record with OAuth token" + ); let get_res = http_client .get(format!("{}/xrpc/com.atproto.repo.getRecord", url)) .bearer_auth(&session.access_token) @@ -280,7 +310,10 @@ async fn test_oauth_full_post_lifecycle_create_edit_delete() { .await .unwrap(); let get_body: Value = get_res.json().await.unwrap(); - assert_eq!(get_body["value"]["text"], updated_text, "Record should have updated text"); + assert_eq!( + get_body["value"]["text"], updated_text, + "Record should have updated text" + ); let delete_res = http_client .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url)) .bearer_auth(&session.access_token) @@ -292,7 +325,11 @@ async fn test_oauth_full_post_lifecycle_create_edit_delete() { .send() .await .unwrap(); - assert_eq!(delete_res.status(), StatusCode::OK, "Should delete record with OAuth token"); + assert_eq!( + delete_res.status(), + StatusCode::OK, + "Should delete record with OAuth token" + ); let get_deleted_res = http_client .get(format!("{}/xrpc/com.atproto.repo.getRecord", url)) .bearer_auth(&session.access_token) @@ -305,7 +342,8 @@ async fn test_oauth_full_post_lifecycle_create_edit_delete() { .await .unwrap(); assert!( - get_deleted_res.status() == StatusCode::BAD_REQUEST || get_deleted_res.status() == StatusCode::NOT_FOUND, + get_deleted_res.status() == StatusCode::BAD_REQUEST + || get_deleted_res.status() == StatusCode::NOT_FOUND, "Deleted record should not be found, got {}", get_deleted_res.status() ); @@ -315,10 +353,8 @@ async fn test_oauth_full_post_lifecycle_create_edit_delete() { async fn test_oauth_batch_operations_apply_writes() { let url = base_url().await; let http_client = client(); - let (session, _mock) = create_user_and_oauth_session( - "oauth-batch", - "https://example.com/callback" - ).await; + let (session, _mock) = + create_user_and_oauth_session("oauth-batch", "https://example.com/callback").await; let collection = "app.bsky.feed.post"; let now = Utc::now().to_rfc3339(); let apply_res = http_client @@ -362,31 +398,33 @@ async fn test_oauth_batch_operations_apply_writes() { .send() .await .unwrap(); - assert_eq!(apply_res.status(), StatusCode::OK, "Should apply batch writes with OAuth token"); + assert_eq!( + apply_res.status(), + StatusCode::OK, + "Should apply batch writes with OAuth token" + ); let list_res = http_client .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) .bearer_auth(&session.access_token) - .query(&[ - ("repo", session.did.as_str()), - ("collection", collection), - ]) + .query(&[("repo", session.did.as_str()), ("collection", collection)]) .send() .await .unwrap(); assert_eq!(list_res.status(), StatusCode::OK); let list_body: Value = list_res.json().await.unwrap(); let records = list_body["records"].as_array().unwrap(); - assert!(records.len() >= 3, "Should have at least 3 records from batch"); + assert!( + records.len() >= 3, + "Should have at least 3 records from batch" + ); } #[tokio::test] async fn test_oauth_token_refresh_maintains_access() { let url = base_url().await; let http_client = client(); - let (session, _mock) = create_user_and_oauth_session( - "oauth-refresh-access", - "https://example.com/callback" - ).await; + let (session, _mock) = + create_user_and_oauth_session("oauth-refresh-access", "https://example.com/callback").await; let collection = "app.bsky.feed.post"; let create_res = http_client .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) @@ -403,7 +441,11 @@ async fn test_oauth_token_refresh_maintains_access() { .send() .await .unwrap(); - assert_eq!(create_res.status(), StatusCode::OK, "Original token should work"); + assert_eq!( + create_res.status(), + StatusCode::OK, + "Original token should work" + ); let refresh_res = http_client .post(format!("{}/oauth/token", url)) .form(&[ @@ -417,7 +459,10 @@ async fn test_oauth_token_refresh_maintains_access() { assert_eq!(refresh_res.status(), StatusCode::OK); let refresh_body: Value = refresh_res.json().await.unwrap(); let new_access_token = refresh_body["access_token"].as_str().unwrap(); - assert_ne!(new_access_token, session.access_token, "New token should be different"); + assert_ne!( + new_access_token, session.access_token, + "New token should be different" + ); let create_res2 = http_client .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) .bearer_auth(new_access_token) @@ -433,18 +478,23 @@ async fn test_oauth_token_refresh_maintains_access() { .send() .await .unwrap(); - assert_eq!(create_res2.status(), StatusCode::OK, "New token should work for creating records"); + assert_eq!( + create_res2.status(), + StatusCode::OK, + "New token should work for creating records" + ); let list_res = http_client .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) .bearer_auth(new_access_token) - .query(&[ - ("repo", session.did.as_str()), - ("collection", collection), - ]) + .query(&[("repo", session.did.as_str()), ("collection", collection)]) .send() .await .unwrap(); - assert_eq!(list_res.status(), StatusCode::OK, "New token should work for listing records"); + assert_eq!( + list_res.status(), + StatusCode::OK, + "New token should work for listing records" + ); let list_body: Value = list_res.json().await.unwrap(); let records = list_body["records"].as_array().unwrap(); assert_eq!(records.len(), 2, "Should have both posts"); @@ -454,10 +504,8 @@ async fn test_oauth_token_refresh_maintains_access() { async fn test_oauth_revoked_token_cannot_access_resources() { let url = base_url().await; let http_client = client(); - let (session, _mock) = create_user_and_oauth_session( - "oauth-revoke-access", - "https://example.com/callback" - ).await; + let (session, _mock) = + create_user_and_oauth_session("oauth-revoke-access", "https://example.com/callback").await; let collection = "app.bsky.feed.post"; let create_res = http_client .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) @@ -474,14 +522,22 @@ async fn test_oauth_revoked_token_cannot_access_resources() { .send() .await .unwrap(); - assert_eq!(create_res.status(), StatusCode::OK, "Token should work before revocation"); + assert_eq!( + create_res.status(), + StatusCode::OK, + "Token should work before revocation" + ); let revoke_res = http_client .post(format!("{}/oauth/revoke", url)) .form(&[("token", session.refresh_token.as_str())]) .send() .await .unwrap(); - assert_eq!(revoke_res.status(), StatusCode::OK, "Revocation should succeed"); + assert_eq!( + revoke_res.status(), + StatusCode::OK, + "Revocation should succeed" + ); let refresh_res = http_client .post(format!("{}/oauth/token", url)) .form(&[ @@ -492,7 +548,11 @@ async fn test_oauth_revoked_token_cannot_access_resources() { .send() .await .unwrap(); - assert_eq!(refresh_res.status(), StatusCode::BAD_REQUEST, "Revoked refresh token should not work"); + assert_eq!( + refresh_res.status(), + StatusCode::BAD_REQUEST, + "Revoked refresh token should not work" + ); } #[tokio::test] @@ -548,8 +608,19 @@ async fn test_oauth_multiple_clients_same_user() { .send() .await .unwrap(); - let location1 = auth_res1.headers().get("location").unwrap().to_str().unwrap(); - let code1 = location1.split("code=").nth(1).unwrap().split('&').next().unwrap(); + let location1 = auth_res1 + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); + let code1 = location1 + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); let token_res1 = http_client .post(format!("{}/oauth/token", url)) .form(&[ @@ -590,8 +661,19 @@ async fn test_oauth_multiple_clients_same_user() { .send() .await .unwrap(); - let location2 = auth_res2.headers().get("location").unwrap().to_str().unwrap(); - let code2 = location2.split("code=").nth(1).unwrap().split('&').next().unwrap(); + let location2 = auth_res2 + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); + let code2 = location2 + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); let token_res2 = http_client .post(format!("{}/oauth/token", url)) .form(&[ @@ -606,7 +688,10 @@ async fn test_oauth_multiple_clients_same_user() { .unwrap(); let token_body2: Value = token_res2.json().await.unwrap(); let token2 = token_body2["access_token"].as_str().unwrap(); - assert_ne!(token1, token2, "Different clients should get different tokens"); + assert_ne!( + token1, token2, + "Different clients should get different tokens" + ); let collection = "app.bsky.feed.post"; let create_res1 = http_client .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) @@ -623,7 +708,11 @@ async fn test_oauth_multiple_clients_same_user() { .send() .await .unwrap(); - assert_eq!(create_res1.status(), StatusCode::OK, "Client 1 token should work"); + assert_eq!( + create_res1.status(), + StatusCode::OK, + "Client 1 token should work" + ); let create_res2 = http_client .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) .bearer_auth(token2) @@ -639,34 +728,36 @@ async fn test_oauth_multiple_clients_same_user() { .send() .await .unwrap(); - assert_eq!(create_res2.status(), StatusCode::OK, "Client 2 token should work"); + assert_eq!( + create_res2.status(), + StatusCode::OK, + "Client 2 token should work" + ); let list_res = http_client .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) .bearer_auth(token1) - .query(&[ - ("repo", user_did), - ("collection", collection), - ]) + .query(&[("repo", user_did), ("collection", collection)]) .send() .await .unwrap(); let list_body: Value = list_res.json().await.unwrap(); let records = list_body["records"].as_array().unwrap(); - assert_eq!(records.len(), 2, "Both posts should be visible to either client"); + assert_eq!( + records.len(), + 2, + "Both posts should be visible to either client" + ); } #[tokio::test] async fn test_oauth_social_interactions_follow_like_repost() { let url = base_url().await; let http_client = client(); - let (alice, _mock_alice) = create_user_and_oauth_session( - "alice-social", - "https://alice-app.example.com/callback" - ).await; - let (bob, _mock_bob) = create_user_and_oauth_session( - "bob-social", - "https://bob-app.example.com/callback" - ).await; + let (alice, _mock_alice) = + create_user_and_oauth_session("alice-social", "https://alice-app.example.com/callback") + .await; + let (bob, _mock_bob) = + create_user_and_oauth_session("bob-social", "https://bob-app.example.com/callback").await; let post_collection = "app.bsky.feed.post"; let post_res = http_client .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) @@ -703,7 +794,11 @@ async fn test_oauth_social_interactions_follow_like_repost() { .send() .await .unwrap(); - assert_eq!(follow_res.status(), StatusCode::OK, "Bob should be able to follow Alice via OAuth"); + assert_eq!( + follow_res.status(), + StatusCode::OK, + "Bob should be able to follow Alice via OAuth" + ); let like_collection = "app.bsky.feed.like"; let like_res = http_client .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) @@ -723,7 +818,11 @@ async fn test_oauth_social_interactions_follow_like_repost() { .send() .await .unwrap(); - assert_eq!(like_res.status(), StatusCode::OK, "Bob should be able to like Alice's post via OAuth"); + assert_eq!( + like_res.status(), + StatusCode::OK, + "Bob should be able to like Alice's post via OAuth" + ); let repost_collection = "app.bsky.feed.repost"; let repost_res = http_client .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) @@ -743,7 +842,11 @@ async fn test_oauth_social_interactions_follow_like_repost() { .send() .await .unwrap(); - assert_eq!(repost_res.status(), StatusCode::OK, "Bob should be able to repost Alice's post via OAuth"); + assert_eq!( + repost_res.status(), + StatusCode::OK, + "Bob should be able to repost Alice's post via OAuth" + ); let bob_follows = http_client .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) .bearer_auth(&bob.access_token) @@ -761,10 +864,7 @@ async fn test_oauth_social_interactions_follow_like_repost() { let bob_likes = http_client .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) .bearer_auth(&bob.access_token) - .query(&[ - ("repo", bob.did.as_str()), - ("collection", like_collection), - ]) + .query(&[("repo", bob.did.as_str()), ("collection", like_collection)]) .send() .await .unwrap(); @@ -777,14 +877,10 @@ async fn test_oauth_social_interactions_follow_like_repost() { async fn test_oauth_cannot_modify_other_users_repo() { let url = base_url().await; let http_client = client(); - let (alice, _mock_alice) = create_user_and_oauth_session( - "alice-boundary", - "https://alice.example.com/callback" - ).await; - let (bob, _mock_bob) = create_user_and_oauth_session( - "bob-boundary", - "https://bob.example.com/callback" - ).await; + let (alice, _mock_alice) = + create_user_and_oauth_session("alice-boundary", "https://alice.example.com/callback").await; + let (bob, _mock_bob) = + create_user_and_oauth_session("bob-boundary", "https://bob.example.com/callback").await; let collection = "app.bsky.feed.post"; let malicious_res = http_client .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) @@ -809,10 +905,7 @@ async fn test_oauth_cannot_modify_other_users_repo() { let alice_posts = http_client .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) .bearer_auth(&alice.access_token) - .query(&[ - ("repo", alice.did.as_str()), - ("collection", collection), - ]) + .query(&[("repo", alice.did.as_str()), ("collection", collection)]) .send() .await .unwrap(); @@ -825,14 +918,11 @@ async fn test_oauth_cannot_modify_other_users_repo() { async fn test_oauth_session_isolation_between_users() { let url = base_url().await; let http_client = client(); - let (alice, _mock_alice) = create_user_and_oauth_session( - "alice-isolation", - "https://alice.example.com/callback" - ).await; - let (bob, _mock_bob) = create_user_and_oauth_session( - "bob-isolation", - "https://bob.example.com/callback" - ).await; + let (alice, _mock_alice) = + create_user_and_oauth_session("alice-isolation", "https://alice.example.com/callback") + .await; + let (bob, _mock_bob) = + create_user_and_oauth_session("bob-isolation", "https://bob.example.com/callback").await; let collection = "app.bsky.feed.post"; let alice_post = http_client .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) @@ -869,10 +959,7 @@ async fn test_oauth_session_isolation_between_users() { let alice_list = http_client .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) .bearer_auth(&alice.access_token) - .query(&[ - ("repo", alice.did.as_str()), - ("collection", collection), - ]) + .query(&[("repo", alice.did.as_str()), ("collection", collection)]) .send() .await .unwrap(); @@ -883,10 +970,7 @@ async fn test_oauth_session_isolation_between_users() { let bob_list = http_client .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) .bearer_auth(&bob.access_token) - .query(&[ - ("repo", bob.did.as_str()), - ("collection", collection), - ]) + .query(&[("repo", bob.did.as_str()), ("collection", collection)]) .send() .await .unwrap(); @@ -900,10 +984,8 @@ async fn test_oauth_session_isolation_between_users() { async fn test_oauth_token_works_with_sync_endpoints() { let url = base_url().await; let http_client = client(); - let (session, _mock) = create_user_and_oauth_session( - "oauth-sync", - "https://example.com/callback" - ).await; + let (session, _mock) = + create_user_and_oauth_session("oauth-sync", "https://example.com/callback").await; let collection = "app.bsky.feed.post"; http_client .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) diff --git a/tests/oauth_security.rs b/tests/oauth_security.rs index c65792b..060e38f 100644 --- a/tests/oauth_security.rs +++ b/tests/oauth_security.rs @@ -3,15 +3,15 @@ mod common; mod helpers; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -use bspds::oauth::dpop::{DPoPVerifier, DPoPJwk, compute_jwk_thumbprint}; +use bspds::oauth::dpop::{DPoPJwk, DPoPVerifier, compute_jwk_thumbprint}; use chrono::Utc; use common::{base_url, client}; use helpers::verify_new_account; -use reqwest::{redirect, StatusCode}; -use serde_json::{json, Value}; +use reqwest::{StatusCode, redirect}; +use serde_json::{Value, json}; use sha2::{Digest, Sha256}; -use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; fn no_redirect_client() -> reqwest::Client { reqwest::Client::builder() @@ -50,10 +50,7 @@ async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer { mock_server } -async fn get_oauth_tokens( - http_client: &reqwest::Client, - url: &str, -) -> (String, String, String) { +async fn get_oauth_tokens(http_client: &reqwest::Client, url: &str) -> (String, String, String) { let ts = Utc::now().timestamp_millis(); let handle = format!("sec-test-{}", ts); let email = format!("sec-test-{}@example.com", ts); @@ -100,8 +97,19 @@ async fn get_oauth_tokens( .send() .await .unwrap(); - let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); - let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); + let location = auth_res + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); + let code = location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); let token_body: Value = http_client .post(format!("{}/oauth/token", url)) .form(&[ @@ -137,7 +145,11 @@ async fn test_security_forged_token_signature_rejected() { .send() .await .unwrap(); - assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Forged signature should be rejected"); + assert_eq!( + res.status(), + StatusCode::UNAUTHORIZED, + "Forged signature should be rejected" + ); } #[tokio::test] @@ -157,7 +169,11 @@ async fn test_security_modified_payload_rejected() { .send() .await .unwrap(); - assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Modified payload should be rejected"); + assert_eq!( + res.status(), + StatusCode::UNAUTHORIZED, + "Modified payload should be rejected" + ); } #[tokio::test] @@ -186,7 +202,11 @@ async fn test_security_algorithm_none_attack_rejected() { .send() .await .unwrap(); - assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Algorithm 'none' attack should be rejected"); + assert_eq!( + res.status(), + StatusCode::UNAUTHORIZED, + "Algorithm 'none' attack should be rejected" + ); } #[tokio::test] @@ -215,7 +235,11 @@ async fn test_security_algorithm_substitution_attack_rejected() { .send() .await .unwrap(); - assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Algorithm substitution attack should be rejected"); + assert_eq!( + res.status(), + StatusCode::UNAUTHORIZED, + "Algorithm substitution attack should be rejected" + ); } #[tokio::test] @@ -244,7 +268,11 @@ async fn test_security_expired_token_rejected() { .send() .await .unwrap(); - assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Expired token should be rejected"); + assert_eq!( + res.status(), + StatusCode::UNAUTHORIZED, + "Expired token should be rejected" + ); } #[tokio::test] @@ -266,11 +294,19 @@ async fn test_security_pkce_plain_method_rejected() { .send() .await .unwrap(); - assert_eq!(res.status(), StatusCode::BAD_REQUEST, "PKCE plain method should be rejected"); + assert_eq!( + res.status(), + StatusCode::BAD_REQUEST, + "PKCE plain method should be rejected" + ); let body: Value = res.json().await.unwrap(); assert_eq!(body["error"], "invalid_request"); assert!( - body["error_description"].as_str().unwrap().to_lowercase().contains("s256"), + body["error_description"] + .as_str() + .unwrap() + .to_lowercase() + .contains("s256"), "Error should mention S256 requirement" ); } @@ -292,7 +328,11 @@ async fn test_security_pkce_missing_challenge_rejected() { .send() .await .unwrap(); - assert_eq!(res.status(), StatusCode::BAD_REQUEST, "Missing PKCE challenge should be rejected"); + assert_eq!( + res.status(), + StatusCode::BAD_REQUEST, + "Missing PKCE challenge should be rejected" + ); } #[tokio::test] @@ -346,8 +386,19 @@ async fn test_security_pkce_wrong_verifier_rejected() { .send() .await .unwrap(); - let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); - let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); + let location = auth_res + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); + let code = location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); let token_res = http_client .post(format!("{}/oauth/token", url)) .form(&[ @@ -360,7 +411,11 @@ async fn test_security_pkce_wrong_verifier_rejected() { .send() .await .unwrap(); - assert_eq!(token_res.status(), StatusCode::BAD_REQUEST, "Wrong PKCE verifier should be rejected"); + assert_eq!( + token_res.status(), + StatusCode::BAD_REQUEST, + "Wrong PKCE verifier should be rejected" + ); let body: Value = token_res.json().await.unwrap(); assert_eq!(body["error"], "invalid_grant"); } @@ -415,8 +470,19 @@ async fn test_security_authorization_code_replay_attack() { .send() .await .unwrap(); - let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); - let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); + let location = auth_res + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); + let code = location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); let stolen_code = code.to_string(); let first_res = http_client .post(format!("{}/oauth/token", url)) @@ -430,7 +496,11 @@ async fn test_security_authorization_code_replay_attack() { .send() .await .unwrap(); - assert_eq!(first_res.status(), StatusCode::OK, "First use should succeed"); + assert_eq!( + first_res.status(), + StatusCode::OK, + "First use should succeed" + ); let replay_res = http_client .post(format!("{}/oauth/token", url)) .form(&[ @@ -443,7 +513,11 @@ async fn test_security_authorization_code_replay_attack() { .send() .await .unwrap(); - assert_eq!(replay_res.status(), StatusCode::BAD_REQUEST, "Replay attack should fail"); + assert_eq!( + replay_res.status(), + StatusCode::BAD_REQUEST, + "Replay attack should fail" + ); let body: Value = replay_res.json().await.unwrap(); assert_eq!(body["error"], "invalid_grant"); } @@ -498,8 +572,19 @@ async fn test_security_refresh_token_replay_attack() { .send() .await .unwrap(); - let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); - let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); + let location = auth_res + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); + let code = location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); let token_body: Value = http_client .post(format!("{}/oauth/token", url)) .form(&[ @@ -529,7 +614,10 @@ async fn test_security_refresh_token_replay_attack() { .json() .await .unwrap(); - assert!(first_refresh["access_token"].is_string(), "First refresh should succeed"); + assert!( + first_refresh["access_token"].is_string(), + "First refresh should succeed" + ); let new_refresh_token = first_refresh["refresh_token"].as_str().unwrap(); let replay_res = http_client .post(format!("{}/oauth/token", url)) @@ -541,11 +629,19 @@ async fn test_security_refresh_token_replay_attack() { .send() .await .unwrap(); - assert_eq!(replay_res.status(), StatusCode::BAD_REQUEST, "Refresh token replay should fail"); + assert_eq!( + replay_res.status(), + StatusCode::BAD_REQUEST, + "Refresh token replay should fail" + ); let body: Value = replay_res.json().await.unwrap(); assert_eq!(body["error"], "invalid_grant"); assert!( - body["error_description"].as_str().unwrap().to_lowercase().contains("reuse"), + body["error_description"] + .as_str() + .unwrap() + .to_lowercase() + .contains("reuse"), "Error should mention token reuse" ); let family_revoked_res = http_client @@ -586,7 +682,11 @@ async fn test_security_redirect_uri_manipulation() { .send() .await .unwrap(); - assert_eq!(res.status(), StatusCode::BAD_REQUEST, "Unregistered redirect_uri should be rejected"); + assert_eq!( + res.status(), + StatusCode::BAD_REQUEST, + "Unregistered redirect_uri should be rejected" + ); } #[tokio::test] @@ -651,7 +751,11 @@ async fn test_security_deactivated_account_blocked() { .send() .await .unwrap(); - assert_eq!(auth_res.status(), StatusCode::FORBIDDEN, "Deactivated account should be blocked from OAuth"); + assert_eq!( + auth_res.status(), + StatusCode::FORBIDDEN, + "Deactivated account should be blocked from OAuth" + ); let body: Value = auth_res.json().await.unwrap(); assert_eq!(body["error"], "access_denied"); } @@ -708,8 +812,16 @@ async fn test_security_url_injection_in_state_parameter() { .send() .await .unwrap(); - assert!(auth_res.status().is_redirection(), "Should redirect successfully"); - let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); + assert!( + auth_res.status().is_redirection(), + "Should redirect successfully" + ); + let location = auth_res + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); assert!( location.starts_with(redirect_uri), "Redirect should go to registered URI, not attacker URI. Got: {}", @@ -721,8 +833,8 @@ async fn test_security_url_injection_in_state_parameter() { "State injection should not add extra redirect_uri parameters" ); assert!( - location.contains(&urlencoding::encode(malicious_state).to_string()) || - location.contains("state=state%26redirect_uri"), + location.contains(&urlencoding::encode(malicious_state).to_string()) + || location.contains("state=state%26redirect_uri"), "State parameter should be properly URL-encoded. Got: {}", location ); @@ -781,8 +893,19 @@ async fn test_security_cross_client_token_theft() { .send() .await .unwrap(); - let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); - let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); + let location = auth_res + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); + let code = location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); let token_res = http_client .post(format!("{}/oauth/token", url)) .form(&[ @@ -803,7 +926,10 @@ async fn test_security_cross_client_token_theft() { let body: Value = token_res.json().await.unwrap(); assert_eq!(body["error"], "invalid_grant"); assert!( - body["error_description"].as_str().unwrap().contains("client_id"), + body["error_description"] + .as_str() + .unwrap() + .contains("client_id"), "Error should mention client_id mismatch" ); } @@ -831,12 +957,15 @@ fn test_security_dpop_nonce_cross_server_rejected() { let verifier2 = DPoPVerifier::new(secret2); let nonce_from_server1 = verifier1.generate_nonce(); let result = verifier2.validate_nonce(&nonce_from_server1); - assert!(result.is_err(), "Nonce from different server should be rejected"); + assert!( + result.is_err(), + "Nonce from different server should be rejected" + ); } #[test] fn test_security_dpop_proof_signature_tampering() { - use p256::ecdsa::{SigningKey, Signature, signature::Signer}; + use p256::ecdsa::{Signature, SigningKey, signature::Signer}; use p256::elliptic_curve::sec1::ToEncodedPoint; let secret = b"test-dpop-secret-32-bytes-long!!"; let verifier = DPoPVerifier::new(secret); @@ -870,12 +999,15 @@ fn test_security_dpop_proof_signature_tampering() { let tampered_sig = URL_SAFE_NO_PAD.encode(&sig_bytes); let tampered_proof = format!("{}.{}.{}", header_b64, payload_b64, tampered_sig); let result = verifier.verify_proof(&tampered_proof, "POST", "https://example.com/token", None); - assert!(result.is_err(), "Tampered DPoP signature should be rejected"); + assert!( + result.is_err(), + "Tampered DPoP signature should be rejected" + ); } #[test] fn test_security_dpop_proof_key_substitution() { - use p256::ecdsa::{SigningKey, Signature, signature::Signer}; + use p256::ecdsa::{Signature, SigningKey, signature::Signer}; use p256::elliptic_curve::sec1::ToEncodedPoint; let secret = b"test-dpop-secret-32-bytes-long!!"; let verifier = DPoPVerifier::new(secret); @@ -907,8 +1039,12 @@ fn test_security_dpop_proof_key_substitution() { let signature: Signature = signing_key.sign(signing_input.as_bytes()); let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); let mismatched_proof = format!("{}.{}.{}", header_b64, payload_b64, signature_b64); - let result = verifier.verify_proof(&mismatched_proof, "POST", "https://example.com/token", None); - assert!(result.is_err(), "DPoP proof with mismatched key should be rejected"); + let result = + verifier.verify_proof(&mismatched_proof, "POST", "https://example.com/token", None); + assert!( + result.is_err(), + "DPoP proof with mismatched key should be rejected" + ); } #[test] @@ -925,13 +1061,17 @@ fn test_security_jwk_thumbprint_consistency() { } let first = &results[0]; for (i, result) in results.iter().enumerate() { - assert_eq!(first, result, "Thumbprint should be deterministic, but iteration {} differs", i); + assert_eq!( + first, result, + "Thumbprint should be deterministic, but iteration {} differs", + i + ); } } #[test] fn test_security_dpop_iat_clock_skew_limits() { - use p256::ecdsa::{SigningKey, Signature, signature::Signer}; + use p256::ecdsa::{Signature, SigningKey, signature::Signer}; use p256::elliptic_curve::sec1::ToEncodedPoint; let secret = b"test-dpop-secret-32-bytes-long!!"; let verifier = DPoPVerifier::new(secret); @@ -974,16 +1114,24 @@ fn test_security_dpop_iat_clock_skew_limits() { let proof = format!("{}.{}.{}", header_b64, payload_b64, signature_b64); let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None); if should_fail { - assert!(result.is_err(), "iat offset {} should be rejected", offset_secs); + assert!( + result.is_err(), + "iat offset {} should be rejected", + offset_secs + ); } else { - assert!(result.is_ok(), "iat offset {} should be accepted", offset_secs); + assert!( + result.is_ok(), + "iat offset {} should be accepted", + offset_secs + ); } } } #[test] fn test_security_dpop_method_case_insensitivity() { - use p256::ecdsa::{SigningKey, Signature, signature::Signer}; + use p256::ecdsa::{Signature, SigningKey, signature::Signer}; use p256::elliptic_curve::sec1::ToEncodedPoint; let secret = b"test-dpop-secret-32-bytes-long!!"; let verifier = DPoPVerifier::new(secret); @@ -1015,7 +1163,10 @@ fn test_security_dpop_method_case_insensitivity() { let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); let proof = format!("{}.{}.{}", header_b64, payload_b64, signature_b64); let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None); - assert!(result.is_ok(), "HTTP method comparison should be case-insensitive"); + assert!( + result.is_ok(), + "HTTP method comparison should be case-insensitive" + ); } #[tokio::test] @@ -1055,13 +1206,7 @@ async fn test_security_invalid_grant_type_rejected() { async fn test_security_token_with_wrong_typ_rejected() { let url = base_url().await; let http_client = client(); - let wrong_types = vec![ - "JWT", - "jwt", - "at+JWT", - "access_token", - "", - ]; + let wrong_types = vec!["JWT", "jwt", "at+JWT", "access_token", ""]; for typ in wrong_types { let header = json!({ "alg": "HS256", @@ -1100,8 +1245,14 @@ async fn test_security_missing_required_claims_rejected() { let http_client = client(); let tokens_missing_claims = vec![ (json!({"iss": "x", "sub": "x", "aud": "x", "iat": 0}), "exp"), - (json!({"iss": "x", "sub": "x", "aud": "x", "exp": 9999999999i64}), "iat"), - (json!({"iss": "x", "aud": "x", "iat": 0, "exp": 9999999999i64}), "sub"), + ( + json!({"iss": "x", "sub": "x", "aud": "x", "exp": 9999999999i64}), + "iat", + ), + ( + json!({"iss": "x", "aud": "x", "iat": 0, "exp": 9999999999i64}), + "sub", + ), ]; for (payload, missing_claim) in tokens_missing_claims { let header = json!({ @@ -1155,7 +1306,11 @@ async fn test_security_malformed_tokens_rejected() { res.status(), StatusCode::UNAUTHORIZED, "Malformed token '{}' should be rejected", - if token.len() > 50 { &token[..50] } else { token } + if token.len() > 50 { + &token[..50] + } else { + token + } ); } } @@ -1181,7 +1336,11 @@ async fn test_security_authorization_header_formats() { res.status(), StatusCode::OK, "Auth header '{}...' should be accepted (RFC 7235 case-insensitivity)", - if auth_header.len() > 30 { &auth_header[..30] } else { &auth_header } + if auth_header.len() > 30 { + &auth_header[..30] + } else { + &auth_header + } ); } let invalid_formats = vec![ @@ -1201,7 +1360,11 @@ async fn test_security_authorization_header_formats() { res.status(), StatusCode::UNAUTHORIZED, "Auth header '{}...' should be rejected", - if auth_header.len() > 30 { &auth_header[..30] } else { &auth_header } + if auth_header.len() > 30 { + &auth_header[..30] + } else { + &auth_header + } ); } } @@ -1215,7 +1378,11 @@ async fn test_security_no_authorization_header() { .send() .await .unwrap(); - assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Missing auth header should return 401"); + assert_eq!( + res.status(), + StatusCode::UNAUTHORIZED, + "Missing auth header should return 401" + ); } #[tokio::test] @@ -1228,7 +1395,11 @@ async fn test_security_empty_authorization_header() { .send() .await .unwrap(); - assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Empty auth header should return 401"); + assert_eq!( + res.status(), + StatusCode::UNAUTHORIZED, + "Empty auth header should return 401" + ); } #[tokio::test] @@ -1250,7 +1421,10 @@ async fn test_security_revoked_token_rejected() { .await .unwrap(); let introspect_body: Value = introspect_res.json().await.unwrap(); - assert_eq!(introspect_body["active"], false, "Revoked token should be inactive"); + assert_eq!( + introspect_body["active"], false, + "Revoked token should be inactive" + ); } #[tokio::test] @@ -1259,7 +1433,12 @@ async fn test_security_oauth_authorize_rate_limiting() { let url = base_url().await; let http_client = no_redirect_client(); let ts = Utc::now().timestamp_nanos_opt().unwrap_or(0); - let unique_ip = format!("10.{}.{}.{}", (ts >> 16) & 0xFF, (ts >> 8) & 0xFF, ts & 0xFF); + let unique_ip = format!( + "10.{}.{}.{}", + (ts >> 16) & 0xFF, + (ts >> 8) & 0xFF, + ts & 0xFF + ); let redirect_uri = "https://example.com/rate-limit-callback"; let mock_client = setup_mock_client_metadata(redirect_uri).await; let client_id = mock_client.uri(); @@ -1316,7 +1495,7 @@ fn create_dpop_proof( ath: Option<&str>, iat_offset_secs: i64, ) -> String { - use p256::ecdsa::{SigningKey, Signature, signature::Signer}; + use p256::ecdsa::{Signature, SigningKey, signature::Signer}; let signing_key = SigningKey::random(&mut rand::thread_rng()); let verifying_key = signing_key.verifying_key(); let point = verifying_key.to_encoded_point(false); @@ -1404,7 +1583,10 @@ fn test_jwk_thumbprint_ec_p256() { assert!(thumbprint.is_ok()); let tp = thumbprint.unwrap(); assert!(!tp.is_empty()); - assert!(tp.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')); + assert!( + tp.chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') + ); } #[test] @@ -1604,11 +1786,10 @@ fn test_dpop_proof_uri_ignores_query_params() { let secret = b"test-dpop-secret-32-bytes-long!!"; let verifier = DPoPVerifier::new(secret); let proof = create_dpop_proof("POST", "https://example.com/token", None, None, 0); - let result = verifier.verify_proof( - &proof, - "POST", - "https://example.com/token?foo=bar", - None, + let result = verifier.verify_proof(&proof, "POST", "https://example.com/token?foo=bar", None); + assert!( + result.is_ok(), + "Query params should be ignored: {:?}", + result ); - assert!(result.is_ok(), "Query params should be ignored: {:?}", result); } diff --git a/tests/password_reset.rs b/tests/password_reset.rs index 245d947..8fcd139 100644 --- a/tests/password_reset.rs +++ b/tests/password_reset.rs @@ -1,9 +1,9 @@ mod common; mod helpers; -use reqwest::StatusCode; -use serde_json::{json, Value}; -use sqlx::PgPool; use helpers::verify_new_account; +use reqwest::StatusCode; +use serde_json::{Value, json}; +use sqlx::PgPool; async fn get_pool() -> PgPool { let conn_str = common::get_db_connection_string().await; @@ -27,14 +27,20 @@ async fn test_request_password_reset_creates_code() { "password": "oldpassword" }); let res = client - .post(format!("{}/xrpc/com.atproto.server.createAccount", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url + )) .json(&payload) .send() .await .expect("Failed to create account"); assert_eq!(res.status(), StatusCode::OK); let res = client - .post(format!("{}/xrpc/com.atproto.server.requestPasswordReset", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.requestPasswordReset", + base_url + )) .json(&json!({"email": email})) .send() .await @@ -59,7 +65,10 @@ async fn test_request_password_reset_unknown_email_returns_ok() { let client = common::client(); let base_url = common::base_url().await; let res = client - .post(format!("{}/xrpc/com.atproto.server.requestPasswordReset", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.requestPasswordReset", + base_url + )) .json(&json!({"email": "nonexistent@example.com"})) .send() .await @@ -82,7 +91,10 @@ async fn test_reset_password_with_valid_token() { "password": old_password }); let res = client - .post(format!("{}/xrpc/com.atproto.server.createAccount", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url + )) .json(&payload) .send() .await @@ -92,7 +104,10 @@ async fn test_reset_password_with_valid_token() { let did = body["did"].as_str().unwrap(); let _ = verify_new_account(&client, did).await; let res = client - .post(format!("{}/xrpc/com.atproto.server.requestPasswordReset", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.requestPasswordReset", + base_url + )) .json(&json!({"email": email})) .send() .await @@ -107,7 +122,10 @@ async fn test_reset_password_with_valid_token() { .expect("User not found"); let token = user.password_reset_code.expect("No reset code"); let res = client - .post(format!("{}/xrpc/com.atproto.server.resetPassword", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.resetPassword", + base_url + )) .json(&json!({ "token": token, "password": new_password @@ -126,7 +144,10 @@ async fn test_reset_password_with_valid_token() { assert!(user.password_reset_code.is_none()); assert!(user.password_reset_code_expires_at.is_none()); let res = client - .post(format!("{}/xrpc/com.atproto.server.createSession", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.createSession", + base_url + )) .json(&json!({ "identifier": handle, "password": new_password @@ -136,7 +157,10 @@ async fn test_reset_password_with_valid_token() { .expect("Failed to login"); assert_eq!(res.status(), StatusCode::OK); let res = client - .post(format!("{}/xrpc/com.atproto.server.createSession", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.createSession", + base_url + )) .json(&json!({ "identifier": handle, "password": old_password @@ -152,7 +176,10 @@ async fn test_reset_password_with_invalid_token() { let client = common::client(); let base_url = common::base_url().await; let res = client - .post(format!("{}/xrpc/com.atproto.server.resetPassword", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.resetPassword", + base_url + )) .json(&json!({ "token": "invalid-token", "password": "newpassword" @@ -178,14 +205,20 @@ async fn test_reset_password_with_expired_token() { "password": "oldpassword" }); let res = client - .post(format!("{}/xrpc/com.atproto.server.createAccount", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url + )) .json(&payload) .send() .await .expect("Failed to create account"); assert_eq!(res.status(), StatusCode::OK); let res = client - .post(format!("{}/xrpc/com.atproto.server.requestPasswordReset", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.requestPasswordReset", + base_url + )) .json(&json!({"email": email})) .send() .await @@ -207,7 +240,10 @@ async fn test_reset_password_with_expired_token() { .await .expect("Failed to expire token"); let res = client - .post(format!("{}/xrpc/com.atproto.server.resetPassword", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.resetPassword", + base_url + )) .json(&json!({ "token": token, "password": "newpassword" @@ -233,7 +269,10 @@ async fn test_reset_password_invalidates_sessions() { "password": "oldpassword" }); let res = client - .post(format!("{}/xrpc/com.atproto.server.createAccount", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url + )) .json(&payload) .send() .await @@ -250,7 +289,10 @@ async fn test_reset_password_invalidates_sessions() { .expect("Failed to get session"); assert_eq!(res.status(), StatusCode::OK); let res = client - .post(format!("{}/xrpc/com.atproto.server.requestPasswordReset", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.requestPasswordReset", + base_url + )) .json(&json!({"email": email})) .send() .await @@ -265,7 +307,10 @@ async fn test_reset_password_invalidates_sessions() { .expect("User not found"); let token = user.password_reset_code.expect("No reset code"); let res = client - .post(format!("{}/xrpc/com.atproto.server.resetPassword", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.resetPassword", + base_url + )) .json(&json!({ "token": token, "password": "newpassword123" @@ -288,7 +333,10 @@ async fn test_request_password_reset_empty_email() { let client = common::client(); let base_url = common::base_url().await; let res = client - .post(format!("{}/xrpc/com.atproto.server.requestPasswordReset", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.requestPasswordReset", + base_url + )) .json(&json!({"email": ""})) .send() .await @@ -311,7 +359,10 @@ async fn test_reset_password_creates_notification() { "password": "oldpassword" }); let res = client - .post(format!("{}/xrpc/com.atproto.server.createAccount", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url + )) .json(&payload) .send() .await @@ -330,7 +381,10 @@ async fn test_reset_password_creates_notification() { .expect("Failed to count") .unwrap_or(0); let res = client - .post(format!("{}/xrpc/com.atproto.server.requestPasswordReset", base_url)) + .post(format!( + "{}/xrpc/com.atproto.server.requestPasswordReset", + base_url + )) .json(&json!({"email": email})) .send() .await diff --git a/tests/plc_migration.rs b/tests/plc_migration.rs index ae29da0..e3a7274 100644 --- a/tests/plc_migration.rs +++ b/tests/plc_migration.rs @@ -2,7 +2,7 @@ mod common; use common::*; use k256::ecdsa::SigningKey; use reqwest::StatusCode; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use sqlx::PgPool; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -73,13 +73,10 @@ async fn get_plc_token_from_db(did: &str) -> Option { async fn get_user_handle(did: &str) -> Option { let db_url = get_db_connection_string().await; let pool = PgPool::connect(&db_url).await.ok()?; - sqlx::query_scalar!( - r#"SELECT handle FROM users WHERE did = $1"#, - did - ) - .fetch_optional(&pool) - .await - .ok()? + sqlx::query_scalar!(r#"SELECT handle FROM users WHERE did = $1"#, did) + .fetch_optional(&pool) + .await + .ok()? } fn create_mock_last_op( @@ -107,7 +104,12 @@ fn create_mock_last_op( }) } -fn create_did_document(did: &str, handle: &str, signing_key: &SigningKey, pds_endpoint: &str) -> Value { +fn create_did_document( + did: &str, + handle: &str, + signing_key: &SigningKey, + pds_endpoint: &str, +) -> Value { let multikey = get_multikey_from_signing_key(signing_key); json!({ "@context": [ @@ -174,11 +176,12 @@ async fn setup_mock_plc_for_submit( async fn test_full_plc_operation_flow() { let client = client(); let (token, did) = create_account_and_login(&client).await; - let key_bytes = get_user_signing_key(&did).await + let key_bytes = get_user_signing_key(&did) + .await .expect("Failed to get user signing key"); - let signing_key = SigningKey::from_slice(&key_bytes) - .expect("Failed to create signing key"); - let handle = get_user_handle(&did).await + let signing_key = SigningKey::from_slice(&key_bytes).expect("Failed to create signing key"); + let handle = get_user_handle(&did) + .await .expect("Failed to get user handle"); let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let pds_endpoint = format!("https://{}", hostname); @@ -192,7 +195,8 @@ async fn test_full_plc_operation_flow() { .await .expect("Request failed"); assert_eq!(request_res.status(), StatusCode::OK); - let plc_token = get_plc_token_from_db(&did).await + let plc_token = get_plc_token_from_db(&did) + .await .expect("PLC token not found in database"); let mock_plc = setup_mock_plc_for_sign(&did, &handle, &signing_key, &pds_endpoint).await; unsafe { @@ -218,11 +222,18 @@ async fn test_full_plc_operation_flow() { "Sign PLC operation should succeed. Response: {:?}", sign_body ); - let operation = sign_body.get("operation") + let operation = sign_body + .get("operation") .expect("Response should contain operation"); assert!(operation.get("sig").is_some(), "Operation should be signed"); - assert_eq!(operation.get("type").and_then(|v| v.as_str()), Some("plc_operation")); - assert!(operation.get("prev").is_some(), "Operation should have prev reference"); + assert_eq!( + operation.get("type").and_then(|v| v.as_str()), + Some("plc_operation") + ); + assert!( + operation.get("prev").is_some(), + "Operation should have prev reference" + ); } #[tokio::test] @@ -230,11 +241,12 @@ async fn test_full_plc_operation_flow() { async fn test_sign_plc_operation_consumes_token() { let client = client(); let (token, did) = create_account_and_login(&client).await; - let key_bytes = get_user_signing_key(&did).await + let key_bytes = get_user_signing_key(&did) + .await .expect("Failed to get user signing key"); - let signing_key = SigningKey::from_slice(&key_bytes) - .expect("Failed to create signing key"); - let handle = get_user_handle(&did).await + let signing_key = SigningKey::from_slice(&key_bytes).expect("Failed to create signing key"); + let handle = get_user_handle(&did) + .await .expect("Failed to get user handle"); let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let pds_endpoint = format!("https://{}", hostname); @@ -248,7 +260,8 @@ async fn test_sign_plc_operation_consumes_token() { .await .expect("Request failed"); assert_eq!(request_res.status(), StatusCode::OK); - let plc_token = get_plc_token_from_db(&did).await + let plc_token = get_plc_token_from_db(&did) + .await .expect("PLC token not found in database"); let mock_plc = setup_mock_plc_for_sign(&did, &handle, &signing_key, &pds_endpoint).await; unsafe { @@ -292,14 +305,16 @@ async fn test_sign_plc_operation_consumes_token() { } #[tokio::test] +#[ignore = "requires exclusive env var access; run with: cargo test test_sign_plc_operation_with_custom_fields -- --ignored --test-threads=1"] async fn test_sign_plc_operation_with_custom_fields() { let client = client(); let (token, did) = create_account_and_login(&client).await; - let key_bytes = get_user_signing_key(&did).await + let key_bytes = get_user_signing_key(&did) + .await .expect("Failed to get user signing key"); - let signing_key = SigningKey::from_slice(&key_bytes) - .expect("Failed to create signing key"); - let handle = get_user_handle(&did).await + let signing_key = SigningKey::from_slice(&key_bytes).expect("Failed to create signing key"); + let handle = get_user_handle(&did) + .await .expect("Failed to get user handle"); let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let pds_endpoint = format!("https://{}", hostname); @@ -313,7 +328,8 @@ async fn test_sign_plc_operation_with_custom_fields() { .await .expect("Request failed"); assert_eq!(request_res.status(), StatusCode::OK); - let plc_token = get_plc_token_from_db(&did).await + let plc_token = get_plc_token_from_db(&did) + .await .expect("PLC token not found in database"); let mock_plc = setup_mock_plc_for_sign(&did, &handle, &signing_key, &pds_endpoint).await; unsafe { @@ -348,7 +364,11 @@ async fn test_sign_plc_operation_with_custom_fields() { assert!(also_known_as.is_some(), "Should have alsoKnownAs"); assert!(rotation_keys.is_some(), "Should have rotationKeys"); assert_eq!(also_known_as.unwrap().len(), 2, "Should have 2 aliases"); - assert_eq!(rotation_keys.unwrap().len(), 2, "Should have 2 rotation keys"); + assert_eq!( + rotation_keys.unwrap().len(), + 2, + "Should have 2 rotation keys" + ); } #[tokio::test] @@ -356,11 +376,12 @@ async fn test_sign_plc_operation_with_custom_fields() { async fn test_submit_plc_operation_success() { let client = client(); let (token, did) = create_account_and_login(&client).await; - let key_bytes = get_user_signing_key(&did).await + let key_bytes = get_user_signing_key(&did) + .await .expect("Failed to get user signing key"); - let signing_key = SigningKey::from_slice(&key_bytes) - .expect("Failed to create signing key"); - let handle = get_user_handle(&did).await + let signing_key = SigningKey::from_slice(&key_bytes).expect("Failed to create signing key"); + let handle = get_user_handle(&did) + .await .expect("Failed to get user handle"); let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let pds_endpoint = format!("https://{}", hostname); @@ -409,11 +430,12 @@ async fn test_submit_plc_operation_success() { async fn test_submit_plc_operation_wrong_endpoint_rejected() { let client = client(); let (token, did) = create_account_and_login(&client).await; - let key_bytes = get_user_signing_key(&did).await + let key_bytes = get_user_signing_key(&did) + .await .expect("Failed to get user signing key"); - let signing_key = SigningKey::from_slice(&key_bytes) - .expect("Failed to create signing key"); - let handle = get_user_handle(&did).await + let signing_key = SigningKey::from_slice(&key_bytes).expect("Failed to create signing key"); + let handle = get_user_handle(&did) + .await .expect("Failed to get user handle"); let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let pds_endpoint = format!("https://{}", hostname); @@ -461,11 +483,12 @@ async fn test_submit_plc_operation_wrong_endpoint_rejected() { async fn test_submit_plc_operation_wrong_signing_key_rejected() { let client = client(); let (token, did) = create_account_and_login(&client).await; - let key_bytes = get_user_signing_key(&did).await + let key_bytes = get_user_signing_key(&did) + .await .expect("Failed to get user signing key"); - let signing_key = SigningKey::from_slice(&key_bytes) - .expect("Failed to create signing key"); - let handle = get_user_handle(&did).await + let signing_key = SigningKey::from_slice(&key_bytes).expect("Failed to create signing key"); + let handle = get_user_handle(&did) + .await .expect("Failed to get user handle"); let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let pds_endpoint = format!("https://{}", hostname); @@ -515,11 +538,12 @@ async fn test_submit_plc_operation_wrong_signing_key_rejected() { async fn test_full_sign_and_submit_flow() { let client = client(); let (token, did) = create_account_and_login(&client).await; - let key_bytes = get_user_signing_key(&did).await + let key_bytes = get_user_signing_key(&did) + .await .expect("Failed to get user signing key"); - let signing_key = SigningKey::from_slice(&key_bytes) - .expect("Failed to create signing key"); - let handle = get_user_handle(&did).await + let signing_key = SigningKey::from_slice(&key_bytes).expect("Failed to create signing key"); + let handle = get_user_handle(&did) + .await .expect("Failed to get user handle"); let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let pds_endpoint = format!("https://{}", hostname); @@ -533,7 +557,8 @@ async fn test_full_sign_and_submit_flow() { .await .expect("Request failed"); assert_eq!(request_res.status(), StatusCode::OK); - let plc_token = get_plc_token_from_db(&did).await + let plc_token = get_plc_token_from_db(&did) + .await .expect("PLC token not found"); let mock_server = MockServer::start().await; let did_encoded = urlencoding::encode(&did); @@ -586,7 +611,8 @@ async fn test_full_sign_and_submit_flow() { .expect("Sign failed"); assert_eq!(sign_res.status(), StatusCode::OK); let sign_body: Value = sign_res.json().await.unwrap(); - let signed_operation = sign_body.get("operation") + let signed_operation = sign_body + .get("operation") .expect("Response should contain operation") .clone(); assert!(signed_operation.get("sig").is_some()); @@ -612,14 +638,16 @@ async fn test_full_sign_and_submit_flow() { } #[tokio::test] +#[ignore = "requires exclusive env var access; run with: cargo test test_cross_pds_migration_with_records -- --ignored --test-threads=1"] async fn test_cross_pds_migration_with_records() { let client = client(); let (token, did) = create_account_and_login(&client).await; - let key_bytes = get_user_signing_key(&did).await + let key_bytes = get_user_signing_key(&did) + .await .expect("Failed to get user signing key"); - let signing_key = SigningKey::from_slice(&key_bytes) - .expect("Failed to create signing key"); - let handle = get_user_handle(&did).await + let signing_key = SigningKey::from_slice(&key_bytes).expect("Failed to create signing key"); + let handle = get_user_handle(&did) + .await .expect("Failed to get user handle"); let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let pds_endpoint = format!("https://{}", hostname); @@ -656,7 +684,10 @@ async fn test_cross_pds_migration_with_records() { .expect("Export failed"); assert_eq!(export_res.status(), StatusCode::OK); let car_bytes = export_res.bytes().await.unwrap(); - assert!(car_bytes.len() > 100, "CAR file should have meaningful content"); + assert!( + car_bytes.len() > 100, + "CAR file should have meaningful content" + ); let mock_server = MockServer::start().await; let did_encoded = urlencoding::encode(&did); let did_doc = create_did_document(&did, &handle, &signing_key, &pds_endpoint); @@ -670,7 +701,10 @@ async fn test_cross_pds_migration_with_records() { std::env::remove_var("SKIP_IMPORT_VERIFICATION"); } let import_res = client - .post(format!("{}/xrpc/com.atproto.repo.importRepo", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.importRepo", + base_url().await + )) .bearer_auth(&token) .header("Content-Type", "application/vnd.ipld.car") .body(car_bytes.to_vec()) @@ -705,8 +739,7 @@ async fn test_cross_pds_migration_with_records() { ); let record_body: Value = get_record_res.json().await.unwrap(); assert_eq!( - record_body["value"]["text"], - "Test post before migration", + record_body["value"]["text"], "Test post before migration", "Record content should match" ); } @@ -716,7 +749,8 @@ async fn test_migration_rejects_wrong_did_document() { let client = client(); let (token, did) = create_account_and_login(&client).await; let wrong_signing_key = SigningKey::random(&mut rand::thread_rng()); - let handle = get_user_handle(&did).await + let handle = get_user_handle(&did) + .await .expect("Failed to get user handle"); let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let pds_endpoint = format!("https://{}", hostname); @@ -744,7 +778,10 @@ async fn test_migration_rejects_wrong_did_document() { std::env::remove_var("SKIP_IMPORT_VERIFICATION"); } let import_res = client - .post(format!("{}/xrpc/com.atproto.repo.importRepo", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.importRepo", + base_url().await + )) .bearer_auth(&token) .header("Content-Type", "application/vnd.ipld.car") .body(car_bytes.to_vec()) @@ -763,8 +800,11 @@ async fn test_migration_rejects_wrong_did_document() { import_body ); assert!( - import_body["error"] == "InvalidSignature" || - import_body["message"].as_str().unwrap_or("").contains("signature"), + import_body["error"] == "InvalidSignature" + || import_body["message"] + .as_str() + .unwrap_or("") + .contains("signature"), "Error should mention signature verification failure" ); } @@ -774,11 +814,12 @@ async fn test_migration_rejects_wrong_did_document() { async fn test_full_migration_flow_end_to_end() { let client = client(); let (token, did) = create_account_and_login(&client).await; - let key_bytes = get_user_signing_key(&did).await + let key_bytes = get_user_signing_key(&did) + .await .expect("Failed to get user signing key"); - let signing_key = SigningKey::from_slice(&key_bytes) - .expect("Failed to create signing key"); - let handle = get_user_handle(&did).await + let signing_key = SigningKey::from_slice(&key_bytes).expect("Failed to create signing key"); + let handle = get_user_handle(&did) + .await .expect("Failed to get user handle"); let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let pds_endpoint = format!("https://{}", hostname); @@ -815,7 +856,8 @@ async fn test_full_migration_flow_end_to_end() { .await .expect("Request failed"); assert_eq!(request_res.status(), StatusCode::OK); - let plc_token = get_plc_token_from_db(&did).await + let plc_token = get_plc_token_from_db(&did) + .await .expect("PLC token not found"); let mock_server = MockServer::start().await; let did_encoded = urlencoding::encode(&did); @@ -892,7 +934,10 @@ async fn test_full_migration_flow_end_to_end() { std::env::remove_var("SKIP_IMPORT_VERIFICATION"); } let import_res = client - .post(format!("{}/xrpc/com.atproto.repo.importRepo", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.importRepo", + base_url().await + )) .bearer_auth(&token) .header("Content-Type", "application/vnd.ipld.car") .body(car_bytes.to_vec()) @@ -921,7 +966,8 @@ async fn test_full_migration_flow_end_to_end() { .expect("List failed"); assert_eq!(list_res.status(), StatusCode::OK); let list_body: Value = list_res.json().await.unwrap(); - let records = list_body["records"].as_array() + let records = list_body["records"] + .as_array() .expect("Should have records array"); assert!( records.len() >= 1, diff --git a/tests/plc_operations.rs b/tests/plc_operations.rs index 5018049..b526f62 100644 --- a/tests/plc_operations.rs +++ b/tests/plc_operations.rs @@ -219,9 +219,15 @@ async fn test_request_plc_operation_creates_token_in_db() { .expect("Query failed"); assert!(row.is_some(), "PLC token should be created in database"); let row = row.unwrap(); - assert!(row.token.len() == 11, "Token should be in format xxxxx-xxxxx"); + assert!( + row.token.len() == 11, + "Token should be in format xxxxx-xxxxx" + ); assert!(row.token.contains('-'), "Token should contain hyphen"); - assert!(row.expires_at > chrono::Utc::now(), "Token should not be expired"); + assert!( + row.expires_at > chrono::Utc::now(), + "Token should not be expired" + ); } #[tokio::test] @@ -294,9 +300,8 @@ async fn test_request_plc_operation_replaces_existing_token() { async fn test_submit_plc_operation_wrong_verification_method() { let client = client(); let (token, did) = create_account_and_login(&client).await; - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| { - format!("127.0.0.1:{}", app_port()) - }); + let hostname = + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| format!("127.0.0.1:{}", app_port())); let handle = did.split(':').last().unwrap_or("user"); let res = client .post(format!( @@ -327,8 +332,11 @@ async fn test_submit_plc_operation_wrong_verification_method() { let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["error"], "InvalidRequest"); assert!( - body["message"].as_str().unwrap_or("").contains("signing key") || - body["message"].as_str().unwrap_or("").contains("rotation"), + body["message"] + .as_str() + .unwrap_or("") + .contains("signing key") + || body["message"].as_str().unwrap_or("").contains("rotation"), "Error should mention key mismatch: {:?}", body ); @@ -338,9 +346,8 @@ async fn test_submit_plc_operation_wrong_verification_method() { async fn test_submit_plc_operation_wrong_handle() { let client = client(); let (token, _did) = create_account_and_login(&client).await; - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| { - format!("127.0.0.1:{}", app_port()) - }); + let hostname = + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| format!("127.0.0.1:{}", app_port())); let res = client .post(format!( "{}/xrpc/com.atproto.identity.submitPlcOperation", @@ -375,9 +382,8 @@ async fn test_submit_plc_operation_wrong_handle() { async fn test_submit_plc_operation_wrong_service_type() { let client = client(); let (token, _did) = create_account_and_login(&client).await; - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| { - format!("127.0.0.1:{}", app_port()) - }); + let hostname = + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| format!("127.0.0.1:{}", app_port())); let res = client .post(format!( "{}/xrpc/com.atproto.identity.submitPlcOperation", @@ -439,6 +445,14 @@ async fn test_plc_token_expiry_format() { let now = chrono::Utc::now(); let expires = row.expires_at; let diff = expires - now; - assert!(diff.num_minutes() >= 9, "Token should expire in ~10 minutes, got {} minutes", diff.num_minutes()); - assert!(diff.num_minutes() <= 11, "Token should expire in ~10 minutes, got {} minutes", diff.num_minutes()); + assert!( + diff.num_minutes() >= 9, + "Token should expire in ~10 minutes, got {} minutes", + diff.num_minutes() + ); + assert!( + diff.num_minutes() <= 11, + "Token should expire in ~10 minutes, got {} minutes", + diff.num_minutes() + ); } diff --git a/tests/plc_validation.rs b/tests/plc_validation.rs index eb0946c..ca3a93c 100644 --- a/tests/plc_validation.rs +++ b/tests/plc_validation.rs @@ -1,7 +1,6 @@ use bspds::plc::{ - PlcError, PlcOperation, PlcService, PlcValidationContext, - cid_for_cbor, sign_operation, signing_key_to_did_key, - validate_plc_operation, validate_plc_operation_for_submission, + PlcError, PlcOperation, PlcService, PlcValidationContext, cid_for_cbor, sign_operation, + signing_key_to_did_key, validate_plc_operation, validate_plc_operation_for_submission, verify_operation_signature, }; use k256::ecdsa::SigningKey; @@ -95,7 +94,9 @@ fn test_validate_plc_operation_missing_verification_methods() { "sig": "test" }); let result = validate_plc_operation(&op); - assert!(matches!(result, Err(PlcError::InvalidResponse(msg)) if msg.contains("verificationMethods"))); + assert!( + matches!(result, Err(PlcError::InvalidResponse(msg)) if msg.contains("verificationMethods")) + ); } #[test] @@ -338,7 +339,10 @@ fn test_cid_for_cbor_deterministic() { let cid1 = cid_for_cbor(&value).unwrap(); let cid2 = cid_for_cbor(&value).unwrap(); assert_eq!(cid1, cid2, "CID generation should be deterministic"); - assert!(cid1.starts_with("bafyrei"), "CID should start with bafyrei (dag-cbor + sha256)"); + assert!( + cid1.starts_with("bafyrei"), + "CID should start with bafyrei (dag-cbor + sha256)" + ); } #[test] @@ -354,7 +358,10 @@ fn test_cid_different_for_different_data() { fn test_signing_key_to_did_key_format() { let key = SigningKey::random(&mut rand::thread_rng()); let did_key = signing_key_to_did_key(&key); - assert!(did_key.starts_with("did:key:z"), "Should start with did:key:z"); + assert!( + did_key.starts_with("did:key:z"), + "Should start with did:key:z" + ); assert!(did_key.len() > 50, "Did key should be reasonably long"); } @@ -364,7 +371,10 @@ fn test_signing_key_to_did_key_unique() { let key2 = SigningKey::random(&mut rand::thread_rng()); let did1 = signing_key_to_did_key(&key1); let did2 = signing_key_to_did_key(&key2); - assert_ne!(did1, did2, "Different keys should produce different did:keys"); + assert_ne!( + did1, did2, + "Different keys should produce different did:keys" + ); } #[test] @@ -414,7 +424,10 @@ fn test_validate_for_submission_tombstone_passes() { expected_pds_endpoint: "https://pds.example.com".to_string(), }; let result = validate_plc_operation_for_submission(&op, &ctx); - assert!(result.is_ok(), "Tombstone should pass submission validation"); + assert!( + result.is_ok(), + "Tombstone should pass submission validation" + ); } #[test] @@ -447,10 +460,13 @@ fn test_verify_signature_invalid_base64() { #[test] fn test_plc_operation_struct() { let mut services = HashMap::new(); - services.insert("atproto_pds".to_string(), PlcService { - service_type: "AtprotoPersonalDataServer".to_string(), - endpoint: "https://pds.example.com".to_string(), - }); + services.insert( + "atproto_pds".to_string(), + PlcService { + service_type: "AtprotoPersonalDataServer".to_string(), + endpoint: "https://pds.example.com".to_string(), + }, + ); let mut verification_methods = HashMap::new(); verification_methods.insert("atproto".to_string(), "did:key:zTest123".to_string()); let op = PlcOperation { diff --git a/tests/proxy.rs b/tests/proxy.rs deleted file mode 100644 index f2ed42e..0000000 --- a/tests/proxy.rs +++ /dev/null @@ -1,141 +0,0 @@ -mod common; -use axum::{Router, extract::Request, http::StatusCode, routing::any}; -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -use reqwest::Client; -use std::sync::Arc; -use tokio::net::TcpListener; - -async fn spawn_mock_upstream() -> ( - String, - tokio::sync::mpsc::Receiver<(String, String, Option)>, -) { - let (tx, rx) = tokio::sync::mpsc::channel(10); - let tx = Arc::new(tx); - let app = Router::new().fallback(any(move |req: Request| { - let tx = tx.clone(); - async move { - let method = req.method().to_string(); - let uri = req.uri().to_string(); - let auth = req - .headers() - .get("Authorization") - .and_then(|h| h.to_str().ok()) - .map(|s| s.to_string()); - let _ = tx.send((method, uri, auth)).await; - (StatusCode::OK, "Mock Response") - } - })); - let listener = 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), rx) -} - -#[tokio::test] -async fn test_proxy_via_header() { - let app_url = common::base_url().await; - let (upstream_url, mut rx) = spawn_mock_upstream().await; - let client = Client::new(); - let res = client - .get(format!("{}/xrpc/com.example.test", app_url)) - .header("atproto-proxy", &upstream_url) - .header("Authorization", "Bearer test-token") - .send() - .await - .unwrap(); - assert_eq!(res.status(), StatusCode::OK); - let (method, uri, auth) = rx.recv().await.expect("Upstream should receive request"); - assert_eq!(method, "GET"); - assert_eq!(uri, "/xrpc/com.example.test"); - assert_eq!(auth, Some("Bearer test-token".to_string())); -} - -#[tokio::test] -async fn test_proxy_auth_signing() { - let app_url = common::base_url().await; - let (upstream_url, mut rx) = spawn_mock_upstream().await; - let client = Client::new(); - let (access_jwt, did) = common::create_account_and_login(&client).await; - let res = client - .get(format!("{}/xrpc/com.example.signed", app_url)) - .header("atproto-proxy", &upstream_url) - .header("Authorization", format!("Bearer {}", access_jwt)) - .send() - .await - .unwrap(); - assert_eq!(res.status(), StatusCode::OK); - let (method, uri, auth) = rx.recv().await.expect("Upstream receive"); - assert_eq!(method, "GET"); - assert_eq!(uri, "/xrpc/com.example.signed"); - let received_token = auth.expect("No auth header").replace("Bearer ", ""); - assert_ne!(received_token, access_jwt, "Token should be replaced"); - let parts: Vec<&str> = received_token.split('.').collect(); - assert_eq!(parts.len(), 3); - let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).expect("payload b64"); - let claims: serde_json::Value = serde_json::from_slice(&payload_bytes).expect("payload json"); - assert_eq!(claims["iss"], did); - assert_eq!(claims["sub"], did); - assert_eq!(claims["aud"], upstream_url); - assert_eq!(claims["lxm"], "com.example.signed"); -} - -#[tokio::test] -async fn test_proxy_post_with_body() { - let app_url = common::base_url().await; - let (upstream_url, mut rx) = spawn_mock_upstream().await; - let client = Client::new(); - let payload = serde_json::json!({ - "text": "Hello from proxy test", - "createdAt": "2024-01-01T00:00:00Z" - }); - let res = client - .post(format!("{}/xrpc/com.example.postMethod", app_url)) - .header("atproto-proxy", &upstream_url) - .header("Authorization", "Bearer test-token") - .json(&payload) - .send() - .await - .unwrap(); - assert_eq!(res.status(), StatusCode::OK); - let (method, uri, auth) = rx.recv().await.expect("Upstream should receive request"); - assert_eq!(method, "POST"); - assert_eq!(uri, "/xrpc/com.example.postMethod"); - assert_eq!(auth, Some("Bearer test-token".to_string())); -} - -#[tokio::test] -async fn test_proxy_with_query_params() { - let app_url = common::base_url().await; - let (upstream_url, mut rx) = spawn_mock_upstream().await; - let client = Client::new(); - let res = client - .get(format!( - "{}/xrpc/com.example.query?repo=did:plc:test&collection=app.bsky.feed.post&limit=50", - app_url - )) - .header("atproto-proxy", &upstream_url) - .header("Authorization", "Bearer test-token") - .send() - .await - .unwrap(); - assert_eq!(res.status(), StatusCode::OK); - let (method, uri, _auth) = rx.recv().await.expect("Upstream should receive request"); - assert_eq!(method, "GET"); - assert!( - uri.contains("repo=did") || uri.contains("repo=did%3Aplc%3Atest"), - "URI should contain repo param, got: {}", - uri - ); - assert!( - uri.contains("collection=app.bsky.feed.post") || uri.contains("collection=app.bsky"), - "URI should contain collection param, got: {}", - uri - ); - assert!( - uri.contains("limit=50"), - "URI should contain limit param, got: {}", - uri - ); -} diff --git a/tests/rate_limit.rs b/tests/rate_limit.rs index 5ea8cbc..3ea9b21 100644 --- a/tests/rate_limit.rs +++ b/tests/rate_limit.rs @@ -85,10 +85,7 @@ async fn test_password_reset_rate_limiting() { #[ignore = "rate limiting is disabled in test environment"] async fn test_account_creation_rate_limiting() { let client = client(); - let url = format!( - "{}/xrpc/com.atproto.server.createAccount", - base_url().await - ); + let url = format!("{}/xrpc/com.atproto.server.createAccount", base_url().await); let mut rate_limited_count = 0; let mut other_count = 0; for i in 0..15 { diff --git a/tests/record_validation.rs b/tests/record_validation.rs index 4897671..e3b0ace 100644 --- a/tests/record_validation.rs +++ b/tests/record_validation.rs @@ -1,4 +1,7 @@ -use bspds::validation::{RecordValidator, ValidationError, ValidationStatus, validate_record_key, validate_collection_nsid}; +use bspds::validation::{ + RecordValidator, ValidationError, ValidationStatus, validate_collection_nsid, + validate_record_key, +}; use serde_json::json; fn now() -> String { @@ -128,7 +131,9 @@ fn test_validate_post_tag_too_long() { "tags": [long_tag] }); let result = validator.validate(&post, "app.bsky.feed.post"); - assert!(matches!(result, Err(ValidationError::InvalidField { path, .. }) if path.starts_with("tags/"))); + assert!( + matches!(result, Err(ValidationError::InvalidField { path, .. }) if path.starts_with("tags/")) + ); } #[test] @@ -162,7 +167,9 @@ fn test_validate_profile_displayname_too_long() { "displayName": long_name }); let result = validator.validate(&profile, "app.bsky.actor.profile"); - assert!(matches!(result, Err(ValidationError::InvalidField { path, .. }) if path == "displayName")); + assert!( + matches!(result, Err(ValidationError::InvalidField { path, .. }) if path == "displayName") + ); } #[test] @@ -174,7 +181,9 @@ fn test_validate_profile_description_too_long() { "description": long_desc }); let result = validator.validate(&profile, "app.bsky.actor.profile"); - assert!(matches!(result, Err(ValidationError::InvalidField { path, .. }) if path == "description")); + assert!( + matches!(result, Err(ValidationError::InvalidField { path, .. }) if path == "description") + ); } #[test] @@ -229,7 +238,9 @@ fn test_validate_like_invalid_subject_uri() { "createdAt": now() }); let result = validator.validate(&like, "app.bsky.feed.like"); - assert!(matches!(result, Err(ValidationError::InvalidField { path, .. }) if path.contains("uri"))); + assert!( + matches!(result, Err(ValidationError::InvalidField { path, .. }) if path.contains("uri")) + ); } #[test] @@ -381,7 +392,9 @@ fn test_validate_feed_generator_displayname_too_long() { "createdAt": now() }); let result = validator.validate(&generator, "app.bsky.feed.generator"); - assert!(matches!(result, Err(ValidationError::InvalidField { path, .. }) if path == "displayName")); + assert!( + matches!(result, Err(ValidationError::InvalidField { path, .. }) if path == "displayName") + ); } #[test] @@ -415,8 +428,10 @@ fn test_validate_type_mismatch() { "createdAt": now() }); let result = validator.validate(&record, "app.bsky.feed.post"); - assert!(matches!(result, Err(ValidationError::TypeMismatch { expected, actual }) - if expected == "app.bsky.feed.post" && actual == "app.bsky.feed.like")); + assert!( + matches!(result, Err(ValidationError::TypeMismatch { expected, actual }) + if expected == "app.bsky.feed.post" && actual == "app.bsky.feed.like") + ); } #[test] @@ -470,7 +485,10 @@ fn test_validate_datetime_invalid_format() { "createdAt": "2024/01/15" }); let result = validator.validate(&post, "app.bsky.feed.post"); - assert!(matches!(result, Err(ValidationError::InvalidDatetime { .. }))); + assert!(matches!( + result, + Err(ValidationError::InvalidDatetime { .. }) + )); } #[test] diff --git a/tests/repo_batch.rs b/tests/repo_batch.rs index a32d517..034cea3 100644 --- a/tests/repo_batch.rs +++ b/tests/repo_batch.rs @@ -1,6 +1,6 @@ mod common; -use common::*; use chrono::Utc; +use common::*; use reqwest::StatusCode; use serde_json::{Value, json}; diff --git a/tests/security_fixes.rs b/tests/security_fixes.rs index 7280b5b..714c55d 100644 --- a/tests/security_fixes.rs +++ b/tests/security_fixes.rs @@ -1,9 +1,7 @@ mod common; -use bspds::notifications::{ - SendError, is_valid_phone_number, sanitize_header_value, -}; -use bspds::oauth::templates::{login_page, error_page, success_page}; -use bspds::image::{ImageProcessor, ImageError}; +use bspds::image::{ImageError, ImageProcessor}; +use bspds::notifications::{SendError, is_valid_phone_number, sanitize_header_value}; +use bspds::oauth::templates::{error_page, login_page, success_page}; #[test] fn test_sanitize_header_value_removes_crlf() { @@ -11,8 +9,14 @@ fn test_sanitize_header_value_removes_crlf() { let sanitized = sanitize_header_value(malicious); assert!(!sanitized.contains('\r'), "CR should be removed"); assert!(!sanitized.contains('\n'), "LF should be removed"); - assert!(sanitized.contains("Injected"), "Original content should be preserved"); - assert!(sanitized.contains("Bcc:"), "Text after newline should be on same line (no header injection)"); + assert!( + sanitized.contains("Injected"), + "Original content should be preserved" + ); + assert!( + sanitized.contains("Bcc:"), + "Text after newline should be on same line (no header injection)" + ); } #[test] @@ -35,8 +39,14 @@ fn test_sanitize_header_value_handles_multiple_newlines() { let sanitized = sanitize_header_value(input); assert!(!sanitized.contains('\r'), "CR should be removed"); assert!(!sanitized.contains('\n'), "LF should be removed"); - assert!(sanitized.contains("Line1"), "Content before newlines preserved"); - assert!(sanitized.contains("Line4"), "Content after newlines preserved"); + assert!( + sanitized.contains("Line1"), + "Content before newlines preserved" + ); + assert!( + sanitized.contains("Line4"), + "Content after newlines preserved" + ); } #[test] @@ -45,9 +55,18 @@ fn test_email_header_injection_sanitization() { let sanitized = sanitize_header_value(header_injection); let lines: Vec<&str> = sanitized.split("\r\n").collect(); assert_eq!(lines.len(), 1, "Should be a single line after sanitization"); - assert!(sanitized.contains("Normal Subject"), "Original content preserved"); - assert!(sanitized.contains("Bcc:"), "Content after CRLF preserved as same line text"); - assert!(sanitized.contains("X-Injected:"), "All content on same line"); + assert!( + sanitized.contains("Normal Subject"), + "Original content preserved" + ); + assert!( + sanitized.contains("Bcc:"), + "Content after CRLF preserved as same line text" + ); + assert!( + sanitized.contains("X-Injected:"), + "All content on same line" + ); } #[test] @@ -114,7 +133,11 @@ fn test_signal_recipient_command_injection_blocked() { "+123--help", ]; for input in malicious_inputs { - assert!(!is_valid_phone_number(input), "Malicious input '{}' should be rejected", input); + assert!( + !is_valid_phone_number(input), + "Malicious input '{}' should be rejected", + input + ); } } @@ -148,36 +171,79 @@ fn test_oauth_template_xss_escaping_client_id() { let malicious_client_id = ""; let html = login_page(malicious_client_id, None, None, "test-uri", None, None); assert!(!html.contains(""; - let html = login_page("client123", None, Some(malicious_scope), "test-uri", None, None); - assert!(!html.contains(""; - let html = login_page("client123", None, None, "test-uri", Some(malicious_error), None); - assert!(!html.contains(""; let malicious_desc = ""; let html = error_page(malicious_error, Some(malicious_desc)); - assert!(!html.contains(""; let html = success_page(Some(malicious_name)); - assert!(!html.contains("