1use std::{fmt, str::FromStr, time::Duration};
4
5use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
6use sha2::{Digest, Sha256};
7
8use crate::{BeamSet, Manifest, PackageContract, 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";
14const WORKER_CONTRACT_DOMAIN: &[u8] = b"aion.package.version.worker-contract.v6";
51const WORKER_CONTRACT_DOMAIN_V5: &[u8] = b"aion.package.version.worker-contract.v5";
52
53#[derive(Clone, Debug, PartialEq, Eq, Hash)]
76pub struct ContentHash([u8; DIGEST_LEN]);
77
78impl ContentHash {
79 #[must_use]
81 pub const fn from_bytes(bytes: [u8; DIGEST_LEN]) -> Self {
82 Self(bytes)
83 }
84
85 #[must_use]
87 pub const fn as_bytes(&self) -> &[u8; DIGEST_LEN] {
88 &self.0
89 }
90}
91
92#[derive(thiserror::Error, Clone, Debug, PartialEq, Eq)]
94pub enum ContentHashParseError {
95 #[error("content hash text must be 64 lowercase hexadecimal characters, found {found} bytes")]
97 InvalidLength {
98 found: usize,
100 },
101
102 #[error("content hash text contains non-lowercase-hex byte 0x{byte:02x} at byte index {index}")]
104 InvalidCharacter {
105 index: usize,
107 byte: u8,
109 },
110}
111
112#[must_use]
120pub fn content_hash(beams: &BeamSet) -> ContentHash {
121 let mut digest = Sha256::new();
122 update_beams(&mut digest, beams);
123 ContentHash(digest.finalize().into())
124}
125
126#[must_use]
135pub fn content_hash_with_timeout(beams: &BeamSet, timeout: Duration) -> ContentHash {
136 let mut digest = Sha256::new();
137 update_beams(&mut digest, beams);
138 update_framed(&mut digest, WORKFLOW_TIMEOUT_DOMAIN);
139 digest.update(timeout.as_secs().to_be_bytes());
140 digest.update(timeout.subsec_nanos().to_be_bytes());
141 ContentHash(digest.finalize().into())
142}
143
144#[must_use]
172pub fn content_hash_with_timeouts(beams: &BeamSet, manifest: &Manifest) -> ContentHash {
173 let mut digest = Sha256::new();
174 update_beams(&mut digest, beams);
175 update_framed(&mut digest, WORKFLOW_TIMEOUTS_DOMAIN);
176 update_timeouts(&mut digest, manifest);
177 ContentHash(digest.finalize().into())
178}
179
180#[must_use]
187pub fn content_hash_with_contract(
188 beams: &BeamSet,
189 manifest: &Manifest,
190 contract: &PackageContract,
191) -> ContentHash {
192 let mut digest = Sha256::new();
193 update_beams(&mut digest, beams);
194 update_framed(&mut digest, WORKER_CONTRACT_DOMAIN);
195 update_timeouts(&mut digest, manifest);
196 update_framed(&mut digest, &contract.canonical_bytes());
197 ContentHash(digest.finalize().into())
198}
199
200fn update_timeouts(digest: &mut Sha256, manifest: &Manifest) {
201 let entry_count = 1 + manifest.additional_workflows.len() as u64;
202 digest.update(entry_count.to_be_bytes());
203 update_framed(digest, manifest.entry_module.as_bytes());
204 update_timeout_field(digest, manifest.timeout);
205 for entry in &manifest.additional_workflows {
206 update_framed(digest, entry.workflow_type.as_bytes());
207 update_timeout_field(digest, entry.timeout);
208 }
209}
210
211#[cfg(test)]
225pub(crate) fn has_explicit_timeout_identity(
226 beams: &BeamSet,
227 manifest: &Manifest,
228 hash: &ContentHash,
229) -> bool {
230 hash != &content_hash(beams) && hash == &content_hash_with_timeouts(beams, manifest)
231}
232
233fn legacy_v5_content_hash_with_contract(
237 beams: &BeamSet,
238 manifest: &Manifest,
239 contract: &PackageContract,
240) -> ContentHash {
241 let mut digest = Sha256::new();
242 update_beams(&mut digest, beams);
243 update_framed(&mut digest, WORKER_CONTRACT_DOMAIN_V5);
244 update_timeouts(&mut digest, manifest);
245 update_framed(&mut digest, &contract.legacy_v5_canonical_bytes());
246 ContentHash(digest.finalize().into())
247}
248
249pub(crate) fn has_contract_identity(
253 beams: &BeamSet,
254 manifest: &Manifest,
255 contract: Option<&PackageContract>,
256 hash: &ContentHash,
257) -> bool {
258 contract.is_some_and(|contract| {
259 hash == &content_hash_with_contract(beams, manifest, contract)
260 || (contract.workloop.is_none()
261 && hash == &legacy_v5_content_hash_with_contract(beams, manifest, contract))
262 })
263}
264
265pub(crate) fn verified_content_hash_with_contract(
267 beams: &BeamSet,
268 manifest: &Manifest,
269 contract: Option<&PackageContract>,
270) -> Result<ContentHash, PackageError> {
271 if let Some(contract) = contract {
272 let contract_hash = content_hash_with_contract(beams, manifest, contract);
273 if manifest.version.as_str() == contract_hash.to_string() {
274 return Ok(contract_hash);
275 }
276 if contract.workloop.is_none() {
285 let v5_hash = legacy_v5_content_hash_with_contract(beams, manifest, contract);
286 if manifest.version.as_str() == v5_hash.to_string() {
287 return Ok(v5_hash);
288 }
289 }
290 return verified_content_hash(beams, manifest).map_err(|error| match error {
298 PackageError::IntegrityMismatch { expected, .. } => PackageError::IntegrityMismatch {
299 expected,
300 computed: contract_hash.to_string(),
301 },
302 other => other,
303 });
304 }
305 verified_content_hash(beams, manifest)
306}
307
308pub(crate) fn verified_content_hash(
327 beams: &BeamSet,
328 manifest: &Manifest,
329) -> Result<ContentHash, PackageError> {
330 let legacy_hash = content_hash(beams);
331 let stored = manifest.version.as_str();
332 if stored == legacy_hash.to_string() {
333 return Ok(legacy_hash);
334 }
335 let timeouts_hash = content_hash_with_timeouts(beams, manifest);
336 if stored == timeouts_hash.to_string() {
337 return Ok(timeouts_hash);
338 }
339 if let Some(primary) = manifest.timeout {
344 let v1_hash = content_hash_with_timeout(beams, primary);
345 if stored == v1_hash.to_string() {
346 return Ok(v1_hash);
347 }
348 }
349 Err(PackageError::IntegrityMismatch {
350 expected: stored.to_owned(),
351 computed: legacy_hash.to_string(),
352 })
353}
354
355fn update_timeout_field(digest: &mut Sha256, timeout: Option<Duration>) {
360 match timeout {
361 Some(timeout) => {
362 digest.update([1_u8]);
363 digest.update(timeout.as_secs().to_be_bytes());
364 digest.update(timeout.subsec_nanos().to_be_bytes());
365 }
366 None => digest.update([0_u8]),
367 }
368}
369
370fn update_beams(digest: &mut Sha256, beams: &BeamSet) {
371 for module in beams.iter() {
372 update_framed(digest, module.name().as_bytes());
373 update_framed(digest, module.bytes());
374 }
375}
376
377fn update_framed(digest: &mut Sha256, bytes: &[u8]) {
378 let length = bytes.len() as u64;
379 digest.update(length.to_be_bytes().as_slice());
380 digest.update(bytes);
381}
382
383impl fmt::Display for ContentHash {
384 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
385 for byte in &self.0 {
386 write!(formatter, "{byte:02x}")?;
387 }
388
389 Ok(())
390 }
391}
392
393impl FromStr for ContentHash {
394 type Err = ContentHashParseError;
395
396 fn from_str(text: &str) -> Result<Self, Self::Err> {
397 let bytes = text.as_bytes();
398 if bytes.len() != TEXT_LEN {
399 return Err(ContentHashParseError::InvalidLength { found: bytes.len() });
400 }
401
402 let mut digest = [0_u8; DIGEST_LEN];
403 for (index, pair) in bytes.chunks_exact(2).enumerate() {
404 let high_index = index * 2;
405 let low_index = high_index + 1;
406 digest[index] = (hex_value(pair[0], high_index)? << 4) | hex_value(pair[1], low_index)?;
407 }
408
409 Ok(Self(digest))
410 }
411}
412
413fn hex_value(byte: u8, index: usize) -> Result<u8, ContentHashParseError> {
414 match byte {
415 b'0'..=b'9' => Ok(byte - b'0'),
416 b'a'..=b'f' => Ok(byte - b'a' + 10),
417 _ => Err(ContentHashParseError::InvalidCharacter { index, byte }),
418 }
419}
420
421impl Serialize for ContentHash {
422 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
423 where
424 S: Serializer,
425 {
426 serializer.serialize_str(&self.to_string())
427 }
428}
429
430impl<'de> Deserialize<'de> for ContentHash {
431 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
432 where
433 D: Deserializer<'de>,
434 {
435 deserializer.deserialize_str(ContentHashVisitor)
436 }
437}
438
439struct ContentHashVisitor;
440
441impl de::Visitor<'_> for ContentHashVisitor {
442 type Value = ContentHash;
443
444 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
445 formatter.write_str("a 64-character lowercase hexadecimal SHA-256 content hash")
446 }
447
448 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
449 where
450 E: de::Error,
451 {
452 ContentHash::from_str(value).map_err(E::custom)
453 }
454}
455
456#[cfg(test)]
457mod tests {
458 use std::time::Duration;
459
460 use serde_json::json;
461
462 use super::{
463 ContentHash, content_hash, content_hash_with_contract, content_hash_with_timeout,
464 content_hash_with_timeouts, has_contract_identity, has_explicit_timeout_identity,
465 legacy_v5_content_hash_with_contract, verified_content_hash,
466 verified_content_hash_with_contract,
467 };
468 use crate::{
469 ActionContract, BeamModule, BeamSet, CURRENT_FORMAT_VERSION, Manifest, ManifestVersion,
470 PackageContract, PackageError, WorkerContract, WorkflowEntry,
471 };
472
473 fn manifest_with(primary: Option<Duration>, additional: Vec<WorkflowEntry>) -> Manifest {
474 Manifest {
475 entry_module: "workflow/a".to_owned(),
476 entry_function: "run".to_owned(),
477 input_schema: json!({ "type": "object" }),
478 output_schema: json!({ "type": "object" }),
479 timeout: primary,
480 activities: Vec::new(),
481 version: ManifestVersion::new("unstamped"),
482 format_version: CURRENT_FORMAT_VERSION,
483 additional_workflows: additional,
484 }
485 }
486
487 fn additional_entry(workflow_type: &str, timeout: Option<Duration>) -> WorkflowEntry {
488 WorkflowEntry {
489 workflow_type: workflow_type.to_owned(),
490 entry_module: "workflow/a".to_owned(),
491 entry_function: format!("{workflow_type}_run"),
492 input_schema: json!({ "type": "object" }),
493 output_schema: json!({ "type": "object" }),
494 timeout,
495 internal: true,
496 }
497 }
498
499 #[test]
500 fn content_hash_is_independent_of_insertion_order() -> Result<(), PackageError> {
501 let first = BeamSet::new(vec![
502 BeamModule::new("workflow/c", vec![3]),
503 BeamModule::new("workflow/a", vec![1]),
504 BeamModule::new("workflow/b", vec![2]),
505 ])?;
506 let second = BeamSet::new(vec![
507 BeamModule::new("workflow/b", vec![2]),
508 BeamModule::new("workflow/c", vec![3]),
509 BeamModule::new("workflow/a", vec![1]),
510 ])?;
511
512 assert_eq!(content_hash(&first), content_hash(&second));
513
514 Ok(())
515 }
516
517 #[test]
518 fn legacy_identity_remains_exactly_the_beams_only_hash() -> Result<(), PackageError> {
519 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
520 let pre_change_rule = content_hash(&beams);
521 assert_eq!(content_hash(&beams), pre_change_rule);
522 Ok(())
523 }
524
525 #[test]
526 fn explicit_timeout_identity_is_deterministic_and_value_sensitive() -> Result<(), PackageError>
527 {
528 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
529 let two_hours = content_hash_with_timeout(&beams, Duration::from_secs(7_200));
530 assert_eq!(
531 two_hours,
532 content_hash_with_timeout(&beams, Duration::from_secs(7_200))
533 );
534 assert_ne!(
535 two_hours,
536 content_hash_with_timeout(&beams, Duration::from_secs(21_600))
537 );
538 assert_ne!(
539 two_hours,
540 content_hash_with_timeout(&beams, Duration::new(7_200, 500_000_000))
541 );
542 assert_ne!(two_hours, content_hash(&beams));
543 Ok(())
544 }
545
546 #[test]
547 fn per_entry_identity_binds_every_additional_entry_timeout() -> Result<(), PackageError> {
548 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
549 let base = manifest_with(
550 Some(Duration::from_secs(60)),
551 vec![additional_entry("child", Some(Duration::from_secs(30)))],
552 );
553
554 let changed_value = manifest_with(
556 Some(Duration::from_secs(60)),
557 vec![additional_entry("child", Some(Duration::from_secs(31)))],
558 );
559 assert_ne!(
560 content_hash_with_timeouts(&beams, &base),
561 content_hash_with_timeouts(&beams, &changed_value),
562 );
563
564 let injected = manifest_with(
567 Some(Duration::from_secs(60)),
568 vec![additional_entry("child", Some(Duration::from_secs(3_600)))],
569 );
570 assert_ne!(
571 content_hash_with_timeouts(&beams, &base),
572 content_hash_with_timeouts(&beams, &injected),
573 );
574
575 let absent = manifest_with(
577 Some(Duration::from_secs(60)),
578 vec![additional_entry("child", None)],
579 );
580 assert_ne!(
581 content_hash_with_timeouts(&beams, &base),
582 content_hash_with_timeouts(&beams, &absent),
583 );
584 Ok(())
585 }
586
587 #[test]
588 fn v3_identity_binds_the_primary_routing_identity() -> Result<(), PackageError> {
589 let beams = BeamSet::new(vec![
594 BeamModule::new("workflow/a", vec![1, 2, 3]),
595 BeamModule::new("workflow/b", vec![4, 5, 6]),
596 ])?;
597 let on_a = manifest_with(Some(Duration::from_secs(60)), Vec::new());
598 let mut on_b = on_a.clone();
599 on_b.entry_module = "workflow/b".to_owned();
600 assert_ne!(
601 content_hash_with_timeouts(&beams, &on_a),
602 content_hash_with_timeouts(&beams, &on_b),
603 "re-routing the primary entry_module changes identity",
604 );
605 let stored_for_a = content_hash_with_timeouts(&beams, &on_a);
608 assert!(!has_explicit_timeout_identity(&beams, &on_b, &stored_for_a));
609 assert!(has_explicit_timeout_identity(&beams, &on_a, &stored_for_a));
610 Ok(())
611 }
612
613 #[test]
614 fn v1_single_value_archive_loads_but_reads_undeclared() -> Result<(), PackageError> {
615 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
621 let mut manifest = manifest_with(Some(Duration::from_secs(60)), Vec::new());
622 let v1 = content_hash_with_timeout(&beams, Duration::from_secs(60));
623 manifest.version = ManifestVersion::new(v1.to_string());
624 assert_eq!(verified_content_hash(&beams, &manifest)?, v1);
625 assert!(!has_explicit_timeout_identity(&beams, &manifest, &v1));
626 Ok(())
627 }
628
629 #[test]
630 fn non_v1_non_v3_timeout_identity_is_rejected() -> Result<(), PackageError> {
631 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
635 let mut manifest = manifest_with(Some(Duration::from_secs(60)), Vec::new());
636 manifest.version = ManifestVersion::new("f".repeat(64));
637 assert!(matches!(
638 verified_content_hash(&beams, &manifest),
639 Err(PackageError::IntegrityMismatch { .. })
640 ));
641 Ok(())
642 }
643
644 #[test]
645 fn mixed_archive_with_injected_additional_timeout_reads_as_undeclared()
646 -> Result<(), PackageError> {
647 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
653 let manifest = manifest_with(
654 Some(Duration::from_secs(60)),
655 vec![additional_entry("child", Some(Duration::from_secs(3_600)))],
656 );
657 let primary_only = content_hash_with_timeout(&beams, Duration::from_secs(60));
659 assert!(
660 !has_explicit_timeout_identity(&beams, &manifest, &primary_only),
661 "an injected additional timeout cannot ride the primary-only identity"
662 );
663 assert!(!has_explicit_timeout_identity(
665 &beams,
666 &manifest,
667 &content_hash(&beams)
668 ));
669 assert!(has_explicit_timeout_identity(
671 &beams,
672 &manifest,
673 &content_hash_with_timeouts(&beams, &manifest)
674 ));
675 Ok(())
676 }
677
678 #[test]
679 fn content_hash_changes_when_a_module_byte_changes() -> Result<(), PackageError> {
680 let original = BeamSet::new(vec![
681 BeamModule::new("workflow/a", vec![1, 2, 3]),
682 BeamModule::new("workflow/b", vec![4, 5, 6]),
683 ])?;
684 let changed = BeamSet::new(vec![
685 BeamModule::new("workflow/a", vec![1, 2, 3]),
686 BeamModule::new("workflow/b", vec![4, 5, 7]),
687 ])?;
688
689 assert_ne!(content_hash(&original), content_hash(&changed));
690
691 Ok(())
692 }
693
694 #[test]
695 fn content_hash_changes_when_a_module_name_changes() -> Result<(), PackageError> {
696 let original = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
697 let renamed = BeamSet::new(vec![BeamModule::new("workflow/renamed", vec![1, 2, 3])])?;
698
699 assert_ne!(content_hash(&original), content_hash(&renamed));
700
701 Ok(())
702 }
703
704 #[test]
705 fn content_hash_framing_prevents_name_bytes_boundary_ambiguity() -> Result<(), PackageError> {
706 let first = BeamSet::new(vec![BeamModule::new("ab", b"c".to_vec())])?;
707 let second = BeamSet::new(vec![BeamModule::new("a", b"bc".to_vec())])?;
708
709 assert_ne!(content_hash(&first), content_hash(&second));
710
711 Ok(())
712 }
713
714 #[test]
715 fn content_hash_text_round_trips() -> Result<(), PackageError> {
716 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![0, 1, 2, 255])])?;
717 let hash = content_hash(&beams);
718 let text = hash.to_string();
719 let parsed = text.parse::<ContentHash>();
720
721 assert_eq!(text.len(), 64);
722 assert!(
723 text.bytes()
724 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
725 );
726 assert_eq!(parsed, Ok(hash));
727
728 Ok(())
729 }
730
731 #[test]
732 fn content_hash_rejects_uppercase_text() {
733 let text = "A000000000000000000000000000000000000000000000000000000000000000";
734
735 assert!(text.parse::<ContentHash>().is_err());
736 }
737
738 fn contract_with(agent: bool, workloop: Option<crate::WorkloopContract>) -> PackageContract {
740 PackageContract {
741 input_schema: json!({"type":"object"}),
742 output_schema: json!({"type":"string"}),
743 workers: vec![WorkerContract {
744 task_queue: "fleet".to_owned(),
745 actions: vec![ActionContract {
746 name: "oversee".to_owned(),
747 input_schema: json!({"type":"object"}),
748 output_schema: json!({"type":"string"}),
749 node: None,
750 timeout: None,
751 retry: None,
752 advisory: false,
753 agent,
754 body: None,
755 }],
756 }],
757 children: Vec::new(),
758 signals: Vec::new(),
759 additional_workflows: Vec::new(),
760 unscoped_activities: Vec::new(),
761 workloop,
762 }
763 }
764
765 fn empty_workloop() -> crate::WorkloopContract {
766 crate::WorkloopContract {
767 cadence_seconds: Some(60),
768 arms: Vec::new(),
769 carries: Vec::new(),
770 invariants: Vec::new(),
771 retention_seconds: 3_600,
772 detached: Vec::new(),
773 reports: Vec::new(),
774 has_retire_body: false,
775 }
776 }
777
778 #[test]
779 fn v5_stamped_archive_re_attests_under_the_migration_accommodation() -> Result<(), PackageError>
780 {
781 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
786 let mut manifest = manifest_with(Some(Duration::from_secs(60)), Vec::new());
787 let contract = contract_with(true, None);
788 let v5 = legacy_v5_content_hash_with_contract(&beams, &manifest, &contract);
789 manifest.version = ManifestVersion::new(v5.to_string());
790
791 let verified = verified_content_hash_with_contract(&beams, &manifest, Some(&contract))?;
792 assert_eq!(verified, v5, "the recorded `.v5` identity is what loads");
793 assert!(
794 has_contract_identity(&beams, &manifest, Some(&contract), &v5),
795 "a re-attested `.v5` archive still vouches for its contract record"
796 );
797 Ok(())
798 }
799
800 #[test]
801 fn v5_agent_flags_are_not_bound_and_that_is_the_recorded_trust_level()
802 -> Result<(), PackageError> {
803 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
809 let manifest = manifest_with(Some(Duration::from_secs(60)), Vec::new());
810 let seam = contract_with(true, None);
811 let plain = contract_with(false, None);
812 assert_eq!(
813 legacy_v5_content_hash_with_contract(&beams, &manifest, &seam),
814 legacy_v5_content_hash_with_contract(&beams, &manifest, &plain),
815 );
816 assert_ne!(
817 content_hash_with_contract(&beams, &manifest, &seam),
818 content_hash_with_contract(&beams, &manifest, &plain),
819 );
820 Ok(())
821 }
822
823 #[test]
824 fn v5_stamp_never_attests_a_workloop() -> Result<(), PackageError> {
825 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
829 let mut manifest = manifest_with(Some(Duration::from_secs(60)), Vec::new());
830 let stripped = contract_with(false, None);
831 let injected = contract_with(false, Some(empty_workloop()));
832 let v5 = legacy_v5_content_hash_with_contract(&beams, &manifest, &stripped);
835 manifest.version = ManifestVersion::new(v5.to_string());
836 assert!(matches!(
837 verified_content_hash_with_contract(&beams, &manifest, Some(&injected)),
838 Err(PackageError::IntegrityMismatch { .. })
839 ));
840 assert!(!has_contract_identity(
841 &beams,
842 &manifest,
843 Some(&injected),
844 &v5
845 ));
846 Ok(())
847 }
848
849 #[test]
850 fn v5_tampered_contract_still_refuses() -> Result<(), PackageError> {
851 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
855 let mut manifest = manifest_with(Some(Duration::from_secs(60)), Vec::new());
856 let recorded = contract_with(false, None);
857 let v5 = legacy_v5_content_hash_with_contract(&beams, &manifest, &recorded);
858 manifest.version = ManifestVersion::new(v5.to_string());
859 let mut tampered = recorded;
860 tampered.workers[0].actions[0].name = "overseen".to_owned();
861 assert!(matches!(
862 verified_content_hash_with_contract(&beams, &manifest, Some(&tampered)),
863 Err(PackageError::IntegrityMismatch { .. })
864 ));
865 Ok(())
866 }
867}