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";
13const WORKFLOW_TIMEOUTS_DOMAIN: &[u8] = b"aion.package.version.workflow-timeouts.v3";
14
15#[derive(Clone, Debug, PartialEq, Eq, Hash)]
27pub struct ContentHash([u8; DIGEST_LEN]);
28
29impl ContentHash {
30 #[must_use]
32 pub const fn from_bytes(bytes: [u8; DIGEST_LEN]) -> Self {
33 Self(bytes)
34 }
35
36 #[must_use]
38 pub const fn as_bytes(&self) -> &[u8; DIGEST_LEN] {
39 &self.0
40 }
41}
42
43#[derive(thiserror::Error, Clone, Debug, PartialEq, Eq)]
45pub enum ContentHashParseError {
46 #[error("content hash text must be 64 lowercase hexadecimal characters, found {found} bytes")]
48 InvalidLength {
49 found: usize,
51 },
52
53 #[error("content hash text contains non-lowercase-hex byte 0x{byte:02x} at byte index {index}")]
55 InvalidCharacter {
56 index: usize,
58 byte: u8,
60 },
61}
62
63#[must_use]
71pub fn content_hash(beams: &BeamSet) -> ContentHash {
72 let mut digest = Sha256::new();
73 update_beams(&mut digest, beams);
74 ContentHash(digest.finalize().into())
75}
76
77#[must_use]
86pub fn content_hash_with_timeout(beams: &BeamSet, timeout: Duration) -> ContentHash {
87 let mut digest = Sha256::new();
88 update_beams(&mut digest, beams);
89 update_framed(&mut digest, WORKFLOW_TIMEOUT_DOMAIN);
90 digest.update(timeout.as_secs().to_be_bytes());
91 digest.update(timeout.subsec_nanos().to_be_bytes());
92 ContentHash(digest.finalize().into())
93}
94
95#[must_use]
123pub fn content_hash_with_timeouts(beams: &BeamSet, manifest: &Manifest) -> ContentHash {
124 let mut digest = Sha256::new();
125 update_beams(&mut digest, beams);
126 update_framed(&mut digest, WORKFLOW_TIMEOUTS_DOMAIN);
127 let entry_count = 1 + manifest.additional_workflows.len() as u64;
128 digest.update(entry_count.to_be_bytes());
129 update_framed(&mut digest, manifest.entry_module.as_bytes());
131 update_timeout_field(&mut digest, manifest.timeout);
132 for entry in &manifest.additional_workflows {
133 update_framed(&mut digest, entry.workflow_type.as_bytes());
134 update_timeout_field(&mut digest, entry.timeout);
135 }
136 ContentHash(digest.finalize().into())
137}
138
139pub(crate) fn has_explicit_timeout_identity(
153 beams: &BeamSet,
154 manifest: &Manifest,
155 hash: &ContentHash,
156) -> bool {
157 hash != &content_hash(beams) && hash == &content_hash_with_timeouts(beams, manifest)
158}
159
160pub(crate) fn verified_content_hash(
179 beams: &BeamSet,
180 manifest: &Manifest,
181) -> Result<ContentHash, PackageError> {
182 let legacy_hash = content_hash(beams);
183 let stored = manifest.version.as_str();
184 if stored == legacy_hash.to_string() {
185 return Ok(legacy_hash);
186 }
187 let timeouts_hash = content_hash_with_timeouts(beams, manifest);
188 if stored == timeouts_hash.to_string() {
189 return Ok(timeouts_hash);
190 }
191 if let Some(primary) = manifest.timeout {
196 let v1_hash = content_hash_with_timeout(beams, primary);
197 if stored == v1_hash.to_string() {
198 return Ok(v1_hash);
199 }
200 }
201 Err(PackageError::IntegrityMismatch {
202 expected: stored.to_owned(),
203 computed: legacy_hash.to_string(),
204 })
205}
206
207fn update_timeout_field(digest: &mut Sha256, timeout: Option<Duration>) {
212 match timeout {
213 Some(timeout) => {
214 digest.update([1_u8]);
215 digest.update(timeout.as_secs().to_be_bytes());
216 digest.update(timeout.subsec_nanos().to_be_bytes());
217 }
218 None => digest.update([0_u8]),
219 }
220}
221
222fn update_beams(digest: &mut Sha256, beams: &BeamSet) {
223 for module in beams.iter() {
224 update_framed(digest, module.name().as_bytes());
225 update_framed(digest, module.bytes());
226 }
227}
228
229fn update_framed(digest: &mut Sha256, bytes: &[u8]) {
230 let length = bytes.len() as u64;
231 digest.update(length.to_be_bytes().as_slice());
232 digest.update(bytes);
233}
234
235impl fmt::Display for ContentHash {
236 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
237 for byte in &self.0 {
238 write!(formatter, "{byte:02x}")?;
239 }
240
241 Ok(())
242 }
243}
244
245impl FromStr for ContentHash {
246 type Err = ContentHashParseError;
247
248 fn from_str(text: &str) -> Result<Self, Self::Err> {
249 let bytes = text.as_bytes();
250 if bytes.len() != TEXT_LEN {
251 return Err(ContentHashParseError::InvalidLength { found: bytes.len() });
252 }
253
254 let mut digest = [0_u8; DIGEST_LEN];
255 for (index, pair) in bytes.chunks_exact(2).enumerate() {
256 let high_index = index * 2;
257 let low_index = high_index + 1;
258 digest[index] = (hex_value(pair[0], high_index)? << 4) | hex_value(pair[1], low_index)?;
259 }
260
261 Ok(Self(digest))
262 }
263}
264
265fn hex_value(byte: u8, index: usize) -> Result<u8, ContentHashParseError> {
266 match byte {
267 b'0'..=b'9' => Ok(byte - b'0'),
268 b'a'..=b'f' => Ok(byte - b'a' + 10),
269 _ => Err(ContentHashParseError::InvalidCharacter { index, byte }),
270 }
271}
272
273impl Serialize for ContentHash {
274 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
275 where
276 S: Serializer,
277 {
278 serializer.serialize_str(&self.to_string())
279 }
280}
281
282impl<'de> Deserialize<'de> for ContentHash {
283 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
284 where
285 D: Deserializer<'de>,
286 {
287 deserializer.deserialize_str(ContentHashVisitor)
288 }
289}
290
291struct ContentHashVisitor;
292
293impl de::Visitor<'_> for ContentHashVisitor {
294 type Value = ContentHash;
295
296 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
297 formatter.write_str("a 64-character lowercase hexadecimal SHA-256 content hash")
298 }
299
300 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
301 where
302 E: de::Error,
303 {
304 ContentHash::from_str(value).map_err(E::custom)
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use std::time::Duration;
311
312 use serde_json::json;
313
314 use super::{
315 ContentHash, content_hash, content_hash_with_timeout, content_hash_with_timeouts,
316 has_explicit_timeout_identity, verified_content_hash,
317 };
318 use crate::{
319 BeamModule, BeamSet, CURRENT_FORMAT_VERSION, Manifest, ManifestVersion, PackageError,
320 WorkflowEntry,
321 };
322
323 fn manifest_with(primary: Option<Duration>, additional: Vec<WorkflowEntry>) -> Manifest {
324 Manifest {
325 entry_module: "workflow/a".to_owned(),
326 entry_function: "run".to_owned(),
327 input_schema: json!({ "type": "object" }),
328 output_schema: json!({ "type": "object" }),
329 timeout: primary,
330 activities: Vec::new(),
331 version: ManifestVersion::new("unstamped"),
332 format_version: CURRENT_FORMAT_VERSION,
333 additional_workflows: additional,
334 }
335 }
336
337 fn additional_entry(workflow_type: &str, timeout: Option<Duration>) -> WorkflowEntry {
338 WorkflowEntry {
339 workflow_type: workflow_type.to_owned(),
340 entry_module: "workflow/a".to_owned(),
341 entry_function: format!("{workflow_type}_run"),
342 input_schema: json!({ "type": "object" }),
343 output_schema: json!({ "type": "object" }),
344 timeout,
345 internal: true,
346 }
347 }
348
349 #[test]
350 fn content_hash_is_independent_of_insertion_order() -> Result<(), PackageError> {
351 let first = BeamSet::new(vec![
352 BeamModule::new("workflow/c", vec![3]),
353 BeamModule::new("workflow/a", vec![1]),
354 BeamModule::new("workflow/b", vec![2]),
355 ])?;
356 let second = BeamSet::new(vec![
357 BeamModule::new("workflow/b", vec![2]),
358 BeamModule::new("workflow/c", vec![3]),
359 BeamModule::new("workflow/a", vec![1]),
360 ])?;
361
362 assert_eq!(content_hash(&first), content_hash(&second));
363
364 Ok(())
365 }
366
367 #[test]
368 fn legacy_identity_remains_exactly_the_beams_only_hash() -> Result<(), PackageError> {
369 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
370 let pre_change_rule = content_hash(&beams);
371 assert_eq!(content_hash(&beams), pre_change_rule);
372 Ok(())
373 }
374
375 #[test]
376 fn explicit_timeout_identity_is_deterministic_and_value_sensitive() -> Result<(), PackageError>
377 {
378 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
379 let two_hours = content_hash_with_timeout(&beams, Duration::from_secs(7_200));
380 assert_eq!(
381 two_hours,
382 content_hash_with_timeout(&beams, Duration::from_secs(7_200))
383 );
384 assert_ne!(
385 two_hours,
386 content_hash_with_timeout(&beams, Duration::from_secs(21_600))
387 );
388 assert_ne!(
389 two_hours,
390 content_hash_with_timeout(&beams, Duration::new(7_200, 500_000_000))
391 );
392 assert_ne!(two_hours, content_hash(&beams));
393 Ok(())
394 }
395
396 #[test]
397 fn per_entry_identity_binds_every_additional_entry_timeout() -> Result<(), PackageError> {
398 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
399 let base = manifest_with(
400 Some(Duration::from_secs(60)),
401 vec![additional_entry("child", Some(Duration::from_secs(30)))],
402 );
403
404 let changed_value = manifest_with(
406 Some(Duration::from_secs(60)),
407 vec![additional_entry("child", Some(Duration::from_secs(31)))],
408 );
409 assert_ne!(
410 content_hash_with_timeouts(&beams, &base),
411 content_hash_with_timeouts(&beams, &changed_value),
412 );
413
414 let injected = manifest_with(
417 Some(Duration::from_secs(60)),
418 vec![additional_entry("child", Some(Duration::from_secs(3_600)))],
419 );
420 assert_ne!(
421 content_hash_with_timeouts(&beams, &base),
422 content_hash_with_timeouts(&beams, &injected),
423 );
424
425 let absent = manifest_with(
427 Some(Duration::from_secs(60)),
428 vec![additional_entry("child", None)],
429 );
430 assert_ne!(
431 content_hash_with_timeouts(&beams, &base),
432 content_hash_with_timeouts(&beams, &absent),
433 );
434 Ok(())
435 }
436
437 #[test]
438 fn v3_identity_binds_the_primary_routing_identity() -> Result<(), PackageError> {
439 let beams = BeamSet::new(vec![
444 BeamModule::new("workflow/a", vec![1, 2, 3]),
445 BeamModule::new("workflow/b", vec![4, 5, 6]),
446 ])?;
447 let on_a = manifest_with(Some(Duration::from_secs(60)), Vec::new());
448 let mut on_b = on_a.clone();
449 on_b.entry_module = "workflow/b".to_owned();
450 assert_ne!(
451 content_hash_with_timeouts(&beams, &on_a),
452 content_hash_with_timeouts(&beams, &on_b),
453 "re-routing the primary entry_module changes identity",
454 );
455 let stored_for_a = content_hash_with_timeouts(&beams, &on_a);
458 assert!(!has_explicit_timeout_identity(&beams, &on_b, &stored_for_a));
459 assert!(has_explicit_timeout_identity(&beams, &on_a, &stored_for_a));
460 Ok(())
461 }
462
463 #[test]
464 fn v1_single_value_archive_loads_but_reads_undeclared() -> Result<(), PackageError> {
465 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
471 let mut manifest = manifest_with(Some(Duration::from_secs(60)), Vec::new());
472 let v1 = content_hash_with_timeout(&beams, Duration::from_secs(60));
473 manifest.version = ManifestVersion::new(v1.to_string());
474 assert_eq!(verified_content_hash(&beams, &manifest)?, v1);
475 assert!(!has_explicit_timeout_identity(&beams, &manifest, &v1));
476 Ok(())
477 }
478
479 #[test]
480 fn non_v1_non_v3_timeout_identity_is_rejected() -> Result<(), PackageError> {
481 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
485 let mut manifest = manifest_with(Some(Duration::from_secs(60)), Vec::new());
486 manifest.version = ManifestVersion::new("f".repeat(64));
487 assert!(matches!(
488 verified_content_hash(&beams, &manifest),
489 Err(PackageError::IntegrityMismatch { .. })
490 ));
491 Ok(())
492 }
493
494 #[test]
495 fn mixed_archive_with_injected_additional_timeout_reads_as_undeclared()
496 -> Result<(), PackageError> {
497 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
503 let manifest = manifest_with(
504 Some(Duration::from_secs(60)),
505 vec![additional_entry("child", Some(Duration::from_secs(3_600)))],
506 );
507 let primary_only = content_hash_with_timeout(&beams, Duration::from_secs(60));
509 assert!(
510 !has_explicit_timeout_identity(&beams, &manifest, &primary_only),
511 "an injected additional timeout cannot ride the primary-only identity"
512 );
513 assert!(!has_explicit_timeout_identity(
515 &beams,
516 &manifest,
517 &content_hash(&beams)
518 ));
519 assert!(has_explicit_timeout_identity(
521 &beams,
522 &manifest,
523 &content_hash_with_timeouts(&beams, &manifest)
524 ));
525 Ok(())
526 }
527
528 #[test]
529 fn content_hash_changes_when_a_module_byte_changes() -> Result<(), PackageError> {
530 let original = BeamSet::new(vec![
531 BeamModule::new("workflow/a", vec![1, 2, 3]),
532 BeamModule::new("workflow/b", vec![4, 5, 6]),
533 ])?;
534 let changed = BeamSet::new(vec![
535 BeamModule::new("workflow/a", vec![1, 2, 3]),
536 BeamModule::new("workflow/b", vec![4, 5, 7]),
537 ])?;
538
539 assert_ne!(content_hash(&original), content_hash(&changed));
540
541 Ok(())
542 }
543
544 #[test]
545 fn content_hash_changes_when_a_module_name_changes() -> Result<(), PackageError> {
546 let original = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
547 let renamed = BeamSet::new(vec![BeamModule::new("workflow/renamed", vec![1, 2, 3])])?;
548
549 assert_ne!(content_hash(&original), content_hash(&renamed));
550
551 Ok(())
552 }
553
554 #[test]
555 fn content_hash_framing_prevents_name_bytes_boundary_ambiguity() -> Result<(), PackageError> {
556 let first = BeamSet::new(vec![BeamModule::new("ab", b"c".to_vec())])?;
557 let second = BeamSet::new(vec![BeamModule::new("a", b"bc".to_vec())])?;
558
559 assert_ne!(content_hash(&first), content_hash(&second));
560
561 Ok(())
562 }
563
564 #[test]
565 fn content_hash_text_round_trips() -> Result<(), PackageError> {
566 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![0, 1, 2, 255])])?;
567 let hash = content_hash(&beams);
568 let text = hash.to_string();
569 let parsed = text.parse::<ContentHash>();
570
571 assert_eq!(text.len(), 64);
572 assert!(
573 text.bytes()
574 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
575 );
576 assert_eq!(parsed, Ok(hash));
577
578 Ok(())
579 }
580
581 #[test]
582 fn content_hash_rejects_uppercase_text() {
583 let text = "A000000000000000000000000000000000000000000000000000000000000000";
584
585 assert!(text.parse::<ContentHash>().is_err());
586 }
587}