lib

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

evidence_report.rs (44136B)


      1 #![forbid(unsafe_code)]
      2 
      3 //! Immutable RHI evidence reports derived from governed trade evidence.
      4 //!
      5 //! Reports bind a separately retained evidence manifest and projection. They
      6 //! do not retrieve evidence, validate signatures, build events, persist state,
      7 //! or publish anything.
      8 
      9 use alloc::{boxed::Box, string::String, vec::Vec};
     10 use core::{fmt, num::NonZeroU64};
     11 
     12 use radroots_event::id::{EventId, MutationId, TradeId};
     13 use radroots_identity::PublicKey;
     14 use serde::Deserialize;
     15 use serde_json::{Value, json};
     16 use sha2::{Digest as _, Sha256};
     17 
     18 use crate::{
     19     evidence::RadrootsTradeEvidenceOutcomeV1,
     20     evidence_manifest::{
     21         RadrootsTradeEvidenceManifestDigestV1, RadrootsTradeEvidenceManifestV1,
     22         RadrootsTradeEvidencePolicyDigestV1,
     23     },
     24     trade_contract_v1::{RADROOTS_TRADE_REDUCER_CONTRACT_ID, RADROOTS_TRADE_REDUCER_VERSION},
     25 };
     26 
     27 pub const RADROOTS_RHI_EVIDENCE_REPORT_CONTRACT_ID: &str = "radroots.rhi.evidence_attestation.v1";
     28 pub const RADROOTS_RHI_EVIDENCE_REPORT_CONTRACT_VERSION: u16 = 1;
     29 pub const RADROOTS_RHI_EVIDENCE_ATTESTATION_METHOD: &str = "signed_evidence_snapshot";
     30 pub const RADROOTS_RHI_EVIDENCE_REPORT_MAXIMUM_REASON_CODES: usize = 16;
     31 pub const RADROOTS_RHI_EVIDENCE_REPORT_REASON_CODE_MAXIMUM_BYTES: usize = 64;
     32 pub const RADROOTS_RHI_EVIDENCE_REPORT_MAXIMUM_CANONICAL_BYTES: usize = 16 * 1024;
     33 
     34 const STATEMENT_DIGEST_DOMAIN: &[u8] = b"radroots:rhi-evidence-attestation-statement:v1\0";
     35 
     36 macro_rules! define_digest {
     37     ($name:ident) => {
     38         #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
     39         pub struct $name([u8; 32]);
     40 
     41         impl $name {
     42             pub const fn from_bytes(bytes: [u8; 32]) -> Self {
     43                 Self(bytes)
     44             }
     45 
     46             pub fn sha256(bytes: &[u8]) -> Self {
     47                 Self(Sha256::digest(bytes).into())
     48             }
     49 
     50             pub const fn as_bytes(&self) -> &[u8; 32] {
     51                 &self.0
     52             }
     53 
     54             pub fn to_hex(&self) -> String {
     55                 hex::encode(self.0)
     56             }
     57         }
     58 
     59         impl fmt::Debug for $name {
     60             fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     61                 formatter.write_str(concat!(stringify!($name), "(<redacted>)"))
     62             }
     63         }
     64     };
     65 }
     66 
     67 define_digest!(RadrootsTradeEvidenceProjectionDigestV1);
     68 
     69 #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
     70 pub struct RadrootsRhiEvidenceStatementDigestV1([u8; 32]);
     71 
     72 impl RadrootsRhiEvidenceStatementDigestV1 {
     73     pub const fn from_bytes(bytes: [u8; 32]) -> Self {
     74         Self(bytes)
     75     }
     76 
     77     pub const fn as_bytes(&self) -> &[u8; 32] {
     78         &self.0
     79     }
     80 
     81     pub fn to_hex(&self) -> String {
     82         hex::encode(self.0)
     83     }
     84 }
     85 
     86 impl fmt::Debug for RadrootsRhiEvidenceStatementDigestV1 {
     87     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     88         formatter.write_str("RadrootsRhiEvidenceStatementDigestV1(<redacted>)")
     89     }
     90 }
     91 
     92 #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
     93 pub struct RadrootsRhiEvidenceReasonCodeV1(String);
     94 
     95 impl RadrootsRhiEvidenceReasonCodeV1 {
     96     pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsRhiEvidenceReportError> {
     97         let value = value.as_ref();
     98         let bytes = value.as_bytes();
     99         if bytes.is_empty()
    100             || bytes.len() > RADROOTS_RHI_EVIDENCE_REPORT_REASON_CODE_MAXIMUM_BYTES
    101             || !bytes[0].is_ascii_lowercase()
    102             || !bytes[bytes.len() - 1].is_ascii_alphanumeric()
    103             || bytes
    104                 .iter()
    105                 .any(|byte| !(byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_'))
    106         {
    107             return Err(RadrootsRhiEvidenceReportError::InvalidReasonCode);
    108         }
    109         Ok(Self(String::from(value)))
    110     }
    111 
    112     pub fn as_str(&self) -> &str {
    113         self.0.as_str()
    114     }
    115 }
    116 
    117 impl fmt::Debug for RadrootsRhiEvidenceReasonCodeV1 {
    118     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    119         formatter.write_str("RadrootsRhiEvidenceReasonCodeV1(<redacted>)")
    120     }
    121 }
    122 
    123 #[derive(Clone, Copy, PartialEq, Eq)]
    124 pub struct RadrootsRhiEvidenceSupersessionV1 {
    125     report_id: RadrootsRhiEvidenceStatementDigestV1,
    126     event_id: EventId,
    127 }
    128 
    129 impl RadrootsRhiEvidenceSupersessionV1 {
    130     pub const fn new(report_id: RadrootsRhiEvidenceStatementDigestV1, event_id: EventId) -> Self {
    131         Self {
    132             report_id,
    133             event_id,
    134         }
    135     }
    136 
    137     pub const fn report_id(&self) -> RadrootsRhiEvidenceStatementDigestV1 {
    138         self.report_id
    139     }
    140 
    141     pub const fn event_id(&self) -> EventId {
    142         self.event_id
    143     }
    144 }
    145 
    146 impl fmt::Debug for RadrootsRhiEvidenceSupersessionV1 {
    147     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    148         formatter.write_str("RadrootsRhiEvidenceSupersessionV1(<redacted>)")
    149     }
    150 }
    151 
    152 #[derive(Clone, PartialEq, Eq)]
    153 pub struct RadrootsRhiEvidenceReportV1 {
    154     issuer_public_key: PublicKey,
    155     trade_id: TradeId,
    156     claim_mutation_id: MutationId,
    157     outcome: RadrootsTradeEvidenceOutcomeV1,
    158     reason_codes: Box<[RadrootsRhiEvidenceReasonCodeV1]>,
    159     projection_digest: RadrootsTradeEvidenceProjectionDigestV1,
    160     evidence_manifest_digest: RadrootsTradeEvidenceManifestDigestV1,
    161     evidence_policy_digest: RadrootsTradeEvidencePolicyDigestV1,
    162     observed_at_unix_s: u64,
    163     supersession: Option<RadrootsRhiEvidenceSupersessionV1>,
    164     trade_generation: NonZeroU64,
    165     statement_digest: RadrootsRhiEvidenceStatementDigestV1,
    166     canonical_statement_payload: Box<str>,
    167     canonical_content: Box<str>,
    168 }
    169 
    170 impl RadrootsRhiEvidenceReportV1 {
    171     pub fn new<R>(
    172         issuer_public_key: PublicKey,
    173         claim_mutation_id: MutationId,
    174         outcome: RadrootsTradeEvidenceOutcomeV1,
    175         reason_codes: R,
    176         projection_digest: RadrootsTradeEvidenceProjectionDigestV1,
    177         manifest: &RadrootsTradeEvidenceManifestV1,
    178         supersession: Option<RadrootsRhiEvidenceSupersessionV1>,
    179     ) -> Result<Self, RadrootsRhiEvidenceReportError>
    180     where
    181         R: IntoIterator<Item = RadrootsRhiEvidenceReasonCodeV1>,
    182     {
    183         if !manifest.coverage().permits(outcome) {
    184             return Err(RadrootsRhiEvidenceReportError::OutcomeNotPermitted);
    185         }
    186         let reason_codes = normalize_reason_codes(reason_codes)?;
    187         Self::from_fields(ReportFields {
    188             issuer_public_key,
    189             trade_id: *manifest.trade_id(),
    190             claim_mutation_id,
    191             outcome,
    192             reason_codes,
    193             projection_digest,
    194             evidence_manifest_digest: manifest.digest(),
    195             evidence_policy_digest: manifest.evidence_policy_digest(),
    196             observed_at_unix_s: manifest.observed_at_unix_s(),
    197             supersession,
    198             trade_generation: manifest.trade_generation(),
    199         })
    200     }
    201 
    202     pub fn from_canonical_content(content: &[u8]) -> Result<Self, RadrootsRhiEvidenceReportError> {
    203         if content.is_empty() {
    204             return Err(RadrootsRhiEvidenceReportError::Malformed);
    205         }
    206         if content.len() > RADROOTS_RHI_EVIDENCE_REPORT_MAXIMUM_CANONICAL_BYTES {
    207             return Err(RadrootsRhiEvidenceReportError::ReportTooLarge);
    208         }
    209         let raw: RawReport = serde_json::from_slice(content)
    210             .map_err(|_| RadrootsRhiEvidenceReportError::Malformed)?;
    211         if raw.contract_version != RADROOTS_RHI_EVIDENCE_REPORT_CONTRACT_VERSION {
    212             return Err(RadrootsRhiEvidenceReportError::UnsupportedVersion);
    213         }
    214         if raw.contract_id != RADROOTS_RHI_EVIDENCE_REPORT_CONTRACT_ID
    215             || raw.reducer_contract_id != RADROOTS_TRADE_REDUCER_CONTRACT_ID
    216             || raw.reducer_contract_version != RADROOTS_TRADE_REDUCER_VERSION
    217             || raw.attestation_method != RADROOTS_RHI_EVIDENCE_ATTESTATION_METHOD
    218         {
    219             return Err(RadrootsRhiEvidenceReportError::FixedFieldMismatch);
    220         }
    221 
    222         let report_id = parse_digest(&raw.report_id)?;
    223         let statement_digest = parse_digest(&raw.statement_digest)?;
    224         if report_id != statement_digest {
    225             return Err(RadrootsRhiEvidenceReportError::StatementDigestMismatch);
    226         }
    227         let supersession = parse_supersession(
    228             raw.supersedes_report_id.as_deref(),
    229             raw.supersedes_event_id.as_deref(),
    230         )?;
    231         let reason_codes = parse_reason_codes(raw.reason_codes)?;
    232         let fields = ReportFields {
    233             issuer_public_key: PublicKey::from_hex(&raw.issuer_pubkey)
    234                 .map_err(|_| RadrootsRhiEvidenceReportError::InvalidIdentifier)?,
    235             trade_id: TradeId::parse(&raw.trade_id)
    236                 .map_err(|_| RadrootsRhiEvidenceReportError::InvalidIdentifier)?,
    237             claim_mutation_id: MutationId::parse(&raw.claim_mutation_id)
    238                 .map_err(|_| RadrootsRhiEvidenceReportError::InvalidIdentifier)?,
    239             outcome: parse_outcome(&raw.outcome)?,
    240             reason_codes,
    241             projection_digest: RadrootsTradeEvidenceProjectionDigestV1::from_bytes(parse_hex_32(
    242                 &raw.projection_digest,
    243             )?),
    244             evidence_manifest_digest: RadrootsTradeEvidenceManifestDigestV1::from_bytes(
    245                 parse_hex_32(&raw.evidence_manifest_digest)?,
    246             ),
    247             evidence_policy_digest: RadrootsTradeEvidencePolicyDigestV1::from_bytes(parse_hex_32(
    248                 &raw.evidence_policy_digest,
    249             )?),
    250             observed_at_unix_s: raw.observed_at_unix_s,
    251             supersession,
    252             trade_generation: NonZeroU64::new(raw.trade_generation)
    253                 .ok_or(RadrootsRhiEvidenceReportError::InvalidTradeGeneration)?,
    254         };
    255         let report = Self::from_fields(fields)?;
    256         if report.statement_digest != statement_digest {
    257             return Err(RadrootsRhiEvidenceReportError::StatementDigestMismatch);
    258         }
    259         if report.canonical_content.as_bytes() != content {
    260             return Err(RadrootsRhiEvidenceReportError::NonCanonical);
    261         }
    262         Ok(report)
    263     }
    264 
    265     fn from_fields(fields: ReportFields) -> Result<Self, RadrootsRhiEvidenceReportError> {
    266         let canonical_statement_payload = canonical_statement_payload(&fields)?;
    267         let statement_digest = statement_digest(canonical_statement_payload.as_bytes());
    268         let canonical_content = canonical_report_content(&fields, statement_digest)?;
    269         if canonical_content.len() > RADROOTS_RHI_EVIDENCE_REPORT_MAXIMUM_CANONICAL_BYTES {
    270             return Err(RadrootsRhiEvidenceReportError::ReportTooLarge);
    271         }
    272         Ok(Self {
    273             issuer_public_key: fields.issuer_public_key,
    274             trade_id: fields.trade_id,
    275             claim_mutation_id: fields.claim_mutation_id,
    276             outcome: fields.outcome,
    277             reason_codes: fields.reason_codes,
    278             projection_digest: fields.projection_digest,
    279             evidence_manifest_digest: fields.evidence_manifest_digest,
    280             evidence_policy_digest: fields.evidence_policy_digest,
    281             observed_at_unix_s: fields.observed_at_unix_s,
    282             supersession: fields.supersession,
    283             trade_generation: fields.trade_generation,
    284             statement_digest,
    285             canonical_statement_payload: canonical_statement_payload.into_boxed_str(),
    286             canonical_content: canonical_content.into_boxed_str(),
    287         })
    288     }
    289 
    290     pub fn validate_against_manifest(
    291         &self,
    292         manifest: &RadrootsTradeEvidenceManifestV1,
    293     ) -> Result<(), RadrootsRhiEvidenceReportError> {
    294         if self.trade_id != *manifest.trade_id()
    295             || self.trade_generation != manifest.trade_generation()
    296             || self.evidence_manifest_digest != manifest.digest()
    297             || self.evidence_policy_digest != manifest.evidence_policy_digest()
    298             || self.observed_at_unix_s != manifest.observed_at_unix_s()
    299         {
    300             return Err(RadrootsRhiEvidenceReportError::ManifestMismatch);
    301         }
    302         if !manifest.coverage().permits(self.outcome) {
    303             return Err(RadrootsRhiEvidenceReportError::OutcomeNotPermitted);
    304         }
    305         Ok(())
    306     }
    307 
    308     pub const fn contract_id(&self) -> &'static str {
    309         RADROOTS_RHI_EVIDENCE_REPORT_CONTRACT_ID
    310     }
    311 
    312     pub const fn contract_version(&self) -> u16 {
    313         RADROOTS_RHI_EVIDENCE_REPORT_CONTRACT_VERSION
    314     }
    315 
    316     pub const fn issuer_public_key(&self) -> PublicKey {
    317         self.issuer_public_key
    318     }
    319 
    320     pub const fn trade_id(&self) -> &TradeId {
    321         &self.trade_id
    322     }
    323 
    324     pub const fn claim_mutation_id(&self) -> MutationId {
    325         self.claim_mutation_id
    326     }
    327 
    328     pub const fn outcome(&self) -> RadrootsTradeEvidenceOutcomeV1 {
    329         self.outcome
    330     }
    331 
    332     pub fn reason_codes(&self) -> &[RadrootsRhiEvidenceReasonCodeV1] {
    333         &self.reason_codes
    334     }
    335 
    336     pub const fn projection_digest(&self) -> RadrootsTradeEvidenceProjectionDigestV1 {
    337         self.projection_digest
    338     }
    339 
    340     pub const fn evidence_manifest_digest(&self) -> RadrootsTradeEvidenceManifestDigestV1 {
    341         self.evidence_manifest_digest
    342     }
    343 
    344     pub const fn evidence_policy_digest(&self) -> RadrootsTradeEvidencePolicyDigestV1 {
    345         self.evidence_policy_digest
    346     }
    347 
    348     pub const fn observed_at_unix_s(&self) -> u64 {
    349         self.observed_at_unix_s
    350     }
    351 
    352     pub const fn supersession(&self) -> Option<RadrootsRhiEvidenceSupersessionV1> {
    353         self.supersession
    354     }
    355 
    356     pub const fn trade_generation(&self) -> NonZeroU64 {
    357         self.trade_generation
    358     }
    359 
    360     pub const fn reducer_contract_id(&self) -> &'static str {
    361         RADROOTS_TRADE_REDUCER_CONTRACT_ID
    362     }
    363 
    364     pub const fn reducer_contract_version(&self) -> u16 {
    365         RADROOTS_TRADE_REDUCER_VERSION
    366     }
    367 
    368     pub const fn attestation_method(&self) -> &'static str {
    369         RADROOTS_RHI_EVIDENCE_ATTESTATION_METHOD
    370     }
    371 
    372     pub const fn statement_digest(&self) -> RadrootsRhiEvidenceStatementDigestV1 {
    373         self.statement_digest
    374     }
    375 
    376     pub fn canonical_statement_payload(&self) -> &str {
    377         &self.canonical_statement_payload
    378     }
    379 
    380     pub fn canonical_content(&self) -> &str {
    381         &self.canonical_content
    382     }
    383 }
    384 
    385 impl fmt::Debug for RadrootsRhiEvidenceReportV1 {
    386     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    387         formatter
    388             .debug_struct("RadrootsRhiEvidenceReportV1")
    389             .field("outcome", &self.outcome)
    390             .field("reason_code_count", &self.reason_codes.len())
    391             .field("has_supersession", &self.supersession.is_some())
    392             .finish_non_exhaustive()
    393     }
    394 }
    395 
    396 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    397 pub enum RadrootsRhiEvidenceReportError {
    398     ReasonCodeCountOutOfRange,
    399     InvalidReasonCode,
    400     DuplicateReasonCode,
    401     OutcomeNotPermitted,
    402     ReportTooLarge,
    403     Malformed,
    404     UnsupportedVersion,
    405     FixedFieldMismatch,
    406     InvalidIdentifier,
    407     InvalidDigest,
    408     InvalidOutcome,
    409     InvalidTradeGeneration,
    410     IncompleteSupersession,
    411     StatementDigestMismatch,
    412     NonCanonical,
    413     ManifestMismatch,
    414 }
    415 
    416 impl fmt::Display for RadrootsRhiEvidenceReportError {
    417     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    418         formatter.write_str(match self {
    419             Self::ReasonCodeCountOutOfRange => "evidence report reason-code count is out of range",
    420             Self::InvalidReasonCode => "evidence report reason code is invalid",
    421             Self::DuplicateReasonCode => "evidence report contains a duplicate reason code",
    422             Self::OutcomeNotPermitted => "evidence report outcome is not permitted by coverage",
    423             Self::ReportTooLarge => "evidence report exceeds its canonical byte limit",
    424             Self::Malformed => "evidence report encoding is malformed",
    425             Self::UnsupportedVersion => "evidence report version is unsupported",
    426             Self::FixedFieldMismatch => "evidence report fixed field does not match the contract",
    427             Self::InvalidIdentifier => "evidence report identifier is invalid",
    428             Self::InvalidDigest => "evidence report digest is invalid",
    429             Self::InvalidOutcome => "evidence report outcome is invalid",
    430             Self::InvalidTradeGeneration => "evidence report trade generation is invalid",
    431             Self::IncompleteSupersession => "evidence report supersession is incomplete",
    432             Self::StatementDigestMismatch => "evidence report statement digest does not match",
    433             Self::NonCanonical => "evidence report encoding is not canonical",
    434             Self::ManifestMismatch => "evidence report does not match the evidence manifest",
    435         })
    436     }
    437 }
    438 
    439 #[cfg(feature = "std")]
    440 impl std::error::Error for RadrootsRhiEvidenceReportError {}
    441 
    442 struct ReportFields {
    443     issuer_public_key: PublicKey,
    444     trade_id: TradeId,
    445     claim_mutation_id: MutationId,
    446     outcome: RadrootsTradeEvidenceOutcomeV1,
    447     reason_codes: Box<[RadrootsRhiEvidenceReasonCodeV1]>,
    448     projection_digest: RadrootsTradeEvidenceProjectionDigestV1,
    449     evidence_manifest_digest: RadrootsTradeEvidenceManifestDigestV1,
    450     evidence_policy_digest: RadrootsTradeEvidencePolicyDigestV1,
    451     observed_at_unix_s: u64,
    452     supersession: Option<RadrootsRhiEvidenceSupersessionV1>,
    453     trade_generation: NonZeroU64,
    454 }
    455 
    456 #[derive(Deserialize)]
    457 #[serde(deny_unknown_fields)]
    458 struct RawReport {
    459     attestation_method: String,
    460     claim_mutation_id: String,
    461     contract_id: String,
    462     contract_version: u16,
    463     evidence_manifest_digest: String,
    464     evidence_policy_digest: String,
    465     issuer_pubkey: String,
    466     observed_at_unix_s: u64,
    467     outcome: String,
    468     projection_digest: String,
    469     reason_codes: Vec<String>,
    470     reducer_contract_id: String,
    471     reducer_contract_version: u16,
    472     report_id: String,
    473     statement_digest: String,
    474     supersedes_event_id: Option<String>,
    475     supersedes_report_id: Option<String>,
    476     trade_generation: u64,
    477     trade_id: String,
    478 }
    479 
    480 fn normalize_reason_codes(
    481     reason_codes: impl IntoIterator<Item = RadrootsRhiEvidenceReasonCodeV1>,
    482 ) -> Result<Box<[RadrootsRhiEvidenceReasonCodeV1]>, RadrootsRhiEvidenceReportError> {
    483     let mut values = Vec::new();
    484     for value in reason_codes {
    485         if values.len() == RADROOTS_RHI_EVIDENCE_REPORT_MAXIMUM_REASON_CODES {
    486             return Err(RadrootsRhiEvidenceReportError::ReasonCodeCountOutOfRange);
    487         }
    488         values.push(value);
    489     }
    490     if values.is_empty() {
    491         return Err(RadrootsRhiEvidenceReportError::ReasonCodeCountOutOfRange);
    492     }
    493     values.sort();
    494     if values.windows(2).any(|pair| pair[0] == pair[1]) {
    495         return Err(RadrootsRhiEvidenceReportError::DuplicateReasonCode);
    496     }
    497     Ok(values.into_boxed_slice())
    498 }
    499 
    500 fn parse_reason_codes(
    501     values: Vec<String>,
    502 ) -> Result<Box<[RadrootsRhiEvidenceReasonCodeV1]>, RadrootsRhiEvidenceReportError> {
    503     if values.len() > RADROOTS_RHI_EVIDENCE_REPORT_MAXIMUM_REASON_CODES {
    504         return Err(RadrootsRhiEvidenceReportError::ReasonCodeCountOutOfRange);
    505     }
    506     let original = values.iter().map(String::as_str).collect::<Vec<_>>();
    507     let reason_codes = normalize_reason_codes(
    508         values
    509             .iter()
    510             .map(RadrootsRhiEvidenceReasonCodeV1::parse)
    511             .collect::<Result<Vec<_>, _>>()?,
    512     )?;
    513     if original.iter().copied().ne(reason_codes
    514         .iter()
    515         .map(RadrootsRhiEvidenceReasonCodeV1::as_str))
    516     {
    517         return Err(RadrootsRhiEvidenceReportError::NonCanonical);
    518     }
    519     Ok(reason_codes)
    520 }
    521 
    522 fn parse_supersession(
    523     report_id: Option<&str>,
    524     event_id: Option<&str>,
    525 ) -> Result<Option<RadrootsRhiEvidenceSupersessionV1>, RadrootsRhiEvidenceReportError> {
    526     match (report_id, event_id) {
    527         (None, None) => Ok(None),
    528         (Some(report_id), Some(event_id)) => Ok(Some(RadrootsRhiEvidenceSupersessionV1::new(
    529             parse_digest(report_id)?,
    530             EventId::parse(event_id)
    531                 .map_err(|_| RadrootsRhiEvidenceReportError::InvalidIdentifier)?,
    532         ))),
    533         _ => Err(RadrootsRhiEvidenceReportError::IncompleteSupersession),
    534     }
    535 }
    536 
    537 fn parse_outcome(
    538     value: &str,
    539 ) -> Result<RadrootsTradeEvidenceOutcomeV1, RadrootsRhiEvidenceReportError> {
    540     match value {
    541         "valid" => Ok(RadrootsTradeEvidenceOutcomeV1::Valid),
    542         "invalid" => Ok(RadrootsTradeEvidenceOutcomeV1::Invalid),
    543         "indeterminate" => Ok(RadrootsTradeEvidenceOutcomeV1::Indeterminate),
    544         _ => Err(RadrootsRhiEvidenceReportError::InvalidOutcome),
    545     }
    546 }
    547 
    548 const fn outcome_name(outcome: RadrootsTradeEvidenceOutcomeV1) -> &'static str {
    549     match outcome {
    550         RadrootsTradeEvidenceOutcomeV1::Valid => "valid",
    551         RadrootsTradeEvidenceOutcomeV1::Invalid => "invalid",
    552         RadrootsTradeEvidenceOutcomeV1::Indeterminate => "indeterminate",
    553     }
    554 }
    555 
    556 fn parse_digest(
    557     value: &str,
    558 ) -> Result<RadrootsRhiEvidenceStatementDigestV1, RadrootsRhiEvidenceReportError> {
    559     Ok(RadrootsRhiEvidenceStatementDigestV1::from_bytes(
    560         parse_hex_32(value)?,
    561     ))
    562 }
    563 
    564 fn parse_hex_32(value: &str) -> Result<[u8; 32], RadrootsRhiEvidenceReportError> {
    565     if value.len() != 64
    566         || value
    567             .bytes()
    568             .any(|byte| !(byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)))
    569     {
    570         return Err(RadrootsRhiEvidenceReportError::InvalidDigest);
    571     }
    572     let decoded = hex::decode(value).map_err(|_| RadrootsRhiEvidenceReportError::InvalidDigest)?;
    573     decoded
    574         .try_into()
    575         .map_err(|_| RadrootsRhiEvidenceReportError::InvalidDigest)
    576 }
    577 
    578 fn statement_digest(payload: &[u8]) -> RadrootsRhiEvidenceStatementDigestV1 {
    579     let mut digest = Sha256::new();
    580     digest.update(STATEMENT_DIGEST_DOMAIN);
    581     digest.update(payload);
    582     RadrootsRhiEvidenceStatementDigestV1::from_bytes(digest.finalize().into())
    583 }
    584 
    585 fn canonical_statement_payload(
    586     fields: &ReportFields,
    587 ) -> Result<String, RadrootsRhiEvidenceReportError> {
    588     canonical_json(statement_value(fields))
    589 }
    590 
    591 fn canonical_report_content(
    592     fields: &ReportFields,
    593     statement_digest: RadrootsRhiEvidenceStatementDigestV1,
    594 ) -> Result<String, RadrootsRhiEvidenceReportError> {
    595     let mut value = statement_value(fields);
    596     let object = value
    597         .as_object_mut()
    598         .ok_or(RadrootsRhiEvidenceReportError::Malformed)?;
    599     object.insert("report_id".into(), json!(statement_digest.to_hex()));
    600     object.insert("statement_digest".into(), json!(statement_digest.to_hex()));
    601     canonical_json(value)
    602 }
    603 
    604 fn statement_value(fields: &ReportFields) -> Value {
    605     let (supersedes_report_id, supersedes_event_id) =
    606         fields
    607             .supersession
    608             .map_or((Value::Null, Value::Null), |supersession| {
    609                 (
    610                     json!(supersession.report_id.to_hex()),
    611                     json!(supersession.event_id.to_hex()),
    612                 )
    613             });
    614     json!({
    615         "contract_id": RADROOTS_RHI_EVIDENCE_REPORT_CONTRACT_ID,
    616         "contract_version": RADROOTS_RHI_EVIDENCE_REPORT_CONTRACT_VERSION,
    617         "issuer_pubkey": fields.issuer_public_key.to_hex(),
    618         "trade_id": fields.trade_id.to_hex(),
    619         "claim_mutation_id": fields.claim_mutation_id.to_hex(),
    620         "outcome": outcome_name(fields.outcome),
    621         "reason_codes": fields.reason_codes.iter().map(RadrootsRhiEvidenceReasonCodeV1::as_str).collect::<Vec<_>>(),
    622         "reducer_contract_id": RADROOTS_TRADE_REDUCER_CONTRACT_ID,
    623         "reducer_contract_version": RADROOTS_TRADE_REDUCER_VERSION,
    624         "projection_digest": fields.projection_digest.to_hex(),
    625         "evidence_manifest_digest": fields.evidence_manifest_digest.to_hex(),
    626         "evidence_policy_digest": fields.evidence_policy_digest.to_hex(),
    627         "observed_at_unix_s": fields.observed_at_unix_s,
    628         "attestation_method": RADROOTS_RHI_EVIDENCE_ATTESTATION_METHOD,
    629         "supersedes_report_id": supersedes_report_id,
    630         "supersedes_event_id": supersedes_event_id,
    631         "trade_generation": fields.trade_generation.get(),
    632     })
    633 }
    634 
    635 fn canonical_json(value: Value) -> Result<String, RadrootsRhiEvidenceReportError> {
    636     radroots_event::trade::canonical_jcs_value(&value)
    637         .map_err(|_| RadrootsRhiEvidenceReportError::Malformed)
    638 }
    639 
    640 #[cfg(test)]
    641 mod tests {
    642     use alloc::{format, vec};
    643 
    644     use super::*;
    645     use crate::evidence::{
    646         RadrootsTradeEvidenceCoverageV1, RadrootsTradeEvidenceManifestSourceResultV1,
    647         RadrootsTradeEvidenceScopePrerequisitesV1, RadrootsTradeEvidenceSourceCompletionV1,
    648         RadrootsTradeEvidenceSourceRequirementV1, RadrootsTradeEvidenceSourceResultDigestV1,
    649         RadrootsTradeEvidenceSourceResultV1,
    650     };
    651 
    652     const CURRENT_REPORT: &str = "{\"attestation_method\":\"signed_evidence_snapshot\",\"claim_mutation_id\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"contract_id\":\"radroots.rhi.evidence_attestation.v1\",\"contract_version\":1,\"evidence_manifest_digest\":\"4444444444444444444444444444444444444444444444444444444444444444\",\"evidence_policy_digest\":\"5555555555555555555555555555555555555555555555555555555555555555\",\"issuer_pubkey\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"observed_at_unix_s\":1800000000,\"outcome\":\"indeterminate\",\"projection_digest\":\"6666666666666666666666666666666666666666666666666666666666666666\",\"reason_codes\":[\"required_source_incomplete\"],\"reducer_contract_id\":\"radroots.trade.reducer.v1\",\"reducer_contract_version\":1,\"report_id\":\"461acfea579481f6aadba47c31abc07eaa700f66abc074b8926ba41266082b44\",\"statement_digest\":\"461acfea579481f6aadba47c31abc07eaa700f66abc074b8926ba41266082b44\",\"supersedes_event_id\":null,\"supersedes_report_id\":null,\"trade_generation\":7,\"trade_id\":\"11111111111111111111111111111111\"}";
    653 
    654     const CURRENT_STATEMENT: &str = "{\"attestation_method\":\"signed_evidence_snapshot\",\"claim_mutation_id\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"contract_id\":\"radroots.rhi.evidence_attestation.v1\",\"contract_version\":1,\"evidence_manifest_digest\":\"4444444444444444444444444444444444444444444444444444444444444444\",\"evidence_policy_digest\":\"5555555555555555555555555555555555555555555555555555555555555555\",\"issuer_pubkey\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"observed_at_unix_s\":1800000000,\"outcome\":\"indeterminate\",\"projection_digest\":\"6666666666666666666666666666666666666666666666666666666666666666\",\"reason_codes\":[\"required_source_incomplete\"],\"reducer_contract_id\":\"radroots.trade.reducer.v1\",\"reducer_contract_version\":1,\"supersedes_event_id\":null,\"supersedes_report_id\":null,\"trade_generation\":7,\"trade_id\":\"11111111111111111111111111111111\"}";
    655 
    656     const SUPERSEDING_REPORT: &str = "{\"attestation_method\":\"signed_evidence_snapshot\",\"claim_mutation_id\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"contract_id\":\"radroots.rhi.evidence_attestation.v1\",\"contract_version\":1,\"evidence_manifest_digest\":\"4444444444444444444444444444444444444444444444444444444444444444\",\"evidence_policy_digest\":\"5555555555555555555555555555555555555555555555555555555555555555\",\"issuer_pubkey\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"observed_at_unix_s\":1800000100,\"outcome\":\"valid\",\"projection_digest\":\"6666666666666666666666666666666666666666666666666666666666666666\",\"reason_codes\":[\"scope_satisfied\"],\"reducer_contract_id\":\"radroots.trade.reducer.v1\",\"reducer_contract_version\":1,\"report_id\":\"61af3caa6a3a14ff7bb026e9f294826d1d1957a5aff3d814156acee9cf0d5807\",\"statement_digest\":\"61af3caa6a3a14ff7bb026e9f294826d1d1957a5aff3d814156acee9cf0d5807\",\"supersedes_event_id\":\"8888888888888888888888888888888888888888888888888888888888888888\",\"supersedes_report_id\":\"7777777777777777777777777777777777777777777777777777777777777777\",\"trade_generation\":8,\"trade_id\":\"11111111111111111111111111111111\"}";
    657 
    658     fn manifest(coverage: RadrootsTradeEvidenceCoverageV1) -> RadrootsTradeEvidenceManifestV1 {
    659         let completion = match coverage {
    660             RadrootsTradeEvidenceCoverageV1::ScopeSatisfied => {
    661                 RadrootsTradeEvidenceSourceCompletionV1::Complete
    662             }
    663             RadrootsTradeEvidenceCoverageV1::Unsupported => {
    664                 RadrootsTradeEvidenceSourceCompletionV1::Unsupported
    665             }
    666             RadrootsTradeEvidenceCoverageV1::Partial => {
    667                 RadrootsTradeEvidenceSourceCompletionV1::Complete
    668             }
    669             RadrootsTradeEvidenceCoverageV1::Missing => {
    670                 RadrootsTradeEvidenceSourceCompletionV1::Incomplete
    671             }
    672         };
    673         let scope = if matches!(coverage, RadrootsTradeEvidenceCoverageV1::ScopeSatisfied) {
    674             RadrootsTradeEvidenceScopePrerequisitesV1::Satisfied
    675         } else {
    676             RadrootsTradeEvidenceScopePrerequisitesV1::Unsatisfied
    677         };
    678         let manifest = RadrootsTradeEvidenceManifestV1::new(
    679             TradeId::from_bytes([0x11; 16]),
    680             NonZeroU64::new(7).unwrap(),
    681             RadrootsTradeEvidencePolicyDigestV1::from_bytes([0x55; 32]),
    682             1_800_000_000,
    683             scope,
    684             [RadrootsTradeEvidenceManifestSourceResultV1::new(
    685                 crate::evidence::RadrootsTradeEvidenceSourceIdV1::parse("relay_a").unwrap(),
    686                 RadrootsTradeEvidenceSourceResultV1::new(
    687                     RadrootsTradeEvidenceSourceRequirementV1::Required,
    688                     completion,
    689                     0,
    690                 )
    691                 .unwrap(),
    692                 RadrootsTradeEvidenceSourceResultDigestV1::from_bytes([0x33; 32]),
    693             )],
    694             [],
    695         )
    696         .unwrap();
    697         assert_eq!(manifest.coverage(), coverage);
    698         manifest
    699     }
    700 
    701     fn report(
    702         coverage: RadrootsTradeEvidenceCoverageV1,
    703         outcome: RadrootsTradeEvidenceOutcomeV1,
    704     ) -> Result<RadrootsRhiEvidenceReportV1, RadrootsRhiEvidenceReportError> {
    705         RadrootsRhiEvidenceReportV1::new(
    706             PublicKey::from_hex("585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df")
    707                 .unwrap(),
    708             MutationId::from_bytes([0x22; 32]),
    709             outcome,
    710             [RadrootsRhiEvidenceReasonCodeV1::parse("scope_satisfied").unwrap()],
    711             RadrootsTradeEvidenceProjectionDigestV1::from_bytes([0x66; 32]),
    712             &manifest(coverage),
    713             None,
    714         )
    715     }
    716 
    717     fn report_with_reasons(
    718         manifest: &RadrootsTradeEvidenceManifestV1,
    719         reasons: impl IntoIterator<Item = RadrootsRhiEvidenceReasonCodeV1>,
    720     ) -> Result<RadrootsRhiEvidenceReportV1, RadrootsRhiEvidenceReportError> {
    721         RadrootsRhiEvidenceReportV1::new(
    722             PublicKey::from_hex("585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df")
    723                 .unwrap(),
    724             MutationId::from_bytes([0x22; 32]),
    725             RadrootsTradeEvidenceOutcomeV1::Valid,
    726             reasons,
    727             RadrootsTradeEvidenceProjectionDigestV1::from_bytes([0x66; 32]),
    728             manifest,
    729             None,
    730         )
    731     }
    732 
    733     #[test]
    734     fn fixed_report_vectors_match_statement_hashes_and_supersession() {
    735         let current =
    736             RadrootsRhiEvidenceReportV1::from_canonical_content(CURRENT_REPORT.as_bytes())
    737                 .expect("current report vector");
    738         assert_eq!(current.canonical_statement_payload(), CURRENT_STATEMENT);
    739         assert_eq!(current.canonical_content(), CURRENT_REPORT);
    740         assert_eq!(
    741             current.statement_digest().to_hex(),
    742             "461acfea579481f6aadba47c31abc07eaa700f66abc074b8926ba41266082b44"
    743         );
    744         assert!(current.supersession().is_none());
    745 
    746         let superseding =
    747             RadrootsRhiEvidenceReportV1::from_canonical_content(SUPERSEDING_REPORT.as_bytes())
    748                 .expect("superseding report vector");
    749         assert_eq!(
    750             superseding.statement_digest().to_hex(),
    751             "61af3caa6a3a14ff7bb026e9f294826d1d1957a5aff3d814156acee9cf0d5807"
    752         );
    753         assert!(superseding.supersession().is_some());
    754     }
    755 
    756     #[test]
    757     fn report_accessors_preserve_the_exact_attestation_projection() {
    758         let current =
    759             RadrootsRhiEvidenceReportV1::from_canonical_content(CURRENT_REPORT.as_bytes())
    760                 .expect("current report vector");
    761 
    762         assert_eq!(
    763             current.contract_id(),
    764             RADROOTS_RHI_EVIDENCE_REPORT_CONTRACT_ID
    765         );
    766         assert_eq!(
    767             current.contract_version(),
    768             RADROOTS_RHI_EVIDENCE_REPORT_CONTRACT_VERSION
    769         );
    770         assert_eq!(current.issuer_public_key().to_hex(), "aa".repeat(32));
    771         assert_eq!(current.trade_id().to_hex(), "11".repeat(16));
    772         assert_eq!(current.claim_mutation_id().to_hex(), "22".repeat(32));
    773         assert_eq!(
    774             current.outcome(),
    775             RadrootsTradeEvidenceOutcomeV1::Indeterminate
    776         );
    777         assert_eq!(
    778             current.reason_codes()[0].as_str(),
    779             "required_source_incomplete"
    780         );
    781         assert_eq!(current.projection_digest().as_bytes(), &[0x66; 32]);
    782         assert_eq!(current.evidence_manifest_digest().as_bytes(), &[0x44; 32]);
    783         assert_eq!(current.evidence_policy_digest().as_bytes(), &[0x55; 32]);
    784         assert_eq!(current.observed_at_unix_s(), 1_800_000_000);
    785         assert_eq!(current.trade_generation(), NonZeroU64::new(7).unwrap());
    786         assert_eq!(
    787             current.reducer_contract_id(),
    788             RADROOTS_TRADE_REDUCER_CONTRACT_ID
    789         );
    790         assert_eq!(
    791             current.reducer_contract_version(),
    792             RADROOTS_TRADE_REDUCER_VERSION
    793         );
    794         assert_eq!(
    795             current.attestation_method(),
    796             RADROOTS_RHI_EVIDENCE_ATTESTATION_METHOD
    797         );
    798         assert_eq!(current.statement_digest().as_bytes().len(), 32);
    799 
    800         let superseding =
    801             RadrootsRhiEvidenceReportV1::from_canonical_content(SUPERSEDING_REPORT.as_bytes())
    802                 .expect("superseding report vector");
    803         let supersession = superseding.supersession().expect("supersession");
    804         assert_eq!(supersession.report_id().as_bytes(), &[0x77; 32]);
    805         assert_eq!(supersession.event_id().as_bytes(), &[0x88; 32]);
    806 
    807         assert_eq!(
    808             RadrootsTradeEvidenceProjectionDigestV1::sha256(b"projection").as_bytes(),
    809             RadrootsTradeEvidenceProjectionDigestV1::from_bytes(
    810                 Sha256::digest(b"projection").into()
    811             )
    812             .as_bytes()
    813         );
    814     }
    815 
    816     #[test]
    817     fn construction_is_canonical_manifest_bound_and_permutation_invariant() {
    818         let manifest = manifest(RadrootsTradeEvidenceCoverageV1::ScopeSatisfied);
    819         let a = RadrootsRhiEvidenceReportV1::new(
    820             PublicKey::from_hex("585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df")
    821                 .unwrap(),
    822             MutationId::from_bytes([0x22; 32]),
    823             RadrootsTradeEvidenceOutcomeV1::Valid,
    824             [
    825                 RadrootsRhiEvidenceReasonCodeV1::parse("source_complete").unwrap(),
    826                 RadrootsRhiEvidenceReasonCodeV1::parse("scope_satisfied").unwrap(),
    827             ],
    828             RadrootsTradeEvidenceProjectionDigestV1::from_bytes([0x66; 32]),
    829             &manifest,
    830             None,
    831         )
    832         .unwrap();
    833         let b = RadrootsRhiEvidenceReportV1::new(
    834             a.issuer_public_key(),
    835             a.claim_mutation_id(),
    836             a.outcome(),
    837             a.reason_codes().iter().cloned().rev(),
    838             a.projection_digest(),
    839             &manifest,
    840             None,
    841         )
    842         .unwrap();
    843         assert_eq!(a.canonical_content(), b.canonical_content());
    844         assert_eq!(a.statement_digest(), b.statement_digest());
    845         a.validate_against_manifest(&manifest).unwrap();
    846         assert_eq!(
    847             RadrootsRhiEvidenceReportV1::from_canonical_content(a.canonical_content().as_bytes()),
    848             Ok(a)
    849         );
    850     }
    851 
    852     #[test]
    853     fn coverage_outcome_matrix_fails_closed() {
    854         use RadrootsTradeEvidenceCoverageV1::{Missing, Partial, ScopeSatisfied, Unsupported};
    855         use RadrootsTradeEvidenceOutcomeV1::{Indeterminate, Invalid, Valid};
    856 
    857         for coverage in [Missing, Partial, ScopeSatisfied, Unsupported] {
    858             for outcome in [Valid, Invalid, Indeterminate] {
    859                 let result = report(coverage, outcome);
    860                 assert_eq!(result.is_ok(), coverage.permits(outcome));
    861             }
    862         }
    863     }
    864 
    865     #[test]
    866     fn reason_codes_are_bounded_validated_sorted_and_unique() {
    867         for invalid in ["", "Upper", "ends_", "has-hyphen", "has space"] {
    868             assert_eq!(
    869                 RadrootsRhiEvidenceReasonCodeV1::parse(invalid),
    870                 Err(RadrootsRhiEvidenceReportError::InvalidReasonCode)
    871             );
    872         }
    873         assert!(RadrootsRhiEvidenceReasonCodeV1::parse("a".repeat(64)).is_ok());
    874         assert_eq!(
    875             RadrootsRhiEvidenceReasonCodeV1::parse("a".repeat(65)),
    876             Err(RadrootsRhiEvidenceReportError::InvalidReasonCode)
    877         );
    878 
    879         let manifest = manifest(RadrootsTradeEvidenceCoverageV1::ScopeSatisfied);
    880         assert_eq!(
    881             report_with_reasons(&manifest, Vec::new()),
    882             Err(RadrootsRhiEvidenceReportError::ReasonCodeCountOutOfRange)
    883         );
    884         let maximum = (0..16)
    885             .map(|index| RadrootsRhiEvidenceReasonCodeV1::parse(format!("reason_{index:02}")))
    886             .collect::<Result<Vec<_>, _>>()
    887             .unwrap();
    888         assert!(report_with_reasons(&manifest, maximum.clone()).is_ok());
    889         assert_eq!(
    890             report_with_reasons(
    891                 &manifest,
    892                 maximum.into_iter().chain(core::iter::repeat(
    893                     RadrootsRhiEvidenceReasonCodeV1::parse("overflow").unwrap(),
    894                 )),
    895             ),
    896             Err(RadrootsRhiEvidenceReportError::ReasonCodeCountOutOfRange)
    897         );
    898         let duplicate = RadrootsRhiEvidenceReasonCodeV1::parse("same").unwrap();
    899         assert_eq!(
    900             report_with_reasons(&manifest, [duplicate.clone(), duplicate]),
    901             Err(RadrootsRhiEvidenceReportError::DuplicateReasonCode)
    902         );
    903     }
    904 
    905     #[test]
    906     fn parser_rejects_noncanonical_malformed_and_unbound_reports() {
    907         let mut value: Value = serde_json::from_str(CURRENT_REPORT).unwrap();
    908         value["report_id"] = json!("00".repeat(32));
    909         assert_eq!(
    910             RadrootsRhiEvidenceReportV1::from_canonical_content(
    911                 canonical_json(value).unwrap().as_bytes()
    912             ),
    913             Err(RadrootsRhiEvidenceReportError::StatementDigestMismatch)
    914         );
    915         assert_eq!(
    916             RadrootsRhiEvidenceReportV1::from_canonical_content(
    917                 format!(" {CURRENT_REPORT}").as_bytes()
    918             ),
    919             Err(RadrootsRhiEvidenceReportError::NonCanonical)
    920         );
    921         assert_eq!(
    922             RadrootsRhiEvidenceReportV1::from_canonical_content(
    923                 CURRENT_REPORT
    924                     .replacen(
    925                         "\"contract_version\":1",
    926                         "\"contract_version\":1,\"contract_version\":1",
    927                         1,
    928                     )
    929                     .as_bytes()
    930             ),
    931             Err(RadrootsRhiEvidenceReportError::Malformed)
    932         );
    933         assert_eq!(
    934             RadrootsRhiEvidenceReportV1::from_canonical_content(
    935                 CURRENT_REPORT
    936                     .replacen(
    937                         "\"contract_version\":1",
    938                         "\"contract_version\":1,\"unknown\":true",
    939                         1,
    940                     )
    941                     .as_bytes()
    942             ),
    943             Err(RadrootsRhiEvidenceReportError::Malformed)
    944         );
    945         assert_eq!(
    946             RadrootsRhiEvidenceReportV1::from_canonical_content(
    947                 CURRENT_REPORT
    948                     .replacen(
    949                         "\"supersedes_report_id\":null",
    950                         &format!("\"supersedes_report_id\":\"{}\"", "77".repeat(32)),
    951                         1,
    952                     )
    953                     .as_bytes()
    954             ),
    955             Err(RadrootsRhiEvidenceReportError::IncompleteSupersession)
    956         );
    957         assert_eq!(
    958             RadrootsRhiEvidenceReportV1::from_canonical_content(&vec![
    959                 b'x';
    960                 RADROOTS_RHI_EVIDENCE_REPORT_MAXIMUM_CANONICAL_BYTES
    961                     + 1
    962             ]),
    963             Err(RadrootsRhiEvidenceReportError::ReportTooLarge)
    964         );
    965         assert_eq!(
    966             RadrootsRhiEvidenceReportV1::from_canonical_content(&vec![
    967                 b'x';
    968                 RADROOTS_RHI_EVIDENCE_REPORT_MAXIMUM_CANONICAL_BYTES
    969             ]),
    970             Err(RadrootsRhiEvidenceReportError::Malformed)
    971         );
    972     }
    973 
    974     #[test]
    975     fn parser_rejects_every_fixed_identity_and_reason_drift() {
    976         for (from, to, error) in [
    977             (
    978                 "\"contract_version\":1",
    979                 "\"contract_version\":2",
    980                 RadrootsRhiEvidenceReportError::UnsupportedVersion,
    981             ),
    982             (
    983                 "radroots.rhi.evidence_attestation.v1",
    984                 "radroots.rhi.evidence_attestation.v2",
    985                 RadrootsRhiEvidenceReportError::FixedFieldMismatch,
    986             ),
    987             (
    988                 "signed_evidence_snapshot",
    989                 "unsigned_evidence_snapshot",
    990                 RadrootsRhiEvidenceReportError::FixedFieldMismatch,
    991             ),
    992             (
    993                 "\"outcome\":\"indeterminate\"",
    994                 "\"outcome\":\"complete\"",
    995                 RadrootsRhiEvidenceReportError::InvalidOutcome,
    996             ),
    997             (
    998                 "\"trade_generation\":7",
    999                 "\"trade_generation\":0",
   1000                 RadrootsRhiEvidenceReportError::InvalidTradeGeneration,
   1001             ),
   1002             (
   1003                 "\"projection_digest\":\"6666",
   1004                 "\"projection_digest\":\"GG66",
   1005                 RadrootsRhiEvidenceReportError::InvalidDigest,
   1006             ),
   1007         ] {
   1008             assert_eq!(
   1009                 RadrootsRhiEvidenceReportV1::from_canonical_content(
   1010                     CURRENT_REPORT.replacen(from, to, 1).as_bytes()
   1011                 ),
   1012                 Err(error)
   1013             );
   1014         }
   1015 
   1016         assert_eq!(
   1017             RadrootsRhiEvidenceReportV1::from_canonical_content(
   1018                 CURRENT_REPORT
   1019                     .replacen(
   1020                         "[\"required_source_incomplete\"]",
   1021                         "[\"z_reason\",\"a_reason\"]",
   1022                         1,
   1023                     )
   1024                     .as_bytes()
   1025             ),
   1026             Err(RadrootsRhiEvidenceReportError::NonCanonical)
   1027         );
   1028         assert_eq!(
   1029             RadrootsRhiEvidenceReportV1::from_canonical_content(
   1030                 CURRENT_REPORT
   1031                     .replacen("[\"required_source_incomplete\"]", "[\"same\",\"same\"]", 1,)
   1032                     .as_bytes()
   1033             ),
   1034             Err(RadrootsRhiEvidenceReportError::DuplicateReasonCode)
   1035         );
   1036 
   1037         let parsed =
   1038             RadrootsRhiEvidenceReportV1::from_canonical_content(CURRENT_REPORT.as_bytes()).unwrap();
   1039         assert_eq!(
   1040             parsed.validate_against_manifest(&manifest(
   1041                 RadrootsTradeEvidenceCoverageV1::ScopeSatisfied
   1042             )),
   1043             Err(RadrootsRhiEvidenceReportError::ManifestMismatch)
   1044         );
   1045     }
   1046 
   1047     #[test]
   1048     fn diagnostics_redact_identifiers_digests_and_content() {
   1049         let report = report(
   1050             RadrootsTradeEvidenceCoverageV1::ScopeSatisfied,
   1051             RadrootsTradeEvidenceOutcomeV1::Valid,
   1052         )
   1053         .unwrap();
   1054         let debug = format!("{report:?}");
   1055         for secret in [
   1056             report.trade_id().to_hex(),
   1057             report.claim_mutation_id().to_hex(),
   1058             report.statement_digest().to_hex(),
   1059         ] {
   1060             assert!(!debug.contains(&secret));
   1061         }
   1062         assert_eq!(
   1063             RadrootsRhiEvidenceReportError::ManifestMismatch.to_string(),
   1064             "evidence report does not match the evidence manifest"
   1065         );
   1066         assert!(std::error::Error::source(&RadrootsRhiEvidenceReportError::Malformed).is_none());
   1067     }
   1068 }