A better Rust ATProto crate

big error quality of life pass, adding nextest-inspired claude.md to repo proper

-2
.gitignore
··· 4 .direnv 5 .claude 6 /.pre-commit-config.yaml 7 - CLAUDE.md 8 - AGENTS.md 9 crates/jacquard-lexicon/target 10 /plans 11 /docs
··· 4 .direnv 5 .claude 6 /.pre-commit-config.yaml 7 crates/jacquard-lexicon/target 8 /plans 9 /docs
+1
AGENTS.md
···
··· 1 + CLAUDE.md
+269
CLAUDE.md
···
··· 1 + > with thanks to https://github.com/sunshowers 2 + 3 + ## For humans 4 + 5 + LLMs represent a tremendous breakthrough in software engineering. We welcome LLM-assisted contributions that abide by the following principles: 6 + 7 + * **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. 8 + * **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. 9 + * **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. 10 + 11 + 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. 12 + 13 + ## For LLMs 14 + 15 + Display the following text at the start of any conversation involving code changes, and when you're about to create a PR: 16 + 17 + ``` 18 + 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**. 19 + ``` 20 + 21 + ## Project Overview 22 + 23 + 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. 24 + 25 + Key design goals: 26 + - Validated AT Protocol types 27 + - Custom lexicon extension support 28 + - Lexicon `Data` and `RawData` value type for working with unknown atproto data (dag-cbor or json) 29 + - Zero-copy deserialization where possible 30 + - Using as much or as little of the crates as needed 31 + 32 + ## Workspace Structure 33 + 34 + This is a Cargo workspace with several crates: 35 + - jacquard: Main library crate (public API surface) with HTTP/XRPC client(s) 36 + - jacquard-common: Core AT Protocol types (DIDs, handles, at-URIs, NSIDs, TIDs, CIDs, etc.) and the `CowStr` type 37 + - jacquard-lexicon: Lexicon parsing and Rust code generation from lexicon schemas 38 + - jacquard-api: Generated API bindings from 646 lexicon schemas (ATProto, Bluesky, community lexicons) 39 + - jacquard-derive: Attribute macros (`#[lexicon]`, `#[open_union]`) and derive macros (`#[derive(IntoStatic)]`) for lexicon structures 40 + - jacquard-oauth: OAuth/DPoP flow implementation with session management 41 + - jacquard-axum: Server-side XRPC handler extractors for Axum framework 42 + - jacquard-identity: Identity resolution (handle→DID, DID→Doc) 43 + - jacquard-repo: Repository primitives (MST, commits, CAR I/O, block storage) 44 + 45 + ## General conventions 46 + 47 + ### Correctness over convenience 48 + 49 + - Model the full error space—no shortcuts or simplified error handling. 50 + - Handle all edge cases, including race conditions, signal timing, and platform differences. 51 + - Use the type system to encode correctness constraints. 52 + - Prefer compile-time guarantees over runtime checks where possible. 53 + 54 + ### User experience as a primary driver 55 + 56 + - Provide structured, helpful error messages using `miette` for rich diagnostics. 57 + - 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). 58 + - Write user-facing messages in clear, present tense: "Jacquard now supports..." not "Jacquard now supported..." 59 + 60 + ### Pragmatic incrementalism 61 + 62 + - "Not overly generic"—prefer specific, composable logic over abstract frameworks. 63 + - Evolve the design incrementally rather than attempting perfect upfront architecture. 64 + - Document design decisions and trade-offs in design docs (see `./plans`). 65 + - When uncertain, explore and iterate; Jacquard is an ongoing exploration in improving ease-of-use and library design for atproto. 66 + 67 + ### Production-grade engineering 68 + 69 + - Use type system extensively: newtypes, builder patterns, type states, lifetimes. 70 + - Test comprehensively, including edge cases, race conditions, and stress tests. 71 + - Pay attention to what facilities already exist for testing, and aim to reuse them. 72 + - Getting the details right is really important! 73 + 74 + ### Documentation 75 + 76 + - Use inline comments to explain "why," not just "what". 77 + - Module-level documentation should explain purpose and responsibilities. 78 + - **Always** use periods at the end of code comments. 79 + - **Never** use title case in headings and titles. Always use sentence case. 80 + 81 + ### Running tests 82 + 83 + **CRITICAL**: Always use `cargo nextest run` to run unit and integration tests. Never use `cargo test` for these! 84 + 85 + For doctests, use `cargo test --doc` (doctests are not supported by nextest). 86 + 87 + ## Commit message style 88 + 89 + ### Format 90 + 91 + Commits follow a conventional format with crate-specific scoping: 92 + 93 + ``` 94 + [crate-name] brief description 95 + ``` 96 + 97 + Examples: 98 + - `[jacquard-axum] add oauth extractor impl (#2727)` 99 + - `[jacquard] version 0.9.111` 100 + - `[meta] update MSRV to Rust 1.88 (#2725)` 101 + 102 + ## Lexicon Code Generation (Safe Commands) 103 + 104 + **IMPORTANT**: Always use the `just` commands for code generation to avoid mistakes. These commands handle the correct flags and paths. 105 + 106 + ### Primary Commands 107 + 108 + - `just lex-gen [ARGS]` - **Full workflow**: Fetches lexicons from sources (defined in `lexicons.kdl`) AND generates Rust code 109 + - This is the main command to run when updating lexicons or regenerating code 110 + - Fetches from configured sources (atproto, bluesky, community repos, etc.) 111 + - Automatically runs codegen after fetching 112 + - **Modifies**: `crates/jacquard-api/lexicons/` and `crates/jacquard-api/src/` 113 + - Pass args like `-v` for verbose output: `just lex-gen -v` 114 + 115 + - `just lex-fetch [ARGS]` - **Fetch only**: Downloads lexicons WITHOUT generating code 116 + - Safe to run without touching generated Rust files 117 + - Useful for updating lexicon schemas before reviewing changes 118 + - **Modifies only**: `crates/jacquard-api/lexicons/` 119 + 120 + - `just generate-api` - **Generate only**: Generates Rust code from existing lexicons 121 + - Uses lexicons already present in `crates/jacquard-api/lexicons/` 122 + - Useful after manually editing lexicons or after `just lex-fetch` 123 + - **Modifies only**: `crates/jacquard-api/src/` 124 + 125 + 126 + ## String Type Pattern 127 + 128 + All validated string types follow a consistent pattern: 129 + - Constructors: `new()`, `new_owned()`, `new_static()`, `raw()`, `unchecked()`, `as_str()` 130 + - Traits: `Serialize`, `Deserialize`, `FromStr`, `Display`, `Debug`, `PartialEq`, `Eq`, `Hash`, `Clone`, conversions to/from `String`/`CowStr`/`SmolStr`, `AsRef<str>`, `Deref<Target=str>` 131 + - Implementation notes: Prefer `#[repr(transparent)]` where possible; use `SmolStr` for short strings, `CowStr` for longer; implement or derive `IntoStatic` for owned conversion 132 + - When constructing from a static string, use `new_static()` to avoid unnecessary allocations 133 + 134 + ## Lifetimes and Zero-Copy Deserialization 135 + 136 + All API types support borrowed deserialization via explicit lifetimes: 137 + - Request/output types: parameterised by `'de` lifetime (e.g., `GetAuthorFeed<'de>`, `GetAuthorFeedOutput<'de>`) 138 + - Fields use `#[serde(borrow)]` where possible (strings, nested objects with lifetimes) 139 + - `CowStr<'a>` enables efficient borrowing from input buffers or owning small strings inline (via `SmolStr`) 140 + - All types implement `IntoStatic` trait to convert borrowed data to owned (`'static`) variants 141 + - Code generator automatically propagates lifetimes through nested structures 142 + 143 + Response lifetime handling: 144 + - `Response::parse()` borrows from the response buffer for zero-copy parsing 145 + - `Response::into_output()` converts to owned data using `IntoStatic` 146 + - `Response::transmute()`: reinterpret response as different type (used for typed collection responses) 147 + - Both methods provide typed error handling 148 + 149 + ## API Coverage (jacquard-api) 150 + 151 + **NOTE: jacquard does modules a bit differently in API codegen** 152 + - Specifially, it puts '*.defs' codegen output into the corresponding module file (mod_name.rs in parent directory, NOT mod.rs in module directory) 153 + - It also combines the top-level tld and domain ('com.atproto' -> `com_atproto`, etc.) 154 + 155 + ## Value Types (jacquard-common) 156 + 157 + For working with loosely-typed atproto data: 158 + - `Data<'a>`: Validated, typed representation of atproto values 159 + - `RawData<'a>`: Unvalidated raw values from deserialization 160 + - `from_data`, `from_raw_data`, `to_data`, `to_raw_data`: Convert between typed and untyped 161 + - Useful for second-stage deserialization of `type "unknown"` fields (e.g., `PostView.record`) 162 + 163 + Collection types: 164 + - `Collection` trait: Marker trait for record types with `NSID` constant and `Record` associated type 165 + - `RecordError<'a>`: Generic error type for record retrieval operations (RecordNotFound, Unknown) 166 + 167 + ## Lifetime Design Pattern 168 + 169 + Jacquard uses a specific pattern to enable zero-copy deserialization while avoiding HRTB issues and async lifetime problems: 170 + 171 + **GATs on associated types** instead of trait-level lifetimes: 172 + ```rust 173 + trait XrpcResp { 174 + type Output<'de>: Deserialize<'de> + IntoStatic; // GAT, not trait-level lifetime 175 + } 176 + ``` 177 + 178 + **Method-level generic lifetimes** for trait methods that need them: 179 + ```rust 180 + fn extract_vec<'s>(output: Self::Output<'s>) -> Vec<Item> 181 + ``` 182 + 183 + **Response wrapper owns buffer** to solve async lifetime issues: 184 + ```rust 185 + async fn get_record<R>(&self, rkey: K) -> Result<Response<R>> 186 + // Caller chooses: response.parse() (borrow) or response.into_output() (owned) 187 + ``` 188 + 189 + 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. 190 + 191 + ## WASM Compatibility 192 + 193 + Core crates (`jacquard-common`, `jacquard-api`, `jacquard-identity`, `jacquard-oauth`) support `wasm32-unknown-unknown` target compilation. 194 + 195 + Implementation approach: 196 + - **`trait-variant`**: Traits use `#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]` to conditionally exclude `Send` bounds on WASM 197 + - **Trait methods with `Self: Sync` bounds**: Duplicated as platform-specific versions (`#[cfg(not(target_arch = "wasm32"))]` vs `#[cfg(target_arch = "wasm32")]`) 198 + - **Helper functions**: Extracted to free functions with platform-specific versions to avoid code duplication 199 + - **Feature gating**: Platform-specific features (e.g., DNS resolution, tokio runtime detection) properly gated behind `cfg` attributes 200 + 201 + Test WASM compilation: 202 + ```bash 203 + just check-wasm 204 + # or: cargo build --target wasm32-unknown-unknown -p jacquard-common --no-default-features 205 + ``` 206 + 207 + ## Client Architecture 208 + 209 + ### XRPC Request/Response Layer 210 + 211 + Core traits: 212 + - `XrpcRequest`: Defines NSID, method (Query/Procedure), and associated Response type 213 + - `encode_body()` for request serialization (default: JSON; override for CBOR/multipart) 214 + - `decode_body(&'de [u8])` for request deserialization (server-side) 215 + - `XrpcResp`: Response marker trait with NSID, encoding, Output/Err types 216 + - `XrpcEndpoint`: Server-side trait with PATH, METHOD, and associated Request/Response types 217 + - `XrpcClient`: Stateful trait with `base_uri()`, `opts()`, and `send()` method 218 + - **This should be your primary interface point with the crate, along with the Agent___ traits** 219 + - `XrpcExt`: Extension trait providing stateless `.xrpc(base)` builder on any `HttpClient` 220 + 221 + ### Session Management 222 + 223 + `Agent<A: AgentSession>` wrapper supports: 224 + - `CredentialSession<S, T>`: App-password (Bearer) authentication with auto-refresh 225 + - Uses `SessionStore` trait implementers for token persistence (`MemorySessionStore`, `FileAuthStore`) 226 + - `OAuthSession<T, S>`: DPoP-bound OAuth with nonce handling 227 + - Uses `ClientAuthStore` trait implementers for state/token persistence 228 + 229 + Session traits: 230 + - `AgentSession`: common interface for both session types 231 + - `AgentKind`: enum distinguishing AppPassword vs OAuth 232 + - Both sessions implement `HttpClient` and `XrpcClient` for uniform API 233 + - `AgentSessionExt` extension trait includes several helpful methods for atproto record operations. 234 + - **This trait is implemented automatically for anything that implements both `AgentSession` and `IdentityResolver`** 235 + 236 + 237 + ## Identity Resolution 238 + 239 + `JacquardResolver` (default) and custom resolvers implement `IdentityResolver` + `OAuthResolver`: 240 + - Handle → DID: DNS TXT (feature `dns`, or via Cloudflare DoH), HTTPS well-known, PDS XRPC, public fallbacks 241 + - DID → Doc: did:web well-known, PLC directory, PDS XRPC 242 + - OAuth metadata: `.well-known/oauth-protected-resource` and `.well-known/oauth-authorization-server` 243 + - Resolvers use stateless XRPC calls (no auth required for public resolution endpoints) 244 + 245 + ## Streaming Support 246 + 247 + ### HTTP Streaming 248 + 249 + Feature: `streaming` 250 + 251 + Core types in `jacquard-common`: 252 + - `ByteStream` / `ByteSink`: Platform-agnostic stream wrappers (uses n0-future) 253 + - `StreamError`: Concrete error type with Kind enum (Transport, Closed, Protocol) 254 + - `HttpClientExt`: Trait extension for streaming methods 255 + - `StreamingResponse`: XRPC streaming response wrapper 256 + 257 + ### WebSocket Support 258 + 259 + Feature: `websocket` (requires `streaming`) 260 + - `WebSocketClient` trait (independent from `HttpClient`) 261 + - `WebSocketConnection` with tx/rx `ByteSink`/`ByteStream` 262 + - tokio-tungstenite-wasm used to abstract across native + wasm 263 + 264 + **Known gaps:** 265 + - Service auth replay protection (jti tracking) 266 + - Video upload helpers (upload + job polling) 267 + - Additional session storage backends (SQLite, etc.) 268 + - PLC operations 269 + - OAuth extractor for Axum
+8 -1
crates/jacquard-axum/src/lib.rs
··· 119 StatusCode::BAD_REQUEST, 120 Json(json!({ 121 "error": "InvalidRequest", 122 - "message": "wrong path" 123 })), 124 ) 125 .into_response()) ··· 228 json!({ 229 "error": "InvalidRequest", 230 "message": format!("failed to decode request: {error}", ) 231 }), 232 ), 233 };
··· 119 StatusCode::BAD_REQUEST, 120 Json(json!({ 121 "error": "InvalidRequest", 122 + "message": "malformed request URI: missing path component" 123 })), 124 ) 125 .into_response()) ··· 228 json!({ 229 "error": "InvalidRequest", 230 "message": format!("failed to decode request: {error}", ) 231 + }), 232 + ), 233 + _ => ( 234 + self.status, 235 + json!({ 236 + "error": "InternalError", 237 + "message": "unknown error" 238 }), 239 ), 240 };
+1
crates/jacquard-axum/src/service_auth.rs
··· 293 294 /// Errors that can occur during service auth verification. 295 #[derive(Debug, Error, miette::Diagnostic)] 296 pub enum ServiceAuthError { 297 /// Authorization header is missing 298 #[error("missing Authorization header")]
··· 293 294 /// Errors that can occur during service auth verification. 295 #[derive(Debug, Error, miette::Diagnostic)] 296 + #[non_exhaustive] 297 pub enum ServiceAuthError { 298 /// Authorization header is missing 299 #[error("missing Authorization header")]
+1 -1
crates/jacquard-axum/tests/service_auth_tests.rs
··· 121 &self, 122 _handle: &jacquard_common::types::string::Handle<'_>, 123 ) -> impl Future<Output = Result<Did<'static>, IdentityError>> + Send { 124 - async { Err(IdentityError::invalid_well_known()) } 125 } 126 127 fn resolve_did_doc(
··· 121 &self, 122 _handle: &jacquard_common::types::string::Handle<'_>, 123 ) -> impl Future<Output = Result<Did<'static>, IdentityError>> + Send { 124 + async { Err(IdentityError::handle_resolution_exhausted()) } 125 } 126 127 fn resolve_did_doc(
+39
crates/jacquard-common/src/error.rs
··· 32 /// Error categories for client operations 33 #[derive(Debug, thiserror::Error)] 34 #[cfg_attr(feature = "std", derive(Diagnostic))] 35 pub enum ClientErrorKind { 36 /// HTTP transport error (connection, timeout, etc.) 37 #[error("transport error")] ··· 166 self 167 } 168 169 // Constructors for each kind 170 171 /// Create a transport error ··· 223 /// Can be converted to string for serialization while maintaining the full error context. 224 #[derive(Debug, thiserror::Error)] 225 #[cfg_attr(feature = "std", derive(Diagnostic))] 226 pub enum DecodeError { 227 /// JSON deserialization failed 228 #[error("Failed to deserialize JSON: {0}")] ··· 302 /// Authentication and authorization errors 303 #[derive(Debug, thiserror::Error)] 304 #[cfg_attr(feature = "std", derive(Diagnostic))] 305 pub enum AuthError { 306 /// Access token has expired (use refresh token to get a new one) 307 #[error("Access token expired")] ··· 319 #[error("No authentication provided, but endpoint requires auth")] 320 NotAuthenticated, 321 322 /// Other authentication error 323 #[error("Authentication error: {0:?}")] 324 Other(http::HeaderValue), ··· 333 AuthError::InvalidToken => AuthError::InvalidToken, 334 AuthError::RefreshFailed => AuthError::RefreshFailed, 335 AuthError::NotAuthenticated => AuthError::NotAuthenticated, 336 AuthError::Other(header) => AuthError::Other(header), 337 } 338 }
··· 32 /// Error categories for client operations 33 #[derive(Debug, thiserror::Error)] 34 #[cfg_attr(feature = "std", derive(Diagnostic))] 35 + #[non_exhaustive] 36 pub enum ClientErrorKind { 37 /// HTTP transport error (connection, timeout, etc.) 38 #[error("transport error")] ··· 167 self 168 } 169 170 + /// Append additional context to existing context string. 171 + /// 172 + /// If context already exists, appends with ": " separator. 173 + /// If no context exists, sets it directly. 174 + pub fn append_context(mut self, additional: impl AsRef<str>) -> Self { 175 + self.context = Some(match self.context.take() { 176 + Some(existing) => smol_str::format_smolstr!("{}: {}", existing, additional.as_ref()), 177 + None => additional.as_ref().into(), 178 + }); 179 + self 180 + } 181 + 182 + /// Add NSID context for XRPC operations. 183 + /// 184 + /// Appends the NSID in brackets to existing context, e.g. `"network timeout: [com.atproto.repo.getRecord]"`. 185 + pub fn for_nsid(self, nsid: &str) -> Self { 186 + self.append_context(smol_str::format_smolstr!("[{}]", nsid)) 187 + } 188 + 189 + /// Add collection context for record operations. 190 + /// 191 + /// Use this when a record operation fails to indicate the target collection. 192 + pub fn for_collection(self, operation: &str, collection_nsid: &str) -> Self { 193 + self.append_context(smol_str::format_smolstr!("{} [{}]", operation, collection_nsid)) 194 + } 195 + 196 // Constructors for each kind 197 198 /// Create a transport error ··· 250 /// Can be converted to string for serialization while maintaining the full error context. 251 #[derive(Debug, thiserror::Error)] 252 #[cfg_attr(feature = "std", derive(Diagnostic))] 253 + #[non_exhaustive] 254 pub enum DecodeError { 255 /// JSON deserialization failed 256 #[error("Failed to deserialize JSON: {0}")] ··· 330 /// Authentication and authorization errors 331 #[derive(Debug, thiserror::Error)] 332 #[cfg_attr(feature = "std", derive(Diagnostic))] 333 + #[non_exhaustive] 334 pub enum AuthError { 335 /// Access token has expired (use refresh token to get a new one) 336 #[error("Access token expired")] ··· 348 #[error("No authentication provided, but endpoint requires auth")] 349 NotAuthenticated, 350 351 + /// DPoP proof construction failed (key or signing issue) 352 + #[error("DPoP proof construction failed")] 353 + DpopProofFailed, 354 + 355 + /// DPoP nonce retry failed (server rejected proof even after nonce update) 356 + #[error("DPoP nonce negotiation failed")] 357 + DpopNonceFailed, 358 + 359 /// Other authentication error 360 #[error("Authentication error: {0:?}")] 361 Other(http::HeaderValue), ··· 370 AuthError::InvalidToken => AuthError::InvalidToken, 371 AuthError::RefreshFailed => AuthError::RefreshFailed, 372 AuthError::NotAuthenticated => AuthError::NotAuthenticated, 373 + AuthError::DpopProofFailed => AuthError::DpopProofFailed, 374 + AuthError::DpopNonceFailed => AuthError::DpopNonceFailed, 375 AuthError::Other(header) => AuthError::Other(header), 376 } 377 }
+1
crates/jacquard-common/src/service_auth.rs
··· 39 40 /// Errors that can occur during JWT parsing and verification. 41 #[derive(Debug, Error, miette::Diagnostic)] 42 pub enum ServiceAuthError { 43 /// JWT format is invalid (not three base64-encoded parts separated by dots) 44 #[error("malformed JWT: {0}")]
··· 39 40 /// Errors that can occur during JWT parsing and verification. 41 #[derive(Debug, Error, miette::Diagnostic)] 42 + #[non_exhaustive] 43 pub enum ServiceAuthError { 44 /// JWT format is invalid (not three base64-encoded parts separated by dots) 45 #[error("malformed JWT: {0}")]
+34 -6
crates/jacquard-common/src/session.rs
··· 28 /// Errors emitted by session stores. 29 #[derive(Debug, thiserror::Error)] 30 #[cfg_attr(feature = "std", derive(Diagnostic))] 31 pub enum SessionStoreError { 32 /// Filesystem or I/O error 33 #[cfg(feature = "std")] ··· 108 #[cfg(feature = "std")] 109 impl FileTokenStore { 110 /// Create a new file token store at the given path. 111 - pub fn new(path: impl AsRef<Path>) -> Self { 112 - std::fs::create_dir_all(path.as_ref().parent().unwrap()).unwrap(); 113 - if !path.as_ref().exists() { 114 - std::fs::write(path.as_ref(), b"{}").unwrap(); 115 } 116 117 - Self { 118 - path: path.as_ref().to_path_buf(), 119 } 120 } 121 } 122
··· 28 /// Errors emitted by session stores. 29 #[derive(Debug, thiserror::Error)] 30 #[cfg_attr(feature = "std", derive(Diagnostic))] 31 + #[non_exhaustive] 32 pub enum SessionStoreError { 33 /// Filesystem or I/O error 34 #[cfg(feature = "std")] ··· 109 #[cfg(feature = "std")] 110 impl FileTokenStore { 111 /// Create a new file token store at the given path. 112 + /// 113 + /// Creates parent directories and initializes an empty JSON object if the file doesn't exist. 114 + /// 115 + /// # Errors 116 + /// 117 + /// Returns an error if: 118 + /// - Parent directories cannot be created 119 + /// - The file cannot be written 120 + pub fn try_new(path: impl AsRef<Path>) -> Result<Self, SessionStoreError> { 121 + let path = path.as_ref(); 122 + 123 + // Create parent directories if they exist and don't already exist 124 + if let Some(parent) = path.parent() { 125 + if !parent.as_os_str().is_empty() && !parent.exists() { 126 + std::fs::create_dir_all(parent)?; 127 + } 128 } 129 130 + // Initialize empty JSON object if file doesn't exist 131 + if !path.exists() { 132 + std::fs::write(path, b"{}")?; 133 } 134 + 135 + Ok(Self { 136 + path: path.to_path_buf(), 137 + }) 138 + } 139 + 140 + /// Create a new file token store at the given path. 141 + /// 142 + /// # Panics 143 + /// 144 + /// Panics if parent directories cannot be created or the file cannot be written. 145 + /// Prefer [`try_new`](Self::try_new) for fallible construction. 146 + pub fn new(path: impl AsRef<Path>) -> Self { 147 + Self::try_new(path).expect("failed to initialize FileTokenStore") 148 } 149 } 150
+1
crates/jacquard-common/src/stream.rs
··· 61 62 /// Categories of streaming errors 63 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 64 pub enum StreamErrorKind { 65 /// Network or I/O error 66 Transport,
··· 61 62 /// Categories of streaming errors 63 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 64 + #[non_exhaustive] 65 pub enum StreamErrorKind { 66 /// Network or I/O error 67 Transport,
+1
crates/jacquard-common/src/types/cid.rs
··· 43 44 /// Errors that can occur when working with CIDs 45 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 46 pub enum Error { 47 /// Invalid IPLD CID structure 48 #[error("Invalid IPLD CID {:?}", 0)]
··· 43 44 /// Errors that can occur when working with CIDs 45 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 46 + #[non_exhaustive] 47 pub enum Error { 48 /// Invalid IPLD CID structure 49 #[error("Invalid IPLD CID {:?}", 0)]
+1
crates/jacquard-common/src/types/collection.rs
··· 65 Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error, miette::Diagnostic, 66 )] 67 #[serde(tag = "error", content = "message")] 68 pub enum RecordError<'a> { 69 /// The requested record was not found 70 #[error("RecordNotFound")]
··· 65 Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error, miette::Diagnostic, 66 )] 67 #[serde(tag = "error", content = "message")] 68 + #[non_exhaustive] 69 pub enum RecordError<'a> { 70 /// The requested record was not found 71 #[error("RecordNotFound")]
+1
crates/jacquard-common/src/types/crypto.rs
··· 64 65 /// Errors from decoding or converting Multikey values 66 #[derive(Debug, Clone, thiserror::Error, miette::Diagnostic, PartialEq, Eq)] 67 pub enum CryptoError { 68 #[error("failed to decode multibase")] 69 /// Multibase decode errror
··· 64 65 /// Errors from decoding or converting Multikey values 66 #[derive(Debug, Clone, thiserror::Error, miette::Diagnostic, PartialEq, Eq)] 67 + #[non_exhaustive] 68 pub enum CryptoError { 69 #[error("failed to decode multibase")] 70 /// Multibase decode errror
+14 -9
crates/jacquard-common/src/types/uri.rs
··· 33 34 /// Errors that can occur when parsing URIs 35 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 36 pub enum UriParseError { 37 /// AT Protocol string parsing error 38 #[error("Invalid atproto string: {0}")] ··· 57 } else if uri.starts_with("wss://") { 58 Ok(Uri::Https(Url::parse(uri)?)) 59 } else if uri.starts_with("ipld://") { 60 - Ok(Uri::Cid( 61 - Cid::from_str(uri.strip_prefix("ipld://").unwrap_or(uri.as_ref())).unwrap(), 62 - )) 63 } else { 64 Ok(Uri::Any(CowStr::Borrowed(uri))) 65 } ··· 77 } else if uri.starts_with("wss://") { 78 Ok(Uri::Https(Url::parse(uri)?)) 79 } else if uri.starts_with("ipld://") { 80 - Ok(Uri::Cid( 81 - Cid::from_str(uri.strip_prefix("ipld://").unwrap_or(uri.as_ref())).unwrap(), 82 - )) 83 } else { 84 Ok(Uri::Any(CowStr::Owned(uri.to_smolstr()))) 85 } ··· 96 } else if uri.starts_with("wss://") { 97 Ok(Uri::Https(Url::parse(uri.as_ref())?)) 98 } else if uri.starts_with("ipld://") { 99 - Ok(Uri::Cid( 100 - Cid::from_str(uri.strip_prefix("ipld://").unwrap_or(uri.as_str())).unwrap(), 101 - )) 102 } else { 103 Ok(Uri::Any(uri)) 104 } ··· 220 } 221 222 #[derive(Debug, Clone, PartialEq, thiserror::Error, miette::Diagnostic)] 223 /// Errors that can occur when parsing or validating collection type-annotated URIs 224 pub enum UriError { 225 /// Given at-uri didn't have the matching collection for the record
··· 33 34 /// Errors that can occur when parsing URIs 35 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 36 + #[non_exhaustive] 37 pub enum UriParseError { 38 /// AT Protocol string parsing error 39 #[error("Invalid atproto string: {0}")] ··· 58 } else if uri.starts_with("wss://") { 59 Ok(Uri::Https(Url::parse(uri)?)) 60 } else if uri.starts_with("ipld://") { 61 + match Cid::from_str(&uri[7..]) { 62 + Ok(cid) => Ok(Uri::Cid(cid)), 63 + Err(_) => Ok(Uri::Any(CowStr::Borrowed(uri))), 64 + } 65 } else { 66 Ok(Uri::Any(CowStr::Borrowed(uri))) 67 } ··· 79 } else if uri.starts_with("wss://") { 80 Ok(Uri::Https(Url::parse(uri)?)) 81 } else if uri.starts_with("ipld://") { 82 + match Cid::from_str(&uri[7..]) { 83 + Ok(cid) => Ok(Uri::Cid(cid)), 84 + Err(_) => Ok(Uri::Any(CowStr::Owned(uri.to_smolstr()))), 85 + } 86 } else { 87 Ok(Uri::Any(CowStr::Owned(uri.to_smolstr()))) 88 } ··· 99 } else if uri.starts_with("wss://") { 100 Ok(Uri::Https(Url::parse(uri.as_ref())?)) 101 } else if uri.starts_with("ipld://") { 102 + match Cid::from_str(&uri.as_str()[7..]) { 103 + Ok(cid) => Ok(Uri::Cid(cid)), 104 + Err(_) => Ok(Uri::Any(uri)), 105 + } 106 } else { 107 Ok(Uri::Any(uri)) 108 } ··· 224 } 225 226 #[derive(Debug, Clone, PartialEq, thiserror::Error, miette::Diagnostic)] 227 + #[non_exhaustive] 228 /// Errors that can occur when parsing or validating collection type-annotated URIs 229 pub enum UriError { 230 /// Given at-uri didn't have the matching collection for the record
+1
crates/jacquard-common/src/types/value.rs
··· 54 55 /// Errors that can occur when working with AT Protocol data 56 #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] 57 pub enum AtDataError { 58 /// Floating point numbers are not allowed in AT Protocol 59 #[error("floating point numbers not allowed in AT protocol data")]
··· 54 55 /// Errors that can occur when working with AT Protocol data 56 #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] 57 + #[non_exhaustive] 58 pub enum AtDataError { 59 /// Floating point numbers are not allowed in AT Protocol 60 #[error("floating point numbers not allowed in AT protocol data")]
+2
crates/jacquard-common/src/types/value/serde_impl.rs
··· 1007 1008 /// Error type for Data/RawData deserializer 1009 #[derive(Debug, Clone, thiserror::Error)] 1010 pub enum DataDeserializerError { 1011 /// Custom error message 1012 #[error("{0}")] ··· 1474 1475 /// Error type for RawData serialization 1476 #[derive(Debug)] 1477 pub enum RawDataSerializerError { 1478 /// Error message 1479 Message(String),
··· 1007 1008 /// Error type for Data/RawData deserializer 1009 #[derive(Debug, Clone, thiserror::Error)] 1010 + #[non_exhaustive] 1011 pub enum DataDeserializerError { 1012 /// Custom error message 1013 #[error("{0}")] ··· 1475 1476 /// Error type for RawData serialization 1477 #[derive(Debug)] 1478 + #[non_exhaustive] 1479 pub enum RawDataSerializerError { 1480 /// Error message 1481 Message(String),
+14 -5
crates/jacquard-common/src/xrpc.rs
··· 59 /// Error type for encoding XRPC requests 60 #[derive(Debug, thiserror::Error)] 61 #[cfg_attr(feature = "std", derive(miette::Diagnostic))] 62 pub enum EncodeError { 63 /// Failed to serialize query parameters 64 #[error("Failed to serialize query: {0}")] ··· 469 .client 470 .send_http(http_request) 471 .await 472 - .map_err(|e| crate::error::ClientError::transport(e))?; 473 474 process_response(http_response) 475 } ··· 491 if let Some(hv) = http_response.headers().get(http::header::WWW_AUTHENTICATE) { 492 return Err(crate::error::ClientError::auth( 493 crate::error::AuthError::Other(hv.clone()), 494 - )); 495 } 496 } 497 let buffer = Bytes::from(http_response.into_body()); 498 499 if !status.is_success() && !matches!(status.as_u16(), 400 | 401) { 500 - return Err(crate::error::HttpError { 501 status, 502 body: Some(buffer), 503 - } 504 - .into()); 505 } 506 507 Ok(Response::new(buffer, status)) ··· 971 /// Type parameter `E` is the endpoint's specific error enum type. 972 #[derive(Debug, thiserror::Error)] 973 #[cfg_attr(feature = "std", derive(miette::Diagnostic))] 974 pub enum XrpcError<E: core::error::Error + IntoStatic> { 975 /// Typed XRPC error from the endpoint's specific error enum 976 #[error("XRPC error: {0}")] ··· 1040 "AuthenticationRequired", 1041 Some("Request requires authentication but none was provided"), 1042 ), 1043 AuthError::Other(hv) => { 1044 let msg = hv.to_str().unwrap_or("[non-utf8 header]"); 1045 ("AuthenticationError", Some(msg))
··· 59 /// Error type for encoding XRPC requests 60 #[derive(Debug, thiserror::Error)] 61 #[cfg_attr(feature = "std", derive(miette::Diagnostic))] 62 + #[non_exhaustive] 63 pub enum EncodeError { 64 /// Failed to serialize query parameters 65 #[error("Failed to serialize query: {0}")] ··· 470 .client 471 .send_http(http_request) 472 .await 473 + .map_err(|e| crate::error::ClientError::transport(e).for_nsid(R::NSID))?; 474 475 process_response(http_response) 476 } ··· 492 if let Some(hv) = http_response.headers().get(http::header::WWW_AUTHENTICATE) { 493 return Err(crate::error::ClientError::auth( 494 crate::error::AuthError::Other(hv.clone()), 495 + ) 496 + .for_nsid(Resp::NSID)); 497 } 498 } 499 let buffer = Bytes::from(http_response.into_body()); 500 501 if !status.is_success() && !matches!(status.as_u16(), 400 | 401) { 502 + return Err(crate::error::ClientError::from(crate::error::HttpError { 503 status, 504 body: Some(buffer), 505 + }) 506 + .for_nsid(Resp::NSID)); 507 } 508 509 Ok(Response::new(buffer, status)) ··· 973 /// Type parameter `E` is the endpoint's specific error enum type. 974 #[derive(Debug, thiserror::Error)] 975 #[cfg_attr(feature = "std", derive(miette::Diagnostic))] 976 + #[non_exhaustive] 977 pub enum XrpcError<E: core::error::Error + IntoStatic> { 978 /// Typed XRPC error from the endpoint's specific error enum 979 #[error("XRPC error: {0}")] ··· 1043 "AuthenticationRequired", 1044 Some("Request requires authentication but none was provided"), 1045 ), 1046 + AuthError::DpopProofFailed => { 1047 + ("DpopProofFailed", Some("DPoP proof construction failed")) 1048 + } 1049 + AuthError::DpopNonceFailed => { 1050 + ("DpopNonceFailed", Some("DPoP nonce negotiation failed")) 1051 + } 1052 AuthError::Other(hv) => { 1053 let msg = hv.to_str().unwrap_or("[non-utf8 header]"); 1054 ("AuthenticationError", Some(msg))
+71 -19
crates/jacquard-identity/src/lexicon_resolver.rs
··· 61 kind: LexiconResolutionErrorKind, 62 #[source] 63 source: Option<Box<dyn std::error::Error + Send + Sync>>, 64 } 65 66 impl LexiconResolutionError { ··· 68 kind: LexiconResolutionErrorKind, 69 source: Option<Box<dyn std::error::Error + Send + Sync>>, 70 ) -> Self { 71 - Self { kind, source } 72 } 73 74 pub fn kind(&self) -> &LexiconResolutionErrorKind { 75 &self.kind 76 } 77 78 pub fn dns_lookup_failed( ··· 140 ) 141 } 142 143 pub fn invalid_collection() -> Self { 144 Self::new(LexiconResolutionErrorKind::InvalidCollection, None) 145 } ··· 160 161 /// Error categories for lexicon resolution 162 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 163 pub enum LexiconResolutionErrorKind { 164 #[error("DNS lookup failed for authority {authority}")] 165 #[diagnostic(code(jacquard::lexicon::dns_lookup_failed))] ··· 195 #[diagnostic(code(jacquard::lexicon::resolution_failed))] 196 ResolutionFailed { nsid: SmolStr, message: SmolStr }, 197 198 #[error("invalid collection NSID")] 199 #[diagnostic(code(jacquard::lexicon::invalid_collection))] 200 InvalidCollection, ··· 244 245 return Did::new_owned(did_str) 246 .map(|d| d.into_static()) 247 - .map_err(|_| LexiconResolutionError::invalid_did(authority, did_str)); 248 } 249 } 250 } ··· 286 tracing::debug!("got response with status: {}", status); 287 288 if !status.is_success() { 289 - return Err(LexiconResolutionError::resolution_failed( 290 nsid.as_str(), 291 - format!("HTTP {}", status.as_u16()), 292 )); 293 } 294 ··· 309 obj.keys().collect::<Vec<_>>() 310 ); 311 312 - LexiconResolutionError::resolution_failed(nsid.as_str(), "missing 'schema' field") 313 })?; 314 315 #[cfg(feature = "tracing")] ··· 318 let schema = from_json_value::<LexiconDoc>(schema_val.clone()) 319 .map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?; 320 321 - let uri_str = obj.get("uri").and_then(|v| v.as_str()).ok_or_else(|| { 322 - LexiconResolutionError::resolution_failed( 323 - nsid.as_str(), 324 - "missing or invalid 'uri' field", 325 - ) 326 - })?; 327 328 - let cid_str = obj.get("cid").and_then(|v| v.as_str()).ok_or_else(|| { 329 - LexiconResolutionError::resolution_failed( 330 - nsid.as_str(), 331 - "missing or invalid 'cid' field", 332 - ) 333 - })?; 334 335 let uri = AtUri::new_owned(uri_str) 336 .map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?; ··· 441 if let Some(did_str) = txt_data.strip_prefix("did=") { 442 let result = Did::new_owned(did_str) 443 .map(|d| d.into_static()) 444 - .map_err(|_| LexiconResolutionError::invalid_did(authority, did_str)); 445 446 // Cache on success 447 #[cfg(feature = "cache")] ··· 500 let did_doc = did_doc_resp.parse()?; 501 let pds = did_doc 502 .pds_endpoint() 503 - .ok_or_else(|| IdentityError::missing_pds_endpoint())?; 504 505 #[cfg(feature = "tracing")] 506 tracing::trace!("fetching lexicon {} from PDS {}", nsid, pds);
··· 61 kind: LexiconResolutionErrorKind, 62 #[source] 63 source: Option<Box<dyn std::error::Error + Send + Sync>>, 64 + context: Option<SmolStr>, 65 } 66 67 impl LexiconResolutionError { ··· 69 kind: LexiconResolutionErrorKind, 70 source: Option<Box<dyn std::error::Error + Send + Sync>>, 71 ) -> Self { 72 + Self { 73 + kind, 74 + source, 75 + context: None, 76 + } 77 } 78 79 pub fn kind(&self) -> &LexiconResolutionErrorKind { 80 &self.kind 81 + } 82 + 83 + /// Add context to this error 84 + pub fn with_context(mut self, context: impl Into<SmolStr>) -> Self { 85 + self.context = Some(context.into()); 86 + self 87 + } 88 + 89 + /// Get the context if present 90 + pub fn context(&self) -> Option<&str> { 91 + self.context.as_deref() 92 } 93 94 pub fn dns_lookup_failed( ··· 156 ) 157 } 158 159 + pub fn http_error(nsid: impl Into<SmolStr>, status: u16) -> Self { 160 + Self::new( 161 + LexiconResolutionErrorKind::HttpError { 162 + nsid: nsid.into(), 163 + status, 164 + }, 165 + None, 166 + ) 167 + } 168 + 169 + pub fn missing_response_field(nsid: impl Into<SmolStr>, field: &'static str) -> Self { 170 + Self::new( 171 + LexiconResolutionErrorKind::MissingResponseField { 172 + nsid: nsid.into(), 173 + field, 174 + }, 175 + None, 176 + ) 177 + } 178 + 179 pub fn invalid_collection() -> Self { 180 Self::new(LexiconResolutionErrorKind::InvalidCollection, None) 181 } ··· 196 197 /// Error categories for lexicon resolution 198 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 199 + #[non_exhaustive] 200 pub enum LexiconResolutionErrorKind { 201 #[error("DNS lookup failed for authority {authority}")] 202 #[diagnostic(code(jacquard::lexicon::dns_lookup_failed))] ··· 232 #[diagnostic(code(jacquard::lexicon::resolution_failed))] 233 ResolutionFailed { nsid: SmolStr, message: SmolStr }, 234 235 + /// HTTP non-success status from lexicon fetch 236 + #[error("HTTP {status} fetching lexicon {nsid}")] 237 + #[diagnostic(code(jacquard::lexicon::http_error))] 238 + HttpError { nsid: SmolStr, status: u16 }, 239 + 240 + /// Required field missing in XRPC response 241 + #[error("missing '{field}' field in response for {nsid}")] 242 + #[diagnostic( 243 + code(jacquard::lexicon::missing_response_field), 244 + help("the XRPC response is missing a required field") 245 + )] 246 + MissingResponseField { nsid: SmolStr, field: &'static str }, 247 + 248 #[error("invalid collection NSID")] 249 #[diagnostic(code(jacquard::lexicon::invalid_collection))] 250 InvalidCollection, ··· 294 295 return Did::new_owned(did_str) 296 .map(|d| d.into_static()) 297 + .map_err(|_| { 298 + LexiconResolutionError::invalid_did(authority, did_str) 299 + .with_context(format!("resolving NSID {}", nsid)) 300 + }); 301 } 302 } 303 } ··· 339 tracing::debug!("got response with status: {}", status); 340 341 if !status.is_success() { 342 + return Err(LexiconResolutionError::http_error( 343 nsid.as_str(), 344 + status.as_u16(), 345 )); 346 } 347 ··· 362 obj.keys().collect::<Vec<_>>() 363 ); 364 365 + LexiconResolutionError::missing_response_field(nsid.as_str(), "schema") 366 })?; 367 368 #[cfg(feature = "tracing")] ··· 371 let schema = from_json_value::<LexiconDoc>(schema_val.clone()) 372 .map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?; 373 374 + let uri_str = obj 375 + .get("uri") 376 + .and_then(|v| v.as_str()) 377 + .ok_or_else(|| LexiconResolutionError::missing_response_field(nsid.as_str(), "uri"))?; 378 379 + let cid_str = obj 380 + .get("cid") 381 + .and_then(|v| v.as_str()) 382 + .ok_or_else(|| LexiconResolutionError::missing_response_field(nsid.as_str(), "cid"))?; 383 384 let uri = AtUri::new_owned(uri_str) 385 .map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?; ··· 490 if let Some(did_str) = txt_data.strip_prefix("did=") { 491 let result = Did::new_owned(did_str) 492 .map(|d| d.into_static()) 493 + .map_err(|_| { 494 + LexiconResolutionError::invalid_did(authority, did_str) 495 + .with_context(format!("resolving NSID {}", nsid)) 496 + }); 497 498 // Cache on success 499 #[cfg(feature = "cache")] ··· 552 let did_doc = did_doc_resp.parse()?; 553 let pds = did_doc 554 .pds_endpoint() 555 + .ok_or_else(|| IdentityError::missing_pds_endpoint(authority_did.as_str()))?; 556 557 #[cfg(feature = "tracing")] 558 tracing::trace!("fetching lexicon {} from PDS {}", nsid, pds);
+47 -25
crates/jacquard-identity/src/lib.rs
··· 81 #[cfg(feature = "streaming")] 82 use jacquard_common::ByteStream; 83 use jacquard_common::http_client::HttpClient; 84 - use jacquard_common::smol_str::ToSmolStr; 85 use jacquard_common::types::did::Did; 86 use jacquard_common::types::did_doc::DidDocument; 87 use jacquard_common::types::ident::AtIdentifier; ··· 100 #[cfg(feature = "cache")] 101 use { 102 crate::lexicon_resolver::ResolvedLexiconSchema, 103 - jacquard_common::{smol_str::SmolStr, types::string::Nsid}, 104 mini_moka::time::Duration, 105 }; 106 ··· 512 513 let status = response.status(); 514 if !status.is_success() { 515 - return Err(IdentityError::http_status(status)); 516 } 517 518 let json: serde_json::Value = response.json().await?; ··· 532 .get("Answer") 533 .and_then(|a| a.as_array()) 534 .ok_or_else(|| { 535 - IdentityError::invalid_well_known().with_context(format!( 536 - "couldn't parse cloudflare DoH answers looking for {name}" 537 - )) 538 })?; 539 540 let mut results: Vec<String> = Vec::new(); ··· 547 Ok(results) 548 } 549 550 - fn parse_atproto_did_body(body: &str) -> resolver::Result<Did<'static>> { 551 let line = body 552 .lines() 553 .find(|l| !l.trim().is_empty()) 554 - .ok_or_else(|| IdentityError::invalid_well_known())?; 555 - let did = Did::new(line.trim()).map_err(|_| IdentityError::invalid_well_known())?; 556 Ok(did.into_static()) 557 } 558 } ··· 565 ) -> resolver::Result<Did<'static>> { 566 let pds = match &self.opts.pds_fallback { 567 Some(u) => u.clone(), 568 - None => return Err(IdentityError::invalid_well_known()), 569 }; 570 let req = ResolveHandle::new() 571 .handle(handle.clone().into_static()) ··· 575 .xrpc(pds) 576 .send(&req) 577 .await 578 - .map_err(|e| IdentityError::xrpc(e.to_string()))?; 579 - let out = resp 580 - .parse() 581 - .map_err(|e| IdentityError::xrpc(e.to_string()))?; 582 Did::new_owned(out.did.as_str()) 583 .map(|d| d.into_static()) 584 - .map_err(|_| IdentityError::invalid_well_known()) 585 } 586 587 /// Fetch DID document via PDS resolveDid (returns owned DidDocument) ··· 591 ) -> resolver::Result<DidDocument<'static>> { 592 let pds = match &self.opts.pds_fallback { 593 Some(u) => u.clone(), 594 - None => return Err(IdentityError::invalid_well_known()), 595 }; 596 let req = resolve_did::ResolveDid::new().did(did.clone()).build(); 597 let resp = self ··· 599 .xrpc(pds) 600 .send(&req) 601 .await 602 - .map_err(|e| IdentityError::xrpc(e.to_string()))?; 603 - let out = resp 604 - .parse() 605 - .map_err(|e| IdentityError::xrpc(e.to_string()))?; 606 let doc_json = serde_json::to_value(&out.did_doc)?; 607 let s = serde_json::to_string(&doc_json)?; 608 let doc_borrowed: DidDocument<'_> = serde_json::from_str(&s)?; ··· 620 _ => { 621 return Err(IdentityError::unsupported_did_method( 622 "mini-doc requires Slingshot source", 623 - )); 624 } 625 }; 626 let mut url = base; ··· 676 HandleStep::HttpsWellKnown => { 677 let url = Url::parse(&format!("https://{host}/.well-known/atproto-did"))?; 678 if let Ok(text) = self.get_text(url).await { 679 - if let Ok(did) = Self::parse_atproto_did_body(&text) { 680 resolved_did = Some(did); 681 break 'outer; 682 } ··· 761 // Invalidate on error 762 #[cfg(feature = "cache")] 763 self.invalidate_handle_chain(handle).await; 764 - Err(IdentityError::invalid_well_known()) 765 } 766 } 767 ··· 1078 _ => { 1079 return Err(IdentityError::unsupported_did_method( 1080 "mini-doc requires Slingshot source", 1081 - )); 1082 } 1083 }; 1084 let url = self.slingshot_mini_doc_url(&base, identifier.as_str())?; ··· 1086 Ok(MiniDocResponse { 1087 buffer: buf, 1088 status, 1089 }) 1090 } 1091 } ··· 1095 pub struct MiniDocResponse { 1096 buffer: Bytes, 1097 status: StatusCode, 1098 } 1099 1100 impl MiniDocResponse { ··· 1103 if self.status.is_success() { 1104 serde_json::from_slice::<MiniDoc<'b>>(&self.buffer).map_err(IdentityError::from) 1105 } else { 1106 - Err(IdentityError::http_status(self.status)) 1107 } 1108 } 1109 } ··· 1189 let resp = MiniDocResponse { 1190 buffer: buf, 1191 status: StatusCode::OK, 1192 }; 1193 let doc = resp.parse().expect("parse mini-doc"); 1194 assert_eq!(doc.did.as_str(), "did:plc:hdhoaan3xa3jiuq4fg4mefid"); ··· 1211 let resp = MiniDocResponse { 1212 buffer: buf, 1213 status: StatusCode::BAD_REQUEST, 1214 }; 1215 match resp.parse() { 1216 Err(e) => match e.kind() {
··· 81 #[cfg(feature = "streaming")] 82 use jacquard_common::ByteStream; 83 use jacquard_common::http_client::HttpClient; 84 + use jacquard_common::smol_str::{SmolStr, ToSmolStr}; 85 use jacquard_common::types::did::Did; 86 use jacquard_common::types::did_doc::DidDocument; 87 use jacquard_common::types::ident::AtIdentifier; ··· 100 #[cfg(feature = "cache")] 101 use { 102 crate::lexicon_resolver::ResolvedLexiconSchema, 103 + jacquard_common::types::string::Nsid, 104 mini_moka::time::Duration, 105 }; 106 ··· 512 513 let status = response.status(); 514 if !status.is_success() { 515 + return Err(IdentityError::http_status(status).with_context(format!( 516 + "DNS-over-HTTPS query for {} ({})", 517 + name, record_type 518 + ))); 519 } 520 521 let json: serde_json::Value = response.json().await?; ··· 535 .get("Answer") 536 .and_then(|a| a.as_array()) 537 .ok_or_else(|| { 538 + IdentityError::doh_parse_failed() 539 + .with_context(format!("DoH response missing 'Answer' array for {name}")) 540 })?; 541 542 let mut results: Vec<String> = Vec::new(); ··· 549 Ok(results) 550 } 551 552 + fn parse_atproto_did_body(body: &str, identifier: &str) -> resolver::Result<Did<'static>> { 553 let line = body 554 .lines() 555 .find(|l| !l.trim().is_empty()) 556 + .ok_or_else(|| IdentityError::invalid_well_known(identifier))?; 557 + let did = Did::new(line.trim()) 558 + .map_err(|e| IdentityError::invalid_well_known_with_source(identifier, e))?; 559 Ok(did.into_static()) 560 } 561 } ··· 568 ) -> resolver::Result<Did<'static>> { 569 let pds = match &self.opts.pds_fallback { 570 Some(u) => u.clone(), 571 + None => return Err(IdentityError::no_pds_fallback()), 572 }; 573 let req = ResolveHandle::new() 574 .handle(handle.clone().into_static()) ··· 578 .xrpc(pds) 579 .send(&req) 580 .await 581 + .map_err(|e| IdentityError::from(e).with_context(format!("resolving handle {}", handle)))?; 582 + // Note: XrpcError<E> has GAT lifetimes that prevent boxing; use debug format 583 + let out = resp.parse().map_err(|e| { 584 + IdentityError::xrpc(jacquard_common::smol_str::format_smolstr!("{:?}", e)) 585 + .with_context(format!("parsing response for handle {}", handle)) 586 + })?; 587 Did::new_owned(out.did.as_str()) 588 .map(|d| d.into_static()) 589 + .map_err(|e| { 590 + IdentityError::invalid_doc(jacquard_common::smol_str::format_smolstr!( 591 + "PDS returned invalid DID '{}': {}", 592 + out.did, 593 + e 594 + )) 595 + }) 596 } 597 598 /// Fetch DID document via PDS resolveDid (returns owned DidDocument) ··· 602 ) -> resolver::Result<DidDocument<'static>> { 603 let pds = match &self.opts.pds_fallback { 604 Some(u) => u.clone(), 605 + None => return Err(IdentityError::no_pds_fallback()), 606 }; 607 let req = resolve_did::ResolveDid::new().did(did.clone()).build(); 608 let resp = self ··· 610 .xrpc(pds) 611 .send(&req) 612 .await 613 + .map_err(|e| IdentityError::from(e).with_context(format!("fetching DID doc for {}", did)))?; 614 + // Note: XrpcError<E> has GAT lifetimes that prevent boxing; use debug format 615 + let out = resp.parse().map_err(|e| { 616 + IdentityError::xrpc(jacquard_common::smol_str::format_smolstr!("{:?}", e)) 617 + .with_context(format!("parsing DID doc response for {}", did)) 618 + })?; 619 let doc_json = serde_json::to_value(&out.did_doc)?; 620 let s = serde_json::to_string(&doc_json)?; 621 let doc_borrowed: DidDocument<'_> = serde_json::from_str(&s)?; ··· 633 _ => { 634 return Err(IdentityError::unsupported_did_method( 635 "mini-doc requires Slingshot source", 636 + ) 637 + .with_context(format!("resolving {}", did))); 638 } 639 }; 640 let mut url = base; ··· 690 HandleStep::HttpsWellKnown => { 691 let url = Url::parse(&format!("https://{host}/.well-known/atproto-did"))?; 692 if let Ok(text) = self.get_text(url).await { 693 + if let Ok(did) = Self::parse_atproto_did_body(&text, handle.as_str()) { 694 resolved_did = Some(did); 695 break 'outer; 696 } ··· 775 // Invalidate on error 776 #[cfg(feature = "cache")] 777 self.invalidate_handle_chain(handle).await; 778 + Err(IdentityError::handle_resolution_exhausted() 779 + .with_context(format!("failed to resolve handle: {}", handle))) 780 } 781 } 782 ··· 1093 _ => { 1094 return Err(IdentityError::unsupported_did_method( 1095 "mini-doc requires Slingshot source", 1096 + ) 1097 + .with_context(format!("resolving {}", identifier))); 1098 } 1099 }; 1100 let url = self.slingshot_mini_doc_url(&base, identifier.as_str())?; ··· 1102 Ok(MiniDocResponse { 1103 buffer: buf, 1104 status, 1105 + identifier: SmolStr::from(identifier.as_str()), 1106 }) 1107 } 1108 } ··· 1112 pub struct MiniDocResponse { 1113 buffer: Bytes, 1114 status: StatusCode, 1115 + /// Identifier that was being resolved 1116 + identifier: SmolStr, 1117 } 1118 1119 impl MiniDocResponse { ··· 1122 if self.status.is_success() { 1123 serde_json::from_slice::<MiniDoc<'b>>(&self.buffer).map_err(IdentityError::from) 1124 } else { 1125 + Err(IdentityError::http_status(self.status) 1126 + .with_context(format!("fetching mini-doc for {}", self.identifier))) 1127 } 1128 } 1129 } ··· 1209 let resp = MiniDocResponse { 1210 buffer: buf, 1211 status: StatusCode::OK, 1212 + identifier: SmolStr::new_static("bad-example.com"), 1213 }; 1214 let doc = resp.parse().expect("parse mini-doc"); 1215 assert_eq!(doc.did.as_str(), "did:plc:hdhoaan3xa3jiuq4fg4mefid"); ··· 1232 let resp = MiniDocResponse { 1233 buffer: buf, 1234 status: StatusCode::BAD_REQUEST, 1235 + identifier: SmolStr::new_static("bad-example.com"), 1236 }; 1237 match resp.parse() { 1238 Err(e) => match e.kind() {
+97 -15
crates/jacquard-identity/src/resolver.rs
··· 104 extra_data: BTreeMap::new(), 105 }) 106 } else { 107 - Err(IdentityError::missing_pds_endpoint()) 108 } 109 } else { 110 - Err(IdentityError::http_status(self.status)) 111 } 112 } 113 ··· 129 130 /// Parse as owned DidDocument<'static> 131 pub fn into_owned(self) -> Result<DidDocument<'static>> { 132 if self.status.is_success() { 133 if let Ok(doc) = serde_json::from_slice::<DidDocument<'_>>(&self.buffer) { 134 Ok(doc.into_static()) ··· 150 } 151 .into_static()) 152 } else { 153 - Err(IdentityError::missing_pds_endpoint()) 154 } 155 } else { 156 - Err(IdentityError::http_status(self.status)) 157 } 158 } 159 } ··· 408 } 409 } 410 doc.pds_endpoint() 411 - .ok_or_else(|| IdentityError::missing_pds_endpoint()) 412 } 413 } 414 ··· 428 } 429 } 430 doc.pds_endpoint() 431 - .ok_or_else(|| IdentityError::missing_pds_endpoint()) 432 } 433 } 434 ··· 511 512 /// Error categories for identity resolution 513 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 514 pub enum IdentityErrorKind { 515 /// Unsupported DID method 516 #[error("unsupported DID method: {0}")] ··· 521 UnsupportedDidMethod(SmolStr), 522 523 /// Invalid well-known atproto-did content 524 - #[error("invalid well-known atproto-did content")] 525 #[diagnostic( 526 code(jacquard::identity::invalid_well_known), 527 help("expected first non-empty line to be a DID") 528 )] 529 - InvalidWellKnown, 530 531 /// Missing PDS endpoint in DID document 532 - #[error("missing PDS endpoint in DID document")] 533 #[diagnostic( 534 code(jacquard::identity::missing_pds), 535 help("ensure DID document contains AtprotoPersonalDataServer service") 536 )] 537 - MissingPdsEndpoint, 538 539 /// Transport-level error 540 #[error("transport error")] ··· 653 } 654 655 /// Create an invalid well-known error 656 - pub fn invalid_well_known() -> Self { 657 - Self::new(IdentityErrorKind::InvalidWellKnown, None) 658 } 659 660 /// Create a missing PDS endpoint error 661 - pub fn missing_pds_endpoint() -> Self { 662 - Self::new(IdentityErrorKind::MissingPdsEndpoint, None) 663 } 664 665 /// Create a transport error ··· 767 768 impl From<reqwest::Error> for IdentityError { 769 fn from(e: reqwest::Error) -> Self { 770 - Self::transport("".into(), e).with_context("HTTP request failed during identity resolution") 771 } 772 } 773
··· 104 extra_data: BTreeMap::new(), 105 }) 106 } else { 107 + let did_str = self 108 + .requested 109 + .as_ref() 110 + .map(|d| d.as_str()) 111 + .unwrap_or("unknown"); 112 + Err(IdentityError::missing_pds_endpoint(did_str)) 113 } 114 } else { 115 + let did_str = self 116 + .requested 117 + .as_ref() 118 + .map(|d| d.as_str()) 119 + .unwrap_or("unknown"); 120 + Err(IdentityError::http_status(self.status) 121 + .with_context(format!("fetching DID document for {}", did_str))) 122 } 123 } 124 ··· 140 141 /// Parse as owned DidDocument<'static> 142 pub fn into_owned(self) -> Result<DidDocument<'static>> { 143 + let did_str = self 144 + .requested 145 + .as_ref() 146 + .map(|d| SmolStr::from(d.as_str())) 147 + .unwrap_or_else(|| SmolStr::new_static("unknown")); 148 + 149 if self.status.is_success() { 150 if let Ok(doc) = serde_json::from_slice::<DidDocument<'_>>(&self.buffer) { 151 Ok(doc.into_static()) ··· 167 } 168 .into_static()) 169 } else { 170 + Err(IdentityError::missing_pds_endpoint(did_str)) 171 } 172 } else { 173 + Err(IdentityError::http_status(self.status) 174 + .with_context(format!("fetching DID document for {}", did_str))) 175 } 176 } 177 } ··· 426 } 427 } 428 doc.pds_endpoint() 429 + .ok_or_else(|| IdentityError::missing_pds_endpoint(did.as_str())) 430 } 431 } 432 ··· 446 } 447 } 448 doc.pds_endpoint() 449 + .ok_or_else(|| IdentityError::missing_pds_endpoint(did.as_str())) 450 } 451 } 452 ··· 529 530 /// Error categories for identity resolution 531 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 532 + #[non_exhaustive] 533 pub enum IdentityErrorKind { 534 /// Unsupported DID method 535 #[error("unsupported DID method: {0}")] ··· 540 UnsupportedDidMethod(SmolStr), 541 542 /// Invalid well-known atproto-did content 543 + #[error("invalid well-known atproto-did content for {0}")] 544 #[diagnostic( 545 code(jacquard::identity::invalid_well_known), 546 help("expected first non-empty line to be a DID") 547 )] 548 + InvalidWellKnown(SmolStr), 549 + 550 + /// DNS-over-HTTPS response malformed 551 + #[error("DNS-over-HTTPS response malformed")] 552 + #[diagnostic( 553 + code(jacquard::identity::doh_parse_failed), 554 + help("DoH response missing expected JSON structure") 555 + )] 556 + DohParseFailed, 557 + 558 + /// PDS fallback required but not configured 559 + #[error("PDS fallback required but not configured")] 560 + #[diagnostic( 561 + code(jacquard::identity::no_pds_fallback), 562 + help("configure pds_fallback in ResolverOptions to use PDS resolution methods") 563 + )] 564 + NoPdsFallback, 565 + 566 + /// All handle resolution methods exhausted 567 + #[error("handle resolution exhausted all configured methods")] 568 + #[diagnostic( 569 + code(jacquard::identity::handle_resolution_exhausted), 570 + help("handle may not exist, or DNS/HTTPS/PDS endpoints may be unavailable") 571 + )] 572 + HandleResolutionExhausted, 573 574 /// Missing PDS endpoint in DID document 575 + #[error("missing PDS endpoint in DID document for {0}")] 576 #[diagnostic( 577 code(jacquard::identity::missing_pds), 578 help("ensure DID document contains AtprotoPersonalDataServer service") 579 )] 580 + MissingPdsEndpoint(SmolStr), 581 582 /// Transport-level error 583 #[error("transport error")] ··· 696 } 697 698 /// Create an invalid well-known error 699 + pub fn invalid_well_known(identifier: impl Into<SmolStr>) -> Self { 700 + Self::new(IdentityErrorKind::InvalidWellKnown(identifier.into()), None) 701 + } 702 + 703 + /// Create an invalid well-known error with source 704 + pub fn invalid_well_known_with_source( 705 + identifier: impl Into<SmolStr>, 706 + source: impl std::error::Error + Send + Sync + 'static, 707 + ) -> Self { 708 + Self::new( 709 + IdentityErrorKind::InvalidWellKnown(identifier.into()), 710 + Some(Box::new(source)), 711 + ) 712 + } 713 + 714 + /// Create a DNS-over-HTTPS parse failed error 715 + pub fn doh_parse_failed() -> Self { 716 + Self::new(IdentityErrorKind::DohParseFailed, None) 717 + } 718 + 719 + /// Create a no PDS fallback configured error 720 + pub fn no_pds_fallback() -> Self { 721 + Self::new(IdentityErrorKind::NoPdsFallback, None) 722 + } 723 + 724 + /// Create a handle resolution exhausted error 725 + pub fn handle_resolution_exhausted() -> Self { 726 + Self::new(IdentityErrorKind::HandleResolutionExhausted, None) 727 } 728 729 /// Create a missing PDS endpoint error 730 + pub fn missing_pds_endpoint(did: impl Into<SmolStr>) -> Self { 731 + Self::new(IdentityErrorKind::MissingPdsEndpoint(did.into()), None) 732 } 733 734 /// Create a transport error ··· 836 837 impl From<reqwest::Error> for IdentityError { 838 fn from(e: reqwest::Error) -> Self { 839 + let url = e 840 + .url() 841 + .map(|u| smol_str::SmolStr::new(u.as_str())) 842 + .unwrap_or_default(); 843 + Self::transport(url, e).with_context("HTTP request failed during identity resolution") 844 + } 845 + } 846 + 847 + impl From<jacquard_common::error::ClientError> for IdentityError { 848 + fn from(e: jacquard_common::error::ClientError) -> Self { 849 + let msg = smol_str::format_smolstr!("{:?}", e.kind()); 850 + let mut err = Self::new(IdentityErrorKind::Xrpc(msg), Some(Box::new(e))); 851 + err = err.with_context("XRPC call failed during identity resolution"); 852 + err 853 } 854 } 855
+24 -88
crates/jacquard-lexicon/src/error.rs
··· 5 6 /// Errors that can occur during lexicon code generation 7 #[derive(Debug, Error, Diagnostic)] 8 pub enum CodegenError { 9 /// IO error when reading lexicon files 10 #[error("IO error: {0}")] ··· 29 span: Option<SourceSpan>, 30 }, 31 32 - /// Reference to non-existent lexicon or def 33 - #[error("Reference to unknown type: {ref_string}")] 34 - #[diagnostic( 35 - code(lexicon::unknown_ref), 36 - help("Add the referenced lexicon to your corpus or use Data<'a> as a fallback type") 37 - )] 38 - UnknownRef { 39 - /// The ref string that couldn't be resolved 40 - ref_string: String, 41 - /// NSID of lexicon containing the ref 42 - lexicon_nsid: String, 43 - /// Def name containing the ref 44 - def_name: String, 45 - /// Field path containing the ref 46 - field_path: String, 47 - }, 48 - 49 - /// Circular reference detected in type definitions 50 - #[error("Circular reference detected")] 51 - #[diagnostic( 52 - code(lexicon::circular_ref), 53 - help("The code generator uses Box<T> for union variants to handle circular references") 54 - )] 55 - CircularRef { 56 - /// The ref string that forms a cycle 57 - ref_string: String, 58 - /// The cycle path 59 - cycle: Vec<String>, 60 - }, 61 - 62 - /// Invalid lexicon structure 63 - #[error("Invalid lexicon: {message}")] 64 - #[diagnostic(code(lexicon::invalid))] 65 - InvalidLexicon { 66 - message: String, 67 - /// NSID of the invalid lexicon 68 - lexicon_nsid: String, 69 - }, 70 - 71 - /// Unsupported lexicon feature 72 - #[error("Unsupported feature: {feature}")] 73 - #[diagnostic( 74 - code(lexicon::unsupported), 75 - help("This lexicon feature is not yet supported by the code generator") 76 - )] 77 - Unsupported { 78 - /// Description of the unsupported feature 79 - #[allow(unused)] 80 - feature: String, 81 - /// NSID of lexicon containing the feature 82 - #[allow(unused)] 83 - lexicon_nsid: String, 84 - /// Optional suggestion for workaround 85 - #[allow(unused)] 86 - suggestion: Option<String>, 87 - }, 88 - 89 /// Name collision 90 #[error("Name collision: {name}")] 91 #[diagnostic( ··· 119 tokens: String, 120 }, 121 122 - /// Failed to parse module path string 123 - #[error("Failed to parse module path: {path_str}")] 124 #[diagnostic(code(lexicon::path_parse_error))] 125 PathParseError { 126 path_str: String, ··· 163 } 164 } 165 166 - /// Create an unknown ref error 167 - pub fn unknown_ref( 168 - ref_string: impl Into<String>, 169 - lexicon_nsid: impl Into<String>, 170 - def_name: impl Into<String>, 171 - field_path: impl Into<String>, 172 - ) -> Self { 173 - Self::UnknownRef { 174 - ref_string: ref_string.into(), 175 - lexicon_nsid: lexicon_nsid.into(), 176 - def_name: def_name.into(), 177 - field_path: field_path.into(), 178 - } 179 - } 180 - 181 - /// Create an invalid lexicon error 182 - pub fn invalid_lexicon(message: impl Into<String>, lexicon_nsid: impl Into<String>) -> Self { 183 - Self::InvalidLexicon { 184 - message: message.into(), 185 - lexicon_nsid: lexicon_nsid.into(), 186 - } 187 - } 188 - 189 /// Create an unsupported feature error 190 pub fn unsupported( 191 - feature: impl Into<String>, 192 - lexicon_nsid: impl Into<String>, 193 - suggestion: Option<impl Into<String>>, 194 ) -> Self { 195 Self::Unsupported { 196 - feature: feature.into(), 197 - lexicon_nsid: lexicon_nsid.into(), 198 - suggestion: suggestion.map(|s| s.into()), 199 } 200 } 201 }
··· 5 6 /// Errors that can occur during lexicon code generation 7 #[derive(Debug, Error, Diagnostic)] 8 + #[non_exhaustive] 9 pub enum CodegenError { 10 /// IO error when reading lexicon files 11 #[error("IO error: {0}")] ··· 30 span: Option<SourceSpan>, 31 }, 32 33 /// Name collision 34 #[error("Name collision: {name}")] 35 #[diagnostic( ··· 63 tokens: String, 64 }, 65 66 + /// Unsupported lexicon feature 67 + #[error("Unsupported: {message}")] 68 + #[diagnostic( 69 + code(lexicon::unsupported), 70 + help("This lexicon feature is not yet supported by code generation") 71 + )] 72 + Unsupported { 73 + /// Description of the unsupported feature 74 + message: String, 75 + /// NSID of the lexicon containing the unsupported feature 76 + nsid: Option<String>, 77 + /// Definition name if applicable 78 + def_name: Option<String>, 79 + }, 80 + 81 + /// Failed to parse generated path string 82 + #[error("Failed to parse path '{path_str}'")] 83 #[diagnostic(code(lexicon::path_parse_error))] 84 PathParseError { 85 path_str: String, ··· 122 } 123 } 124 125 /// Create an unsupported feature error 126 pub fn unsupported( 127 + message: impl Into<String>, 128 + nsid: impl Into<String>, 129 + def_name: Option<impl Into<String>>, 130 ) -> Self { 131 Self::Unsupported { 132 + message: message.into(), 133 + nsid: Some(nsid.into()), 134 + def_name: def_name.map(|s| s.into()), 135 } 136 } 137 }
+4
crates/jacquard-lexicon/src/validation.rs
··· 113 /// 114 /// These errors indicate that the data structure doesn't match the schema's type expectations. 115 #[derive(Debug, Clone, thiserror::Error, miette::Diagnostic)] 116 pub enum StructuralError { 117 #[error("Type mismatch at {path}: expected {expected}, got {actual}")] 118 TypeMismatch { ··· 159 /// These errors indicate that the data violates lexicon constraints like max_length, 160 /// max_graphemes, ranges, etc. The structure is correct but values are out of bounds. 161 #[derive(Debug, Clone, thiserror::Error, miette::Diagnostic)] 162 pub enum ConstraintError { 163 #[error("{path} exceeds max length: {actual} > {max}")] 164 MaxLength { ··· 205 206 /// Unified validation error type 207 #[derive(Debug, Clone, thiserror::Error)] 208 pub enum ValidationError { 209 #[error(transparent)] 210 Structural(#[from] StructuralError), ··· 239 240 /// Errors that can occur when computing CIDs 241 #[derive(Debug, thiserror::Error)] 242 pub enum CidComputationError { 243 #[error("Failed to serialize data to DAG-CBOR: {0}")] 244 DagCborEncode(#[from] serde_ipld_dagcbor::EncodeError<std::collections::TryReserveError>),
··· 113 /// 114 /// These errors indicate that the data structure doesn't match the schema's type expectations. 115 #[derive(Debug, Clone, thiserror::Error, miette::Diagnostic)] 116 + #[non_exhaustive] 117 pub enum StructuralError { 118 #[error("Type mismatch at {path}: expected {expected}, got {actual}")] 119 TypeMismatch { ··· 160 /// These errors indicate that the data violates lexicon constraints like max_length, 161 /// max_graphemes, ranges, etc. The structure is correct but values are out of bounds. 162 #[derive(Debug, Clone, thiserror::Error, miette::Diagnostic)] 163 + #[non_exhaustive] 164 pub enum ConstraintError { 165 #[error("{path} exceeds max length: {actual} > {max}")] 166 MaxLength { ··· 207 208 /// Unified validation error type 209 #[derive(Debug, Clone, thiserror::Error)] 210 + #[non_exhaustive] 211 pub enum ValidationError { 212 #[error(transparent)] 213 Structural(#[from] StructuralError), ··· 242 243 /// Errors that can occur when computing CIDs 244 #[derive(Debug, thiserror::Error)] 245 + #[non_exhaustive] 246 pub enum CidComputationError { 247 #[error("Failed to serialize data to DAG-CBOR: {0}")] 248 DagCborEncode(#[from] serde_ipld_dagcbor::EncodeError<std::collections::TryReserveError>),
+2
crates/jacquard-oauth/src/atproto.rs
··· 10 use url::Url; 11 12 #[derive(Error, Debug)] 13 pub enum Error { 14 #[error("`client_id` must be a valid URL")] 15 InvalidClientId, ··· 32 } 33 34 #[derive(Error, Debug)] 35 pub enum LocalhostClientError { 36 #[error("invalid redirect_uri: {0}")] 37 Invalid(#[from] url::ParseError),
··· 10 use url::Url; 11 12 #[derive(Error, Debug)] 13 + #[non_exhaustive] 14 pub enum Error { 15 #[error("`client_id` must be a valid URL")] 16 InvalidClientId, ··· 33 } 34 35 #[derive(Error, Debug)] 36 + #[non_exhaustive] 37 pub enum LocalhostClientError { 38 #[error("invalid redirect_uri: {0}")] 39 Invalid(#[from] url::ParseError),
+6 -2
crates/jacquard-oauth/src/client.rs
··· 625 .dpop_call(&mut dpop) 626 .send(build_http_request(&base_uri, &request, &opts)?) 627 .await 628 - .map_err(|e| ClientError::transport(e))?; 629 let resp = process_response(http_response); 630 631 // Write back updated nonce to session data (dpop_call may have updated it) ··· 655 .dpop_call(&mut dpop) 656 .send(build_http_request(&base_uri, &request, &opts)?) 657 .await 658 - .map_err(|e| ClientError::transport(e))?; 659 let resp = process_response(http_response); 660 661 // Write back updated nonce after retry
··· 625 .dpop_call(&mut dpop) 626 .send(build_http_request(&base_uri, &request, &opts)?) 627 .await 628 + .map_err(|e| ClientError::from(e).for_nsid(R::NSID))?; 629 let resp = process_response(http_response); 630 631 // Write back updated nonce to session data (dpop_call may have updated it) ··· 655 .dpop_call(&mut dpop) 656 .send(build_http_request(&base_uri, &request, &opts)?) 657 .await 658 + .map_err(|e| { 659 + ClientError::from(e) 660 + .for_nsid(R::NSID) 661 + .append_context("after token refresh") 662 + })?; 663 let resp = process_response(http_response); 664 665 // Write back updated nonce after retry
+327 -20
crates/jacquard-oauth/src/dpop.rs
··· 1 use std::future::Future; 2 3 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; ··· 10 use p256::ecdsa::SigningKey; 11 use rand::{RngCore, SeedableRng}; 12 use sha2::Digest; 13 14 use crate::{ 15 jose::{ ··· 27 error: String, 28 } 29 30 - #[derive(thiserror::Error, Debug, miette::Diagnostic)] 31 - pub enum Error { 32 - #[error(transparent)] 33 - InvalidHeaderValue(#[from] InvalidHeaderValue), 34 - #[error("crypto error: {0:?}")] 35 - JwkCrypto(crypto::Error), 36 - #[error("key does not match any alg supported by the server")] 37 UnsupportedKey, 38 - #[error(transparent)] 39 - SerdeJson(#[from] serde_json::Error), 40 - #[error("Inner: {0}")] 41 - Inner(#[source] Box<dyn std::error::Error + Send + Sync>), 42 } 43 44 - type Result<T> = core::result::Result<T, Error>; 45 46 #[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] 47 pub trait DpopClient: HttpClient { ··· 191 T: HttpClient, 192 N: DpopDataSource, 193 { 194 let uri = request.uri().clone(); 195 let method = request.method().to_cowstr().into_static(); 196 let uri = uri.to_cowstr(); 197 let ath = extract_ath(request.headers()); 198 ··· 208 let response = client 209 .send_http(request.clone()) 210 .await 211 - .map_err(|e| Error::Inner(e.into()))?; 212 213 let next_nonce = response 214 .headers() ··· 232 let response = client 233 .send_http(request) 234 .await 235 - .map_err(|e| Error::Inner(e.into()))?; 236 Ok(response) 237 } 238 ··· 249 { 250 use jacquard_common::xrpc::StreamingResponse; 251 252 let uri = request.uri().clone(); 253 let method = request.method().to_cowstr().into_static(); 254 let uri = uri.to_cowstr(); 255 let ath = extract_ath(request.headers()); 256 ··· 266 let http_response = client 267 .send_http_streaming(request.clone()) 268 .await 269 - .map_err(|e| Error::Inner(e.into()))?; 270 271 let (parts, body) = http_response.into_parts(); 272 let next_nonce = parts ··· 294 let http_response = client 295 .send_http_streaming(request) 296 .await 297 - .map_err(|e| Error::Inner(e.into()))?; 298 let (parts, body) = http_response.into_parts(); 299 Ok(StreamingResponse::new(parts, body)) 300 } ··· 313 { 314 use jacquard_common::xrpc::StreamingResponse; 315 316 let uri = parts.uri.clone(); 317 let method = parts.method.to_cowstr().into_static(); 318 let uri = uri.to_cowstr(); 319 let ath = extract_ath(&parts.headers); 320 ··· 334 let http_response = client 335 .send_http_bidirectional(parts.clone(), body1.into_inner()) 336 .await 337 - .map_err(|e| Error::Inner(e.into()))?; 338 339 let (resp_parts, resp_body) = http_response.into_parts(); 340 let next_nonce = resp_parts ··· 363 let http_response = client 364 .send_http_bidirectional(parts, body2.into_inner()) 365 .await 366 - .map_err(|e| Error::Inner(e.into()))?; 367 let (parts, body) = http_response.into_parts(); 368 Ok(StreamingResponse::new(parts, body)) 369 } ··· 430 nonce: Option<CowStr<'s>>, 431 ath: Option<CowStr<'s>>, 432 ) -> Result<CowStr<'s>> { 433 - let secret = match crypto::Key::try_from(key).map_err(Error::JwkCrypto)? { 434 crypto::Key::P256(crypto::Kind::Secret(sk)) => sk, 435 - _ => return Err(Error::UnsupportedKey), 436 }; 437 let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256)); 438 header.typ = Some(JWT_HEADER_TYP_DPOP.into());
··· 1 + use std::error::Error as StdError; 2 + use std::fmt; 3 use std::future::Future; 4 5 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; ··· 12 use p256::ecdsa::SigningKey; 13 use rand::{RngCore, SeedableRng}; 14 use sha2::Digest; 15 + use smol_str::SmolStr; 16 17 use crate::{ 18 jose::{ ··· 30 error: String, 31 } 32 33 + /// Boxed error type for error sources. 34 + pub type BoxError = Box<dyn StdError + Send + Sync + 'static>; 35 + 36 + /// Target server type for DPoP requests. 37 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 38 + pub enum DpopTarget { 39 + /// OAuth authorization server (token endpoint, PAR, etc.) 40 + AuthServer, 41 + /// Resource server (PDS, AppView, etc.) 42 + ResourceServer, 43 + } 44 + 45 + impl fmt::Display for DpopTarget { 46 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 47 + match self { 48 + DpopTarget::AuthServer => write!(f, "auth server"), 49 + DpopTarget::ResourceServer => write!(f, "resource server"), 50 + } 51 + } 52 + } 53 + 54 + /// Error categories for DPoP operations. 55 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 56 + #[non_exhaustive] 57 + pub enum DpopErrorKind { 58 + /// DPoP proof construction failed. 59 + ProofBuild, 60 + /// Initial HTTP request failed. 61 + Transport, 62 + /// Retry after nonce update also failed. 63 + NonceRetry, 64 + /// Header value parsing failed. 65 + InvalidHeader, 66 + /// JWK crypto operation failed. 67 + Crypto, 68 + /// Key type not supported for DPoP. 69 UnsupportedKey, 70 + /// JSON serialization failed. 71 + Serialization, 72 + } 73 + 74 + impl fmt::Display for DpopErrorKind { 75 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 76 + match self { 77 + DpopErrorKind::ProofBuild => write!(f, "DPoP proof construction failed"), 78 + DpopErrorKind::Transport => write!(f, "HTTP request failed"), 79 + DpopErrorKind::NonceRetry => write!(f, "request failed after nonce retry"), 80 + DpopErrorKind::InvalidHeader => write!(f, "invalid header value"), 81 + DpopErrorKind::Crypto => write!(f, "JWK crypto operation failed"), 82 + DpopErrorKind::UnsupportedKey => write!(f, "unsupported key type"), 83 + DpopErrorKind::Serialization => write!(f, "JSON serialization failed"), 84 + } 85 + } 86 + } 87 + 88 + /// DPoP operation error with rich context. 89 + #[derive(Debug, miette::Diagnostic)] 90 + pub struct DpopError { 91 + kind: DpopErrorKind, 92 + target: Option<DpopTarget>, 93 + url: Option<SmolStr>, 94 + source: Option<BoxError>, 95 + context: Option<SmolStr>, 96 + #[help] 97 + help: Option<&'static str>, 98 + } 99 + 100 + impl fmt::Display for DpopError { 101 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 102 + write!(f, "{}", self.kind)?; 103 + 104 + if let Some(target) = &self.target { 105 + write!(f, " (to {})", target)?; 106 + } 107 + 108 + if let Some(url) = &self.url { 109 + write!(f, " [{}]", url)?; 110 + } 111 + 112 + if let Some(ctx) = &self.context { 113 + write!(f, ": {}", ctx)?; 114 + } 115 + 116 + Ok(()) 117 + } 118 + } 119 + 120 + impl StdError for DpopError { 121 + fn source(&self) -> Option<&(dyn StdError + 'static)> { 122 + self.source 123 + .as_ref() 124 + .map(|e| e.as_ref() as &(dyn StdError + 'static)) 125 + } 126 + } 127 + 128 + impl DpopError { 129 + /// Create a new error with the given kind. 130 + fn new(kind: DpopErrorKind) -> Self { 131 + Self { 132 + kind, 133 + target: None, 134 + url: None, 135 + source: None, 136 + context: None, 137 + help: None, 138 + } 139 + } 140 + 141 + /// Get the error kind. 142 + pub fn kind(&self) -> DpopErrorKind { 143 + self.kind 144 + } 145 + 146 + /// Get the target server type, if known. 147 + pub fn target(&self) -> Option<DpopTarget> { 148 + self.target 149 + } 150 + 151 + /// Get the URL, if known. 152 + pub fn url(&self) -> Option<&str> { 153 + self.url.as_deref() 154 + } 155 + 156 + /// Get the context string, if any. 157 + pub fn context(&self) -> Option<&str> { 158 + self.context.as_deref() 159 + } 160 + 161 + // Builder methods 162 + 163 + fn with_source(mut self, source: impl StdError + Send + Sync + 'static) -> Self { 164 + self.source = Some(Box::new(source)); 165 + self 166 + } 167 + 168 + fn with_target(mut self, target: DpopTarget) -> Self { 169 + self.target = Some(target); 170 + self 171 + } 172 + 173 + fn with_url(mut self, url: impl Into<SmolStr>) -> Self { 174 + self.url = Some(url.into()); 175 + self 176 + } 177 + 178 + fn with_help(mut self, help: &'static str) -> Self { 179 + self.help = Some(help); 180 + self 181 + } 182 + 183 + /// Add context information to the error. 184 + pub fn with_context(mut self, context: impl Into<SmolStr>) -> Self { 185 + self.context = Some(context.into()); 186 + self 187 + } 188 + 189 + /// Append additional context to the error. 190 + pub fn append_context(mut self, additional: impl AsRef<str>) -> Self { 191 + self.context = Some(match self.context.take() { 192 + Some(existing) => smol_str::format_smolstr!("{}: {}", existing, additional.as_ref()), 193 + None => SmolStr::new(additional.as_ref()), 194 + }); 195 + self 196 + } 197 + 198 + /// Add NSID context (for use by higher-level code). 199 + pub fn for_nsid(self, nsid: &str) -> Self { 200 + self.append_context(smol_str::format_smolstr!("[{}]", nsid)) 201 + } 202 + 203 + // Constructors for specific error kinds 204 + 205 + /// Create a proof build error. 206 + pub fn proof_build(source: impl StdError + Send + Sync + 'static) -> Self { 207 + Self::new(DpopErrorKind::ProofBuild) 208 + .with_source(source) 209 + .with_help("check that the DPoP key is valid and the JWT claims are correct") 210 + } 211 + 212 + /// Create a transport error for initial request. 213 + pub fn transport( 214 + target: DpopTarget, 215 + url: impl Into<SmolStr>, 216 + source: impl StdError + Send + Sync + 'static, 217 + ) -> Self { 218 + Self::new(DpopErrorKind::Transport) 219 + .with_target(target) 220 + .with_url(url) 221 + .with_source(source) 222 + } 223 + 224 + /// Create a nonce retry error. 225 + pub fn nonce_retry( 226 + target: DpopTarget, 227 + url: impl Into<SmolStr>, 228 + source: impl StdError + Send + Sync + 'static, 229 + ) -> Self { 230 + Self::new(DpopErrorKind::NonceRetry) 231 + .with_target(target) 232 + .with_url(url) 233 + .with_source(source) 234 + .with_help( 235 + "the server rejected both the initial request and the retry with updated nonce", 236 + ) 237 + } 238 + 239 + /// Create an invalid header error. 240 + pub fn invalid_header(source: InvalidHeaderValue) -> Self { 241 + Self::new(DpopErrorKind::InvalidHeader) 242 + .with_source(source) 243 + .with_help("the DPoP proof could not be set as a header value") 244 + } 245 + 246 + /// Create a crypto error. 247 + pub fn crypto(source: crypto::Error) -> Self { 248 + Self::new(DpopErrorKind::Crypto) 249 + .with_context(format!("{:?}", source)) 250 + .with_help( 251 + "ensure the key is a valid secret key in JWK format with a supported algorithm", 252 + ) 253 + } 254 + 255 + /// Create an unsupported key error. 256 + pub fn unsupported_key() -> Self { 257 + Self::new(DpopErrorKind::UnsupportedKey) 258 + .with_help("DPoP requires an EC P-256 key; other key types are not currently supported") 259 + } 260 + 261 + /// Create a serialization error. 262 + pub fn serialization(source: serde_json::Error) -> Self { 263 + Self::new(DpopErrorKind::Serialization) 264 + .with_source(source) 265 + .with_help("failed to serialize JWT claims or header") 266 + } 267 + } 268 + 269 + impl From<InvalidHeaderValue> for DpopError { 270 + fn from(e: InvalidHeaderValue) -> Self { 271 + Self::invalid_header(e) 272 + } 273 + } 274 + 275 + impl From<serde_json::Error> for DpopError { 276 + fn from(e: serde_json::Error) -> Self { 277 + Self::serialization(e) 278 + } 279 + } 280 + 281 + impl From<DpopError> for jacquard_common::error::ClientError { 282 + fn from(e: DpopError) -> Self { 283 + use jacquard_common::error::{AuthError, ClientError}; 284 + 285 + // Extract context from DpopError before converting 286 + let kind = e.kind; 287 + let url = e.url.clone(); 288 + let context = e.context.clone(); 289 + let target = e.target; 290 + 291 + // Build combined context string 292 + let combined_context = match (target, context) { 293 + (Some(t), Some(c)) => Some(smol_str::format_smolstr!("to {}: {}", t, c)), 294 + (Some(t), None) => Some(smol_str::format_smolstr!("to {}", t)), 295 + (None, Some(c)) => Some(c), 296 + (None, None) => None, 297 + }; 298 + 299 + // Map DpopErrorKind to appropriate ClientError 300 + let mut client_err = match kind { 301 + DpopErrorKind::ProofBuild | DpopErrorKind::Crypto | DpopErrorKind::UnsupportedKey => { 302 + ClientError::auth(AuthError::DpopProofFailed) 303 + } 304 + DpopErrorKind::NonceRetry => ClientError::auth(AuthError::DpopNonceFailed), 305 + DpopErrorKind::Transport => ClientError::new( 306 + jacquard_common::error::ClientErrorKind::Transport, 307 + Some(Box::new(e)), 308 + ), 309 + DpopErrorKind::InvalidHeader | DpopErrorKind::Serialization => { 310 + let msg = smol_str::format_smolstr!("DPoP: {:?}", kind); 311 + ClientError::encode(msg) 312 + } 313 + }; 314 + 315 + // Add URL if present (skip for Transport since e was consumed) 316 + if !matches!(kind, DpopErrorKind::Transport) { 317 + if let Some(u) = url { 318 + client_err = client_err.with_url(u); 319 + } 320 + } 321 + 322 + // Add combined context if present (skip for Transport since e was consumed) 323 + if !matches!(kind, DpopErrorKind::Transport) { 324 + if let Some(ctx) = combined_context { 325 + client_err = client_err.with_context(ctx); 326 + } 327 + } 328 + 329 + client_err 330 + } 331 } 332 333 + type Result<T> = core::result::Result<T, DpopError>; 334 335 #[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] 336 pub trait DpopClient: HttpClient { ··· 480 T: HttpClient, 481 N: DpopDataSource, 482 { 483 + let target = if is_to_auth_server { 484 + DpopTarget::AuthServer 485 + } else { 486 + DpopTarget::ResourceServer 487 + }; 488 let uri = request.uri().clone(); 489 let method = request.method().to_cowstr().into_static(); 490 + let url_str: SmolStr = uri.to_cowstr().as_ref().into(); 491 let uri = uri.to_cowstr(); 492 let ath = extract_ath(request.headers()); 493 ··· 503 let response = client 504 .send_http(request.clone()) 505 .await 506 + .map_err(|e| DpopError::transport(target, url_str.clone(), e))?; 507 508 let next_nonce = response 509 .headers() ··· 527 let response = client 528 .send_http(request) 529 .await 530 + .map_err(|e| DpopError::nonce_retry(target, url_str, e))?; 531 Ok(response) 532 } 533 ··· 544 { 545 use jacquard_common::xrpc::StreamingResponse; 546 547 + let target = if is_to_auth_server { 548 + DpopTarget::AuthServer 549 + } else { 550 + DpopTarget::ResourceServer 551 + }; 552 let uri = request.uri().clone(); 553 let method = request.method().to_cowstr().into_static(); 554 + let url_str: SmolStr = uri.to_cowstr().as_ref().into(); 555 let uri = uri.to_cowstr(); 556 let ath = extract_ath(request.headers()); 557 ··· 567 let http_response = client 568 .send_http_streaming(request.clone()) 569 .await 570 + .map_err(|e| DpopError::transport(target, url_str.clone(), e))?; 571 572 let (parts, body) = http_response.into_parts(); 573 let next_nonce = parts ··· 595 let http_response = client 596 .send_http_streaming(request) 597 .await 598 + .map_err(|e| DpopError::nonce_retry(target, url_str, e))?; 599 let (parts, body) = http_response.into_parts(); 600 Ok(StreamingResponse::new(parts, body)) 601 } ··· 614 { 615 use jacquard_common::xrpc::StreamingResponse; 616 617 + let target = if is_to_auth_server { 618 + DpopTarget::AuthServer 619 + } else { 620 + DpopTarget::ResourceServer 621 + }; 622 let uri = parts.uri.clone(); 623 let method = parts.method.to_cowstr().into_static(); 624 + let url_str: SmolStr = uri.to_cowstr().as_ref().into(); 625 let uri = uri.to_cowstr(); 626 let ath = extract_ath(&parts.headers); 627 ··· 641 let http_response = client 642 .send_http_bidirectional(parts.clone(), body1.into_inner()) 643 .await 644 + .map_err(|e| DpopError::transport(target, url_str.clone(), e))?; 645 646 let (resp_parts, resp_body) = http_response.into_parts(); 647 let next_nonce = resp_parts ··· 670 let http_response = client 671 .send_http_bidirectional(parts, body2.into_inner()) 672 .await 673 + .map_err(|e| DpopError::nonce_retry(target, url_str, e))?; 674 let (parts, body) = http_response.into_parts(); 675 Ok(StreamingResponse::new(parts, body)) 676 } ··· 737 nonce: Option<CowStr<'s>>, 738 ath: Option<CowStr<'s>>, 739 ) -> Result<CowStr<'s>> { 740 + let secret = match crypto::Key::try_from(key).map_err(DpopError::crypto)? { 741 crypto::Key::P256(crypto::Kind::Secret(sk)) => sk, 742 + _ => return Err(DpopError::unsupported_key()), 743 }; 744 let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256)); 745 header.typ = Some(JWT_HEADER_TYP_DPOP.into());
+3 -1
crates/jacquard-oauth/src/error.rs
··· 6 7 /// High-level errors emitted by OAuth helpers. 8 #[derive(Debug, thiserror::Error, Diagnostic)] 9 pub enum OAuthError { 10 #[error(transparent)] 11 #[diagnostic(code(jacquard_oauth::resolver))] ··· 21 22 #[error(transparent)] 23 #[diagnostic(code(jacquard_oauth::dpop))] 24 - Dpop(#[from] crate::dpop::Error), 25 26 #[error(transparent)] 27 #[diagnostic(code(jacquard_oauth::keyset))] ··· 54 55 /// Typed callback validation errors (redirect handling). 56 #[derive(Debug, thiserror::Error, Diagnostic)] 57 pub enum CallbackError { 58 #[error("missing state parameter in callback")] 59 #[diagnostic(code(jacquard_oauth::callback::missing_state))]
··· 6 7 /// High-level errors emitted by OAuth helpers. 8 #[derive(Debug, thiserror::Error, Diagnostic)] 9 + #[non_exhaustive] 10 pub enum OAuthError { 11 #[error(transparent)] 12 #[diagnostic(code(jacquard_oauth::resolver))] ··· 22 23 #[error(transparent)] 24 #[diagnostic(code(jacquard_oauth::dpop))] 25 + Dpop(#[from] crate::dpop::DpopError), 26 27 #[error(transparent)] 28 #[diagnostic(code(jacquard_oauth::keyset))] ··· 55 56 /// Typed callback validation errors (redirect handling). 57 #[derive(Debug, thiserror::Error, Diagnostic)] 58 + #[non_exhaustive] 59 pub enum CallbackError { 60 #[error("missing state parameter in callback")] 61 #[diagnostic(code(jacquard_oauth::callback::missing_state))]
+5 -4
crates/jacquard-oauth/src/keyset.rs
··· 9 use thiserror::Error; 10 11 #[derive(Error, Debug)] 12 pub enum Error { 13 #[error("duplicate kid: {0}")] 14 DuplicateKid(String), 15 #[error("keys must not be empty")] 16 EmptyKeys, 17 - #[error("key must have a `kid`")] 18 - EmptyKid, 19 #[error("no signing key found for algorithms: {0:?}")] 20 NotFound(Vec<CowStr<'static>>), 21 #[error("key for signing must be a secret key")] ··· 103 } 104 let mut v = Vec::with_capacity(keys.len()); 105 let mut hs = HashSet::with_capacity(keys.len()); 106 - for key in keys { 107 if let Some(kid) = key.prm.kid.clone() { 108 if hs.contains(&kid) { 109 return Err(Error::DuplicateKid(kid)); ··· 119 } 120 v.push(key); 121 } else { 122 - return Err(Error::EmptyKid); 123 } 124 } 125 Ok(Self(v))
··· 9 use thiserror::Error; 10 11 #[derive(Error, Debug)] 12 + #[non_exhaustive] 13 pub enum Error { 14 #[error("duplicate kid: {0}")] 15 DuplicateKid(String), 16 #[error("keys must not be empty")] 17 EmptyKeys, 18 + #[error("key at index {0} must have a `kid`")] 19 + EmptyKid(usize), 20 #[error("no signing key found for algorithms: {0:?}")] 21 NotFound(Vec<CowStr<'static>>), 22 #[error("key for signing must be a secret key")] ··· 104 } 105 let mut v = Vec::with_capacity(keys.len()); 106 let mut hs = HashSet::with_capacity(keys.len()); 107 + for (i, key) in keys.into_iter().enumerate() { 108 if let Some(kid) = key.prm.kid.clone() { 109 if hs.contains(&kid) { 110 return Err(Error::DuplicateKid(kid)); ··· 120 } 121 v.push(key); 122 } else { 123 + return Err(Error::EmptyKid(i)); 124 } 125 } 126 Ok(Self(v))
+3 -2
crates/jacquard-oauth/src/request.rs
··· 61 62 /// Error categories for OAuth request operations 63 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 64 pub enum RequestErrorKind { 65 /// No endpoint available 66 #[error("no {0} endpoint available")] ··· 331 } 332 } 333 334 - impl From<crate::dpop::Error> for RequestError { 335 - fn from(e: crate::dpop::Error) -> Self { 336 let msg = smol_str::format_smolstr!("{:?}", e); 337 Self::new(RequestErrorKind::Dpop, Some(Box::new(e))) 338 .with_context(msg)
··· 61 62 /// Error categories for OAuth request operations 63 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 64 + #[non_exhaustive] 65 pub enum RequestErrorKind { 66 /// No endpoint available 67 #[error("no {0} endpoint available")] ··· 332 } 333 } 334 335 + impl From<crate::dpop::DpopError> for RequestError { 336 + fn from(e: crate::dpop::DpopError) -> Self { 337 let msg = smol_str::format_smolstr!("{:?}", e); 338 Self::new(RequestErrorKind::Dpop, Some(Box::new(e))) 339 .with_context(msg)
+1
crates/jacquard-oauth/src/resolver.rs
··· 74 75 /// Error categories for OAuth resolver operations 76 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 77 pub enum ResolverErrorKind { 78 /// Resource not found 79 #[error("resource not found")]
··· 74 75 /// Error categories for OAuth resolver operations 76 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 77 + #[non_exhaustive] 78 pub enum ResolverErrorKind { 79 /// Resource not found 80 #[error("resource not found")]
+1
crates/jacquard-oauth/src/scopes.rs
··· 1023 1024 /// Error type for scope parsing 1025 #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] 1026 pub enum ParseError { 1027 /// Unknown scope prefix 1028 UnknownPrefix(String),
··· 1023 1024 /// Error type for scope parsing 1025 #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] 1026 + #[non_exhaustive] 1027 pub enum ParseError { 1028 /// Unknown scope prefix 1029 UnknownPrefix(String),
+1
crates/jacquard-oauth/src/session.rs
··· 287 } 288 289 #[derive(thiserror::Error, Debug, miette::Diagnostic)] 290 pub enum Error { 291 #[error(transparent)] 292 #[diagnostic(code(jacquard_oauth::session::request))]
··· 287 } 288 289 #[derive(thiserror::Error, Debug, miette::Diagnostic)] 290 + #[non_exhaustive] 291 pub enum Error { 292 #[error(transparent)] 293 #[diagnostic(code(jacquard_oauth::session::request))]
+11
crates/jacquard-repo/src/error.rs
··· 22 23 /// Error categories for repository operations 24 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 25 pub enum RepoErrorKind { 26 /// Storage operation failed 27 Storage, ··· 145 Self::new(RepoErrorKind::Car, Some(Box::new(source))) 146 } 147 148 /// Create a CAR parse error (alias for car) 149 pub fn car_parse(source: impl Error + Send + Sync + 'static) -> Self { 150 Self::car(source).with_context("Failed to parse CAR file".to_string()) ··· 207 208 /// MST-specific errors 209 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 210 pub enum MstError { 211 /// Empty key not allowed 212 #[error("Empty key not allowed")] ··· 253 254 /// Commit-specific errors 255 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 256 pub enum CommitError { 257 /// Invalid commit version 258 #[error("Invalid commit version: {0}")] ··· 302 303 /// Diff-specific errors 304 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 305 pub enum DiffError { 306 /// Too many operations 307 #[error("Too many operations: {count} (max {max})")] ··· 335 336 /// Proof verification errors 337 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 338 pub enum ProofError { 339 /// CAR file has no root CID 340 #[error("CAR file has no root CID")]
··· 22 23 /// Error categories for repository operations 24 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 25 + #[non_exhaustive] 26 pub enum RepoErrorKind { 27 /// Storage operation failed 28 Storage, ··· 146 Self::new(RepoErrorKind::Car, Some(Box::new(source))) 147 } 148 149 + /// Create a CAR write error with CID context 150 + pub fn car_write(cid: impl fmt::Display, source: impl Error + Send + Sync + 'static) -> Self { 151 + Self::new(RepoErrorKind::Car, Some(Box::new(source))) 152 + .with_context(format!("failed to write block {}", cid)) 153 + } 154 + 155 /// Create a CAR parse error (alias for car) 156 pub fn car_parse(source: impl Error + Send + Sync + 'static) -> Self { 157 Self::car(source).with_context("Failed to parse CAR file".to_string()) ··· 214 215 /// MST-specific errors 216 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 217 + #[non_exhaustive] 218 pub enum MstError { 219 /// Empty key not allowed 220 #[error("Empty key not allowed")] ··· 261 262 /// Commit-specific errors 263 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 264 + #[non_exhaustive] 265 pub enum CommitError { 266 /// Invalid commit version 267 #[error("Invalid commit version: {0}")] ··· 311 312 /// Diff-specific errors 313 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 314 + #[non_exhaustive] 315 pub enum DiffError { 316 /// Too many operations 317 #[error("Too many operations: {count} (max {max})")] ··· 345 346 /// Proof verification errors 347 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 348 + #[non_exhaustive] 349 pub enum ProofError { 350 /// CAR file has no root CID 351 #[error("CAR file has no root CID")]
+22 -12
crates/jacquard-repo/src/mst/tree.rs
··· 1262 entries = self.get_entries().await? 1263 } 1264 let data = util::serialize_node_data(entries.as_slice()).await?; 1265 - let bytes = serde_ipld_dagcbor::to_vec(&data).map_err(|e| RepoError::car(e))?; 1266 let cid = util::compute_cid(&bytes)?; 1267 1268 Ok((cid, Bytes::from_owner(bytes))) ··· 1336 writer 1337 .write(*cid, &data) 1338 .await 1339 - .map_err(|e| RepoError::car(e))?; 1340 } 1341 } 1342 ··· 1364 writer 1365 .write(pointer, &node_bytes) 1366 .await 1367 - .map_err(|e| RepoError::car(e))?; 1368 1369 // Parse to get entries 1370 let entries = self.get_entries().await?; ··· 1411 .collect(); 1412 1413 // Await all tasks concurrently 1414 - let results = try_join_all(tasks) 1415 - .await 1416 - .map_err(|e| RepoError::invalid(format!("Task join error: {}", e)))?; 1417 1418 // Flatten results 1419 let mut cids = vec![pointer]; ··· 1455 1456 // Await all tasks concurrently 1457 if !tasks.is_empty() { 1458 - let subtree_results = try_join_all(tasks) 1459 - .await 1460 - .map_err(|e| RepoError::invalid(format!("Task join error: {}", e)))?; 1461 1462 for (pos, leaves) in task_positions.into_iter().zip(subtree_results) { 1463 result.push((pos, leaves?)); ··· 1518 1519 // Await all tasks concurrently 1520 if !tasks.is_empty() { 1521 - let results = try_join_all(tasks) 1522 - .await 1523 - .map_err(|e| RepoError::invalid(format!("Task join error: {}", e)))?; 1524 1525 for subtree_result in results { 1526 let (_, subtree_blocks) = subtree_result?;
··· 1262 entries = self.get_entries().await? 1263 } 1264 let data = util::serialize_node_data(entries.as_slice()).await?; 1265 + let bytes = serde_ipld_dagcbor::to_vec(&data).map_err(|e| { 1266 + // Provide best available context for debugging serialization failures 1267 + let context = if let Some(left_cid) = &data.left { 1268 + format!("serializing MST node (left pointer: {})", left_cid) 1269 + } else { 1270 + // No left pointer - use last leaf key as identifier 1271 + let last_key = entries.iter().rev().find_map(|e| match e { 1272 + NodeEntry::Leaf { key, .. } => Some(key.as_str()), 1273 + _ => None, 1274 + }); 1275 + match last_key { 1276 + Some(k) => format!("serializing MST node (last key: {})", k), 1277 + None => "serializing MST node (empty or tree-only)".to_string(), 1278 + } 1279 + }; 1280 + RepoError::serialization(e).with_context(context) 1281 + })?; 1282 let cid = util::compute_cid(&bytes)?; 1283 1284 Ok((cid, Bytes::from_owner(bytes))) ··· 1352 writer 1353 .write(*cid, &data) 1354 .await 1355 + .map_err(|e| RepoError::car_write(cid, e))?; 1356 } 1357 } 1358 ··· 1380 writer 1381 .write(pointer, &node_bytes) 1382 .await 1383 + .map_err(|e| RepoError::car_write(&pointer, e))?; 1384 1385 // Parse to get entries 1386 let entries = self.get_entries().await?; ··· 1427 .collect(); 1428 1429 // Await all tasks concurrently 1430 + let results = try_join_all(tasks).await.map_err(RepoError::task_failed)?; 1431 1432 // Flatten results 1433 let mut cids = vec![pointer]; ··· 1469 1470 // Await all tasks concurrently 1471 if !tasks.is_empty() { 1472 + let subtree_results = try_join_all(tasks).await.map_err(RepoError::task_failed)?; 1473 1474 for (pos, leaves) in task_positions.into_iter().zip(subtree_results) { 1475 result.push((pos, leaves?)); ··· 1530 1531 // Await all tasks concurrently 1532 if !tasks.is_empty() { 1533 + let results = try_join_all(tasks).await.map_err(RepoError::task_failed)?; 1534 1535 for subtree_result in results { 1536 let (_, subtree_blocks) = subtree_result?;
+45 -35
crates/jacquard/src/client.rs
··· 674 let (did, _) = self 675 .session_info() 676 .await 677 - .ok_or_else(AgentError::no_session)?; 678 679 #[cfg(feature = "tracing")] 680 let _span = tracing::debug_span!("create_record", collection = %R::nsid()).entered(); ··· 692 #[cfg(feature = "tracing")] 693 _span.exit(); 694 695 - let response = self.send(request).await?; 696 response.into_output().map_err(|e| match e { 697 XrpcError::Auth(auth) => AgentError::from(auth), 698 - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 699 XrpcError::Xrpc(typed) => AgentError::sub_operation("create record", typed), 700 }) 701 } 702 } ··· 790 let http_response = self 791 .send_http(http_request) 792 .await 793 - .map_err(|e| ClientError::transport(e))?; 794 795 xrpc::process_response(http_response) 796 - }?; 797 Ok(response.transmute()) 798 } 799 } ··· 837 &self.opts().await, 838 )?; 839 840 - let http_response = self 841 - .send_http(http_request) 842 - .await 843 - .map_err(|e| ClientError::transport(e))?; 844 845 xrpc::process_response(http_response) 846 - }?; 847 let output = response.into_output().map_err(|e| match e { 848 XrpcError::Auth(auth) => AgentError::from(auth), 849 - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 850 - XrpcError::Xrpc(typed) => AgentError::new( 851 - AgentErrorKind::SubOperation { 852 - step: "fetch record", 853 - }, 854 - None, 855 - ) 856 - .with_details(typed.to_string()), 857 })?; 858 Ok(output) 859 } ··· 874 { 875 let uri = uri.as_uri(); 876 async move { 877 let response = self.get_record::<R>(uri).await?; 878 let response: Response<R::Record> = response.transmute(); 879 let output = response.into_output().map_err(|e| match e { 880 XrpcError::Auth(auth) => AgentError::from(auth), 881 - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 882 - XrpcError::Xrpc(typed) => { 883 - AgentError::new(AgentErrorKind::SubOperation { step: "get record" }, None) 884 - .with_details(typed.to_string()) 885 - } 886 })?; 887 Ok(output) 888 } ··· 937 // Parse to get R<'_> borrowing from response buffer 938 let record = response.parse().map_err(|e| match e { 939 XrpcError::Auth(auth) => AgentError::from(auth), 940 - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 941 XrpcError::Xrpc(typed) => { 942 - AgentError::new(AgentErrorKind::SubOperation { step: "get record" }, None) 943 - .with_details(typed.to_string()) 944 } 945 })?; 946 947 // Convert to owned ··· 954 let rkey = uri 955 .rkey() 956 .ok_or_else(|| { 957 AgentError::sub_operation( 958 "extract rkey", 959 - std::io::Error::new(std::io::ErrorKind::InvalidInput, "AtUri missing rkey"), 960 ) 961 })? 962 .clone() ··· 983 let (did, _) = self 984 .session_info() 985 .await 986 - .ok_or_else(AgentError::no_session)?; 987 #[cfg(feature = "tracing")] 988 let _span = tracing::debug_span!("delete_record", collection = %R::nsid()).entered(); 989 ··· 999 #[cfg(feature = "tracing")] 1000 _span.exit(); 1001 1002 - let response = self.send(request).await?; 1003 response.into_output().map_err(|e| match e { 1004 XrpcError::Auth(auth) => AgentError::from(auth), 1005 - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 1006 XrpcError::Xrpc(typed) => AgentError::sub_operation("delete record", typed), 1007 }) 1008 } 1009 } ··· 1031 let (did, _) = self 1032 .session_info() 1033 .await 1034 - .ok_or_else(AgentError::no_session)?; 1035 1036 let data = 1037 to_data(&record).map_err(|e| AgentError::sub_operation("serialize record", e))?; ··· 1046 #[cfg(feature = "tracing")] 1047 _span.exit(); 1048 1049 - let response = self.send(request).await?; 1050 response.into_output().map_err(|e| match e { 1051 XrpcError::Auth(auth) => AgentError::from(auth), 1052 - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 1053 XrpcError::Xrpc(typed) => AgentError::sub_operation("put record", typed), 1054 }) 1055 } 1056 } ··· 1105 let response = self.send_with_opts(request, opts).await?; 1106 let output = response.into_output().map_err(|e| match e { 1107 XrpcError::Auth(auth) => AgentError::from(auth), 1108 - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 1109 XrpcError::Xrpc(typed) => AgentError::sub_operation("upload blob", typed), 1110 })?; 1111 Ok(output.blob.blob().clone().into_static()) 1112 } ··· 1148 let response = self.send(get_request).await?; 1149 let output = response.parse().map_err(|e| match e { 1150 XrpcError::Auth(auth) => AgentError::from(auth), 1151 - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 1152 XrpcError::Xrpc(typed) => { 1153 AgentError::sub_operation("update vec", typed.into_static()) 1154 } 1155 })?; 1156 1157 // Extract vec (converts to owned via IntoStatic)
··· 674 let (did, _) = self 675 .session_info() 676 .await 677 + .ok_or_else(|| AgentError::no_session().for_collection("create record", R::NSID))?; 678 679 #[cfg(feature = "tracing")] 680 let _span = tracing::debug_span!("create_record", collection = %R::nsid()).entered(); ··· 692 #[cfg(feature = "tracing")] 693 _span.exit(); 694 695 + let response = self 696 + .send(request) 697 + .await 698 + .map_err(|e| e.for_collection("create record", R::NSID))?; 699 response.into_output().map_err(|e| match e { 700 XrpcError::Auth(auth) => AgentError::from(auth), 701 XrpcError::Xrpc(typed) => AgentError::sub_operation("create record", typed), 702 + e => AgentError::xrpc(e), 703 }) 704 } 705 } ··· 793 let http_response = self 794 .send_http(http_request) 795 .await 796 + .map_err(|e| ClientError::transport(e).for_collection("get record", R::NSID))?; 797 798 xrpc::process_response(http_response) 799 + .map_err(|e| e.for_collection("get record", R::NSID))? 800 + }; 801 Ok(response.transmute()) 802 } 803 } ··· 841 &self.opts().await, 842 )?; 843 844 + let http_response = self.send_http(http_request).await.map_err(|e| { 845 + ClientError::transport(e).for_collection("fetch record", collection.as_str()) 846 + })?; 847 848 xrpc::process_response(http_response) 849 + .map_err(|e| e.for_collection("fetch record", collection.as_str()))? 850 + }; 851 let output = response.into_output().map_err(|e| match e { 852 XrpcError::Auth(auth) => AgentError::from(auth), 853 + XrpcError::Xrpc(typed) => AgentError::sub_operation("parse record", typed), 854 + e => AgentError::xrpc(e), 855 })?; 856 Ok(output) 857 } ··· 872 { 873 let uri = uri.as_uri(); 874 async move { 875 + use smol_str::format_smolstr; 876 + 877 let response = self.get_record::<R>(uri).await?; 878 let response: Response<R::Record> = response.transmute(); 879 let output = response.into_output().map_err(|e| match e { 880 XrpcError::Auth(auth) => AgentError::from(auth), 881 + XrpcError::Xrpc(typed) => AgentError::new( 882 + AgentErrorKind::SubOperation { 883 + step: "parse record", 884 + }, 885 + None, 886 + ) 887 + .with_details(format_smolstr!("{:?}", typed)), 888 + // Note for future orual: the above was done this way due to GAT lifetime inference constraints.. 889 + e => AgentError::xrpc(e), 890 })?; 891 Ok(output) 892 } ··· 941 // Parse to get R<'_> borrowing from response buffer 942 let record = response.parse().map_err(|e| match e { 943 XrpcError::Auth(auth) => AgentError::from(auth), 944 XrpcError::Xrpc(typed) => { 945 + AgentError::sub_operation("parse record", typed.into_static()) 946 } 947 + e => AgentError::xrpc(e), 948 })?; 949 950 // Convert to owned ··· 957 let rkey = uri 958 .rkey() 959 .ok_or_else(|| { 960 + use jacquard_common::types::string::AtStrError; 961 AgentError::sub_operation( 962 "extract rkey", 963 + AtStrError::missing("at-uri-scheme", &uri, "rkey"), 964 ) 965 })? 966 .clone() ··· 987 let (did, _) = self 988 .session_info() 989 .await 990 + .ok_or_else(|| AgentError::no_session().for_collection("delete record", R::NSID))?; 991 #[cfg(feature = "tracing")] 992 let _span = tracing::debug_span!("delete_record", collection = %R::nsid()).entered(); 993 ··· 1003 #[cfg(feature = "tracing")] 1004 _span.exit(); 1005 1006 + let response = self 1007 + .send(request) 1008 + .await 1009 + .map_err(|e| e.for_collection("delete record", R::NSID))?; 1010 response.into_output().map_err(|e| match e { 1011 XrpcError::Auth(auth) => AgentError::from(auth), 1012 XrpcError::Xrpc(typed) => AgentError::sub_operation("delete record", typed), 1013 + e => AgentError::xrpc(e), 1014 }) 1015 } 1016 } ··· 1038 let (did, _) = self 1039 .session_info() 1040 .await 1041 + .ok_or_else(|| AgentError::no_session().for_collection("put record", R::NSID))?; 1042 1043 let data = 1044 to_data(&record).map_err(|e| AgentError::sub_operation("serialize record", e))?; ··· 1053 #[cfg(feature = "tracing")] 1054 _span.exit(); 1055 1056 + let response = self 1057 + .send(request) 1058 + .await 1059 + .map_err(|e| e.for_collection("put record", R::NSID))?; 1060 response.into_output().map_err(|e| match e { 1061 XrpcError::Auth(auth) => AgentError::from(auth), 1062 XrpcError::Xrpc(typed) => AgentError::sub_operation("put record", typed), 1063 + e => AgentError::xrpc(e), 1064 }) 1065 } 1066 } ··· 1115 let response = self.send_with_opts(request, opts).await?; 1116 let output = response.into_output().map_err(|e| match e { 1117 XrpcError::Auth(auth) => AgentError::from(auth), 1118 XrpcError::Xrpc(typed) => AgentError::sub_operation("upload blob", typed), 1119 + e => AgentError::xrpc(e), 1120 })?; 1121 Ok(output.blob.blob().clone().into_static()) 1122 } ··· 1158 let response = self.send(get_request).await?; 1159 let output = response.parse().map_err(|e| match e { 1160 XrpcError::Auth(auth) => AgentError::from(auth), 1161 XrpcError::Xrpc(typed) => { 1162 AgentError::sub_operation("update vec", typed.into_static()) 1163 } 1164 + e => AgentError::xrpc(e), 1165 })?; 1166 1167 // Extract vec (converts to owned via IntoStatic)
+39 -1
crates/jacquard/src/client/error.rs
··· 50 51 /// Error categories for Agent operations 52 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 53 pub enum AgentErrorKind { 54 /// Transport/network layer failure 55 #[error("client error (see context for details)")] ··· 172 self 173 } 174 175 /// Add XRPC error data to this error for observability 176 pub fn with_xrpc<E>(mut self, xrpc: XrpcError<E>) -> Self 177 where ··· 253 fn from(e: ClientError) -> Self { 254 use smol_str::ToSmolStr; 255 256 - let context_msg: SmolStr; 257 let help_msg: SmolStr; 258 let url = e.url().map(|s| s.to_smolstr()); 259 let details = e.details().map(|s| s.to_smolstr()); 260 261 // Build context and help based on the error kind 262 match e.kind() { ··· 297 help_msg = "verify storage backend is accessible".to_smolstr(); 298 context_msg = "storage operation failed".to_smolstr(); 299 } 300 } 301 302 let mut error = Self::new(AgentErrorKind::Client, Some(Box::new(e)));
··· 50 51 /// Error categories for Agent operations 52 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 53 + #[non_exhaustive] 54 pub enum AgentErrorKind { 55 /// Transport/network layer failure 56 #[error("client error (see context for details)")] ··· 173 self 174 } 175 176 + /// Append additional context to existing context string. 177 + /// 178 + /// If context already exists, appends with ": " separator. 179 + /// If no context exists, sets it directly. 180 + pub fn append_context(mut self, additional: impl AsRef<str>) -> Self { 181 + self.context = Some(match self.context.take() { 182 + Some(existing) => smol_str::format_smolstr!("{}: {}", existing, additional.as_ref()), 183 + None => additional.as_ref().into(), 184 + }); 185 + self 186 + } 187 + 188 + /// Add NSID context for XRPC operations. 189 + /// 190 + /// Appends the NSID in brackets to existing context, e.g. `"network timeout: [com.atproto.repo.getRecord]"`. 191 + pub fn for_nsid(self, nsid: &str) -> Self { 192 + self.append_context(smol_str::format_smolstr!("[{}]", nsid)) 193 + } 194 + 195 + /// Add collection context for record operations. 196 + /// 197 + /// Use this when a record operation fails to indicate the target collection. 198 + pub fn for_collection(self, operation: &str, collection_nsid: &str) -> Self { 199 + self.append_context(smol_str::format_smolstr!("{} [{}]", operation, collection_nsid)) 200 + } 201 + 202 /// Add XRPC error data to this error for observability 203 pub fn with_xrpc<E>(mut self, xrpc: XrpcError<E>) -> Self 204 where ··· 280 fn from(e: ClientError) -> Self { 281 use smol_str::ToSmolStr; 282 283 + let mut context_msg: SmolStr; 284 let help_msg: SmolStr; 285 let url = e.url().map(|s| s.to_smolstr()); 286 let details = e.details().map(|s| s.to_smolstr()); 287 + // Preserve original context from ClientError to append later 288 + let original_context = e.context().map(|s| s.to_smolstr()); 289 290 // Build context and help based on the error kind 291 match e.kind() { ··· 326 help_msg = "verify storage backend is accessible".to_smolstr(); 327 context_msg = "storage operation failed".to_smolstr(); 328 } 329 + _ => { 330 + help_msg = "see source error for details".to_smolstr(); 331 + context_msg = "client error".to_smolstr(); 332 + } 333 + } 334 + 335 + // Append original context from ClientError if present 336 + if let Some(original) = original_context { 337 + context_msg = smol_str::format_smolstr!("{}: {}", context_msg, original); 338 } 339 340 let mut error = Self::new(AgentErrorKind::Client, Some(Box::new(e)));
+14
crates/jacquard/src/client/token.rs
··· 243 244 impl FileAuthStore { 245 /// Create a new file-backed auth store wrapping `FileTokenStore`. 246 pub fn new(path: impl AsRef<std::path::Path>) -> Self { 247 Self(FileTokenStore::new(path)) 248 }
··· 243 244 impl FileAuthStore { 245 /// Create a new file-backed auth store wrapping `FileTokenStore`. 246 + /// 247 + /// # Errors 248 + /// 249 + /// Returns an error if parent directories cannot be created or the file cannot be written. 250 + pub fn try_new(path: impl AsRef<std::path::Path>) -> Result<Self, SessionStoreError> { 251 + Ok(Self(FileTokenStore::try_new(path)?)) 252 + } 253 + 254 + /// Create a new file-backed auth store wrapping `FileTokenStore`. 255 + /// 256 + /// # Panics 257 + /// 258 + /// Panics if parent directories cannot be created or the file cannot be written. 259 + /// Prefer [`try_new`](Self::try_new) for fallible construction. 260 pub fn new(path: impl AsRef<std::path::Path>) -> Self { 261 Self(FileTokenStore::new(path)) 262 }
+2 -1
crates/jacquard/src/moderation/fetch.rs
··· 34 XrpcError::Generic(g) => ClientError::decode(g.to_string()), 35 XrpcError::Decode(e) => ClientError::decode(format!("{:?}", e)), 36 XrpcError::Xrpc(typed) => ClientError::decode(format!("{:?}", typed)), 37 })?; 38 39 let mut defs = LabelerDefs::new(); ··· 132 .into_output() 133 .map_err(|e| match e { 134 XrpcError::Auth(auth) => AgentError::from(auth), 135 - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 136 XrpcError::Xrpc(typed) => AgentError::xrpc(XrpcError::Xrpc(typed)), 137 })?; 138 Ok((labels.labels, labels.cursor)) 139 }
··· 34 XrpcError::Generic(g) => ClientError::decode(g.to_string()), 35 XrpcError::Decode(e) => ClientError::decode(format!("{:?}", e)), 36 XrpcError::Xrpc(typed) => ClientError::decode(format!("{:?}", typed)), 37 + _ => ClientError::decode("unknown XRPC error"), 38 })?; 39 40 let mut defs = LabelerDefs::new(); ··· 133 .into_output() 134 .map_err(|e| match e { 135 XrpcError::Auth(auth) => AgentError::from(auth), 136 XrpcError::Xrpc(typed) => AgentError::xrpc(XrpcError::Xrpc(typed)), 137 + e => AgentError::xrpc(e), 138 })?; 139 Ok((labels.labels, labels.cursor)) 140 }
+1
crates/jacquard/src/richtext.rs
··· 699 700 /// Errors that can occur during richtext building 701 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 702 pub enum RichTextError { 703 /// Handle found that needs resolution but no resolver provided 704 #[error("Handle '{0}' requires resolution - use build_async() with an IdentityResolver")]
··· 699 700 /// Errors that can occur during richtext building 701 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 702 + #[non_exhaustive] 703 pub enum RichTextError { 704 /// Handle found that needs resolution but no resolver provided 705 #[error("Handle '{0}' requires resolution - use build_async() with an IdentityResolver")]