heddle_object_model/object/
check_evidence.rs1use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6use super::thread_replication::metadata::AUTHORITY_FORMAT;
7use crate::{
8 error::{HeddleError, Result},
9 object::{CollaborationActor, ContentHash, StateId},
10};
11
12pub const EVIDENCE_FORMAT: &str = "heddle-check-evidence-v2";
13pub const ACKNOWLEDGEMENT_FORMAT: &str = "heddle-check-acknowledgement-v1";
14pub const MAX_BYTES: usize = 128 * 1024;
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum CheckOutcome {
19 Passed,
20 Failed,
21 Error,
22 Skipped,
23}
24
25#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct CheckAuthor {
28 pub actor: CollaborationActor,
29 pub publisher: [u8; 32],
30 pub authority_digest: ContentHash,
31 pub authority_envelope: Vec<u8>,
32}
33impl CheckAuthor {
34 fn validate(&self) -> Result<()> {
35 if self.actor.principal_id.is_nil()
36 || self.publisher == [0; 32]
37 || self
38 .actor
39 .agent_id
40 .as_ref()
41 .is_some_and(|id| !valid_text(id, 256, false))
42 || self.authority_envelope.is_empty()
43 || self.authority_envelope.len() > 64 * 1024
44 || ContentHash::compute_typed(AUTHORITY_FORMAT, &self.authority_envelope)
45 != self.authority_digest
46 {
47 return Err(invalid("invalid check author authority binding"));
48 }
49 Ok(())
50 }
51}
52#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(deny_unknown_fields)]
54pub struct CheckEvidence {
55 pub version: u16,
56 pub id: Uuid,
57 pub spool: Uuid,
58 pub thread: ContentHash,
60 pub revision: StateId,
61 pub check: String,
62 pub outcome: CheckOutcome,
63 pub detail: String,
64 pub artifacts: Vec<Uuid>,
66 pub supersedes: Vec<Uuid>,
69 pub author: CheckAuthor,
70 pub completed_at_ms: i64,
71}
72impl CheckEvidence {
73 pub fn encode(&self) -> Result<Vec<u8>> {
74 self.author.validate()?;
75 if self.version != 2
76 || self.id.is_nil()
77 || self.spool.is_nil()
78 || self.completed_at_ms < 0
79 || !valid_text(&self.check, 512, false)
80 || !valid_text(&self.detail, 32 * 1024, true)
81 || self.artifacts.len() > 64
82 || self.artifacts.iter().any(Uuid::is_nil)
83 || self.artifacts.windows(2).any(|ids| ids[0] >= ids[1])
84 || self.supersedes.len() > 32
85 || self
86 .supersedes
87 .iter()
88 .any(|id| id.is_nil() || *id == self.id)
89 || self.supersedes.windows(2).any(|ids| ids[0] >= ids[1])
90 {
91 return Err(invalid("invalid check evidence"));
92 }
93 bounded_encode(self)
94 }
95 pub fn decode(bytes: &[u8]) -> Result<Self> {
96 bound(bytes)?;
97 let value: Self = rmp_serde::from_slice(bytes)?;
98 if value.encode()? != bytes {
99 return Err(invalid("noncanonical check evidence"));
100 }
101 Ok(value)
102 }
103 pub fn id(&self) -> Result<ContentHash> {
104 Ok(ContentHash::compute_typed(EVIDENCE_FORMAT, &self.encode()?))
105 }
106}
107#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct CheckAcknowledgement {
110 pub version: u16,
111 pub spool: Uuid,
112 pub evidence: Uuid,
113 pub evidence_digest: ContentHash,
115 pub revision: StateId,
116 pub policy_version: ContentHash,
117 pub author: CheckAuthor,
118 pub client_operation_id: Uuid,
119 pub occurred_at_ms: i64,
120}
121impl CheckAcknowledgement {
122 pub fn encode(&self) -> Result<Vec<u8>> {
123 self.author.validate()?;
124 if self.version != 1
125 || self.spool.is_nil()
126 || self.evidence.is_nil()
127 || self.client_operation_id.is_nil()
128 || self.occurred_at_ms < 0
129 {
130 return Err(invalid("invalid check acknowledgement"));
131 }
132 bounded_encode(self)
133 }
134 pub fn decode(bytes: &[u8]) -> Result<Self> {
135 bound(bytes)?;
136 let value: Self = rmp_serde::from_slice(bytes)?;
137 if value.encode()? != bytes {
138 return Err(invalid("noncanonical check acknowledgement"));
139 }
140 Ok(value)
141 }
142}
143fn bounded_encode(value: &impl Serialize) -> Result<Vec<u8>> {
144 let bytes = rmp_serde::to_vec_named(value)?;
145 bound(&bytes)?;
146 Ok(bytes)
147}
148fn bound(bytes: &[u8]) -> Result<()> {
149 if bytes.is_empty() || bytes.len() > MAX_BYTES {
150 return Err(invalid("check record exceeds byte bounds"));
151 }
152 Ok(())
153}
154fn valid_text(value: &str, max: usize, empty: bool) -> bool {
155 (empty || !value.trim().is_empty()) && value.len() <= max && !value.contains('\0')
156}
157fn invalid(message: &str) -> HeddleError {
158 HeddleError::InvalidObject(message.into())
159}