1use std::fmt;
4
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8const PREFIX: &str = "sha256:";
9
10#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
15#[serde(try_from = "String", into = "String")]
16pub struct Sha256Digest(String);
17
18#[cfg(test)]
19impl schemars::JsonSchema for Sha256Digest {
20 fn schema_name() -> std::borrow::Cow<'static, str> {
21 "Sha256Digest".into()
22 }
23
24 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
25 schemars::json_schema!({
26 "description": "A SHA-256 digest: `sha256:` followed by 64 lowercase hex digits.",
27 "type": "string",
28 "pattern": "^sha256:[0-9a-f]{64}$",
29 })
30 }
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct DigestFormatError(String);
36
37impl fmt::Display for DigestFormatError {
38 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39 write!(
40 f,
41 "'{}' is not a sha256:<64 lowercase hex digits> digest",
42 self.0
43 )
44 }
45}
46
47impl std::error::Error for DigestFormatError {}
48
49impl Sha256Digest {
50 #[must_use]
52 pub fn of(data: &[u8]) -> Self {
53 let hash = Sha256::digest(data);
54 Self(format!("{PREFIX}{}", base16ct::lower::encode_string(&hash)))
55 }
56
57 #[must_use]
59 pub fn from_bytes(bytes: &[u8; 32]) -> Self {
60 Self(format!("{PREFIX}{}", base16ct::lower::encode_string(bytes)))
61 }
62
63 #[must_use]
65 pub fn to_bytes(&self) -> [u8; 32] {
66 let mut out = [0u8; 32];
67 let _ = base16ct::lower::decode(&self.0[PREFIX.len()..], &mut out);
69 out
70 }
71
72 pub fn parse(s: &str) -> Result<Self, DigestFormatError> {
88 let valid = s.strip_prefix(PREFIX).is_some_and(|hex| {
89 hex.len() == 64 && hex.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
90 });
91 if valid {
92 Ok(Self(s.to_string()))
93 } else {
94 Err(DigestFormatError(s.to_string()))
95 }
96 }
97
98 #[must_use]
100 pub fn as_str(&self) -> &str {
101 &self.0
102 }
103}
104
105impl fmt::Display for Sha256Digest {
106 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107 f.write_str(&self.0)
108 }
109}
110
111impl TryFrom<String> for Sha256Digest {
112 type Error = DigestFormatError;
113
114 fn try_from(s: String) -> Result<Self, Self::Error> {
115 Self::parse(&s)
116 }
117}
118
119impl From<Sha256Digest> for String {
120 fn from(d: Sha256Digest) -> Self {
121 d.0
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn digest_of_known_bytes() {
131 assert_eq!(
132 Sha256Digest::of(b"hello world").as_str(),
133 "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
134 );
135 }
136
137 #[test]
138 fn bytes_round_trip() {
139 let d = Sha256Digest::of(b"x");
140 assert_eq!(Sha256Digest::from_bytes(&d.to_bytes()), d);
141 }
142
143 #[test]
144 fn parse_accepts_the_written_form() {
145 let d = Sha256Digest::of(b"x");
146 assert_eq!(Sha256Digest::parse(d.as_str()), Ok(d));
147 }
148
149 #[test]
150 fn parse_rejects_other_forms() {
151 let hex = "a".repeat(64);
152 for bad in [
153 hex.clone(),
154 format!("sha256:{}", "A".repeat(64)),
155 format!("sha256:{}", &hex[..63]),
156 format!("sha512:{hex}"),
157 format!("sha256:{hex}0"),
158 ] {
159 assert!(Sha256Digest::parse(&bad).is_err(), "accepted {bad}");
160 }
161 }
162
163 #[test]
164 fn deserialize_validates() {
165 let err = serde_json::from_str::<Sha256Digest>("\"sha256:zz\"");
166 assert!(err.is_err());
167 }
168}