CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

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

feat(cli): accept http:// targets with local hostnames

`parse_target` now allows plaintext `http://` URLs when the hostname is
classified as local by `is_local_labeler_hostname` (loopback, RFC 1918,
`.local` mDNS, etc.). Remote HTTP is still rejected with a message that
names the exception, so a typo in a production URL still fails fast.

This unblocks `atproto-devtool test labeler http://localhost:8080`, which
the test plan's Phase 3 assumed but the previous grammar rejected outright.

Tests: `parse_target_endpoint_http_local_accepted` covers localhost,
127.0.0.0/8, RFC 1918, .local, and ::1; the existing rejection test is
renamed to `parse_target_endpoint_http_remote_rejected` and tightened to
assert the error mentions the local exception.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

authored by

Jack Grigg
Claude Opus 4.7
and committed by
Tangled
6bb4c1c0 77325dd0

+52 -9
+4 -3
src/commands/test/labeler/CLAUDE.md
··· 18 18 `labeler.rs`) — constructs the shared reqwest client and calls 19 19 `pipeline::run_pipeline`. 20 20 - `pipeline::parse_target(raw, explicit_did) -> LabelerTarget` — the 21 - accepted target grammar is frozen: handle, `did:*`, or `https://` URL 22 - (HTTP is rejected with a helpful error; raw endpoints with no DID 23 - simply skip identity/crypto). 21 + accepted target grammar is handle, `did:*`, `https://` URL, or 22 + `http://` URL with a local hostname (loopback, RFC 1918, `.local`). 23 + Remote HTTP is rejected with a helpful error; raw endpoints with 24 + no DID simply skip identity/crypto. 24 25 - `pipeline::run_pipeline(target, LabelerOptions) -> LabelerReport` — the 25 26 one orchestrator that every test hits. 26 27 - **Per-stage entry points**: `identity::run`, `http::run`,
+48 -6
src/commands/test/labeler/pipeline.rs
··· 18 18 CheckResult, CheckStatus, LabelerReport, ReportHeader, Stage, 19 19 }; 20 20 use crate::commands::test::labeler::subscription::{self, RealWebSocketClient}; 21 - use crate::common::identity::{Did, DnsResolver, HttpClient}; 21 + use crate::common::identity::{Did, DnsResolver, HttpClient, is_local_labeler_hostname}; 22 22 23 23 /// A labeler target: either a resolvable identifier (handle or DID) or a raw endpoint URL. 24 24 #[derive(Debug, Clone)] ··· 138 138 139 139 fn unrecognized_target(raw: &str) -> Self { 140 140 Self::new(format!( 141 - "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)" 141 + "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)" 142 142 )) 143 143 } 144 144 145 145 fn http_not_supported(raw: &str) -> Self { 146 146 Self::new(format!( 147 - "HTTP endpoint '{raw}' is not supported. Please use an HTTPS endpoint instead." 147 + "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." 148 148 )) 149 149 } 150 150 ··· 227 227 }); 228 228 } 229 229 230 - // Check for HTTP URL (reject with helpful message). 230 + // Check for HTTP URL. Local hostnames (loopback, RFC 1918, .local, mDNS) 231 + // are accepted so developers can target a labeler running on their 232 + // machine or LAN. Remote HTTP is still rejected to guard against 233 + // accidental plaintext traffic to a production labeler. 231 234 if raw.starts_with("http://") { 235 + let url = Url::parse(raw) 236 + .map_err(|e| TargetParseError::new(format!("Invalid URL '{raw}': {e}")))?; 237 + if is_local_labeler_hostname(&url) { 238 + return Ok(LabelerTarget::Endpoint { 239 + url, 240 + did: explicit_did.map(|d| Did(d.to_string())), 241 + }); 242 + } 232 243 return Err(TargetParseError::http_not_supported(raw)); 233 244 } 234 245 ··· 605 616 } 606 617 607 618 #[test] 608 - fn parse_target_endpoint_http_rejected() { 619 + fn parse_target_endpoint_http_remote_rejected() { 609 620 let err = parse_target("http://evil.example", None).expect_err("should reject http"); 610 - assert!(err.message.contains("HTTPS")); 621 + assert!(err.message.contains("HTTP")); 622 + assert!(err.message.contains("local")); 623 + } 624 + 625 + #[test] 626 + fn parse_target_endpoint_http_local_accepted() { 627 + // Each of these hostnames is classified as local by 628 + // `is_local_labeler_hostname`, so plaintext HTTP is allowed. 629 + let cases = &[ 630 + "http://localhost:8080", 631 + "http://127.0.0.1:5000", 632 + "http://127.1.2.3/", 633 + "http://[::1]:8080/", 634 + "http://10.0.0.1/", 635 + "http://192.168.1.100:8080", 636 + "http://172.16.0.1/", 637 + "http://mybox.local:8080", 638 + ]; 639 + for raw in cases { 640 + let target = parse_target(raw, None) 641 + .unwrap_or_else(|e| panic!("expected {raw} to parse, got: {}", e.message)); 642 + match target { 643 + LabelerTarget::Endpoint { url, did } => { 644 + assert_eq!( 645 + url.as_str().trim_end_matches('/'), 646 + raw.trim_end_matches('/') 647 + ); 648 + assert!(did.is_none()); 649 + } 650 + _ => panic!("expected Endpoint variant for {raw}"), 651 + } 652 + } 611 653 } 612 654 613 655 #[test]