Skip to main content

ferro_blob_store/
digest.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Content-addressed digest.
3
4use std::fmt;
5use std::str::FromStr;
6
7use sha2::{Digest as _, Sha256, Sha512};
8use thiserror::Error;
9
10/// Hash algorithm used by a [`Digest`].
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12#[non_exhaustive]
13pub enum DigestAlgo {
14    /// SHA-256 (32-byte output, 64 hex chars). Default for OCI / Maven /
15    /// Cargo on the wire.
16    Sha256,
17    /// SHA-512 (64-byte output, 128 hex chars). Accepted by OCI manifests
18    /// that advertise a `sha512:` prefix.
19    Sha512,
20}
21
22impl DigestAlgo {
23    /// Expected hex-encoded length in characters.
24    #[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    /// Wire prefix used in `<algo>:<hex>` form.
33    #[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/// Errors returned when a `<algo>:<hex>` string fails validation.
57#[derive(Debug, Clone, Error, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum DigestParseError {
60    /// The input did not contain the `<algo>:<hex>` separator.
61    #[error("digest must be of the form `<algo>:<hex>`")]
62    MissingSeparator,
63    /// The algorithm prefix is not recognised.
64    #[error("unsupported digest algorithm prefix: {0:?}")]
65    UnsupportedAlgo(String),
66    /// The hex portion has the wrong length for the declared algorithm.
67    #[error("invalid digest hex length for {algo}: expected {expected}, got {actual}")]
68    BadLength {
69        /// Algorithm reported by the prefix.
70        algo: DigestAlgo,
71        /// Expected hex length in characters.
72        expected: usize,
73        /// Actual hex length supplied.
74        actual: usize,
75    },
76    /// The hex portion contains a non-hex character.
77    #[error("invalid hex character {bad:?} in digest")]
78    BadHex {
79        /// Offending character.
80        bad: char,
81    },
82}
83
84/// Content-addressed identifier in `<algo>:<hex>` form.
85///
86/// With the `serde` feature enabled, [`Digest`] serializes and
87/// deserializes via its wire `<algo>:<hex>` string form.
88#[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    /// Construct from a known algorithm and hex string. Validates the
113    /// hex length and character set.
114    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    /// Compute the SHA-256 digest of `bytes`.
124    #[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    /// Compute the SHA-512 digest of `bytes`.
136    #[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    /// Algorithm this digest was produced with.
148    #[must_use]
149    pub const fn algo(&self) -> DigestAlgo {
150        self.algo
151    }
152
153    /// Lower-case hex string of the digest body (no algorithm prefix).
154    #[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}