CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'house-style-cleanup'

+1035 -210
+74
Cargo.lock
··· 176 176 "multibase", 177 177 "p256", 178 178 "percent-encoding", 179 + "proptest", 179 180 "rand 0.8.6", 180 181 "rand_chacha 0.3.1", 181 182 "rand_core 0.6.4", ··· 385 386 version = "1.8.3" 386 387 source = "registry+https://github.com/rust-lang/crates.io-index" 387 388 checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" 389 + 390 + [[package]] 391 + name = "bit-set" 392 + version = "0.8.0" 393 + source = "registry+https://github.com/rust-lang/crates.io-index" 394 + checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" 395 + dependencies = [ 396 + "bit-vec", 397 + ] 398 + 399 + [[package]] 400 + name = "bit-vec" 401 + version = "0.8.0" 402 + source = "registry+https://github.com/rust-lang/crates.io-index" 403 + checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" 388 404 389 405 [[package]] 390 406 name = "bitflags" ··· 1032 1048 "crc32fast", 1033 1049 "miniz_oxide", 1034 1050 ] 1051 + 1052 + [[package]] 1053 + name = "fnv" 1054 + version = "1.0.7" 1055 + source = "registry+https://github.com/rust-lang/crates.io-index" 1056 + checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 1035 1057 1036 1058 [[package]] 1037 1059 name = "foldhash" ··· 2210 2232 ] 2211 2233 2212 2234 [[package]] 2235 + name = "proptest" 2236 + version = "1.11.0" 2237 + source = "registry+https://github.com/rust-lang/crates.io-index" 2238 + checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" 2239 + dependencies = [ 2240 + "bit-set", 2241 + "bit-vec", 2242 + "bitflags", 2243 + "num-traits", 2244 + "rand 0.9.4", 2245 + "rand_chacha 0.9.0", 2246 + "rand_xorshift", 2247 + "regex-syntax", 2248 + "rusty-fork", 2249 + "tempfile", 2250 + "unarray", 2251 + ] 2252 + 2253 + [[package]] 2254 + name = "quick-error" 2255 + version = "1.2.3" 2256 + source = "registry+https://github.com/rust-lang/crates.io-index" 2257 + checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" 2258 + 2259 + [[package]] 2213 2260 name = "quinn" 2214 2261 version = "0.11.9" 2215 2262 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2343 2390 checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" 2344 2391 dependencies = [ 2345 2392 "getrandom 0.3.4", 2393 + ] 2394 + 2395 + [[package]] 2396 + name = "rand_xorshift" 2397 + version = "0.4.0" 2398 + source = "registry+https://github.com/rust-lang/crates.io-index" 2399 + checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" 2400 + dependencies = [ 2401 + "rand_core 0.9.5", 2346 2402 ] 2347 2403 2348 2404 [[package]] ··· 2616 2672 version = "1.0.22" 2617 2673 source = "registry+https://github.com/rust-lang/crates.io-index" 2618 2674 checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 2675 + 2676 + [[package]] 2677 + name = "rusty-fork" 2678 + version = "0.3.1" 2679 + source = "registry+https://github.com/rust-lang/crates.io-index" 2680 + checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" 2681 + dependencies = [ 2682 + "fnv", 2683 + "quick-error", 2684 + "tempfile", 2685 + "wait-timeout", 2686 + ] 2619 2687 2620 2688 [[package]] 2621 2689 name = "ryu" ··· 3303 3371 version = "1.19.0" 3304 3372 source = "registry+https://github.com/rust-lang/crates.io-index" 3305 3373 checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 3374 + 3375 + [[package]] 3376 + name = "unarray" 3377 + version = "0.1.4" 3378 + source = "registry+https://github.com/rust-lang/crates.io-index" 3379 + checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" 3306 3380 3307 3381 [[package]] 3308 3382 name = "unicode-ident"
+6
Cargo.toml
··· 60 60 [dev-dependencies] 61 61 assert_cmd = "2.0" 62 62 insta = "1.47" 63 + # Property-based tests pin roundtrip / idempotence properties for the 64 + # cryptographic primitives (multikey encode/decode, JWT encode/verify, 65 + # JWS sign/verify, signature byte-length invariants). 64 cases per 66 + # property is enough to exercise every byte-pattern edge case without 67 + # making the test suite slow. 68 + proptest = "1.5" 63 69 rand = "0.8" 64 70 rand_chacha = "0.3" 65 71 tokio = { version = "1.51", features = ["rt", "macros", "test-util", "time"] }
+2
src/cli.rs
··· 1 1 //! Root clap parser and dispatch entry point. 2 2 3 + // pattern: Imperative Shell 4 + 3 5 use clap::Parser; 4 6 use miette::Result; 5 7 use std::process::ExitCode;
+2
src/commands.rs
··· 1 1 //! Top-level subcommand dispatch. 2 2 3 + // pattern: Imperative Shell 4 + 3 5 use clap::Subcommand; 4 6 use miette::Report; 5 7 use std::process::ExitCode;
+2
src/commands/test.rs
··· 1 1 //! `atproto-devtool test ...` subcommand tree. 2 2 3 + // pattern: Imperative Shell 4 + 3 5 use clap::Subcommand; 4 6 use miette::Report; 5 7 use std::process::ExitCode;
+2
src/commands/test/labeler.rs
··· 1 1 //! `atproto-devtool test labeler <target>` command. 2 2 3 + // pattern: Imperative Shell 4 + 3 5 pub mod pipeline; 4 6 pub mod target; 5 7
+8
src/commands/test/labeler/pipeline.rs
··· 1 1 //! Target parsing and pipeline driver skeleton for labeler conformance checks. 2 2 3 + // pattern: Mixed (unavoidable) 4 + // 5 + // Stage orchestration: drives every stage's `run` (which itself does I/O 6 + // through trait seams) and threads facts forward. Splitting orchestration 7 + // from per-stage gating logic would not improve testability — the 8 + // pipeline is already exercised end-to-end by `tests/labeler_*` with 9 + // fakes wired in. 10 + 3 11 use std::time::Duration; 4 12 5 13 use url::Url;
+2
src/commands/test/labeler/pipeline/create_report.rs
··· 4 4 //! The `sentinel` submodule builds the pollution-avoidance reason string 5 5 //! that every committed report body carries. 6 6 7 + // pattern: Mixed (unavoidable: stage orchestration interleaves trait-object I/O with check-result construction) 8 + 7 9 use std::borrow::Cow; 8 10 use std::sync::Arc; 9 11 use std::time::{Duration, SystemTime, UNIX_EPOCH};
+5
src/commands/test/labeler/pipeline/create_report/did_doc_server.rs
··· 9 9 //! Shuts down on drop: the RAII handle aborts the background task and 10 10 //! closes the listener. 11 11 12 + // pattern: Imperative Shell 13 + // 14 + // Pure-listener loop: binds a TCP socket and serves a fixed JSON 15 + // payload. No business logic; the DID document is built upstream. 16 + 12 17 use std::net::SocketAddr; 13 18 use std::sync::Arc; 14 19
+2
src/commands/test/labeler/pipeline/create_report/pollution.rs
··· 23 23 //! (pointing at the reporter's own DID) to exercise the simplest working 24 24 //! shape. This makes the test deterministic for round-trip debugging. 25 25 26 + // pattern: Functional Core 27 + 26 28 use serde_json::{Value, json}; 27 29 28 30 use crate::common::identity::Did;
+6
src/commands/test/labeler/pipeline/create_report/self_mint.rs
··· 2 2 //! server, and a reference curve. Exposes a single method for signing 3 3 //! atproto service-auth JWTs with that identity. 4 4 5 + // pattern: Mixed (unavoidable) 6 + // 7 + // Owns a signing key (pure crypto), a `DidDocServer` (imperative TCP), 8 + // and a JWT minting helper (pure). The mix mirrors the lifecycle: the 9 + // signer must keep the server alive while it's signing tokens. 10 + 5 11 use std::net::SocketAddr; 6 12 use std::time::Duration; 7 13
+5
src/commands/test/labeler/pipeline/create_report/sentinel.rs
··· 14 14 //! within a single `test labeler` invocation so operators can trace a group 15 15 //! of test reports back to one run. 16 16 17 + // pattern: Functional Core 18 + // 19 + // `build` and `format_rfc3339_utc` take time as a parameter; the 20 + // `SystemTime` import is for the type only. 21 + 17 22 use std::time::{SystemTime, UNIX_EPOCH}; 18 23 19 24 /// Prefix used so operators can grep their moderation queue for
+2
src/commands/test/labeler/pipeline/crypto.rs
··· 5 5 //! (deterministic canonical encoding per RFC 8949) and supports key rotation via 6 6 //! the did:plc audit log. 7 7 8 + // pattern: Mixed (unavoidable: stage orchestration interleaves trait-object I/O with check-result construction) 9 + 8 10 use std::borrow::Cow; 9 11 use std::collections::BTreeMap; 10 12
+2
src/commands/test/labeler/pipeline/http.rs
··· 3 3 //! Performs `com.atproto.label.queryLabels` requests against the labeler endpoint, 4 4 //! verifies schema conformance, and exercises pagination. 5 5 6 + // pattern: Mixed (unavoidable: stage orchestration interleaves trait-object I/O with check-result construction) 7 + 6 8 use std::borrow::Cow; 7 9 use std::sync::Arc; 8 10
+2
src/commands/test/labeler/pipeline/identity.rs
··· 3 3 //! Performs DID document resolution and labeler record validation, 4 4 //! emitting a series of named checks for each identity-layer requirement. 5 5 6 + // pattern: Mixed (unavoidable: stage orchestration interleaves trait-object I/O with check-result construction) 7 + 6 8 use std::borrow::Cow; 7 9 use std::sync::Arc; 8 10
+2
src/commands/test/labeler/pipeline/subscription.rs
··· 4 4 //! using a two-connection strategy: backfill with cursor=0, and live-tail if backfill 5 5 //! did not complete within the budget. 6 6 7 + // pattern: Mixed (unavoidable: stage orchestration interleaves trait-object I/O with check-result construction) 8 + 7 9 use std::sync::Arc; 8 10 use std::time::Duration; 9 11
+2
src/commands/test/labeler/target.rs
··· 1 + // pattern: Functional Core 2 + 1 3 use std::fmt; 2 4 3 5 use miette::Diagnostic;
+2
src/commands/test/oauth.rs
··· 1 1 //! OAuth conformance tests. 2 2 3 + // pattern: Imperative Shell 4 + 3 5 use std::process::ExitCode; 4 6 5 7 use clap::Subcommand;
+2
src/commands/test/oauth/client.rs
··· 6 6 //! spins up an in-process fake authorization server and observes the 7 7 //! client driving end-to-end OAuth flows. 8 8 9 + // pattern: Imperative Shell 10 + 9 11 pub mod fake_as; 10 12 pub mod pipeline; 11 13 pub mod target;
+7
src/commands/test/oauth/client/fake_as.rs
··· 1 1 //! In-process fake atproto OAuth authorization server for interactive mode. 2 2 3 + // pattern: Imperative Shell 4 + // 5 + // Owns the axum server lifecycle (bind, spawn, shutdown) and exposes 6 + // a `ServerHandle` through which tests address the AS by URL. Routing 7 + // and per-flow logic live in `endpoints` and `identity` (Mixed and 8 + // Functional Core respectively). 9 + 3 10 pub mod endpoints; 4 11 pub mod identity; 5 12 pub mod request_log;
+7
src/commands/test/oauth/client/fake_as/endpoints.rs
··· 1 + // pattern: Mixed (unavoidable) 2 + // 3 + // Axum handlers (Imperative Shell — accept the request, log it, return 4 + // the response) wrap the per-`FlowScript` decision logic (pure: which 5 + // status, which body, which header). The mix is deliberate: each handler 6 + // is short enough that splitting would obscure the request/response shape. 7 + 1 8 use std::collections::{HashMap, HashSet, VecDeque}; 2 9 use std::sync::{Arc, Mutex}; 3 10
+2
src/commands/test/oauth/client/fake_as/identity.rs
··· 1 + // pattern: Functional Core 2 + 1 3 use k256::ecdsa::SigningKey as K256SigningKey; 2 4 use serde_json::json; 3 5 use url::Url;
+6
src/commands/test/oauth/client/fake_as/request_log.rs
··· 1 + // pattern: Functional Core 2 + // 3 + // In-memory append-only log container. The `Mutex` provides thread-safe 4 + // state, not external I/O — timestamps are supplied by the caller, not 5 + // observed from `SystemTime` here. 6 + 1 7 use std::sync::Mutex; 2 8 3 9 /// Append-only log of inbound requests. Cloneable `Arc` handle; locks
+2
src/commands/test/oauth/client/pipeline.rs
··· 1 1 //! OAuth client conformance test pipeline and target parsing. 2 2 3 + // pattern: Mixed (unavoidable: stage orchestration interleaves trait-object I/O with check-result construction) 4 + 3 5 use super::target::OauthClientTarget; 4 6 5 7 pub mod discovery;
+345 -148
src/commands/test/oauth/client/pipeline/discovery.rs
··· 3 3 //! This stage resolves the client metadata document for HTTPS targets or 4 4 //! synthesizes implicit metadata for loopback development clients. 5 5 6 + // pattern: Mixed (unavoidable) 7 + // 8 + // `run` is the imperative shell (does the HTTP fetch); the bulk of the 9 + // logic lives in pure helpers `evaluate_https_metadata_response` and 10 + // `evaluate_loopback_metadata`, which is why this stage has unit tests 11 + // at the bottom of the file alongside its integration coverage. 12 + 6 13 use std::borrow::Cow; 7 14 use std::sync::Arc; 8 15 ··· 190 197 pub results: Vec<CheckResult>, 191 198 } 192 199 200 + /// Outcome of an HTTPS metadata fetch, in a shape suitable for the pure 201 + /// `evaluate_https_metadata_response` evaluator below. 202 + #[derive(Debug)] 203 + pub(super) enum HttpsFetchOutcome { 204 + /// The fetch failed at the transport layer. 205 + NetworkFailure(crate::common::identity::IdentityError), 206 + /// The fetch returned an HTTP response (2xx or otherwise). 207 + Response { 208 + status: u16, 209 + body: Vec<u8>, 210 + content_type: Option<String>, 211 + }, 212 + } 213 + 193 214 /// Run the discovery stage for the given target. 215 + /// 216 + /// This is the imperative shell: it does the network fetch and dispatches 217 + /// to the pure `evaluate_*` helpers below to compute every check result. 194 218 pub async fn run(target: &OauthClientTarget, http: &dyn HttpClient) -> DiscoveryStageOutput { 195 - let mut results = Vec::new(); 196 - 197 219 match target { 198 220 OauthClientTarget::HttpsUrl(url) => { 199 - // Check that the client_id is well-formed (it's already validated by parse_target, 200 - // but we still emit the check). 201 - results.push(Check::ClientIdWellFormed.pass()); 221 + let outcome = match http.get_bytes_with_content_type(url).await { 222 + Err(e) => HttpsFetchOutcome::NetworkFailure(e), 223 + Ok((status, body, content_type)) => HttpsFetchOutcome::Response { 224 + status, 225 + body, 226 + content_type, 227 + }, 228 + }; 229 + evaluate_https_metadata_response(url, outcome) 230 + } 231 + OauthClientTarget::Loopback(_) => evaluate_loopback_metadata(), 232 + } 233 + } 202 234 203 - // Fetch the metadata document. 204 - match http.get_bytes_with_content_type(url).await { 205 - Err(e) => { 206 - // Network error on fetch. 207 - let diagnostic: Box<dyn Diagnostic + Send + Sync> = 208 - Box::new(FetchError::from_identity_error(e, url)); 209 - results.push(Check::MetadataDocumentFetchable.network_error(Some(diagnostic))); 210 - for check in [Check::MetadataContentTypeIsJson, Check::MetadataIsJson] { 211 - results.push(blocked_by( 212 - check.id(), 213 - Stage::OAUTH_CLIENT_DISCOVERY, 214 - check.pass_summary(), 215 - Check::MetadataDocumentFetchable.id(), 216 - )); 217 - } 218 - DiscoveryStageOutput { 219 - facts: None, 220 - results, 221 - } 222 - } 223 - Ok((status, _, _)) if !(200..=299).contains(&status) => { 224 - // Non-2xx status: transport-layer failure. 225 - let diagnostic: Box<dyn Diagnostic + Send + Sync> = Box::new(HttpStatusError { 235 + /// Pure: evaluate an HTTPS metadata fetch outcome and emit all four 236 + /// discovery-stage check results plus the discovery facts (when the 237 + /// fetch produced parseable JSON). 238 + pub(super) fn evaluate_https_metadata_response( 239 + url: &Url, 240 + fetch_outcome: HttpsFetchOutcome, 241 + ) -> DiscoveryStageOutput { 242 + let mut results = Vec::new(); 243 + // `client_id` validity is already enforced by `parse_target`; emit the 244 + // pass row so the report still shows the check. 245 + results.push(Check::ClientIdWellFormed.pass()); 246 + 247 + match fetch_outcome { 248 + HttpsFetchOutcome::NetworkFailure(e) => { 249 + let diagnostic: Box<dyn Diagnostic + Send + Sync> = 250 + Box::new(FetchError::from_identity_error(e, url)); 251 + results.push(Check::MetadataDocumentFetchable.network_error(Some(diagnostic))); 252 + push_blocked_by_fetchable(&mut results); 253 + DiscoveryStageOutput { 254 + facts: None, 255 + results, 256 + } 257 + } 258 + HttpsFetchOutcome::Response { 259 + status, 260 + body, 261 + content_type, 262 + } => { 263 + if !(200..=299).contains(&status) { 264 + // Non-2xx status: transport-layer failure. 265 + let diagnostic: Box<dyn Diagnostic + Send + Sync> = Box::new(HttpStatusError { 266 + url: url.clone(), 267 + status, 268 + }); 269 + results.push(Check::MetadataDocumentFetchable.network_error(Some(diagnostic))); 270 + push_blocked_by_fetchable(&mut results); 271 + return DiscoveryStageOutput { 272 + facts: None, 273 + results, 274 + }; 275 + } 276 + if status != 200 { 277 + // 2xx but not 200: spec violation per 278 + // <https://atproto.com/specs/oauth#client-metadata> 279 + // ("must be 200 (not another 2xx or a redirect)"). 280 + let diagnostic: Box<dyn Diagnostic + Send + Sync> = Box::new(NonOkStatusError { 281 + url: url.clone(), 282 + status, 283 + }); 284 + results.push(Check::MetadataDocumentFetchable.spec_violation(Some(diagnostic))); 285 + push_blocked_by_fetchable(&mut results); 286 + return DiscoveryStageOutput { 287 + facts: None, 288 + results, 289 + }; 290 + } 291 + results.push(Check::MetadataDocumentFetchable.pass()); 292 + 293 + // Content-Type check: the atproto OAuth profile requires the 294 + // metadata response carry the correct JSON content type. 295 + // Accept `application/json` with optional parameters like 296 + // `charset=utf-8`. 297 + if content_type_is_json(content_type.as_deref()) { 298 + results.push(Check::MetadataContentTypeIsJson.pass()); 299 + } else { 300 + let diagnostic: Box<dyn Diagnostic + Send + Sync> = 301 + Box::new(NonJsonContentTypeError { 226 302 url: url.clone(), 227 - status, 303 + content_type: content_type.clone(), 228 304 }); 229 - results.push(Check::MetadataDocumentFetchable.network_error(Some(diagnostic))); 230 - for check in [Check::MetadataContentTypeIsJson, Check::MetadataIsJson] { 231 - results.push(blocked_by( 232 - check.id(), 233 - Stage::OAUTH_CLIENT_DISCOVERY, 234 - check.pass_summary(), 235 - Check::MetadataDocumentFetchable.id(), 236 - )); 237 - } 305 + results.push(Check::MetadataContentTypeIsJson.spec_violation(Some(diagnostic))); 306 + } 307 + 308 + // Try to parse as JSON. 309 + match serde_json::from_slice::<serde_json::Value>(&body) { 310 + Err(json_err) => { 311 + let pretty_body = crate::common::diagnostics::pretty_json_for_display(&body); 312 + let span = 313 + span_at_line_column(&pretty_body, json_err.line(), json_err.column()); 314 + let ct = content_type.as_deref().unwrap_or("<unknown>"); 315 + let diagnostic: Box<dyn Diagnostic + Send + Sync> = Box::new(JsonParseError { 316 + source: named_source_from_bytes( 317 + format!("metadata document (content-type: {ct})"), 318 + pretty_body, 319 + ), 320 + span, 321 + message: format!("response body is not valid JSON (content-type: {ct})"), 322 + }); 323 + results.push(Check::MetadataIsJson.spec_violation(Some(diagnostic))); 238 324 DiscoveryStageOutput { 239 325 facts: None, 240 326 results, 241 327 } 242 328 } 243 - Ok((status, _, _)) if status != 200 => { 244 - // 2xx but not 200: spec violation per 245 - // <https://atproto.com/specs/oauth#client-metadata> 246 - // ("must be 200 (not another 2xx or a redirect)"). 247 - let diagnostic: Box<dyn Diagnostic + Send + Sync> = 248 - Box::new(NonOkStatusError { 249 - url: url.clone(), 250 - status, 251 - }); 252 - results.push(Check::MetadataDocumentFetchable.spec_violation(Some(diagnostic))); 253 - for check in [Check::MetadataContentTypeIsJson, Check::MetadataIsJson] { 254 - results.push(blocked_by( 255 - check.id(), 256 - Stage::OAUTH_CLIENT_DISCOVERY, 257 - check.pass_summary(), 258 - Check::MetadataDocumentFetchable.id(), 259 - )); 260 - } 329 + Ok(_) => { 330 + results.push(Check::MetadataIsJson.pass()); 261 331 DiscoveryStageOutput { 262 - facts: None, 332 + facts: Some(DiscoveryFacts { 333 + client_id: url.clone(), 334 + kind: ClientIdKind::HttpsUrl, 335 + raw_metadata: RawMetadata::Document { 336 + bytes: Arc::from(body), 337 + content_type, 338 + }, 339 + }), 263 340 results, 264 341 } 265 342 } 266 - Ok((_status, body, content_type)) => { 267 - results.push(Check::MetadataDocumentFetchable.pass()); 268 - 269 - // Content-Type check: the atproto OAuth profile 270 - // requires the metadata response carry the correct 271 - // JSON content type. Accept `application/json` 272 - // with optional parameters like `charset=utf-8`. 273 - if content_type_is_json(content_type.as_deref()) { 274 - results.push(Check::MetadataContentTypeIsJson.pass()); 275 - } else { 276 - let diagnostic: Box<dyn Diagnostic + Send + Sync> = 277 - Box::new(NonJsonContentTypeError { 278 - url: url.clone(), 279 - content_type: content_type.clone(), 280 - }); 281 - results.push( 282 - Check::MetadataContentTypeIsJson.spec_violation(Some(diagnostic)), 283 - ); 284 - } 285 - 286 - // Try to parse as JSON. 287 - match serde_json::from_slice::<serde_json::Value>(&body) { 288 - Err(json_err) => { 289 - // Parse error. 290 - let pretty_body = 291 - crate::common::diagnostics::pretty_json_for_display(&body); 292 - let span = span_at_line_column( 293 - &pretty_body, 294 - json_err.line(), 295 - json_err.column(), 296 - ); 297 - let ct = content_type.as_deref().unwrap_or("<unknown>"); 298 - let diagnostic: Box<dyn Diagnostic + Send + Sync> = 299 - Box::new(JsonParseError { 300 - source: named_source_from_bytes( 301 - format!("metadata document (content-type: {ct})"), 302 - pretty_body, 303 - ), 304 - span, 305 - message: format!( 306 - "response body is not valid JSON (content-type: {ct})" 307 - ), 308 - }); 309 - results.push(Check::MetadataIsJson.spec_violation(Some(diagnostic))); 310 - DiscoveryStageOutput { 311 - facts: None, 312 - results, 313 - } 314 - } 315 - Ok(_) => { 316 - // Valid JSON. 317 - results.push(Check::MetadataIsJson.pass()); 318 - DiscoveryStageOutput { 319 - facts: Some(DiscoveryFacts { 320 - client_id: url.clone(), 321 - kind: ClientIdKind::HttpsUrl, 322 - raw_metadata: RawMetadata::Document { 323 - bytes: Arc::from(body), 324 - content_type, 325 - }, 326 - }), 327 - results, 328 - } 329 - } 330 - } 331 - } 332 343 } 333 344 } 334 - OauthClientTarget::Loopback(_) => { 335 - // Loopback targets have well-formed client_id by definition. 336 - results.push(Check::ClientIdWellFormed.pass()); 345 + } 346 + } 337 347 338 - // Metadata is implicit for loopback clients. 339 - for check in [ 340 - Check::MetadataDocumentFetchable, 341 - Check::MetadataContentTypeIsJson, 342 - Check::MetadataIsJson, 343 - ] { 344 - results.push(check.skipped("metadata is implicit for loopback clients")); 345 - } 346 - 347 - // The atproto loopback client_id is fixed at 348 - // `http://localhost/`. 349 - let client_id = Url::parse("http://localhost/") 350 - .expect("`http://localhost/` is a statically known-good URL"); 348 + /// Pure: emit the discovery-stage results for a loopback client. Loopback 349 + /// targets have no metadata document; the well-formedness check passes 350 + /// by definition and the three document-related checks are skipped. 351 + pub(super) fn evaluate_loopback_metadata() -> DiscoveryStageOutput { 352 + let mut results = vec![Check::ClientIdWellFormed.pass()]; 353 + for check in [ 354 + Check::MetadataDocumentFetchable, 355 + Check::MetadataContentTypeIsJson, 356 + Check::MetadataIsJson, 357 + ] { 358 + results.push(check.skipped("metadata is implicit for loopback clients")); 359 + } 360 + let client_id = Url::parse("http://localhost/") 361 + .expect("`http://localhost/` is a statically known-good URL"); 362 + DiscoveryStageOutput { 363 + facts: Some(DiscoveryFacts { 364 + client_id: client_id.clone(), 365 + kind: ClientIdKind::Loopback, 366 + raw_metadata: RawMetadata::Implicit { client_id }, 367 + }), 368 + results, 369 + } 370 + } 351 371 352 - DiscoveryStageOutput { 353 - facts: Some(DiscoveryFacts { 354 - client_id: client_id.clone(), 355 - kind: ClientIdKind::Loopback, 356 - raw_metadata: RawMetadata::Implicit { client_id }, 357 - }), 358 - results, 359 - } 360 - } 372 + /// Append the two `blocked_by` skip rows that follow a failed 373 + /// `MetadataDocumentFetchable` check. 374 + fn push_blocked_by_fetchable(results: &mut Vec<CheckResult>) { 375 + for check in [Check::MetadataContentTypeIsJson, Check::MetadataIsJson] { 376 + results.push(blocked_by( 377 + check.id(), 378 + Stage::OAUTH_CLIENT_DISCOVERY, 379 + check.pass_summary(), 380 + Check::MetadataDocumentFetchable.id(), 381 + )); 361 382 } 362 383 } 363 384 ··· 508 529 let media_type = ct.split(';').next().unwrap_or("").trim(); 509 530 media_type.eq_ignore_ascii_case("application/json") 510 531 } 532 + 533 + #[cfg(test)] 534 + mod tests { 535 + use super::*; 536 + use crate::common::identity::IdentityError; 537 + use crate::common::report::CheckStatus; 538 + 539 + fn https_url() -> Url { 540 + Url::parse("https://example.com/client-metadata.json").unwrap() 541 + } 542 + 543 + fn check_ids(output: &DiscoveryStageOutput) -> Vec<&'static str> { 544 + output.results.iter().map(|r| r.id).collect() 545 + } 546 + 547 + fn statuses(output: &DiscoveryStageOutput) -> Vec<CheckStatus> { 548 + output.results.iter().map(|r| r.status).collect() 549 + } 550 + 551 + #[test] 552 + fn loopback_emits_one_pass_and_three_skips_with_facts() { 553 + let output = evaluate_loopback_metadata(); 554 + assert_eq!( 555 + check_ids(&output), 556 + vec![ 557 + Check::ClientIdWellFormed.id(), 558 + Check::MetadataDocumentFetchable.id(), 559 + Check::MetadataContentTypeIsJson.id(), 560 + Check::MetadataIsJson.id(), 561 + ] 562 + ); 563 + assert_eq!( 564 + statuses(&output), 565 + vec![ 566 + CheckStatus::Pass, 567 + CheckStatus::Skipped, 568 + CheckStatus::Skipped, 569 + CheckStatus::Skipped, 570 + ] 571 + ); 572 + let facts = output.facts.expect("loopback always produces facts"); 573 + assert_eq!(facts.kind, ClientIdKind::Loopback); 574 + assert!(matches!(facts.raw_metadata, RawMetadata::Implicit { .. })); 575 + } 576 + 577 + #[test] 578 + fn network_failure_blocks_downstream_checks() { 579 + let outcome = HttpsFetchOutcome::NetworkFailure(IdentityError::InvalidHandle); 580 + let output = evaluate_https_metadata_response(&https_url(), outcome); 581 + assert!(output.facts.is_none()); 582 + assert_eq!( 583 + statuses(&output), 584 + vec![ 585 + CheckStatus::Pass, 586 + CheckStatus::NetworkError, 587 + CheckStatus::Skipped, 588 + CheckStatus::Skipped, 589 + ] 590 + ); 591 + } 592 + 593 + #[test] 594 + fn non_2xx_is_network_error_not_spec_violation() { 595 + let outcome = HttpsFetchOutcome::Response { 596 + status: 500, 597 + body: b"{}".to_vec(), 598 + content_type: Some("application/json".to_string()), 599 + }; 600 + let output = evaluate_https_metadata_response(&https_url(), outcome); 601 + assert!(output.facts.is_none()); 602 + assert_eq!( 603 + statuses(&output), 604 + vec![ 605 + CheckStatus::Pass, 606 + CheckStatus::NetworkError, 607 + CheckStatus::Skipped, 608 + CheckStatus::Skipped, 609 + ] 610 + ); 611 + } 612 + 613 + #[test] 614 + fn non_200_2xx_is_spec_violation() { 615 + // 201 / 204 / 3xx (followed) all violate the 616 + // <https://atproto.com/specs/oauth#client-metadata> "must be 200" 617 + // requirement. 618 + let outcome = HttpsFetchOutcome::Response { 619 + status: 201, 620 + body: b"{}".to_vec(), 621 + content_type: Some("application/json".to_string()), 622 + }; 623 + let output = evaluate_https_metadata_response(&https_url(), outcome); 624 + assert!(output.facts.is_none()); 625 + assert_eq!( 626 + statuses(&output), 627 + vec![ 628 + CheckStatus::Pass, 629 + CheckStatus::SpecViolation, 630 + CheckStatus::Skipped, 631 + CheckStatus::Skipped, 632 + ] 633 + ); 634 + } 635 + 636 + #[test] 637 + fn ok_with_html_content_type_passes_json_parse_but_flags_content_type() { 638 + let outcome = HttpsFetchOutcome::Response { 639 + status: 200, 640 + body: br#"{"client_id":"https://example.com/x"}"#.to_vec(), 641 + content_type: Some("text/html".to_string()), 642 + }; 643 + let output = evaluate_https_metadata_response(&https_url(), outcome); 644 + assert_eq!( 645 + statuses(&output), 646 + vec![ 647 + CheckStatus::Pass, // ClientIdWellFormed 648 + CheckStatus::Pass, // MetadataDocumentFetchable 649 + CheckStatus::SpecViolation, // MetadataContentTypeIsJson 650 + CheckStatus::Pass, // MetadataIsJson 651 + ] 652 + ); 653 + // Facts are still produced — downstream metadata stage can run. 654 + assert!(output.facts.is_some()); 655 + } 656 + 657 + #[test] 658 + fn ok_with_invalid_json_emits_spec_violation_and_no_facts() { 659 + let outcome = HttpsFetchOutcome::Response { 660 + status: 200, 661 + body: b"not json".to_vec(), 662 + content_type: Some("application/json".to_string()), 663 + }; 664 + let output = evaluate_https_metadata_response(&https_url(), outcome); 665 + assert!(output.facts.is_none()); 666 + assert_eq!( 667 + statuses(&output), 668 + vec![ 669 + CheckStatus::Pass, 670 + CheckStatus::Pass, 671 + CheckStatus::Pass, 672 + CheckStatus::SpecViolation, 673 + ] 674 + ); 675 + } 676 + 677 + #[test] 678 + fn ok_with_charset_param_is_accepted_as_json_content_type() { 679 + let outcome = HttpsFetchOutcome::Response { 680 + status: 200, 681 + body: b"{}".to_vec(), 682 + content_type: Some("application/json; charset=utf-8".to_string()), 683 + }; 684 + let output = evaluate_https_metadata_response(&https_url(), outcome); 685 + assert!(output.facts.is_some()); 686 + assert_eq!( 687 + statuses(&output), 688 + vec![ 689 + CheckStatus::Pass, 690 + CheckStatus::Pass, 691 + CheckStatus::Pass, 692 + CheckStatus::Pass, 693 + ] 694 + ); 695 + } 696 + 697 + #[test] 698 + fn content_type_predicate_handles_edge_cases() { 699 + assert!(content_type_is_json(Some("application/json"))); 700 + assert!(content_type_is_json(Some("application/JSON"))); 701 + assert!(content_type_is_json(Some("application/json;charset=utf-8"))); 702 + assert!(content_type_is_json(Some(" application/json "))); 703 + assert!(!content_type_is_json(None)); 704 + assert!(!content_type_is_json(Some("text/html"))); 705 + assert!(!content_type_is_json(Some("application/ld+json"))); 706 + } 707 + }
+2
src/commands/test/oauth/client/pipeline/interactive.rs
··· 1 1 //! OAuth client interactive stage — fake AS server, RP-driven flow, conformance checks. 2 2 3 + // pattern: Mixed (unavoidable: stage orchestration interleaves trait-object I/O with check-result construction) 4 + 3 5 pub mod dpop_edges; 4 6 pub mod iss_sub_verification; 5 7 pub mod scope_variations;
+2
src/commands/test/oauth/client/pipeline/interactive/dpop_edges.rs
··· 2 2 //! 3 3 //! Verifies AC7.1–AC7.6: nonce rotation, refresh rotation, replay rejection, and compliance violations. 4 4 5 + // pattern: Mixed (unavoidable: stage orchestration interleaves trait-object I/O with check-result construction) 6 + 5 7 use std::borrow::Cow; 6 8 use std::fmt; 7 9 use std::sync::Arc;
+2
src/commands/test/oauth/client/pipeline/interactive/iss_sub_verification.rs
··· 18 18 //! behaviour on all other endpoints, a cooperating RP can reach the 19 19 //! final verification step before the broken-AS injection kicks in. 20 20 21 + // pattern: Mixed (unavoidable: stage orchestration interleaves trait-object I/O with check-result construction) 22 + 21 23 use crate::commands::test::oauth::client::fake_as::{ServerHandle, endpoints::FlowScript}; 22 24 use crate::common::oauth::relying_party::{AuthorizeOutcome, ParRequest, RelyingParty, RpError}; 23 25 use crate::common::report::CheckResult;
+2
src/commands/test/oauth/client/pipeline/interactive/scope_variations.rs
··· 2 2 //! 3 3 //! Verifies AC6.1–AC6.6: scope grant behaviors, user denial, refresh scoping, and mandatory fields. 4 4 5 + // pattern: Mixed (unavoidable: stage orchestration interleaves trait-object I/O with check-result construction) 6 + 5 7 use std::borrow::Cow; 6 8 use std::fmt; 7 9 use std::sync::Arc;
+2
src/commands/test/oauth/client/pipeline/jwks.rs
··· 3 3 //! Fetches and validates the client's JWKS (JSON Web Key Set) for confidential 4 4 //! clients using either an inline document or an external URI. 5 5 6 + // pattern: Mixed (unavoidable: stage orchestration interleaves trait-object I/O with check-result construction) 7 + 6 8 use async_trait::async_trait; 7 9 use miette::{Diagnostic, LabeledSpan, NamedSource}; 8 10 use reqwest::Client as ReqwestClient;
+6
src/commands/test/oauth/client/pipeline/metadata.rs
··· 4 4 //! every spec-derived static property of an atproto OAuth client metadata 5 5 //! document. 6 6 7 + // pattern: Functional Core 8 + // 9 + // `metadata::run` is synchronous — it consumes `DiscoveryFacts` (already 10 + // gathered by the discovery shell) and produces check results plus 11 + // downstream facts. No I/O. 12 + 7 13 use miette::{Diagnostic, NamedSource, SourceSpan}; 8 14 use serde::Deserialize; 9 15 use std::borrow::Cow;
+2
src/commands/test/oauth/client/target.rs
··· 1 + // pattern: Functional Core 2 + 1 3 use std::fmt; 2 4 3 5 use miette::{Diagnostic, NamedSource, SourceSpan};
+9
src/common/diagnostics.rs
··· 1 1 //! Shared miette configuration and `NamedSource` helpers. 2 2 3 + // pattern: Mixed (unavoidable) 4 + // 5 + // `install_miette_handler` mutates global process state via 6 + // `miette::set_hook` (a side effect, called once from `cli::run`); the 7 + // other helpers (`named_source_from_bytes`, `pretty_json_for_display`, 8 + // `span_at_line_column`, `span_for_quoted_literal`) are pure and could 9 + // live in a sibling file, but the module is small enough that splitting 10 + // would obscure the shared diagnostic concern. 11 + 3 12 use std::sync::Arc; 4 13 5 14 use miette::{GraphicalTheme, MietteHandlerOpts, NamedSource, SourceSpan};
+117
src/common/identity.rs
··· 3 3 //! This module provides a narrow interface over HTTP and DNS resolution, 4 4 //! allowing callers to swap real network I/O with recorded fixtures in tests. 5 5 6 + // pattern: Mixed (unavoidable) 7 + // 8 + // Pure: `parse_multikey`, `encode_multikey`, the `AnyVerifyingKey` / 9 + // `AnySigningKey` / `AnySignature` newtypes and their crypto methods, 10 + // `is_local_labeler_hostname`, all `IdentityError` construction. 11 + // Imperative shell: `RealHttpClient`, `RealDnsResolver`, `resolve_handle`, 12 + // `resolve_did`, `plc_history_for_fragment`. Splitting would force every 13 + // downstream stage to import from two modules and would not improve 14 + // testability — the I/O surface is already behind narrow trait seams 15 + // (`HttpClient`, `DnsResolver`). 16 + 6 17 use async_trait::async_trait; 7 18 use k256::ecdsa::signature::hazmat::PrehashVerifier; 8 19 use serde::{Deserialize, Serialize}; ··· 1737 1748 ); 1738 1749 } 1739 1750 _ => panic!("Expected P256 keys"), 1751 + } 1752 + } 1753 + 1754 + // Property-based tests pinning the roundtrip and length invariants 1755 + // documented in `src/common/CLAUDE.md`: 1756 + // - `encode_multikey(parse_multikey(s).verifying_key) == s` for 1757 + // every well-formed atproto multikey, 1758 + // - `AnySignature::to_jws_bytes()` is always exactly 64 bytes for 1759 + // both curves. 1760 + // Keys are generated deterministically from the proptest-supplied 1761 + // 32-byte seed via `ChaCha20Rng`, so each shrunk failure is 1762 + // reproducible from the seed alone. 1763 + mod pbt { 1764 + use super::*; 1765 + use proptest::prelude::*; 1766 + use rand_chacha::ChaCha20Rng; 1767 + use rand_core::SeedableRng; 1768 + 1769 + proptest! { 1770 + #![proptest_config(ProptestConfig::with_cases(64))] 1771 + 1772 + #[test] 1773 + fn multikey_roundtrip_k256(seed in any::<[u8; 32]>()) { 1774 + let mut rng = ChaCha20Rng::from_seed(seed); 1775 + let signing = k256::ecdsa::SigningKey::random(&mut rng); 1776 + let original = AnyVerifyingKey::K256(*signing.verifying_key()); 1777 + 1778 + let encoded = encode_multikey(&original); 1779 + prop_assert!(encoded.starts_with('z')); 1780 + 1781 + let parsed = parse_multikey(&encoded) 1782 + .expect("a freshly encoded multikey must parse"); 1783 + let re_encoded = encode_multikey(&parsed.verifying_key); 1784 + prop_assert_eq!( 1785 + encoded, 1786 + re_encoded, 1787 + "encode_multikey must round-trip through parse_multikey" 1788 + ); 1789 + } 1790 + 1791 + #[test] 1792 + fn multikey_roundtrip_p256(seed in any::<[u8; 32]>()) { 1793 + let mut rng = ChaCha20Rng::from_seed(seed); 1794 + let signing = p256::ecdsa::SigningKey::random(&mut rng); 1795 + let original = AnyVerifyingKey::P256(*signing.verifying_key()); 1796 + 1797 + let encoded = encode_multikey(&original); 1798 + prop_assert!(encoded.starts_with('z')); 1799 + 1800 + let parsed = parse_multikey(&encoded) 1801 + .expect("a freshly encoded multikey must parse"); 1802 + let re_encoded = encode_multikey(&parsed.verifying_key); 1803 + prop_assert_eq!( 1804 + encoded, 1805 + re_encoded, 1806 + "encode_multikey must round-trip through parse_multikey" 1807 + ); 1808 + } 1809 + 1810 + #[test] 1811 + fn signature_jws_bytes_is_always_64_k256( 1812 + seed in any::<[u8; 32]>(), 1813 + msg_seed in any::<[u8; 32]>(), 1814 + ) { 1815 + let mut rng = ChaCha20Rng::from_seed(seed); 1816 + let signing = AnySigningKey::K256(k256::ecdsa::SigningKey::random(&mut rng)); 1817 + let signature = signing.sign_prehash(&msg_seed); 1818 + prop_assert_eq!(signature.to_jws_bytes().len(), 64); 1819 + } 1820 + 1821 + #[test] 1822 + fn signature_jws_bytes_is_always_64_p256( 1823 + seed in any::<[u8; 32]>(), 1824 + msg_seed in any::<[u8; 32]>(), 1825 + ) { 1826 + let mut rng = ChaCha20Rng::from_seed(seed); 1827 + let signing = AnySigningKey::P256(p256::ecdsa::SigningKey::random(&mut rng)); 1828 + let signature = signing.sign_prehash(&msg_seed); 1829 + prop_assert_eq!(signature.to_jws_bytes().len(), 64); 1830 + } 1831 + 1832 + #[test] 1833 + fn sign_verify_prehash_roundtrip_k256( 1834 + seed in any::<[u8; 32]>(), 1835 + msg in any::<[u8; 32]>(), 1836 + ) { 1837 + let mut rng = ChaCha20Rng::from_seed(seed); 1838 + let signing = AnySigningKey::K256(k256::ecdsa::SigningKey::random(&mut rng)); 1839 + let signature = signing.sign_prehash(&msg); 1840 + let verifying = signing.verifying_key(); 1841 + verifying.verify_prehash(&msg, &signature) 1842 + .expect("verify_prehash must accept a freshly produced signature"); 1843 + } 1844 + 1845 + #[test] 1846 + fn sign_verify_prehash_roundtrip_p256( 1847 + seed in any::<[u8; 32]>(), 1848 + msg in any::<[u8; 32]>(), 1849 + ) { 1850 + let mut rng = ChaCha20Rng::from_seed(seed); 1851 + let signing = AnySigningKey::P256(p256::ecdsa::SigningKey::random(&mut rng)); 1852 + let signature = signing.sign_prehash(&msg); 1853 + let verifying = signing.verifying_key(); 1854 + verifying.verify_prehash(&msg, &signature) 1855 + .expect("verify_prehash must accept a freshly produced signature"); 1856 + } 1740 1857 } 1741 1858 } 1742 1859 }
+91
src/common/jwt.rs
··· 7 7 //! Only ES256 and ES256K are supported (RFC 7518 §3.4); raw r||s signature 8 8 //! encoding, unpadded base64url segments, UTF-8 JSON payloads. 9 9 10 + // pattern: Functional Core 11 + 10 12 use base64::Engine; 11 13 use base64::engine::general_purpose::URL_SAFE_NO_PAD; 12 14 use serde::{Deserialize, Serialize}; ··· 458 460 459 461 let result = verify_compact(&tampered, &vkey); 460 462 assert!(matches!(result, Err(JwtError::InvalidSignatureScalar))); 463 + } 464 + 465 + // Property-based roundtrip tests pinning the invariant that 466 + // `verify_compact(encode_compact(claims, key), key.verifying_key())` 467 + // recovers the same claim payload, for every well-formed claim set 468 + // and every signing key generated from a 32-byte seed. 469 + mod pbt { 470 + use super::*; 471 + use proptest::prelude::*; 472 + use rand_chacha::ChaCha20Rng; 473 + use rand_core::SeedableRng; 474 + 475 + // 16 hex chars matches the format atproto labelers expect for 476 + // `jti` and is what `RelyingParty::new_jti` produces. 477 + const JTI_REGEX: &str = "[0-9a-f]{16}"; 478 + 479 + proptest! { 480 + #![proptest_config(ProptestConfig::with_cases(32))] 481 + 482 + #[test] 483 + fn encode_verify_compact_roundtrip_k256( 484 + seed in any::<[u8; 32]>(), 485 + jti in JTI_REGEX, 486 + iat in 1_500_000_000i64..2_500_000_000i64, 487 + exp_offset in 1i64..86_400i64, 488 + ) { 489 + let mut rng = ChaCha20Rng::from_seed(seed); 490 + let signing = AnySigningKey::K256(k256::ecdsa::SigningKey::random(&mut rng)); 491 + let vkey = signing.verifying_key(); 492 + let header = JwtHeader::for_signing_key(&signing); 493 + let claims = JwtClaims { 494 + iss: "did:web:test".to_string(), 495 + aud: "did:plc:test".to_string(), 496 + exp: iat + exp_offset, 497 + iat, 498 + lxm: "com.atproto.moderation.createReport".to_string(), 499 + jti, 500 + }; 501 + 502 + let token = encode_compact(&header, &claims, &signing) 503 + .expect("encode_compact must succeed"); 504 + let (decoded_header, decoded_claims) = verify_compact(&token, &vkey) 505 + .expect("verify_compact must accept a freshly produced token"); 506 + 507 + prop_assert_eq!(decoded_header.alg, "ES256K"); 508 + prop_assert_eq!(decoded_header.typ, header.typ); 509 + prop_assert_eq!(decoded_claims.iss, claims.iss); 510 + prop_assert_eq!(decoded_claims.aud, claims.aud); 511 + prop_assert_eq!(decoded_claims.exp, claims.exp); 512 + prop_assert_eq!(decoded_claims.iat, claims.iat); 513 + prop_assert_eq!(decoded_claims.lxm, claims.lxm); 514 + prop_assert_eq!(decoded_claims.jti, claims.jti); 515 + } 516 + 517 + #[test] 518 + fn encode_verify_compact_roundtrip_p256( 519 + seed in any::<[u8; 32]>(), 520 + jti in JTI_REGEX, 521 + iat in 1_500_000_000i64..2_500_000_000i64, 522 + exp_offset in 1i64..86_400i64, 523 + ) { 524 + let mut rng = ChaCha20Rng::from_seed(seed); 525 + let signing = AnySigningKey::P256(p256::ecdsa::SigningKey::random(&mut rng)); 526 + let vkey = signing.verifying_key(); 527 + let header = JwtHeader::for_signing_key(&signing); 528 + let claims = JwtClaims { 529 + iss: "did:web:example.com".to_string(), 530 + aud: "did:plc:test".to_string(), 531 + exp: iat + exp_offset, 532 + iat, 533 + lxm: "com.atproto.moderation.createReport".to_string(), 534 + jti, 535 + }; 536 + 537 + let token = encode_compact(&header, &claims, &signing) 538 + .expect("encode_compact must succeed"); 539 + let (decoded_header, decoded_claims) = verify_compact(&token, &vkey) 540 + .expect("verify_compact must accept a freshly produced token"); 541 + 542 + prop_assert_eq!(decoded_header.alg, "ES256"); 543 + prop_assert_eq!(decoded_header.typ, header.typ); 544 + prop_assert_eq!(decoded_claims.iss, claims.iss); 545 + prop_assert_eq!(decoded_claims.aud, claims.aud); 546 + prop_assert_eq!(decoded_claims.exp, claims.exp); 547 + prop_assert_eq!(decoded_claims.iat, claims.iat); 548 + prop_assert_eq!(decoded_claims.lxm, claims.lxm); 549 + prop_assert_eq!(decoded_claims.jti, claims.jti); 550 + } 551 + } 461 552 } 462 553 }
+6
src/common/oauth/clock.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // `RealClock::now_unix_seconds` reads `SystemTime::now()`, the canonical 4 + // non-deterministic side effect. The trait itself is the seam tests use 5 + // to inject `FakeClock`. 6 + 1 7 use std::time::{Duration, SystemTime, UNIX_EPOCH}; 2 8 3 9 /// A clock source. `RealClock` uses `SystemTime::now()`; tests inject
+73
src/common/oauth/jws.rs
··· 4 4 //! test-friendly JWS signing and JWKS parsing. Diagnostic codes in the 5 5 //! `oauth_client::jws::*` namespace enable stable error reporting downstream. 6 6 7 + // pattern: Functional Core 8 + 7 9 use std::sync::Arc; 8 10 9 11 use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey}; ··· 748 750 if let Err(err) = result { 749 751 let code_str = err.code().map(|c| c.to_string()); 750 752 assert_eq!(code_str, Some("oauth_client::jws::not_json".to_string())); 753 + } 754 + } 755 + 756 + // Property-based test for the ES256 sign/verify roundtrip: 757 + // `verify_jws(sign_es256_jws(claims, key), parsed_jwk(key)) == claims` 758 + // for every signing key generated from a 32-byte seed and arbitrary 759 + // string claim payload. 760 + mod pbt { 761 + use super::*; 762 + use p256::ecdsa::SigningKey; 763 + use p256::pkcs8::EncodePrivateKey; 764 + use proptest::prelude::*; 765 + use rand_chacha::ChaCha20Rng; 766 + use rand_core::SeedableRng; 767 + 768 + /// Construct the JWK + encoding key pair backing a P-256 signing 769 + /// key, in the same shape `oauth/jws::parse_jwk` accepts. 770 + fn build_es256_material(seed: [u8; 32]) -> (ParsedJwk, EncodingKey) { 771 + let mut rng = ChaCha20Rng::from_seed(seed); 772 + let signing_key = SigningKey::random(&mut rng); 773 + 774 + let public_key = signing_key.verifying_key(); 775 + let sec1 = public_key.to_sec1_bytes(); 776 + let x_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&sec1[1..33]); 777 + let y_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&sec1[33..65]); 778 + 779 + let jwk_json = serde_json::json!({ 780 + "kty": "EC", 781 + "crv": "P-256", 782 + "x": x_b64, 783 + "y": y_b64, 784 + "kid": "k1", 785 + "alg": "ES256", 786 + "use": "sig", 787 + }); 788 + let source = Arc::<[u8]>::from(b"test".as_ref()); 789 + let parsed = parse_jwk(&jwk_json, "test", source).expect("freshly built JWK parses"); 790 + 791 + let der = signing_key 792 + .to_pkcs8_der() 793 + .expect("p256 keys must export to PKCS8"); 794 + let encoding_key = EncodingKey::from_ec_der(der.as_bytes()); 795 + 796 + (parsed, encoding_key) 797 + } 798 + 799 + proptest! { 800 + // Each iteration builds a key + does a sign/verify pair; cap 801 + // at 16 to keep the suite fast. 802 + #![proptest_config(ProptestConfig::with_cases(16))] 803 + 804 + #[test] 805 + fn es256_sign_verify_roundtrip( 806 + seed in any::<[u8; 32]>(), 807 + foo in any::<String>(), 808 + count in 0u32..1000u32, 809 + ) { 810 + let (jwk, encoding_key) = build_es256_material(seed); 811 + 812 + let claims = serde_json::json!({"foo": foo, "count": count}); 813 + let mut header = jsonwebtoken::Header::new(Algorithm::ES256); 814 + header.kid = Some("k1".to_string()); 815 + 816 + let token = sign_es256_jws(&header, &claims, &encoding_key) 817 + .expect("sign_es256_jws must succeed"); 818 + 819 + let decoded: jsonwebtoken::TokenData<serde_json::Value> = 820 + verify_jws(&token, &jwk, JwsAlg::Es256) 821 + .expect("verify_jws must accept a freshly produced token"); 822 + prop_assert_eq!(decoded.claims, claims); 823 + } 751 824 } 752 825 } 753 826 }
+218 -62
src/common/oauth/relying_party.rs
··· 4 4 //! (Pushed Authorization Request), PKCE S256, DPoP proof, and private_key_jwt 5 5 //! flows. The RelyingParty is constructed with a deterministic seeded RNG to 6 6 //! enable reproducible testing. 7 + //! 8 + //! # HTTP-client seam exception 9 + //! 10 + //! Unlike every other network-touching module in the crate (which routes 11 + //! through the `HttpClient` trait from `common::identity` so tests can 12 + //! intercept traffic with `FakeHttpClient`), the RelyingParty uses 13 + //! `reqwest::Client` directly. This is deliberate: 14 + //! 15 + //! 1. The RP only ever talks to an authorization server over genuine HTTP. 16 + //! The interactive tests stand up a real axum fake-AS on `127.0.0.1`, 17 + //! so determinism comes from a fixed `Clock` + seeded `ChaCha20Rng`, 18 + //! not from intercepting bytes on the wire. 19 + //! 2. The `do_authorize` flow needs a redirect-following policy distinct 20 + //! from the rest of the RP's calls, which is awkward to express 21 + //! through a two-method `HttpClient` trait without leaking 22 + //! reqwest-specific concepts back through the seam. 23 + //! 24 + //! The RP owns two `reqwest::Client` instances built once at construction 25 + //! time: `http` (default redirect policy) for PAR / token / refresh / 26 + //! discover_as, and `http_no_redirect` for `do_authorize_inner`, which 27 + //! manually inspects the first 3xx response. 28 + 29 + // pattern: Mixed (unavoidable) 30 + // 31 + // Pure: `parse_as_descriptor`, `verify_redirect_iss`, `extract_auth_response`, 32 + // `new_pkce`, `new_jti`, `sign_dpop`, `sign_private_key_jwt`. Imperative 33 + // shell: every `do_*` flow method (PAR / authorize / token / refresh) 34 + // drives reqwest. The mix is deliberate — see the module-level docs above 35 + // for why the RP holds its own reqwest clients rather than going through 36 + // the project's `HttpClient` seam. 7 37 8 38 use std::collections::HashMap; 9 39 use std::sync::{Arc, Mutex}; ··· 147 177 signing_jwk_public: Value, 148 178 clock: Arc<dyn Clock>, 149 179 rng: Mutex<ChaCha20Rng>, 180 + /// Default-redirect-policy client used for PAR, token, refresh, 181 + /// and `discover_as`. 150 182 http: ReqwestClient, 183 + /// Redirect-disabled client used by `do_authorize_inner`, which 184 + /// inspects the first 3xx response from the authorization endpoint 185 + /// and resolves the `redirect_uri` itself. 186 + http_no_redirect: ReqwestClient, 151 187 /// DPoP nonces keyed by endpoint URL, for use_dpop_nonce retry. 152 188 dpop_nonces: Mutex<HashMap<Url, String>>, 153 189 } ··· 246 282 "y": y, 247 283 }); 248 284 249 - // Build HTTP client with rustls, user-agent, and timeout. 285 + // Build HTTP clients with rustls, user-agent, and timeout. The 286 + // no-redirect variant is used by `do_authorize_inner` to inspect 287 + // the first 3xx response from the authorization endpoint 288 + // directly. Both clients share the same TLS pool only within 289 + // their own builder; reqwest does not let us share a connection 290 + // pool across two redirect policies, so we accept two pools per 291 + // RP. 250 292 let http = ReqwestClient::builder() 251 293 .use_rustls_tls() 252 294 .user_agent(APP_USER_AGENT) 253 295 .timeout(Duration::from_secs(30)) 254 296 .build() 255 297 .unwrap_or_else(|_| ReqwestClient::new()); 298 + let http_no_redirect = ReqwestClient::builder() 299 + .use_rustls_tls() 300 + .user_agent(APP_USER_AGENT) 301 + .timeout(Duration::from_secs(30)) 302 + .redirect(reqwest::redirect::Policy::none()) 303 + .build() 304 + .unwrap_or_else(|_| ReqwestClient::new()); 256 305 257 306 Self { 258 307 client_id, ··· 262 311 clock, 263 312 rng: Mutex::new(rng), 264 313 http, 314 + http_no_redirect, 265 315 dpop_nonces: Mutex::new(HashMap::new()), 266 316 } 267 317 } ··· 278 328 }); 279 329 } 280 330 281 - let metadata: serde_json::Value = response.json().await?; 282 - 283 - let issuer = metadata 284 - .get("issuer") 285 - .and_then(|v| v.as_str()) 286 - .and_then(|s| Url::parse(s).ok()) 287 - .ok_or_else(|| RpError::MetadataMalformed { 288 - reason: "missing or invalid issuer".to_string(), 289 - })?; 290 - 291 - let pushed_authorization_request_endpoint = metadata 292 - .get("pushed_authorization_request_endpoint") 293 - .and_then(|v| v.as_str()) 294 - .and_then(|s| Url::parse(s).ok()) 295 - .ok_or_else(|| RpError::MetadataMalformed { 296 - reason: "missing or invalid pushed_authorization_request_endpoint".to_string(), 297 - })?; 298 - 299 - let authorization_endpoint = metadata 300 - .get("authorization_endpoint") 301 - .and_then(|v| v.as_str()) 302 - .and_then(|s| Url::parse(s).ok()) 303 - .ok_or_else(|| RpError::MetadataMalformed { 304 - reason: "missing or invalid authorization_endpoint".to_string(), 305 - })?; 306 - 307 - let token_endpoint = metadata 308 - .get("token_endpoint") 309 - .and_then(|v| v.as_str()) 310 - .and_then(|s| Url::parse(s).ok()) 311 - .ok_or_else(|| RpError::MetadataMalformed { 312 - reason: "missing or invalid token_endpoint".to_string(), 313 - })?; 314 - 315 - let require_pushed_authorization_requests = metadata 316 - .get("require_pushed_authorization_requests") 317 - .and_then(|v| v.as_bool()) 318 - .unwrap_or(false); 319 - 320 - if !require_pushed_authorization_requests { 321 - return Err(RpError::MetadataMalformed { 322 - reason: "require_pushed_authorization_requests is not true".to_string(), 323 - }); 324 - } 325 - 326 - Ok(AsDescriptor { 327 - issuer, 328 - pushed_authorization_request_endpoint, 329 - authorization_endpoint, 330 - token_endpoint, 331 - }) 331 + let metadata: Value = response.json().await?; 332 + parse_as_descriptor(&metadata) 332 333 } 333 334 334 335 /// Perform Pushed Authorization Request (PAR). ··· 478 479 .append_pair("request_uri", request_uri) 479 480 .append_pair("client_id", self.client_id.as_str()); 480 481 481 - // Build a client with redirect policy disabled to manually follow redirects. 482 - let client = ReqwestClient::builder() 483 - .use_rustls_tls() 484 - .user_agent(APP_USER_AGENT) 485 - .timeout(Duration::from_secs(30)) 486 - .redirect(reqwest::redirect::Policy::none()) 487 - .build() 488 - .unwrap_or_else(|_| ReqwestClient::new()); 489 - 490 - let response = client.get(url).send().await?; 482 + // Reuse the redirect-disabled client built once in 483 + // `RelyingParty::new` so successive `do_authorize` calls share 484 + // its connection pool. 485 + let response = self.http_no_redirect.get(url).send().await?; 491 486 492 487 // Follow redirects manually: stop on the first redirect whose 493 488 // Location header matches the redirect_uri scheme/origin. ··· 965 960 } 966 961 } 967 962 963 + /// Pure: parse an authorization-server metadata document into an 964 + /// `AsDescriptor`. Returns `RpError::MetadataMalformed` if any required 965 + /// field (`issuer`, `pushed_authorization_request_endpoint`, 966 + /// `authorization_endpoint`, `token_endpoint`) is missing or unparseable, 967 + /// or if `require_pushed_authorization_requests` is not advertised as 968 + /// `true` (atproto requires PAR). 969 + fn parse_as_descriptor(metadata: &Value) -> Result<AsDescriptor, RpError> { 970 + let issuer = metadata 971 + .get("issuer") 972 + .and_then(|v| v.as_str()) 973 + .and_then(|s| Url::parse(s).ok()) 974 + .ok_or_else(|| RpError::MetadataMalformed { 975 + reason: "missing or invalid issuer".to_string(), 976 + })?; 977 + 978 + let pushed_authorization_request_endpoint = metadata 979 + .get("pushed_authorization_request_endpoint") 980 + .and_then(|v| v.as_str()) 981 + .and_then(|s| Url::parse(s).ok()) 982 + .ok_or_else(|| RpError::MetadataMalformed { 983 + reason: "missing or invalid pushed_authorization_request_endpoint".to_string(), 984 + })?; 985 + 986 + let authorization_endpoint = metadata 987 + .get("authorization_endpoint") 988 + .and_then(|v| v.as_str()) 989 + .and_then(|s| Url::parse(s).ok()) 990 + .ok_or_else(|| RpError::MetadataMalformed { 991 + reason: "missing or invalid authorization_endpoint".to_string(), 992 + })?; 993 + 994 + let token_endpoint = metadata 995 + .get("token_endpoint") 996 + .and_then(|v| v.as_str()) 997 + .and_then(|s| Url::parse(s).ok()) 998 + .ok_or_else(|| RpError::MetadataMalformed { 999 + reason: "missing or invalid token_endpoint".to_string(), 1000 + })?; 1001 + 1002 + let require_pushed_authorization_requests = metadata 1003 + .get("require_pushed_authorization_requests") 1004 + .and_then(|v| v.as_bool()) 1005 + .unwrap_or(false); 1006 + 1007 + if !require_pushed_authorization_requests { 1008 + return Err(RpError::MetadataMalformed { 1009 + reason: "require_pushed_authorization_requests is not true".to_string(), 1010 + }); 1011 + } 1012 + 1013 + Ok(AsDescriptor { 1014 + issuer, 1015 + pushed_authorization_request_endpoint, 1016 + authorization_endpoint, 1017 + token_endpoint, 1018 + }) 1019 + } 1020 + 968 1021 /// Verify that the `iss` query parameter on an authorization 969 1022 /// redirect matches the expected AS issuer. The comparison strips a 970 1023 /// single trailing slash from both sides because atproto's ··· 1109 1162 assert_eq!(payload.get("htm").and_then(|v| v.as_str()), Some("POST")); 1110 1163 assert!(payload.get("jti").is_some(), "JTI should be present"); 1111 1164 assert!(payload.get("iat").is_some(), "iat should be present"); 1165 + } 1166 + 1167 + fn good_metadata() -> serde_json::Value { 1168 + json!({ 1169 + "issuer": "https://auth.example.com", 1170 + "pushed_authorization_request_endpoint": "https://auth.example.com/oauth/par", 1171 + "authorization_endpoint": "https://auth.example.com/oauth/authorize", 1172 + "token_endpoint": "https://auth.example.com/oauth/token", 1173 + "require_pushed_authorization_requests": true, 1174 + }) 1175 + } 1176 + 1177 + #[test] 1178 + fn parse_as_descriptor_accepts_well_formed_metadata() { 1179 + let metadata = good_metadata(); 1180 + let descriptor = parse_as_descriptor(&metadata).expect("well-formed metadata parses"); 1181 + assert_eq!(descriptor.issuer.as_str(), "https://auth.example.com/"); 1182 + assert_eq!( 1183 + descriptor.token_endpoint.as_str(), 1184 + "https://auth.example.com/oauth/token" 1185 + ); 1186 + } 1187 + 1188 + #[test] 1189 + fn parse_as_descriptor_requires_par_advertisement() { 1190 + // atproto requires `require_pushed_authorization_requests = true`; 1191 + // any other value (including missing) must error. 1192 + let mut metadata = good_metadata(); 1193 + metadata 1194 + .as_object_mut() 1195 + .unwrap() 1196 + .remove("require_pushed_authorization_requests"); 1197 + let err = parse_as_descriptor(&metadata).expect_err("missing PAR flag must error"); 1198 + assert!(matches!(err, RpError::MetadataMalformed { .. })); 1199 + 1200 + let metadata = json!({ 1201 + "issuer": "https://auth.example.com", 1202 + "pushed_authorization_request_endpoint": "https://auth.example.com/oauth/par", 1203 + "authorization_endpoint": "https://auth.example.com/oauth/authorize", 1204 + "token_endpoint": "https://auth.example.com/oauth/token", 1205 + "require_pushed_authorization_requests": false, 1206 + }); 1207 + let err = parse_as_descriptor(&metadata).expect_err("PAR=false must error"); 1208 + assert!(matches!(err, RpError::MetadataMalformed { .. })); 1209 + } 1210 + 1211 + #[test] 1212 + fn parse_as_descriptor_rejects_each_missing_required_field() { 1213 + for field in [ 1214 + "issuer", 1215 + "pushed_authorization_request_endpoint", 1216 + "authorization_endpoint", 1217 + "token_endpoint", 1218 + ] { 1219 + let mut metadata = good_metadata(); 1220 + metadata.as_object_mut().unwrap().remove(field); 1221 + let err = parse_as_descriptor(&metadata) 1222 + .unwrap_err_with(|_| format!("missing {field} must error")); 1223 + match err { 1224 + RpError::MetadataMalformed { reason } => { 1225 + assert!( 1226 + reason.contains(field), 1227 + "error message should mention {field}, got {reason:?}" 1228 + ); 1229 + } 1230 + other => panic!("expected MetadataMalformed, got {other:?}"), 1231 + } 1232 + } 1233 + } 1234 + 1235 + #[test] 1236 + fn parse_as_descriptor_rejects_non_url_endpoint() { 1237 + let mut metadata = good_metadata(); 1238 + metadata.as_object_mut().unwrap().insert( 1239 + "token_endpoint".to_string(), 1240 + serde_json::Value::String("not a url".to_string()), 1241 + ); 1242 + let err = parse_as_descriptor(&metadata).expect_err("non-URL endpoint must error"); 1243 + match err { 1244 + RpError::MetadataMalformed { reason } => { 1245 + assert!( 1246 + reason.contains("token_endpoint"), 1247 + "error message should mention token_endpoint, got {reason:?}" 1248 + ); 1249 + } 1250 + other => panic!("expected MetadataMalformed, got {other:?}"), 1251 + } 1252 + } 1253 + 1254 + /// Variant of `Result::expect_err` whose panic message is computed 1255 + /// from the surprising `Ok` value, so loop iterations can attribute 1256 + /// the failure back to the field under test. 1257 + trait UnwrapErrWith<T, E> { 1258 + fn unwrap_err_with<F: FnOnce(&T) -> String>(self, msg: F) -> E; 1259 + } 1260 + 1261 + impl<T: std::fmt::Debug, E> UnwrapErrWith<T, E> for Result<T, E> { 1262 + fn unwrap_err_with<F: FnOnce(&T) -> String>(self, msg: F) -> E { 1263 + match self { 1264 + Ok(v) => panic!("{}: got Ok({v:?})", msg(&v)), 1265 + Err(e) => e, 1266 + } 1267 + } 1112 1268 } 1113 1269 }
+2
src/common/report.rs
··· 1 1 //! Report aggregation and rendering for the labeler conformance suite. 2 2 3 + // pattern: Functional Core 4 + 3 5 use std::borrow::Cow; 4 6 use std::fmt; 5 7 use std::io;
+2
src/main.rs
··· 1 1 //! `atproto-devtool` binary entry point. 2 2 3 + // pattern: Imperative Shell 4 + 3 5 use miette::Result; 4 6 use std::process::ExitCode; 5 7