a3s_code_core/
content_digest.rs1use serde::Serialize;
9use sha2::{Digest, Sha256};
10
11pub fn digest_bytes(domain: &str, bytes: &[u8]) -> String {
13 let mut hasher = Sha256::new();
14 hasher.update(domain.as_bytes());
15 hasher.update([0]);
16 hasher.update(bytes);
17 let digest = hasher.finalize();
18 format!("sha256:{digest:x}")
19}
20
21pub fn digest_json<T: Serialize>(domain: &str, value: &T) -> Result<String, serde_json::Error> {
23 let bytes = serde_json::to_vec(value)?;
24 Ok(digest_bytes(domain, &bytes))
25}
26
27pub fn validate_digest(value: &str) -> Result<(), InvalidDigest> {
29 let Some(hex) = value.strip_prefix("sha256:") else {
30 return Err(InvalidDigest);
31 };
32 if hex.len() != 64
33 || !hex
34 .bytes()
35 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
36 {
37 return Err(InvalidDigest);
38 }
39 Ok(())
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct InvalidDigest;
45
46#[cfg(test)]
47mod tests {
48 use super::*;
49
50 #[test]
51 fn domain_separation_changes_digest() {
52 let left = digest_bytes("a.v1", b"payload");
53 let right = digest_bytes("b.v1", b"payload");
54 assert_ne!(left, right);
55 assert!(left.starts_with("sha256:"));
56 assert_eq!(left.len(), "sha256:".len() + 64);
57 }
58
59 #[test]
60 fn validate_digest_accepts_canonical_form() {
61 let digest = digest_bytes("a.v1", b"payload");
62 assert!(validate_digest(&digest).is_ok());
63 assert!(validate_digest(&digest.to_ascii_uppercase()).is_err());
64 assert!(validate_digest("not-a-digest").is_err());
65 }
66
67 #[test]
68 fn digest_json_round_trips_structured_values() {
69 #[derive(Serialize)]
70 struct Payload<'a> {
71 name: &'a str,
72 count: u32,
73 }
74 let digest = digest_json(
75 "test.domain",
76 &Payload {
77 name: "alpha",
78 count: 3,
79 },
80 )
81 .unwrap();
82 assert!(digest.starts_with("sha256:"));
83 assert_eq!(digest.len(), "sha256:".len() + 64);
84 assert_eq!(
85 digest,
86 digest_json(
87 "test.domain",
88 &Payload {
89 name: "alpha",
90 count: 3
91 }
92 )
93 .unwrap()
94 );
95 assert_ne!(
96 digest,
97 digest_json(
98 "other.domain",
99 &Payload {
100 name: "alpha",
101 count: 3
102 }
103 )
104 .unwrap()
105 );
106 }
107
108 #[test]
109 fn validate_digest_rejects_malformed_hex() {
110 let digest = digest_bytes("a.v1", b"payload");
111 let short = format!("sha256:{}", "a".repeat(63));
112 let long = format!("sha256:{}", "a".repeat(65));
113 let non_hex = format!("sha256:{}", "g".repeat(64));
114 assert!(validate_digest(&digest).is_ok());
115 assert!(validate_digest(&short).is_err());
116 assert!(validate_digest(&long).is_err());
117 assert!(validate_digest(&non_hex).is_err());
118 assert!(validate_digest("sha256:").is_err());
119 }
120}