Skip to main content

objects/object/
state_review.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Review signatures: who reviewed a state, in what role, with what scope.
3//!
4//! Distinct from [`StateSignature`](crate::object::StateSignature). That signature
5//! authenticates *authorship* — "this principal captured this state". A
6//! [`ReviewSignature`] authenticates *review* — "this actor read / previewed /
7//! co-reviewed this state". A state may carry many review signatures across its
8//! lifetime; the kind enum is extensible to admit future kinds without
9//! re-encoding old data.
10//!
11//! The cryptographic primitives live in `crates/crypto/`. This module owns the
12//! payload format the signature is computed over, so verifiers in any language
13//! can reproduce it.
14
15use serde::{Deserialize, Serialize};
16
17use crate::object::{hash::ChangeId, state_attribution::Principal};
18
19/// Stable byte prefix the signing payload begins with. Bumping this versions
20/// the payload format itself; old signatures with the old prefix continue to
21/// verify exactly as they did when written.
22pub 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    /// Reserved for future review kinds (e.g. send-it / fast-track) that need
70    /// a written rationale. Read/preview/co-review leave this `None`.
71    #[serde(default)]
72    pub justification: Option<String>,
73    /// Unix epoch seconds.
74    pub signed_at: i64,
75    pub algorithm: String,
76    pub public_key: String,
77    /// Hex-encoded signature bytes — same encoding convention as
78    /// [`StateSignature::signature`](crate::object::StateSignature).
79    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/// Reviewer roles. New variants append at the tail; the wire format stays
99/// backwards compatible because `serde` emits the snake-case discriminant.
100#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102pub enum ReviewKind {
103    /// Reviewer claims to have read the change.
104    Read,
105    /// Agent ran a preview pass — listed signals, optionally annotated.
106    AgentPreview,
107    /// Agent acted as co-reviewer; comments and signatures recorded.
108    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/// Reviewer signed off on the whole change, or on a specific list of symbols.
122#[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/// Durable symbol-level anchor: a file path plus a symbol name. No line range
146/// — line numbers move under reformatting; symbols do not.
147#[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
172/// Build the deterministic byte payload that a [`ReviewSignature`] is computed
173/// over. Re-implementing this in another language (TypeScript, Python) must
174/// produce byte-identical output for verification to round-trip.
175///
176/// Layout: version tag, then NUL-terminated string fields, then fixed-width
177/// integers. Uses NUL byte as a field separator, which is safe because
178/// `change_id` is hex and other fields are utf-8 strings without embedded NULs.
179pub 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}