1use std::{fmt, str::FromStr, time::Duration};
4
5use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
6use sha2::{Digest, Sha256};
7
8use crate::{BeamSet, Manifest, PackageError};
9
10const DIGEST_LEN: usize = 32;
11const TEXT_LEN: usize = DIGEST_LEN * 2;
12const WORKFLOW_TIMEOUT_DOMAIN: &[u8] = b"aion.package.version.workflow-timeout.v1";
13
14#[derive(Clone, Debug, PartialEq, Eq, Hash)]
26pub struct ContentHash([u8; DIGEST_LEN]);
27
28impl ContentHash {
29 #[must_use]
31 pub const fn from_bytes(bytes: [u8; DIGEST_LEN]) -> Self {
32 Self(bytes)
33 }
34
35 #[must_use]
37 pub const fn as_bytes(&self) -> &[u8; DIGEST_LEN] {
38 &self.0
39 }
40}
41
42#[derive(thiserror::Error, Clone, Debug, PartialEq, Eq)]
44pub enum ContentHashParseError {
45 #[error("content hash text must be 64 lowercase hexadecimal characters, found {found} bytes")]
47 InvalidLength {
48 found: usize,
50 },
51
52 #[error("content hash text contains non-lowercase-hex byte 0x{byte:02x} at byte index {index}")]
54 InvalidCharacter {
55 index: usize,
57 byte: u8,
59 },
60}
61
62#[must_use]
70pub fn content_hash(beams: &BeamSet) -> ContentHash {
71 let mut digest = Sha256::new();
72 update_beams(&mut digest, beams);
73 ContentHash(digest.finalize().into())
74}
75
76#[must_use]
81pub fn content_hash_with_timeout(beams: &BeamSet, timeout: Duration) -> ContentHash {
82 let mut digest = Sha256::new();
83 update_beams(&mut digest, beams);
84 update_framed(&mut digest, WORKFLOW_TIMEOUT_DOMAIN);
85 digest.update(timeout.as_secs().to_be_bytes());
86 digest.update(timeout.subsec_nanos().to_be_bytes());
87 ContentHash(digest.finalize().into())
88}
89
90pub(crate) fn has_explicit_timeout_identity(
91 beams: &BeamSet,
92 manifest: &Manifest,
93 hash: &ContentHash,
94) -> bool {
95 hash != &content_hash(beams) && hash == &content_hash_with_timeout(beams, manifest.timeout)
96}
97
98pub(crate) fn verified_content_hash(
99 beams: &BeamSet,
100 manifest: &Manifest,
101) -> Result<ContentHash, PackageError> {
102 let legacy_hash = content_hash(beams);
103 let timeout_hash = content_hash_with_timeout(beams, manifest.timeout);
104 let stored = manifest.version.as_str();
105 if stored == legacy_hash.to_string() {
106 Ok(legacy_hash)
107 } else if stored == timeout_hash.to_string() {
108 Ok(timeout_hash)
109 } else {
110 Err(PackageError::IntegrityMismatch {
111 expected: stored.to_owned(),
112 computed: legacy_hash.to_string(),
113 })
114 }
115}
116
117fn update_beams(digest: &mut Sha256, beams: &BeamSet) {
118 for module in beams.iter() {
119 update_framed(digest, module.name().as_bytes());
120 update_framed(digest, module.bytes());
121 }
122}
123
124fn update_framed(digest: &mut Sha256, bytes: &[u8]) {
125 let length = bytes.len() as u64;
126 digest.update(length.to_be_bytes().as_slice());
127 digest.update(bytes);
128}
129
130impl fmt::Display for ContentHash {
131 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
132 for byte in &self.0 {
133 write!(formatter, "{byte:02x}")?;
134 }
135
136 Ok(())
137 }
138}
139
140impl FromStr for ContentHash {
141 type Err = ContentHashParseError;
142
143 fn from_str(text: &str) -> Result<Self, Self::Err> {
144 let bytes = text.as_bytes();
145 if bytes.len() != TEXT_LEN {
146 return Err(ContentHashParseError::InvalidLength { found: bytes.len() });
147 }
148
149 let mut digest = [0_u8; DIGEST_LEN];
150 for (index, pair) in bytes.chunks_exact(2).enumerate() {
151 let high_index = index * 2;
152 let low_index = high_index + 1;
153 digest[index] = (hex_value(pair[0], high_index)? << 4) | hex_value(pair[1], low_index)?;
154 }
155
156 Ok(Self(digest))
157 }
158}
159
160fn hex_value(byte: u8, index: usize) -> Result<u8, ContentHashParseError> {
161 match byte {
162 b'0'..=b'9' => Ok(byte - b'0'),
163 b'a'..=b'f' => Ok(byte - b'a' + 10),
164 _ => Err(ContentHashParseError::InvalidCharacter { index, byte }),
165 }
166}
167
168impl Serialize for ContentHash {
169 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
170 where
171 S: Serializer,
172 {
173 serializer.serialize_str(&self.to_string())
174 }
175}
176
177impl<'de> Deserialize<'de> for ContentHash {
178 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
179 where
180 D: Deserializer<'de>,
181 {
182 deserializer.deserialize_str(ContentHashVisitor)
183 }
184}
185
186struct ContentHashVisitor;
187
188impl de::Visitor<'_> for ContentHashVisitor {
189 type Value = ContentHash;
190
191 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
192 formatter.write_str("a 64-character lowercase hexadecimal SHA-256 content hash")
193 }
194
195 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
196 where
197 E: de::Error,
198 {
199 ContentHash::from_str(value).map_err(E::custom)
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use std::time::Duration;
206
207 use super::{ContentHash, content_hash, content_hash_with_timeout};
208 use crate::{BeamModule, BeamSet, PackageError};
209
210 #[test]
211 fn content_hash_is_independent_of_insertion_order() -> Result<(), PackageError> {
212 let first = BeamSet::new(vec![
213 BeamModule::new("workflow/c", vec![3]),
214 BeamModule::new("workflow/a", vec![1]),
215 BeamModule::new("workflow/b", vec![2]),
216 ])?;
217 let second = BeamSet::new(vec![
218 BeamModule::new("workflow/b", vec![2]),
219 BeamModule::new("workflow/c", vec![3]),
220 BeamModule::new("workflow/a", vec![1]),
221 ])?;
222
223 assert_eq!(content_hash(&first), content_hash(&second));
224
225 Ok(())
226 }
227
228 #[test]
229 fn legacy_identity_remains_exactly_the_beams_only_hash() -> Result<(), PackageError> {
230 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
231 let pre_change_rule = content_hash(&beams);
232 assert_eq!(content_hash(&beams), pre_change_rule);
233 Ok(())
234 }
235
236 #[test]
237 fn explicit_timeout_identity_is_deterministic_and_value_sensitive() -> Result<(), PackageError>
238 {
239 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
240 let two_hours = content_hash_with_timeout(&beams, Duration::from_secs(7_200));
241 assert_eq!(
242 two_hours,
243 content_hash_with_timeout(&beams, Duration::from_secs(7_200))
244 );
245 assert_ne!(
246 two_hours,
247 content_hash_with_timeout(&beams, Duration::from_secs(21_600))
248 );
249 assert_ne!(
250 two_hours,
251 content_hash_with_timeout(&beams, Duration::new(7_200, 500_000_000))
252 );
253 assert_ne!(two_hours, content_hash(&beams));
254 Ok(())
255 }
256
257 #[test]
258 fn content_hash_changes_when_a_module_byte_changes() -> Result<(), PackageError> {
259 let original = BeamSet::new(vec![
260 BeamModule::new("workflow/a", vec![1, 2, 3]),
261 BeamModule::new("workflow/b", vec![4, 5, 6]),
262 ])?;
263 let changed = BeamSet::new(vec![
264 BeamModule::new("workflow/a", vec![1, 2, 3]),
265 BeamModule::new("workflow/b", vec![4, 5, 7]),
266 ])?;
267
268 assert_ne!(content_hash(&original), content_hash(&changed));
269
270 Ok(())
271 }
272
273 #[test]
274 fn content_hash_changes_when_a_module_name_changes() -> Result<(), PackageError> {
275 let original = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
276 let renamed = BeamSet::new(vec![BeamModule::new("workflow/renamed", vec![1, 2, 3])])?;
277
278 assert_ne!(content_hash(&original), content_hash(&renamed));
279
280 Ok(())
281 }
282
283 #[test]
284 fn content_hash_framing_prevents_name_bytes_boundary_ambiguity() -> Result<(), PackageError> {
285 let first = BeamSet::new(vec![BeamModule::new("ab", b"c".to_vec())])?;
286 let second = BeamSet::new(vec![BeamModule::new("a", b"bc".to_vec())])?;
287
288 assert_ne!(content_hash(&first), content_hash(&second));
289
290 Ok(())
291 }
292
293 #[test]
294 fn content_hash_text_round_trips() -> Result<(), PackageError> {
295 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![0, 1, 2, 255])])?;
296 let hash = content_hash(&beams);
297 let text = hash.to_string();
298 let parsed = text.parse::<ContentHash>();
299
300 assert_eq!(text.len(), 64);
301 assert!(
302 text.bytes()
303 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
304 );
305 assert_eq!(parsed, Ok(hash));
306
307 Ok(())
308 }
309
310 #[test]
311 fn content_hash_rejects_uppercase_text() {
312 let text = "A000000000000000000000000000000000000000000000000000000000000000";
313
314 assert!(text.parse::<ContentHash>().is_err());
315 }
316}