ferro_blob_store/
digest.rs1use std::fmt;
5use std::str::FromStr;
6
7use sha2::{Digest as _, Sha256, Sha512};
8use thiserror::Error;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12#[non_exhaustive]
13pub enum DigestAlgo {
14 Sha256,
17 Sha512,
20}
21
22impl DigestAlgo {
23 #[must_use]
25 pub const fn hex_len(self) -> usize {
26 match self {
27 Self::Sha256 => 64,
28 Self::Sha512 => 128,
29 }
30 }
31
32 #[must_use]
34 pub const fn prefix(self) -> &'static str {
35 match self {
36 Self::Sha256 => "sha256",
37 Self::Sha512 => "sha512",
38 }
39 }
40
41 fn parse_prefix(s: &str) -> Option<Self> {
42 match s {
43 "sha256" => Some(Self::Sha256),
44 "sha512" => Some(Self::Sha512),
45 _ => None,
46 }
47 }
48}
49
50impl fmt::Display for DigestAlgo {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 f.write_str(self.prefix())
53 }
54}
55
56#[derive(Debug, Clone, Error, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum DigestParseError {
60 #[error("digest must be of the form `<algo>:<hex>`")]
62 MissingSeparator,
63 #[error("unsupported digest algorithm prefix: {0:?}")]
65 UnsupportedAlgo(String),
66 #[error("invalid digest hex length for {algo}: expected {expected}, got {actual}")]
68 BadLength {
69 algo: DigestAlgo,
71 expected: usize,
73 actual: usize,
75 },
76 #[error("invalid hex character {bad:?} in digest")]
78 BadHex {
79 bad: char,
81 },
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Hash)]
89#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
90#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
91pub struct Digest {
92 algo: DigestAlgo,
93 hex: String,
94}
95
96#[cfg(feature = "serde")]
97impl TryFrom<String> for Digest {
98 type Error = DigestParseError;
99 fn try_from(s: String) -> Result<Self, Self::Error> {
100 s.parse()
101 }
102}
103
104#[cfg(feature = "serde")]
105impl From<Digest> for String {
106 fn from(d: Digest) -> Self {
107 d.to_string()
108 }
109}
110
111impl Digest {
112 pub fn new(algo: DigestAlgo, hex: impl Into<String>) -> Result<Self, DigestParseError> {
115 let hex = hex.into();
116 Self::validate_hex(algo, &hex)?;
117 Ok(Self {
118 algo,
119 hex: hex.to_ascii_lowercase(),
120 })
121 }
122
123 #[must_use]
125 pub fn sha256_of(bytes: &[u8]) -> Self {
126 let mut hasher = Sha256::new();
127 hasher.update(bytes);
128 let result = hasher.finalize();
129 Self {
130 algo: DigestAlgo::Sha256,
131 hex: hex::encode(result),
132 }
133 }
134
135 #[must_use]
137 pub fn sha512_of(bytes: &[u8]) -> Self {
138 let mut hasher = Sha512::new();
139 hasher.update(bytes);
140 let result = hasher.finalize();
141 Self {
142 algo: DigestAlgo::Sha512,
143 hex: hex::encode(result),
144 }
145 }
146
147 #[must_use]
149 pub const fn algo(&self) -> DigestAlgo {
150 self.algo
151 }
152
153 #[must_use]
155 pub fn hex(&self) -> &str {
156 &self.hex
157 }
158
159 fn validate_hex(algo: DigestAlgo, hex: &str) -> Result<(), DigestParseError> {
160 let expected = algo.hex_len();
161 if hex.len() != expected {
162 return Err(DigestParseError::BadLength {
163 algo,
164 expected,
165 actual: hex.len(),
166 });
167 }
168 if let Some(bad) = hex.chars().find(|c| !c.is_ascii_hexdigit()) {
169 return Err(DigestParseError::BadHex { bad });
170 }
171 Ok(())
172 }
173}
174
175impl fmt::Display for Digest {
176 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177 write!(f, "{}:{}", self.algo, self.hex)
178 }
179}
180
181impl FromStr for Digest {
182 type Err = DigestParseError;
183 fn from_str(s: &str) -> Result<Self, Self::Err> {
184 let (prefix, hex) = s
185 .split_once(':')
186 .ok_or(DigestParseError::MissingSeparator)?;
187 let algo = DigestAlgo::parse_prefix(prefix)
188 .ok_or_else(|| DigestParseError::UnsupportedAlgo(prefix.to_string()))?;
189 Self::new(algo, hex)
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 #[test]
198 fn sha256_of_round_trips() {
199 let d = Digest::sha256_of(b"hello");
200 assert_eq!(d.algo(), DigestAlgo::Sha256);
201 assert_eq!(d.hex().len(), 64);
202 let s = d.to_string();
203 let parsed: Digest = s.parse().unwrap();
204 assert_eq!(parsed, d);
205 }
206
207 #[test]
208 fn sha512_of_round_trips() {
209 let d = Digest::sha512_of(b"hello");
210 assert_eq!(d.algo(), DigestAlgo::Sha512);
211 assert_eq!(d.hex().len(), 128);
212 let s = d.to_string();
213 let parsed: Digest = s.parse().unwrap();
214 assert_eq!(parsed, d);
215 }
216
217 #[test]
218 fn parse_missing_separator() {
219 assert!(matches!(
220 "abc".parse::<Digest>(),
221 Err(DigestParseError::MissingSeparator)
222 ));
223 }
224
225 #[test]
226 fn parse_unsupported_algo() {
227 assert!(matches!(
228 "md5:abc".parse::<Digest>(),
229 Err(DigestParseError::UnsupportedAlgo(_))
230 ));
231 }
232
233 #[test]
234 fn parse_bad_length() {
235 assert!(matches!(
236 "sha256:deadbeef".parse::<Digest>(),
237 Err(DigestParseError::BadLength { .. })
238 ));
239 }
240
241 #[test]
242 fn parse_bad_hex() {
243 let bogus = format!("sha256:{}", "z".repeat(64));
244 assert!(matches!(
245 bogus.parse::<Digest>(),
246 Err(DigestParseError::BadHex { bad: 'z' })
247 ));
248 }
249
250 #[test]
251 fn upper_case_hex_is_normalized() {
252 let upper = format!("sha256:{}", "A".repeat(64));
253 let d: Digest = upper.parse().unwrap();
254 assert!(d.hex().chars().all(|c| !c.is_ascii_uppercase()));
255 }
256}