Skip to main content

agentics_domain/models/
hashes.rs

1//! Validated hash-like values used by public API contracts.
2
3use 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
12/// Number of bytes in a SHA-256 digest.
13pub const SHA256_DIGEST_BYTES: usize = 32;
14
15/// User-facing validation message for Git commit SHA values.
16pub 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
19/// User-facing validation message for plain SHA-256 digests.
20pub const SHA256_DIGEST_ERROR_MESSAGE: &str =
21    "SHA-256 digest must be exactly 64 hexadecimal characters";
22
23/// User-facing validation message for OCI image SHA-256 digests.
24pub const OCI_SHA256_DIGEST_ERROR_MESSAGE: &str =
25    "OCI image digest must be exactly sha256: followed by 64 lowercase hexadecimal characters";
26
27/// Validation failure for [`Sha256Digest`].
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct Sha256DigestError;
30
31impl fmt::Display for Sha256DigestError {
32    /// Handles fmt for this module.
33    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/// Plain SHA-256 content digest stored as bytes and rendered as lowercase hex.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
42pub struct Sha256Digest([u8; SHA256_DIGEST_BYTES]);
43
44impl Sha256Digest {
45    /// Build a digest from its raw bytes.
46    pub const fn from_bytes(bytes: [u8; SHA256_DIGEST_BYTES]) -> Self {
47        Self(bytes)
48    }
49
50    /// Parse a 64-character hexadecimal SHA-256 digest.
51    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    /// Borrow the digest bytes.
62    pub fn as_bytes(&self) -> &[u8; SHA256_DIGEST_BYTES] {
63        &self.0
64    }
65
66    /// Render the digest as lowercase hexadecimal text.
67    pub fn to_hex(self) -> String {
68        hex::encode(self.0)
69    }
70}
71
72impl fmt::Display for Sha256Digest {
73    /// Handles fmt for this module.
74    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    /// Handles from str for this module.
83    fn from_str(value: &str) -> Result<Self, Self::Err> {
84        Self::try_new(value)
85    }
86}
87
88impl Serialize for Sha256Digest {
89    /// Handles serialize for this module.
90    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    /// Handles deserialize for this module.
100    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    /// Handles inline schema for this module.
111    fn inline_schema() -> bool {
112        true
113    }
114
115    /// Handles schema name for this module.
116    fn schema_name() -> Cow<'static, str> {
117        "Sha256Digest".into()
118    }
119
120    /// Handles json schema for this module.
121    fn json_schema(_: &mut SchemaGenerator) -> Schema {
122        json_schema!({
123            "type": "string",
124            "pattern": "^[0-9a-f]{64}$"
125        })
126    }
127}
128
129/// Validation failure for [`OciSha256Digest`].
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub struct OciSha256DigestError;
132
133impl fmt::Display for OciSha256DigestError {
134    /// Handles fmt for this module.
135    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/// OCI/Docker image digest serialized as `sha256:<64 lowercase hex>`.
143#[derive(Debug, Clone, PartialEq, Eq, Hash)]
144pub struct OciSha256Digest(OciDigest);
145
146impl OciSha256Digest {
147    /// Parse and validate an OCI SHA-256 image digest.
148    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    /// Borrow the underlying OCI digest value.
161    pub fn as_oci_digest(&self) -> &OciDigest {
162        &self.0
163    }
164
165    /// Borrow the hex digest component without the `sha256:` algorithm prefix.
166    pub fn hex_digest(&self) -> &str {
167        self.0.digest()
168    }
169}
170
171impl fmt::Display for OciSha256Digest {
172    /// Handles fmt for this module.
173    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    /// Handles from str for this module.
182    fn from_str(value: &str) -> Result<Self, Self::Err> {
183        Self::try_new(value)
184    }
185}
186
187impl Serialize for OciSha256Digest {
188    /// Handles serialize for this module.
189    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    /// Handles deserialize for this module.
199    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    /// Handles inline schema for this module.
210    fn inline_schema() -> bool {
211        true
212    }
213
214    /// Handles schema name for this module.
215    fn schema_name() -> Cow<'static, str> {
216        "OciSha256Digest".into()
217    }
218
219    /// Handles json schema for this module.
220    fn json_schema(_: &mut SchemaGenerator) -> Schema {
221        json_schema!({
222            "type": "string",
223            "pattern": "^sha256:[0-9a-f]{64}$"
224        })
225    }
226}
227
228/// Validation failure for [`GitCommitSha`].
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230pub struct GitCommitShaError;
231
232impl fmt::Display for GitCommitShaError {
233    /// Handles fmt for this module.
234    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/// Full Git object id used to bind a challenge review record to a reviewed PR commit.
242#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
243pub struct GitCommitSha(ObjectId);
244
245impl GitCommitSha {
246    /// Parse and canonicalize a full Git SHA-1 or SHA-256 object id.
247    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    /// Borrow the parsed Git object id.
258    pub fn as_object_id(&self) -> &ObjectId {
259        &self.0
260    }
261}
262
263impl fmt::Display for GitCommitSha {
264    /// Handles fmt for this module.
265    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    /// Handles from str for this module.
274    fn from_str(value: &str) -> Result<Self, Self::Err> {
275        Self::try_new(value)
276    }
277}
278
279impl Serialize for GitCommitSha {
280    /// Handles serialize for this module.
281    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    /// Handles deserialize for this module.
291    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    /// Handles inline schema for this module.
302    fn inline_schema() -> bool {
303        true
304    }
305
306    /// Handles schema name for this module.
307    fn schema_name() -> Cow<'static, str> {
308        "GitCommitSha".into()
309    }
310
311    /// Handles json schema for this module.
312    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    /// Verifies that validates and canonicalizes sha256 digest.
325    #[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    /// Verifies that serde rejects invalid sha256 digest.
349    #[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    /// Verifies that validates oci sha256 digest.
359    #[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    /// Verifies that serde rejects invalid oci sha256 digest.
374    #[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    /// Verifies that validates and canonicalizes git commit sha.
384    #[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    /// Verifies that serde rejects invalid git commit sha.
413    #[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}