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, collaboration::CanonicalBody},
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 #[serde(skip)]
74 pub canonical_body: CanonicalBody,
75}
76impl CheckEvidence {
77 fn encode_fields(&self) -> Result<Vec<u8>> {
78 self.author.validate()?;
79 if self.version != 2
80 || self.id.is_nil()
81 || self.spool.is_nil()
82 || self.completed_at_ms < 0
83 || !valid_text(&self.check, 512, false)
84 || !valid_text(&self.detail, 32 * 1024, true)
85 || self.artifacts.len() > 64
86 || self.artifacts.iter().any(Uuid::is_nil)
87 || self.artifacts.windows(2).any(|ids| ids[0] >= ids[1])
88 || self.supersedes.len() > 32
89 || self
90 .supersedes
91 .iter()
92 .any(|id| id.is_nil() || *id == self.id)
93 || self.supersedes.windows(2).any(|ids| ids[0] >= ids[1])
94 {
95 return Err(invalid("invalid check evidence"));
96 }
97 bounded_encode(self)
98 }
99 pub fn encode(&self) -> Result<Vec<u8>> {
100 self.canonical_body.clear();
101 let bytes = self.encode_fields()?;
102 self.canonical_body.store(bytes.clone());
103 Ok(bytes)
104 }
105 pub fn decode(bytes: &[u8]) -> Result<Self> {
106 bound(bytes)?;
107 let value: Self = rmp_serde::from_slice(bytes)?;
108 if value.encode()? != bytes {
109 return Err(invalid("noncanonical check evidence"));
110 }
111 Ok(value)
112 }
113 pub fn id(&self) -> Result<ContentHash> {
114 if let Some(bytes) = self.canonical_body.cloned() {
115 CanonicalBody::debug_matches(&bytes, || self.encode_fields());
116 return Ok(ContentHash::compute_typed(EVIDENCE_FORMAT, &bytes));
117 }
118 Ok(ContentHash::compute_typed(EVIDENCE_FORMAT, &self.encode()?))
119 }
120}
121#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(deny_unknown_fields)]
123pub struct CheckAcknowledgement {
124 pub version: u16,
125 pub spool: Uuid,
126 pub evidence: Uuid,
127 pub evidence_digest: ContentHash,
129 pub revision: StateId,
130 pub policy_version: ContentHash,
131 pub author: CheckAuthor,
132 pub client_operation_id: Uuid,
133 pub occurred_at_ms: i64,
134}
135impl CheckAcknowledgement {
136 pub fn encode(&self) -> Result<Vec<u8>> {
137 self.author.validate()?;
138 if self.version != 1
139 || self.spool.is_nil()
140 || self.evidence.is_nil()
141 || self.client_operation_id.is_nil()
142 || self.occurred_at_ms < 0
143 {
144 return Err(invalid("invalid check acknowledgement"));
145 }
146 bounded_encode(self)
147 }
148 pub fn decode(bytes: &[u8]) -> Result<Self> {
149 bound(bytes)?;
150 let value: Self = rmp_serde::from_slice(bytes)?;
151 if value.encode()? != bytes {
152 return Err(invalid("noncanonical check acknowledgement"));
153 }
154 Ok(value)
155 }
156}
157fn bounded_encode(value: &impl Serialize) -> Result<Vec<u8>> {
158 let bytes = rmp_serde::to_vec_named(value)?;
159 bound(&bytes)?;
160 Ok(bytes)
161}
162fn bound(bytes: &[u8]) -> Result<()> {
163 if bytes.is_empty() || bytes.len() > MAX_BYTES {
164 return Err(invalid("check record exceeds byte bounds"));
165 }
166 Ok(())
167}
168fn valid_text(value: &str, max: usize, empty: bool) -> bool {
169 (empty || !value.trim().is_empty()) && value.len() <= max && !value.contains('\0')
170}
171fn invalid(message: &str) -> HeddleError {
172 HeddleError::InvalidObject(message.into())
173}
174
175#[cfg(test)]
176mod tests {
177 use uuid::Uuid;
178
179 use super::*;
180 use crate::object::{
181 CollaborationActor, ContentHash, StateId, thread_replication::metadata::AUTHORITY_FORMAT,
182 };
183
184 fn sample() -> CheckEvidence {
185 let envelope = b"independently verified authority".to_vec();
186 CheckEvidence {
187 version: 2,
188 id: Uuid::from_u128(1),
189 spool: Uuid::from_u128(2),
190 thread: ContentHash::from_bytes([7; 32]),
191 revision: StateId::from_bytes([3; 32]),
192 check: "unit-tests".into(),
193 outcome: CheckOutcome::Passed,
194 detail: "42 passed".into(),
195 artifacts: Vec::new(),
196 supersedes: Vec::new(),
197 author: CheckAuthor {
198 actor: CollaborationActor {
199 principal_id: Uuid::from_u128(4),
200 agent_id: None,
201 },
202 publisher: [9; 32],
203 authority_digest: ContentHash::compute_typed(AUTHORITY_FORMAT, &envelope),
204 authority_envelope: envelope,
205 },
206 completed_at_ms: 1000,
207 canonical_body: Default::default(),
208 }
209 }
210
211 #[test]
212 fn id_matches_reencode_of_canonical_body() {
213 let evidence = sample();
214 let bytes = evidence.encode().expect("canonical evidence");
215 let old = ContentHash::compute_typed(EVIDENCE_FORMAT, &bytes);
216 assert_eq!(evidence.id().expect("fresh id"), old);
217 assert_eq!(evidence.id().expect("cached id"), old);
218
219 let decoded = CheckEvidence::decode(&bytes).expect("decode");
220 assert_eq!(decoded, evidence);
221 assert_eq!(decoded.id().expect("decoded id"), old);
222 assert_eq!(
223 decoded.id().expect("decoded id"),
224 ContentHash::compute_typed(EVIDENCE_FORMAT, &decoded.encode().expect("re-encode"))
225 );
226
227 let mut changed = evidence.clone();
228 changed.outcome = CheckOutcome::Failed;
229 let changed_bytes = changed.encode().expect("changed evidence");
230 let changed_id = changed.id().expect("changed id");
231 assert_ne!(changed_id, old);
232 assert_eq!(
233 changed_id,
234 ContentHash::compute_typed(EVIDENCE_FORMAT, &changed_bytes)
235 );
236 }
237}