Skip to main content

mdcast_api/
digest.rs

1//! Content digest: the identity of one blob in the asset-negotiation
2//! protocol. A caller announces the assets a render needs by digest, the
3//! server answers with the ones it does not hold yet.
4//!
5//! [`Digest`] is a newtype rather than a bare `String` because the server
6//! must never trust a caller-supplied digest: it is used as a lookup key for
7//! stored blob bytes, so anything that is not exactly 64 lowercase hex
8//! characters is rejected at the deserialization boundary instead of
9//! reaching storage.
10
11use std::fmt;
12use std::str::FromStr;
13
14use serde::{Deserialize, Serialize};
15
16/// Number of hex characters in a sha256 digest.
17pub const DIGEST_HEX_LEN: usize = 64;
18
19const HEX_DIGITS: [u8; 16] = *b"0123456789abcdef";
20
21/// A validated lowercase-hex sha256 digest.
22///
23/// Serializes as a plain JSON string; deserializing runs the same validation
24/// as [`Digest::parse`], so an invalid digest never materializes.
25#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
26#[serde(try_from = "String", into = "String")]
27pub struct Digest(String);
28
29impl Digest {
30    /// Validate `s` as a lowercase-hex sha256 digest.
31    pub fn parse(s: impl Into<String>) -> Result<Self, DigestError> {
32        let s = s.into();
33        if s.len() != DIGEST_HEX_LEN {
34            return Err(DigestError::Length(s.len()));
35        }
36        if !s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
37            return Err(DigestError::NotLowercaseHex);
38        }
39        Ok(Self(s))
40    }
41
42    /// Build a digest from raw sha256 output — the shape every hasher hands
43    /// back — without going through a string that could be malformed.
44    pub fn from_sha256(bytes: [u8; 32]) -> Self {
45        let mut hex = String::with_capacity(DIGEST_HEX_LEN);
46        for b in bytes {
47            hex.push(HEX_DIGITS[(b >> 4) as usize] as char);
48            hex.push(HEX_DIGITS[(b & 0x0f) as usize] as char);
49        }
50        Self(hex)
51    }
52
53    pub fn as_str(&self) -> &str {
54        &self.0
55    }
56}
57
58impl fmt::Display for Digest {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        f.write_str(&self.0)
61    }
62}
63
64impl AsRef<str> for Digest {
65    fn as_ref(&self) -> &str {
66        &self.0
67    }
68}
69
70impl From<Digest> for String {
71    fn from(d: Digest) -> Self {
72        d.0
73    }
74}
75
76impl TryFrom<String> for Digest {
77    type Error = DigestError;
78
79    fn try_from(s: String) -> Result<Self, Self::Error> {
80        Digest::parse(s)
81    }
82}
83
84impl FromStr for Digest {
85    type Err = DigestError;
86
87    fn from_str(s: &str) -> Result<Self, Self::Err> {
88        Digest::parse(s)
89    }
90}
91
92/// Why a string is not a valid [`Digest`]. Hand-written rather than derived
93/// with `thiserror`, to keep this crate on serde alone.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum DigestError {
96    /// Wrong number of characters (a sha256 digest is exactly 64).
97    Length(usize),
98    /// Contains something other than `0-9a-f` — uppercase included, since a
99    /// digest has exactly one spelling.
100    NotLowercaseHex,
101}
102
103impl fmt::Display for DigestError {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        match self {
106            DigestError::Length(len) => {
107                write!(f, "expected {DIGEST_HEX_LEN} hex characters, got {len}")
108            }
109            DigestError::NotLowercaseHex => {
110                f.write_str("expected lowercase hex characters (0-9, a-f)")
111            }
112        }
113    }
114}
115
116impl std::error::Error for DigestError {}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    const VALID: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
123
124    #[test]
125    fn parse_accepts_a_lowercase_hex_sha256() {
126        let digest = Digest::parse(VALID).unwrap();
127        assert_eq!(digest.as_str(), VALID);
128        assert_eq!(digest.to_string(), VALID);
129    }
130
131    #[test]
132    fn parse_rejects_wrong_length() {
133        assert_eq!(Digest::parse("abc"), Err(DigestError::Length(3)));
134        assert_eq!(
135            Digest::parse(format!("{VALID}0")),
136            Err(DigestError::Length(DIGEST_HEX_LEN + 1))
137        );
138        assert_eq!(
139            Digest::parse(&VALID[..DIGEST_HEX_LEN - 1]),
140            Err(DigestError::Length(DIGEST_HEX_LEN - 1))
141        );
142    }
143
144    #[test]
145    fn parse_rejects_uppercase() {
146        assert_eq!(
147            Digest::parse(VALID.to_uppercase()),
148            Err(DigestError::NotLowercaseHex)
149        );
150    }
151
152    #[test]
153    fn parse_rejects_non_hex() {
154        let non_hex = format!("z{}", &VALID[1..]);
155        assert_eq!(Digest::parse(non_hex), Err(DigestError::NotLowercaseHex));
156    }
157
158    #[test]
159    fn from_sha256_hex_encodes_all_nibbles() {
160        let mut bytes = [0u8; 32];
161        bytes[0] = 0x0f;
162        bytes[1] = 0xa0;
163        bytes[31] = 0xff;
164
165        let digest = Digest::from_sha256(bytes);
166
167        assert_eq!(digest.as_str().len(), DIGEST_HEX_LEN);
168        assert!(digest.as_str().starts_with("0fa0"));
169        assert!(digest.as_str().ends_with("ff"));
170        assert_eq!(Digest::parse(digest.as_str()).unwrap(), digest);
171    }
172
173    #[test]
174    fn serializes_as_a_plain_string() {
175        let digest = Digest::parse(VALID).unwrap();
176        let json = serde_json::to_string(&digest).unwrap();
177        assert_eq!(json, format!("\"{VALID}\""));
178        assert_eq!(serde_json::from_str::<Digest>(&json).unwrap(), digest);
179    }
180
181    #[test]
182    fn deserialization_rejects_an_invalid_digest() {
183        let err = serde_json::from_str::<Digest>("\"NOPE\"").unwrap_err();
184        assert!(err.to_string().contains("64 hex characters"), "{err}");
185    }
186
187    #[test]
188    fn from_str_matches_parse() {
189        assert_eq!(
190            VALID.parse::<Digest>().unwrap(),
191            Digest::parse(VALID).unwrap()
192        );
193        assert!("".parse::<Digest>().is_err());
194    }
195}