heddle_object_model/object/
state_review.rs1use serde::{Deserialize, Serialize};
16
17use crate::object::{hash::StateId, state_attribution::Principal};
18
19pub const SIGNING_PAYLOAD_VERSION_TAG: &[u8] = b"hc-rev-sig-v1\x00";
22
23#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
24pub struct ReviewSignaturesBlob {
25 pub format_version: u8,
26 pub signatures: Vec<ReviewSignature>,
27}
28
29versioned_msgpack_blob! {
30 blob: ReviewSignaturesBlob,
31 item: ReviewSignature,
32 field: signatures,
33 error: ReviewSignatureError,
34 codec_err: Encoding,
35 version: 1,
36}
37
38#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
39pub struct ReviewSignature {
40 pub actor: Principal,
41 pub kind: ReviewKind,
42 pub scope: ReviewScope,
43 #[serde(default)]
46 pub justification: Option<String>,
47 pub signed_at: i64,
49 pub algorithm: String,
50 pub public_key: String,
51 pub signature: String,
54}
55
56impl ReviewSignature {
57 pub fn validate(&self) -> Result<(), ReviewSignatureError> {
58 if self.algorithm.is_empty() {
59 return Err(ReviewSignatureError::EmptyAlgorithm);
60 }
61 if self.public_key.is_empty() {
62 return Err(ReviewSignatureError::EmptyPublicKey);
63 }
64 if self.signature.is_empty() {
65 return Err(ReviewSignatureError::EmptySignature);
66 }
67 self.scope.validate()?;
68 Ok(())
69 }
70}
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum ReviewKind {
77 Read,
79 AgentPreview,
81 AgentCoReview,
83}
84
85impl ReviewKind {
86 pub fn as_str(&self) -> &'static str {
87 match self {
88 Self::Read => "read",
89 Self::AgentPreview => "agent_preview",
90 Self::AgentCoReview => "agent_co_review",
91 }
92 }
93}
94
95#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
97pub enum ReviewScope {
98 WholeChange,
99 Symbols(Vec<SymbolAnchor>),
100}
101
102impl ReviewScope {
103 pub fn validate(&self) -> Result<(), ReviewSignatureError> {
104 match self {
105 Self::WholeChange => Ok(()),
106 Self::Symbols(symbols) => {
107 if symbols.is_empty() {
108 return Err(ReviewSignatureError::EmptySymbolScope);
109 }
110 for s in symbols {
111 s.validate()?;
112 }
113 Ok(())
114 }
115 }
116 }
117}
118
119#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
122pub struct SymbolAnchor {
123 pub file: String,
124 pub symbol: String,
125}
126
127impl SymbolAnchor {
128 pub fn new(file: impl Into<String>, symbol: impl Into<String>) -> Self {
129 Self {
130 file: file.into(),
131 symbol: symbol.into(),
132 }
133 }
134
135 pub fn validate(&self) -> Result<(), ReviewSignatureError> {
136 if self.file.is_empty() {
137 return Err(ReviewSignatureError::EmptyAnchorFile);
138 }
139 if self.symbol.is_empty() {
140 return Err(ReviewSignatureError::EmptyAnchorSymbol);
141 }
142 Ok(())
143 }
144}
145
146pub fn signing_payload(
154 state_id: StateId,
155 kind: ReviewKind,
156 scope: &ReviewScope,
157 signed_at: i64,
158 justification: Option<&str>,
159) -> Vec<u8> {
160 let mut buf = Vec::with_capacity(SIGNING_PAYLOAD_VERSION_TAG.len() + 256);
161 buf.extend_from_slice(SIGNING_PAYLOAD_VERSION_TAG);
162 buf.extend_from_slice(state_id.to_string_full().as_bytes());
163 buf.push(0);
164 buf.extend_from_slice(kind.as_str().as_bytes());
165 buf.push(0);
166 match scope {
167 ReviewScope::WholeChange => {
168 buf.extend_from_slice(b"whole_change");
169 buf.push(0);
170 }
171 ReviewScope::Symbols(symbols) => {
172 buf.extend_from_slice(b"symbols");
173 buf.push(0);
174 buf.extend_from_slice(&(symbols.len() as u32).to_le_bytes());
175 for s in symbols {
176 buf.extend_from_slice(s.file.as_bytes());
177 buf.push(0);
178 buf.extend_from_slice(s.symbol.as_bytes());
179 buf.push(0);
180 }
181 }
182 }
183 buf.extend_from_slice(&signed_at.to_le_bytes());
184 if let Some(j) = justification {
185 buf.push(1);
186 buf.extend_from_slice(j.as_bytes());
187 buf.push(0);
188 } else {
189 buf.push(0);
190 }
191 buf
192}
193
194#[derive(Debug, thiserror::Error)]
195pub enum ReviewSignatureError {
196 #[error("unsupported review signatures blob version {0}")]
197 UnsupportedVersion(u8),
198 #[error("review signature must declare a non-empty algorithm")]
199 EmptyAlgorithm,
200 #[error("review signature must include a public key")]
201 EmptyPublicKey,
202 #[error("review signature must include a signature value")]
203 EmptySignature,
204 #[error("symbol-scope review must include at least one symbol")]
205 EmptySymbolScope,
206 #[error("symbol anchor must reference a non-empty file")]
207 EmptyAnchorFile,
208 #[error("symbol anchor must reference a non-empty symbol")]
209 EmptyAnchorSymbol,
210 #[error("review signatures blob encoding error: {0}")]
211 Encoding(String),
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 fn sample_principal() -> Principal {
219 Principal::new("Alice", "alice@example.com")
220 }
221
222 fn sample_signature() -> ReviewSignature {
223 ReviewSignature {
224 actor: sample_principal(),
225 kind: ReviewKind::Read,
226 scope: ReviewScope::WholeChange,
227 justification: None,
228 signed_at: 1_700_000_000,
229 algorithm: "ed25519".into(),
230 public_key: "deadbeef".into(),
231 signature: "abad1dea".into(),
232 }
233 }
234
235 #[test]
236 fn read_signature_validates() {
237 sample_signature().validate().unwrap();
238 }
239
240 #[test]
241 fn empty_symbol_scope_rejected() {
242 let mut sig = sample_signature();
243 sig.scope = ReviewScope::Symbols(vec![]);
244 assert!(matches!(
245 sig.validate(),
246 Err(ReviewSignatureError::EmptySymbolScope)
247 ));
248 }
249
250 #[test]
251 fn unsigned_blob_validates() {
252 let blob = ReviewSignaturesBlob::new(vec![]);
253 blob.validate().unwrap();
254 }
255
256 #[test]
257 fn blob_roundtrip() {
258 let blob = ReviewSignaturesBlob::new(vec![sample_signature()]);
259 let bytes = blob.encode().unwrap();
260 let decoded = ReviewSignaturesBlob::decode(&bytes).unwrap();
261 assert_eq!(blob, decoded);
262 }
263
264 #[test]
265 fn signing_payload_distinguishes_scope() {
266 let id = StateId::from_bytes([1; 32]);
267 let whole = signing_payload(id, ReviewKind::Read, &ReviewScope::WholeChange, 0, None);
268 let one_symbol = signing_payload(
269 id,
270 ReviewKind::Read,
271 &ReviewScope::Symbols(vec![SymbolAnchor::new("a.rs", "foo")]),
272 0,
273 None,
274 );
275 assert_ne!(whole, one_symbol);
276 }
277
278 #[test]
279 fn signing_payload_starts_with_version_tag() {
280 let id = StateId::from_bytes([1; 32]);
281 let payload = signing_payload(id, ReviewKind::Read, &ReviewScope::WholeChange, 0, None);
282 assert_eq!(SIGNING_PAYLOAD_VERSION_TAG, b"hc-rev-sig-v1\x00");
283 assert!(payload.starts_with(SIGNING_PAYLOAD_VERSION_TAG));
284 }
285
286 #[test]
287 fn signing_payload_distinguishes_kind() {
288 let id = StateId::from_bytes([1; 32]);
289 let read = signing_payload(id, ReviewKind::Read, &ReviewScope::WholeChange, 0, None);
290 let preview = signing_payload(
291 id,
292 ReviewKind::AgentPreview,
293 &ReviewScope::WholeChange,
294 0,
295 None,
296 );
297 assert_ne!(read, preview);
298 }
299}