1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7#[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
35pub struct DigestAlgorithm(String);
36
37impl DigestAlgorithm {
38 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 #[must_use]
52 pub fn sha256() -> Self {
53 Self("sha256".to_string())
54 }
55
56 #[must_use]
58 pub fn as_str(&self) -> &str {
59 &self.0
60 }
61
62 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
99pub struct DigestValue(String);
100
101impl DigestValue {
102 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 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 #[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#[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 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 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 #[must_use]
190 pub fn as_str(&self) -> &str {
191 &self.value
192 }
193
194 #[must_use]
196 pub const fn algorithm(&self) -> &DigestAlgorithm {
197 &self.algorithm
198 }
199
200 #[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}