Skip to main content

rite_model/
digest.rs

1//! The `sha256:<hex>` digest used for every hash the transcript records.
2
3use std::fmt;
4
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8const PREFIX: &str = "sha256:";
9
10/// A SHA-256 digest written as `sha256:` followed by 64 lowercase hex digits.
11///
12/// The one hash encoding in the transcript. Parsing rejects any other form,
13/// so a recorded digest compares correctly as a string.
14#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
15#[serde(try_from = "String", into = "String")]
16pub struct Sha256Digest(String);
17
18#[cfg(test)]
19impl schemars::JsonSchema for Sha256Digest {
20    fn schema_name() -> std::borrow::Cow<'static, str> {
21        "Sha256Digest".into()
22    }
23
24    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
25        schemars::json_schema!({
26            "description": "A SHA-256 digest: `sha256:` followed by 64 lowercase hex digits.",
27            "type": "string",
28            "pattern": "^sha256:[0-9a-f]{64}$",
29        })
30    }
31}
32
33/// A string that is not a `sha256:<64 lowercase hex>` digest.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct DigestFormatError(String);
36
37impl fmt::Display for DigestFormatError {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        write!(
40            f,
41            "'{}' is not a sha256:<64 lowercase hex digits> digest",
42            self.0
43        )
44    }
45}
46
47impl std::error::Error for DigestFormatError {}
48
49impl Sha256Digest {
50    /// The digest of `data`.
51    #[must_use]
52    pub fn of(data: &[u8]) -> Self {
53        let hash = Sha256::digest(data);
54        Self(format!("{PREFIX}{}", base16ct::lower::encode_string(&hash)))
55    }
56
57    /// The written form of a raw 32-byte digest.
58    #[must_use]
59    pub fn from_bytes(bytes: &[u8; 32]) -> Self {
60        Self(format!("{PREFIX}{}", base16ct::lower::encode_string(bytes)))
61    }
62
63    /// The raw 32 bytes.
64    #[must_use]
65    pub fn to_bytes(&self) -> [u8; 32] {
66        let mut out = [0u8; 32];
67        // `parse` admits only 64 lowercase hex digits, so this decodes.
68        let _ = base16ct::lower::decode(&self.0[PREFIX.len()..], &mut out);
69        out
70    }
71
72    /// Parse a digest in its written form.
73    ///
74    /// ```
75    /// use rite_model::Sha256Digest;
76    ///
77    /// let digest = Sha256Digest::of(b"ceremony");
78    /// assert_eq!(Sha256Digest::parse(digest.as_str()), Ok(digest));
79    /// // Any other spelling is refused.
80    /// assert!(Sha256Digest::parse("SHA256:00").is_err());
81    /// ```
82    ///
83    /// # Errors
84    ///
85    /// Returns [`DigestFormatError`] unless `s` is `sha256:` followed by
86    /// exactly 64 lowercase hex digits.
87    pub fn parse(s: &str) -> Result<Self, DigestFormatError> {
88        let valid = s.strip_prefix(PREFIX).is_some_and(|hex| {
89            hex.len() == 64 && hex.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
90        });
91        if valid {
92            Ok(Self(s.to_string()))
93        } else {
94            Err(DigestFormatError(s.to_string()))
95        }
96    }
97
98    /// The written form, `sha256:<hex>`.
99    #[must_use]
100    pub fn as_str(&self) -> &str {
101        &self.0
102    }
103}
104
105impl fmt::Display for Sha256Digest {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        f.write_str(&self.0)
108    }
109}
110
111impl TryFrom<String> for Sha256Digest {
112    type Error = DigestFormatError;
113
114    fn try_from(s: String) -> Result<Self, Self::Error> {
115        Self::parse(&s)
116    }
117}
118
119impl From<Sha256Digest> for String {
120    fn from(d: Sha256Digest) -> Self {
121        d.0
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn digest_of_known_bytes() {
131        assert_eq!(
132            Sha256Digest::of(b"hello world").as_str(),
133            "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
134        );
135    }
136
137    #[test]
138    fn bytes_round_trip() {
139        let d = Sha256Digest::of(b"x");
140        assert_eq!(Sha256Digest::from_bytes(&d.to_bytes()), d);
141    }
142
143    #[test]
144    fn parse_accepts_the_written_form() {
145        let d = Sha256Digest::of(b"x");
146        assert_eq!(Sha256Digest::parse(d.as_str()), Ok(d));
147    }
148
149    #[test]
150    fn parse_rejects_other_forms() {
151        let hex = "a".repeat(64);
152        for bad in [
153            hex.clone(),
154            format!("sha256:{}", "A".repeat(64)),
155            format!("sha256:{}", &hex[..63]),
156            format!("sha512:{hex}"),
157            format!("sha256:{hex}0"),
158        ] {
159            assert!(Sha256Digest::parse(&bad).is_err(), "accepted {bad}");
160        }
161    }
162
163    #[test]
164    fn deserialize_validates() {
165        let err = serde_json::from_str::<Sha256Digest>("\"sha256:zz\"");
166        assert!(err.is_err());
167    }
168}