1use std::borrow::Cow;
8use std::sync::Arc;
9use std::time::{Duration, SystemTime, UNIX_EPOCH};
10use url::Url;
11
12use async_trait::async_trait;
13use miette::{Diagnostic, NamedSource, SourceSpan};
14use reqwest::StatusCode;
15use thiserror::Error;
16
17use crate::commands::test::labeler::identity::IdentityFacts;
18use crate::commands::test::labeler::report::{CheckResult, CheckStatus, Stage};
19use crate::common::diagnostics::pretty_json_for_display;
20use crate::common::identity::{Did, is_local_labeler_hostname};
21
22pub mod did_doc_server;
23pub mod pollution;
24pub mod self_mint;
25pub mod sentinel;
26
27#[derive(Debug)]
34pub struct RawCreateReportResponse {
35 pub status: StatusCode,
37 pub content_type: Option<String>,
39 pub raw_body: Arc<[u8]>,
41 pub source_url: String,
43}
44
45#[derive(Debug, Error, Diagnostic)]
52pub enum CreateReportStageError {
53 #[error("createReport transport error: {source}")]
56 #[diagnostic(code = "labeler::report::transport_error")]
57 Transport {
58 #[source]
60 source: Box<dyn std::error::Error + Send + Sync>,
61 },
62}
63
64#[async_trait]
72pub trait CreateReportTee: Send + Sync {
73 async fn post_create_report(
82 &self,
83 auth: Option<&str>,
84 body: &serde_json::Value,
85 ) -> Result<RawCreateReportResponse, CreateReportStageError>;
86}
87
88#[derive(Debug)]
94pub struct RawPdsXrpcResponse {
95 pub status: StatusCode,
97 pub raw_body: Arc<[u8]>,
99 pub content_type: Option<String>,
101 pub source_url: String,
103}
104
105#[async_trait]
111pub trait PdsXrpcClient: Send + Sync {
112 async fn post(
116 &self,
117 path: &str,
118 bearer: Option<&str>,
119 atproto_proxy: Option<&str>,
120 body: &serde_json::Value,
121 ) -> Result<RawPdsXrpcResponse, CreateReportStageError>;
122
123 async fn get(
126 &self,
127 path: &str,
128 bearer: Option<&str>,
129 query: &[(&str, &str)],
130 ) -> Result<RawPdsXrpcResponse, CreateReportStageError>;
131}
132
133pub struct RealPdsXrpcClient {
135 client: reqwest::Client,
136 base: Url,
137}
138
139impl RealPdsXrpcClient {
140 pub fn new(client: reqwest::Client, base: Url) -> Self {
143 Self { client, base }
144 }
145}
146
147#[async_trait]
148impl PdsXrpcClient for RealPdsXrpcClient {
149 async fn post(
150 &self,
151 path: &str,
152 bearer: Option<&str>,
153 atproto_proxy: Option<&str>,
154 body: &serde_json::Value,
155 ) -> Result<RawPdsXrpcResponse, CreateReportStageError> {
156 let mut url = self.base.clone();
157 url.set_path(path);
158 let source_url = url.to_string();
159 let mut req = self
160 .client
161 .post(url.as_str())
162 .header("Content-Type", "application/json")
163 .body(serde_json::to_vec(body).expect("serde_json::Value always serializes"));
164 if let Some(b) = bearer {
165 req = req.header("Authorization", format!("Bearer {b}"));
166 }
167 if let Some(p) = atproto_proxy {
168 req = req.header("atproto-proxy", p);
169 }
170 let resp = req
171 .send()
172 .await
173 .map_err(|e| CreateReportStageError::Transport {
174 source: Box::new(e),
175 })?;
176 let status = resp.status();
177 let content_type = resp
178 .headers()
179 .get(reqwest::header::CONTENT_TYPE)
180 .and_then(|h| h.to_str().ok())
181 .map(|s| s.to_ascii_lowercase());
182 let body = resp
183 .bytes()
184 .await
185 .map_err(|e| CreateReportStageError::Transport {
186 source: Box::new(e),
187 })?;
188 Ok(RawPdsXrpcResponse {
189 status,
190 raw_body: Arc::from(body.as_ref()),
191 content_type,
192 source_url,
193 })
194 }
195
196 async fn get(
197 &self,
198 path: &str,
199 bearer: Option<&str>,
200 query: &[(&str, &str)],
201 ) -> Result<RawPdsXrpcResponse, CreateReportStageError> {
202 let mut url = self.base.clone();
203 url.set_path(path);
204 {
205 let mut pairs = url.query_pairs_mut();
206 for (k, v) in query {
207 pairs.append_pair(k, v);
208 }
209 }
210 let source_url = url.to_string();
211 let mut req = self.client.get(url.as_str());
212 if let Some(b) = bearer {
213 req = req.header("Authorization", format!("Bearer {b}"));
214 }
215 let resp = req
216 .send()
217 .await
218 .map_err(|e| CreateReportStageError::Transport {
219 source: Box::new(e),
220 })?;
221 let status = resp.status();
222 let content_type = resp
223 .headers()
224 .get(reqwest::header::CONTENT_TYPE)
225 .and_then(|h| h.to_str().ok())
226 .map(|s| s.to_ascii_lowercase());
227 let body = resp
228 .bytes()
229 .await
230 .map_err(|e| CreateReportStageError::Transport {
231 source: Box::new(e),
232 })?;
233 Ok(RawPdsXrpcResponse {
234 status,
235 raw_body: Arc::from(body.as_ref()),
236 content_type,
237 source_url,
238 })
239 }
240}
241
242pub struct RealCreateReportTee {
244 client: reqwest::Client,
245 endpoint: Url,
246}
247
248impl RealCreateReportTee {
249 pub fn new(client: reqwest::Client, endpoint: Url) -> Self {
254 Self { client, endpoint }
255 }
256}
257
258#[async_trait]
259impl CreateReportTee for RealCreateReportTee {
260 async fn post_create_report(
261 &self,
262 auth: Option<&str>,
263 body: &serde_json::Value,
264 ) -> Result<RawCreateReportResponse, CreateReportStageError> {
265 let mut url = self.endpoint.clone();
266 url.set_path("xrpc/com.atproto.moderation.createReport");
267 let source_url = url.to_string();
268
269 tracing::debug!(
270 url = %source_url,
271 auth_kind = match auth {
272 None => "none",
273 Some(t) if !t.starts_with("ey") => "malformed",
274 Some(_) => "jwt",
275 },
276 "report stage: issuing createReport POST"
277 );
278
279 let mut req = self
280 .client
281 .post(url.as_str())
282 .header("Content-Type", "application/json")
283 .body(serde_json::to_vec(body).expect("serde_json::Value always serializes"));
284 if let Some(token) = auth {
285 req = req.header("Authorization", format!("Bearer {token}"));
286 }
287
288 let response = req
289 .send()
290 .await
291 .map_err(|e| CreateReportStageError::Transport {
292 source: Box::new(e),
293 })?;
294
295 let status = response.status();
296 let content_type = response
297 .headers()
298 .get(reqwest::header::CONTENT_TYPE)
299 .and_then(|h| h.to_str().ok())
300 .map(|s| s.to_ascii_lowercase());
301
302 let body_bytes = response
303 .bytes()
304 .await
305 .map_err(|e| CreateReportStageError::Transport {
306 source: Box::new(e),
307 })?;
308
309 tracing::debug!(
310 url = %source_url,
311 status = %status,
312 body_len = body_bytes.len(),
313 "report stage: createReport response received"
314 );
315
316 Ok(RawCreateReportResponse {
317 status,
318 content_type,
319 raw_body: Arc::from(body_bytes.as_ref()),
320 source_url,
321 })
322 }
323}
324
325#[derive(Debug)]
330pub enum PdsJwtFetchError {
331 Transport(CreateReportStageError),
332 Failed(RawPdsXrpcResponse),
333 InvalidBody {
334 resp: RawPdsXrpcResponse,
335 error: serde_json::Error,
336 },
337 MissingToken(RawPdsXrpcResponse),
338}
339
340impl std::fmt::Display for PdsJwtFetchError {
341 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
342 match self {
343 PdsJwtFetchError::Transport(e) => write!(f, "getServiceAuth transport: {e}"),
344 PdsJwtFetchError::Failed(resp) => {
345 write!(f, "getServiceAuth returned status {}", resp.status)
346 }
347 PdsJwtFetchError::InvalidBody { error, .. } => {
348 write!(f, "getServiceAuth body not JSON: {error}")
349 }
350 PdsJwtFetchError::MissingToken(_) => write!(f, "getServiceAuth response missing token"),
351 }
352 }
353}
354
355impl PdsJwtFetchError {
356 fn into_diagnostic(self) -> Option<Box<dyn miette::Diagnostic + Send + Sync>> {
357 match self {
358 Self::Transport(_) => None,
359 Self::Failed(resp) | Self::InvalidBody { resp, .. } | Self::MissingToken(resp) => {
360 let (source_code, span) = body_as_named_source_from_pds(&resp);
361 let diag = CreateReportDiagnostic::PdsServiceAuthRejected {
362 origin: ResponseOrigin::Pds,
363 status: resp.status.as_u16(),
364 source_code,
365 span,
366 };
367 Some(Box::new(diag))
368 }
369 }
370 }
371}
372
373pub struct PdsJwtFetcher<'a> {
376 client: &'a dyn PdsXrpcClient,
377}
378
379impl<'a> PdsJwtFetcher<'a> {
380 pub fn new(client: &'a dyn PdsXrpcClient) -> Self {
382 Self { client }
383 }
384
385 pub async fn fetch_with_jwt(
389 &self,
390 access_jwt: &str,
391 aud: &str,
392 lxm: &str,
393 exp_absolute_unix: i64,
394 ) -> Result<String, PdsJwtFetchError> {
395 let exp_s = exp_absolute_unix.to_string();
397 let resp = self
398 .client
399 .get(
400 "xrpc/com.atproto.server.getServiceAuth",
401 Some(access_jwt),
402 &[("aud", aud), ("lxm", lxm), ("exp", &exp_s)],
403 )
404 .await
405 .map_err(PdsJwtFetchError::Transport)?;
406 if !resp.status.is_success() {
407 return Err(PdsJwtFetchError::Failed(resp));
408 }
409 let token = match serde_json::from_slice::<serde_json::Value>(&resp.raw_body) {
410 Err(error) => Err(PdsJwtFetchError::InvalidBody { resp, error }),
411 Ok(auth) => auth["token"]
412 .as_str()
413 .map(|s| s.to_string())
414 .ok_or_else(|| PdsJwtFetchError::MissingToken(resp)),
415 }?;
416
417 Ok(token)
418 }
419}
420
421pub struct PdsProxiedPoster<'a> {
425 client: &'a dyn PdsXrpcClient,
426}
427
428impl<'a> PdsProxiedPoster<'a> {
429 pub fn new(client: &'a dyn PdsXrpcClient) -> Self {
431 Self { client }
432 }
433
434 pub async fn post(
438 &self,
439 labeler_did: &str,
440 access_jwt: &str,
441 body: &serde_json::Value,
442 ) -> Result<RawPdsXrpcResponse, CreateReportStageError> {
443 self.client
444 .post(
445 "xrpc/com.atproto.moderation.createReport",
446 Some(access_jwt),
447 Some(&format!("{labeler_did}#atproto_labeler")),
448 body,
449 )
450 .await
451 }
452}
453
454#[derive(Debug, Clone, Default)]
458pub struct CreateReportFacts {
459 pub self_mint_succeeded: Option<bool>,
462 pub pds_service_auth_succeeded: Option<bool>,
464 pub pds_proxied_succeeded: Option<bool>,
466}
467
468#[derive(Debug)]
471pub struct CreateReportStageOutput {
472 pub facts: Option<CreateReportFacts>,
473 pub results: Vec<CheckResult>,
474}
475
476#[derive(Debug, Clone, Copy, PartialEq, Eq)]
482pub enum Check {
483 ContractPublished,
484 UnauthenticatedRejected,
485 MalformedBearerRejected,
486 WrongAudRejected,
487 WrongLxmRejected,
488 ExpiredRejected,
489 RejectedShapeReturns400,
490 SelfMintAccepted,
491 PdsServiceAuthAccepted,
492 PdsProxiedAccepted,
493}
494
495impl Check {
496 pub fn id(self) -> &'static str {
498 match self {
499 Check::ContractPublished => "report::contract_published",
500 Check::UnauthenticatedRejected => "report::unauthenticated_rejected",
501 Check::MalformedBearerRejected => "report::malformed_bearer_rejected",
502 Check::WrongAudRejected => "report::wrong_aud_rejected",
503 Check::WrongLxmRejected => "report::wrong_lxm_rejected",
504 Check::ExpiredRejected => "report::expired_rejected",
505 Check::RejectedShapeReturns400 => "report::rejected_shape_returns_400",
506 Check::SelfMintAccepted => "report::self_mint_accepted",
507 Check::PdsServiceAuthAccepted => "report::pds_service_auth_accepted",
508 Check::PdsProxiedAccepted => "report::pds_proxied_accepted",
509 }
510 }
511
512 pub const ORDER: [Check; 10] = [
514 Check::ContractPublished,
515 Check::UnauthenticatedRejected,
516 Check::MalformedBearerRejected,
517 Check::WrongAudRejected,
518 Check::WrongLxmRejected,
519 Check::ExpiredRejected,
520 Check::RejectedShapeReturns400,
521 Check::SelfMintAccepted,
522 Check::PdsServiceAuthAccepted,
523 Check::PdsProxiedAccepted,
524 ];
525
526 pub fn pass(self) -> CheckResult {
528 CheckResult {
529 id: self.id(),
530 stage: Stage::Report,
531 status: CheckStatus::Pass,
532 summary: Cow::Borrowed(self.default_summary_pass()),
533 diagnostic: None,
534 skipped_reason: None,
535 }
536 }
537
538 pub fn spec_violation(self, diagnostic: CreateReportDiagnostic) -> CheckResult {
541 CheckResult {
542 id: self.id(),
543 stage: Stage::Report,
544 status: CheckStatus::SpecViolation,
545 summary: Cow::Borrowed(self.default_summary_fail()),
546 diagnostic: Some(Box::new(diagnostic) as _),
547 skipped_reason: None,
548 }
549 }
550
551 pub fn advisory(self, diagnostic: CreateReportDiagnostic) -> CheckResult {
553 CheckResult {
554 id: self.id(),
555 stage: Stage::Report,
556 status: CheckStatus::Advisory,
557 summary: Cow::Borrowed(self.default_summary_fail()),
558 diagnostic: Some(Box::new(diagnostic) as _),
559 skipped_reason: None,
560 }
561 }
562
563 pub fn network_error(self, message: String) -> CheckResult {
565 CheckResult {
566 id: self.id(),
567 stage: Stage::Report,
568 status: CheckStatus::NetworkError,
569 summary: Cow::Owned(format!("{}: {message}", self.default_summary_fail())),
570 diagnostic: None,
571 skipped_reason: None,
572 }
573 }
574
575 pub fn skip(self, reason: &'static str) -> CheckResult {
577 CheckResult {
578 id: self.id(),
579 stage: Stage::Report,
580 status: CheckStatus::Skipped,
581 summary: Cow::Borrowed(self.default_summary_pass()),
582 diagnostic: None,
583 skipped_reason: Some(Cow::Borrowed(reason)),
584 }
585 }
586
587 fn default_summary_pass(self) -> &'static str {
588 match self {
589 Check::ContractPublished => "Labeler advertises reportable shape",
590 Check::UnauthenticatedRejected => "Unauthenticated report rejected",
591 Check::MalformedBearerRejected => "Malformed bearer rejected",
592 Check::WrongAudRejected => "JWT with wrong `aud` rejected",
593 Check::WrongLxmRejected => "JWT with wrong `lxm` rejected",
594 Check::ExpiredRejected => "Expired JWT rejected",
595 Check::RejectedShapeReturns400 => "Invalid shape returns 400 InvalidRequest",
596 Check::SelfMintAccepted => "Self-mint report accepted",
597 Check::PdsServiceAuthAccepted => "PDS-minted JWT accepted",
598 Check::PdsProxiedAccepted => "PDS-proxied report accepted",
599 }
600 }
601
602 fn default_summary_fail(self) -> &'static str {
603 match self {
604 Check::ContractPublished => "Labeler does not advertise a reportable shape",
605 Check::UnauthenticatedRejected => {
606 "Unauthenticated report accepted (should have been rejected)"
607 }
608 Check::MalformedBearerRejected => {
609 "Malformed bearer accepted (should have been rejected)"
610 }
611 Check::WrongAudRejected => "JWT with wrong `aud` accepted",
612 Check::WrongLxmRejected => "JWT with wrong `lxm` accepted",
613 Check::ExpiredRejected => "Expired JWT accepted",
614 Check::RejectedShapeReturns400 => "Rejection status was not 400 InvalidRequest",
615 Check::SelfMintAccepted => "Self-mint report rejected",
616 Check::PdsServiceAuthAccepted => "PDS-minted JWT rejected",
617 Check::PdsProxiedAccepted => "PDS-proxied report rejected",
618 }
619 }
620}
621
622pub struct CreateReportRunOptions<'a> {
626 pub commit_report: bool,
627 pub force_self_mint: bool,
628 pub self_mint_curve: self_mint::SelfMintCurve,
629 pub report_subject_override: Option<&'a crate::common::identity::Did>,
630 pub self_mint_signer: Option<&'a self_mint::SelfMintSigner>,
631 pub pds_credentials: Option<&'a crate::commands::test::labeler::pipeline::PdsCredentials>,
632 pub pds_xrpc_client: Option<&'a dyn PdsXrpcClient>,
633 pub pds_resolution_error: Option<&'a str>,
638 pub run_id: &'a str,
639}
640
641pub async fn run(
648 identity_facts: Option<&crate::commands::test::labeler::identity::IdentityFacts>,
649 report_tee: &dyn CreateReportTee,
650 opts: &CreateReportRunOptions<'_>,
651) -> CreateReportStageOutput {
652 let mut results = Vec::with_capacity(10);
653
654 let Some(id_facts) = identity_facts else {
657 for c in Check::ORDER {
658 results.push(c.skip("blocked by identity stage"));
659 }
660 return CreateReportStageOutput {
661 facts: None,
662 results,
663 };
664 };
665
666 let reason_types = id_facts.reason_types.as_ref();
668 let subject_types = id_facts.subject_types.as_ref();
669 let has_reason_types = reason_types.map(|v| !v.is_empty()).unwrap_or(false);
670 let has_subject_types = subject_types.map(|v| !v.is_empty()).unwrap_or(false);
671 let contract_advertised = has_reason_types && has_subject_types;
672
673 if !contract_advertised {
681 if opts.commit_report {
682 let diag = CreateReportDiagnostic::ContractMissing {
685 has_reason_types,
686 has_subject_types,
687 };
688 results.push(Check::ContractPublished.spec_violation(diag));
689 for c in Check::ORDER.iter().skip(1).copied() {
690 results.push(c.skip("blocked by `report::contract_published`"));
691 }
692 } else {
693 results.push(
695 Check::ContractPublished.skip("labeler does not advertise report acceptance"),
696 );
697 for c in Check::ORDER.iter().skip(1).copied() {
698 results.push(c.skip("labeler does not advertise report acceptance"));
699 }
700 }
701 return CreateReportStageOutput {
702 facts: None,
703 results,
704 };
705 }
706
707 results.push(Check::ContractPublished.pass());
710
711 let negative_body = build_minimal_report_body(id_facts);
716
717 match report_tee.post_create_report(None, &negative_body).await {
719 Ok(resp) => match RejectionShape::classify(&resp) {
720 RejectionShape::Conformant { .. } => {
721 results.push(Check::UnauthenticatedRejected.pass());
722 }
723 RejectionShape::ConformantStatusNonConformantShape => {
724 results.push(CheckResult {
725 summary: Cow::Borrowed(
726 "Unauthenticated report rejected (status 401, non-conformant envelope)",
727 ),
728 ..Check::UnauthenticatedRejected.pass()
729 });
730 }
731 RejectionShape::WrongStatus { status } => {
732 let status_u16 = status.as_u16();
733 let (source_code, span) = body_as_named_source(&resp);
734 let diag = CreateReportDiagnostic::UnauthenticatedAccepted {
735 status: status_u16,
736 source_code,
737 span,
738 };
739 results.push(Check::UnauthenticatedRejected.spec_violation(diag));
740 }
741 },
742 Err(CreateReportStageError::Transport { source }) => {
743 results.push(Check::UnauthenticatedRejected.network_error(source.to_string()));
744 }
745 }
746
747 match report_tee
749 .post_create_report(Some("not-a-jwt"), &negative_body)
750 .await
751 {
752 Ok(resp) => match RejectionShape::classify(&resp) {
753 RejectionShape::Conformant { .. } => {
754 results.push(Check::MalformedBearerRejected.pass());
755 }
756 RejectionShape::ConformantStatusNonConformantShape => {
757 results.push(CheckResult {
758 summary: Cow::Borrowed(
759 "Malformed bearer rejected (status 401, non-conformant envelope)",
760 ),
761 ..Check::MalformedBearerRejected.pass()
762 });
763 }
764 RejectionShape::WrongStatus { status } => {
765 let status_u16 = status.as_u16();
766 let (source_code, span) = body_as_named_source(&resp);
767 let diag = CreateReportDiagnostic::MalformedBearerAccepted {
768 status: status_u16,
769 source_code,
770 span,
771 };
772 results.push(Check::MalformedBearerRejected.spec_violation(diag));
773 }
774 },
775 Err(CreateReportStageError::Transport { source }) => {
776 results.push(Check::MalformedBearerRejected.network_error(source.to_string()));
777 }
778 }
779
780 let is_local_labeler = is_local_labeler_hostname(&id_facts.labeler_endpoint);
783 let self_mint_viable = opts.force_self_mint || is_local_labeler;
784
785 let signer_for_negative = if self_mint_viable {
786 opts.self_mint_signer
787 } else {
788 None
789 };
790
791 let now = SystemTime::now()
793 .duration_since(UNIX_EPOCH)
794 .map(|d| d.as_secs() as i64)
795 .unwrap_or(0);
796
797 if let Some(signer) = signer_for_negative {
803 let lxm = "com.atproto.moderation.createReport";
810 let template =
811 signer.valid_claims_template(&id_facts.did, lxm, now, Duration::from_secs(60));
812
813 let negative_body = build_minimal_report_body(id_facts);
814
815 {
817 let mut claims = template.clone();
818 claims.aud = "did:plc:0000000000000000000000000".to_string();
819 let token = signer.sign_jwt(claims);
820 match report_tee
821 .post_create_report(Some(&token), &negative_body)
822 .await
823 {
824 Ok(resp) => match RejectionShape::classify(&resp) {
825 RejectionShape::Conformant { .. } => {
826 results.push(Check::WrongAudRejected.pass())
827 }
828 RejectionShape::ConformantStatusNonConformantShape => {
829 results.push(CheckResult {
830 summary: Cow::Borrowed(
831 "Rejected with 401 but envelope is non-conformant",
832 ),
833 ..Check::WrongAudRejected.pass()
834 })
835 }
836 RejectionShape::WrongStatus { .. } => {
837 let (source_code, span) = body_as_named_source(&resp);
838 let diag = CreateReportDiagnostic::WrongAudAccepted {
839 status: resp.status.as_u16(),
840 source_code,
841 span,
842 };
843 results.push(Check::WrongAudRejected.spec_violation(diag));
844 }
845 },
846 Err(CreateReportStageError::Transport { source }) => {
847 results.push(Check::WrongAudRejected.network_error(source.to_string()));
848 }
849 }
850 }
851
852 {
854 let mut claims = template.clone();
855 claims.lxm = "com.atproto.server.getSession".to_string();
856 let token = signer.sign_jwt(claims);
857 match report_tee
858 .post_create_report(Some(&token), &negative_body)
859 .await
860 {
861 Ok(resp) => match RejectionShape::classify(&resp) {
862 RejectionShape::Conformant { .. } => {
863 results.push(Check::WrongLxmRejected.pass())
864 }
865 RejectionShape::ConformantStatusNonConformantShape => {
866 results.push(CheckResult {
867 summary: Cow::Borrowed(
868 "Rejected with 401 but envelope is non-conformant",
869 ),
870 ..Check::WrongLxmRejected.pass()
871 })
872 }
873 RejectionShape::WrongStatus { .. } => {
874 let (source_code, span) = body_as_named_source(&resp);
875 let diag = CreateReportDiagnostic::WrongLxmAccepted {
876 status: resp.status.as_u16(),
877 source_code,
878 span,
879 };
880 results.push(Check::WrongLxmRejected.spec_violation(diag));
881 }
882 },
883 Err(CreateReportStageError::Transport { source }) => {
884 results.push(Check::WrongLxmRejected.network_error(source.to_string()));
885 }
886 }
887 }
888
889 {
891 let mut claims = template.clone();
892 claims.exp = now - 300;
893 claims.iat = now - 360;
894 let token = signer.sign_jwt(claims);
895 match report_tee
896 .post_create_report(Some(&token), &negative_body)
897 .await
898 {
899 Ok(resp) => match RejectionShape::classify(&resp) {
900 RejectionShape::Conformant { .. } => {
901 results.push(Check::ExpiredRejected.pass())
902 }
903 RejectionShape::ConformantStatusNonConformantShape => {
904 results.push(CheckResult {
905 summary: Cow::Borrowed(
906 "Rejected with 401 but envelope is non-conformant",
907 ),
908 ..Check::ExpiredRejected.pass()
909 })
910 }
911 RejectionShape::WrongStatus { .. } => {
912 let (source_code, span) = body_as_named_source(&resp);
913 let diag = CreateReportDiagnostic::ExpiredAccepted {
914 status: resp.status.as_u16(),
915 source_code,
916 span,
917 };
918 results.push(Check::ExpiredRejected.spec_violation(diag));
919 }
920 },
921 Err(CreateReportStageError::Transport { source }) => {
922 results.push(Check::ExpiredRejected.network_error(source.to_string()));
923 }
924 }
925 }
926
927 {
929 let claims = template.clone();
930 let token = signer.sign_jwt(claims);
931 let bogus_reason_type = synth_unadvertised_reason_type(id_facts);
933 let invalid_body = {
934 let mut body = negative_body.clone();
935 if let Some(obj) = body.as_object_mut() {
936 obj.insert(
937 "reasonType".to_string(),
938 serde_json::Value::String(bogus_reason_type),
939 );
940 }
941 body
942 };
943 match report_tee
944 .post_create_report(Some(&token), &invalid_body)
945 .await
946 {
947 Ok(resp) => {
948 let envelope = XrpcErrorEnvelope::parse(&resp.raw_body);
949 let error_name = envelope.as_ref().and_then(|e| e.error.clone());
950 if resp.status == reqwest::StatusCode::BAD_REQUEST
951 && error_name.as_deref() == Some("InvalidRequest")
952 {
953 results.push(Check::RejectedShapeReturns400.pass());
955 } else if resp.status == reqwest::StatusCode::UNAUTHORIZED
956 || resp.status.is_server_error()
957 {
958 let (source_code, span) = body_as_named_source(&resp);
960 let diag = CreateReportDiagnostic::ShapeNot400 {
961 status: resp.status.as_u16(),
962 error_name: error_name.clone(),
963 source_code,
964 span,
965 };
966 results.push(Check::RejectedShapeReturns400.advisory(diag));
967 } else if resp.status == reqwest::StatusCode::BAD_REQUEST {
968 let (source_code, span) = body_as_named_source(&resp);
970 let diag = CreateReportDiagnostic::ShapeNot400 {
971 status: 400,
972 error_name: error_name.clone(),
973 source_code,
974 span,
975 };
976 results.push(Check::RejectedShapeReturns400.advisory(diag));
977 } else {
978 let (source_code, span) = body_as_named_source(&resp);
983 let diag = CreateReportDiagnostic::ShapeNot400 {
984 status: resp.status.as_u16(),
985 error_name,
986 source_code,
987 span,
988 };
989 results.push(Check::RejectedShapeReturns400.advisory(diag));
990 }
991 }
992 Err(CreateReportStageError::Transport { source }) => {
993 results.push(Check::RejectedShapeReturns400.network_error(source.to_string()));
994 }
995 }
996 }
997 } else {
998 let reason = "self-mint required; labeler endpoint appears non-local (override with --force-self-mint)";
999 for c in [
1000 Check::WrongAudRejected,
1001 Check::WrongLxmRejected,
1002 Check::ExpiredRejected,
1003 Check::RejectedShapeReturns400,
1004 ] {
1005 results.push(c.skip(reason));
1006 }
1007 }
1008
1009 if !opts.commit_report {
1015 results.push(Check::SelfMintAccepted.skip("commit gated behind --commit-report"));
1016 } else if let Some(signer) = signer_for_negative {
1017 let reason_type = pollution::choose_reason_type(
1021 id_facts.reason_types.as_deref().unwrap_or(&[]),
1022 is_local_labeler,
1023 );
1024 let subject = pollution::choose_subject(
1025 id_facts.subject_types.as_deref().unwrap_or(&[]),
1026 signer.issuer_did(),
1027 opts.report_subject_override,
1028 is_local_labeler,
1029 );
1030 let sentinel = sentinel::build(opts.run_id, SystemTime::now());
1031 let positive_body = serde_json::json!({
1032 "reasonType": reason_type,
1033 "subject": subject,
1034 "reason": sentinel,
1035 });
1036
1037 let claims = signer.valid_claims_template(
1041 &id_facts.did,
1042 "com.atproto.moderation.createReport",
1043 now,
1044 Duration::from_secs(60),
1045 );
1046 let token = signer.sign_jwt(claims);
1047
1048 match report_tee
1049 .post_create_report(Some(&token), &positive_body)
1050 .await
1051 {
1052 Ok(resp) if resp.status.is_success() => {
1053 let body_ok = serde_json::from_slice::<serde_json::Value>(&resp.raw_body)
1056 .ok()
1057 .and_then(|v| v.get("id").and_then(|id| id.as_i64()))
1058 .is_some();
1059 if body_ok {
1060 results.push(Check::SelfMintAccepted.pass());
1061 } else {
1062 results.push(CheckResult {
1066 summary: Cow::Borrowed(
1067 "Self-mint report accepted (2xx), body did not match createReport#output shape",
1068 ),
1069 ..Check::SelfMintAccepted.pass()
1070 });
1071 }
1072 }
1073 Ok(resp) => {
1074 let (source_code, span) = body_as_named_source(&resp);
1076 let diag = CreateReportDiagnostic::SelfMintRejected {
1077 status: resp.status.as_u16(),
1078 source_code,
1079 span,
1080 };
1081 results.push(Check::SelfMintAccepted.spec_violation(diag));
1082 }
1083 Err(CreateReportStageError::Transport { source }) => {
1084 results.push(Check::SelfMintAccepted.network_error(source.to_string()));
1085 }
1086 }
1087 } else {
1088 let reason = "self-mint required; labeler endpoint appears non-local (override with --force-self-mint)";
1091 results.push(Check::SelfMintAccepted.skip(reason));
1092 }
1093
1094 let pds_gate_reason: &'static str = "requires --handle, --app-password, and --commit-report";
1097 let pds_ready =
1098 opts.commit_report && opts.pds_credentials.is_some() && opts.pds_xrpc_client.is_some();
1099 let pds_resolution_failed = opts.commit_report
1103 && opts.pds_credentials.is_some()
1104 && opts.pds_xrpc_client.is_none()
1105 && opts.pds_resolution_error.is_some();
1106
1107 if pds_resolution_failed {
1108 let msg = opts
1109 .pds_resolution_error
1110 .expect("pds_resolution_failed implies Some")
1111 .to_string();
1112 results.push(Check::PdsServiceAuthAccepted.network_error(msg.clone()));
1113 results.push(Check::PdsProxiedAccepted.network_error(msg));
1114 } else if !pds_ready {
1115 results.push(Check::PdsServiceAuthAccepted.skip(pds_gate_reason));
1116 results.push(Check::PdsProxiedAccepted.skip(pds_gate_reason));
1117 } else {
1118 let creds = opts.pds_credentials.expect("pds_ready implies creds");
1120 let pds_client = opts.pds_xrpc_client.expect("pds_ready implies client");
1121
1122 let is_local = is_local_labeler;
1124 let reason_type = pollution::choose_reason_type(
1125 id_facts.reason_types.as_deref().unwrap_or(&[]),
1126 is_local,
1127 );
1128
1129 match fetch_session_and_did(pds_client, &creds.handle, &creds.app_password).await {
1132 Err(message) => {
1133 results.push(Check::PdsServiceAuthAccepted.network_error(message.clone()));
1134 results.push(Check::PdsProxiedAccepted.network_error(message));
1136 }
1137 Ok(session) => {
1138 let user_did = Did(session.did);
1139 let access_jwt = session.access_jwt;
1140 let subject = pollution::choose_subject(
1141 id_facts.subject_types.as_deref().unwrap_or(&[]),
1142 &user_did,
1143 opts.report_subject_override,
1144 is_local,
1145 );
1146 let sentinel = sentinel::build(opts.run_id, SystemTime::now());
1147 let pds_body = serde_json::json!({
1148 "reasonType": reason_type,
1149 "subject": subject,
1150 "reason": sentinel,
1151 });
1152
1153 let exp_abs = now + 60;
1155 let fetcher = PdsJwtFetcher::new(pds_client);
1156 match fetcher
1157 .fetch_with_jwt(
1158 &access_jwt,
1159 &id_facts.did.0,
1160 "com.atproto.moderation.createReport",
1161 exp_abs,
1162 )
1163 .await
1164 {
1165 Err(e) => {
1166 let message = e.to_string();
1167 let diagnostic = e.into_diagnostic();
1168 results.push(CheckResult {
1169 diagnostic,
1170 ..Check::PdsServiceAuthAccepted.network_error(message)
1171 });
1172 }
1173 Ok(service_jwt) => {
1174 match report_tee
1175 .post_create_report(Some(&service_jwt), &pds_body)
1176 .await
1177 {
1178 Ok(resp) if resp.status.is_success() => {
1179 results.push(Check::PdsServiceAuthAccepted.pass());
1180 }
1181 Ok(resp) => {
1182 let (source_code, span) = body_as_named_source(&resp);
1183 let diag = CreateReportDiagnostic::PdsServiceAuthRejected {
1184 origin: ResponseOrigin::Labeler,
1185 status: resp.status.as_u16(),
1186 source_code,
1187 span,
1188 };
1189 results.push(Check::PdsServiceAuthAccepted.spec_violation(diag));
1190 }
1191 Err(CreateReportStageError::Transport { source }) => {
1192 results.push(
1194 Check::PdsServiceAuthAccepted.network_error(source.to_string()),
1195 );
1196 }
1197 }
1198 }
1199 }
1200
1201 let proxier = PdsProxiedPoster::new(pds_client);
1203 match proxier.post(&id_facts.did.0, &access_jwt, &pds_body).await {
1204 Err(CreateReportStageError::Transport { source }) => {
1205 results.push(Check::PdsProxiedAccepted.network_error(source.to_string()));
1207 }
1208 Ok(resp) if resp.status.is_success() => {
1209 results.push(Check::PdsProxiedAccepted.pass());
1210 }
1211 Ok(resp) => {
1212 let (source_code, span) = body_as_named_source_from_pds(&resp);
1215 let envelope = XrpcErrorEnvelope::parse(&resp.raw_body);
1216 let err_name = envelope.as_ref().and_then(|e| e.error.clone());
1217 let is_upstream_label_error = matches!(
1218 err_name.as_deref(),
1219 Some("UpstreamError") | Some("UpstreamFailure")
1220 ) || resp.status.as_u16() == 502
1221 || resp.status.as_u16() == 504;
1222 if is_upstream_label_error {
1223 let diag = CreateReportDiagnostic::PdsProxiedRejected {
1225 origin: ResponseOrigin::Labeler,
1226 status: resp.status.as_u16(),
1227 source_code,
1228 span,
1229 };
1230 results.push(Check::PdsProxiedAccepted.spec_violation(diag));
1231 } else {
1232 let diag = CreateReportDiagnostic::PdsProxiedRejected {
1234 origin: ResponseOrigin::Pds,
1235 status: resp.status.as_u16(),
1236 source_code,
1237 span,
1238 };
1239 results.push(CheckResult {
1240 diagnostic: Some(Box::new(diag)),
1241 ..Check::PdsProxiedAccepted.network_error(format!(
1242 "PDS rejected proxy attempt with status {}",
1243 resp.status
1244 ))
1245 });
1246 }
1247 }
1248 }
1249 }
1250 }
1251 }
1252
1253 CreateReportStageOutput {
1254 facts: None,
1255 results,
1256 }
1257}
1258
1259struct SessionResult {
1263 did: String,
1264 access_jwt: String,
1265}
1266
1267async fn fetch_session_and_did(
1268 client: &dyn PdsXrpcClient,
1269 handle: &str,
1270 app_password: &str,
1271) -> Result<SessionResult, String> {
1272 let body = serde_json::json!({ "identifier": handle, "password": app_password });
1273 let resp = client
1274 .post("xrpc/com.atproto.server.createSession", None, None, &body)
1275 .await
1276 .map_err(|e| format!("createSession transport: {e}"))?;
1277 if !resp.status.is_success() {
1278 return Err(format!("createSession returned {}", resp.status));
1279 }
1280 let session: serde_json::Value =
1281 serde_json::from_slice(&resp.raw_body).map_err(|e| format!("createSession body: {e}"))?;
1282 let did = session["did"]
1283 .as_str()
1284 .ok_or("createSession missing did")?
1285 .to_string();
1286 let access_jwt = session["accessJwt"]
1287 .as_str()
1288 .ok_or("createSession missing accessJwt")?
1289 .to_string();
1290 Ok(SessionResult { did, access_jwt })
1291}
1292
1293fn synth_unadvertised_reason_type(facts: &IdentityFacts) -> String {
1298 let empty = Vec::new();
1299 let advertised: &[String] = facts.reason_types.as_ref().unwrap_or(&empty);
1300 for i in 0..1000 {
1301 let candidate = format!("xyz.atprotodevtool.conformance.defs#unadvertised{i:03}");
1302 if !advertised.iter().any(|r| r == &candidate) {
1303 return candidate;
1304 }
1305 }
1306 "xyz.atprotodevtool.conformance.defs#unadvertisedFallback".to_string()
1308}
1309
1310#[cfg(test)]
1311mod check_tests {
1312 use super::*;
1313
1314 #[test]
1315 fn check_ids_are_unique_and_report_namespaced() {
1316 let mut seen = std::collections::HashSet::new();
1317 for c in Check::ORDER {
1318 let id = c.id();
1319 assert!(id.starts_with("report::"), "{id} not in report:: namespace");
1320 assert!(seen.insert(id), "duplicate check id: {id}");
1321 }
1322 assert_eq!(Check::ORDER.len(), 10, "DoD requires exactly 10 checks");
1323 }
1324}
1325
1326#[derive(Debug, Clone)]
1331pub struct XrpcErrorEnvelope {
1332 pub error: Option<String>,
1335 pub message: Option<String>,
1337}
1338
1339impl XrpcErrorEnvelope {
1340 pub fn parse(body: &[u8]) -> Option<Self> {
1344 let v: serde_json::Value = serde_json::from_slice(body).ok()?;
1345 let obj = v.as_object()?;
1346 Some(Self {
1347 error: obj.get("error").and_then(|x| x.as_str()).map(String::from),
1348 message: obj
1349 .get("message")
1350 .and_then(|x| x.as_str())
1351 .map(String::from),
1352 })
1353 }
1354
1355 pub fn has_nonempty_error(&self) -> bool {
1357 self.error
1358 .as_deref()
1359 .map(|s| !s.is_empty())
1360 .unwrap_or(false)
1361 }
1362}
1363
1364pub enum RejectionShape {
1366 Conformant {
1368 envelope: XrpcErrorEnvelope,
1370 },
1371 ConformantStatusNonConformantShape,
1375 WrongStatus {
1377 status: reqwest::StatusCode,
1379 },
1380}
1381
1382impl RejectionShape {
1383 pub fn classify(resp: &RawCreateReportResponse) -> Self {
1385 if resp.status != reqwest::StatusCode::UNAUTHORIZED {
1386 return Self::WrongStatus {
1387 status: resp.status,
1388 };
1389 }
1390 match XrpcErrorEnvelope::parse(&resp.raw_body) {
1391 Some(env) if env.has_nonempty_error() => Self::Conformant { envelope: env },
1392 _ => Self::ConformantStatusNonConformantShape,
1393 }
1394 }
1395}
1396
1397#[derive(Debug, Error, Diagnostic)]
1398pub enum CreateReportDiagnostic {
1399 #[error("Labeler does not advertise a reportable `LabelerPolicies` shape")]
1406 #[diagnostic(
1407 code = "labeler::report::contract_missing",
1408 help = "`reasonTypes` and `subjectTypes` must both be present and non-empty on the labeler's published policies; the tool cannot verify reporting conformance without them."
1409 )]
1410 ContractMissing {
1411 has_reason_types: bool,
1413 has_subject_types: bool,
1415 },
1416
1417 #[error("Labeler accepted unauthenticated createReport (status {status})")]
1419 #[diagnostic(
1420 code = "labeler::report::unauthenticated_accepted",
1421 help = "A labeler must reject createReport with 401 when no Authorization header is supplied."
1422 )]
1423 UnauthenticatedAccepted {
1424 status: u16,
1426 #[source_code]
1428 source_code: NamedSource<Arc<[u8]>>,
1429 #[label("accepted here")]
1431 span: SourceSpan,
1432 },
1433
1434 #[error("Labeler accepted malformed Bearer token (status {status})")]
1436 #[diagnostic(
1437 code = "labeler::report::malformed_bearer_accepted",
1438 help = "A labeler must reject createReport with 401 when the Authorization header carries a non-JWT string."
1439 )]
1440 MalformedBearerAccepted {
1441 status: u16,
1443 #[source_code]
1445 source_code: NamedSource<Arc<[u8]>>,
1446 #[label("accepted here")]
1448 span: SourceSpan,
1449 },
1450
1451 #[error("Labeler accepted JWT with wrong `aud` (status {status})")]
1453 #[diagnostic(
1454 code = "labeler::report::wrong_aud_accepted",
1455 help = "A labeler must reject JWTs whose `aud` claim does not match its own DID."
1456 )]
1457 WrongAudAccepted {
1458 status: u16,
1460 #[source_code]
1462 source_code: NamedSource<Arc<[u8]>>,
1463 #[label("accepted here")]
1465 span: SourceSpan,
1466 },
1467
1468 #[error("Labeler accepted JWT with wrong `lxm` (status {status})")]
1470 #[diagnostic(
1471 code = "labeler::report::wrong_lxm_accepted",
1472 help = "A labeler must reject JWTs whose `lxm` claim does not match the invoked Lexicon method."
1473 )]
1474 WrongLxmAccepted {
1475 status: u16,
1477 #[source_code]
1479 source_code: NamedSource<Arc<[u8]>>,
1480 #[label("accepted here")]
1482 span: SourceSpan,
1483 },
1484
1485 #[error("Labeler accepted expired JWT (status {status})")]
1487 #[diagnostic(
1488 code = "labeler::report::expired_accepted",
1489 help = "A labeler must reject JWTs whose `exp` claim is in the past."
1490 )]
1491 ExpiredAccepted {
1492 status: u16,
1494 #[source_code]
1496 source_code: NamedSource<Arc<[u8]>>,
1497 #[label("accepted here")]
1499 span: SourceSpan,
1500 },
1501
1502 #[error(
1504 "Unadvertised `reasonType` was rejected with status {status}, expected 400 InvalidRequest"
1505 )]
1506 #[diagnostic(
1507 code = "labeler::report::shape_not_400",
1508 help = "A labeler should return 400 InvalidRequest (not 401 or 500) for a `reasonType` not listed in its published LabelerPolicies.reasonTypes."
1509 )]
1510 ShapeNot400 {
1511 status: u16,
1513 error_name: Option<String>,
1515 #[source_code]
1517 source_code: NamedSource<Arc<[u8]>>,
1518 #[label("rejected with wrong status here")]
1520 span: SourceSpan,
1521 },
1522
1523 #[error("Self-mint report rejected (status {status})")]
1525 #[diagnostic(
1526 code = "labeler::report::self_mint_rejected",
1527 help = "A labeler that advertises reportable shape should accept a well-formed, authenticated createReport. Check the labeler's service-auth validation and its acceptance of the advertised reasonType/subject shape."
1528 )]
1529 SelfMintRejected {
1530 status: u16,
1532 #[source_code]
1534 source_code: NamedSource<Arc<[u8]>>,
1535 #[label("rejected here")]
1537 span: SourceSpan,
1538 },
1539
1540 #[error("{origin} rejected PDS-minted service-auth createReport (status {status})")]
1545 #[diagnostic(
1546 code = "labeler::report::pds_service_auth_rejected",
1547 help = "When `origin` is `Labeler`, the PDS issued a service-auth JWT bound to the labeler's DID and the createReport NSID; the labeler should have accepted it. When `origin` is `PDS`, the user's PDS refused to mint the service-auth JWT — verify the handle and app password, and confirm `--handle` resolves to a PDS that can mint service-auth tokens for this user."
1548 )]
1549 PdsServiceAuthRejected {
1550 origin: ResponseOrigin,
1552 status: u16,
1554 #[source_code]
1556 source_code: NamedSource<Arc<[u8]>>,
1557 #[label("rejected here")]
1559 span: SourceSpan,
1560 },
1561
1562 #[error("{origin} rejected PDS-proxied createReport (status {status})")]
1568 #[diagnostic(
1569 code = "labeler::report::pds_proxied_rejected",
1570 help = "When `origin` is `Labeler`, the PDS forwarded the createReport call on the user's behalf; the downstream labeler reached it but rejected the submission. When `origin` is `PDS`, the user's PDS rejected the proxied call before it could reach the labeler — verify the handle, app password, and that the PDS is configured to proxy moderation calls to the target labeler."
1571 )]
1572 PdsProxiedRejected {
1573 origin: ResponseOrigin,
1575 status: u16,
1577 #[source_code]
1579 source_code: NamedSource<Arc<[u8]>>,
1580 #[label("rejected here")]
1582 span: SourceSpan,
1583 },
1584}
1585
1586#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1591pub enum ResponseOrigin {
1592 Labeler,
1595 Pds,
1599}
1600
1601impl std::fmt::Display for ResponseOrigin {
1602 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1603 match self {
1604 ResponseOrigin::Labeler => f.write_str("Labeler"),
1605 ResponseOrigin::Pds => f.write_str("PDS"),
1606 }
1607 }
1608}
1609
1610pub(crate) fn body_as_named_source(
1618 resp: &RawCreateReportResponse,
1619) -> (NamedSource<Arc<[u8]>>, SourceSpan) {
1620 let pretty = pretty_json_for_display(&resp.raw_body);
1621 let span = SourceSpan::new(0.into(), pretty.len());
1622 (NamedSource::new(resp.source_url.clone(), pretty), span)
1623}
1624
1625pub(crate) fn body_as_named_source_from_pds(
1629 resp: &RawPdsXrpcResponse,
1630) -> (NamedSource<Arc<[u8]>>, SourceSpan) {
1631 let pretty = pretty_json_for_display(&resp.raw_body);
1632 let span = SourceSpan::new(0.into(), pretty.len());
1633 (NamedSource::new(resp.source_url.clone(), pretty), span)
1634}
1635
1636pub(crate) fn build_minimal_report_body(facts: &IdentityFacts) -> serde_json::Value {
1644 let reason_type = facts
1647 .reason_types
1648 .as_ref()
1649 .and_then(|v| v.first())
1650 .cloned()
1651 .unwrap_or_else(|| "com.atproto.moderation.defs#reasonOther".to_string());
1652
1653 let subject_types: &[String] = facts.subject_types.as_deref().unwrap_or(&[]);
1654 let subject = if subject_types.iter().any(|t| t == "account") {
1655 serde_json::json!({
1656 "$type": "com.atproto.admin.defs#repoRef",
1657 "did": facts.did.0,
1658 })
1659 } else if subject_types.iter().any(|t| t == "record") {
1660 serde_json::json!({
1661 "$type": "com.atproto.repo.strongRef",
1662 "uri": format!("at://{}/app.bsky.feed.post/not-real", facts.did.0),
1666 "cid": "bafyreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1667 })
1668 } else {
1669 serde_json::json!({
1671 "$type": "com.atproto.admin.defs#repoRef",
1672 "did": facts.did.0,
1673 })
1674 };
1675
1676 serde_json::json!({
1677 "reasonType": reason_type,
1678 "subject": subject,
1679 })
1680}
1681
1682#[cfg(test)]
1683mod envelope_tests {
1684 use super::*;
1685
1686 #[test]
1687 fn parse_well_formed_envelope() {
1688 let body = br#"{"error":"BadJwt","message":"invalid token"}"#;
1689 let env = XrpcErrorEnvelope::parse(body).expect("parses");
1690 assert_eq!(env.error.as_deref(), Some("BadJwt"));
1691 assert_eq!(env.message.as_deref(), Some("invalid token"));
1692 assert!(env.has_nonempty_error());
1693 }
1694
1695 #[test]
1696 fn parse_empty_envelope() {
1697 let body = br#"{}"#;
1698 let env = XrpcErrorEnvelope::parse(body).expect("parses empty object");
1699 assert_eq!(env.error, None);
1700 assert!(!env.has_nonempty_error());
1701 }
1702
1703 #[test]
1704 fn parse_non_json_returns_none() {
1705 assert!(XrpcErrorEnvelope::parse(b"<html>").is_none());
1706 }
1707
1708 #[test]
1709 fn parse_empty_error_field_treated_as_missing() {
1710 let body = br#"{"error":""}"#;
1711 let env = XrpcErrorEnvelope::parse(body).unwrap();
1712 assert!(!env.has_nonempty_error());
1713 }
1714}