1use std::borrow::Cow;
4use std::fmt;
5use std::str::FromStr;
6
7use gix_hash::ObjectId;
8use oci_spec::image::{Digest as OciDigest, DigestAlgorithm};
9use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
10use serde::{Deserialize, Deserializer, Serialize, Serializer};
11
12pub const SHA256_DIGEST_BYTES: usize = 32;
14
15pub const GIT_COMMIT_SHA_ERROR_MESSAGE: &str =
17 "commit_sha must be a full 40-character SHA-1 or 64-character SHA-256 Git object id";
18
19pub const SHA256_DIGEST_ERROR_MESSAGE: &str =
21 "SHA-256 digest must be exactly 64 hexadecimal characters";
22
23pub const OCI_SHA256_DIGEST_ERROR_MESSAGE: &str =
25 "OCI image digest must be exactly sha256: followed by 64 lowercase hexadecimal characters";
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct Sha256DigestError;
30
31impl fmt::Display for Sha256DigestError {
32 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34 f.write_str(SHA256_DIGEST_ERROR_MESSAGE)
35 }
36}
37
38impl std::error::Error for Sha256DigestError {}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
42pub struct Sha256Digest([u8; SHA256_DIGEST_BYTES]);
43
44impl Sha256Digest {
45 pub const fn from_bytes(bytes: [u8; SHA256_DIGEST_BYTES]) -> Self {
47 Self(bytes)
48 }
49
50 pub fn try_new(value: impl AsRef<str>) -> Result<Self, Sha256DigestError> {
52 let value = value.as_ref();
53 if value.trim() != value || value.len() != SHA256_DIGEST_BYTES * 2 {
54 return Err(Sha256DigestError);
55 }
56 let mut bytes = [0; SHA256_DIGEST_BYTES];
57 hex::decode_to_slice(value, &mut bytes).map_err(|_| Sha256DigestError)?;
58 Ok(Self(bytes))
59 }
60
61 pub fn as_bytes(&self) -> &[u8; SHA256_DIGEST_BYTES] {
63 &self.0
64 }
65
66 pub fn to_hex(self) -> String {
68 hex::encode(self.0)
69 }
70}
71
72impl fmt::Display for Sha256Digest {
73 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75 f.write_str(&self.to_hex())
76 }
77}
78
79impl FromStr for Sha256Digest {
80 type Err = Sha256DigestError;
81
82 fn from_str(value: &str) -> Result<Self, Self::Err> {
84 Self::try_new(value)
85 }
86}
87
88impl Serialize for Sha256Digest {
89 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
91 where
92 S: Serializer,
93 {
94 serializer.serialize_str(&self.to_string())
95 }
96}
97
98impl<'de> Deserialize<'de> for Sha256Digest {
99 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
101 where
102 D: Deserializer<'de>,
103 {
104 let value = String::deserialize(deserializer)?;
105 Self::try_new(&value).map_err(serde::de::Error::custom)
106 }
107}
108
109impl JsonSchema for Sha256Digest {
110 fn inline_schema() -> bool {
112 true
113 }
114
115 fn schema_name() -> Cow<'static, str> {
117 "Sha256Digest".into()
118 }
119
120 fn json_schema(_: &mut SchemaGenerator) -> Schema {
122 json_schema!({
123 "type": "string",
124 "pattern": "^[0-9a-f]{64}$"
125 })
126 }
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub struct OciSha256DigestError;
132
133impl fmt::Display for OciSha256DigestError {
134 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136 f.write_str(OCI_SHA256_DIGEST_ERROR_MESSAGE)
137 }
138}
139
140impl std::error::Error for OciSha256DigestError {}
141
142#[derive(Debug, Clone, PartialEq, Eq, Hash)]
144pub struct OciSha256Digest(OciDigest);
145
146impl OciSha256Digest {
147 pub fn try_new(value: impl AsRef<str>) -> Result<Self, OciSha256DigestError> {
149 let value = value.as_ref();
150 if value.trim() != value {
151 return Err(OciSha256DigestError);
152 }
153 let digest = OciDigest::from_str(value).map_err(|_| OciSha256DigestError)?;
154 match digest.algorithm() {
155 DigestAlgorithm::Sha256 => Ok(Self(digest)),
156 _ => Err(OciSha256DigestError),
157 }
158 }
159
160 pub fn as_oci_digest(&self) -> &OciDigest {
162 &self.0
163 }
164
165 pub fn hex_digest(&self) -> &str {
167 self.0.digest()
168 }
169}
170
171impl fmt::Display for OciSha256Digest {
172 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174 write!(f, "{}", self.0)
175 }
176}
177
178impl FromStr for OciSha256Digest {
179 type Err = OciSha256DigestError;
180
181 fn from_str(value: &str) -> Result<Self, Self::Err> {
183 Self::try_new(value)
184 }
185}
186
187impl Serialize for OciSha256Digest {
188 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
190 where
191 S: Serializer,
192 {
193 serializer.serialize_str(&self.to_string())
194 }
195}
196
197impl<'de> Deserialize<'de> for OciSha256Digest {
198 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
200 where
201 D: Deserializer<'de>,
202 {
203 let value = String::deserialize(deserializer)?;
204 Self::try_new(&value).map_err(serde::de::Error::custom)
205 }
206}
207
208impl JsonSchema for OciSha256Digest {
209 fn inline_schema() -> bool {
211 true
212 }
213
214 fn schema_name() -> Cow<'static, str> {
216 "OciSha256Digest".into()
217 }
218
219 fn json_schema(_: &mut SchemaGenerator) -> Schema {
221 json_schema!({
222 "type": "string",
223 "pattern": "^sha256:[0-9a-f]{64}$"
224 })
225 }
226}
227
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230pub struct GitCommitShaError;
231
232impl fmt::Display for GitCommitShaError {
233 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
235 f.write_str(GIT_COMMIT_SHA_ERROR_MESSAGE)
236 }
237}
238
239impl std::error::Error for GitCommitShaError {}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
243pub struct GitCommitSha(ObjectId);
244
245impl GitCommitSha {
246 pub fn try_new(value: impl AsRef<str>) -> Result<Self, GitCommitShaError> {
248 let value = value.as_ref();
249 if value.trim() != value {
250 return Err(GitCommitShaError);
251 }
252 let value = value.to_ascii_lowercase();
253 let object_id = ObjectId::from_hex(value.as_bytes()).map_err(|_| GitCommitShaError)?;
254 Ok(Self(object_id))
255 }
256
257 pub fn as_object_id(&self) -> &ObjectId {
259 &self.0
260 }
261}
262
263impl fmt::Display for GitCommitSha {
264 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
266 write!(f, "{}", self.0)
267 }
268}
269
270impl FromStr for GitCommitSha {
271 type Err = GitCommitShaError;
272
273 fn from_str(value: &str) -> Result<Self, Self::Err> {
275 Self::try_new(value)
276 }
277}
278
279impl Serialize for GitCommitSha {
280 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
282 where
283 S: Serializer,
284 {
285 serializer.serialize_str(&self.to_string())
286 }
287}
288
289impl<'de> Deserialize<'de> for GitCommitSha {
290 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
292 where
293 D: Deserializer<'de>,
294 {
295 let value = String::deserialize(deserializer)?;
296 Self::try_new(&value).map_err(serde::de::Error::custom)
297 }
298}
299
300impl JsonSchema for GitCommitSha {
301 fn inline_schema() -> bool {
303 true
304 }
305
306 fn schema_name() -> Cow<'static, str> {
308 "GitCommitSha".into()
309 }
310
311 fn json_schema(_: &mut SchemaGenerator) -> Schema {
313 json_schema!({
314 "type": "string",
315 "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$"
316 })
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::{GitCommitSha, OciSha256Digest, Sha256Digest};
323
324 #[test]
326 fn validates_and_canonicalizes_sha256_digest() {
327 let digest = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
328 let parsed = Sha256Digest::try_new(digest).expect("digest is valid");
329
330 assert_eq!(parsed.to_string(), digest);
331 assert_eq!(parsed.as_bytes().len(), 32);
332 assert_eq!(
333 Sha256Digest::try_new(digest.to_ascii_uppercase())
334 .expect("hex case should canonicalize")
335 .to_string(),
336 digest
337 );
338 assert!(Sha256Digest::try_new("abcdef").is_err());
339 assert!(Sha256Digest::try_new(format!(" {digest}")).is_err());
340 assert!(
341 Sha256Digest::try_new(
342 "g23456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
343 )
344 .is_err()
345 );
346 }
347
348 #[test]
350 fn serde_rejects_invalid_sha256_digest() {
351 let digest = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
352 let parsed: Sha256Digest =
353 serde_json::from_str(&format!("\"{digest}\"")).expect("valid digest should parse");
354 assert_eq!(parsed.to_string(), digest);
355 assert!(serde_json::from_str::<Sha256Digest>("\"abcdef\"").is_err());
356 }
357
358 #[test]
360 fn validates_oci_sha256_digest() {
361 let hex = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
362 let digest = format!("sha256:{hex}");
363 let parsed = OciSha256Digest::try_new(&digest).expect("OCI digest is valid");
364
365 assert_eq!(parsed.to_string(), digest);
366 assert_eq!(parsed.hex_digest(), hex);
367 assert!(OciSha256Digest::try_new(hex).is_err());
368 assert!(OciSha256Digest::try_new(format!(" {digest}")).is_err());
369 assert!(OciSha256Digest::try_new(format!("sha512:{hex}")).is_err());
370 assert!(OciSha256Digest::try_new(digest.to_ascii_uppercase()).is_err());
371 }
372
373 #[test]
375 fn serde_rejects_invalid_oci_sha256_digest() {
376 let digest = "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
377 let parsed: OciSha256Digest =
378 serde_json::from_str(&format!("\"{digest}\"")).expect("valid digest should parse");
379 assert_eq!(parsed.to_string(), digest);
380 assert!(serde_json::from_str::<OciSha256Digest>("\"abcdef\"").is_err());
381 }
382
383 #[test]
385 fn validates_and_canonicalizes_git_commit_sha() {
386 let sha1 = "0123456789abcdef0123456789abcdef01234567";
387 let sha256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
388
389 assert_eq!(
390 GitCommitSha::try_new(sha1)
391 .expect("sha1 is valid")
392 .to_string(),
393 sha1
394 );
395 assert_eq!(
396 GitCommitSha::try_new(sha256)
397 .expect("sha256 is valid")
398 .to_string(),
399 sha256
400 );
401 assert_eq!(
402 GitCommitSha::try_new(sha1.to_ascii_uppercase())
403 .expect("hex case should canonicalize")
404 .to_string(),
405 sha1
406 );
407 assert!(GitCommitSha::try_new("0123456789abcdef").is_err());
408 assert!(GitCommitSha::try_new(format!(" {sha1}")).is_err());
409 assert!(GitCommitSha::try_new("g123456789abcdef0123456789abcdef01234567").is_err());
410 }
411
412 #[test]
414 fn serde_rejects_invalid_git_commit_sha() {
415 let sha1 = "0123456789abcdef0123456789abcdef01234567";
416 let parsed: GitCommitSha =
417 serde_json::from_str(&format!("\"{sha1}\"")).expect("valid sha should deserialize");
418 assert_eq!(parsed.to_string(), sha1);
419 assert!(serde_json::from_str::<GitCommitSha>("\"0123456789abcdef\"").is_err());
420 }
421}