Skip to main content

atproto_devtool/commands/test/
labeler.rs

1//! `atproto-devtool test labeler <target>` command.
2
3pub mod create_report;
4pub mod crypto;
5pub mod http;
6pub mod identity;
7pub mod pipeline;
8pub mod report;
9pub mod subscription;
10
11use std::io;
12use std::process::ExitCode;
13use std::time::Duration;
14
15use clap::Args;
16use miette::Report;
17
18use crate::commands::test::labeler::create_report::self_mint::{SelfMintCurve, SelfMintSigner};
19use crate::common::{
20    APP_USER_AGENT,
21    identity::{Did, RealDnsResolver, RealHttpClient, is_local_labeler_hostname},
22};
23use pipeline::{LabelerOptions, parse_target, run_pipeline};
24use report::RenderConfig;
25
26/// Run the labeler conformance suite against a handle, DID, or endpoint URL.
27#[derive(Debug, Args)]
28pub struct LabelerCmd {
29    /// Handle (`alice.example`), DID (`did:plc:...` / `did:web:...`), or labeler endpoint URL.
30    pub target: String,
31
32    /// Explicit DID override. Required (and combined with the target URL) when
33    /// `target` is a raw endpoint URL and you want identity/crypto checks to run.
34    #[arg(long)]
35    pub did: Option<String>,
36
37    /// Per-connection time budget for the subscription-layer checks.
38    ///
39    /// Minimum 1 second; values below 1 second are rejected at parse time.
40    #[arg(
41        long,
42        default_value = "5s",
43        value_parser = parse_subscribe_timeout,
44    )]
45    pub subscribe_timeout: Duration,
46
47    /// Whether to suppress colored output.
48    #[arg(long)]
49    pub no_color: bool,
50
51    /// Whether to emit verbose diagnostics.
52    #[arg(long)]
53    pub verbose: bool,
54
55    /// Commit: opt in to actually POSTing report bodies to the labeler and
56    /// assert reporting conformance (missing `LabelerPolicies` becomes a
57    /// SpecViolation rather than a stage-skip).
58    #[arg(long)]
59    pub commit_report: bool,
60
61    /// Force self-mint checks to run even when the labeler endpoint is
62    /// classified as non-local by the hostname heuristic. Use when
63    /// running against a LAN-reachable labeler that the heuristic misses.
64    #[arg(long)]
65    pub force_self_mint: bool,
66
67    /// Curve to use for self-mint JWTs.
68    #[arg(long, value_enum, default_value_t = SelfMintCurve::default())]
69    pub self_mint_curve: SelfMintCurve,
70
71    /// Override the default computed subject DID for committing checks.
72    /// Passed through to `self_mint_accepted`, `pds_service_auth_accepted`,
73    /// and `pds_proxied_accepted` bodies.
74    #[arg(long)]
75    pub report_subject_did: Option<String>,
76
77    /// User handle for PDS-mediated report modes. Must be supplied together
78    /// with --app-password; enables `pds_service_auth_accepted` and
79    /// `pds_proxied_accepted` checks when combined with --commit-report.
80    #[arg(long, requires = "app_password")]
81    pub handle: Option<String>,
82
83    /// App password for PDS-mediated report modes. Must be supplied
84    /// together with --handle.
85    #[arg(long, requires = "handle")]
86    pub app_password: Option<String>,
87}
88
89impl LabelerCmd {
90    pub async fn run(self, no_color: bool) -> Result<ExitCode, Report> {
91        // Parse the target.
92        let target =
93            parse_target(&self.target, self.did.as_deref()).map_err(|e| miette::miette!("{e}"))?;
94
95        // Determine tentative endpoint for the locality check. When the target is a
96        // DID or handle, the endpoint is known only after identity stage; for the
97        // self-mint signer construction we need it now. We construct the signer
98        // pessimistically (endpoint unknown) only when --force-self-mint is set.
99        let tentative_endpoint: Option<url::Url> = match &target {
100            pipeline::LabelerTarget::Endpoint { url, .. } => Some(url.clone()),
101            pipeline::LabelerTarget::Identified { .. } => None,
102        };
103
104        let tentative_local = tentative_endpoint
105            .as_ref()
106            .map(is_local_labeler_hostname)
107            .unwrap_or(false);
108
109        // Build a single shared HTTP client.
110        let reqwest_client = reqwest::Client::builder()
111            .use_rustls_tls()
112            .user_agent(APP_USER_AGENT)
113            .timeout(std::time::Duration::from_secs(10))
114            .build()
115            .map_err(|e| miette::miette!("Failed to initialize HTTP client: {}", e))?;
116
117        // Build HTTP and DNS clients using the shared client.
118        let http = RealHttpClient::from_client(reqwest_client.clone());
119        let dns = RealDnsResolver::new();
120
121        // Build the subject DID override if provided.
122        let report_subject_override = self.report_subject_did.clone().map(Did);
123
124        // Construct the stable run-id for the sentinel reason string.
125        let run_id = create_report::sentinel::new_run_id();
126
127        // Pre-construct the self-mint signer (binds the DidDocServer) when:
128        //   - --force-self-mint is set, OR
129        //   - tentative endpoint is known and classified local.
130        // Otherwise we skip the allocation and let the stage see
131        // `self_mint_signer == None` → skip all self-mint checks.
132        let self_mint_signer_opt = if self.force_self_mint || tentative_local {
133            Some(
134                SelfMintSigner::spawn(self.self_mint_curve)
135                    .await
136                    .map_err(|e| miette::miette!("Failed to bind self-mint DID server: {e}"))?,
137            )
138        } else {
139            None
140        };
141        let self_mint_signer_ref = self_mint_signer_opt.as_ref();
142
143        // Construct PDS credentials when both handle and app_password are supplied.
144        let pds_credentials = match (self.handle.as_deref(), self.app_password.as_deref()) {
145            (Some(h), Some(p)) => Some(pipeline::PdsCredentials {
146                handle: h.to_string(),
147                app_password: p.to_string(),
148            }),
149            _ => None,
150        };
151        let pds_credentials_ref = pds_credentials.as_ref();
152
153        // Run the pipeline.
154        let opts = LabelerOptions {
155            http: &http,
156            dns: &dns,
157            http_tee: pipeline::HttpTee::Real(&reqwest_client),
158            ws_client: None,
159            subscribe_timeout: self.subscribe_timeout,
160            verbose: self.verbose,
161
162            create_report_tee: pipeline::CreateReportTeeKind::Real(&reqwest_client),
163            commit_report: self.commit_report,
164            force_self_mint: self.force_self_mint,
165            self_mint_curve: self.self_mint_curve,
166            report_subject_override: report_subject_override.as_ref(),
167            self_mint_signer: self_mint_signer_ref,
168            pds_credentials: pds_credentials_ref,
169            pds_xrpc_client: None,
170            pds_xrpc_client_override: None,
171            run_id: &run_id,
172        };
173
174        let report = run_pipeline(target, opts).await;
175
176        // Render the report to stdout.
177        let mut stdout = io::stdout().lock();
178        report
179            .render(&mut stdout, &RenderConfig { no_color })
180            .map_err(|e| miette::miette!("Failed to render report: {}", e))?;
181
182        // Return appropriate exit code.
183        let exit_code = report.exit_code();
184        Ok(ExitCode::from(exit_code as u8))
185    }
186}
187
188pub fn parse_subscribe_timeout(raw: &str) -> Result<Duration, String> {
189    let parsed = humantime::parse_duration(raw)
190        .map_err(|e| format!("invalid --subscribe-timeout value `{raw}`: {e}"))?;
191
192    if parsed < Duration::from_secs(1) {
193        return Err(format!(
194            "--subscribe-timeout must be at least 1 second (got {parsed:?})"
195        ));
196    }
197
198    Ok(parsed)
199}