···1111use self::oauth::OauthCmd;
12121313#[derive(Debug, Subcommand)]
1414-pub enum TestCmd {
1414+pub(crate) enum TestCmd {
1515 /// Run the labeler conformance suite against an atproto labeler.
1616 Labeler(LabelerCmd),
1717 /// Test an atproto OAuth client.
···2020}
21212222impl TestCmd {
2323- pub async fn run(self, no_color: bool) -> Result<ExitCode, Report> {
2323+ pub(crate) async fn run(self, no_color: bool) -> Result<ExitCode, Report> {
2424 match self {
2525 TestCmd::Labeler(cmd) => cmd.run(no_color).await,
2626 TestCmd::Oauth(cmd) => cmd.run(no_color).await,
+25-23
src/commands/test/labeler.rs
···11//! `atproto-devtool test labeler <target>` command.
2233-pub mod create_report;
44-pub mod crypto;
55-pub mod http;
66-pub mod identity;
73pub mod pipeline;
88-pub mod subscription;
44+pub mod target;
95106use std::io;
117use std::process::ExitCode;
···1410use clap::Args;
1511use miette::Report;
16121717-use crate::commands::test::labeler::create_report::self_mint::{SelfMintCurve, SelfMintSigner};
1313+use self::pipeline::{
1414+ LabelerOptions,
1515+ create_report::{
1616+ self,
1717+ self_mint::{SelfMintCurve, SelfMintSigner},
1818+ },
1919+ run_pipeline,
2020+};
1821use crate::common::{
1922 APP_USER_AGENT,
2023 identity::{Did, RealDnsResolver, RealHttpClient, is_local_labeler_hostname},
2124 report::RenderConfig,
2225};
2323-use pipeline::{LabelerOptions, parse_target, run_pipeline};
24262527/// Run the labeler conformance suite against a handle, DID, or endpoint URL.
2628#[derive(Debug, Args)]
2727-pub struct LabelerCmd {
2929+pub(crate) struct LabelerCmd {
2830 /// Handle (`alice.example`), DID (`did:plc:...` / `did:web:...`), or labeler endpoint URL.
2929- pub target: String,
3131+ target: String,
30323133 /// Explicit DID override. Required (and combined with the target URL) when
3234 /// `target` is a raw endpoint URL and you want identity/crypto checks to run.
3335 #[arg(long)]
3434- pub did: Option<String>,
3636+ did: Option<String>,
35373638 /// Per-connection time budget for the subscription-layer checks.
3739 ///
···4143 default_value = "5s",
4244 value_parser = parse_subscribe_timeout,
4345 )]
4444- pub subscribe_timeout: Duration,
4646+ subscribe_timeout: Duration,
45474648 /// Whether to suppress colored output.
4749 #[arg(long)]
4848- pub no_color: bool,
5050+ no_color: bool,
49515052 /// Whether to emit verbose diagnostics.
5153 #[arg(long)]
5252- pub verbose: bool,
5454+ verbose: bool,
53555456 /// Commit: opt in to actually POSTing report bodies to the labeler and
5557 /// assert reporting conformance (missing `LabelerPolicies` becomes a
5658 /// SpecViolation rather than a stage-skip).
5759 #[arg(long)]
5858- pub commit_report: bool,
6060+ commit_report: bool,
59616062 /// Force self-mint checks to run even when the labeler endpoint is
6163 /// classified as non-local by the hostname heuristic. Use when
6264 /// running against a LAN-reachable labeler that the heuristic misses.
6365 #[arg(long)]
6464- pub force_self_mint: bool,
6666+ force_self_mint: bool,
65676668 /// Curve to use for self-mint JWTs.
6769 #[arg(long, value_enum, default_value_t = SelfMintCurve::default())]
6868- pub self_mint_curve: SelfMintCurve,
7070+ self_mint_curve: SelfMintCurve,
69717072 /// Override the default computed subject DID for committing checks.
7173 /// Passed through to `self_mint_accepted`, `pds_service_auth_accepted`,
7274 /// and `pds_proxied_accepted` bodies.
7375 #[arg(long)]
7474- pub report_subject_did: Option<String>,
7676+ report_subject_did: Option<String>,
75777678 /// User handle for PDS-mediated report modes. Must be supplied together
7779 /// with --app-password; enables `pds_service_auth_accepted` and
7880 /// `pds_proxied_accepted` checks when combined with --commit-report.
7981 #[arg(long, requires = "app_password")]
8080- pub handle: Option<String>,
8282+ handle: Option<String>,
81838284 /// App password for PDS-mediated report modes. Must be supplied
8385 /// together with --handle.
8486 #[arg(long, requires = "handle")]
8585- pub app_password: Option<String>,
8787+ app_password: Option<String>,
8688}
87898890impl LabelerCmd {
8989- pub async fn run(self, no_color: bool) -> Result<ExitCode, Report> {
9191+ pub(crate) async fn run(self, no_color: bool) -> Result<ExitCode, Report> {
9092 // Parse the target.
9193 let target =
9292- parse_target(&self.target, self.did.as_deref()).map_err(|e| miette::miette!("{e}"))?;
9494+ target::parse(&self.target, self.did.as_deref()).map_err(|e| miette::miette!("{e}"))?;
93959496 // Determine tentative endpoint for the locality check. When the target is a
9597 // DID or handle, the endpoint is known only after identity stage; for the
9698 // self-mint signer construction we need it now. We construct the signer
9799 // pessimistically (endpoint unknown) only when --force-self-mint is set.
98100 let tentative_endpoint: Option<url::Url> = match &target {
9999- pipeline::LabelerTarget::Endpoint { url, .. } => Some(url.clone()),
100100- pipeline::LabelerTarget::Identified { .. } => None,
101101+ target::LabelerTarget::Endpoint { url, .. } => Some(url.clone()),
102102+ target::LabelerTarget::Identified { .. } => None,
101103 };
102104103105 let tentative_local = tentative_endpoint
+4-3
src/commands/test/labeler/CLAUDE.md
···1717 - `LabelerCmd::run(no_color) -> Result<ExitCode, miette::Report>` (in
1818 `labeler.rs`) — constructs the shared reqwest client and calls
1919 `pipeline::run_pipeline`.
2020- - `pipeline::parse_target(raw, explicit_did) -> LabelerTarget` — the
2020+ - `target::parse(raw, explicit_did) -> LabelerTarget` — the
2121 accepted target grammar is handle, `did:*`, `https://` URL, or
2222 `http://` URL with a local hostname (loopback, RFC 1918, `.local`).
2323 Remote HTTP is rejected with a helpful error; raw endpoints with
2424 no DID simply skip identity/crypto.
2525 - `pipeline::run_pipeline(target, LabelerOptions) -> LabelerReport` — the
2626 one orchestrator that every test hits.
2727-- **Per-stage entry points**: `identity::run`, `http::run`,
2828- `subscription::run`, `crypto::run`, `create_report::run`. Each returns a
2727+- **Per-stage entry points**: `pipeline::identity::run`, `pipeline::http::run`,
2828+ `pipeline::subscription::run`, `pipeline::crypto::run`,
2929+ `pipeline::create_report::run`. Each returns a
2930 `*StageOutput` with an `Option<*Facts>` (populated only when the stage
3031 succeeds enough to let downstream stages run, or `None` when there are no
3132 meaningful facts to carry forward) plus a `Vec<CheckResult>`.
···11+use std::fmt;
22+33+use miette::Diagnostic;
44+use thiserror::Error;
55+use url::Url;
66+77+use crate::common::identity::{Did, is_local_labeler_hostname};
88+99+/// A labeler target: either a resolvable identifier (handle or DID) or a raw endpoint URL.
1010+#[derive(Debug, Clone)]
1111+pub enum LabelerTarget {
1212+ /// A handle or DID that can be resolved.
1313+ Identified {
1414+ /// The handle or DID to resolve.
1515+ identifier: AtIdentifier,
1616+ /// An optional explicit DID override (for cross-checking).
1717+ explicit_did: Option<Did>,
1818+ },
1919+ /// A raw HTTP endpoint, optionally with a DID for identity checks.
2020+ Endpoint {
2121+ /// The endpoint URL.
2222+ url: Url,
2323+ /// An optional DID to cross-check against the endpoint.
2424+ did: Option<Did>,
2525+ },
2626+}
2727+2828+/// An ATProto identifier: a handle or a DID.
2929+#[derive(Debug, Clone)]
3030+pub enum AtIdentifier {
3131+ /// An ATProto handle (e.g., `alice.bsky.social`).
3232+ Handle(String),
3333+ /// A decentralized identifier (e.g., `did:plc:...` or `did:web:...`).
3434+ Did(Did),
3535+}
3636+3737+/// Parse a labeler target from a string and optional explicit DID.
3838+///
3939+/// Returns a `LabelerTarget` on success, or a `TargetParseError` on failure.
4040+///
4141+/// Rules:
4242+/// - If `raw` starts with `did:`, parse as a DID. If `explicit_did` is also provided, return an error.
4343+/// - If `raw` starts with `https://`, parse as a URL. `explicit_did` is carried as an optional DID.
4444+/// - If `raw` starts with `http://`, return an error pointing the user to HTTPS.
4545+/// - If `raw` contains a dot and matches handle grammar, treat as a handle. `explicit_did` is carried.
4646+/// - Otherwise, return an unrecognized target error.
4747+pub fn parse(raw: &str, explicit_did: Option<&str>) -> Result<LabelerTarget, TargetParseError> {
4848+ // Check for DID.
4949+ if raw.starts_with("did:") {
5050+ if let Some(ed) = explicit_did {
5151+ return Err(TargetParseError::ambiguous_did(raw, ed));
5252+ }
5353+ return Ok(LabelerTarget::Identified {
5454+ identifier: AtIdentifier::Did(Did(raw.to_string())),
5555+ explicit_did: None,
5656+ });
5757+ }
5858+5959+ // Check for HTTPS URL.
6060+ if raw.starts_with("https://") {
6161+ let url = Url::parse(raw)
6262+ .map_err(|e| TargetParseError::new(format!("Invalid URL '{raw}': {e}")))?;
6363+ return Ok(LabelerTarget::Endpoint {
6464+ url,
6565+ did: explicit_did.map(|d| Did(d.to_string())),
6666+ });
6767+ }
6868+6969+ // Check for HTTP URL. Local hostnames (loopback, RFC 1918, .local, mDNS)
7070+ // are accepted so developers can target a labeler running on their
7171+ // machine or LAN. Remote HTTP is still rejected to guard against
7272+ // accidental plaintext traffic to a production labeler.
7373+ if raw.starts_with("http://") {
7474+ let url = Url::parse(raw)
7575+ .map_err(|e| TargetParseError::new(format!("Invalid URL '{raw}': {e}")))?;
7676+ if is_local_labeler_hostname(&url) {
7777+ return Ok(LabelerTarget::Endpoint {
7878+ url,
7979+ did: explicit_did.map(|d| Did(d.to_string())),
8080+ });
8181+ }
8282+ return Err(TargetParseError::http_not_supported(raw));
8383+ }
8484+8585+ // Check for handle.
8686+ if is_valid_handle(raw) {
8787+ return Ok(LabelerTarget::Identified {
8888+ identifier: AtIdentifier::Handle(raw.to_string()),
8989+ explicit_did: explicit_did.map(|d| Did(d.to_string())),
9090+ });
9191+ }
9292+9393+ // Unrecognized target.
9494+ Err(TargetParseError::unrecognized_target(raw))
9595+}
9696+9797+impl fmt::Display for LabelerTarget {
9898+ /// Format a target for display in the report header.
9999+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100100+ match self {
101101+ LabelerTarget::Identified {
102102+ identifier,
103103+ explicit_did,
104104+ } => {
105105+ let id_str = match identifier {
106106+ AtIdentifier::Handle(h) => h.clone(),
107107+ AtIdentifier::Did(d) => d.0.clone(),
108108+ };
109109+ if explicit_did.is_some() {
110110+ write!(f, "{id_str} (with explicit DID)")
111111+ } else {
112112+ id_str.fmt(f)
113113+ }
114114+ }
115115+ LabelerTarget::Endpoint { url, did } => {
116116+ if did.is_some() {
117117+ write!(f, "{url} (with explicit DID)")
118118+ } else {
119119+ url.fmt(f)
120120+ }
121121+ }
122122+ }
123123+ }
124124+}
125125+126126+/// Check if a string is a valid ATProto handle.
127127+///
128128+/// A valid handle:
129129+/// - Contains at least one dot.
130130+/// - Contains only alphanumeric characters, hyphens, and dots.
131131+/// - Does not start or end with a hyphen or dot.
132132+/// - Has no empty segments (no consecutive dots or leading/trailing dots).
133133+fn is_valid_handle(s: &str) -> bool {
134134+ if !s.contains('.') {
135135+ return false;
136136+ }
137137+138138+ // Check for empty string or leading/trailing special chars.
139139+ if s.is_empty()
140140+ || s.starts_with('-')
141141+ || s.starts_with('.')
142142+ || s.ends_with('-')
143143+ || s.ends_with('.')
144144+ {
145145+ return false;
146146+ }
147147+148148+ // Check all characters are alphanumeric, hyphen, or dot.
149149+ for c in s.chars() {
150150+ if !c.is_ascii_alphanumeric() && c != '-' && c != '.' {
151151+ return false;
152152+ }
153153+ }
154154+155155+ // Check no empty segments (no consecutive dots).
156156+ if s.contains("..") {
157157+ return false;
158158+ }
159159+160160+ true
161161+}
162162+163163+/// Error from target parsing.
164164+#[derive(Debug, Error, Diagnostic)]
165165+#[error("{message}")]
166166+pub struct TargetParseError {
167167+ /// The error message.
168168+ pub message: String,
169169+}
170170+171171+impl TargetParseError {
172172+ fn new(message: impl Into<String>) -> Self {
173173+ Self {
174174+ message: message.into(),
175175+ }
176176+ }
177177+178178+ fn unrecognized_target(raw: &str) -> Self {
179179+ Self::new(format!(
180180+ "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)"
181181+ ))
182182+ }
183183+184184+ fn http_not_supported(raw: &str) -> Self {
185185+ Self::new(format!(
186186+ "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."
187187+ ))
188188+ }
189189+190190+ fn ambiguous_did(raw: &str, explicit: &str) -> Self {
191191+ Self::new(format!(
192192+ "Ambiguous target specification: target '{raw}' is already a DID, but --did {explicit} was also provided. Please use only one."
193193+ ))
194194+ }
195195+}
196196+197197+#[cfg(test)]
198198+mod tests {
199199+ use super::*;
200200+201201+ #[test]
202202+ fn parse_target_handle() {
203203+ let target = parse("alice.bsky.social", None).expect("should parse");
204204+ match target {
205205+ LabelerTarget::Identified {
206206+ identifier,
207207+ explicit_did,
208208+ } => {
209209+ assert!(
210210+ matches!(identifier, AtIdentifier::Handle(ref h) if h == "alice.bsky.social")
211211+ );
212212+ assert!(explicit_did.is_none());
213213+ }
214214+ _ => panic!("expected Identified variant"),
215215+ }
216216+ }
217217+218218+ #[test]
219219+ fn parse_target_did_plc() {
220220+ let target = parse("did:plc:abc123", None).expect("should parse");
221221+ match target {
222222+ LabelerTarget::Identified {
223223+ identifier,
224224+ explicit_did,
225225+ } => {
226226+ assert!(matches!(identifier, AtIdentifier::Did(ref d) if d.0 == "did:plc:abc123"));
227227+ assert!(explicit_did.is_none());
228228+ }
229229+ _ => panic!("expected Identified variant"),
230230+ }
231231+ }
232232+233233+ #[test]
234234+ fn parse_target_did_web() {
235235+ let target = parse("did:web:example.com", None).expect("should parse");
236236+ match target {
237237+ LabelerTarget::Identified {
238238+ identifier,
239239+ explicit_did,
240240+ } => {
241241+ assert!(
242242+ matches!(identifier, AtIdentifier::Did(ref d) if d.0 == "did:web:example.com")
243243+ );
244244+ assert!(explicit_did.is_none());
245245+ }
246246+ _ => panic!("expected Identified variant"),
247247+ }
248248+ }
249249+250250+ #[test]
251251+ fn parse_target_endpoint_https() {
252252+ let target = parse("https://example.com/labeler", None).expect("should parse");
253253+ match target {
254254+ LabelerTarget::Endpoint { url, did } => {
255255+ assert_eq!(url.as_str(), "https://example.com/labeler");
256256+ assert!(did.is_none());
257257+ }
258258+ _ => panic!("expected Endpoint variant"),
259259+ }
260260+ }
261261+262262+ #[test]
263263+ fn parse_target_endpoint_with_explicit_did() {
264264+ let target =
265265+ parse("https://example.com/labeler", Some("did:plc:xyz")).expect("should parse");
266266+ match target {
267267+ LabelerTarget::Endpoint { url, did } => {
268268+ assert_eq!(url.as_str(), "https://example.com/labeler");
269269+ assert_eq!(did.map(|d| d.0.clone()), Some("did:plc:xyz".to_string()));
270270+ }
271271+ _ => panic!("expected Endpoint variant"),
272272+ }
273273+ }
274274+275275+ #[test]
276276+ fn parse_target_endpoint_http_remote_rejected() {
277277+ let err = parse("http://evil.example", None).expect_err("should reject http");
278278+ assert!(err.message.contains("HTTP"));
279279+ assert!(err.message.contains("local"));
280280+ }
281281+282282+ #[test]
283283+ fn parse_target_endpoint_http_local_accepted() {
284284+ // Each of these hostnames is classified as local by
285285+ // `is_local_labeler_hostname`, so plaintext HTTP is allowed.
286286+ let cases = &[
287287+ "http://localhost:8080",
288288+ "http://127.0.0.1:5000",
289289+ "http://127.1.2.3/",
290290+ "http://[::1]:8080/",
291291+ "http://10.0.0.1/",
292292+ "http://192.168.1.100:8080",
293293+ "http://172.16.0.1/",
294294+ "http://mybox.local:8080",
295295+ ];
296296+ for raw in cases {
297297+ let target = parse(raw, None)
298298+ .unwrap_or_else(|e| panic!("expected {raw} to parse, got: {}", e.message));
299299+ match target {
300300+ LabelerTarget::Endpoint { url, did } => {
301301+ assert_eq!(
302302+ url.as_str().trim_end_matches('/'),
303303+ raw.trim_end_matches('/')
304304+ );
305305+ assert!(did.is_none());
306306+ }
307307+ _ => panic!("expected Endpoint variant for {raw}"),
308308+ }
309309+ }
310310+ }
311311+312312+ #[test]
313313+ fn parse_target_unrecognised() {
314314+ let err = parse("not a handle or did", None).expect_err("should fail");
315315+ assert!(err.message.contains("Unrecognized target"));
316316+ }
317317+318318+ #[test]
319319+ fn parse_target_did_with_conflicting_flag() {
320320+ let err = parse("did:plc:abc", Some("did:web:example.com"))
321321+ .expect_err("should reject ambiguous target");
322322+ assert!(err.message.contains("Ambiguous"));
323323+ }
324324+}
+2-2
src/common.rs
···11//! Cross-feature primitives shared by every `atproto-devtool` subcommand.
2233-pub mod diagnostics;
33+pub(crate) mod diagnostics;
44pub mod identity;
55-pub mod jwt;
55+pub(crate) mod jwt;
66pub mod oauth;
77pub mod report;
88
+6-7
src/common/diagnostics.rs
···2233use std::sync::Arc;
4455-use miette::{GraphicalTheme, MietteHandlerOpts, NamedSource};
66-77-// Re-exports so stages can import `LabeledSpan` / `SourceSpan` from a single
88-// path.
99-pub use miette::{LabeledSpan, SourceSpan};
55+use miette::{GraphicalTheme, MietteHandlerOpts, NamedSource, SourceSpan};
106117/// Install the miette panic hook and graphical report handler.
128///
···4137///
4238/// The bytes are cloned into an `Arc<[u8]>` via miette's constructor,
4339/// so callers may drop the original slice after this returns.
4444-pub fn named_source_from_bytes(name: impl AsRef<str>, bytes: &[u8]) -> NamedSource<Arc<[u8]>> {
4040+pub(crate) fn named_source_from_bytes(
4141+ name: impl AsRef<str>,
4242+ bytes: &[u8],
4343+) -> NamedSource<Arc<[u8]>> {
4544 NamedSource::new(name, Arc::<[u8]>::from(bytes))
4645}
47464847/// Build a `NamedSource` from a name and a UTF-8 string slice.
4949-pub fn named_source_from_str(name: impl AsRef<str>, text: &str) -> NamedSource<String> {
4848+pub(crate) fn named_source_from_str(name: impl AsRef<str>, text: &str) -> NamedSource<String> {
5049 NamedSource::new(name, text.to_string())
5150}
5251
+10-5
src/common/jwt.rs
···1212use serde::{Deserialize, Serialize};
1313use thiserror::Error;
14141515-use crate::common::identity::{AnySignature, AnySignatureError, AnySigningKey, AnyVerifyingKey};
1515+use crate::common::identity::{AnySignatureError, AnySigningKey};
1616+1717+#[cfg(test)]
1818+use crate::common::identity::{AnySignature, AnyVerifyingKey};
16191720/// Compact JWS header for atproto service-auth tokens.
1821///
···7578/// rendered to the user, they must wrap it in a stage-local diagnostic
7679/// with a proper `code = "labeler::..."` string.
7780#[derive(Debug, Error)]
7878-pub enum JwtError {
8181+pub(crate) enum JwtError {
7982 /// Compact form was not three `.`-separated base64url segments.
8083 #[error("malformed compact JWT: expected three segments")]
8184 MalformedCompact,
···126129///
127130/// Signs the concatenation `header_b64 + "." + claims_b64` with SHA-256
128131/// prehash under the supplied key. Returns the full compact token string.
129129-pub fn encode_compact(
132132+pub(crate) fn encode_compact(
130133 header: &JwtHeader,
131134 claims: &JwtClaims,
132135 signer: &AnySigningKey,
···147150/// Does NOT verify the signature — use `verify_compact` for that. This helper
148151/// is primarily for test round-tripping and for negative-test assertions
149152/// (e.g., "the minted token has the expected `alg` header").
150150-pub fn decode_compact(token: &str) -> Result<(JwtHeader, JwtClaims, Vec<u8>), JwtError> {
153153+#[cfg(test)]
154154+fn decode_compact(token: &str) -> Result<(JwtHeader, JwtClaims, Vec<u8>), JwtError> {
151155 let parts: Vec<&str> = token.split('.').collect();
152156 if parts.len() != 3 {
153157 return Err(JwtError::MalformedCompact);
···191195/// Verify a compact JWT against the given verifying key. Does NOT check
192196/// claim values (exp/aud/lxm) — that is the labeler's job in production,
193197/// or the stage's assertion job in tests. Only verifies the signature.
194194-pub fn verify_compact(
198198+#[cfg(test)]
199199+pub(crate) fn verify_compact(
195200 token: &str,
196201 vkey: &AnyVerifyingKey,
197202) -> Result<(JwtHeader, JwtClaims), JwtError> {