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