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 }