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