Skip to main content

release_kit/
digest.rs

1//! SHA-256 digests in their hex text form.
2//!
3//! One digest type serves every record and report in the binary: the
4//! user-scope skill record, and the payload identity `rk payload` prints.
5//! Computing at runtime over the embedded bytes keeps `build.rs` free of
6//! code generation and makes a digest necessarily equal to what the
7//! binary actually carries, which a build-time table would only claim.
8
9use std::fmt;
10
11/// The lowercase hex alphabet, indexed by nibble.
12const HEX: [u8; 16] = *b"0123456789abcdef";
13
14/// A SHA-256 digest, in its 64-character lowercase hex form.
15#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
16pub struct Digest(String);
17
18impl Digest {
19    /// Digest a byte string.
20    #[must_use]
21    pub fn of(bytes: &[u8]) -> Self {
22        use sha2::Digest as _;
23        let mut hex = String::with_capacity(64);
24        for byte in sha2::Sha256::digest(bytes) {
25            hex.push(char::from(HEX[usize::from(byte >> 4)]));
26            hex.push(char::from(HEX[usize::from(byte & 0x0f)]));
27        }
28        Self(hex)
29    }
30
31    /// Parse a 64-character lowercase hex digest, or reject it.
32    #[must_use]
33    pub fn parse(text: &str) -> Option<Self> {
34        let hex = text.len() == 64
35            && text
36                .bytes()
37                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
38        hex.then(|| Self(text.to_owned()))
39    }
40}
41
42impl fmt::Display for Digest {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        f.write_str(&self.0)
45    }
46}
47
48impl serde::Serialize for Digest {
49    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
50        serializer.serialize_str(&self.0)
51    }
52}
53
54impl<'de> serde::Deserialize<'de> for Digest {
55    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
56        let text = String::deserialize(deserializer)?;
57        Self::parse(&text).ok_or_else(|| {
58            serde::de::Error::custom(format!("'{text}' is not a 64-character hex sha256"))
59        })
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    #![allow(clippy::expect_used)]
66
67    use super::Digest;
68
69    #[test]
70    fn a_digest_round_trips_through_its_hex_form() {
71        // The published SHA-256 of the empty string, so the hex encoding is
72        // checked against a value this crate did not compute.
73        let empty = Digest::of(b"");
74        assert_eq!(
75            empty.to_string(),
76            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
77        );
78        assert_eq!(Digest::parse(&empty.to_string()), Some(empty));
79    }
80
81    #[test]
82    fn a_malformed_digest_is_rejected() {
83        for text in ["", "abc", &"g".repeat(64), &"A".repeat(64), &"a".repeat(63)] {
84            assert!(Digest::parse(text).is_none(), "'{text}' parsed as a digest");
85        }
86    }
87}