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 'main' into test-oauth-client

+508 -464
+1 -1
src/cli.rs
··· 16 16 about = "Diagnostics and conformance tooling for atproto services.", 17 17 long_about = None, 18 18 )] 19 - pub struct Cli { 19 + struct Cli { 20 20 /// Enable verbose (DEBUG-level) logging to stderr. 21 21 #[arg(long, global = true)] 22 22 verbose: bool,
+2 -2
src/commands.rs
··· 9 9 use self::test::TestCmd; 10 10 11 11 #[derive(Debug, Subcommand)] 12 - pub enum Command { 12 + pub(crate) enum Command { 13 13 /// Conformance and diagnostic checks against atproto services. 14 14 #[command(subcommand)] 15 15 Test(TestCmd), 16 16 } 17 17 18 18 impl Command { 19 - pub async fn run(self, no_color: bool) -> Result<ExitCode, Report> { 19 + pub(crate) async fn run(self, no_color: bool) -> Result<ExitCode, Report> { 20 20 match self { 21 21 Command::Test(cmd) => cmd.run(no_color).await, 22 22 }
+2 -2
src/commands/test.rs
··· 11 11 use self::oauth::OauthCmd; 12 12 13 13 #[derive(Debug, Subcommand)] 14 - pub enum TestCmd { 14 + pub(crate) enum TestCmd { 15 15 /// Run the labeler conformance suite against an atproto labeler. 16 16 Labeler(LabelerCmd), 17 17 /// Test an atproto OAuth client. ··· 20 20 } 21 21 22 22 impl TestCmd { 23 - pub async fn run(self, no_color: bool) -> Result<ExitCode, Report> { 23 + pub(crate) async fn run(self, no_color: bool) -> Result<ExitCode, Report> { 24 24 match self { 25 25 TestCmd::Labeler(cmd) => cmd.run(no_color).await, 26 26 TestCmd::Oauth(cmd) => cmd.run(no_color).await,
+25 -23
src/commands/test/labeler.rs
··· 1 1 //! `atproto-devtool test labeler <target>` command. 2 2 3 - pub mod create_report; 4 - pub mod crypto; 5 - pub mod http; 6 - pub mod identity; 7 3 pub mod pipeline; 8 - pub mod subscription; 4 + pub mod target; 9 5 10 6 use std::io; 11 7 use std::process::ExitCode; ··· 14 10 use clap::Args; 15 11 use miette::Report; 16 12 17 - use crate::commands::test::labeler::create_report::self_mint::{SelfMintCurve, SelfMintSigner}; 13 + use self::pipeline::{ 14 + LabelerOptions, 15 + create_report::{ 16 + self, 17 + self_mint::{SelfMintCurve, SelfMintSigner}, 18 + }, 19 + run_pipeline, 20 + }; 18 21 use crate::common::{ 19 22 APP_USER_AGENT, 20 23 identity::{Did, RealDnsResolver, RealHttpClient, is_local_labeler_hostname}, 21 24 report::RenderConfig, 22 25 }; 23 - use pipeline::{LabelerOptions, parse_target, run_pipeline}; 24 26 25 27 /// Run the labeler conformance suite against a handle, DID, or endpoint URL. 26 28 #[derive(Debug, Args)] 27 - pub struct LabelerCmd { 29 + pub(crate) struct LabelerCmd { 28 30 /// Handle (`alice.example`), DID (`did:plc:...` / `did:web:...`), or labeler endpoint URL. 29 - pub target: String, 31 + target: String, 30 32 31 33 /// Explicit DID override. Required (and combined with the target URL) when 32 34 /// `target` is a raw endpoint URL and you want identity/crypto checks to run. 33 35 #[arg(long)] 34 - pub did: Option<String>, 36 + did: Option<String>, 35 37 36 38 /// Per-connection time budget for the subscription-layer checks. 37 39 /// ··· 41 43 default_value = "5s", 42 44 value_parser = parse_subscribe_timeout, 43 45 )] 44 - pub subscribe_timeout: Duration, 46 + subscribe_timeout: Duration, 45 47 46 48 /// Whether to suppress colored output. 47 49 #[arg(long)] 48 - pub no_color: bool, 50 + no_color: bool, 49 51 50 52 /// Whether to emit verbose diagnostics. 51 53 #[arg(long)] 52 - pub verbose: bool, 54 + verbose: bool, 53 55 54 56 /// Commit: opt in to actually POSTing report bodies to the labeler and 55 57 /// assert reporting conformance (missing `LabelerPolicies` becomes a 56 58 /// SpecViolation rather than a stage-skip). 57 59 #[arg(long)] 58 - pub commit_report: bool, 60 + commit_report: bool, 59 61 60 62 /// Force self-mint checks to run even when the labeler endpoint is 61 63 /// classified as non-local by the hostname heuristic. Use when 62 64 /// running against a LAN-reachable labeler that the heuristic misses. 63 65 #[arg(long)] 64 - pub force_self_mint: bool, 66 + force_self_mint: bool, 65 67 66 68 /// Curve to use for self-mint JWTs. 67 69 #[arg(long, value_enum, default_value_t = SelfMintCurve::default())] 68 - pub self_mint_curve: SelfMintCurve, 70 + self_mint_curve: SelfMintCurve, 69 71 70 72 /// Override the default computed subject DID for committing checks. 71 73 /// Passed through to `self_mint_accepted`, `pds_service_auth_accepted`, 72 74 /// and `pds_proxied_accepted` bodies. 73 75 #[arg(long)] 74 - pub report_subject_did: Option<String>, 76 + report_subject_did: Option<String>, 75 77 76 78 /// User handle for PDS-mediated report modes. Must be supplied together 77 79 /// with --app-password; enables `pds_service_auth_accepted` and 78 80 /// `pds_proxied_accepted` checks when combined with --commit-report. 79 81 #[arg(long, requires = "app_password")] 80 - pub handle: Option<String>, 82 + handle: Option<String>, 81 83 82 84 /// App password for PDS-mediated report modes. Must be supplied 83 85 /// together with --handle. 84 86 #[arg(long, requires = "handle")] 85 - pub app_password: Option<String>, 87 + app_password: Option<String>, 86 88 } 87 89 88 90 impl LabelerCmd { 89 - pub async fn run(self, no_color: bool) -> Result<ExitCode, Report> { 91 + pub(crate) async fn run(self, no_color: bool) -> Result<ExitCode, Report> { 90 92 // Parse the target. 91 93 let target = 92 - parse_target(&self.target, self.did.as_deref()).map_err(|e| miette::miette!("{e}"))?; 94 + target::parse(&self.target, self.did.as_deref()).map_err(|e| miette::miette!("{e}"))?; 93 95 94 96 // Determine tentative endpoint for the locality check. When the target is a 95 97 // DID or handle, the endpoint is known only after identity stage; for the 96 98 // self-mint signer construction we need it now. We construct the signer 97 99 // pessimistically (endpoint unknown) only when --force-self-mint is set. 98 100 let tentative_endpoint: Option<url::Url> = match &target { 99 - pipeline::LabelerTarget::Endpoint { url, .. } => Some(url.clone()), 100 - pipeline::LabelerTarget::Identified { .. } => None, 101 + target::LabelerTarget::Endpoint { url, .. } => Some(url.clone()), 102 + target::LabelerTarget::Identified { .. } => None, 101 103 }; 102 104 103 105 let tentative_local = tentative_endpoint
+4 -3
src/commands/test/labeler/CLAUDE.md
··· 17 17 - `LabelerCmd::run(no_color) -> Result<ExitCode, miette::Report>` (in 18 18 `labeler.rs`) — constructs the shared reqwest client and calls 19 19 `pipeline::run_pipeline`. 20 - - `pipeline::parse_target(raw, explicit_did) -> LabelerTarget` — the 20 + - `target::parse(raw, explicit_did) -> LabelerTarget` — the 21 21 accepted target grammar is handle, `did:*`, `https://` URL, or 22 22 `http://` URL with a local hostname (loopback, RFC 1918, `.local`). 23 23 Remote HTTP is rejected with a helpful error; raw endpoints with 24 24 no DID simply skip identity/crypto. 25 25 - `pipeline::run_pipeline(target, LabelerOptions) -> LabelerReport` — the 26 26 one orchestrator that every test hits. 27 - - **Per-stage entry points**: `identity::run`, `http::run`, 28 - `subscription::run`, `crypto::run`, `create_report::run`. Each returns a 27 + - **Per-stage entry points**: `pipeline::identity::run`, `pipeline::http::run`, 28 + `pipeline::subscription::run`, `pipeline::crypto::run`, 29 + `pipeline::create_report::run`. Each returns a 29 30 `*StageOutput` with an `Option<*Facts>` (populated only when the stage 30 31 succeeds enough to let downstream stages run, or `None` when there are no 31 32 meaningful facts to carry forward) plus a `Vec<CheckResult>`.
+2 -2
src/commands/test/labeler/create_report.rs src/commands/test/labeler/pipeline/create_report.rs
··· 14 14 use reqwest::StatusCode; 15 15 use thiserror::Error; 16 16 17 - use crate::commands::test::labeler::identity::IdentityFacts; 17 + use super::identity::IdentityFacts; 18 18 use crate::common::diagnostics::pretty_json_for_display; 19 19 use crate::common::identity::{Did, is_local_labeler_hostname}; 20 20 use crate::common::report::{CheckResult, CheckStatus, Stage}; ··· 645 645 /// always emits exactly 10 `report::*` CheckResults (AC7.1) in canonical 646 646 /// order (AC7.2), regardless of gating decisions. 647 647 pub async fn run( 648 - identity_facts: Option<&crate::commands::test::labeler::identity::IdentityFacts>, 648 + identity_facts: Option<&super::identity::IdentityFacts>, 649 649 report_tee: &dyn CreateReportTee, 650 650 opts: &CreateReportRunOptions<'_>, 651 651 ) -> CreateReportStageOutput {
src/commands/test/labeler/create_report/did_doc_server.rs src/commands/test/labeler/pipeline/create_report/did_doc_server.rs
src/commands/test/labeler/create_report/pollution.rs src/commands/test/labeler/pipeline/create_report/pollution.rs
src/commands/test/labeler/create_report/self_mint.rs src/commands/test/labeler/pipeline/create_report/self_mint.rs
src/commands/test/labeler/create_report/sentinel.rs src/commands/test/labeler/pipeline/create_report/sentinel.rs
+2 -2
src/commands/test/labeler/crypto.rs src/commands/test/labeler/pipeline/crypto.rs
··· 459 459 /// 6. Else if `did:plc`: fetch PLC audit log and retry against historic keys. 460 460 /// 7. Else (`did:web`): emit `crypto::rollup` SpecViolation with no rotation history. 461 461 pub async fn run( 462 - identity: &crate::commands::test::labeler::identity::IdentityFacts, 462 + identity: &super::identity::IdentityFacts, 463 463 labels: &[Label], 464 464 http: &dyn crate::common::identity::HttpClient, 465 465 ) -> CryptoStageOutput { ··· 788 788 789 789 #[cfg(test)] 790 790 mod tests { 791 + use super::super::identity::IdentityFacts; 791 792 use super::*; 792 - use crate::commands::test::labeler::identity::IdentityFacts; 793 793 use crate::common::identity::{ 794 794 AnySignature, AnyVerifyingKey, Did, DidDocument, IdentityError, RawDidDocument, 795 795 encode_multikey,
src/commands/test/labeler/http.rs src/commands/test/labeler/pipeline/http.rs
src/commands/test/labeler/identity.rs src/commands/test/labeler/pipeline/identity.rs
+12 -329
src/commands/test/labeler/pipeline.rs
··· 2 2 3 3 use std::time::Duration; 4 4 5 - use miette::Diagnostic; 6 - use thiserror::Error; 7 5 use url::Url; 8 6 9 - use crate::commands::test::labeler::create_report::self_mint::{SelfMintCurve, SelfMintSigner}; 10 - use crate::commands::test::labeler::create_report::{ 11 - self, CreateReportTee, PdsXrpcClient, RealCreateReportTee, 12 - }; 13 - use crate::commands::test::labeler::crypto; 14 - use crate::commands::test::labeler::http::{self, RealHttpTee}; 15 - use crate::commands::test::labeler::identity; 16 - use crate::commands::test::labeler::subscription::{self, RealWebSocketClient}; 7 + use super::target::{AtIdentifier, LabelerTarget}; 17 8 use crate::common::identity::{ 18 - Did, DnsResolver, HttpClient, find_service, is_local_labeler_hostname, resolve_did, 19 - resolve_handle, 9 + Did, DnsResolver, HttpClient, find_service, resolve_did, resolve_handle, 20 10 }; 21 11 use crate::common::report::{ 22 12 CheckStatus, LabelerReport, ReportHeader, Stage, blocked_by, skipped_with_reason, 23 13 }; 24 14 25 - /// A labeler target: either a resolvable identifier (handle or DID) or a raw endpoint URL. 26 - #[derive(Debug, Clone)] 27 - pub enum LabelerTarget { 28 - /// A handle or DID that can be resolved. 29 - Identified { 30 - /// The handle or DID to resolve. 31 - identifier: AtIdentifier, 32 - /// An optional explicit DID override (for cross-checking). 33 - explicit_did: Option<Did>, 34 - }, 35 - /// A raw HTTP endpoint, optionally with a DID for identity checks. 36 - Endpoint { 37 - /// The endpoint URL. 38 - url: Url, 39 - /// An optional DID to cross-check against the endpoint. 40 - did: Option<Did>, 41 - }, 42 - } 15 + pub mod create_report; 16 + pub mod crypto; 17 + pub mod http; 18 + pub mod identity; 19 + pub mod subscription; 43 20 44 - /// An ATProto identifier: a handle or a DID. 45 - #[derive(Debug, Clone)] 46 - pub enum AtIdentifier { 47 - /// An ATProto handle (e.g., `alice.bsky.social`). 48 - Handle(String), 49 - /// A decentralized identifier (e.g., `did:plc:...` or `did:web:...`). 50 - Did(Did), 51 - } 21 + use self::create_report::self_mint::{SelfMintCurve, SelfMintSigner}; 22 + use self::create_report::{CreateReportTee, PdsXrpcClient, RealCreateReportTee}; 23 + use self::http::RealHttpTee; 24 + use self::subscription::RealWebSocketClient; 52 25 53 26 /// Options for running the labeler pipeline. 54 27 pub struct LabelerOptions<'a> { ··· 123 96 pub app_password: String, 124 97 } 125 98 126 - /// Error from target parsing. 127 - #[derive(Debug, Error, Diagnostic)] 128 - #[error("{message}")] 129 - pub struct TargetParseError { 130 - /// The error message. 131 - pub message: String, 132 - } 133 - 134 - impl TargetParseError { 135 - fn new(message: impl Into<String>) -> Self { 136 - Self { 137 - message: message.into(), 138 - } 139 - } 140 - 141 - fn unrecognized_target(raw: &str) -> Self { 142 - Self::new(format!( 143 - "Unrecognized target '{raw}'. Expected one of:\n - ATProto handle (e.g., alice.bsky.social)\n - DID (e.g., did:plc:abc123 or did:web:example.com)\n - HTTPS endpoint URL (e.g., https://labeler.example.com)\n - HTTP endpoint URL with a local hostname (e.g., http://localhost:8080)" 144 - )) 145 - } 146 - 147 - fn http_not_supported(raw: &str) -> Self { 148 - Self::new(format!( 149 - "HTTP endpoint '{raw}' is not supported for remote hosts. Use HTTPS, or point at a local labeler (localhost / 127.0.0.0/8 / RFC 1918 / .local) to allow plaintext HTTP." 150 - )) 151 - } 152 - 153 - fn ambiguous_did(raw: &str, explicit: &str) -> Self { 154 - Self::new(format!( 155 - "Ambiguous target specification: target '{raw}' is already a DID, but --did {explicit} was also provided. Please use only one." 156 - )) 157 - } 158 - } 159 - 160 - /// Check if a string is a valid ATProto handle. 161 - /// 162 - /// A valid handle: 163 - /// - Contains at least one dot. 164 - /// - Contains only alphanumeric characters, hyphens, and dots. 165 - /// - Does not start or end with a hyphen or dot. 166 - /// - Has no empty segments (no consecutive dots or leading/trailing dots). 167 - fn is_valid_handle(s: &str) -> bool { 168 - if !s.contains('.') { 169 - return false; 170 - } 171 - 172 - // Check for empty string or leading/trailing special chars. 173 - if s.is_empty() 174 - || s.starts_with('-') 175 - || s.starts_with('.') 176 - || s.ends_with('-') 177 - || s.ends_with('.') 178 - { 179 - return false; 180 - } 181 - 182 - // Check all characters are alphanumeric, hyphen, or dot. 183 - for c in s.chars() { 184 - if !c.is_ascii_alphanumeric() && c != '-' && c != '.' { 185 - return false; 186 - } 187 - } 188 - 189 - // Check no empty segments (no consecutive dots). 190 - if s.contains("..") { 191 - return false; 192 - } 193 - 194 - true 195 - } 196 - 197 - /// Parse a labeler target from a string and optional explicit DID. 198 - /// 199 - /// Returns a `LabelerTarget` on success, or a `TargetParseError` on failure. 200 - /// 201 - /// Rules: 202 - /// - If `raw` starts with `did:`, parse as a DID. If `explicit_did` is also provided, return an error. 203 - /// - If `raw` starts with `https://`, parse as a URL. `explicit_did` is carried as an optional DID. 204 - /// - If `raw` starts with `http://`, return an error pointing the user to HTTPS. 205 - /// - If `raw` contains a dot and matches handle grammar, treat as a handle. `explicit_did` is carried. 206 - /// - Otherwise, return an unrecognized target error. 207 - pub fn parse_target( 208 - raw: &str, 209 - explicit_did: Option<&str>, 210 - ) -> Result<LabelerTarget, TargetParseError> { 211 - // Check for DID. 212 - if raw.starts_with("did:") { 213 - if let Some(ed) = explicit_did { 214 - return Err(TargetParseError::ambiguous_did(raw, ed)); 215 - } 216 - return Ok(LabelerTarget::Identified { 217 - identifier: AtIdentifier::Did(Did(raw.to_string())), 218 - explicit_did: None, 219 - }); 220 - } 221 - 222 - // Check for HTTPS URL. 223 - if raw.starts_with("https://") { 224 - let url = Url::parse(raw) 225 - .map_err(|e| TargetParseError::new(format!("Invalid URL '{raw}': {e}")))?; 226 - return Ok(LabelerTarget::Endpoint { 227 - url, 228 - did: explicit_did.map(|d| Did(d.to_string())), 229 - }); 230 - } 231 - 232 - // Check for HTTP URL. Local hostnames (loopback, RFC 1918, .local, mDNS) 233 - // are accepted so developers can target a labeler running on their 234 - // machine or LAN. Remote HTTP is still rejected to guard against 235 - // accidental plaintext traffic to a production labeler. 236 - if raw.starts_with("http://") { 237 - let url = Url::parse(raw) 238 - .map_err(|e| TargetParseError::new(format!("Invalid URL '{raw}': {e}")))?; 239 - if is_local_labeler_hostname(&url) { 240 - return Ok(LabelerTarget::Endpoint { 241 - url, 242 - did: explicit_did.map(|d| Did(d.to_string())), 243 - }); 244 - } 245 - return Err(TargetParseError::http_not_supported(raw)); 246 - } 247 - 248 - // Check for handle. 249 - if is_valid_handle(raw) { 250 - return Ok(LabelerTarget::Identified { 251 - identifier: AtIdentifier::Handle(raw.to_string()), 252 - explicit_did: explicit_did.map(|d| Did(d.to_string())), 253 - }); 254 - } 255 - 256 - // Unrecognized target. 257 - Err(TargetParseError::unrecognized_target(raw)) 258 - } 259 - 260 99 /// Run the full labeler conformance pipeline. 261 100 /// 262 101 /// This is the main driver that orchestrates all validation stages. 263 102 pub async fn run_pipeline(target: LabelerTarget, opts: LabelerOptions<'_>) -> LabelerReport { 264 103 // Build initial header from target. 265 104 let header = ReportHeader { 266 - target: format_target(&target), 105 + target: target.to_string(), 267 106 resolved_did: None, 268 107 pds_endpoint: None, 269 108 labeler_endpoint: None, ··· 535 374 ) 536 375 }) 537 376 } 538 - 539 - /// Format a target for display in the report header. 540 - fn format_target(target: &LabelerTarget) -> String { 541 - match target { 542 - LabelerTarget::Identified { 543 - identifier, 544 - explicit_did, 545 - } => { 546 - let id_str = match identifier { 547 - AtIdentifier::Handle(h) => h.clone(), 548 - AtIdentifier::Did(d) => d.0.clone(), 549 - }; 550 - if explicit_did.is_some() { 551 - format!("{id_str} (with explicit DID)") 552 - } else { 553 - id_str 554 - } 555 - } 556 - LabelerTarget::Endpoint { url, did } => { 557 - if did.is_some() { 558 - format!("{url} (with explicit DID)") 559 - } else { 560 - url.to_string() 561 - } 562 - } 563 - } 564 - } 565 - 566 - #[cfg(test)] 567 - mod tests { 568 - use super::*; 569 - 570 - #[test] 571 - fn parse_target_handle() { 572 - let target = parse_target("alice.bsky.social", None).expect("should parse"); 573 - match target { 574 - LabelerTarget::Identified { 575 - identifier, 576 - explicit_did, 577 - } => { 578 - assert!( 579 - matches!(identifier, AtIdentifier::Handle(ref h) if h == "alice.bsky.social") 580 - ); 581 - assert!(explicit_did.is_none()); 582 - } 583 - _ => panic!("expected Identified variant"), 584 - } 585 - } 586 - 587 - #[test] 588 - fn parse_target_did_plc() { 589 - let target = parse_target("did:plc:abc123", None).expect("should parse"); 590 - match target { 591 - LabelerTarget::Identified { 592 - identifier, 593 - explicit_did, 594 - } => { 595 - assert!(matches!(identifier, AtIdentifier::Did(ref d) if d.0 == "did:plc:abc123")); 596 - assert!(explicit_did.is_none()); 597 - } 598 - _ => panic!("expected Identified variant"), 599 - } 600 - } 601 - 602 - #[test] 603 - fn parse_target_did_web() { 604 - let target = parse_target("did:web:example.com", None).expect("should parse"); 605 - match target { 606 - LabelerTarget::Identified { 607 - identifier, 608 - explicit_did, 609 - } => { 610 - assert!( 611 - matches!(identifier, AtIdentifier::Did(ref d) if d.0 == "did:web:example.com") 612 - ); 613 - assert!(explicit_did.is_none()); 614 - } 615 - _ => panic!("expected Identified variant"), 616 - } 617 - } 618 - 619 - #[test] 620 - fn parse_target_endpoint_https() { 621 - let target = parse_target("https://example.com/labeler", None).expect("should parse"); 622 - match target { 623 - LabelerTarget::Endpoint { url, did } => { 624 - assert_eq!(url.as_str(), "https://example.com/labeler"); 625 - assert!(did.is_none()); 626 - } 627 - _ => panic!("expected Endpoint variant"), 628 - } 629 - } 630 - 631 - #[test] 632 - fn parse_target_endpoint_with_explicit_did() { 633 - let target = 634 - parse_target("https://example.com/labeler", Some("did:plc:xyz")).expect("should parse"); 635 - match target { 636 - LabelerTarget::Endpoint { url, did } => { 637 - assert_eq!(url.as_str(), "https://example.com/labeler"); 638 - assert_eq!(did.map(|d| d.0.clone()), Some("did:plc:xyz".to_string())); 639 - } 640 - _ => panic!("expected Endpoint variant"), 641 - } 642 - } 643 - 644 - #[test] 645 - fn parse_target_endpoint_http_remote_rejected() { 646 - let err = parse_target("http://evil.example", None).expect_err("should reject http"); 647 - assert!(err.message.contains("HTTP")); 648 - assert!(err.message.contains("local")); 649 - } 650 - 651 - #[test] 652 - fn parse_target_endpoint_http_local_accepted() { 653 - // Each of these hostnames is classified as local by 654 - // `is_local_labeler_hostname`, so plaintext HTTP is allowed. 655 - let cases = &[ 656 - "http://localhost:8080", 657 - "http://127.0.0.1:5000", 658 - "http://127.1.2.3/", 659 - "http://[::1]:8080/", 660 - "http://10.0.0.1/", 661 - "http://192.168.1.100:8080", 662 - "http://172.16.0.1/", 663 - "http://mybox.local:8080", 664 - ]; 665 - for raw in cases { 666 - let target = parse_target(raw, None) 667 - .unwrap_or_else(|e| panic!("expected {raw} to parse, got: {}", e.message)); 668 - match target { 669 - LabelerTarget::Endpoint { url, did } => { 670 - assert_eq!( 671 - url.as_str().trim_end_matches('/'), 672 - raw.trim_end_matches('/') 673 - ); 674 - assert!(did.is_none()); 675 - } 676 - _ => panic!("expected Endpoint variant for {raw}"), 677 - } 678 - } 679 - } 680 - 681 - #[test] 682 - fn parse_target_unrecognised() { 683 - let err = parse_target("not a handle or did", None).expect_err("should fail"); 684 - assert!(err.message.contains("Unrecognized target")); 685 - } 686 - 687 - #[test] 688 - fn parse_target_did_with_conflicting_flag() { 689 - let err = parse_target("did:plc:abc", Some("did:web:example.com")) 690 - .expect_err("should reject ambiguous target"); 691 - assert!(err.message.contains("Ambiguous")); 692 - } 693 - }
src/commands/test/labeler/subscription.rs src/commands/test/labeler/pipeline/subscription.rs
+324
src/commands/test/labeler/target.rs
··· 1 + use std::fmt; 2 + 3 + use miette::Diagnostic; 4 + use thiserror::Error; 5 + use url::Url; 6 + 7 + use crate::common::identity::{Did, is_local_labeler_hostname}; 8 + 9 + /// A labeler target: either a resolvable identifier (handle or DID) or a raw endpoint URL. 10 + #[derive(Debug, Clone)] 11 + pub enum LabelerTarget { 12 + /// A handle or DID that can be resolved. 13 + Identified { 14 + /// The handle or DID to resolve. 15 + identifier: AtIdentifier, 16 + /// An optional explicit DID override (for cross-checking). 17 + explicit_did: Option<Did>, 18 + }, 19 + /// A raw HTTP endpoint, optionally with a DID for identity checks. 20 + Endpoint { 21 + /// The endpoint URL. 22 + url: Url, 23 + /// An optional DID to cross-check against the endpoint. 24 + did: Option<Did>, 25 + }, 26 + } 27 + 28 + /// An ATProto identifier: a handle or a DID. 29 + #[derive(Debug, Clone)] 30 + pub enum AtIdentifier { 31 + /// An ATProto handle (e.g., `alice.bsky.social`). 32 + Handle(String), 33 + /// A decentralized identifier (e.g., `did:plc:...` or `did:web:...`). 34 + Did(Did), 35 + } 36 + 37 + /// Parse a labeler target from a string and optional explicit DID. 38 + /// 39 + /// Returns a `LabelerTarget` on success, or a `TargetParseError` on failure. 40 + /// 41 + /// Rules: 42 + /// - If `raw` starts with `did:`, parse as a DID. If `explicit_did` is also provided, return an error. 43 + /// - If `raw` starts with `https://`, parse as a URL. `explicit_did` is carried as an optional DID. 44 + /// - If `raw` starts with `http://`, return an error pointing the user to HTTPS. 45 + /// - If `raw` contains a dot and matches handle grammar, treat as a handle. `explicit_did` is carried. 46 + /// - Otherwise, return an unrecognized target error. 47 + pub fn parse(raw: &str, explicit_did: Option<&str>) -> Result<LabelerTarget, TargetParseError> { 48 + // Check for DID. 49 + if raw.starts_with("did:") { 50 + if let Some(ed) = explicit_did { 51 + return Err(TargetParseError::ambiguous_did(raw, ed)); 52 + } 53 + return Ok(LabelerTarget::Identified { 54 + identifier: AtIdentifier::Did(Did(raw.to_string())), 55 + explicit_did: None, 56 + }); 57 + } 58 + 59 + // Check for HTTPS URL. 60 + if raw.starts_with("https://") { 61 + let url = Url::parse(raw) 62 + .map_err(|e| TargetParseError::new(format!("Invalid URL '{raw}': {e}")))?; 63 + return Ok(LabelerTarget::Endpoint { 64 + url, 65 + did: explicit_did.map(|d| Did(d.to_string())), 66 + }); 67 + } 68 + 69 + // Check for HTTP URL. Local hostnames (loopback, RFC 1918, .local, mDNS) 70 + // are accepted so developers can target a labeler running on their 71 + // machine or LAN. Remote HTTP is still rejected to guard against 72 + // accidental plaintext traffic to a production labeler. 73 + if raw.starts_with("http://") { 74 + let url = Url::parse(raw) 75 + .map_err(|e| TargetParseError::new(format!("Invalid URL '{raw}': {e}")))?; 76 + if is_local_labeler_hostname(&url) { 77 + return Ok(LabelerTarget::Endpoint { 78 + url, 79 + did: explicit_did.map(|d| Did(d.to_string())), 80 + }); 81 + } 82 + return Err(TargetParseError::http_not_supported(raw)); 83 + } 84 + 85 + // Check for handle. 86 + if is_valid_handle(raw) { 87 + return Ok(LabelerTarget::Identified { 88 + identifier: AtIdentifier::Handle(raw.to_string()), 89 + explicit_did: explicit_did.map(|d| Did(d.to_string())), 90 + }); 91 + } 92 + 93 + // Unrecognized target. 94 + Err(TargetParseError::unrecognized_target(raw)) 95 + } 96 + 97 + impl fmt::Display for LabelerTarget { 98 + /// Format a target for display in the report header. 99 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 100 + match self { 101 + LabelerTarget::Identified { 102 + identifier, 103 + explicit_did, 104 + } => { 105 + let id_str = match identifier { 106 + AtIdentifier::Handle(h) => h.clone(), 107 + AtIdentifier::Did(d) => d.0.clone(), 108 + }; 109 + if explicit_did.is_some() { 110 + write!(f, "{id_str} (with explicit DID)") 111 + } else { 112 + id_str.fmt(f) 113 + } 114 + } 115 + LabelerTarget::Endpoint { url, did } => { 116 + if did.is_some() { 117 + write!(f, "{url} (with explicit DID)") 118 + } else { 119 + url.fmt(f) 120 + } 121 + } 122 + } 123 + } 124 + } 125 + 126 + /// Check if a string is a valid ATProto handle. 127 + /// 128 + /// A valid handle: 129 + /// - Contains at least one dot. 130 + /// - Contains only alphanumeric characters, hyphens, and dots. 131 + /// - Does not start or end with a hyphen or dot. 132 + /// - Has no empty segments (no consecutive dots or leading/trailing dots). 133 + fn is_valid_handle(s: &str) -> bool { 134 + if !s.contains('.') { 135 + return false; 136 + } 137 + 138 + // Check for empty string or leading/trailing special chars. 139 + if s.is_empty() 140 + || s.starts_with('-') 141 + || s.starts_with('.') 142 + || s.ends_with('-') 143 + || s.ends_with('.') 144 + { 145 + return false; 146 + } 147 + 148 + // Check all characters are alphanumeric, hyphen, or dot. 149 + for c in s.chars() { 150 + if !c.is_ascii_alphanumeric() && c != '-' && c != '.' { 151 + return false; 152 + } 153 + } 154 + 155 + // Check no empty segments (no consecutive dots). 156 + if s.contains("..") { 157 + return false; 158 + } 159 + 160 + true 161 + } 162 + 163 + /// Error from target parsing. 164 + #[derive(Debug, Error, Diagnostic)] 165 + #[error("{message}")] 166 + pub struct TargetParseError { 167 + /// The error message. 168 + pub message: String, 169 + } 170 + 171 + impl TargetParseError { 172 + fn new(message: impl Into<String>) -> Self { 173 + Self { 174 + message: message.into(), 175 + } 176 + } 177 + 178 + fn unrecognized_target(raw: &str) -> Self { 179 + Self::new(format!( 180 + "Unrecognized target '{raw}'. Expected one of:\n - ATProto handle (e.g., alice.bsky.social)\n - DID (e.g., did:plc:abc123 or did:web:example.com)\n - HTTPS endpoint URL (e.g., https://labeler.example.com)\n - HTTP endpoint URL with a local hostname (e.g., http://localhost:8080)" 181 + )) 182 + } 183 + 184 + fn http_not_supported(raw: &str) -> Self { 185 + Self::new(format!( 186 + "HTTP endpoint '{raw}' is not supported for remote hosts. Use HTTPS, or point at a local labeler (localhost / 127.0.0.0/8 / RFC 1918 / .local) to allow plaintext HTTP." 187 + )) 188 + } 189 + 190 + fn ambiguous_did(raw: &str, explicit: &str) -> Self { 191 + Self::new(format!( 192 + "Ambiguous target specification: target '{raw}' is already a DID, but --did {explicit} was also provided. Please use only one." 193 + )) 194 + } 195 + } 196 + 197 + #[cfg(test)] 198 + mod tests { 199 + use super::*; 200 + 201 + #[test] 202 + fn parse_target_handle() { 203 + let target = parse("alice.bsky.social", None).expect("should parse"); 204 + match target { 205 + LabelerTarget::Identified { 206 + identifier, 207 + explicit_did, 208 + } => { 209 + assert!( 210 + matches!(identifier, AtIdentifier::Handle(ref h) if h == "alice.bsky.social") 211 + ); 212 + assert!(explicit_did.is_none()); 213 + } 214 + _ => panic!("expected Identified variant"), 215 + } 216 + } 217 + 218 + #[test] 219 + fn parse_target_did_plc() { 220 + let target = parse("did:plc:abc123", None).expect("should parse"); 221 + match target { 222 + LabelerTarget::Identified { 223 + identifier, 224 + explicit_did, 225 + } => { 226 + assert!(matches!(identifier, AtIdentifier::Did(ref d) if d.0 == "did:plc:abc123")); 227 + assert!(explicit_did.is_none()); 228 + } 229 + _ => panic!("expected Identified variant"), 230 + } 231 + } 232 + 233 + #[test] 234 + fn parse_target_did_web() { 235 + let target = parse("did:web:example.com", None).expect("should parse"); 236 + match target { 237 + LabelerTarget::Identified { 238 + identifier, 239 + explicit_did, 240 + } => { 241 + assert!( 242 + matches!(identifier, AtIdentifier::Did(ref d) if d.0 == "did:web:example.com") 243 + ); 244 + assert!(explicit_did.is_none()); 245 + } 246 + _ => panic!("expected Identified variant"), 247 + } 248 + } 249 + 250 + #[test] 251 + fn parse_target_endpoint_https() { 252 + let target = parse("https://example.com/labeler", None).expect("should parse"); 253 + match target { 254 + LabelerTarget::Endpoint { url, did } => { 255 + assert_eq!(url.as_str(), "https://example.com/labeler"); 256 + assert!(did.is_none()); 257 + } 258 + _ => panic!("expected Endpoint variant"), 259 + } 260 + } 261 + 262 + #[test] 263 + fn parse_target_endpoint_with_explicit_did() { 264 + let target = 265 + parse("https://example.com/labeler", Some("did:plc:xyz")).expect("should parse"); 266 + match target { 267 + LabelerTarget::Endpoint { url, did } => { 268 + assert_eq!(url.as_str(), "https://example.com/labeler"); 269 + assert_eq!(did.map(|d| d.0.clone()), Some("did:plc:xyz".to_string())); 270 + } 271 + _ => panic!("expected Endpoint variant"), 272 + } 273 + } 274 + 275 + #[test] 276 + fn parse_target_endpoint_http_remote_rejected() { 277 + let err = parse("http://evil.example", None).expect_err("should reject http"); 278 + assert!(err.message.contains("HTTP")); 279 + assert!(err.message.contains("local")); 280 + } 281 + 282 + #[test] 283 + fn parse_target_endpoint_http_local_accepted() { 284 + // Each of these hostnames is classified as local by 285 + // `is_local_labeler_hostname`, so plaintext HTTP is allowed. 286 + let cases = &[ 287 + "http://localhost:8080", 288 + "http://127.0.0.1:5000", 289 + "http://127.1.2.3/", 290 + "http://[::1]:8080/", 291 + "http://10.0.0.1/", 292 + "http://192.168.1.100:8080", 293 + "http://172.16.0.1/", 294 + "http://mybox.local:8080", 295 + ]; 296 + for raw in cases { 297 + let target = parse(raw, None) 298 + .unwrap_or_else(|e| panic!("expected {raw} to parse, got: {}", e.message)); 299 + match target { 300 + LabelerTarget::Endpoint { url, did } => { 301 + assert_eq!( 302 + url.as_str().trim_end_matches('/'), 303 + raw.trim_end_matches('/') 304 + ); 305 + assert!(did.is_none()); 306 + } 307 + _ => panic!("expected Endpoint variant for {raw}"), 308 + } 309 + } 310 + } 311 + 312 + #[test] 313 + fn parse_target_unrecognised() { 314 + let err = parse("not a handle or did", None).expect_err("should fail"); 315 + assert!(err.message.contains("Unrecognized target")); 316 + } 317 + 318 + #[test] 319 + fn parse_target_did_with_conflicting_flag() { 320 + let err = parse("did:plc:abc", Some("did:web:example.com")) 321 + .expect_err("should reject ambiguous target"); 322 + assert!(err.message.contains("Ambiguous")); 323 + } 324 + }
+2 -2
src/common.rs
··· 1 1 //! Cross-feature primitives shared by every `atproto-devtool` subcommand. 2 2 3 - pub mod diagnostics; 3 + pub(crate) mod diagnostics; 4 4 pub mod identity; 5 - pub mod jwt; 5 + pub(crate) mod jwt; 6 6 pub mod oauth; 7 7 pub mod report; 8 8
+6 -7
src/common/diagnostics.rs
··· 2 2 3 3 use std::sync::Arc; 4 4 5 - use miette::{GraphicalTheme, MietteHandlerOpts, NamedSource}; 6 - 7 - // Re-exports so stages can import `LabeledSpan` / `SourceSpan` from a single 8 - // path. 9 - pub use miette::{LabeledSpan, SourceSpan}; 5 + use miette::{GraphicalTheme, MietteHandlerOpts, NamedSource, SourceSpan}; 10 6 11 7 /// Install the miette panic hook and graphical report handler. 12 8 /// ··· 41 37 /// 42 38 /// The bytes are cloned into an `Arc<[u8]>` via miette's constructor, 43 39 /// so callers may drop the original slice after this returns. 44 - pub fn named_source_from_bytes(name: impl AsRef<str>, bytes: &[u8]) -> NamedSource<Arc<[u8]>> { 40 + pub(crate) fn named_source_from_bytes( 41 + name: impl AsRef<str>, 42 + bytes: &[u8], 43 + ) -> NamedSource<Arc<[u8]>> { 45 44 NamedSource::new(name, Arc::<[u8]>::from(bytes)) 46 45 } 47 46 48 47 /// Build a `NamedSource` from a name and a UTF-8 string slice. 49 - pub fn named_source_from_str(name: impl AsRef<str>, text: &str) -> NamedSource<String> { 48 + pub(crate) fn named_source_from_str(name: impl AsRef<str>, text: &str) -> NamedSource<String> { 50 49 NamedSource::new(name, text.to_string()) 51 50 } 52 51
+10 -5
src/common/jwt.rs
··· 12 12 use serde::{Deserialize, Serialize}; 13 13 use thiserror::Error; 14 14 15 - use crate::common::identity::{AnySignature, AnySignatureError, AnySigningKey, AnyVerifyingKey}; 15 + use crate::common::identity::{AnySignatureError, AnySigningKey}; 16 + 17 + #[cfg(test)] 18 + use crate::common::identity::{AnySignature, AnyVerifyingKey}; 16 19 17 20 /// Compact JWS header for atproto service-auth tokens. 18 21 /// ··· 75 78 /// rendered to the user, they must wrap it in a stage-local diagnostic 76 79 /// with a proper `code = "labeler::..."` string. 77 80 #[derive(Debug, Error)] 78 - pub enum JwtError { 81 + pub(crate) enum JwtError { 79 82 /// Compact form was not three `.`-separated base64url segments. 80 83 #[error("malformed compact JWT: expected three segments")] 81 84 MalformedCompact, ··· 126 129 /// 127 130 /// Signs the concatenation `header_b64 + "." + claims_b64` with SHA-256 128 131 /// prehash under the supplied key. Returns the full compact token string. 129 - pub fn encode_compact( 132 + pub(crate) fn encode_compact( 130 133 header: &JwtHeader, 131 134 claims: &JwtClaims, 132 135 signer: &AnySigningKey, ··· 147 150 /// Does NOT verify the signature — use `verify_compact` for that. This helper 148 151 /// is primarily for test round-tripping and for negative-test assertions 149 152 /// (e.g., "the minted token has the expected `alg` header"). 150 - pub fn decode_compact(token: &str) -> Result<(JwtHeader, JwtClaims, Vec<u8>), JwtError> { 153 + #[cfg(test)] 154 + fn decode_compact(token: &str) -> Result<(JwtHeader, JwtClaims, Vec<u8>), JwtError> { 151 155 let parts: Vec<&str> = token.split('.').collect(); 152 156 if parts.len() != 3 { 153 157 return Err(JwtError::MalformedCompact); ··· 191 195 /// Verify a compact JWT against the given verifying key. Does NOT check 192 196 /// claim values (exp/aud/lxm) — that is the labeler's job in production, 193 197 /// or the stage's assertion job in tests. Only verifies the signature. 194 - pub fn verify_compact( 198 + #[cfg(test)] 199 + pub(crate) fn verify_compact( 195 200 token: &str, 196 201 vkey: &AnyVerifyingKey, 197 202 ) -> Result<(JwtHeader, JwtClaims), JwtError> {
+11 -11
src/common/report.rs
··· 24 24 25 25 impl CheckStatus { 26 26 /// Plain-text glyph for this status. 27 - pub fn glyph(self) -> &'static str { 27 + fn glyph(self) -> &'static str { 28 28 match self { 29 29 CheckStatus::Pass => "[OK]", 30 30 CheckStatus::SpecViolation => "[FAIL]", ··· 44 44 /// reachability failures don't blur into spec warnings). 45 45 /// * `Advisory` — bold yellow. 46 46 /// * `Skipped` — dim. 47 - pub fn styled_glyph(self, no_color: bool) -> &'static str { 47 + fn styled_glyph(self, no_color: bool) -> &'static str { 48 48 if no_color { 49 49 return self.glyph(); 50 50 } ··· 96 96 97 97 impl Stage { 98 98 /// Human-readable heading for this stage. 99 - pub const fn label(self) -> &'static str { 99 + const fn label(self) -> &'static str { 100 100 self.0 101 101 } 102 102 ··· 116 116 117 117 /// Summary counts of check results by severity. 118 118 #[derive(Debug, Clone, PartialEq, Eq)] 119 - pub struct SummaryCounts { 120 - pub pass: usize, 121 - pub spec_violation: usize, 122 - pub network_error: usize, 123 - pub advisory: usize, 124 - pub skipped: usize, 119 + struct SummaryCounts { 120 + pass: usize, 121 + spec_violation: usize, 122 + network_error: usize, 123 + advisory: usize, 124 + skipped: usize, 125 125 } 126 126 127 127 impl SummaryCounts { 128 128 /// Count results by severity. 129 - pub fn from_results(results: &[CheckResult]) -> Self { 129 + fn from_results(results: &[CheckResult]) -> Self { 130 130 let mut counts = SummaryCounts { 131 131 pass: 0, 132 132 spec_violation: 0, ··· 236 236 } 237 237 238 238 /// Get summary counts of all results. 239 - pub fn summary_counts(&self) -> SummaryCounts { 239 + fn summary_counts(&self) -> SummaryCounts { 240 240 SummaryCounts::from_results(&self.results) 241 241 } 242 242
+7 -7
tests/common/mod.rs
··· 8 8 #![allow(dead_code)] 9 9 10 10 use async_trait::async_trait; 11 - use atproto_devtool::commands::test::labeler::create_report::{ 12 - CreateReportStageError, CreateReportTee, PdsXrpcClient, RawCreateReportResponse, 13 - RawPdsXrpcResponse, 14 - }; 15 - use atproto_devtool::commands::test::labeler::http::{HttpStageError, RawHttpTee, RawXrpcResponse}; 16 - use atproto_devtool::commands::test::labeler::subscription::{ 17 - FrameStream, SubscriptionStageError, WebSocketClient, 11 + use atproto_devtool::commands::test::labeler::pipeline::{ 12 + create_report::{ 13 + CreateReportStageError, CreateReportTee, PdsXrpcClient, RawCreateReportResponse, 14 + RawPdsXrpcResponse, 15 + }, 16 + http::{HttpStageError, RawHttpTee, RawXrpcResponse}, 17 + subscription::{FrameStream, SubscriptionStageError, WebSocketClient}, 18 18 }; 19 19 use atproto_devtool::commands::test::oauth::client::pipeline::jwks::{ 20 20 JwksFetchError, JwksFetchResponse, JwksFetcher,
+1 -1
tests/common_fakes.rs
··· 2 2 3 3 mod common; 4 4 5 - use atproto_devtool::commands::test::labeler::create_report::CreateReportTee; 5 + use atproto_devtool::commands::test::labeler::pipeline::create_report::CreateReportTee; 6 6 use common::*; 7 7 use reqwest::StatusCode; 8 8
+29 -17
tests/labeler_endtoend.rs
··· 3 3 mod common; 4 4 5 5 use async_trait::async_trait; 6 - use atproto_devtool::commands::test::labeler::create_report::self_mint::SelfMintCurve; 7 - use atproto_devtool::commands::test::labeler::crypto::canonicalize_label_for_signing; 8 - use atproto_devtool::commands::test::labeler::pipeline::{ 9 - CreateReportTeeKind, HttpTee, LabelerOptions, parse_target, run_pipeline, 6 + use atproto_devtool::{ 7 + commands::test::labeler::{ 8 + pipeline::{ 9 + CreateReportTeeKind, HttpTee, LabelerOptions, create_report::self_mint::SelfMintCurve, 10 + crypto::canonicalize_label_for_signing, run_pipeline, 11 + }, 12 + target, 13 + }, 14 + common::identity::{DnsResolver, HttpClient, IdentityError}, 10 15 }; 11 - use atproto_devtool::common::identity::{DnsResolver, HttpClient, IdentityError}; 12 16 use atrium_api::com::atproto::label::defs::{Label, LabelData}; 13 17 use atrium_api::com::atproto::label::query_labels::{Output, OutputData}; 14 18 use atrium_api::types::string::Datetime; ··· 268 272 labeler_record_json.to_vec(), 269 273 ); 270 274 271 - let target = parse_target(did, None).expect("parse failed"); 275 + let target = target::parse(did, None).expect("parse failed"); 272 276 let fake_tee = common::FakeRawHttpTee::new(); 273 277 fake_tee.add_response(None, 200, labels_response); 274 278 let fake_ws = common::FakeWebSocketClient::empty(); ··· 333 337 serde_json::to_vec(&did_json).unwrap(), 334 338 ); 335 339 336 - let target = parse_target("did:plc:test123456789abcdefghijklmnop", None).expect("parse failed"); 340 + let target = 341 + target::parse("did:plc:test123456789abcdefghijklmnop", None).expect("parse failed"); 337 342 let fake_tee = common::FakeRawHttpTee::new(); 338 343 let fake_ws = common::FakeWebSocketClient::empty(); 339 344 let fake_report_tee = common::FakeCreateReportTee::new(); ··· 388 393 labeler_record_json.to_vec(), 389 394 ); 390 395 391 - let target = parse_target("did:plc:test123456789abcdefghijklmnop", None).expect("parse failed"); 396 + let target = 397 + target::parse("did:plc:test123456789abcdefghijklmnop", None).expect("parse failed"); 392 398 let fake_tee = common::FakeRawHttpTee::new(); 393 399 fake_tee.add_response(None, 200, b"{malformed json".to_vec()); 394 400 let fake_ws = common::FakeWebSocketClient::empty(); ··· 441 447 labeler_record_json.to_vec(), 442 448 ); 443 449 444 - let target = parse_target("did:plc:test123456789abcdefghijklmnop", None).expect("parse failed"); 450 + let target = 451 + target::parse("did:plc:test123456789abcdefghijklmnop", None).expect("parse failed"); 445 452 let fake_tee = common::FakeRawHttpTee::new(); 446 453 fake_tee.add_response(None, 200, br#"{"cursor": null, "labels": []}"#.to_vec()); 447 454 ··· 545 552 serde_json::to_vec(&plc_audit_log).expect("serialize plc audit log"), 546 553 ); 547 554 548 - let target = parse_target(did, None).expect("parse failed"); 555 + let target = target::parse(did, None).expect("parse failed"); 549 556 let fake_tee = common::FakeRawHttpTee::new(); 550 557 fake_tee.add_response(None, 200, labels_response); 551 558 let fake_ws = common::FakeWebSocketClient::empty(); ··· 638 645 serde_json::to_vec(&plc_audit_log).unwrap(), 639 646 ); 640 647 641 - let target = parse_target("did:plc:test123456789abcdefghijklmnop", None).expect("parse failed"); 648 + let target = 649 + target::parse("did:plc:test123456789abcdefghijklmnop", None).expect("parse failed"); 642 650 let fake_tee = common::FakeRawHttpTee::new(); 643 651 fake_tee.add_response(None, 200, labels_response); 644 652 let fake_ws = common::FakeWebSocketClient::empty(); ··· 728 736 labeler_record_json.to_vec(), 729 737 ); 730 738 731 - let target = parse_target("did:web:web-labeler.example", None).expect("parse failed"); 739 + let target = target::parse("did:web:web-labeler.example", None).expect("parse failed"); 732 740 let fake_tee = common::FakeRawHttpTee::new(); 733 741 fake_tee.add_response(None, 200, labels_response); 734 742 let fake_ws = common::FakeWebSocketClient::empty(); ··· 783 791 labeler_record_json.to_vec(), 784 792 ); 785 793 786 - let target = parse_target("did:plc:test123456789abcdefghijklmnop", None).expect("parse failed"); 794 + let target = 795 + target::parse("did:plc:test123456789abcdefghijklmnop", None).expect("parse failed"); 787 796 let fake_tee = common::FakeRawHttpTee::new(); 788 797 fake_tee.add_response(None, 200, labels_response); 789 798 let fake_ws = common::FakeWebSocketClient::empty(); ··· 863 872 "https://plc.directory/did:plc:test123456789abcdefghijklmnop/log/audit", 864 873 ); 865 874 866 - let target = parse_target("did:plc:test123456789abcdefghijklmnop", None).expect("parse failed"); 875 + let target = 876 + target::parse("did:plc:test123456789abcdefghijklmnop", None).expect("parse failed"); 867 877 let fake_tee = common::FakeRawHttpTee::new(); 868 878 fake_tee.add_response(None, 200, labels_response); 869 879 let fake_ws = common::FakeWebSocketClient::empty(); ··· 919 929 labeler_record_json.to_vec(), 920 930 ); 921 931 922 - let target = parse_target("did:plc:test123456789abcdefghijklmnop", None).expect("parse failed"); 932 + let target = 933 + target::parse("did:plc:test123456789abcdefghijklmnop", None).expect("parse failed"); 923 934 let fake_tee = common::FakeRawHttpTee::new(); 924 935 fake_tee.add_response(None, 200, br#"{"cursor": null, "labels": []}"#.to_vec()); 925 936 let fake_ws = common::FakeWebSocketClient::empty(); ··· 979 990 labeler_record_json.to_vec(), 980 991 ); 981 992 982 - let target = parse_target("did:plc:test123456789abcdefghijklmnop", None).expect("parse failed"); 993 + let target = 994 + target::parse("did:plc:test123456789abcdefghijklmnop", None).expect("parse failed"); 983 995 let fake_tee = common::FakeRawHttpTee::new(); 984 996 fake_tee.set_transport_error(); // Force HTTP stage transport error. 985 997 let fake_ws = common::FakeWebSocketClient::empty(); ··· 1024 1036 let http = FakeHttpClient::new(); 1025 1037 let dns = FakeDnsResolver::new(); 1026 1038 1027 - let target = parse_target("https://labeler.example.com", None).expect("parse failed"); 1039 + let target = target::parse("https://labeler.example.com", None).expect("parse failed"); 1028 1040 let fake_tee = common::FakeRawHttpTee::new(); 1029 1041 fake_tee.add_response(None, 200, br#"{"cursor": null, "labels": []}"#.to_vec()); 1030 1042 let fake_ws = common::FakeWebSocketClient::empty();
+6 -6
tests/labeler_http.rs
··· 2 2 3 3 mod common; 4 4 5 - use atproto_devtool::commands::test::labeler::http::run; 5 + use atproto_devtool::commands::test::labeler::pipeline::http; 6 6 use common::FakeRawHttpTee; 7 7 8 8 /// Helper to render a report to a string for snapshot testing. ··· 27 27 tee.add_response(None, 200, first_page); 28 28 tee.add_response(Some("cursor1"), 200, second_page); 29 29 30 - let output = run(&tee).await; 30 + let output = http::run(&tee).await; 31 31 32 32 // Build a minimal report for snapshot testing. 33 33 let mut report = atproto_devtool::common::report::LabelerReport::new( ··· 55 55 56 56 tee.add_response(None, 200, empty_page); 57 57 58 - let output = run(&tee).await; 58 + let output = http::run(&tee).await; 59 59 60 60 let mut report = atproto_devtool::common::report::LabelerReport::new( 61 61 atproto_devtool::common::report::ReportHeader { ··· 82 82 83 83 tee.add_response(None, 200, malformed.clone()); 84 84 85 - let output = run(&tee).await; 85 + let output = http::run(&tee).await; 86 86 87 87 let mut report = atproto_devtool::common::report::LabelerReport::new( 88 88 atproto_devtool::common::report::ReportHeader { ··· 114 114 tee.add_response(None, 200, first_page); 115 115 tee.add_response(Some("cursor1"), 200, second_page); 116 116 117 - let output = run(&tee).await; 117 + let output = http::run(&tee).await; 118 118 119 119 let mut report = atproto_devtool::common::report::LabelerReport::new( 120 120 atproto_devtool::common::report::ReportHeader { ··· 139 139 let tee = FakeRawHttpTee::new(); 140 140 tee.set_transport_error(); 141 141 142 - let output = run(&tee).await; 142 + let output = http::run(&tee).await; 143 143 144 144 let mut report = atproto_devtool::common::report::LabelerReport::new( 145 145 atproto_devtool::common::report::ReportHeader {
+22 -16
tests/labeler_identity.rs
··· 2 2 3 3 mod common; 4 4 5 - use atproto_devtool::commands::test::labeler::create_report::self_mint::SelfMintCurve; 6 - use atproto_devtool::commands::test::labeler::pipeline::{ 7 - CreateReportTeeKind, HttpTee, LabelerOptions, parse_target, run_pipeline, 5 + use atproto_devtool::commands::test::labeler::{ 6 + pipeline::{ 7 + CreateReportTeeKind, HttpTee, LabelerOptions, create_report::self_mint::SelfMintCurve, 8 + run_pipeline, 9 + }, 10 + target, 8 11 }; 9 12 use url::Url; 10 13 ··· 60 63 plc_audit_log, 61 64 ); 62 65 63 - let target = parse_target("did:plc:test123456789abcdefghijklmnop", None).expect("parse failed"); 66 + let target = 67 + target::parse("did:plc:test123456789abcdefghijklmnop", None).expect("parse failed"); 64 68 let fake_tee = common::FakeRawHttpTee::new(); 65 69 fake_tee.add_response(None, 200, healthy_labels_response()); 66 70 let fake_ws = common::FakeWebSocketClient::empty(); ··· 96 100 let http = common::FakeHttpClient::new(); 97 101 let dns = common::FakeDnsResolver::new(); 98 102 99 - let target = parse_target("https://example.com/labeler", None).expect("parse failed"); 103 + let target = target::parse("https://example.com/labeler", None).expect("parse failed"); 100 104 let fake_tee = common::FakeRawHttpTee::new(); 101 105 let empty_response = br#"{"cursor":null,"labels":[]}"#.to_vec(); 102 106 fake_tee.add_response(None, 200, empty_response); ··· 156 160 labeler_record_json, 157 161 ); 158 162 159 - let target = parse_target("alice.example", None).expect("parse failed"); 163 + let target = target::parse("alice.example", None).expect("parse failed"); 160 164 let fake_tee = common::FakeRawHttpTee::new(); 161 165 let empty_response = br#"{"cursor":null,"labels":[]}"#.to_vec(); 162 166 fake_tee.add_response(None, 200, empty_response); ··· 220 224 plc_audit_log, 221 225 ); 222 226 223 - let target = parse_target("did:plc:test123456789abcdefghijklmnop", None).expect("parse failed"); 227 + let target = 228 + target::parse("did:plc:test123456789abcdefghijklmnop", None).expect("parse failed"); 224 229 let fake_tee = common::FakeRawHttpTee::new(); 225 230 fake_tee.add_response(None, 200, healthy_labels_response()); 226 231 let fake_ws = common::FakeWebSocketClient::empty(); ··· 272 277 labeler_record_json, 273 278 ); 274 279 275 - let target = parse_target("did:web:web-labeler.example", None).expect("parse failed"); 280 + let target = target::parse("did:web:web-labeler.example", None).expect("parse failed"); 276 281 let fake_tee = common::FakeRawHttpTee::new(); 277 282 let empty_response = br#"{"cursor":null,"labels":[]}"#.to_vec(); 278 283 fake_tee.add_response(None, 200, empty_response); ··· 330 335 // Also add transport error for the .well-known fallback. 331 336 http.add_transport_error(&Url::parse("https://alice.test/.well-known/atproto-did").unwrap()); 332 337 333 - let target = parse_target("alice.test", None).expect("parse failed"); 338 + let target = target::parse("alice.test", None).expect("parse failed"); 334 339 let fake_tee = common::FakeRawHttpTee::new(); 335 340 let empty_response = br#"{"cursor":null,"labels":[]}"#.to_vec(); 336 341 fake_tee.add_response(None, 200, empty_response); ··· 382 387 b"Not found".to_vec(), 383 388 ); 384 389 385 - let target = parse_target("did:plc:test123456789abcdefghijklmnop", None).expect("parse failed"); 390 + let target = 391 + target::parse("did:plc:test123456789abcdefghijklmnop", None).expect("parse failed"); 386 392 let fake_tee = common::FakeRawHttpTee::new(); 387 393 fake_tee.add_response(None, 200, healthy_labels_response()); 388 394 let fake_ws = common::FakeWebSocketClient::empty(); ··· 435 441 ); 436 442 437 443 let target = 438 - parse_target("did:plc:missing_service_test_123456789", None).expect("parse failed"); 444 + target::parse("did:plc:missing_service_test_123456789", None).expect("parse failed"); 439 445 let fake_tee = common::FakeRawHttpTee::new(); 440 446 let empty_response = br#"{"cursor":null,"labels":[]}"#.to_vec(); 441 447 fake_tee.add_response(None, 200, empty_response); ··· 492 498 ); 493 499 494 500 let target = 495 - parse_target("did:plc:missing_signing_key_test_12345", None).expect("parse failed"); 501 + target::parse("did:plc:missing_signing_key_test_12345", None).expect("parse failed"); 496 502 let fake_tee = common::FakeRawHttpTee::new(); 497 503 let empty_response = br#"{"cursor":null,"labels":[]}"#.to_vec(); 498 504 fake_tee.add_response(None, 200, empty_response); ··· 547 553 ); 548 554 549 555 let target = 550 - parse_target("did:plc:non_https_endpoint_test_123456", None).expect("parse failed"); 556 + target::parse("did:plc:non_https_endpoint_test_123456", None).expect("parse failed"); 551 557 let fake_tee = common::FakeRawHttpTee::new(); 552 558 let empty_response = br#"{"cursor":null,"labels":[]}"#.to_vec(); 553 559 fake_tee.add_response(None, 200, empty_response); ··· 602 608 ); 603 609 604 610 let target = 605 - parse_target("did:plc:empty_policies_test_123456789ab", None).expect("parse failed"); 611 + target::parse("did:plc:empty_policies_test_123456789ab", None).expect("parse failed"); 606 612 let fake_tee = common::FakeRawHttpTee::new(); 607 613 let empty_response = br#"{"cursor":null,"labels":[]}"#.to_vec(); 608 614 fake_tee.add_response(None, 200, empty_response); ··· 657 663 ); 658 664 659 665 // The target is the mismatched URL; the DID says a different endpoint. 660 - let target = parse_target( 666 + let target = target::parse( 661 667 "https://other-labeler.example/", 662 668 Some("did:plc:endpoint_mismatch_test_123456789"), 663 669 ) ··· 727 733 ); 728 734 729 735 // Target is a local labeler copy; the DID document points somewhere else. 730 - let target = parse_target( 736 + let target = target::parse( 731 737 "http://localhost:8080", 732 738 Some("did:plc:endpoint_mismatch_test_123456789"), 733 739 )
+19 -14
tests/labeler_report.rs
··· 11 11 use std::sync::Arc; 12 12 use std::time::Duration; 13 13 14 - use atproto_devtool::commands::test::labeler::create_report; 15 - use atproto_devtool::commands::test::labeler::create_report::Check; 16 - use atproto_devtool::commands::test::labeler::create_report::self_mint::{ 17 - SelfMintCurve, SelfMintSigner, 18 - }; 19 - use atproto_devtool::commands::test::labeler::identity::IdentityFacts; 20 - use atproto_devtool::commands::test::labeler::pipeline::{ 21 - CreateReportTeeKind, HttpTee, LabelerOptions, parse_target, run_pipeline, 22 - }; 23 - use atproto_devtool::common::identity::{Did, DnsResolver, HttpClient, IdentityError}; 24 - use atproto_devtool::common::report::{ 25 - CheckResult, CheckStatus, LabelerReport, RenderConfig, ReportHeader, 14 + use atproto_devtool::{ 15 + commands::test::labeler::{ 16 + pipeline::{ 17 + CreateReportTeeKind, HttpTee, LabelerOptions, 18 + create_report::{ 19 + self, 20 + self_mint::{SelfMintCurve, SelfMintSigner}, 21 + }, 22 + identity::IdentityFacts, 23 + run_pipeline, 24 + }, 25 + target, 26 + }, 27 + common::{ 28 + identity::{Did, DnsResolver, HttpClient, IdentityError}, 29 + report::{CheckResult, CheckStatus, LabelerReport, RenderConfig, ReportHeader}, 30 + }, 26 31 }; 27 32 28 33 use atrium_api::app::bsky::labeler::defs::LabelerPolicies; ··· 478 483 let fake_create_report_tee = common::FakeCreateReportTee::new(); 479 484 480 485 // Use endpoint target (no identity stage needed). 481 - let target = parse_target("https://labeler.example.com", None).expect("parse endpoint target"); 486 + let target = target::parse("https://labeler.example.com", None).expect("parse endpoint target"); 482 487 483 488 let opts = LabelerOptions { 484 489 http: &StubHttpClient, ··· 1522 1527 "AC7.1 failed: expected 10 rows with commit={commit}, force={force}, pds={pds}" 1523 1528 ); 1524 1529 // Verify Check::ORDER is respected. 1525 - let expected_ids: Vec<_> = Check::ORDER.iter().map(|c| c.id()).collect(); 1530 + let expected_ids: Vec<_> = create_report::Check::ORDER.iter().map(|c| c.id()).collect(); 1526 1531 let actual_ids: Vec<_> = results.iter().map(|r| r.id).collect(); 1527 1532 assert_eq!(actual_ids, expected_ids, "Check order mismatch"); 1528 1533 }
+21 -14
tests/labeler_subscription.rs
··· 7 7 8 8 mod common; 9 9 10 - use atproto_devtool::commands::test::labeler::create_report::self_mint::SelfMintCurve; 11 - use atproto_devtool::commands::test::labeler::pipeline::{ 12 - CreateReportTeeKind, HttpTee, LabelerOptions, parse_target, run_pipeline, 13 - }; 14 - use atproto_devtool::commands::test::labeler::subscription::{FrameHeader, SubscribeLabelsPayload}; 15 - use atproto_devtool::common::identity::{DnsResolver, HttpClient, IdentityError}; 16 10 use std::collections::HashMap; 17 11 use std::sync::{Arc, Mutex}; 18 12 use std::time::Duration; 13 + 14 + use atproto_devtool::{ 15 + commands::test::labeler::{ 16 + pipeline::{ 17 + CreateReportTeeKind, HttpTee, LabelerOptions, 18 + create_report::self_mint::SelfMintCurve, 19 + run_pipeline, 20 + subscription::{FrameHeader, SubscribeLabelsPayload}, 21 + }, 22 + target, 23 + }, 24 + common::identity::{DnsResolver, HttpClient, IdentityError}, 25 + }; 19 26 use url::Url; 20 27 21 28 /// Type alias for the response map in FakeHttpClient. ··· 259 266 let http = FakeHttpClient::new(); 260 267 let dns = FakeDnsResolver::new(); 261 268 let fake_tee = make_passing_http_tee(); 262 - let target = parse_target("https://example.com/labeler", None).expect("parse failed"); 269 + let target = target::parse("https://example.com/labeler", None).expect("parse failed"); 263 270 264 271 let opts = LabelerOptions { 265 272 http: &http, ··· 318 325 let http = FakeHttpClient::new(); 319 326 let dns = FakeDnsResolver::new(); 320 327 let fake_tee = make_passing_http_tee(); 321 - let target = parse_target("https://example.com/labeler", None).expect("parse failed"); 328 + let target = target::parse("https://example.com/labeler", None).expect("parse failed"); 322 329 323 330 let opts = LabelerOptions { 324 331 http: &http, ··· 363 370 let http = FakeHttpClient::new(); 364 371 let dns = FakeDnsResolver::new(); 365 372 let fake_tee = make_passing_http_tee(); 366 - let target = parse_target("https://example.com/labeler", None).expect("parse failed"); 373 + let target = target::parse("https://example.com/labeler", None).expect("parse failed"); 367 374 368 375 let opts = LabelerOptions { 369 376 http: &http, ··· 419 426 let http = FakeHttpClient::new(); 420 427 let dns = FakeDnsResolver::new(); 421 428 let fake_tee = make_passing_http_tee(); 422 - let target = parse_target("https://example.com/labeler", None).expect("parse failed"); 429 + let target = target::parse("https://example.com/labeler", None).expect("parse failed"); 423 430 424 431 let opts = LabelerOptions { 425 432 http: &http, ··· 476 483 let http = FakeHttpClient::new(); 477 484 let dns = FakeDnsResolver::new(); 478 485 let fake_tee = make_passing_http_tee(); 479 - let target = parse_target("https://example.com/labeler", None).expect("parse failed"); 486 + let target = target::parse("https://example.com/labeler", None).expect("parse failed"); 480 487 481 488 let opts = LabelerOptions { 482 489 http: &http, ··· 520 527 let http = FakeHttpClient::new(); 521 528 let dns = FakeDnsResolver::new(); 522 529 let fake_tee = make_passing_http_tee(); 523 - let target = parse_target("https://example.com/labeler", None).expect("parse failed"); 530 + let target = target::parse("https://example.com/labeler", None).expect("parse failed"); 524 531 525 532 let opts = LabelerOptions { 526 533 http: &http, ··· 576 583 let http = FakeHttpClient::new(); 577 584 let dns = FakeDnsResolver::new(); 578 585 let fake_tee = make_passing_http_tee(); 579 - let target = parse_target("https://example.com/labeler", None).expect("parse failed"); 586 + let target = target::parse("https://example.com/labeler", None).expect("parse failed"); 580 587 581 588 let opts = LabelerOptions { 582 589 http: &http, ··· 643 650 let http = FakeHttpClient::new(); 644 651 let dns = FakeDnsResolver::new(); 645 652 let fake_tee = make_passing_http_tee(); 646 - let target = parse_target("https://example.com/labeler", None).expect("parse failed"); 653 + let target = target::parse("https://example.com/labeler", None).expect("parse failed"); 647 654 648 655 let opts = LabelerOptions { 649 656 http: &http,