oci_spec/image/
digest.rs

1//! Functionality corresponding to <https://github.com/opencontainers/image-spec/blob/main/descriptor.md#digests>.
2
3use std::fmt::Display;
4use std::str::FromStr;
5
6/// A digest algorithm; at the current time only SHA-256
7/// is widely used and supported in the ecosystem. Other
8/// SHA variants are included as they are noted in the
9/// standards. Other digest algorithms may be added
10/// in the future, so this structure is marked as non-exhaustive.
11#[non_exhaustive]
12#[derive(Clone, Debug, PartialEq, Eq, Hash)]
13pub enum DigestAlgorithm {
14    /// The SHA-256 algorithm.
15    Sha256,
16    /// The SHA-384 algorithm.
17    Sha384,
18    /// The SHA-512 algorithm.
19    Sha512,
20    /// Any other algorithm. Note that it is possible
21    /// that other algorithms will be added as enum members.
22    /// If you want to try to handle those, consider also
23    /// comparing against [`Self::as_ref<str>`].
24    Other(Box<str>),
25}
26
27impl AsRef<str> for DigestAlgorithm {
28    fn as_ref(&self) -> &str {
29        match self {
30            DigestAlgorithm::Sha256 => "sha256",
31            DigestAlgorithm::Sha384 => "sha384",
32            DigestAlgorithm::Sha512 => "sha512",
33            DigestAlgorithm::Other(o) => o,
34        }
35    }
36}
37
38impl Display for DigestAlgorithm {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        f.write_str(self.as_ref())
41    }
42}
43
44impl DigestAlgorithm {
45    /// Return the length of the digest in hexadecimal ASCII characters.
46    pub const fn digest_hexlen(&self) -> Option<u32> {
47        match self {
48            DigestAlgorithm::Sha256 => Some(64),
49            DigestAlgorithm::Sha384 => Some(96),
50            DigestAlgorithm::Sha512 => Some(128),
51            DigestAlgorithm::Other(_) => None,
52        }
53    }
54}
55
56impl From<&str> for DigestAlgorithm {
57    fn from(value: &str) -> Self {
58        match value {
59            "sha256" => Self::Sha256,
60            "sha384" => Self::Sha384,
61            "sha512" => Self::Sha512,
62            o => Self::Other(o.into()),
63        }
64    }
65}
66
67fn char_is_lowercase_ascii_hex(c: char) -> bool {
68    matches!(c, '0'..='9' | 'a'..='f')
69}
70
71/// algorithm-component ::= [a-z0-9]+
72fn char_is_algorithm_component(c: char) -> bool {
73    matches!(c, 'a'..='z' | '0'..='9')
74}
75
76/// encoded ::= [a-zA-Z0-9=_-]+
77fn char_is_encoded(c: char) -> bool {
78    char_is_algorithm_component(c) || matches!(c, 'A'..='Z' | '=' | '_' | '-')
79}
80
81/// A parsed pair of algorithm:digest as defined
82/// by <https://github.com/opencontainers/image-spec/blob/main/descriptor.md#digests>
83///
84/// ```
85/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
86/// use std::str::FromStr;
87/// use oci_spec::image::{Digest, DigestAlgorithm};
88/// let d = Digest::from_str("sha256:6c3c624b58dbbcd3c0dd82b4c53f04194d1247c6eebdaab7c610cf7d66709b3b")?;
89/// assert_eq!(d.algorithm(), &DigestAlgorithm::Sha256);
90/// assert_eq!(d.digest(), "6c3c624b58dbbcd3c0dd82b4c53f04194d1247c6eebdaab7c610cf7d66709b3b");
91/// let d = Digest::from_str("multihash+base58:QmRZxt2b1FVZPNqd8hsiykDL3TdBDeTSPX9Kv46HmX4Gx8")?;
92/// assert_eq!(d.algorithm(), &DigestAlgorithm::from("multihash+base58"));
93/// assert_eq!(d.digest(), "QmRZxt2b1FVZPNqd8hsiykDL3TdBDeTSPX9Kv46HmX4Gx8");
94/// # Ok(())
95/// # }
96/// ```
97
98#[derive(Clone, Debug, Eq, PartialEq, Hash)]
99pub struct Digest {
100    /// The algorithm; we need to hold a copy of this
101    /// right now as we ended up returning a reference
102    /// from the accessor. It probably would have been
103    /// better to have both borrowed/owned DigestAlgorithm
104    /// versions and our accessor just returns a borrowed version.
105    algorithm: DigestAlgorithm,
106    value: Box<str>,
107    split: usize,
108}
109
110impl AsRef<str> for Digest {
111    fn as_ref(&self) -> &str {
112        &self.value
113    }
114}
115
116impl Display for Digest {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        f.write_str(self.as_ref())
119    }
120}
121
122impl<'de> serde::Deserialize<'de> for Digest {
123    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
124    where
125        D: serde::Deserializer<'de>,
126    {
127        let s = String::deserialize(deserializer)?;
128        Self::from_str(&s).map_err(serde::de::Error::custom)
129    }
130}
131
132impl serde::ser::Serialize for Digest {
133    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
134    where
135        S: serde::Serializer,
136    {
137        let v = self.to_string();
138        serializer.serialize_str(&v)
139    }
140}
141
142impl Digest {
143    const ALGORITHM_SEPARATOR: &'static [char] = &['+', '.', '_', '-'];
144    /// The algorithm name (e.g. sha256, sha512)
145    pub fn algorithm(&self) -> &DigestAlgorithm {
146        &self.algorithm
147    }
148
149    /// The algorithm digest component. When this is one of the
150    /// SHA family (SHA-256, SHA-384, etc.) the digest value
151    /// is guaranteed to be a valid length with only lowercase hexadecimal
152    /// characters. For example with SHA-256, the length is 64.
153    pub fn digest(&self) -> &str {
154        &self.value[self.split + 1..]
155    }
156}
157
158impl FromStr for Digest {
159    type Err = crate::OciSpecError;
160
161    fn from_str(s: &str) -> Result<Self, Self::Err> {
162        Digest::try_from(s)
163    }
164}
165
166impl TryFrom<String> for Digest {
167    type Error = crate::OciSpecError;
168
169    fn try_from(s: String) -> Result<Self, Self::Error> {
170        let s = s.into_boxed_str();
171        let Some(split) = s.find(':') else {
172            return Err(crate::OciSpecError::Other("missing ':' in digest".into()));
173        };
174        let (algorithm, value) = s.split_at(split);
175        let value = &value[1..];
176
177        // algorithm ::= algorithm-component (algorithm-separator algorithm-component)*
178        let algorithm_parts = algorithm.split(Self::ALGORITHM_SEPARATOR);
179        for part in algorithm_parts {
180            if part.is_empty() {
181                return Err(crate::OciSpecError::Other(
182                    "Empty algorithm component".into(),
183                ));
184            }
185            if !part.chars().all(char_is_algorithm_component) {
186                return Err(crate::OciSpecError::Other(format!(
187                    "Invalid algorithm component: {part}"
188                )));
189            }
190        }
191
192        if value.is_empty() {
193            return Err(crate::OciSpecError::Other("Empty algorithm value".into()));
194        }
195        if !value.chars().all(char_is_encoded) {
196            return Err(crate::OciSpecError::Other(format!(
197                "Invalid encoded value {value}"
198            )));
199        }
200
201        let algorithm = DigestAlgorithm::from(algorithm);
202        if let Some(expected) = algorithm.digest_hexlen() {
203            let found = value.len();
204            if expected as usize != found {
205                return Err(crate::OciSpecError::Other(format!(
206                    "Invalid digest length {found} expected {expected}"
207                )));
208            }
209            let is_all_hex = value.chars().all(char_is_lowercase_ascii_hex);
210            if !is_all_hex {
211                return Err(crate::OciSpecError::Other(format!(
212                    "Invalid non-hexadecimal character in digest: {value}"
213                )));
214            }
215        }
216        Ok(Self {
217            algorithm,
218            value: s,
219            split,
220        })
221    }
222}
223
224impl TryFrom<&str> for Digest {
225    type Error = crate::OciSpecError;
226
227    fn try_from(string: &str) -> Result<Self, Self::Error> {
228        TryFrom::try_from(string.to_owned())
229    }
230}
231
232/// A SHA-256 digest, guaranteed to be 64 lowercase hexadecimal ASCII characters.
233#[derive(Clone, Debug, Eq, PartialEq, Hash)]
234pub struct Sha256Digest {
235    digest: Box<str>,
236}
237
238impl From<Sha256Digest> for Digest {
239    fn from(value: Sha256Digest) -> Self {
240        Self {
241            algorithm: DigestAlgorithm::Sha256,
242            value: format!("sha256:{}", value.digest()).into(),
243            split: 6,
244        }
245    }
246}
247
248impl AsRef<str> for Sha256Digest {
249    fn as_ref(&self) -> &str {
250        self.digest()
251    }
252}
253
254impl Display for Sha256Digest {
255    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256        f.write_str(self.digest())
257    }
258}
259
260impl FromStr for Sha256Digest {
261    type Err = crate::OciSpecError;
262
263    fn from_str(digest: &str) -> Result<Self, Self::Err> {
264        let alg = DigestAlgorithm::Sha256;
265        let v = format!("{alg}:{digest}");
266        let d = Digest::from_str(&v)?;
267        match d.algorithm {
268            DigestAlgorithm::Sha256 => Ok(Self {
269                digest: d.digest().into(),
270            }),
271            o => Err(crate::OciSpecError::Other(format!(
272                "Expected algorithm sha256 but found {o}",
273            ))),
274        }
275    }
276}
277
278impl Sha256Digest {
279    /// The SHA-256 digest, guaranteed to be 64 lowercase hexadecimal characters.
280    pub fn digest(&self) -> &str {
281        &self.digest
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn test_digest_invalid() {
291        let invalid = [
292            "",
293            "foo",
294            ":",
295            "blah+",
296            "_digest:somevalue",
297            ":blah",
298            "blah:",
299            "FooBar:123abc",
300            "^:foo",
301            "bar^baz:blah",
302            "sha256:123456*78",
303            "sha256:6c3c624b58dbbcd3c0dd82b4z53f04194d1247c6eebdaab7c610cf7d66709b3b", // has a z in the middle
304            "sha384:x",
305            "sha384:6c3c624b58dbbcd3c0dd82b4c53f04194d1247c6eebdaab7c610cf7d66709b3b",
306            "sha512:6c3c624b58dbbcd3c0dd82b4c53f04194d1247c6eebdaab7c610cf7d66709b3b",
307        ];
308        for case in invalid {
309            assert!(
310                Digest::from_str(case).is_err(),
311                "Should have failed to parse: {case}"
312            )
313        }
314    }
315
316    const VALID_DIGEST_SHA256: &str =
317        "sha256:6c3c624b58dbbcd3c0dd82b4c53f04194d1247c6eebdaab7c610cf7d66709b3b";
318    const VALID_DIGEST_SHA384: &str =
319        "sha384:6c3c624b58dbbcd4d1247c6eebdaab7c610cf7d66709b3b3c0dd82b4c53f04194d1247c6eebdaab7c610cf7d66709b3b";
320    const VALID_DIGEST_SHA512: &str =
321        "sha512:6c3c624b58dbbcd3c0dd826c3c624b58dbbcd3c0dd82b4c53f04194d1247c6eebdaab7c610cf7d66709b3bb4c53f04194d1247c6eebdaab7c610cf7d66709b3b";
322
323    #[test]
324    fn test_digest_valid() {
325        let cases = ["foo:bar", "xxhash:42"];
326        for case in cases {
327            Digest::from_str(case).unwrap();
328        }
329
330        let d = Digest::try_from("multihash+base58:QmRZxt2b1FVZPNqd8hsiykDL3TdBDeTSPX9Kv46HmX4Gx8")
331            .unwrap();
332        assert_eq!(d.algorithm(), &DigestAlgorithm::from("multihash+base58"));
333        assert_eq!(d.digest(), "QmRZxt2b1FVZPNqd8hsiykDL3TdBDeTSPX9Kv46HmX4Gx8");
334    }
335
336    #[test]
337    fn test_sha256_valid() {
338        let expected_value = VALID_DIGEST_SHA256.split_once(':').unwrap().1;
339        let d = Digest::from_str(VALID_DIGEST_SHA256).unwrap();
340        assert_eq!(d.algorithm(), &DigestAlgorithm::Sha256);
341        assert_eq!(d.digest(), expected_value);
342        let base_digest = Digest::from(d.clone());
343        assert_eq!(base_digest.digest(), expected_value);
344    }
345
346    #[test]
347    fn test_sha384_valid() {
348        let expected_value = VALID_DIGEST_SHA384.split_once(':').unwrap().1;
349        let d = Digest::from_str(VALID_DIGEST_SHA384).unwrap();
350        assert_eq!(d.algorithm(), &DigestAlgorithm::Sha384);
351        assert_eq!(d.digest(), expected_value);
352        // Verify we can cheaply coerce to a string
353        assert_eq!(d.as_ref(), VALID_DIGEST_SHA384);
354        let base_digest = Digest::from(d.clone());
355        assert_eq!(base_digest.digest(), expected_value);
356    }
357
358    #[test]
359    fn test_sha512_valid() {
360        let expected_value = VALID_DIGEST_SHA512.split_once(':').unwrap().1;
361        let d = Digest::from_str(VALID_DIGEST_SHA512).unwrap();
362        assert_eq!(d.algorithm(), &DigestAlgorithm::Sha512);
363        assert_eq!(d.digest(), expected_value);
364        let base_digest = Digest::from(d.clone());
365        assert_eq!(base_digest.digest(), expected_value);
366    }
367
368    #[test]
369    fn test_sha256() {
370        let digest = VALID_DIGEST_SHA256.split_once(':').unwrap().1;
371        let v = Sha256Digest::from_str(digest).unwrap();
372        assert_eq!(v.digest(), digest);
373    }
374}