atproto-devtool 0.1.1

A multitool for the atproto developer ecosystem
Documentation
//! `atproto-devtool test labeler <target>` command.

pub mod create_report;
pub mod crypto;
pub mod http;
pub mod identity;
pub mod pipeline;
pub mod report;
pub mod subscription;

use std::io;
use std::process::ExitCode;
use std::time::Duration;

use clap::Args;
use miette::Report;

use crate::commands::test::labeler::create_report::self_mint::{SelfMintCurve, SelfMintSigner};
use crate::common::{
    APP_USER_AGENT,
    identity::{Did, RealDnsResolver, RealHttpClient, is_local_labeler_hostname},
};
use pipeline::{LabelerOptions, parse_target, run_pipeline};
use report::RenderConfig;

/// Run the labeler conformance suite against a handle, DID, or endpoint URL.
#[derive(Debug, Args)]
pub struct LabelerCmd {
    /// Handle (`alice.example`), DID (`did:plc:...` / `did:web:...`), or labeler endpoint URL.
    pub target: String,

    /// Explicit DID override. Required (and combined with the target URL) when
    /// `target` is a raw endpoint URL and you want identity/crypto checks to run.
    #[arg(long)]
    pub did: Option<String>,

    /// Per-connection time budget for the subscription-layer checks.
    ///
    /// Minimum 1 second; values below 1 second are rejected at parse time.
    #[arg(
        long,
        default_value = "5s",
        value_parser = parse_subscribe_timeout,
    )]
    pub subscribe_timeout: Duration,

    /// Whether to suppress colored output.
    #[arg(long)]
    pub no_color: bool,

    /// Whether to emit verbose diagnostics.
    #[arg(long)]
    pub verbose: bool,

    /// Commit: opt in to actually POSTing report bodies to the labeler and
    /// assert reporting conformance (missing `LabelerPolicies` becomes a
    /// SpecViolation rather than a stage-skip).
    #[arg(long)]
    pub commit_report: bool,

    /// Force self-mint checks to run even when the labeler endpoint is
    /// classified as non-local by the hostname heuristic. Use when
    /// running against a LAN-reachable labeler that the heuristic misses.
    #[arg(long)]
    pub force_self_mint: bool,

    /// Curve to use for self-mint JWTs.
    #[arg(long, value_enum, default_value_t = SelfMintCurve::default())]
    pub self_mint_curve: SelfMintCurve,

    /// Override the default computed subject DID for committing checks.
    /// Passed through to `self_mint_accepted`, `pds_service_auth_accepted`,
    /// and `pds_proxied_accepted` bodies.
    #[arg(long)]
    pub report_subject_did: Option<String>,

    /// User handle for PDS-mediated report modes. Must be supplied together
    /// with --app-password; enables `pds_service_auth_accepted` and
    /// `pds_proxied_accepted` checks when combined with --commit-report.
    #[arg(long, requires = "app_password")]
    pub handle: Option<String>,

    /// App password for PDS-mediated report modes. Must be supplied
    /// together with --handle.
    #[arg(long, requires = "handle")]
    pub app_password: Option<String>,
}

impl LabelerCmd {
    pub async fn run(self, no_color: bool) -> Result<ExitCode, Report> {
        // Parse the target.
        let target =
            parse_target(&self.target, self.did.as_deref()).map_err(|e| miette::miette!("{e}"))?;

        // Determine tentative endpoint for the locality check. When the target is a
        // DID or handle, the endpoint is known only after identity stage; for the
        // self-mint signer construction we need it now. We construct the signer
        // pessimistically (endpoint unknown) only when --force-self-mint is set.
        let tentative_endpoint: Option<url::Url> = match &target {
            pipeline::LabelerTarget::Endpoint { url, .. } => Some(url.clone()),
            pipeline::LabelerTarget::Identified { .. } => None,
        };

        let tentative_local = tentative_endpoint
            .as_ref()
            .map(is_local_labeler_hostname)
            .unwrap_or(false);

        // Build a single shared HTTP client.
        let reqwest_client = reqwest::Client::builder()
            .use_rustls_tls()
            .user_agent(APP_USER_AGENT)
            .timeout(std::time::Duration::from_secs(10))
            .build()
            .map_err(|e| miette::miette!("Failed to initialize HTTP client: {}", e))?;

        // Build HTTP and DNS clients using the shared client.
        let http = RealHttpClient::from_client(reqwest_client.clone());
        let dns = RealDnsResolver::new();

        // Build the subject DID override if provided.
        let report_subject_override = self.report_subject_did.clone().map(Did);

        // Construct the stable run-id for the sentinel reason string.
        let run_id = create_report::sentinel::new_run_id();

        // Pre-construct the self-mint signer (binds the DidDocServer) when:
        //   - --force-self-mint is set, OR
        //   - tentative endpoint is known and classified local.
        // Otherwise we skip the allocation and let the stage see
        // `self_mint_signer == None` → skip all self-mint checks.
        let self_mint_signer_opt = if self.force_self_mint || tentative_local {
            Some(
                SelfMintSigner::spawn(self.self_mint_curve)
                    .await
                    .map_err(|e| miette::miette!("Failed to bind self-mint DID server: {e}"))?,
            )
        } else {
            None
        };
        let self_mint_signer_ref = self_mint_signer_opt.as_ref();

        // Construct PDS credentials when both handle and app_password are supplied.
        let pds_credentials = match (self.handle.as_deref(), self.app_password.as_deref()) {
            (Some(h), Some(p)) => Some(pipeline::PdsCredentials {
                handle: h.to_string(),
                app_password: p.to_string(),
            }),
            _ => None,
        };
        let pds_credentials_ref = pds_credentials.as_ref();

        // Run the pipeline.
        let opts = LabelerOptions {
            http: &http,
            dns: &dns,
            http_tee: pipeline::HttpTee::Real(&reqwest_client),
            ws_client: None,
            subscribe_timeout: self.subscribe_timeout,
            verbose: self.verbose,

            create_report_tee: pipeline::CreateReportTeeKind::Real(&reqwest_client),
            commit_report: self.commit_report,
            force_self_mint: self.force_self_mint,
            self_mint_curve: self.self_mint_curve,
            report_subject_override: report_subject_override.as_ref(),
            self_mint_signer: self_mint_signer_ref,
            pds_credentials: pds_credentials_ref,
            pds_xrpc_client: None,
            pds_xrpc_client_override: None,
            run_id: &run_id,
        };

        let report = run_pipeline(target, opts).await;

        // Render the report to stdout.
        let mut stdout = io::stdout().lock();
        report
            .render(&mut stdout, &RenderConfig { no_color })
            .map_err(|e| miette::miette!("Failed to render report: {}", e))?;

        // Return appropriate exit code.
        let exit_code = report.exit_code();
        Ok(ExitCode::from(exit_code as u8))
    }
}

pub fn parse_subscribe_timeout(raw: &str) -> Result<Duration, String> {
    let parsed = humantime::parse_duration(raw)
        .map_err(|e| format!("invalid --subscribe-timeout value `{raw}`: {e}"))?;

    if parsed < Duration::from_secs(1) {
        return Err(format!(
            "--subscribe-timeout must be at least 1 second (got {parsed:?})"
        ));
    }

    Ok(parsed)
}