Skip to main content

perspt_sdk/
canon.rs

1//! Domain-tagged canonical byte encoding (PSP-10 system 23).
2//!
3//! serde output is not canonical — field order, float formatting, and escape
4//! choices may change across versions — so every digest that must replay
5//! commits to this fixed, length-prefixed encoding instead. The pattern is
6//! the one `GrantPolicy::canonical_bytes` established; these helpers make it
7//! reusable for the prompt plane (`perspt-prompt-v1`) and later digest
8//! domains without re-deriving the discipline each time.
9
10use sha2::{Digest, Sha256};
11
12/// An in-progress canonical encoding, opened under one domain tag.
13///
14/// Every value is length-prefixed (u64 big-endian) so no concatenation can
15/// alias two encodings; lists are additionally count-prefixed.
16#[derive(Debug)]
17pub struct CanonicalEncoder {
18    out: Vec<u8>,
19}
20
21impl CanonicalEncoder {
22    /// Open an encoding under a domain tag such as `b"perspt-prompt-v1"`.
23    /// The tag itself is the first length-prefixed field.
24    pub fn new(domain_tag: &[u8]) -> Self {
25        let mut encoder = Self { out: Vec::new() };
26        encoder.field(domain_tag);
27        encoder
28    }
29
30    /// Append one length-prefixed byte field.
31    pub fn field(&mut self, bytes: &[u8]) -> &mut Self {
32        self.out
33            .extend_from_slice(&(bytes.len() as u64).to_be_bytes());
34        self.out.extend_from_slice(bytes);
35        self
36    }
37
38    /// Append one length-prefixed UTF-8 field.
39    pub fn text(&mut self, value: &str) -> &mut Self {
40        self.field(value.as_bytes())
41    }
42
43    /// Append a fixed-width unsigned integer (no length prefix needed).
44    pub fn u64(&mut self, value: u64) -> &mut Self {
45        self.out.extend_from_slice(&value.to_be_bytes());
46        self
47    }
48
49    /// Append one boolean byte.
50    pub fn bool(&mut self, value: bool) -> &mut Self {
51        self.out.push(u8::from(value));
52        self
53    }
54
55    /// Append a count-prefixed list of text entries.
56    pub fn list<I>(&mut self, entries: I) -> &mut Self
57    where
58        I: ExactSizeIterator,
59        I::Item: AsRef<str>,
60    {
61        self.out
62            .extend_from_slice(&(entries.len() as u64).to_be_bytes());
63        for entry in entries {
64            self.text(entry.as_ref());
65        }
66        self
67    }
68
69    /// The finished canonical bytes.
70    pub fn finish(self) -> Vec<u8> {
71        self.out
72    }
73
74    /// SHA-256 over the finished canonical bytes, hex encoded with a
75    /// `sha256:` prefix so a digest names its own algorithm.
76    pub fn digest(self) -> String {
77        let mut hasher = Sha256::new();
78        hasher.update(&self.out);
79        let mut hex = String::with_capacity(7 + 64);
80        hex.push_str("sha256:");
81        for byte in hasher.finalize() {
82            hex.push_str(&format!("{byte:02x}"));
83        }
84        hex
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn encoding_is_length_prefixed_and_unambiguous() {
94        let mut a = CanonicalEncoder::new(b"tag");
95        a.text("ab").text("c");
96        let mut b = CanonicalEncoder::new(b"tag");
97        b.text("a").text("bc");
98        assert_ne!(a.finish(), b.finish(), "field boundaries must not alias");
99    }
100
101    #[test]
102    fn lists_are_count_prefixed() {
103        let mut a = CanonicalEncoder::new(b"tag");
104        a.list(["x", "y"].iter());
105        let mut b = CanonicalEncoder::new(b"tag");
106        b.list(["x"].iter()).list(["y"].iter());
107        assert_ne!(a.finish(), b.finish());
108    }
109
110    #[test]
111    fn digest_is_deterministic_and_self_describing() {
112        let mut a = CanonicalEncoder::new(b"perspt-prompt-v1");
113        a.text("section").u64(3).bool(true);
114        let mut b = CanonicalEncoder::new(b"perspt-prompt-v1");
115        b.text("section").u64(3).bool(true);
116        let digest = a.digest();
117        assert_eq!(digest, b.digest());
118        assert!(digest.starts_with("sha256:"));
119        assert_eq!(digest.len(), 7 + 64);
120    }
121}