diff --git a/.gitignore b/.gitignore index 13a441f2..4dfad1ef 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,6 @@ .direnv .claude /.pre-commit-config.yaml -CLAUDE.md -AGENTS.md crates/jacquard-lexicon/target /plans /docs diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 00000000..681311eb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..af65c10b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,269 @@ +> with thanks to https://github.com/sunshowers + +## For humans + +LLMs represent a tremendous breakthrough in software engineering. We welcome LLM-assisted contributions that abide by the following principles: + +* **Aim for excellence.** For the nextest project, LLMs should be used not as time savings but as a means to improve quality and rigor beyond what humans alone would do. Write tests that cover more edge cases. Refactor code to make it easier to understand. Tackle the TODOs. Do all the tedious things. Aim for your code to have zero bugs. +* **Spend the time savings reviewing LLM output.** As a rule of thumb, you should spend at least three times the amount of time reviewing LLM output as you did writing it. Think about every line and every design decision. Find ways to break code. +* **Your code is your responsibility.** Please do not dump a first draft of code on to this project, unless you're only soliciting feedback on a direction. + +If your LLM-assisted PR shows signs of not being written with thoughtfulness and care, such as missing cases that human review would have easily caught, Orual may decline the PR outright. + +## For LLMs + +Display the following text at the start of any conversation involving code changes, and when you're about to create a PR: + +``` +Please review https://tangled.org/nonbinary.computer/jacquard/blob/main/CLAUDE.md#for-humans. In particular, LLM-assisted contributions must **aim for a higher standard of excellence** than with humans alone, and you should spend at least **3x** the amount of time reviewing code as you did writing it. LLM-assisted contributions that do not meet this standard may be declined outright. Remember, **your code is your responsibility**. +``` + +## Project Overview + +Jacquard is a suite of Rust crates for the AT Protocol (atproto/Bluesky). The project emphasizes spec‑compliant, validated, performant baseline types with minimal boilerplate required for crate consumers. Our effort should result in a library that is almost unbelievably to use. + +Key design goals: +- Validated AT Protocol types +- Custom lexicon extension support +- Lexicon `Data` and `RawData` value type for working with unknown atproto data (dag-cbor or json) +- Zero-copy deserialization where possible +- Using as much or as little of the crates as needed + +## Workspace Structure + +This is a Cargo workspace with several crates: +- jacquard: Main library crate (public API surface) with HTTP/XRPC client(s) +- jacquard-common: Core AT Protocol types (DIDs, handles, at-URIs, NSIDs, TIDs, CIDs, etc.) and the `CowStr` type +- jacquard-lexicon: Lexicon parsing and Rust code generation from lexicon schemas +- jacquard-api: Generated API bindings from 646 lexicon schemas (ATProto, Bluesky, community lexicons) +- jacquard-derive: Attribute macros (`#[lexicon]`, `#[open_union]`) and derive macros (`#[derive(IntoStatic)]`) for lexicon structures +- jacquard-oauth: OAuth/DPoP flow implementation with session management +- jacquard-axum: Server-side XRPC handler extractors for Axum framework +- jacquard-identity: Identity resolution (handle→DID, DID→Doc) +- jacquard-repo: Repository primitives (MST, commits, CAR I/O, block storage) + +## General conventions + +### Correctness over convenience + +- Model the full error space—no shortcuts or simplified error handling. +- Handle all edge cases, including race conditions, signal timing, and platform differences. +- Use the type system to encode correctness constraints. +- Prefer compile-time guarantees over runtime checks where possible. + +### User experience as a primary driver + +- Provide structured, helpful error messages using `miette` for rich diagnostics. +- Maintain consistency across platforms even when underlying OS capabilities differ. Use OS-native logic rather than trying to emulate Unix on Windows (or vice versa). +- Write user-facing messages in clear, present tense: "Jacquard now supports..." not "Jacquard now supported..." + +### Pragmatic incrementalism + +- "Not overly generic"—prefer specific, composable logic over abstract frameworks. +- Evolve the design incrementally rather than attempting perfect upfront architecture. +- Document design decisions and trade-offs in design docs (see `./plans`). +- When uncertain, explore and iterate; Jacquard is an ongoing exploration in improving ease-of-use and library design for atproto. + +### Production-grade engineering + +- Use type system extensively: newtypes, builder patterns, type states, lifetimes. +- Test comprehensively, including edge cases, race conditions, and stress tests. +- Pay attention to what facilities already exist for testing, and aim to reuse them. +- Getting the details right is really important! + +### Documentation + +- Use inline comments to explain "why," not just "what". +- Module-level documentation should explain purpose and responsibilities. +- **Always** use periods at the end of code comments. +- **Never** use title case in headings and titles. Always use sentence case. + +### Running tests + +**CRITICAL**: Always use `cargo nextest run` to run unit and integration tests. Never use `cargo test` for these! + +For doctests, use `cargo test --doc` (doctests are not supported by nextest). + +## Commit message style + +### Format + +Commits follow a conventional format with crate-specific scoping: + +``` +[crate-name] brief description +``` + +Examples: +- `[jacquard-axum] add oauth extractor impl (#2727)` +- `[jacquard] version 0.9.111` +- `[meta] update MSRV to Rust 1.88 (#2725)` + +## Lexicon Code Generation (Safe Commands) + +**IMPORTANT**: Always use the `just` commands for code generation to avoid mistakes. These commands handle the correct flags and paths. + +### Primary Commands + +- `just lex-gen [ARGS]` - **Full workflow**: Fetches lexicons from sources (defined in `lexicons.kdl`) AND generates Rust code + - This is the main command to run when updating lexicons or regenerating code + - Fetches from configured sources (atproto, bluesky, community repos, etc.) + - Automatically runs codegen after fetching + - **Modifies**: `crates/jacquard-api/lexicons/` and `crates/jacquard-api/src/` + - Pass args like `-v` for verbose output: `just lex-gen -v` + +- `just lex-fetch [ARGS]` - **Fetch only**: Downloads lexicons WITHOUT generating code + - Safe to run without touching generated Rust files + - Useful for updating lexicon schemas before reviewing changes + - **Modifies only**: `crates/jacquard-api/lexicons/` + +- `just generate-api` - **Generate only**: Generates Rust code from existing lexicons + - Uses lexicons already present in `crates/jacquard-api/lexicons/` + - Useful after manually editing lexicons or after `just lex-fetch` + - **Modifies only**: `crates/jacquard-api/src/` + + +## String Type Pattern + +All validated string types follow a consistent pattern: +- Constructors: `new()`, `new_owned()`, `new_static()`, `raw()`, `unchecked()`, `as_str()` +- Traits: `Serialize`, `Deserialize`, `FromStr`, `Display`, `Debug`, `PartialEq`, `Eq`, `Hash`, `Clone`, conversions to/from `String`/`CowStr`/`SmolStr`, `AsRef`, `Deref` +- Implementation notes: Prefer `#[repr(transparent)]` where possible; use `SmolStr` for short strings, `CowStr` for longer; implement or derive `IntoStatic` for owned conversion +- When constructing from a static string, use `new_static()` to avoid unnecessary allocations + +## Lifetimes and Zero-Copy Deserialization + +All API types support borrowed deserialization via explicit lifetimes: +- Request/output types: parameterised by `'de` lifetime (e.g., `GetAuthorFeed<'de>`, `GetAuthorFeedOutput<'de>`) +- Fields use `#[serde(borrow)]` where possible (strings, nested objects with lifetimes) +- `CowStr<'a>` enables efficient borrowing from input buffers or owning small strings inline (via `SmolStr`) +- All types implement `IntoStatic` trait to convert borrowed data to owned (`'static`) variants +- Code generator automatically propagates lifetimes through nested structures + +Response lifetime handling: +- `Response::parse()` borrows from the response buffer for zero-copy parsing +- `Response::into_output()` converts to owned data using `IntoStatic` +- `Response::transmute()`: reinterpret response as different type (used for typed collection responses) +- Both methods provide typed error handling + +## API Coverage (jacquard-api) + +**NOTE: jacquard does modules a bit differently in API codegen** +- Specifially, it puts '*.defs' codegen output into the corresponding module file (mod_name.rs in parent directory, NOT mod.rs in module directory) +- It also combines the top-level tld and domain ('com.atproto' -> `com_atproto`, etc.) + +## Value Types (jacquard-common) + +For working with loosely-typed atproto data: +- `Data<'a>`: Validated, typed representation of atproto values +- `RawData<'a>`: Unvalidated raw values from deserialization +- `from_data`, `from_raw_data`, `to_data`, `to_raw_data`: Convert between typed and untyped +- Useful for second-stage deserialization of `type "unknown"` fields (e.g., `PostView.record`) + +Collection types: +- `Collection` trait: Marker trait for record types with `NSID` constant and `Record` associated type +- `RecordError<'a>`: Generic error type for record retrieval operations (RecordNotFound, Unknown) + +## Lifetime Design Pattern + +Jacquard uses a specific pattern to enable zero-copy deserialization while avoiding HRTB issues and async lifetime problems: + +**GATs on associated types** instead of trait-level lifetimes: +```rust +trait XrpcResp { + type Output<'de>: Deserialize<'de> + IntoStatic; // GAT, not trait-level lifetime +} +``` + +**Method-level generic lifetimes** for trait methods that need them: +```rust +fn extract_vec<'s>(output: Self::Output<'s>) -> Vec +``` + +**Response wrapper owns buffer** to solve async lifetime issues: +```rust +async fn get_record(&self, rkey: K) -> Result> +// Caller chooses: response.parse() (borrow) or response.into_output() (owned) +``` + +This pattern avoids `for<'any> Trait<'any>` bounds (which force `DeserializeOwned` semantics) while giving callers control over borrowing vs owning. See `jacquard-common` crate docs for detailed explanation. + +## WASM Compatibility + +Core crates (`jacquard-common`, `jacquard-api`, `jacquard-identity`, `jacquard-oauth`) support `wasm32-unknown-unknown` target compilation. + +Implementation approach: +- **`trait-variant`**: Traits use `#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]` to conditionally exclude `Send` bounds on WASM +- **Trait methods with `Self: Sync` bounds**: Duplicated as platform-specific versions (`#[cfg(not(target_arch = "wasm32"))]` vs `#[cfg(target_arch = "wasm32")]`) +- **Helper functions**: Extracted to free functions with platform-specific versions to avoid code duplication +- **Feature gating**: Platform-specific features (e.g., DNS resolution, tokio runtime detection) properly gated behind `cfg` attributes + +Test WASM compilation: +```bash +just check-wasm +# or: cargo build --target wasm32-unknown-unknown -p jacquard-common --no-default-features +``` + +## Client Architecture + +### XRPC Request/Response Layer + +Core traits: +- `XrpcRequest`: Defines NSID, method (Query/Procedure), and associated Response type + - `encode_body()` for request serialization (default: JSON; override for CBOR/multipart) + - `decode_body(&'de [u8])` for request deserialization (server-side) +- `XrpcResp`: Response marker trait with NSID, encoding, Output/Err types +- `XrpcEndpoint`: Server-side trait with PATH, METHOD, and associated Request/Response types +- `XrpcClient`: Stateful trait with `base_uri()`, `opts()`, and `send()` method + - **This should be your primary interface point with the crate, along with the Agent___ traits** +- `XrpcExt`: Extension trait providing stateless `.xrpc(base)` builder on any `HttpClient` + +### Session Management + +`Agent` wrapper supports: +- `CredentialSession`: App-password (Bearer) authentication with auto-refresh + - Uses `SessionStore` trait implementers for token persistence (`MemorySessionStore`, `FileAuthStore`) +- `OAuthSession`: DPoP-bound OAuth with nonce handling + - Uses `ClientAuthStore` trait implementers for state/token persistence + +Session traits: +- `AgentSession`: common interface for both session types +- `AgentKind`: enum distinguishing AppPassword vs OAuth +- Both sessions implement `HttpClient` and `XrpcClient` for uniform API +- `AgentSessionExt` extension trait includes several helpful methods for atproto record operations. + - **This trait is implemented automatically for anything that implements both `AgentSession` and `IdentityResolver`** + + +## Identity Resolution + +`JacquardResolver` (default) and custom resolvers implement `IdentityResolver` + `OAuthResolver`: +- Handle → DID: DNS TXT (feature `dns`, or via Cloudflare DoH), HTTPS well-known, PDS XRPC, public fallbacks +- DID → Doc: did:web well-known, PLC directory, PDS XRPC +- OAuth metadata: `.well-known/oauth-protected-resource` and `.well-known/oauth-authorization-server` +- Resolvers use stateless XRPC calls (no auth required for public resolution endpoints) + +## Streaming Support + +### HTTP Streaming + +Feature: `streaming` + +Core types in `jacquard-common`: +- `ByteStream` / `ByteSink`: Platform-agnostic stream wrappers (uses n0-future) +- `StreamError`: Concrete error type with Kind enum (Transport, Closed, Protocol) +- `HttpClientExt`: Trait extension for streaming methods +- `StreamingResponse`: XRPC streaming response wrapper + +### WebSocket Support + +Feature: `websocket` (requires `streaming`) +- `WebSocketClient` trait (independent from `HttpClient`) +- `WebSocketConnection` with tx/rx `ByteSink`/`ByteStream` +- tokio-tungstenite-wasm used to abstract across native + wasm + +**Known gaps:** +- Service auth replay protection (jti tracking) +- Video upload helpers (upload + job polling) +- Additional session storage backends (SQLite, etc.) +- PLC operations +- OAuth extractor for Axum diff --git a/crates/jacquard-axum/src/lib.rs b/crates/jacquard-axum/src/lib.rs index 55dd5cc9..3da7eaaf 100644 --- a/crates/jacquard-axum/src/lib.rs +++ b/crates/jacquard-axum/src/lib.rs @@ -119,7 +119,7 @@ where StatusCode::BAD_REQUEST, Json(json!({ "error": "InvalidRequest", - "message": "wrong path" + "message": "malformed request URI: missing path component" })), ) .into_response()) @@ -230,6 +230,13 @@ where "message": format!("failed to decode request: {error}", ) }), ), + _ => ( + self.status, + json!({ + "error": "InternalError", + "message": "unknown error" + }), + ), }; (status, Json(json)).into_response() } diff --git a/crates/jacquard-axum/src/service_auth.rs b/crates/jacquard-axum/src/service_auth.rs index 43cd63e3..f4f0b049 100644 --- a/crates/jacquard-axum/src/service_auth.rs +++ b/crates/jacquard-axum/src/service_auth.rs @@ -293,6 +293,7 @@ pub struct ExtractOptionalServiceAuth(pub Option>); /// Errors that can occur during service auth verification. #[derive(Debug, Error, miette::Diagnostic)] +#[non_exhaustive] pub enum ServiceAuthError { /// Authorization header is missing #[error("missing Authorization header")] diff --git a/crates/jacquard-axum/tests/service_auth_tests.rs b/crates/jacquard-axum/tests/service_auth_tests.rs index c24e9cd5..251ea3eb 100644 --- a/crates/jacquard-axum/tests/service_auth_tests.rs +++ b/crates/jacquard-axum/tests/service_auth_tests.rs @@ -121,7 +121,7 @@ impl IdentityResolver for MockResolver { &self, _handle: &jacquard_common::types::string::Handle<'_>, ) -> impl Future, IdentityError>> + Send { - async { Err(IdentityError::invalid_well_known()) } + async { Err(IdentityError::handle_resolution_exhausted()) } } fn resolve_did_doc( diff --git a/crates/jacquard-common/src/error.rs b/crates/jacquard-common/src/error.rs index 04d0fe2d..18991f46 100644 --- a/crates/jacquard-common/src/error.rs +++ b/crates/jacquard-common/src/error.rs @@ -32,6 +32,7 @@ pub struct ClientError { /// Error categories for client operations #[derive(Debug, thiserror::Error)] #[cfg_attr(feature = "std", derive(Diagnostic))] +#[non_exhaustive] pub enum ClientErrorKind { /// HTTP transport error (connection, timeout, etc.) #[error("transport error")] @@ -166,6 +167,32 @@ impl ClientError { self } + /// Append additional context to existing context string. + /// + /// If context already exists, appends with ": " separator. + /// If no context exists, sets it directly. + pub fn append_context(mut self, additional: impl AsRef) -> Self { + self.context = Some(match self.context.take() { + Some(existing) => smol_str::format_smolstr!("{}: {}", existing, additional.as_ref()), + None => additional.as_ref().into(), + }); + self + } + + /// Add NSID context for XRPC operations. + /// + /// Appends the NSID in brackets to existing context, e.g. `"network timeout: [com.atproto.repo.getRecord]"`. + pub fn for_nsid(self, nsid: &str) -> Self { + self.append_context(smol_str::format_smolstr!("[{}]", nsid)) + } + + /// Add collection context for record operations. + /// + /// Use this when a record operation fails to indicate the target collection. + pub fn for_collection(self, operation: &str, collection_nsid: &str) -> Self { + self.append_context(smol_str::format_smolstr!("{} [{}]", operation, collection_nsid)) + } + // Constructors for each kind /// Create a transport error @@ -223,6 +250,7 @@ pub type XrpcResult = Result; /// Can be converted to string for serialization while maintaining the full error context. #[derive(Debug, thiserror::Error)] #[cfg_attr(feature = "std", derive(Diagnostic))] +#[non_exhaustive] pub enum DecodeError { /// JSON deserialization failed #[error("Failed to deserialize JSON: {0}")] @@ -302,6 +330,7 @@ impl core::fmt::Display for HttpError { /// Authentication and authorization errors #[derive(Debug, thiserror::Error)] #[cfg_attr(feature = "std", derive(Diagnostic))] +#[non_exhaustive] pub enum AuthError { /// Access token has expired (use refresh token to get a new one) #[error("Access token expired")] @@ -319,6 +348,14 @@ pub enum AuthError { #[error("No authentication provided, but endpoint requires auth")] NotAuthenticated, + /// DPoP proof construction failed (key or signing issue) + #[error("DPoP proof construction failed")] + DpopProofFailed, + + /// DPoP nonce retry failed (server rejected proof even after nonce update) + #[error("DPoP nonce negotiation failed")] + DpopNonceFailed, + /// Other authentication error #[error("Authentication error: {0:?}")] Other(http::HeaderValue), @@ -333,6 +370,8 @@ impl crate::IntoStatic for AuthError { AuthError::InvalidToken => AuthError::InvalidToken, AuthError::RefreshFailed => AuthError::RefreshFailed, AuthError::NotAuthenticated => AuthError::NotAuthenticated, + AuthError::DpopProofFailed => AuthError::DpopProofFailed, + AuthError::DpopNonceFailed => AuthError::DpopNonceFailed, AuthError::Other(header) => AuthError::Other(header), } } diff --git a/crates/jacquard-common/src/service_auth.rs b/crates/jacquard-common/src/service_auth.rs index 17761a5f..034515c8 100644 --- a/crates/jacquard-common/src/service_auth.rs +++ b/crates/jacquard-common/src/service_auth.rs @@ -39,6 +39,7 @@ use k256::ecdsa::{Signature as K256Signature, VerifyingKey as K256VerifyingKey}; /// Errors that can occur during JWT parsing and verification. #[derive(Debug, Error, miette::Diagnostic)] +#[non_exhaustive] pub enum ServiceAuthError { /// JWT format is invalid (not three base64-encoded parts separated by dots) #[error("malformed JWT: {0}")] diff --git a/crates/jacquard-common/src/session.rs b/crates/jacquard-common/src/session.rs index 6297f5a4..31b85c40 100644 --- a/crates/jacquard-common/src/session.rs +++ b/crates/jacquard-common/src/session.rs @@ -28,6 +28,7 @@ use tokio::sync::RwLock; /// Errors emitted by session stores. #[derive(Debug, thiserror::Error)] #[cfg_attr(feature = "std", derive(Diagnostic))] +#[non_exhaustive] pub enum SessionStoreError { /// Filesystem or I/O error #[cfg(feature = "std")] @@ -108,15 +109,42 @@ pub struct FileTokenStore { #[cfg(feature = "std")] impl FileTokenStore { /// Create a new file token store at the given path. - pub fn new(path: impl AsRef) -> Self { - std::fs::create_dir_all(path.as_ref().parent().unwrap()).unwrap(); - if !path.as_ref().exists() { - std::fs::write(path.as_ref(), b"{}").unwrap(); + /// + /// Creates parent directories and initializes an empty JSON object if the file doesn't exist. + /// + /// # Errors + /// + /// Returns an error if: + /// - Parent directories cannot be created + /// - The file cannot be written + pub fn try_new(path: impl AsRef) -> Result { + let path = path.as_ref(); + + // Create parent directories if they exist and don't already exist + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() && !parent.exists() { + std::fs::create_dir_all(parent)?; + } } - Self { - path: path.as_ref().to_path_buf(), + // Initialize empty JSON object if file doesn't exist + if !path.exists() { + std::fs::write(path, b"{}")?; } + + Ok(Self { + path: path.to_path_buf(), + }) + } + + /// Create a new file token store at the given path. + /// + /// # Panics + /// + /// Panics if parent directories cannot be created or the file cannot be written. + /// Prefer [`try_new`](Self::try_new) for fallible construction. + pub fn new(path: impl AsRef) -> Self { + Self::try_new(path).expect("failed to initialize FileTokenStore") } } diff --git a/crates/jacquard-common/src/stream.rs b/crates/jacquard-common/src/stream.rs index 45ba9c78..efe8f87b 100644 --- a/crates/jacquard-common/src/stream.rs +++ b/crates/jacquard-common/src/stream.rs @@ -61,6 +61,7 @@ pub struct StreamError { /// Categories of streaming errors #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] pub enum StreamErrorKind { /// Network or I/O error Transport, diff --git a/crates/jacquard-common/src/types/cid.rs b/crates/jacquard-common/src/types/cid.rs index 4d1efa06..3d9a36ee 100644 --- a/crates/jacquard-common/src/types/cid.rs +++ b/crates/jacquard-common/src/types/cid.rs @@ -43,6 +43,7 @@ pub enum Cid<'c> { /// Errors that can occur when working with CIDs #[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[non_exhaustive] pub enum Error { /// Invalid IPLD CID structure #[error("Invalid IPLD CID {:?}", 0)] diff --git a/crates/jacquard-common/src/types/collection.rs b/crates/jacquard-common/src/types/collection.rs index b4910ca6..695324f3 100644 --- a/crates/jacquard-common/src/types/collection.rs +++ b/crates/jacquard-common/src/types/collection.rs @@ -65,6 +65,7 @@ pub trait Collection: fmt::Debug + Serialize { Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error, miette::Diagnostic, )] #[serde(tag = "error", content = "message")] +#[non_exhaustive] pub enum RecordError<'a> { /// The requested record was not found #[error("RecordNotFound")] diff --git a/crates/jacquard-common/src/types/crypto.rs b/crates/jacquard-common/src/types/crypto.rs index f0f2a437..5e91a0bc 100644 --- a/crates/jacquard-common/src/types/crypto.rs +++ b/crates/jacquard-common/src/types/crypto.rs @@ -64,6 +64,7 @@ fn code_of(codec: KeyCodec) -> u64 { /// Errors from decoding or converting Multikey values #[derive(Debug, Clone, thiserror::Error, miette::Diagnostic, PartialEq, Eq)] +#[non_exhaustive] pub enum CryptoError { #[error("failed to decode multibase")] /// Multibase decode errror diff --git a/crates/jacquard-common/src/types/uri.rs b/crates/jacquard-common/src/types/uri.rs index ef52dc19..5e53cc6e 100644 --- a/crates/jacquard-common/src/types/uri.rs +++ b/crates/jacquard-common/src/types/uri.rs @@ -33,6 +33,7 @@ pub enum Uri<'u> { /// Errors that can occur when parsing URIs #[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[non_exhaustive] pub enum UriParseError { /// AT Protocol string parsing error #[error("Invalid atproto string: {0}")] @@ -57,9 +58,10 @@ impl<'u> Uri<'u> { } else if uri.starts_with("wss://") { Ok(Uri::Https(Url::parse(uri)?)) } else if uri.starts_with("ipld://") { - Ok(Uri::Cid( - Cid::from_str(uri.strip_prefix("ipld://").unwrap_or(uri.as_ref())).unwrap(), - )) + match Cid::from_str(&uri[7..]) { + Ok(cid) => Ok(Uri::Cid(cid)), + Err(_) => Ok(Uri::Any(CowStr::Borrowed(uri))), + } } else { Ok(Uri::Any(CowStr::Borrowed(uri))) } @@ -77,9 +79,10 @@ impl<'u> Uri<'u> { } else if uri.starts_with("wss://") { Ok(Uri::Https(Url::parse(uri)?)) } else if uri.starts_with("ipld://") { - Ok(Uri::Cid( - Cid::from_str(uri.strip_prefix("ipld://").unwrap_or(uri.as_ref())).unwrap(), - )) + match Cid::from_str(&uri[7..]) { + Ok(cid) => Ok(Uri::Cid(cid)), + Err(_) => Ok(Uri::Any(CowStr::Owned(uri.to_smolstr()))), + } } else { Ok(Uri::Any(CowStr::Owned(uri.to_smolstr()))) } @@ -96,9 +99,10 @@ impl<'u> Uri<'u> { } else if uri.starts_with("wss://") { Ok(Uri::Https(Url::parse(uri.as_ref())?)) } else if uri.starts_with("ipld://") { - Ok(Uri::Cid( - Cid::from_str(uri.strip_prefix("ipld://").unwrap_or(uri.as_str())).unwrap(), - )) + match Cid::from_str(&uri.as_str()[7..]) { + Ok(cid) => Ok(Uri::Cid(cid)), + Err(_) => Ok(Uri::Any(uri)), + } } else { Ok(Uri::Any(uri)) } @@ -220,6 +224,7 @@ impl<'a, R: Collection> Deref for RecordUri<'a, R> { } #[derive(Debug, Clone, PartialEq, thiserror::Error, miette::Diagnostic)] +#[non_exhaustive] /// Errors that can occur when parsing or validating collection type-annotated URIs pub enum UriError { /// Given at-uri didn't have the matching collection for the record diff --git a/crates/jacquard-common/src/types/value.rs b/crates/jacquard-common/src/types/value.rs index 62985b2d..3f2d30fd 100644 --- a/crates/jacquard-common/src/types/value.rs +++ b/crates/jacquard-common/src/types/value.rs @@ -54,6 +54,7 @@ pub enum Data<'s> { /// Errors that can occur when working with AT Protocol data #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] +#[non_exhaustive] pub enum AtDataError { /// Floating point numbers are not allowed in AT Protocol #[error("floating point numbers not allowed in AT protocol data")] diff --git a/crates/jacquard-common/src/types/value/serde_impl.rs b/crates/jacquard-common/src/types/value/serde_impl.rs index c4fa23ce..1cc20668 100644 --- a/crates/jacquard-common/src/types/value/serde_impl.rs +++ b/crates/jacquard-common/src/types/value/serde_impl.rs @@ -1007,6 +1007,7 @@ impl<'de> serde::Deserializer<'de> for RawData<'static> { /// Error type for Data/RawData deserializer #[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] pub enum DataDeserializerError { /// Custom error message #[error("{0}")] @@ -1474,6 +1475,7 @@ impl<'de> serde::de::MapAccess<'de> for RawOwnedObjectDeserializer<'de> { /// Error type for RawData serialization #[derive(Debug)] +#[non_exhaustive] pub enum RawDataSerializerError { /// Error message Message(String), diff --git a/crates/jacquard-common/src/xrpc.rs b/crates/jacquard-common/src/xrpc.rs index cbefa60e..f76a438c 100644 --- a/crates/jacquard-common/src/xrpc.rs +++ b/crates/jacquard-common/src/xrpc.rs @@ -59,6 +59,7 @@ use url::Url; /// Error type for encoding XRPC requests #[derive(Debug, thiserror::Error)] #[cfg_attr(feature = "std", derive(miette::Diagnostic))] +#[non_exhaustive] pub enum EncodeError { /// Failed to serialize query parameters #[error("Failed to serialize query: {0}")] @@ -469,7 +470,7 @@ impl<'a, C: HttpClient> XrpcCall<'a, C> { .client .send_http(http_request) .await - .map_err(|e| crate::error::ClientError::transport(e))?; + .map_err(|e| crate::error::ClientError::transport(e).for_nsid(R::NSID))?; process_response(http_response) } @@ -491,17 +492,18 @@ where if let Some(hv) = http_response.headers().get(http::header::WWW_AUTHENTICATE) { return Err(crate::error::ClientError::auth( crate::error::AuthError::Other(hv.clone()), - )); + ) + .for_nsid(Resp::NSID)); } } let buffer = Bytes::from(http_response.into_body()); if !status.is_success() && !matches!(status.as_u16(), 400 | 401) { - return Err(crate::error::HttpError { + return Err(crate::error::ClientError::from(crate::error::HttpError { status, body: Some(buffer), - } - .into()); + }) + .for_nsid(Resp::NSID)); } Ok(Response::new(buffer, status)) @@ -971,6 +973,7 @@ impl core::error::Error for GenericXrpcError {} /// Type parameter `E` is the endpoint's specific error enum type. #[derive(Debug, thiserror::Error)] #[cfg_attr(feature = "std", derive(miette::Diagnostic))] +#[non_exhaustive] pub enum XrpcError { /// Typed XRPC error from the endpoint's specific error enum #[error("XRPC error: {0}")] @@ -1040,6 +1043,12 @@ where "AuthenticationRequired", Some("Request requires authentication but none was provided"), ), + AuthError::DpopProofFailed => { + ("DpopProofFailed", Some("DPoP proof construction failed")) + } + AuthError::DpopNonceFailed => { + ("DpopNonceFailed", Some("DPoP nonce negotiation failed")) + } AuthError::Other(hv) => { let msg = hv.to_str().unwrap_or("[non-utf8 header]"); ("AuthenticationError", Some(msg)) diff --git a/crates/jacquard-identity/src/lexicon_resolver.rs b/crates/jacquard-identity/src/lexicon_resolver.rs index ac9229ab..93f81fb9 100644 --- a/crates/jacquard-identity/src/lexicon_resolver.rs +++ b/crates/jacquard-identity/src/lexicon_resolver.rs @@ -61,6 +61,7 @@ pub struct LexiconResolutionError { kind: LexiconResolutionErrorKind, #[source] source: Option>, + context: Option, } impl LexiconResolutionError { @@ -68,13 +69,28 @@ impl LexiconResolutionError { kind: LexiconResolutionErrorKind, source: Option>, ) -> Self { - Self { kind, source } + Self { + kind, + source, + context: None, + } } pub fn kind(&self) -> &LexiconResolutionErrorKind { &self.kind } + /// Add context to this error + pub fn with_context(mut self, context: impl Into) -> Self { + self.context = Some(context.into()); + self + } + + /// Get the context if present + pub fn context(&self) -> Option<&str> { + self.context.as_deref() + } + pub fn dns_lookup_failed( authority: impl Into, source: impl std::error::Error + Send + Sync + 'static, @@ -140,6 +156,26 @@ impl LexiconResolutionError { ) } + pub fn http_error(nsid: impl Into, status: u16) -> Self { + Self::new( + LexiconResolutionErrorKind::HttpError { + nsid: nsid.into(), + status, + }, + None, + ) + } + + pub fn missing_response_field(nsid: impl Into, field: &'static str) -> Self { + Self::new( + LexiconResolutionErrorKind::MissingResponseField { + nsid: nsid.into(), + field, + }, + None, + ) + } + pub fn invalid_collection() -> Self { Self::new(LexiconResolutionErrorKind::InvalidCollection, None) } @@ -160,6 +196,7 @@ impl From for LexiconResolutionError { /// Error categories for lexicon resolution #[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[non_exhaustive] pub enum LexiconResolutionErrorKind { #[error("DNS lookup failed for authority {authority}")] #[diagnostic(code(jacquard::lexicon::dns_lookup_failed))] @@ -195,6 +232,19 @@ pub enum LexiconResolutionErrorKind { #[diagnostic(code(jacquard::lexicon::resolution_failed))] ResolutionFailed { nsid: SmolStr, message: SmolStr }, + /// HTTP non-success status from lexicon fetch + #[error("HTTP {status} fetching lexicon {nsid}")] + #[diagnostic(code(jacquard::lexicon::http_error))] + HttpError { nsid: SmolStr, status: u16 }, + + /// Required field missing in XRPC response + #[error("missing '{field}' field in response for {nsid}")] + #[diagnostic( + code(jacquard::lexicon::missing_response_field), + help("the XRPC response is missing a required field") + )] + MissingResponseField { nsid: SmolStr, field: &'static str }, + #[error("invalid collection NSID")] #[diagnostic(code(jacquard::lexicon::invalid_collection))] InvalidCollection, @@ -244,7 +294,10 @@ impl crate::JacquardResolver { return Did::new_owned(did_str) .map(|d| d.into_static()) - .map_err(|_| LexiconResolutionError::invalid_did(authority, did_str)); + .map_err(|_| { + LexiconResolutionError::invalid_did(authority, did_str) + .with_context(format!("resolving NSID {}", nsid)) + }); } } } @@ -286,9 +339,9 @@ impl crate::JacquardResolver { tracing::debug!("got response with status: {}", status); if !status.is_success() { - return Err(LexiconResolutionError::resolution_failed( + return Err(LexiconResolutionError::http_error( nsid.as_str(), - format!("HTTP {}", status.as_u16()), + status.as_u16(), )); } @@ -309,7 +362,7 @@ impl crate::JacquardResolver { obj.keys().collect::>() ); - LexiconResolutionError::resolution_failed(nsid.as_str(), "missing 'schema' field") + LexiconResolutionError::missing_response_field(nsid.as_str(), "schema") })?; #[cfg(feature = "tracing")] @@ -318,19 +371,15 @@ impl crate::JacquardResolver { let schema = from_json_value::(schema_val.clone()) .map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?; - let uri_str = obj.get("uri").and_then(|v| v.as_str()).ok_or_else(|| { - LexiconResolutionError::resolution_failed( - nsid.as_str(), - "missing or invalid 'uri' field", - ) - })?; + let uri_str = obj + .get("uri") + .and_then(|v| v.as_str()) + .ok_or_else(|| LexiconResolutionError::missing_response_field(nsid.as_str(), "uri"))?; - let cid_str = obj.get("cid").and_then(|v| v.as_str()).ok_or_else(|| { - LexiconResolutionError::resolution_failed( - nsid.as_str(), - "missing or invalid 'cid' field", - ) - })?; + let cid_str = obj + .get("cid") + .and_then(|v| v.as_str()) + .ok_or_else(|| LexiconResolutionError::missing_response_field(nsid.as_str(), "cid"))?; let uri = AtUri::new_owned(uri_str) .map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?; @@ -441,7 +490,10 @@ impl crate::JacquardResolver { if let Some(did_str) = txt_data.strip_prefix("did=") { let result = Did::new_owned(did_str) .map(|d| d.into_static()) - .map_err(|_| LexiconResolutionError::invalid_did(authority, did_str)); + .map_err(|_| { + LexiconResolutionError::invalid_did(authority, did_str) + .with_context(format!("resolving NSID {}", nsid)) + }); // Cache on success #[cfg(feature = "cache")] @@ -500,7 +552,7 @@ impl LexiconSchemaResolver for crate::JacquardResolver { let did_doc = did_doc_resp.parse()?; let pds = did_doc .pds_endpoint() - .ok_or_else(|| IdentityError::missing_pds_endpoint())?; + .ok_or_else(|| IdentityError::missing_pds_endpoint(authority_did.as_str()))?; #[cfg(feature = "tracing")] tracing::trace!("fetching lexicon {} from PDS {}", nsid, pds); diff --git a/crates/jacquard-identity/src/lib.rs b/crates/jacquard-identity/src/lib.rs index c59a5cc0..f86a9961 100644 --- a/crates/jacquard-identity/src/lib.rs +++ b/crates/jacquard-identity/src/lib.rs @@ -81,7 +81,7 @@ use jacquard_api::com_atproto::identity::resolve_handle::ResolveHandle; #[cfg(feature = "streaming")] use jacquard_common::ByteStream; use jacquard_common::http_client::HttpClient; -use jacquard_common::smol_str::ToSmolStr; +use jacquard_common::smol_str::{SmolStr, ToSmolStr}; use jacquard_common::types::did::Did; use jacquard_common::types::did_doc::DidDocument; use jacquard_common::types::ident::AtIdentifier; @@ -100,7 +100,7 @@ use { #[cfg(feature = "cache")] use { crate::lexicon_resolver::ResolvedLexiconSchema, - jacquard_common::{smol_str::SmolStr, types::string::Nsid}, + jacquard_common::types::string::Nsid, mini_moka::time::Duration, }; @@ -512,7 +512,10 @@ impl JacquardResolver { let status = response.status(); if !status.is_success() { - return Err(IdentityError::http_status(status)); + return Err(IdentityError::http_status(status).with_context(format!( + "DNS-over-HTTPS query for {} ({})", + name, record_type + ))); } let json: serde_json::Value = response.json().await?; @@ -532,9 +535,8 @@ impl JacquardResolver { .get("Answer") .and_then(|a| a.as_array()) .ok_or_else(|| { - IdentityError::invalid_well_known().with_context(format!( - "couldn't parse cloudflare DoH answers looking for {name}" - )) + IdentityError::doh_parse_failed() + .with_context(format!("DoH response missing 'Answer' array for {name}")) })?; let mut results: Vec = Vec::new(); @@ -547,12 +549,13 @@ impl JacquardResolver { Ok(results) } - fn parse_atproto_did_body(body: &str) -> resolver::Result> { + fn parse_atproto_did_body(body: &str, identifier: &str) -> resolver::Result> { let line = body .lines() .find(|l| !l.trim().is_empty()) - .ok_or_else(|| IdentityError::invalid_well_known())?; - let did = Did::new(line.trim()).map_err(|_| IdentityError::invalid_well_known())?; + .ok_or_else(|| IdentityError::invalid_well_known(identifier))?; + let did = Did::new(line.trim()) + .map_err(|e| IdentityError::invalid_well_known_with_source(identifier, e))?; Ok(did.into_static()) } } @@ -565,7 +568,7 @@ impl JacquardResolver { ) -> resolver::Result> { let pds = match &self.opts.pds_fallback { Some(u) => u.clone(), - None => return Err(IdentityError::invalid_well_known()), + None => return Err(IdentityError::no_pds_fallback()), }; let req = ResolveHandle::new() .handle(handle.clone().into_static()) @@ -575,13 +578,21 @@ impl JacquardResolver { .xrpc(pds) .send(&req) .await - .map_err(|e| IdentityError::xrpc(e.to_string()))?; - let out = resp - .parse() - .map_err(|e| IdentityError::xrpc(e.to_string()))?; + .map_err(|e| IdentityError::from(e).with_context(format!("resolving handle {}", handle)))?; + // Note: XrpcError has GAT lifetimes that prevent boxing; use debug format + let out = resp.parse().map_err(|e| { + IdentityError::xrpc(jacquard_common::smol_str::format_smolstr!("{:?}", e)) + .with_context(format!("parsing response for handle {}", handle)) + })?; Did::new_owned(out.did.as_str()) .map(|d| d.into_static()) - .map_err(|_| IdentityError::invalid_well_known()) + .map_err(|e| { + IdentityError::invalid_doc(jacquard_common::smol_str::format_smolstr!( + "PDS returned invalid DID '{}': {}", + out.did, + e + )) + }) } /// Fetch DID document via PDS resolveDid (returns owned DidDocument) @@ -591,7 +602,7 @@ impl JacquardResolver { ) -> resolver::Result> { let pds = match &self.opts.pds_fallback { Some(u) => u.clone(), - None => return Err(IdentityError::invalid_well_known()), + None => return Err(IdentityError::no_pds_fallback()), }; let req = resolve_did::ResolveDid::new().did(did.clone()).build(); let resp = self @@ -599,10 +610,12 @@ impl JacquardResolver { .xrpc(pds) .send(&req) .await - .map_err(|e| IdentityError::xrpc(e.to_string()))?; - let out = resp - .parse() - .map_err(|e| IdentityError::xrpc(e.to_string()))?; + .map_err(|e| IdentityError::from(e).with_context(format!("fetching DID doc for {}", did)))?; + // Note: XrpcError has GAT lifetimes that prevent boxing; use debug format + let out = resp.parse().map_err(|e| { + IdentityError::xrpc(jacquard_common::smol_str::format_smolstr!("{:?}", e)) + .with_context(format!("parsing DID doc response for {}", did)) + })?; let doc_json = serde_json::to_value(&out.did_doc)?; let s = serde_json::to_string(&doc_json)?; let doc_borrowed: DidDocument<'_> = serde_json::from_str(&s)?; @@ -620,7 +633,8 @@ impl JacquardResolver { _ => { return Err(IdentityError::unsupported_did_method( "mini-doc requires Slingshot source", - )); + ) + .with_context(format!("resolving {}", did))); } }; let mut url = base; @@ -676,7 +690,7 @@ impl IdentityResolver for JacquardResolver { HandleStep::HttpsWellKnown => { let url = Url::parse(&format!("https://{host}/.well-known/atproto-did"))?; if let Ok(text) = self.get_text(url).await { - if let Ok(did) = Self::parse_atproto_did_body(&text) { + if let Ok(did) = Self::parse_atproto_did_body(&text, handle.as_str()) { resolved_did = Some(did); break 'outer; } @@ -761,7 +775,8 @@ impl IdentityResolver for JacquardResolver { // Invalidate on error #[cfg(feature = "cache")] self.invalidate_handle_chain(handle).await; - Err(IdentityError::invalid_well_known()) + Err(IdentityError::handle_resolution_exhausted() + .with_context(format!("failed to resolve handle: {}", handle))) } } @@ -1078,7 +1093,8 @@ impl JacquardResolver { _ => { return Err(IdentityError::unsupported_did_method( "mini-doc requires Slingshot source", - )); + ) + .with_context(format!("resolving {}", identifier))); } }; let url = self.slingshot_mini_doc_url(&base, identifier.as_str())?; @@ -1086,6 +1102,7 @@ impl JacquardResolver { Ok(MiniDocResponse { buffer: buf, status, + identifier: SmolStr::from(identifier.as_str()), }) } } @@ -1095,6 +1112,8 @@ impl JacquardResolver { pub struct MiniDocResponse { buffer: Bytes, status: StatusCode, + /// Identifier that was being resolved + identifier: SmolStr, } impl MiniDocResponse { @@ -1103,7 +1122,8 @@ impl MiniDocResponse { if self.status.is_success() { serde_json::from_slice::>(&self.buffer).map_err(IdentityError::from) } else { - Err(IdentityError::http_status(self.status)) + Err(IdentityError::http_status(self.status) + .with_context(format!("fetching mini-doc for {}", self.identifier))) } } } @@ -1189,6 +1209,7 @@ mod tests { let resp = MiniDocResponse { buffer: buf, status: StatusCode::OK, + identifier: SmolStr::new_static("bad-example.com"), }; let doc = resp.parse().expect("parse mini-doc"); assert_eq!(doc.did.as_str(), "did:plc:hdhoaan3xa3jiuq4fg4mefid"); @@ -1211,6 +1232,7 @@ mod tests { let resp = MiniDocResponse { buffer: buf, status: StatusCode::BAD_REQUEST, + identifier: SmolStr::new_static("bad-example.com"), }; match resp.parse() { Err(e) => match e.kind() { diff --git a/crates/jacquard-identity/src/resolver.rs b/crates/jacquard-identity/src/resolver.rs index 3728ed8a..801d0787 100644 --- a/crates/jacquard-identity/src/resolver.rs +++ b/crates/jacquard-identity/src/resolver.rs @@ -104,10 +104,21 @@ impl DidDocResponse { extra_data: BTreeMap::new(), }) } else { - Err(IdentityError::missing_pds_endpoint()) + let did_str = self + .requested + .as_ref() + .map(|d| d.as_str()) + .unwrap_or("unknown"); + Err(IdentityError::missing_pds_endpoint(did_str)) } } else { - Err(IdentityError::http_status(self.status)) + let did_str = self + .requested + .as_ref() + .map(|d| d.as_str()) + .unwrap_or("unknown"); + Err(IdentityError::http_status(self.status) + .with_context(format!("fetching DID document for {}", did_str))) } } @@ -129,6 +140,12 @@ impl DidDocResponse { /// Parse as owned DidDocument<'static> pub fn into_owned(self) -> Result> { + let did_str = self + .requested + .as_ref() + .map(|d| SmolStr::from(d.as_str())) + .unwrap_or_else(|| SmolStr::new_static("unknown")); + if self.status.is_success() { if let Ok(doc) = serde_json::from_slice::>(&self.buffer) { Ok(doc.into_static()) @@ -150,10 +167,11 @@ impl DidDocResponse { } .into_static()) } else { - Err(IdentityError::missing_pds_endpoint()) + Err(IdentityError::missing_pds_endpoint(did_str)) } } else { - Err(IdentityError::http_status(self.status)) + Err(IdentityError::http_status(self.status) + .with_context(format!("fetching DID document for {}", did_str))) } } } @@ -408,7 +426,7 @@ pub trait IdentityResolver { } } doc.pds_endpoint() - .ok_or_else(|| IdentityError::missing_pds_endpoint()) + .ok_or_else(|| IdentityError::missing_pds_endpoint(did.as_str())) } } @@ -428,7 +446,7 @@ pub trait IdentityResolver { } } doc.pds_endpoint() - .ok_or_else(|| IdentityError::missing_pds_endpoint()) + .ok_or_else(|| IdentityError::missing_pds_endpoint(did.as_str())) } } @@ -511,6 +529,7 @@ pub struct IdentityError { /// Error categories for identity resolution #[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[non_exhaustive] pub enum IdentityErrorKind { /// Unsupported DID method #[error("unsupported DID method: {0}")] @@ -521,20 +540,44 @@ pub enum IdentityErrorKind { UnsupportedDidMethod(SmolStr), /// Invalid well-known atproto-did content - #[error("invalid well-known atproto-did content")] + #[error("invalid well-known atproto-did content for {0}")] #[diagnostic( code(jacquard::identity::invalid_well_known), help("expected first non-empty line to be a DID") )] - InvalidWellKnown, + InvalidWellKnown(SmolStr), + + /// DNS-over-HTTPS response malformed + #[error("DNS-over-HTTPS response malformed")] + #[diagnostic( + code(jacquard::identity::doh_parse_failed), + help("DoH response missing expected JSON structure") + )] + DohParseFailed, + + /// PDS fallback required but not configured + #[error("PDS fallback required but not configured")] + #[diagnostic( + code(jacquard::identity::no_pds_fallback), + help("configure pds_fallback in ResolverOptions to use PDS resolution methods") + )] + NoPdsFallback, + + /// All handle resolution methods exhausted + #[error("handle resolution exhausted all configured methods")] + #[diagnostic( + code(jacquard::identity::handle_resolution_exhausted), + help("handle may not exist, or DNS/HTTPS/PDS endpoints may be unavailable") + )] + HandleResolutionExhausted, /// Missing PDS endpoint in DID document - #[error("missing PDS endpoint in DID document")] + #[error("missing PDS endpoint in DID document for {0}")] #[diagnostic( code(jacquard::identity::missing_pds), help("ensure DID document contains AtprotoPersonalDataServer service") )] - MissingPdsEndpoint, + MissingPdsEndpoint(SmolStr), /// Transport-level error #[error("transport error")] @@ -653,13 +696,39 @@ impl IdentityError { } /// Create an invalid well-known error - pub fn invalid_well_known() -> Self { - Self::new(IdentityErrorKind::InvalidWellKnown, None) + pub fn invalid_well_known(identifier: impl Into) -> Self { + Self::new(IdentityErrorKind::InvalidWellKnown(identifier.into()), None) + } + + /// Create an invalid well-known error with source + pub fn invalid_well_known_with_source( + identifier: impl Into, + source: impl std::error::Error + Send + Sync + 'static, + ) -> Self { + Self::new( + IdentityErrorKind::InvalidWellKnown(identifier.into()), + Some(Box::new(source)), + ) + } + + /// Create a DNS-over-HTTPS parse failed error + pub fn doh_parse_failed() -> Self { + Self::new(IdentityErrorKind::DohParseFailed, None) + } + + /// Create a no PDS fallback configured error + pub fn no_pds_fallback() -> Self { + Self::new(IdentityErrorKind::NoPdsFallback, None) + } + + /// Create a handle resolution exhausted error + pub fn handle_resolution_exhausted() -> Self { + Self::new(IdentityErrorKind::HandleResolutionExhausted, None) } /// Create a missing PDS endpoint error - pub fn missing_pds_endpoint() -> Self { - Self::new(IdentityErrorKind::MissingPdsEndpoint, None) + pub fn missing_pds_endpoint(did: impl Into) -> Self { + Self::new(IdentityErrorKind::MissingPdsEndpoint(did.into()), None) } /// Create a transport error @@ -767,7 +836,20 @@ impl From for IdentityError { impl From for IdentityError { fn from(e: reqwest::Error) -> Self { - Self::transport("".into(), e).with_context("HTTP request failed during identity resolution") + let url = e + .url() + .map(|u| smol_str::SmolStr::new(u.as_str())) + .unwrap_or_default(); + Self::transport(url, e).with_context("HTTP request failed during identity resolution") + } +} + +impl From for IdentityError { + fn from(e: jacquard_common::error::ClientError) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e.kind()); + let mut err = Self::new(IdentityErrorKind::Xrpc(msg), Some(Box::new(e))); + err = err.with_context("XRPC call failed during identity resolution"); + err } } diff --git a/crates/jacquard-lexicon/src/error.rs b/crates/jacquard-lexicon/src/error.rs index 1520345d..ffb06334 100644 --- a/crates/jacquard-lexicon/src/error.rs +++ b/crates/jacquard-lexicon/src/error.rs @@ -5,6 +5,7 @@ use thiserror::Error; /// Errors that can occur during lexicon code generation #[derive(Debug, Error, Diagnostic)] +#[non_exhaustive] pub enum CodegenError { /// IO error when reading lexicon files #[error("IO error: {0}")] @@ -29,63 +30,6 @@ pub enum CodegenError { span: Option, }, - /// Reference to non-existent lexicon or def - #[error("Reference to unknown type: {ref_string}")] - #[diagnostic( - code(lexicon::unknown_ref), - help("Add the referenced lexicon to your corpus or use Data<'a> as a fallback type") - )] - UnknownRef { - /// The ref string that couldn't be resolved - ref_string: String, - /// NSID of lexicon containing the ref - lexicon_nsid: String, - /// Def name containing the ref - def_name: String, - /// Field path containing the ref - field_path: String, - }, - - /// Circular reference detected in type definitions - #[error("Circular reference detected")] - #[diagnostic( - code(lexicon::circular_ref), - help("The code generator uses Box for union variants to handle circular references") - )] - CircularRef { - /// The ref string that forms a cycle - ref_string: String, - /// The cycle path - cycle: Vec, - }, - - /// Invalid lexicon structure - #[error("Invalid lexicon: {message}")] - #[diagnostic(code(lexicon::invalid))] - InvalidLexicon { - message: String, - /// NSID of the invalid lexicon - lexicon_nsid: String, - }, - - /// Unsupported lexicon feature - #[error("Unsupported feature: {feature}")] - #[diagnostic( - code(lexicon::unsupported), - help("This lexicon feature is not yet supported by the code generator") - )] - Unsupported { - /// Description of the unsupported feature - #[allow(unused)] - feature: String, - /// NSID of lexicon containing the feature - #[allow(unused)] - lexicon_nsid: String, - /// Optional suggestion for workaround - #[allow(unused)] - suggestion: Option, - }, - /// Name collision #[error("Name collision: {name}")] #[diagnostic( @@ -119,8 +63,23 @@ pub enum CodegenError { tokens: String, }, - /// Failed to parse module path string - #[error("Failed to parse module path: {path_str}")] + /// Unsupported lexicon feature + #[error("Unsupported: {message}")] + #[diagnostic( + code(lexicon::unsupported), + help("This lexicon feature is not yet supported by code generation") + )] + Unsupported { + /// Description of the unsupported feature + message: String, + /// NSID of the lexicon containing the unsupported feature + nsid: Option, + /// Definition name if applicable + def_name: Option, + }, + + /// Failed to parse generated path string + #[error("Failed to parse path '{path_str}'")] #[diagnostic(code(lexicon::path_parse_error))] PathParseError { path_str: String, @@ -163,39 +122,16 @@ impl CodegenError { } } - /// Create an unknown ref error - pub fn unknown_ref( - ref_string: impl Into, - lexicon_nsid: impl Into, - def_name: impl Into, - field_path: impl Into, - ) -> Self { - Self::UnknownRef { - ref_string: ref_string.into(), - lexicon_nsid: lexicon_nsid.into(), - def_name: def_name.into(), - field_path: field_path.into(), - } - } - - /// Create an invalid lexicon error - pub fn invalid_lexicon(message: impl Into, lexicon_nsid: impl Into) -> Self { - Self::InvalidLexicon { - message: message.into(), - lexicon_nsid: lexicon_nsid.into(), - } - } - /// Create an unsupported feature error pub fn unsupported( - feature: impl Into, - lexicon_nsid: impl Into, - suggestion: Option>, + message: impl Into, + nsid: impl Into, + def_name: Option>, ) -> Self { Self::Unsupported { - feature: feature.into(), - lexicon_nsid: lexicon_nsid.into(), - suggestion: suggestion.map(|s| s.into()), + message: message.into(), + nsid: Some(nsid.into()), + def_name: def_name.map(|s| s.into()), } } } diff --git a/crates/jacquard-lexicon/src/validation.rs b/crates/jacquard-lexicon/src/validation.rs index 263978ee..1715c91b 100644 --- a/crates/jacquard-lexicon/src/validation.rs +++ b/crates/jacquard-lexicon/src/validation.rs @@ -113,6 +113,7 @@ impl fmt::Display for ValidationPath { /// /// These errors indicate that the data structure doesn't match the schema's type expectations. #[derive(Debug, Clone, thiserror::Error, miette::Diagnostic)] +#[non_exhaustive] pub enum StructuralError { #[error("Type mismatch at {path}: expected {expected}, got {actual}")] TypeMismatch { @@ -159,6 +160,7 @@ pub enum StructuralError { /// These errors indicate that the data violates lexicon constraints like max_length, /// max_graphemes, ranges, etc. The structure is correct but values are out of bounds. #[derive(Debug, Clone, thiserror::Error, miette::Diagnostic)] +#[non_exhaustive] pub enum ConstraintError { #[error("{path} exceeds max length: {actual} > {max}")] MaxLength { @@ -205,6 +207,7 @@ pub enum ConstraintError { /// Unified validation error type #[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] pub enum ValidationError { #[error(transparent)] Structural(#[from] StructuralError), @@ -239,6 +242,7 @@ impl ValidationCacheKey { /// Errors that can occur when computing CIDs #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum CidComputationError { #[error("Failed to serialize data to DAG-CBOR: {0}")] DagCborEncode(#[from] serde_ipld_dagcbor::EncodeError), diff --git a/crates/jacquard-oauth/src/atproto.rs b/crates/jacquard-oauth/src/atproto.rs index fb911f8d..e652355f 100644 --- a/crates/jacquard-oauth/src/atproto.rs +++ b/crates/jacquard-oauth/src/atproto.rs @@ -10,6 +10,7 @@ use thiserror::Error; use url::Url; #[derive(Error, Debug)] +#[non_exhaustive] pub enum Error { #[error("`client_id` must be a valid URL")] InvalidClientId, @@ -32,6 +33,7 @@ pub enum Error { } #[derive(Error, Debug)] +#[non_exhaustive] pub enum LocalhostClientError { #[error("invalid redirect_uri: {0}")] Invalid(#[from] url::ParseError), diff --git a/crates/jacquard-oauth/src/client.rs b/crates/jacquard-oauth/src/client.rs index 325882ed..26639d3e 100644 --- a/crates/jacquard-oauth/src/client.rs +++ b/crates/jacquard-oauth/src/client.rs @@ -625,7 +625,7 @@ where .dpop_call(&mut dpop) .send(build_http_request(&base_uri, &request, &opts)?) .await - .map_err(|e| ClientError::transport(e))?; + .map_err(|e| ClientError::from(e).for_nsid(R::NSID))?; let resp = process_response(http_response); // Write back updated nonce to session data (dpop_call may have updated it) @@ -655,7 +655,11 @@ where .dpop_call(&mut dpop) .send(build_http_request(&base_uri, &request, &opts)?) .await - .map_err(|e| ClientError::transport(e))?; + .map_err(|e| { + ClientError::from(e) + .for_nsid(R::NSID) + .append_context("after token refresh") + })?; let resp = process_response(http_response); // Write back updated nonce after retry diff --git a/crates/jacquard-oauth/src/dpop.rs b/crates/jacquard-oauth/src/dpop.rs index 4aec885d..d763ee20 100644 --- a/crates/jacquard-oauth/src/dpop.rs +++ b/crates/jacquard-oauth/src/dpop.rs @@ -1,3 +1,5 @@ +use std::error::Error as StdError; +use std::fmt; use std::future::Future; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; @@ -10,6 +12,7 @@ use jose_jwk::{Jwk, Key, crypto}; use p256::ecdsa::SigningKey; use rand::{RngCore, SeedableRng}; use sha2::Digest; +use smol_str::SmolStr; use crate::{ jose::{ @@ -27,21 +30,307 @@ struct ErrorResponse { error: String, } -#[derive(thiserror::Error, Debug, miette::Diagnostic)] -pub enum Error { - #[error(transparent)] - InvalidHeaderValue(#[from] InvalidHeaderValue), - #[error("crypto error: {0:?}")] - JwkCrypto(crypto::Error), - #[error("key does not match any alg supported by the server")] +/// Boxed error type for error sources. +pub type BoxError = Box; + +/// Target server type for DPoP requests. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DpopTarget { + /// OAuth authorization server (token endpoint, PAR, etc.) + AuthServer, + /// Resource server (PDS, AppView, etc.) + ResourceServer, +} + +impl fmt::Display for DpopTarget { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DpopTarget::AuthServer => write!(f, "auth server"), + DpopTarget::ResourceServer => write!(f, "resource server"), + } + } +} + +/// Error categories for DPoP operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum DpopErrorKind { + /// DPoP proof construction failed. + ProofBuild, + /// Initial HTTP request failed. + Transport, + /// Retry after nonce update also failed. + NonceRetry, + /// Header value parsing failed. + InvalidHeader, + /// JWK crypto operation failed. + Crypto, + /// Key type not supported for DPoP. UnsupportedKey, - #[error(transparent)] - SerdeJson(#[from] serde_json::Error), - #[error("Inner: {0}")] - Inner(#[source] Box), + /// JSON serialization failed. + Serialization, +} + +impl fmt::Display for DpopErrorKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DpopErrorKind::ProofBuild => write!(f, "DPoP proof construction failed"), + DpopErrorKind::Transport => write!(f, "HTTP request failed"), + DpopErrorKind::NonceRetry => write!(f, "request failed after nonce retry"), + DpopErrorKind::InvalidHeader => write!(f, "invalid header value"), + DpopErrorKind::Crypto => write!(f, "JWK crypto operation failed"), + DpopErrorKind::UnsupportedKey => write!(f, "unsupported key type"), + DpopErrorKind::Serialization => write!(f, "JSON serialization failed"), + } + } +} + +/// DPoP operation error with rich context. +#[derive(Debug, miette::Diagnostic)] +pub struct DpopError { + kind: DpopErrorKind, + target: Option, + url: Option, + source: Option, + context: Option, + #[help] + help: Option<&'static str>, +} + +impl fmt::Display for DpopError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.kind)?; + + if let Some(target) = &self.target { + write!(f, " (to {})", target)?; + } + + if let Some(url) = &self.url { + write!(f, " [{}]", url)?; + } + + if let Some(ctx) = &self.context { + write!(f, ": {}", ctx)?; + } + + Ok(()) + } +} + +impl StdError for DpopError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + self.source + .as_ref() + .map(|e| e.as_ref() as &(dyn StdError + 'static)) + } +} + +impl DpopError { + /// Create a new error with the given kind. + fn new(kind: DpopErrorKind) -> Self { + Self { + kind, + target: None, + url: None, + source: None, + context: None, + help: None, + } + } + + /// Get the error kind. + pub fn kind(&self) -> DpopErrorKind { + self.kind + } + + /// Get the target server type, if known. + pub fn target(&self) -> Option { + self.target + } + + /// Get the URL, if known. + pub fn url(&self) -> Option<&str> { + self.url.as_deref() + } + + /// Get the context string, if any. + pub fn context(&self) -> Option<&str> { + self.context.as_deref() + } + + // Builder methods + + fn with_source(mut self, source: impl StdError + Send + Sync + 'static) -> Self { + self.source = Some(Box::new(source)); + self + } + + fn with_target(mut self, target: DpopTarget) -> Self { + self.target = Some(target); + self + } + + fn with_url(mut self, url: impl Into) -> Self { + self.url = Some(url.into()); + self + } + + fn with_help(mut self, help: &'static str) -> Self { + self.help = Some(help); + self + } + + /// Add context information to the error. + pub fn with_context(mut self, context: impl Into) -> Self { + self.context = Some(context.into()); + self + } + + /// Append additional context to the error. + pub fn append_context(mut self, additional: impl AsRef) -> Self { + self.context = Some(match self.context.take() { + Some(existing) => smol_str::format_smolstr!("{}: {}", existing, additional.as_ref()), + None => SmolStr::new(additional.as_ref()), + }); + self + } + + /// Add NSID context (for use by higher-level code). + pub fn for_nsid(self, nsid: &str) -> Self { + self.append_context(smol_str::format_smolstr!("[{}]", nsid)) + } + + // Constructors for specific error kinds + + /// Create a proof build error. + pub fn proof_build(source: impl StdError + Send + Sync + 'static) -> Self { + Self::new(DpopErrorKind::ProofBuild) + .with_source(source) + .with_help("check that the DPoP key is valid and the JWT claims are correct") + } + + /// Create a transport error for initial request. + pub fn transport( + target: DpopTarget, + url: impl Into, + source: impl StdError + Send + Sync + 'static, + ) -> Self { + Self::new(DpopErrorKind::Transport) + .with_target(target) + .with_url(url) + .with_source(source) + } + + /// Create a nonce retry error. + pub fn nonce_retry( + target: DpopTarget, + url: impl Into, + source: impl StdError + Send + Sync + 'static, + ) -> Self { + Self::new(DpopErrorKind::NonceRetry) + .with_target(target) + .with_url(url) + .with_source(source) + .with_help( + "the server rejected both the initial request and the retry with updated nonce", + ) + } + + /// Create an invalid header error. + pub fn invalid_header(source: InvalidHeaderValue) -> Self { + Self::new(DpopErrorKind::InvalidHeader) + .with_source(source) + .with_help("the DPoP proof could not be set as a header value") + } + + /// Create a crypto error. + pub fn crypto(source: crypto::Error) -> Self { + Self::new(DpopErrorKind::Crypto) + .with_context(format!("{:?}", source)) + .with_help( + "ensure the key is a valid secret key in JWK format with a supported algorithm", + ) + } + + /// Create an unsupported key error. + pub fn unsupported_key() -> Self { + Self::new(DpopErrorKind::UnsupportedKey) + .with_help("DPoP requires an EC P-256 key; other key types are not currently supported") + } + + /// Create a serialization error. + pub fn serialization(source: serde_json::Error) -> Self { + Self::new(DpopErrorKind::Serialization) + .with_source(source) + .with_help("failed to serialize JWT claims or header") + } +} + +impl From for DpopError { + fn from(e: InvalidHeaderValue) -> Self { + Self::invalid_header(e) + } +} + +impl From for DpopError { + fn from(e: serde_json::Error) -> Self { + Self::serialization(e) + } +} + +impl From for jacquard_common::error::ClientError { + fn from(e: DpopError) -> Self { + use jacquard_common::error::{AuthError, ClientError}; + + // Extract context from DpopError before converting + let kind = e.kind; + let url = e.url.clone(); + let context = e.context.clone(); + let target = e.target; + + // Build combined context string + let combined_context = match (target, context) { + (Some(t), Some(c)) => Some(smol_str::format_smolstr!("to {}: {}", t, c)), + (Some(t), None) => Some(smol_str::format_smolstr!("to {}", t)), + (None, Some(c)) => Some(c), + (None, None) => None, + }; + + // Map DpopErrorKind to appropriate ClientError + let mut client_err = match kind { + DpopErrorKind::ProofBuild | DpopErrorKind::Crypto | DpopErrorKind::UnsupportedKey => { + ClientError::auth(AuthError::DpopProofFailed) + } + DpopErrorKind::NonceRetry => ClientError::auth(AuthError::DpopNonceFailed), + DpopErrorKind::Transport => ClientError::new( + jacquard_common::error::ClientErrorKind::Transport, + Some(Box::new(e)), + ), + DpopErrorKind::InvalidHeader | DpopErrorKind::Serialization => { + let msg = smol_str::format_smolstr!("DPoP: {:?}", kind); + ClientError::encode(msg) + } + }; + + // Add URL if present (skip for Transport since e was consumed) + if !matches!(kind, DpopErrorKind::Transport) { + if let Some(u) = url { + client_err = client_err.with_url(u); + } + } + + // Add combined context if present (skip for Transport since e was consumed) + if !matches!(kind, DpopErrorKind::Transport) { + if let Some(ctx) = combined_context { + client_err = client_err.with_context(ctx); + } + } + + client_err + } } -type Result = core::result::Result; +type Result = core::result::Result; #[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] pub trait DpopClient: HttpClient { @@ -191,8 +480,14 @@ where T: HttpClient, N: DpopDataSource, { + let target = if is_to_auth_server { + DpopTarget::AuthServer + } else { + DpopTarget::ResourceServer + }; let uri = request.uri().clone(); let method = request.method().to_cowstr().into_static(); + let url_str: SmolStr = uri.to_cowstr().as_ref().into(); let uri = uri.to_cowstr(); let ath = extract_ath(request.headers()); @@ -208,7 +503,7 @@ where let response = client .send_http(request.clone()) .await - .map_err(|e| Error::Inner(e.into()))?; + .map_err(|e| DpopError::transport(target, url_str.clone(), e))?; let next_nonce = response .headers() @@ -232,7 +527,7 @@ where let response = client .send_http(request) .await - .map_err(|e| Error::Inner(e.into()))?; + .map_err(|e| DpopError::nonce_retry(target, url_str, e))?; Ok(response) } @@ -249,8 +544,14 @@ where { use jacquard_common::xrpc::StreamingResponse; + let target = if is_to_auth_server { + DpopTarget::AuthServer + } else { + DpopTarget::ResourceServer + }; let uri = request.uri().clone(); let method = request.method().to_cowstr().into_static(); + let url_str: SmolStr = uri.to_cowstr().as_ref().into(); let uri = uri.to_cowstr(); let ath = extract_ath(request.headers()); @@ -266,7 +567,7 @@ where let http_response = client .send_http_streaming(request.clone()) .await - .map_err(|e| Error::Inner(e.into()))?; + .map_err(|e| DpopError::transport(target, url_str.clone(), e))?; let (parts, body) = http_response.into_parts(); let next_nonce = parts @@ -294,7 +595,7 @@ where let http_response = client .send_http_streaming(request) .await - .map_err(|e| Error::Inner(e.into()))?; + .map_err(|e| DpopError::nonce_retry(target, url_str, e))?; let (parts, body) = http_response.into_parts(); Ok(StreamingResponse::new(parts, body)) } @@ -313,8 +614,14 @@ where { use jacquard_common::xrpc::StreamingResponse; + let target = if is_to_auth_server { + DpopTarget::AuthServer + } else { + DpopTarget::ResourceServer + }; let uri = parts.uri.clone(); let method = parts.method.to_cowstr().into_static(); + let url_str: SmolStr = uri.to_cowstr().as_ref().into(); let uri = uri.to_cowstr(); let ath = extract_ath(&parts.headers); @@ -334,7 +641,7 @@ where let http_response = client .send_http_bidirectional(parts.clone(), body1.into_inner()) .await - .map_err(|e| Error::Inner(e.into()))?; + .map_err(|e| DpopError::transport(target, url_str.clone(), e))?; let (resp_parts, resp_body) = http_response.into_parts(); let next_nonce = resp_parts @@ -363,7 +670,7 @@ where let http_response = client .send_http_bidirectional(parts, body2.into_inner()) .await - .map_err(|e| Error::Inner(e.into()))?; + .map_err(|e| DpopError::nonce_retry(target, url_str, e))?; let (parts, body) = http_response.into_parts(); Ok(StreamingResponse::new(parts, body)) } @@ -430,9 +737,9 @@ pub fn build_dpop_proof<'s>( nonce: Option>, ath: Option>, ) -> Result> { - let secret = match crypto::Key::try_from(key).map_err(Error::JwkCrypto)? { + let secret = match crypto::Key::try_from(key).map_err(DpopError::crypto)? { crypto::Key::P256(crypto::Kind::Secret(sk)) => sk, - _ => return Err(Error::UnsupportedKey), + _ => return Err(DpopError::unsupported_key()), }; let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256)); header.typ = Some(JWT_HEADER_TYP_DPOP.into()); diff --git a/crates/jacquard-oauth/src/error.rs b/crates/jacquard-oauth/src/error.rs index f82e7b45..05ec613c 100644 --- a/crates/jacquard-oauth/src/error.rs +++ b/crates/jacquard-oauth/src/error.rs @@ -6,6 +6,7 @@ use crate::resolver::ResolverError; /// High-level errors emitted by OAuth helpers. #[derive(Debug, thiserror::Error, Diagnostic)] +#[non_exhaustive] pub enum OAuthError { #[error(transparent)] #[diagnostic(code(jacquard_oauth::resolver))] @@ -21,7 +22,7 @@ pub enum OAuthError { #[error(transparent)] #[diagnostic(code(jacquard_oauth::dpop))] - Dpop(#[from] crate::dpop::Error), + Dpop(#[from] crate::dpop::DpopError), #[error(transparent)] #[diagnostic(code(jacquard_oauth::keyset))] @@ -54,6 +55,7 @@ pub enum OAuthError { /// Typed callback validation errors (redirect handling). #[derive(Debug, thiserror::Error, Diagnostic)] +#[non_exhaustive] pub enum CallbackError { #[error("missing state parameter in callback")] #[diagnostic(code(jacquard_oauth::callback::missing_state))] diff --git a/crates/jacquard-oauth/src/keyset.rs b/crates/jacquard-oauth/src/keyset.rs index 0ddca7bf..4ccde773 100644 --- a/crates/jacquard-oauth/src/keyset.rs +++ b/crates/jacquard-oauth/src/keyset.rs @@ -9,13 +9,14 @@ use std::collections::HashSet; use thiserror::Error; #[derive(Error, Debug)] +#[non_exhaustive] pub enum Error { #[error("duplicate kid: {0}")] DuplicateKid(String), #[error("keys must not be empty")] EmptyKeys, - #[error("key must have a `kid`")] - EmptyKid, + #[error("key at index {0} must have a `kid`")] + EmptyKid(usize), #[error("no signing key found for algorithms: {0:?}")] NotFound(Vec>), #[error("key for signing must be a secret key")] @@ -103,7 +104,7 @@ impl TryFrom> for Keyset { } let mut v = Vec::with_capacity(keys.len()); let mut hs = HashSet::with_capacity(keys.len()); - for key in keys { + for (i, key) in keys.into_iter().enumerate() { if let Some(kid) = key.prm.kid.clone() { if hs.contains(&kid) { return Err(Error::DuplicateKid(kid)); @@ -119,7 +120,7 @@ impl TryFrom> for Keyset { } v.push(key); } else { - return Err(Error::EmptyKid); + return Err(Error::EmptyKid(i)); } } Ok(Self(v)) diff --git a/crates/jacquard-oauth/src/request.rs b/crates/jacquard-oauth/src/request.rs index fb0a5e5e..769f5a8f 100644 --- a/crates/jacquard-oauth/src/request.rs +++ b/crates/jacquard-oauth/src/request.rs @@ -61,6 +61,7 @@ pub struct RequestError { /// Error categories for OAuth request operations #[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[non_exhaustive] pub enum RequestErrorKind { /// No endpoint available #[error("no {0} endpoint available")] @@ -331,8 +332,8 @@ impl From for RequestError { } } -impl From for RequestError { - fn from(e: crate::dpop::Error) -> Self { +impl From for RequestError { + fn from(e: crate::dpop::DpopError) -> Self { let msg = smol_str::format_smolstr!("{:?}", e); Self::new(RequestErrorKind::Dpop, Some(Box::new(e))) .with_context(msg) diff --git a/crates/jacquard-oauth/src/resolver.rs b/crates/jacquard-oauth/src/resolver.rs index 247d2112..f4ac4b3c 100644 --- a/crates/jacquard-oauth/src/resolver.rs +++ b/crates/jacquard-oauth/src/resolver.rs @@ -74,6 +74,7 @@ pub struct ResolverError { /// Error categories for OAuth resolver operations #[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[non_exhaustive] pub enum ResolverErrorKind { /// Resource not found #[error("resource not found")] diff --git a/crates/jacquard-oauth/src/scopes.rs b/crates/jacquard-oauth/src/scopes.rs index b6d06e9b..8eedc6c8 100644 --- a/crates/jacquard-oauth/src/scopes.rs +++ b/crates/jacquard-oauth/src/scopes.rs @@ -1023,6 +1023,7 @@ fn parse_query_string(query: &str) -> BTreeMap>> { /// Error type for scope parsing #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] +#[non_exhaustive] pub enum ParseError { /// Unknown scope prefix UnknownPrefix(String), diff --git a/crates/jacquard-oauth/src/session.rs b/crates/jacquard-oauth/src/session.rs index 43ac3561..f97a4b95 100644 --- a/crates/jacquard-oauth/src/session.rs +++ b/crates/jacquard-oauth/src/session.rs @@ -287,6 +287,7 @@ impl<'s> ClientSession<'s> { } #[derive(thiserror::Error, Debug, miette::Diagnostic)] +#[non_exhaustive] pub enum Error { #[error(transparent)] #[diagnostic(code(jacquard_oauth::session::request))] diff --git a/crates/jacquard-repo/src/error.rs b/crates/jacquard-repo/src/error.rs index 7f9caf35..a30561e7 100644 --- a/crates/jacquard-repo/src/error.rs +++ b/crates/jacquard-repo/src/error.rs @@ -22,6 +22,7 @@ pub struct RepoError { /// Error categories for repository operations #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] pub enum RepoErrorKind { /// Storage operation failed Storage, @@ -145,6 +146,12 @@ impl RepoError { Self::new(RepoErrorKind::Car, Some(Box::new(source))) } + /// Create a CAR write error with CID context + pub fn car_write(cid: impl fmt::Display, source: impl Error + Send + Sync + 'static) -> Self { + Self::new(RepoErrorKind::Car, Some(Box::new(source))) + .with_context(format!("failed to write block {}", cid)) + } + /// Create a CAR parse error (alias for car) pub fn car_parse(source: impl Error + Send + Sync + 'static) -> Self { Self::car(source).with_context("Failed to parse CAR file".to_string()) @@ -207,6 +214,7 @@ impl fmt::Display for RepoError { /// MST-specific errors #[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[non_exhaustive] pub enum MstError { /// Empty key not allowed #[error("Empty key not allowed")] @@ -253,6 +261,7 @@ impl From for RepoError { /// Commit-specific errors #[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[non_exhaustive] pub enum CommitError { /// Invalid commit version #[error("Invalid commit version: {0}")] @@ -302,6 +311,7 @@ impl From for RepoError { /// Diff-specific errors #[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[non_exhaustive] pub enum DiffError { /// Too many operations #[error("Too many operations: {count} (max {max})")] @@ -335,6 +345,7 @@ impl From for RepoError { /// Proof verification errors #[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[non_exhaustive] pub enum ProofError { /// CAR file has no root CID #[error("CAR file has no root CID")] diff --git a/crates/jacquard-repo/src/mst/tree.rs b/crates/jacquard-repo/src/mst/tree.rs index 8d22814b..6e5022c2 100644 --- a/crates/jacquard-repo/src/mst/tree.rs +++ b/crates/jacquard-repo/src/mst/tree.rs @@ -1262,7 +1262,23 @@ impl Mst { entries = self.get_entries().await? } let data = util::serialize_node_data(entries.as_slice()).await?; - let bytes = serde_ipld_dagcbor::to_vec(&data).map_err(|e| RepoError::car(e))?; + let bytes = serde_ipld_dagcbor::to_vec(&data).map_err(|e| { + // Provide best available context for debugging serialization failures + let context = if let Some(left_cid) = &data.left { + format!("serializing MST node (left pointer: {})", left_cid) + } else { + // No left pointer - use last leaf key as identifier + let last_key = entries.iter().rev().find_map(|e| match e { + NodeEntry::Leaf { key, .. } => Some(key.as_str()), + _ => None, + }); + match last_key { + Some(k) => format!("serializing MST node (last key: {})", k), + None => "serializing MST node (empty or tree-only)".to_string(), + } + }; + RepoError::serialization(e).with_context(context) + })?; let cid = util::compute_cid(&bytes)?; Ok((cid, Bytes::from_owner(bytes))) @@ -1336,7 +1352,7 @@ impl Mst { writer .write(*cid, &data) .await - .map_err(|e| RepoError::car(e))?; + .map_err(|e| RepoError::car_write(cid, e))?; } } @@ -1364,7 +1380,7 @@ impl Mst { writer .write(pointer, &node_bytes) .await - .map_err(|e| RepoError::car(e))?; + .map_err(|e| RepoError::car_write(&pointer, e))?; // Parse to get entries let entries = self.get_entries().await?; @@ -1411,9 +1427,7 @@ fn collect_node_cids_parallel( .collect(); // Await all tasks concurrently - let results = try_join_all(tasks) - .await - .map_err(|e| RepoError::invalid(format!("Task join error: {}", e)))?; + let results = try_join_all(tasks).await.map_err(RepoError::task_failed)?; // Flatten results let mut cids = vec![pointer]; @@ -1455,9 +1469,7 @@ fn collect_leaves_parallel( // Await all tasks concurrently if !tasks.is_empty() { - let subtree_results = try_join_all(tasks) - .await - .map_err(|e| RepoError::invalid(format!("Task join error: {}", e)))?; + let subtree_results = try_join_all(tasks).await.map_err(RepoError::task_failed)?; for (pos, leaves) in task_positions.into_iter().zip(subtree_results) { result.push((pos, leaves?)); @@ -1518,9 +1530,7 @@ fn collect_blocks_parallel( // Await all tasks concurrently if !tasks.is_empty() { - let results = try_join_all(tasks) - .await - .map_err(|e| RepoError::invalid(format!("Task join error: {}", e)))?; + let results = try_join_all(tasks).await.map_err(RepoError::task_failed)?; for subtree_result in results { let (_, subtree_blocks) = subtree_result?; diff --git a/crates/jacquard/src/client.rs b/crates/jacquard/src/client.rs index b22b0d10..a6769b9f 100644 --- a/crates/jacquard/src/client.rs +++ b/crates/jacquard/src/client.rs @@ -674,7 +674,7 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { let (did, _) = self .session_info() .await - .ok_or_else(AgentError::no_session)?; + .ok_or_else(|| AgentError::no_session().for_collection("create record", R::NSID))?; #[cfg(feature = "tracing")] let _span = tracing::debug_span!("create_record", collection = %R::nsid()).entered(); @@ -692,11 +692,14 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { #[cfg(feature = "tracing")] _span.exit(); - let response = self.send(request).await?; + let response = self + .send(request) + .await + .map_err(|e| e.for_collection("create record", R::NSID))?; response.into_output().map_err(|e| match e { XrpcError::Auth(auth) => AgentError::from(auth), - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), XrpcError::Xrpc(typed) => AgentError::sub_operation("create record", typed), + e => AgentError::xrpc(e), }) } } @@ -790,10 +793,11 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { let http_response = self .send_http(http_request) .await - .map_err(|e| ClientError::transport(e))?; + .map_err(|e| ClientError::transport(e).for_collection("get record", R::NSID))?; xrpc::process_response(http_response) - }?; + .map_err(|e| e.for_collection("get record", R::NSID))? + }; Ok(response.transmute()) } } @@ -837,23 +841,17 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { &self.opts().await, )?; - let http_response = self - .send_http(http_request) - .await - .map_err(|e| ClientError::transport(e))?; + let http_response = self.send_http(http_request).await.map_err(|e| { + ClientError::transport(e).for_collection("fetch record", collection.as_str()) + })?; xrpc::process_response(http_response) - }?; + .map_err(|e| e.for_collection("fetch record", collection.as_str()))? + }; let output = response.into_output().map_err(|e| match e { XrpcError::Auth(auth) => AgentError::from(auth), - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), - XrpcError::Xrpc(typed) => AgentError::new( - AgentErrorKind::SubOperation { - step: "fetch record", - }, - None, - ) - .with_details(typed.to_string()), + XrpcError::Xrpc(typed) => AgentError::sub_operation("parse record", typed), + e => AgentError::xrpc(e), })?; Ok(output) } @@ -874,15 +872,21 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { { let uri = uri.as_uri(); async move { + use smol_str::format_smolstr; + let response = self.get_record::(uri).await?; let response: Response = response.transmute(); let output = response.into_output().map_err(|e| match e { XrpcError::Auth(auth) => AgentError::from(auth), - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), - XrpcError::Xrpc(typed) => { - AgentError::new(AgentErrorKind::SubOperation { step: "get record" }, None) - .with_details(typed.to_string()) - } + XrpcError::Xrpc(typed) => AgentError::new( + AgentErrorKind::SubOperation { + step: "parse record", + }, + None, + ) + .with_details(format_smolstr!("{:?}", typed)), + // Note for future orual: the above was done this way due to GAT lifetime inference constraints.. + e => AgentError::xrpc(e), })?; Ok(output) } @@ -937,11 +941,10 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { // Parse to get R<'_> borrowing from response buffer let record = response.parse().map_err(|e| match e { XrpcError::Auth(auth) => AgentError::from(auth), - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), XrpcError::Xrpc(typed) => { - AgentError::new(AgentErrorKind::SubOperation { step: "get record" }, None) - .with_details(typed.to_string()) + AgentError::sub_operation("parse record", typed.into_static()) } + e => AgentError::xrpc(e), })?; // Convert to owned @@ -954,9 +957,10 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { let rkey = uri .rkey() .ok_or_else(|| { + use jacquard_common::types::string::AtStrError; AgentError::sub_operation( "extract rkey", - std::io::Error::new(std::io::ErrorKind::InvalidInput, "AtUri missing rkey"), + AtStrError::missing("at-uri-scheme", &uri, "rkey"), ) })? .clone() @@ -983,7 +987,7 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { let (did, _) = self .session_info() .await - .ok_or_else(AgentError::no_session)?; + .ok_or_else(|| AgentError::no_session().for_collection("delete record", R::NSID))?; #[cfg(feature = "tracing")] let _span = tracing::debug_span!("delete_record", collection = %R::nsid()).entered(); @@ -999,11 +1003,14 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { #[cfg(feature = "tracing")] _span.exit(); - let response = self.send(request).await?; + let response = self + .send(request) + .await + .map_err(|e| e.for_collection("delete record", R::NSID))?; response.into_output().map_err(|e| match e { XrpcError::Auth(auth) => AgentError::from(auth), - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), XrpcError::Xrpc(typed) => AgentError::sub_operation("delete record", typed), + e => AgentError::xrpc(e), }) } } @@ -1031,7 +1038,7 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { let (did, _) = self .session_info() .await - .ok_or_else(AgentError::no_session)?; + .ok_or_else(|| AgentError::no_session().for_collection("put record", R::NSID))?; let data = to_data(&record).map_err(|e| AgentError::sub_operation("serialize record", e))?; @@ -1046,11 +1053,14 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { #[cfg(feature = "tracing")] _span.exit(); - let response = self.send(request).await?; + let response = self + .send(request) + .await + .map_err(|e| e.for_collection("put record", R::NSID))?; response.into_output().map_err(|e| match e { XrpcError::Auth(auth) => AgentError::from(auth), - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), XrpcError::Xrpc(typed) => AgentError::sub_operation("put record", typed), + e => AgentError::xrpc(e), }) } } @@ -1105,8 +1115,8 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { let response = self.send_with_opts(request, opts).await?; let output = response.into_output().map_err(|e| match e { XrpcError::Auth(auth) => AgentError::from(auth), - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), XrpcError::Xrpc(typed) => AgentError::sub_operation("upload blob", typed), + e => AgentError::xrpc(e), })?; Ok(output.blob.blob().clone().into_static()) } @@ -1148,10 +1158,10 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { let response = self.send(get_request).await?; let output = response.parse().map_err(|e| match e { XrpcError::Auth(auth) => AgentError::from(auth), - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), XrpcError::Xrpc(typed) => { AgentError::sub_operation("update vec", typed.into_static()) } + e => AgentError::xrpc(e), })?; // Extract vec (converts to owned via IntoStatic) diff --git a/crates/jacquard/src/client/error.rs b/crates/jacquard/src/client/error.rs index fcd64a30..79854f96 100644 --- a/crates/jacquard/src/client/error.rs +++ b/crates/jacquard/src/client/error.rs @@ -50,6 +50,7 @@ impl std::fmt::Display for AgentError { /// Error categories for Agent operations #[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[non_exhaustive] pub enum AgentErrorKind { /// Transport/network layer failure #[error("client error (see context for details)")] @@ -172,6 +173,32 @@ impl AgentError { self } + /// Append additional context to existing context string. + /// + /// If context already exists, appends with ": " separator. + /// If no context exists, sets it directly. + pub fn append_context(mut self, additional: impl AsRef) -> Self { + self.context = Some(match self.context.take() { + Some(existing) => smol_str::format_smolstr!("{}: {}", existing, additional.as_ref()), + None => additional.as_ref().into(), + }); + self + } + + /// Add NSID context for XRPC operations. + /// + /// Appends the NSID in brackets to existing context, e.g. `"network timeout: [com.atproto.repo.getRecord]"`. + pub fn for_nsid(self, nsid: &str) -> Self { + self.append_context(smol_str::format_smolstr!("[{}]", nsid)) + } + + /// Add collection context for record operations. + /// + /// Use this when a record operation fails to indicate the target collection. + pub fn for_collection(self, operation: &str, collection_nsid: &str) -> Self { + self.append_context(smol_str::format_smolstr!("{} [{}]", operation, collection_nsid)) + } + /// Add XRPC error data to this error for observability pub fn with_xrpc(mut self, xrpc: XrpcError) -> Self where @@ -253,10 +280,12 @@ impl From for AgentError { fn from(e: ClientError) -> Self { use smol_str::ToSmolStr; - let context_msg: SmolStr; + let mut context_msg: SmolStr; let help_msg: SmolStr; let url = e.url().map(|s| s.to_smolstr()); let details = e.details().map(|s| s.to_smolstr()); + // Preserve original context from ClientError to append later + let original_context = e.context().map(|s| s.to_smolstr()); // Build context and help based on the error kind match e.kind() { @@ -297,6 +326,15 @@ impl From for AgentError { help_msg = "verify storage backend is accessible".to_smolstr(); context_msg = "storage operation failed".to_smolstr(); } + _ => { + help_msg = "see source error for details".to_smolstr(); + context_msg = "client error".to_smolstr(); + } + } + + // Append original context from ClientError if present + if let Some(original) = original_context { + context_msg = smol_str::format_smolstr!("{}: {}", context_msg, original); } let mut error = Self::new(AgentErrorKind::Client, Some(Box::new(e))); diff --git a/crates/jacquard/src/client/token.rs b/crates/jacquard/src/client/token.rs index 451f2056..e49c01b9 100644 --- a/crates/jacquard/src/client/token.rs +++ b/crates/jacquard/src/client/token.rs @@ -243,6 +243,20 @@ pub struct FileAuthStore(FileTokenStore); impl FileAuthStore { /// Create a new file-backed auth store wrapping `FileTokenStore`. + /// + /// # Errors + /// + /// Returns an error if parent directories cannot be created or the file cannot be written. + pub fn try_new(path: impl AsRef) -> Result { + Ok(Self(FileTokenStore::try_new(path)?)) + } + + /// Create a new file-backed auth store wrapping `FileTokenStore`. + /// + /// # Panics + /// + /// Panics if parent directories cannot be created or the file cannot be written. + /// Prefer [`try_new`](Self::try_new) for fallible construction. pub fn new(path: impl AsRef) -> Self { Self(FileTokenStore::new(path)) } diff --git a/crates/jacquard/src/moderation/fetch.rs b/crates/jacquard/src/moderation/fetch.rs index 128ae18d..ea23ab76 100644 --- a/crates/jacquard/src/moderation/fetch.rs +++ b/crates/jacquard/src/moderation/fetch.rs @@ -34,6 +34,7 @@ pub async fn fetch_labeler_defs( XrpcError::Generic(g) => ClientError::decode(g.to_string()), XrpcError::Decode(e) => ClientError::decode(format!("{:?}", e)), XrpcError::Xrpc(typed) => ClientError::decode(format!("{:?}", typed)), + _ => ClientError::decode("unknown XRPC error"), })?; let mut defs = LabelerDefs::new(); @@ -132,8 +133,8 @@ pub async fn fetch_labels( .into_output() .map_err(|e| match e { XrpcError::Auth(auth) => AgentError::from(auth), - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), XrpcError::Xrpc(typed) => AgentError::xrpc(XrpcError::Xrpc(typed)), + e => AgentError::xrpc(e), })?; Ok((labels.labels, labels.cursor)) } diff --git a/crates/jacquard/src/richtext.rs b/crates/jacquard/src/richtext.rs index d4f903df..123ab35f 100644 --- a/crates/jacquard/src/richtext.rs +++ b/crates/jacquard/src/richtext.rs @@ -699,6 +699,7 @@ pub fn extract_at_uri_from_url(url: &str, embed_domains: &[&str]) -> Option