lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

rhi.rs (26053B)


      1 #![forbid(unsafe_code)]
      2 
      3 //! Exact RHI evidence-attestation wire construction and validation.
      4 
      5 #[cfg(not(feature = "std"))]
      6 use alloc::{
      7     boxed::Box,
      8     format,
      9     string::{String, ToString},
     10     vec,
     11     vec::Vec,
     12 };
     13 use core::{cmp::Ordering, fmt, num::NonZeroU64};
     14 #[cfg(feature = "std")]
     15 use std::{
     16     boxed::Box,
     17     format,
     18     string::{String, ToString},
     19     vec,
     20     vec::Vec,
     21 };
     22 
     23 use radroots_event::{
     24     admission::SignatureVerifiedEvent,
     25     envelope::EventEnvelope,
     26     envelope::kind::KIND_RHI_EVIDENCE_ATTESTATION,
     27     id::{EventId, MutationId, TradeId},
     28     trade::canonical_jcs_value,
     29     wire::Nip01EventWireParts,
     30 };
     31 use radroots_identity::PublicKey;
     32 use serde::Deserialize;
     33 use serde_json::Value;
     34 use sha2::{Digest as _, Sha256};
     35 
     36 pub const RADROOTS_RHI_EVIDENCE_ATTESTATION_CONTRACT_ID: &str =
     37     "radroots.rhi.evidence_attestation.v1";
     38 pub const RADROOTS_RHI_EVIDENCE_ATTESTATION_MAXIMUM_BYTES: usize = 16 * 1024;
     39 const MAXIMUM_TAGS: usize = 7;
     40 const MAXIMUM_REASON_CODES: usize = 16;
     41 const MAXIMUM_REASON_CODE_BYTES: usize = 64;
     42 const STATEMENT_DIGEST_DOMAIN: &[u8] = b"radroots:rhi-evidence-attestation-statement:v1\0";
     43 
     44 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     45 pub enum RadrootsRhiEvidenceAttestationOutcomeV1 {
     46     Valid,
     47     Invalid,
     48     Indeterminate,
     49 }
     50 
     51 impl RadrootsRhiEvidenceAttestationOutcomeV1 {
     52     #[must_use]
     53     pub const fn as_str(self) -> &'static str {
     54         match self {
     55             Self::Valid => "valid",
     56             Self::Invalid => "invalid",
     57             Self::Indeterminate => "indeterminate",
     58         }
     59     }
     60 
     61     fn topic(self) -> String {
     62         format!("radroots:rhi-outcome:{}", self.as_str())
     63     }
     64 }
     65 
     66 #[derive(Clone, Copy, PartialEq, Eq)]
     67 pub struct RadrootsRhiEvidenceAttestationSupersessionV1 {
     68     report_id: [u8; 32],
     69     event_id: EventId,
     70 }
     71 
     72 impl RadrootsRhiEvidenceAttestationSupersessionV1 {
     73     #[must_use]
     74     pub const fn report_id(&self) -> &[u8; 32] {
     75         &self.report_id
     76     }
     77 
     78     #[must_use]
     79     pub const fn event_id(&self) -> &EventId {
     80         &self.event_id
     81     }
     82 }
     83 
     84 impl fmt::Debug for RadrootsRhiEvidenceAttestationSupersessionV1 {
     85     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     86         formatter.write_str("RadrootsRhiEvidenceAttestationSupersessionV1(<redacted>)")
     87     }
     88 }
     89 
     90 #[derive(Clone, PartialEq, Eq)]
     91 pub struct RadrootsRhiEvidenceAttestationV1 {
     92     issuer: PublicKey,
     93     trade_id: TradeId,
     94     claim_mutation_id: MutationId,
     95     outcome: RadrootsRhiEvidenceAttestationOutcomeV1,
     96     observed_at_unix_s: u64,
     97     trade_generation: NonZeroU64,
     98     statement_digest: [u8; 32],
     99     supersession: Option<RadrootsRhiEvidenceAttestationSupersessionV1>,
    100     canonical_content: Box<str>,
    101 }
    102 
    103 impl RadrootsRhiEvidenceAttestationV1 {
    104     pub fn from_canonical_content(
    105         content: impl AsRef<[u8]>,
    106     ) -> Result<Self, RadrootsRhiEvidenceAttestationError> {
    107         let content = content.as_ref();
    108         if content.is_empty() || content.len() > RADROOTS_RHI_EVIDENCE_ATTESTATION_MAXIMUM_BYTES {
    109             return Err(RadrootsRhiEvidenceAttestationError::NoncanonicalReportContent);
    110         }
    111         let raw: RawReport = serde_json::from_slice(content)
    112             .map_err(|_| RadrootsRhiEvidenceAttestationError::NoncanonicalReportContent)?;
    113         validate_fixed_fields(&raw)?;
    114         validate_reason_codes(&raw.reason_codes)?;
    115 
    116         let value: Value = serde_json::from_slice(content)
    117             .map_err(|_| RadrootsRhiEvidenceAttestationError::NoncanonicalReportContent)?;
    118         let object = value
    119             .as_object()
    120             .ok_or(RadrootsRhiEvidenceAttestationError::NoncanonicalReportContent)?;
    121         if !object.contains_key("supersedes_report_id")
    122             || !object.contains_key("supersedes_event_id")
    123         {
    124             return Err(RadrootsRhiEvidenceAttestationError::NoncanonicalReportContent);
    125         }
    126 
    127         let issuer = canonical_public_key(&raw.issuer_pubkey)?;
    128         let trade_id = canonical_trade_id(&raw.trade_id)?;
    129         let claim_mutation_id = canonical_mutation_id(&raw.claim_mutation_id)?;
    130         let outcome = parse_outcome(&raw.outcome)?;
    131         let trade_generation = NonZeroU64::new(raw.trade_generation)
    132             .ok_or(RadrootsRhiEvidenceAttestationError::InvalidReport)?;
    133         let report_id = parse_hex_32(&raw.report_id)?;
    134         let declared_statement_digest = parse_hex_32(&raw.statement_digest)?;
    135         if report_id != declared_statement_digest {
    136             return Err(RadrootsRhiEvidenceAttestationError::StatementDigestMismatch);
    137         }
    138         for digest in [
    139             &raw.projection_digest,
    140             &raw.evidence_manifest_digest,
    141             &raw.evidence_policy_digest,
    142         ] {
    143             parse_hex_32(digest)?;
    144         }
    145         let supersession = parse_supersession(
    146             raw.supersedes_report_id.as_deref(),
    147             raw.supersedes_event_id.as_deref(),
    148         )?;
    149 
    150         let canonical_content = canonical_jcs_value(&value)
    151             .map_err(|_| RadrootsRhiEvidenceAttestationError::NoncanonicalReportContent)?;
    152         if canonical_content.as_bytes() != content {
    153             return Err(RadrootsRhiEvidenceAttestationError::NoncanonicalReportContent);
    154         }
    155         let mut statement = value;
    156         let object = statement
    157             .as_object_mut()
    158             .ok_or(RadrootsRhiEvidenceAttestationError::NoncanonicalReportContent)?;
    159         object.remove("report_id");
    160         object.remove("statement_digest");
    161         let statement_payload = canonical_jcs_value(&statement)
    162             .map_err(|_| RadrootsRhiEvidenceAttestationError::NoncanonicalReportContent)?;
    163         let mut hasher = Sha256::new();
    164         hasher.update(STATEMENT_DIGEST_DOMAIN);
    165         hasher.update(statement_payload.as_bytes());
    166         let computed_statement_digest: [u8; 32] = hasher.finalize().into();
    167         if computed_statement_digest != declared_statement_digest {
    168             return Err(RadrootsRhiEvidenceAttestationError::StatementDigestMismatch);
    169         }
    170 
    171         Ok(Self {
    172             issuer,
    173             trade_id,
    174             claim_mutation_id,
    175             outcome,
    176             observed_at_unix_s: raw.observed_at_unix_s,
    177             trade_generation,
    178             statement_digest: declared_statement_digest,
    179             supersession,
    180             canonical_content: canonical_content.into_boxed_str(),
    181         })
    182     }
    183 
    184     #[must_use]
    185     pub const fn issuer(&self) -> &PublicKey {
    186         &self.issuer
    187     }
    188 
    189     #[must_use]
    190     pub const fn trade_id(&self) -> &TradeId {
    191         &self.trade_id
    192     }
    193 
    194     #[must_use]
    195     pub const fn claim_mutation_id(&self) -> &MutationId {
    196         &self.claim_mutation_id
    197     }
    198 
    199     #[must_use]
    200     pub const fn outcome(&self) -> RadrootsRhiEvidenceAttestationOutcomeV1 {
    201         self.outcome
    202     }
    203 
    204     #[must_use]
    205     pub const fn observed_at_unix_s(&self) -> u64 {
    206         self.observed_at_unix_s
    207     }
    208 
    209     #[must_use]
    210     pub const fn trade_generation(&self) -> NonZeroU64 {
    211         self.trade_generation
    212     }
    213 
    214     #[must_use]
    215     pub const fn statement_digest(&self) -> &[u8; 32] {
    216         &self.statement_digest
    217     }
    218 
    219     #[must_use]
    220     pub const fn supersession(&self) -> Option<RadrootsRhiEvidenceAttestationSupersessionV1> {
    221         self.supersession
    222     }
    223 
    224     #[must_use]
    225     pub fn canonical_content(&self) -> &str {
    226         &self.canonical_content
    227     }
    228 }
    229 
    230 impl fmt::Debug for RadrootsRhiEvidenceAttestationV1 {
    231     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    232         formatter
    233             .debug_struct("RadrootsRhiEvidenceAttestationV1")
    234             .field("outcome", &self.outcome)
    235             .field("has_supersession", &self.supersession.is_some())
    236             .finish_non_exhaustive()
    237     }
    238 }
    239 
    240 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    241 pub enum RadrootsRhiEvidenceAttestationError {
    242     InvalidAttestationKind,
    243     IssuerAuthorMismatch,
    244     NoncanonicalReportContent,
    245     StatementDigestMismatch,
    246     InvalidOutcome,
    247     MissingClaimTag,
    248     DuplicateTradeTag,
    249     DuplicateStatementTag,
    250     IncompleteSupersessionReference,
    251     StaleTradeGeneration,
    252     CallerStructuralTagForbidden,
    253     InvalidIdentifier,
    254     InvalidReport,
    255     InvalidTagShape,
    256     UnexpectedTag,
    257     ContractTagMismatch,
    258     TradeTagMismatch,
    259     ClaimTagMismatch,
    260     StatementTagMismatch,
    261     OutcomeTagMismatch,
    262     SupersessionTagMismatch,
    263 }
    264 
    265 impl RadrootsRhiEvidenceAttestationError {
    266     #[must_use]
    267     pub const fn code(self) -> &'static str {
    268         match self {
    269             Self::InvalidAttestationKind => "invalid_attestation_kind",
    270             Self::IssuerAuthorMismatch => "issuer_author_mismatch",
    271             Self::NoncanonicalReportContent => "noncanonical_report_content",
    272             Self::StatementDigestMismatch => "statement_digest_mismatch",
    273             Self::InvalidOutcome => "invalid_outcome",
    274             Self::MissingClaimTag => "missing_claim_tag",
    275             Self::DuplicateTradeTag => "duplicate_trade_tag",
    276             Self::DuplicateStatementTag => "duplicate_statement_tag",
    277             Self::IncompleteSupersessionReference => "incomplete_supersession_reference",
    278             Self::StaleTradeGeneration => "stale_trade_generation",
    279             Self::CallerStructuralTagForbidden => "caller_structural_tag_forbidden",
    280             Self::InvalidIdentifier => "invalid_identifier",
    281             Self::InvalidReport => "invalid_report",
    282             Self::InvalidTagShape => "invalid_tag_shape",
    283             Self::UnexpectedTag => "unexpected_tag",
    284             Self::ContractTagMismatch => "contract_tag_mismatch",
    285             Self::TradeTagMismatch => "trade_tag_mismatch",
    286             Self::ClaimTagMismatch => "claim_tag_mismatch",
    287             Self::StatementTagMismatch => "statement_tag_mismatch",
    288             Self::OutcomeTagMismatch => "outcome_tag_mismatch",
    289             Self::SupersessionTagMismatch => "supersession_tag_mismatch",
    290         }
    291     }
    292 }
    293 
    294 impl fmt::Display for RadrootsRhiEvidenceAttestationError {
    295     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    296         formatter.write_str(match self {
    297             Self::InvalidAttestationKind => "RHI attestation kind is invalid",
    298             Self::IssuerAuthorMismatch => "RHI attestation issuer does not match author",
    299             Self::NoncanonicalReportContent => "RHI attestation report is not canonical",
    300             Self::StatementDigestMismatch => "RHI attestation statement digest does not match",
    301             Self::InvalidOutcome => "RHI attestation outcome is invalid",
    302             Self::MissingClaimTag => "RHI attestation claim tag is missing",
    303             Self::DuplicateTradeTag => "RHI attestation trade tag is duplicated",
    304             Self::DuplicateStatementTag => "RHI attestation statement tag is duplicated",
    305             Self::IncompleteSupersessionReference => "RHI attestation supersession is incomplete",
    306             Self::StaleTradeGeneration => "RHI attestation supersession is stale",
    307             Self::CallerStructuralTagForbidden => "caller supplied a governed RHI attestation tag",
    308             Self::InvalidIdentifier => "RHI attestation identifier is invalid",
    309             Self::InvalidReport => "RHI attestation report is invalid",
    310             Self::InvalidTagShape => "RHI attestation tag shape is invalid",
    311             Self::UnexpectedTag => "RHI attestation tag is not permitted",
    312             Self::ContractTagMismatch => "RHI attestation contract tag does not match",
    313             Self::TradeTagMismatch => "RHI attestation trade tag does not match",
    314             Self::ClaimTagMismatch => "RHI attestation claim tag does not match",
    315             Self::StatementTagMismatch => "RHI attestation statement tag does not match",
    316             Self::OutcomeTagMismatch => "RHI attestation outcome tag does not match",
    317             Self::SupersessionTagMismatch => "RHI attestation supersession tag does not match",
    318         })
    319     }
    320 }
    321 
    322 #[cfg(feature = "std")]
    323 impl std::error::Error for RadrootsRhiEvidenceAttestationError {}
    324 
    325 pub fn rhi_evidence_attestation_event_build(
    326     attestation: &RadrootsRhiEvidenceAttestationV1,
    327 ) -> Nip01EventWireParts {
    328     Nip01EventWireParts {
    329         kind: KIND_RHI_EVIDENCE_ATTESTATION,
    330         tags: canonical_tags(attestation),
    331         content: attestation.canonical_content().to_string(),
    332     }
    333 }
    334 
    335 pub fn rhi_evidence_attestation_event_build_with_extra_tags(
    336     attestation: &RadrootsRhiEvidenceAttestationV1,
    337     extra_tags: &[Vec<String>],
    338 ) -> Result<Nip01EventWireParts, RadrootsRhiEvidenceAttestationError> {
    339     if extra_tags.iter().any(|tag| {
    340         matches!(
    341             tag.first().map(String::as_str),
    342             Some("contract" | "d" | "x" | "t" | "e")
    343         )
    344     }) {
    345         return Err(RadrootsRhiEvidenceAttestationError::CallerStructuralTagForbidden);
    346     }
    347     if !extra_tags.is_empty() {
    348         return Err(RadrootsRhiEvidenceAttestationError::UnexpectedTag);
    349     }
    350     Ok(rhi_evidence_attestation_event_build(attestation))
    351 }
    352 
    353 /// Structurally parses an RHI attestation without claiming signature proof.
    354 pub fn rhi_evidence_attestation_from_event(
    355     event: &EventEnvelope,
    356 ) -> Result<RadrootsRhiEvidenceAttestationV1, RadrootsRhiEvidenceAttestationError> {
    357     validate_parts(
    358         event.kind_u32(),
    359         &event.author().to_hex(),
    360         &event.tags_as_vec(),
    361         event.content(),
    362     )
    363 }
    364 
    365 /// Validates an RHI attestation whose NIP-01 signature is already verified.
    366 pub fn rhi_evidence_attestation_from_verified_event(
    367     event: &SignatureVerifiedEvent,
    368 ) -> Result<RadrootsRhiEvidenceAttestationV1, RadrootsRhiEvidenceAttestationError> {
    369     rhi_evidence_attestation_from_event(event.event())
    370 }
    371 
    372 pub fn validate_rhi_evidence_attestation_tags(
    373     attestation: &RadrootsRhiEvidenceAttestationV1,
    374     tags: &[Vec<String>],
    375 ) -> Result<(), RadrootsRhiEvidenceAttestationError> {
    376     if tags.len() > MAXIMUM_TAGS {
    377         return Err(RadrootsRhiEvidenceAttestationError::InvalidTagShape);
    378     }
    379     let trade_count = count_unmarked(tags, "d");
    380     if trade_count > 1 {
    381         return Err(RadrootsRhiEvidenceAttestationError::DuplicateTradeTag);
    382     }
    383     let statement_count = count_marked(tags, "x", "statement");
    384     if statement_count > 1 {
    385         return Err(RadrootsRhiEvidenceAttestationError::DuplicateStatementTag);
    386     }
    387     let claim_count = count_marked(tags, "x", "claim");
    388     if claim_count == 0 {
    389         return Err(RadrootsRhiEvidenceAttestationError::MissingClaimTag);
    390     }
    391     let report_count = count_marked(tags, "x", "supersedes_report");
    392     let event_count = count_unmarked(tags, "e");
    393     if report_count != event_count {
    394         return Err(RadrootsRhiEvidenceAttestationError::IncompleteSupersessionReference);
    395     }
    396     if trade_count != 1
    397         || statement_count != 1
    398         || claim_count != 1
    399         || report_count > 1
    400         || count_unmarked(tags, "contract") != 1
    401         || count_unmarked(tags, "t") != 1
    402         || count_named(tags, "x") != claim_count + statement_count + report_count
    403     {
    404         return Err(RadrootsRhiEvidenceAttestationError::InvalidTagShape);
    405     }
    406     if tags.iter().any(|tag| {
    407         !matches!(
    408             tag.first().map(String::as_str),
    409             Some("contract" | "d" | "x" | "t" | "e")
    410         )
    411     }) {
    412         return Err(RadrootsRhiEvidenceAttestationError::UnexpectedTag);
    413     }
    414 
    415     if exact_unmarked(tags.first(), "contract")? != RADROOTS_RHI_EVIDENCE_ATTESTATION_CONTRACT_ID {
    416         return Err(RadrootsRhiEvidenceAttestationError::ContractTagMismatch);
    417     }
    418     if canonical_trade_id(exact_unmarked(tags.get(1), "d")?)? != attestation.trade_id {
    419         return Err(RadrootsRhiEvidenceAttestationError::TradeTagMismatch);
    420     }
    421     if canonical_mutation_id(exact_marked(tags.get(2), "x", "claim")?)?
    422         != attestation.claim_mutation_id
    423     {
    424         return Err(RadrootsRhiEvidenceAttestationError::ClaimTagMismatch);
    425     }
    426     if parse_hex_32(exact_marked(tags.get(3), "x", "statement")?)? != attestation.statement_digest {
    427         return Err(RadrootsRhiEvidenceAttestationError::StatementTagMismatch);
    428     }
    429     if exact_unmarked(tags.get(4), "t")? != attestation.outcome.topic() {
    430         return Err(RadrootsRhiEvidenceAttestationError::OutcomeTagMismatch);
    431     }
    432     match attestation.supersession {
    433         None if tags.len() == 5 => Ok(()),
    434         Some(supersession) if tags.len() == 7 => {
    435             if parse_hex_32(exact_marked(tags.get(5), "x", "supersedes_report")?)?
    436                 != supersession.report_id
    437                 || canonical_event_id(exact_unmarked(tags.get(6), "e")?)? != supersession.event_id
    438             {
    439                 return Err(RadrootsRhiEvidenceAttestationError::SupersessionTagMismatch);
    440             }
    441             Ok(())
    442         }
    443         _ => Err(RadrootsRhiEvidenceAttestationError::IncompleteSupersessionReference),
    444     }
    445 }
    446 
    447 pub fn validate_rhi_evidence_attestation_supersession(
    448     current: &RadrootsRhiEvidenceAttestationV1,
    449     current_event_id: &EventId,
    450     candidate: &RadrootsRhiEvidenceAttestationV1,
    451 ) -> Result<(), RadrootsRhiEvidenceAttestationError> {
    452     if current.trade_id != candidate.trade_id {
    453         return Err(RadrootsRhiEvidenceAttestationError::SupersessionTagMismatch);
    454     }
    455     let Some(supersession) = candidate.supersession else {
    456         return Err(RadrootsRhiEvidenceAttestationError::IncompleteSupersessionReference);
    457     };
    458     if supersession.report_id != current.statement_digest
    459         || supersession.event_id != *current_event_id
    460     {
    461         return Err(RadrootsRhiEvidenceAttestationError::SupersessionTagMismatch);
    462     }
    463     let ordering = candidate
    464         .trade_generation
    465         .cmp(&current.trade_generation)
    466         .then_with(|| {
    467             candidate
    468                 .observed_at_unix_s
    469                 .cmp(&current.observed_at_unix_s)
    470         })
    471         .then_with(|| candidate.statement_digest.cmp(&current.statement_digest));
    472     if ordering != Ordering::Greater {
    473         return Err(RadrootsRhiEvidenceAttestationError::StaleTradeGeneration);
    474     }
    475     Ok(())
    476 }
    477 
    478 pub(crate) fn validate_parts(
    479     kind: u32,
    480     author: &str,
    481     tags: &[Vec<String>],
    482     content: &str,
    483 ) -> Result<RadrootsRhiEvidenceAttestationV1, RadrootsRhiEvidenceAttestationError> {
    484     if kind != KIND_RHI_EVIDENCE_ATTESTATION {
    485         return Err(RadrootsRhiEvidenceAttestationError::InvalidAttestationKind);
    486     }
    487     let attestation = RadrootsRhiEvidenceAttestationV1::from_canonical_content(content)?;
    488     if canonical_public_key(author)? != attestation.issuer {
    489         return Err(RadrootsRhiEvidenceAttestationError::IssuerAuthorMismatch);
    490     }
    491     validate_rhi_evidence_attestation_tags(&attestation, tags)?;
    492     Ok(attestation)
    493 }
    494 
    495 fn canonical_tags(attestation: &RadrootsRhiEvidenceAttestationV1) -> Vec<Vec<String>> {
    496     let mut tags = Vec::with_capacity(if attestation.supersession.is_some() {
    497         7
    498     } else {
    499         5
    500     });
    501     tags.push(vec![
    502         "contract".to_string(),
    503         RADROOTS_RHI_EVIDENCE_ATTESTATION_CONTRACT_ID.to_string(),
    504     ]);
    505     tags.push(vec!["d".to_string(), attestation.trade_id.to_hex()]);
    506     tags.push(vec![
    507         "x".to_string(),
    508         attestation.claim_mutation_id.to_hex(),
    509         "claim".to_string(),
    510     ]);
    511     tags.push(vec![
    512         "x".to_string(),
    513         hex::encode(attestation.statement_digest),
    514         "statement".to_string(),
    515     ]);
    516     tags.push(vec!["t".to_string(), attestation.outcome.topic()]);
    517     if let Some(supersession) = attestation.supersession {
    518         tags.push(vec![
    519             "x".to_string(),
    520             hex::encode(supersession.report_id),
    521             "supersedes_report".to_string(),
    522         ]);
    523         tags.push(vec!["e".to_string(), supersession.event_id.to_hex()]);
    524     }
    525     tags
    526 }
    527 
    528 fn validate_fixed_fields(raw: &RawReport) -> Result<(), RadrootsRhiEvidenceAttestationError> {
    529     if raw.contract_id != RADROOTS_RHI_EVIDENCE_ATTESTATION_CONTRACT_ID
    530         || raw.contract_version != 1
    531         || raw.reducer_contract_id != "radroots.trade.reducer.v1"
    532         || raw.reducer_contract_version != 1
    533         || raw.attestation_method != "signed_evidence_snapshot"
    534     {
    535         return Err(RadrootsRhiEvidenceAttestationError::InvalidReport);
    536     }
    537     Ok(())
    538 }
    539 
    540 fn validate_reason_codes(codes: &[String]) -> Result<(), RadrootsRhiEvidenceAttestationError> {
    541     if codes.is_empty() || codes.len() > MAXIMUM_REASON_CODES {
    542         return Err(RadrootsRhiEvidenceAttestationError::InvalidReport);
    543     }
    544     let mut previous: Option<&str> = None;
    545     for code in codes {
    546         let bytes = code.as_bytes();
    547         if bytes.is_empty()
    548             || bytes.len() > MAXIMUM_REASON_CODE_BYTES
    549             || !bytes[0].is_ascii_lowercase()
    550             || !bytes[bytes.len() - 1].is_ascii_alphanumeric()
    551             || bytes
    552                 .iter()
    553                 .any(|byte| !(byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_'))
    554             || previous.is_some_and(|value| value >= code.as_str())
    555         {
    556             return Err(RadrootsRhiEvidenceAttestationError::InvalidReport);
    557         }
    558         previous = Some(code);
    559     }
    560     Ok(())
    561 }
    562 
    563 fn parse_outcome(
    564     outcome: &str,
    565 ) -> Result<RadrootsRhiEvidenceAttestationOutcomeV1, RadrootsRhiEvidenceAttestationError> {
    566     match outcome {
    567         "valid" => Ok(RadrootsRhiEvidenceAttestationOutcomeV1::Valid),
    568         "invalid" => Ok(RadrootsRhiEvidenceAttestationOutcomeV1::Invalid),
    569         "indeterminate" => Ok(RadrootsRhiEvidenceAttestationOutcomeV1::Indeterminate),
    570         _ => Err(RadrootsRhiEvidenceAttestationError::InvalidOutcome),
    571     }
    572 }
    573 
    574 fn parse_supersession(
    575     report: Option<&str>,
    576     event: Option<&str>,
    577 ) -> Result<Option<RadrootsRhiEvidenceAttestationSupersessionV1>, RadrootsRhiEvidenceAttestationError>
    578 {
    579     match (report, event) {
    580         (None, None) => Ok(None),
    581         (Some(report), Some(event)) => Ok(Some(RadrootsRhiEvidenceAttestationSupersessionV1 {
    582             report_id: parse_hex_32(report)?,
    583             event_id: canonical_event_id(event)?,
    584         })),
    585         _ => Err(RadrootsRhiEvidenceAttestationError::IncompleteSupersessionReference),
    586     }
    587 }
    588 
    589 fn canonical_public_key(value: &str) -> Result<PublicKey, RadrootsRhiEvidenceAttestationError> {
    590     let key = PublicKey::from_hex(value)
    591         .map_err(|_| RadrootsRhiEvidenceAttestationError::InvalidIdentifier)?;
    592     if key.to_hex() != value {
    593         return Err(RadrootsRhiEvidenceAttestationError::InvalidIdentifier);
    594     }
    595     Ok(key)
    596 }
    597 
    598 fn canonical_trade_id(value: &str) -> Result<TradeId, RadrootsRhiEvidenceAttestationError> {
    599     let id = TradeId::parse(value)
    600         .map_err(|_| RadrootsRhiEvidenceAttestationError::InvalidIdentifier)?;
    601     if id.to_hex() != value {
    602         return Err(RadrootsRhiEvidenceAttestationError::InvalidIdentifier);
    603     }
    604     Ok(id)
    605 }
    606 
    607 fn canonical_mutation_id(value: &str) -> Result<MutationId, RadrootsRhiEvidenceAttestationError> {
    608     let id = MutationId::parse(value)
    609         .map_err(|_| RadrootsRhiEvidenceAttestationError::InvalidIdentifier)?;
    610     if id.to_hex() != value {
    611         return Err(RadrootsRhiEvidenceAttestationError::InvalidIdentifier);
    612     }
    613     Ok(id)
    614 }
    615 
    616 fn canonical_event_id(value: &str) -> Result<EventId, RadrootsRhiEvidenceAttestationError> {
    617     let id = EventId::parse(value)
    618         .map_err(|_| RadrootsRhiEvidenceAttestationError::InvalidIdentifier)?;
    619     if id.to_hex() != value {
    620         return Err(RadrootsRhiEvidenceAttestationError::InvalidIdentifier);
    621     }
    622     Ok(id)
    623 }
    624 
    625 fn parse_hex_32(value: &str) -> Result<[u8; 32], RadrootsRhiEvidenceAttestationError> {
    626     if value.len() != 64
    627         || value
    628             .bytes()
    629             .any(|byte| !(byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)))
    630     {
    631         return Err(RadrootsRhiEvidenceAttestationError::InvalidIdentifier);
    632     }
    633     let mut bytes = [0_u8; 32];
    634     hex::decode_to_slice(value, &mut bytes)
    635         .map_err(|_| RadrootsRhiEvidenceAttestationError::InvalidIdentifier)?;
    636     Ok(bytes)
    637 }
    638 
    639 fn count_named(tags: &[Vec<String>], name: &str) -> usize {
    640     tags.iter()
    641         .filter(|tag| tag.first().map(String::as_str) == Some(name))
    642         .count()
    643 }
    644 
    645 fn count_unmarked(tags: &[Vec<String>], name: &str) -> usize {
    646     tags.iter()
    647         .filter(|tag| tag.len() == 2 && tag.first().map(String::as_str) == Some(name))
    648         .count()
    649 }
    650 
    651 fn count_marked(tags: &[Vec<String>], name: &str, marker: &str) -> usize {
    652     tags.iter()
    653         .filter(|tag| {
    654             tag.len() == 3
    655                 && tag.first().map(String::as_str) == Some(name)
    656                 && tag.get(2).map(String::as_str) == Some(marker)
    657         })
    658         .count()
    659 }
    660 
    661 fn exact_unmarked<'a>(
    662     tag: Option<&'a Vec<String>>,
    663     name: &str,
    664 ) -> Result<&'a str, RadrootsRhiEvidenceAttestationError> {
    665     let tag = tag.ok_or(RadrootsRhiEvidenceAttestationError::InvalidTagShape)?;
    666     if tag.len() != 2 || tag.first().map(String::as_str) != Some(name) {
    667         return Err(RadrootsRhiEvidenceAttestationError::InvalidTagShape);
    668     }
    669     Ok(&tag[1])
    670 }
    671 
    672 fn exact_marked<'a>(
    673     tag: Option<&'a Vec<String>>,
    674     name: &str,
    675     marker: &str,
    676 ) -> Result<&'a str, RadrootsRhiEvidenceAttestationError> {
    677     let tag = tag.ok_or(RadrootsRhiEvidenceAttestationError::InvalidTagShape)?;
    678     if tag.len() != 3
    679         || tag.first().map(String::as_str) != Some(name)
    680         || tag.get(2).map(String::as_str) != Some(marker)
    681     {
    682         return Err(RadrootsRhiEvidenceAttestationError::InvalidTagShape);
    683     }
    684     Ok(&tag[1])
    685 }
    686 
    687 #[derive(Deserialize)]
    688 #[serde(deny_unknown_fields)]
    689 struct RawReport {
    690     attestation_method: String,
    691     claim_mutation_id: String,
    692     contract_id: String,
    693     contract_version: u16,
    694     evidence_manifest_digest: String,
    695     evidence_policy_digest: String,
    696     issuer_pubkey: String,
    697     observed_at_unix_s: u64,
    698     outcome: String,
    699     projection_digest: String,
    700     reason_codes: Vec<String>,
    701     reducer_contract_id: String,
    702     reducer_contract_version: u16,
    703     report_id: String,
    704     statement_digest: String,
    705     supersedes_event_id: Option<String>,
    706     supersedes_report_id: Option<String>,
    707     trade_generation: u64,
    708     trade_id: String,
    709 }