Skip to main content

use_oci_digest/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Errors returned while parsing OCI digest text.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum OciDigestError {
10    Empty,
11    MissingSeparator,
12    InvalidAlgorithm,
13    InvalidValue,
14    InvalidSha256Length,
15}
16
17impl fmt::Display for OciDigestError {
18    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
19        match self {
20            Self::Empty => formatter.write_str("OCI digest cannot be empty"),
21            Self::MissingSeparator => formatter.write_str("OCI digest must contain ':'"),
22            Self::InvalidAlgorithm => formatter.write_str("invalid OCI digest algorithm"),
23            Self::InvalidValue => formatter.write_str("invalid OCI digest value"),
24            Self::InvalidSha256Length => {
25                formatter.write_str("sha256 digests must be 64 hex characters")
26            },
27        }
28    }
29}
30
31impl Error for OciDigestError {}
32
33/// A validated OCI digest algorithm label.
34#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
35pub struct DigestAlgorithm(String);
36
37impl DigestAlgorithm {
38    /// Creates a digest algorithm label.
39    pub fn new(value: impl AsRef<str>) -> Result<Self, OciDigestError> {
40        let normalized = value.as_ref().trim().to_ascii_lowercase();
41        if normalized.is_empty() {
42            return Err(OciDigestError::Empty);
43        }
44        if !is_valid_algorithm(&normalized) {
45            return Err(OciDigestError::InvalidAlgorithm);
46        }
47        Ok(Self(normalized))
48    }
49
50    /// Returns the conventional `sha256` algorithm label.
51    #[must_use]
52    pub fn sha256() -> Self {
53        Self("sha256".to_string())
54    }
55
56    /// Returns the algorithm text.
57    #[must_use]
58    pub fn as_str(&self) -> &str {
59        &self.0
60    }
61
62    /// Returns true when the algorithm is `sha256`.
63    #[must_use]
64    pub fn is_sha256(&self) -> bool {
65        self.as_str() == "sha256"
66    }
67}
68
69impl AsRef<str> for DigestAlgorithm {
70    fn as_ref(&self) -> &str {
71        self.as_str()
72    }
73}
74
75impl fmt::Display for DigestAlgorithm {
76    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
77        formatter.write_str(self.as_str())
78    }
79}
80
81impl FromStr for DigestAlgorithm {
82    type Err = OciDigestError;
83
84    fn from_str(value: &str) -> Result<Self, Self::Err> {
85        Self::new(value)
86    }
87}
88
89impl TryFrom<&str> for DigestAlgorithm {
90    type Error = OciDigestError;
91
92    fn try_from(value: &str) -> Result<Self, Self::Error> {
93        Self::new(value)
94    }
95}
96
97/// A validated encoded digest value.
98#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
99pub struct DigestValue(String);
100
101impl DigestValue {
102    /// Creates an encoded digest value.
103    pub fn new(value: impl AsRef<str>) -> Result<Self, OciDigestError> {
104        let trimmed = value.as_ref().trim();
105        if trimmed.is_empty() {
106            return Err(OciDigestError::InvalidValue);
107        }
108        if !trimmed
109            .bytes()
110            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'='))
111        {
112            return Err(OciDigestError::InvalidValue);
113        }
114        Ok(Self(trimmed.to_string()))
115    }
116
117    /// Creates a sha256 hex digest value.
118    pub fn sha256_hex(value: impl AsRef<str>) -> Result<Self, OciDigestError> {
119        let trimmed = value.as_ref().trim();
120        if trimmed.len() != 64 {
121            return Err(OciDigestError::InvalidSha256Length);
122        }
123        if !trimmed.bytes().all(|byte| byte.is_ascii_hexdigit()) {
124            return Err(OciDigestError::InvalidValue);
125        }
126        Ok(Self(trimmed.to_ascii_lowercase()))
127    }
128
129    /// Returns the encoded digest text.
130    #[must_use]
131    pub fn as_str(&self) -> &str {
132        &self.0
133    }
134}
135
136impl AsRef<str> for DigestValue {
137    fn as_ref(&self) -> &str {
138        self.as_str()
139    }
140}
141
142impl fmt::Display for DigestValue {
143    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
144        formatter.write_str(self.as_str())
145    }
146}
147
148/// A parsed OCI digest such as `sha256:<hex>`.
149#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
150pub struct OciDigest {
151    value: String,
152    algorithm: DigestAlgorithm,
153    encoded: DigestValue,
154}
155
156impl OciDigest {
157    /// Creates an OCI digest from typed parts.
158    pub fn new(algorithm: DigestAlgorithm, encoded: DigestValue) -> Result<Self, OciDigestError> {
159        if algorithm.is_sha256() && encoded.as_str().len() != 64 {
160            return Err(OciDigestError::InvalidSha256Length);
161        }
162        let value = format!("{algorithm}:{encoded}");
163        Ok(Self {
164            value,
165            algorithm,
166            encoded,
167        })
168    }
169
170    /// Parses digest text.
171    pub fn parse(value: impl AsRef<str>) -> Result<Self, OciDigestError> {
172        let trimmed = value.as_ref().trim();
173        if trimmed.is_empty() {
174            return Err(OciDigestError::Empty);
175        }
176        let Some((algorithm, encoded)) = trimmed.split_once(':') else {
177            return Err(OciDigestError::MissingSeparator);
178        };
179        let algorithm = DigestAlgorithm::new(algorithm)?;
180        let encoded = if algorithm.is_sha256() {
181            DigestValue::sha256_hex(encoded)?
182        } else {
183            DigestValue::new(encoded)?
184        };
185        Self::new(algorithm, encoded)
186    }
187
188    /// Returns the full digest text.
189    #[must_use]
190    pub fn as_str(&self) -> &str {
191        &self.value
192    }
193
194    /// Returns the digest algorithm.
195    #[must_use]
196    pub const fn algorithm(&self) -> &DigestAlgorithm {
197        &self.algorithm
198    }
199
200    /// Returns the encoded digest value.
201    #[must_use]
202    pub const fn encoded(&self) -> &DigestValue {
203        &self.encoded
204    }
205}
206
207impl AsRef<str> for OciDigest {
208    fn as_ref(&self) -> &str {
209        self.as_str()
210    }
211}
212
213impl fmt::Display for OciDigest {
214    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
215        formatter.write_str(self.as_str())
216    }
217}
218
219impl FromStr for OciDigest {
220    type Err = OciDigestError;
221
222    fn from_str(value: &str) -> Result<Self, Self::Err> {
223        Self::parse(value)
224    }
225}
226
227impl TryFrom<&str> for OciDigest {
228    type Error = OciDigestError;
229
230    fn try_from(value: &str) -> Result<Self, Self::Error> {
231        Self::parse(value)
232    }
233}
234
235fn is_valid_algorithm(value: &str) -> bool {
236    value
237        .bytes()
238        .next()
239        .is_some_and(|byte| byte.is_ascii_alphanumeric())
240        && value
241            .bytes()
242            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-' | b'+'))
243}
244
245#[cfg(test)]
246mod tests {
247    use super::{DigestAlgorithm, DigestValue, OciDigest, OciDigestError};
248
249    const SHA: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
250
251    #[test]
252    fn parses_sha256_digest() -> Result<(), Box<dyn std::error::Error>> {
253        let digest: OciDigest = format!("sha256:{SHA}").parse()?;
254
255        assert_eq!(digest.algorithm().as_str(), "sha256");
256        assert_eq!(digest.encoded().as_str(), SHA);
257        assert_eq!(digest.to_string(), format!("sha256:{SHA}"));
258        Ok(())
259    }
260
261    #[test]
262    fn validates_digest_parts() -> Result<(), Box<dyn std::error::Error>> {
263        let digest = OciDigest::new(DigestAlgorithm::sha256(), DigestValue::sha256_hex(SHA)?)?;
264
265        assert_eq!(digest.as_str(), format!("sha256:{SHA}"));
266        assert_eq!(
267            OciDigest::parse("sha256:abc"),
268            Err(OciDigestError::InvalidSha256Length)
269        );
270        assert_eq!(
271            DigestAlgorithm::new("bad algorithm"),
272            Err(OciDigestError::InvalidAlgorithm)
273        );
274        Ok(())
275    }
276}