CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

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

Implement labeler identity stage with per-check diagnostics

Adds the identity stage that performs DID document resolution and labeler
record validation. Implements 9 named checks with per-check diagnostics:
target_resolved, did_document_fetched, labeler_service_present,
labeler_endpoint_is_https, labeler_endpoint_matches_flag,
signing_key_present, pds_endpoint_present, labeler_record_fetched,
labeler_record_policies_nonempty.

Each failing check carries a diagnostic with NamedSource and span
highlighting the relevant JSON field. Network errors are distinguished
from specification violations. Facts are populated only when all checks
pass, blocking later stages if unavailable.

Verifies test-labeler.AC2.1 through AC2.8 and AC1.4 (endpoint/DID cross-check).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

+898 -16
+1
src/commands/test/labeler.rs
··· 1 1 //! `atproto-devtool test labeler <target>` command. 2 2 3 + pub mod identity; 3 4 pub mod pipeline; 4 5 pub mod report; 5 6
+867
src/commands/test/labeler/identity.rs
··· 1 + //! Identity stage for the labeler conformance suite. 2 + //! 3 + //! Performs DID document resolution and labeler record validation, 4 + //! emitting a series of named checks for each identity-layer requirement. 5 + 6 + use std::borrow::Cow; 7 + use std::sync::Arc; 8 + 9 + use miette::{Diagnostic, NamedSource, SourceSpan}; 10 + use thiserror::Error; 11 + use url::Url; 12 + 13 + use crate::commands::test::labeler::pipeline::{AtIdentifier, LabelerTarget}; 14 + use crate::commands::test::labeler::report::{CheckResult, CheckStatus, Stage}; 15 + use crate::common::identity::{ 16 + find_service, parse_multikey, resolve_did, resolve_handle, AnyVerifyingKey, Did, 17 + DidDocument, DnsResolver, HttpClient, RawDidDocument, IdentityError, 18 + }; 19 + 20 + /// Facts about the labeler's identity, populated only when all checks pass. 21 + #[derive(Debug, Clone)] 22 + pub struct IdentityFacts { 23 + /// The resolved DID. 24 + pub did: Did, 25 + /// The parsed DID document with raw bytes. 26 + pub raw_did_doc: RawDidDocument, 27 + /// The labeler's service endpoint URL. 28 + pub labeler_endpoint: Url, 29 + /// The PDS (personal data server) endpoint URL. 30 + pub pds_endpoint: Url, 31 + /// The ID of the signing key (from the verification method). 32 + pub signing_key_id: String, 33 + /// The parsed signing key. 34 + pub signing_key: AnyVerifyingKey, 35 + /// Raw bytes of the labeler record (for diagnostics). 36 + pub labeler_record_bytes: Arc<[u8]>, 37 + } 38 + 39 + /// Output from the identity stage: facts (if all checks pass) plus all check results. 40 + #[derive(Debug)] 41 + pub struct IdentityStageOutput { 42 + /// Facts populated only when all checks pass and no check is blocking. 43 + pub facts: Option<IdentityFacts>, 44 + /// All check results from this stage. 45 + pub results: Vec<CheckResult>, 46 + } 47 + 48 + /// Represents a check result that failed, with a diagnostic spanning a JSON key. 49 + #[derive(Debug, Error, Diagnostic)] 50 + #[error("{message}")] 51 + #[diagnostic(code = "labeler::identity::labeler_service_present")] 52 + struct ServiceMissingError { 53 + /// The error message. 54 + message: String, 55 + /// The raw DID document bytes. 56 + #[source_code] 57 + named_source: NamedSource<Arc<[u8]>>, 58 + /// The span highlighting the "service" key. 59 + #[label("service array")] 60 + span: SourceSpan, 61 + } 62 + 63 + /// Represents a non-HTTPS endpoint error. 64 + #[derive(Debug, Error, Diagnostic)] 65 + #[error("{message}")] 66 + #[diagnostic(code = "labeler::identity::labeler_endpoint_is_https")] 67 + struct NonHttpsEndpointError { 68 + /// The error message. 69 + message: String, 70 + /// The raw DID document bytes. 71 + #[source_code] 72 + named_source: NamedSource<Arc<[u8]>>, 73 + /// The span highlighting the endpoint value. 74 + #[label("endpoint value")] 75 + span: SourceSpan, 76 + } 77 + 78 + /// Represents an endpoint mismatch between resolved and provided. 79 + #[derive(Debug, Error, Diagnostic)] 80 + #[error("{message}")] 81 + #[diagnostic(code = "labeler::identity::labeler_endpoint_matches_flag")] 82 + struct EndpointMismatchError { 83 + /// The error message. 84 + message: String, 85 + /// The raw DID document bytes. 86 + #[source_code] 87 + named_source: NamedSource<Arc<[u8]>>, 88 + /// The span highlighting the endpoint value. 89 + #[label("endpoint value")] 90 + span: SourceSpan, 91 + } 92 + 93 + /// Represents a missing verification method error. 94 + #[derive(Debug, Error, Diagnostic)] 95 + #[error("{message}")] 96 + #[diagnostic(code = "labeler::identity::signing_key_present")] 97 + struct SigningKeyMissingError { 98 + /// The error message. 99 + message: String, 100 + /// The raw DID document bytes. 101 + #[source_code] 102 + named_source: NamedSource<Arc<[u8]>>, 103 + /// The span highlighting the "verificationMethod" key. 104 + #[label("verificationMethod array")] 105 + span: SourceSpan, 106 + } 107 + 108 + /// Represents an unparseable signing key error. 109 + #[derive(Debug, Error, Diagnostic)] 110 + #[error("{message}")] 111 + #[diagnostic(code = "labeler::identity::signing_key_present")] 112 + struct SigningKeyUnparseableError { 113 + /// The error message. 114 + message: String, 115 + /// The raw DID document bytes. 116 + #[source_code] 117 + named_source: NamedSource<Arc<[u8]>>, 118 + /// The span highlighting the multikey. 119 + #[label("multikey")] 120 + span: SourceSpan, 121 + } 122 + 123 + /// Represents a missing PDS service error. 124 + #[derive(Debug, Error, Diagnostic)] 125 + #[error("{message}")] 126 + #[diagnostic(code = "labeler::identity::pds_endpoint_present")] 127 + struct PdsServiceMissingError { 128 + /// The error message. 129 + message: String, 130 + /// The raw DID document bytes. 131 + #[source_code] 132 + named_source: NamedSource<Arc<[u8]>>, 133 + /// The span highlighting the "service" key. 134 + #[label("service array")] 135 + span: SourceSpan, 136 + } 137 + 138 + /// Represents a labeler record fetch error (404 vs transport error). 139 + #[derive(Debug, Error, Diagnostic)] 140 + #[error("{message}")] 141 + #[diagnostic(code = "labeler::identity::labeler_record_fetched")] 142 + struct LabelerRecordFetchError { 143 + /// The error message. 144 + message: String, 145 + } 146 + 147 + /// Represents an empty labeler policies error. 148 + #[derive(Debug, Error, Diagnostic)] 149 + #[error("{message}")] 150 + #[diagnostic(code = "labeler::identity::labeler_record_policies_nonempty")] 151 + struct EmptyPoliciesError { 152 + /// The error message. 153 + message: String, 154 + /// The raw labeler record bytes. 155 + #[source_code] 156 + named_source: NamedSource<Arc<[u8]>>, 157 + /// The span highlighting the "policies" key. 158 + #[label("policies field")] 159 + span: SourceSpan, 160 + } 161 + 162 + /// Run the identity stage of the labeler conformance suite. 163 + pub async fn run( 164 + target: &LabelerTarget, 165 + http: &dyn HttpClient, 166 + dns: &dyn DnsResolver, 167 + ) -> IdentityStageOutput { 168 + let mut results = Vec::new(); 169 + 170 + // Determine if we have a DID to work with. 171 + let resolved_did = match target { 172 + LabelerTarget::Identified { 173 + identifier, 174 + explicit_did, 175 + } => { 176 + // Resolve the identifier to a DID. 177 + let did = resolve_identifier(identifier, explicit_did, http, dns, &mut results).await; 178 + did 179 + } 180 + LabelerTarget::Endpoint { did, .. } => { 181 + // If no DID is provided, skip all identity checks. 182 + if did.is_none() { 183 + return IdentityStageOutput { 184 + facts: None, 185 + results: vec![CheckResult { 186 + id: "identity::target_resolved", 187 + stage: Stage::Identity, 188 + status: CheckStatus::Skipped, 189 + summary: Cow::Borrowed("target resolved"), 190 + diagnostic: None, 191 + skipped_reason: Some(Cow::Borrowed( 192 + "no DID supplied; run with a handle, a DID, or --did <did>", 193 + )), 194 + }], 195 + }; 196 + } 197 + did.clone() 198 + } 199 + }; 200 + 201 + let Some(did) = resolved_did else { 202 + // Resolution failed; facts are blocked. 203 + return IdentityStageOutput { 204 + facts: None, 205 + results, 206 + }; 207 + }; 208 + 209 + // Resolve the DID document. 210 + let raw_did_doc = match resolve_did(&did, http).await { 211 + Ok(doc) => { 212 + results.push(CheckResult { 213 + id: "identity::did_document_fetched", 214 + stage: Stage::Identity, 215 + status: CheckStatus::Pass, 216 + summary: Cow::Borrowed("DID document fetched"), 217 + diagnostic: None, 218 + skipped_reason: None, 219 + }); 220 + doc 221 + } 222 + Err(e) => { 223 + let (status, _msg, is_network) = match &e { 224 + IdentityError::HttpTransport(_) => { 225 + (CheckStatus::NetworkError, format!("DID fetch failed: {}", e), true) 226 + } 227 + IdentityError::DidDocumentDecodeFailed { 228 + source_name, 229 + source_bytes, 230 + .. 231 + } => { 232 + let diag = Box::new(DidDocumentDecodeError { 233 + message: format!("DID document JSON decode failed: {}", e), 234 + named_source: NamedSource::new( 235 + source_name.clone(), 236 + source_bytes.clone(), 237 + ), 238 + span: SourceSpan::new(0.into(), 0), 239 + }); 240 + results.push(CheckResult { 241 + id: "identity::did_document_fetched", 242 + stage: Stage::Identity, 243 + status: CheckStatus::SpecViolation, 244 + summary: Cow::Borrowed("DID document fetched"), 245 + diagnostic: Some(diag), 246 + skipped_reason: None, 247 + }); 248 + return IdentityStageOutput { 249 + facts: None, 250 + results, 251 + }; 252 + } 253 + _ => (CheckStatus::SpecViolation, format!("DID fetch failed: {}", e), false), 254 + }; 255 + 256 + if is_network { 257 + results.push(CheckResult { 258 + id: "identity::did_document_fetched", 259 + stage: Stage::Identity, 260 + status, 261 + summary: Cow::Borrowed("DID document fetched"), 262 + diagnostic: None, 263 + skipped_reason: None, 264 + }); 265 + } 266 + return IdentityStageOutput { 267 + facts: None, 268 + results, 269 + }; 270 + } 271 + }; 272 + 273 + // Check for labeler service. 274 + let labeler_service = match find_service(&raw_did_doc.parsed, "atproto_labeler", "AtprotoLabeler") { 275 + Some(svc) => { 276 + results.push(CheckResult { 277 + id: "identity::labeler_service_present", 278 + stage: Stage::Identity, 279 + status: CheckStatus::Pass, 280 + summary: Cow::Borrowed("labeler service present"), 281 + diagnostic: None, 282 + skipped_reason: None, 283 + }); 284 + svc.clone() 285 + } 286 + None => { 287 + let span = json_span_for_key(raw_did_doc.source_bytes.as_ref(), "service"); 288 + let diag = Box::new(ServiceMissingError { 289 + message: "DID document is missing the #atproto_labeler service entry".to_string(), 290 + named_source: NamedSource::new( 291 + raw_did_doc.source_name.clone(), 292 + raw_did_doc.source_bytes.clone(), 293 + ), 294 + span, 295 + }); 296 + results.push(CheckResult { 297 + id: "identity::labeler_service_present", 298 + stage: Stage::Identity, 299 + status: CheckStatus::SpecViolation, 300 + summary: Cow::Borrowed("labeler service present"), 301 + diagnostic: Some(diag), 302 + skipped_reason: None, 303 + }); 304 + return IdentityStageOutput { 305 + facts: None, 306 + results, 307 + }; 308 + } 309 + }; 310 + 311 + // Parse the labeler endpoint as a URL. 312 + let labeler_endpoint = match Url::parse(&labeler_service.service_endpoint) { 313 + Ok(url) => { 314 + // Check that it's HTTPS. 315 + if url.scheme() != "https" { 316 + let span = 317 + json_span_for_key(raw_did_doc.source_bytes.as_ref(), &labeler_service.service_endpoint); 318 + let diag = Box::new(NonHttpsEndpointError { 319 + message: format!( 320 + "Labeler endpoint must use HTTPS, got: {}", 321 + labeler_service.service_endpoint 322 + ), 323 + named_source: NamedSource::new( 324 + raw_did_doc.source_name.clone(), 325 + raw_did_doc.source_bytes.clone(), 326 + ), 327 + span, 328 + }); 329 + results.push(CheckResult { 330 + id: "identity::labeler_endpoint_is_https", 331 + stage: Stage::Identity, 332 + status: CheckStatus::SpecViolation, 333 + summary: Cow::Borrowed("labeler endpoint is HTTPS"), 334 + diagnostic: Some(diag), 335 + skipped_reason: None, 336 + }); 337 + return IdentityStageOutput { 338 + facts: None, 339 + results, 340 + }; 341 + } 342 + 343 + results.push(CheckResult { 344 + id: "identity::labeler_endpoint_is_https", 345 + stage: Stage::Identity, 346 + status: CheckStatus::Pass, 347 + summary: Cow::Borrowed("labeler endpoint is HTTPS"), 348 + diagnostic: None, 349 + skipped_reason: None, 350 + }); 351 + url 352 + } 353 + Err(_) => { 354 + let span = 355 + json_span_for_key(raw_did_doc.source_bytes.as_ref(), &labeler_service.service_endpoint); 356 + let diag = Box::new(NonHttpsEndpointError { 357 + message: format!( 358 + "Labeler endpoint is not a valid URL: {}", 359 + labeler_service.service_endpoint 360 + ), 361 + named_source: NamedSource::new( 362 + raw_did_doc.source_name.clone(), 363 + raw_did_doc.source_bytes.clone(), 364 + ), 365 + span, 366 + }); 367 + results.push(CheckResult { 368 + id: "identity::labeler_endpoint_is_https", 369 + stage: Stage::Identity, 370 + status: CheckStatus::SpecViolation, 371 + summary: Cow::Borrowed("labeler endpoint is HTTPS"), 372 + diagnostic: Some(diag), 373 + skipped_reason: None, 374 + }); 375 + return IdentityStageOutput { 376 + facts: None, 377 + results, 378 + }; 379 + } 380 + }; 381 + 382 + // If target has an explicit DID and endpoint, cross-check them. 383 + if let LabelerTarget::Endpoint { 384 + url: flag_url, 385 + did: Some(_), 386 + } = target 387 + { 388 + if !endpoints_match(flag_url, &labeler_endpoint) { 389 + let span = 390 + json_span_for_key(raw_did_doc.source_bytes.as_ref(), &labeler_service.service_endpoint); 391 + let diag = Box::new(EndpointMismatchError { 392 + message: format!( 393 + "DID document endpoint ({}) does not match provided endpoint ({})", 394 + labeler_endpoint, flag_url 395 + ), 396 + named_source: NamedSource::new( 397 + raw_did_doc.source_name.clone(), 398 + raw_did_doc.source_bytes.clone(), 399 + ), 400 + span, 401 + }); 402 + results.push(CheckResult { 403 + id: "identity::labeler_endpoint_matches_flag", 404 + stage: Stage::Identity, 405 + status: CheckStatus::SpecViolation, 406 + summary: Cow::Borrowed("labeler endpoint matches flag"), 407 + diagnostic: Some(diag), 408 + skipped_reason: None, 409 + }); 410 + return IdentityStageOutput { 411 + facts: None, 412 + results, 413 + }; 414 + } else { 415 + results.push(CheckResult { 416 + id: "identity::labeler_endpoint_matches_flag", 417 + stage: Stage::Identity, 418 + status: CheckStatus::Pass, 419 + summary: Cow::Borrowed("labeler endpoint matches flag"), 420 + diagnostic: None, 421 + skipped_reason: None, 422 + }); 423 + } 424 + } else { 425 + // Check is skipped if endpoint is not provided with a DID. 426 + results.push(CheckResult { 427 + id: "identity::labeler_endpoint_matches_flag", 428 + stage: Stage::Identity, 429 + status: CheckStatus::Skipped, 430 + summary: Cow::Borrowed("labeler endpoint matches flag"), 431 + diagnostic: None, 432 + skipped_reason: Some(Cow::Borrowed("no endpoint override provided")), 433 + }); 434 + } 435 + 436 + // Find the signing key. 437 + let signing_key_id = match find_signing_key(&raw_did_doc.parsed) { 438 + Some((id, multikey_str)) => { 439 + // Try to parse the multikey. 440 + match parse_multikey(&multikey_str) { 441 + Ok(_parsed) => { 442 + results.push(CheckResult { 443 + id: "identity::signing_key_present", 444 + stage: Stage::Identity, 445 + status: CheckStatus::Pass, 446 + summary: Cow::Borrowed("signing key present"), 447 + diagnostic: None, 448 + skipped_reason: None, 449 + }); 450 + id 451 + } 452 + Err(e) => { 453 + let span = json_span_for_key(raw_did_doc.source_bytes.as_ref(), &multikey_str); 454 + let diag = Box::new(SigningKeyUnparseableError { 455 + message: format!("Failed to parse signing key multikey: {}", e), 456 + named_source: NamedSource::new( 457 + raw_did_doc.source_name.clone(), 458 + raw_did_doc.source_bytes.clone(), 459 + ), 460 + span, 461 + }); 462 + results.push(CheckResult { 463 + id: "identity::signing_key_present", 464 + stage: Stage::Identity, 465 + status: CheckStatus::SpecViolation, 466 + summary: Cow::Borrowed("signing key present"), 467 + diagnostic: Some(diag), 468 + skipped_reason: None, 469 + }); 470 + return IdentityStageOutput { 471 + facts: None, 472 + results, 473 + }; 474 + } 475 + } 476 + } 477 + None => { 478 + let span = json_span_for_key(raw_did_doc.source_bytes.as_ref(), "verificationMethod"); 479 + let diag = Box::new(SigningKeyMissingError { 480 + message: "DID document is missing the #atproto_label signing key".to_string(), 481 + named_source: NamedSource::new( 482 + raw_did_doc.source_name.clone(), 483 + raw_did_doc.source_bytes.clone(), 484 + ), 485 + span, 486 + }); 487 + results.push(CheckResult { 488 + id: "identity::signing_key_present", 489 + stage: Stage::Identity, 490 + status: CheckStatus::SpecViolation, 491 + summary: Cow::Borrowed("signing key present"), 492 + diagnostic: Some(diag), 493 + skipped_reason: None, 494 + }); 495 + return IdentityStageOutput { 496 + facts: None, 497 + results, 498 + }; 499 + } 500 + }; 501 + 502 + // Re-parse the signing key now that we know it succeeded. 503 + let signing_key = parse_multikey( 504 + raw_did_doc 505 + .parsed 506 + .verification_method 507 + .as_ref() 508 + .and_then(|vms| { 509 + vms.iter() 510 + .find(|vm| { 511 + vm.id.rsplit_once('#').map(|(_, f)| f).unwrap_or("") == "atproto_label" 512 + }) 513 + .and_then(|vm| vm.public_key_multibase.as_deref()) 514 + }) 515 + .unwrap_or(""), 516 + ) 517 + .expect("parsing should have succeeded already") 518 + .verifying_key; 519 + 520 + // Find PDS service. 521 + let pds_endpoint = match find_service(&raw_did_doc.parsed, "atproto_pds", "AtprotoPersonalDataServer") { 522 + Some(svc) => { 523 + match Url::parse(&svc.service_endpoint) { 524 + Ok(url) => { 525 + results.push(CheckResult { 526 + id: "identity::pds_endpoint_present", 527 + stage: Stage::Identity, 528 + status: CheckStatus::Pass, 529 + summary: Cow::Borrowed("PDS endpoint present"), 530 + diagnostic: None, 531 + skipped_reason: None, 532 + }); 533 + url 534 + } 535 + Err(_) => { 536 + let span = 537 + json_span_for_key(raw_did_doc.source_bytes.as_ref(), &svc.service_endpoint); 538 + let diag = Box::new(PdsServiceMissingError { 539 + message: format!("PDS endpoint is not a valid URL: {}", svc.service_endpoint), 540 + named_source: NamedSource::new( 541 + raw_did_doc.source_name.clone(), 542 + raw_did_doc.source_bytes.clone(), 543 + ), 544 + span, 545 + }); 546 + results.push(CheckResult { 547 + id: "identity::pds_endpoint_present", 548 + stage: Stage::Identity, 549 + status: CheckStatus::SpecViolation, 550 + summary: Cow::Borrowed("PDS endpoint present"), 551 + diagnostic: Some(diag), 552 + skipped_reason: None, 553 + }); 554 + return IdentityStageOutput { 555 + facts: None, 556 + results, 557 + }; 558 + } 559 + } 560 + } 561 + None => { 562 + let span = json_span_for_key(raw_did_doc.source_bytes.as_ref(), "service"); 563 + let diag = Box::new(PdsServiceMissingError { 564 + message: "DID document is missing the #atproto_pds service entry".to_string(), 565 + named_source: NamedSource::new( 566 + raw_did_doc.source_name.clone(), 567 + raw_did_doc.source_bytes.clone(), 568 + ), 569 + span, 570 + }); 571 + results.push(CheckResult { 572 + id: "identity::pds_endpoint_present", 573 + stage: Stage::Identity, 574 + status: CheckStatus::SpecViolation, 575 + summary: Cow::Borrowed("PDS endpoint present"), 576 + diagnostic: Some(diag), 577 + skipped_reason: None, 578 + }); 579 + return IdentityStageOutput { 580 + facts: None, 581 + results, 582 + }; 583 + } 584 + }; 585 + 586 + // Fetch the labeler record from the PDS. 587 + let (labeler_record_bytes, labeler_policies) = 588 + match fetch_labeler_record(&did, &pds_endpoint).await { 589 + Ok((bytes, policies)) => { 590 + results.push(CheckResult { 591 + id: "identity::labeler_record_fetched", 592 + stage: Stage::Identity, 593 + status: CheckStatus::Pass, 594 + summary: Cow::Borrowed("labeler record fetched"), 595 + diagnostic: None, 596 + skipped_reason: None, 597 + }); 598 + (bytes, policies) 599 + } 600 + Err((_status_opt, msg, is_network)) => { 601 + let status = if is_network { 602 + CheckStatus::NetworkError 603 + } else { 604 + CheckStatus::SpecViolation 605 + }; 606 + 607 + let diag = Box::new(LabelerRecordFetchError { 608 + message: msg, 609 + }); 610 + 611 + results.push(CheckResult { 612 + id: "identity::labeler_record_fetched", 613 + stage: Stage::Identity, 614 + status, 615 + summary: Cow::Borrowed("labeler record fetched"), 616 + diagnostic: Some(diag), 617 + skipped_reason: None, 618 + }); 619 + return IdentityStageOutput { 620 + facts: None, 621 + results, 622 + }; 623 + } 624 + }; 625 + 626 + // Check that the labeler policies are non-empty. 627 + if labeler_policies.label_values.is_empty() { 628 + let span = json_span_for_key(labeler_record_bytes.as_ref(), "policies"); 629 + let diag = Box::new(EmptyPoliciesError { 630 + message: "Labeler record policies.labelValues is empty".to_string(), 631 + named_source: NamedSource::new( 632 + "labeler record".to_string(), 633 + labeler_record_bytes.clone(), 634 + ), 635 + span, 636 + }); 637 + results.push(CheckResult { 638 + id: "identity::labeler_record_policies_nonempty", 639 + stage: Stage::Identity, 640 + status: CheckStatus::SpecViolation, 641 + summary: Cow::Borrowed("labeler record policies nonempty"), 642 + diagnostic: Some(diag), 643 + skipped_reason: None, 644 + }); 645 + return IdentityStageOutput { 646 + facts: None, 647 + results, 648 + }; 649 + } 650 + 651 + results.push(CheckResult { 652 + id: "identity::labeler_record_policies_nonempty", 653 + stage: Stage::Identity, 654 + status: CheckStatus::Pass, 655 + summary: Cow::Borrowed("labeler record policies nonempty"), 656 + diagnostic: None, 657 + skipped_reason: None, 658 + }); 659 + 660 + // All checks passed; populate and return facts. 661 + let facts = IdentityFacts { 662 + did, 663 + raw_did_doc, 664 + labeler_endpoint, 665 + pds_endpoint, 666 + signing_key_id, 667 + signing_key, 668 + labeler_record_bytes, 669 + }; 670 + 671 + IdentityStageOutput { 672 + facts: Some(facts), 673 + results, 674 + } 675 + } 676 + 677 + /// Helper to resolve an identifier (handle or DID) to a DID. 678 + async fn resolve_identifier( 679 + identifier: &AtIdentifier, 680 + explicit_did: &Option<Did>, 681 + http: &dyn HttpClient, 682 + dns: &dyn DnsResolver, 683 + results: &mut Vec<CheckResult>, 684 + ) -> Option<Did> { 685 + let did = match identifier { 686 + AtIdentifier::Handle(handle) => { 687 + match resolve_handle(handle, http, dns).await { 688 + Ok(did) => { 689 + results.push(CheckResult { 690 + id: "identity::target_resolved", 691 + stage: Stage::Identity, 692 + status: CheckStatus::Pass, 693 + summary: Cow::Borrowed("target resolved"), 694 + diagnostic: None, 695 + skipped_reason: None, 696 + }); 697 + Some(did) 698 + } 699 + Err(e) => { 700 + let is_network = matches!( 701 + e, 702 + IdentityError::HttpTransport(_) 703 + | IdentityError::DnsLookupFailed { .. } 704 + | IdentityError::HandleUnresolvable { .. } 705 + ); 706 + let status = if is_network { 707 + CheckStatus::NetworkError 708 + } else { 709 + CheckStatus::SpecViolation 710 + }; 711 + 712 + results.push(CheckResult { 713 + id: "identity::target_resolved", 714 + stage: Stage::Identity, 715 + status, 716 + summary: Cow::Borrowed("target resolved"), 717 + diagnostic: None, 718 + skipped_reason: None, 719 + }); 720 + None 721 + } 722 + } 723 + } 724 + AtIdentifier::Did(did) => { 725 + // If an explicit DID is also provided, that's an error (but already caught in parsing). 726 + results.push(CheckResult { 727 + id: "identity::target_resolved", 728 + stage: Stage::Identity, 729 + status: CheckStatus::Pass, 730 + summary: Cow::Borrowed("target resolved"), 731 + diagnostic: None, 732 + skipped_reason: None, 733 + }); 734 + Some(did.clone()) 735 + } 736 + }; 737 + 738 + // Verify that if an explicit DID was provided, it matches the resolved one. 739 + if let Some(explicit) = explicit_did { 740 + if let Some(resolved) = &did { 741 + if explicit != resolved { 742 + results.push(CheckResult { 743 + id: "identity::target_resolved", 744 + stage: Stage::Identity, 745 + status: CheckStatus::SpecViolation, 746 + summary: Cow::Borrowed("target resolved"), 747 + diagnostic: None, 748 + skipped_reason: None, 749 + }); 750 + return None; 751 + } 752 + } 753 + } 754 + 755 + did 756 + } 757 + 758 + /// Helper to find the signing key in a DID document. 759 + fn find_signing_key(doc: &DidDocument) -> Option<(String, String)> { 760 + let vms = doc.verification_method.as_ref()?; 761 + for vm in vms { 762 + if vm.id.rsplit_once('#').map(|(_, f)| f).unwrap_or("") == "atproto_label" { 763 + let multikey = vm.public_key_multibase.as_ref()?; 764 + return Some((vm.id.clone(), multikey.clone())); 765 + } 766 + } 767 + None 768 + } 769 + 770 + /// Helper to compare two endpoints, normalizing scheme and authority. 771 + fn endpoints_match(url1: &Url, url2: &Url) -> bool { 772 + url1.scheme() == url2.scheme() 773 + && url1.host_str() == url2.host_str() 774 + && url1.port() == url2.port() 775 + } 776 + 777 + /// Helper to find a JSON key span in a byte slice. 778 + /// Simple substring search for now; scans for the literal `"<key>"` pattern. 779 + fn json_span_for_key(bytes: &[u8], key: &str) -> SourceSpan { 780 + let search = format!("\"{}\"", key); 781 + if let Some(pos) = bytes.windows(search.len()).position(|w| w == search.as_bytes()) { 782 + SourceSpan::new((pos as usize).into(), search.len()) 783 + } else { 784 + SourceSpan::new(0.into(), 0) 785 + } 786 + } 787 + 788 + /// Fetch the labeler record from the PDS. 789 + /// Returns (bytes, policies) on success, or (optional_status, message, is_network_error). 790 + async fn fetch_labeler_record( 791 + did: &Did, 792 + pds_endpoint: &Url, 793 + ) -> Result<(Arc<[u8]>, atrium_api::app::bsky::labeler::defs::LabelerPolicies), (Option<u16>, String, bool)> { 794 + // Build the XRPC request URL. 795 + let mut url = pds_endpoint.clone(); 796 + url.set_path("/xrpc/com.atproto.repo.getRecord"); 797 + // URL encoding: the did value is already percent-safe in the `did:` form. 798 + let query = format!( 799 + "repo={}&collection=app.bsky.labeler.service&rkey=self", 800 + did.0 801 + ); 802 + url.set_query(Some(&query)); 803 + 804 + // Perform the HTTP request. 805 + let reqwest_client = reqwest::Client::new(); 806 + match reqwest_client.get(url).send().await { 807 + Ok(response) => { 808 + let status = response.status().as_u16(); 809 + match response.bytes().await { 810 + Ok(body) => { 811 + if status == 404 { 812 + Err((Some(404), "PDS returned 404: labeler record not found".to_string(), false)) 813 + } else if status == 200 { 814 + // Try to deserialize into LabelerPolicies. 815 + match serde_json::from_slice::<atrium_api::app::bsky::labeler::defs::LabelerPolicies>(&body) { 816 + Ok(policies) => Ok((Arc::from(body.to_vec()), policies)), 817 + Err(e) => Err((None, format!("Failed to parse labeler record: {}", e), false)), 818 + } 819 + } else { 820 + Err((Some(status), format!("PDS returned status {}", status), false)) 821 + } 822 + } 823 + Err(e) => Err((None, format!("Failed to read response body: {}", e), true)), 824 + } 825 + } 826 + Err(e) => { 827 + // Network error. 828 + Err(( 829 + None, 830 + format!("PDS request failed: {}", e), 831 + true, 832 + )) 833 + } 834 + } 835 + } 836 + 837 + /// Helper diagnostic for DID document decode errors. 838 + #[derive(Debug, Error, Diagnostic)] 839 + #[error("{message}")] 840 + #[diagnostic(code = "labeler::identity::did_document_fetched")] 841 + struct DidDocumentDecodeError { 842 + message: String, 843 + #[source_code] 844 + named_source: NamedSource<Arc<[u8]>>, 845 + #[label("(document start)")] 846 + span: SourceSpan, 847 + } 848 + 849 + #[cfg(test)] 850 + mod tests { 851 + use super::*; 852 + 853 + #[test] 854 + fn json_span_for_key_simple() { 855 + let json = br#"{"service": [], "other": 123}"#; 856 + let span = json_span_for_key(json, "service"); 857 + assert!(span.offset() > 0 || span.len() > 0); // Should find something. 858 + } 859 + 860 + #[test] 861 + fn json_span_for_key_not_found() { 862 + let json = br#"{"other": 123}"#; 863 + let span = json_span_for_key(json, "service"); 864 + assert_eq!(span.offset(), 0.into()); 865 + assert_eq!(span.len(), 0.into()); 866 + } 867 + }
+30 -16
src/commands/test/labeler/pipeline.rs
··· 179 179 /// Run the full labeler conformance pipeline. 180 180 /// 181 181 /// This is the main driver that orchestrates all validation stages. 182 - /// Currently only identity checks are stubbed; later stages return `Skipped` results. 183 - pub async fn run_pipeline(target: LabelerTarget, _opts: LabelerOptions<'_>) -> LabelerReport { 182 + /// Currently only identity checks are implemented; later stages return `Skipped` results. 183 + pub async fn run_pipeline(target: LabelerTarget, opts: LabelerOptions<'_>) -> LabelerReport { 184 184 // Build initial header from target. 185 - let header = ReportHeader { 185 + let mut header = ReportHeader { 186 186 target: format_target(&target), 187 187 resolved_did: None, 188 188 pds_endpoint: None, ··· 191 191 192 192 let mut report = LabelerReport::new(header); 193 193 194 - // TODO: Implement identity stage (Task 4). 195 - // For now, stub it out so the pipeline compiles. 196 - report.record(CheckResult { 197 - id: "identity::stub", 198 - stage: Stage::Identity, 199 - status: crate::commands::test::labeler::report::CheckStatus::Skipped, 200 - summary: Cow::Borrowed("identity stage (stub)"), 201 - diagnostic: None, 202 - skipped_reason: Some(Cow::Borrowed("not yet implemented (phase 3 task 4)")), 203 - }); 194 + // Run the identity stage. 195 + let identity_output = crate::commands::test::labeler::identity::run(&target, opts.http, opts.dns).await; 196 + 197 + // Populate header from facts if available. 198 + if let Some(ref facts) = identity_output.facts { 199 + report.header.resolved_did = Some(facts.did.to_string()); 200 + report.header.pds_endpoint = Some(facts.pds_endpoint.to_string()); 201 + report.header.labeler_endpoint = Some(facts.labeler_endpoint.to_string()); 202 + } 203 + 204 + // Record all identity stage results. 205 + for result in identity_output.results { 206 + report.record(result); 207 + } 208 + 209 + // If identity facts are not available, skip subsequent stages. 210 + let blocked_reason = if identity_output.facts.is_none() { 211 + Some(Cow::Borrowed("blocked by identity stage failures")) 212 + } else { 213 + None 214 + }; 204 215 205 216 // Stub HTTP stage. 217 + let http_reason = blocked_reason.clone().or_else(|| Some(Cow::Borrowed("not yet implemented (phase 4)"))); 206 218 report.record(CheckResult { 207 219 id: "http::stub", 208 220 stage: Stage::Http, 209 221 status: crate::commands::test::labeler::report::CheckStatus::Skipped, 210 222 summary: Cow::Borrowed("HTTP stage (stub)"), 211 223 diagnostic: None, 212 - skipped_reason: Some(Cow::Borrowed("not yet implemented (phase 4)")), 224 + skipped_reason: http_reason, 213 225 }); 214 226 215 227 // Stub subscription stage. 228 + let sub_reason = blocked_reason.clone().or_else(|| Some(Cow::Borrowed("not yet implemented (phase 5)"))); 216 229 report.record(CheckResult { 217 230 id: "subscription::stub", 218 231 stage: Stage::Subscription, 219 232 status: crate::commands::test::labeler::report::CheckStatus::Skipped, 220 233 summary: Cow::Borrowed("Subscription stage (stub)"), 221 234 diagnostic: None, 222 - skipped_reason: Some(Cow::Borrowed("not yet implemented (phase 5)")), 235 + skipped_reason: sub_reason, 223 236 }); 224 237 225 238 // Stub crypto stage. 239 + let crypto_reason = blocked_reason.or_else(|| Some(Cow::Borrowed("not yet implemented (phase 6)"))); 226 240 report.record(CheckResult { 227 241 id: "crypto::stub", 228 242 stage: Stage::Crypto, 229 243 status: crate::commands::test::labeler::report::CheckStatus::Skipped, 230 244 summary: Cow::Borrowed("Crypto stage (stub)"), 231 245 diagnostic: None, 232 - skipped_reason: Some(Cow::Borrowed("not yet implemented (phase 6)")), 246 + skipped_reason: crypto_reason, 233 247 }); 234 248 235 249 report.finish();