1use std::{fmt, str::FromStr, time::Duration};
4
5use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
6use sha2::{Digest, Sha256};
7
8use crate::{
9 BeamSet, Manifest, PackageContract, PackageError, declared_command::PriorCommandIdentities,
10};
11
12const DIGEST_LEN: usize = 32;
13const TEXT_LEN: usize = DIGEST_LEN * 2;
14const WORKFLOW_TIMEOUT_DOMAIN: &[u8] = b"aion.package.version.workflow-timeout.v1";
15const WORKFLOW_TIMEOUTS_DOMAIN: &[u8] = b"aion.package.version.workflow-timeouts.v3";
16const WORKER_CONTRACT_DOMAIN: &[u8] = b"aion.package.version.worker-contract.v6";
53const WORKER_CONTRACT_DOMAIN_V5: &[u8] = b"aion.package.version.worker-contract.v5";
54
55#[derive(Clone, Debug, PartialEq, Eq, Hash)]
78pub struct ContentHash([u8; DIGEST_LEN]);
79
80impl ContentHash {
81 #[must_use]
83 pub const fn from_bytes(bytes: [u8; DIGEST_LEN]) -> Self {
84 Self(bytes)
85 }
86
87 #[must_use]
89 pub const fn as_bytes(&self) -> &[u8; DIGEST_LEN] {
90 &self.0
91 }
92}
93
94#[derive(thiserror::Error, Clone, Debug, PartialEq, Eq)]
96pub enum ContentHashParseError {
97 #[error("content hash text must be 64 lowercase hexadecimal characters, found {found} bytes")]
99 InvalidLength {
100 found: usize,
102 },
103
104 #[error("content hash text contains non-lowercase-hex byte 0x{byte:02x} at byte index {index}")]
106 InvalidCharacter {
107 index: usize,
109 byte: u8,
111 },
112}
113
114#[must_use]
122pub fn content_hash(beams: &BeamSet) -> ContentHash {
123 let mut digest = Sha256::new();
124 update_beams(&mut digest, beams);
125 ContentHash(digest.finalize().into())
126}
127
128#[must_use]
137pub fn content_hash_with_timeout(beams: &BeamSet, timeout: Duration) -> ContentHash {
138 let mut digest = Sha256::new();
139 update_beams(&mut digest, beams);
140 update_framed(&mut digest, WORKFLOW_TIMEOUT_DOMAIN);
141 digest.update(timeout.as_secs().to_be_bytes());
142 digest.update(timeout.subsec_nanos().to_be_bytes());
143 ContentHash(digest.finalize().into())
144}
145
146#[must_use]
174pub fn content_hash_with_timeouts(beams: &BeamSet, manifest: &Manifest) -> ContentHash {
175 let mut digest = Sha256::new();
176 update_beams(&mut digest, beams);
177 update_framed(&mut digest, WORKFLOW_TIMEOUTS_DOMAIN);
178 update_timeouts(&mut digest, manifest);
179 ContentHash(digest.finalize().into())
180}
181
182#[must_use]
189pub fn content_hash_with_contract(
190 beams: &BeamSet,
191 manifest: &Manifest,
192 contract: &PackageContract,
193) -> ContentHash {
194 content_hash_with_contract_prior(beams, manifest, contract, &PriorCommandIdentities::new())
195}
196
197pub(crate) fn content_hash_with_contract_prior(
204 beams: &BeamSet,
205 manifest: &Manifest,
206 contract: &PackageContract,
207 prior: &PriorCommandIdentities,
208) -> ContentHash {
209 let mut digest = Sha256::new();
210 update_beams(&mut digest, beams);
211 update_framed(&mut digest, WORKER_CONTRACT_DOMAIN);
212 update_timeouts(&mut digest, manifest);
213 update_framed(
214 &mut digest,
215 &contract.canonical_bytes_with_prior_commands(prior),
216 );
217 ContentHash(digest.finalize().into())
218}
219
220fn update_timeouts(digest: &mut Sha256, manifest: &Manifest) {
221 let entry_count = 1 + manifest.additional_workflows.len() as u64;
222 digest.update(entry_count.to_be_bytes());
223 update_framed(digest, manifest.entry_module.as_bytes());
224 update_timeout_field(digest, manifest.timeout);
225 for entry in &manifest.additional_workflows {
226 update_framed(digest, entry.workflow_type.as_bytes());
227 update_timeout_field(digest, entry.timeout);
228 }
229}
230
231#[cfg(test)]
245pub(crate) fn has_explicit_timeout_identity(
246 beams: &BeamSet,
247 manifest: &Manifest,
248 hash: &ContentHash,
249) -> bool {
250 hash != &content_hash(beams) && hash == &content_hash_with_timeouts(beams, manifest)
251}
252
253fn legacy_v5_content_hash_with_contract(
257 beams: &BeamSet,
258 manifest: &Manifest,
259 contract: &PackageContract,
260 prior: &PriorCommandIdentities,
261) -> ContentHash {
262 let mut digest = Sha256::new();
263 update_beams(&mut digest, beams);
264 update_framed(&mut digest, WORKER_CONTRACT_DOMAIN_V5);
265 update_timeouts(&mut digest, manifest);
266 update_framed(
267 &mut digest,
268 &contract.legacy_v5_canonical_bytes_with_prior_commands(prior),
269 );
270 ContentHash(digest.finalize().into())
271}
272
273pub(crate) fn has_contract_identity(
281 beams: &BeamSet,
282 manifest: &Manifest,
283 contract: Option<&PackageContract>,
284 prior: &PriorCommandIdentities,
285 hash: &ContentHash,
286) -> bool {
287 contract.is_some_and(|contract| {
288 hash == &content_hash_with_contract_prior(beams, manifest, contract, prior)
289 || (contract.workloop.is_none()
290 && hash == &legacy_v5_content_hash_with_contract(beams, manifest, contract, prior))
291 })
292}
293
294pub(crate) fn verified_content_hash_with_contract(
302 beams: &BeamSet,
303 manifest: &Manifest,
304 contract: Option<&PackageContract>,
305 prior: &PriorCommandIdentities,
306) -> Result<ContentHash, PackageError> {
307 if let Some(contract) = contract {
308 let contract_hash = content_hash_with_contract_prior(beams, manifest, contract, prior);
309 if manifest.version.as_str() == contract_hash.to_string() {
310 return Ok(contract_hash);
311 }
312 if contract.workloop.is_none() {
321 let v5_hash = legacy_v5_content_hash_with_contract(beams, manifest, contract, prior);
322 if manifest.version.as_str() == v5_hash.to_string() {
323 return Ok(v5_hash);
324 }
325 }
326 return verified_content_hash(beams, manifest).map_err(|error| match error {
334 PackageError::IntegrityMismatch { expected, .. } => PackageError::IntegrityMismatch {
335 expected,
336 computed: contract_hash.to_string(),
337 },
338 other => other,
339 });
340 }
341 verified_content_hash(beams, manifest)
342}
343
344pub(crate) fn verified_content_hash(
363 beams: &BeamSet,
364 manifest: &Manifest,
365) -> Result<ContentHash, PackageError> {
366 let legacy_hash = content_hash(beams);
367 let stored = manifest.version.as_str();
368 if stored == legacy_hash.to_string() {
369 return Ok(legacy_hash);
370 }
371 let timeouts_hash = content_hash_with_timeouts(beams, manifest);
372 if stored == timeouts_hash.to_string() {
373 return Ok(timeouts_hash);
374 }
375 if let Some(primary) = manifest.timeout {
380 let v1_hash = content_hash_with_timeout(beams, primary);
381 if stored == v1_hash.to_string() {
382 return Ok(v1_hash);
383 }
384 }
385 Err(PackageError::IntegrityMismatch {
386 expected: stored.to_owned(),
387 computed: legacy_hash.to_string(),
388 })
389}
390
391fn update_timeout_field(digest: &mut Sha256, timeout: Option<Duration>) {
396 match timeout {
397 Some(timeout) => {
398 digest.update([1_u8]);
399 digest.update(timeout.as_secs().to_be_bytes());
400 digest.update(timeout.subsec_nanos().to_be_bytes());
401 }
402 None => digest.update([0_u8]),
403 }
404}
405
406fn update_beams(digest: &mut Sha256, beams: &BeamSet) {
407 for module in beams.iter() {
408 update_framed(digest, module.name().as_bytes());
409 update_framed(digest, module.bytes());
410 }
411}
412
413fn update_framed(digest: &mut Sha256, bytes: &[u8]) {
414 let length = bytes.len() as u64;
415 digest.update(length.to_be_bytes().as_slice());
416 digest.update(bytes);
417}
418
419impl fmt::Display for ContentHash {
420 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
421 for byte in &self.0 {
422 write!(formatter, "{byte:02x}")?;
423 }
424
425 Ok(())
426 }
427}
428
429impl FromStr for ContentHash {
430 type Err = ContentHashParseError;
431
432 fn from_str(text: &str) -> Result<Self, Self::Err> {
433 let bytes = text.as_bytes();
434 if bytes.len() != TEXT_LEN {
435 return Err(ContentHashParseError::InvalidLength { found: bytes.len() });
436 }
437
438 let mut digest = [0_u8; DIGEST_LEN];
439 for (index, pair) in bytes.chunks_exact(2).enumerate() {
440 let high_index = index * 2;
441 let low_index = high_index + 1;
442 digest[index] = (hex_value(pair[0], high_index)? << 4) | hex_value(pair[1], low_index)?;
443 }
444
445 Ok(Self(digest))
446 }
447}
448
449fn hex_value(byte: u8, index: usize) -> Result<u8, ContentHashParseError> {
450 match byte {
451 b'0'..=b'9' => Ok(byte - b'0'),
452 b'a'..=b'f' => Ok(byte - b'a' + 10),
453 _ => Err(ContentHashParseError::InvalidCharacter { index, byte }),
454 }
455}
456
457impl Serialize for ContentHash {
458 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
459 where
460 S: Serializer,
461 {
462 serializer.serialize_str(&self.to_string())
463 }
464}
465
466impl<'de> Deserialize<'de> for ContentHash {
467 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
468 where
469 D: Deserializer<'de>,
470 {
471 deserializer.deserialize_str(ContentHashVisitor)
472 }
473}
474
475struct ContentHashVisitor;
476
477impl de::Visitor<'_> for ContentHashVisitor {
478 type Value = ContentHash;
479
480 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
481 formatter.write_str("a 64-character lowercase hexadecimal SHA-256 content hash")
482 }
483
484 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
485 where
486 E: de::Error,
487 {
488 ContentHash::from_str(value).map_err(E::custom)
489 }
490}
491
492#[cfg(test)]
493mod tests {
494 use std::time::Duration;
495
496 use serde_json::json;
497
498 use super::{
499 ContentHash, content_hash, content_hash_with_contract, content_hash_with_timeout,
500 content_hash_with_timeouts, has_contract_identity, has_explicit_timeout_identity,
501 legacy_v5_content_hash_with_contract, verified_content_hash,
502 verified_content_hash_with_contract,
503 };
504 use crate::{
505 ActionContract, BeamModule, BeamSet, CURRENT_FORMAT_VERSION, Manifest, ManifestVersion,
506 PackageContract, PackageError, WorkerContract, WorkflowEntry,
507 declared_command::PriorCommandIdentities,
508 };
509
510 fn no_prior() -> PriorCommandIdentities {
512 PriorCommandIdentities::new()
513 }
514
515 fn manifest_with(primary: Option<Duration>, additional: Vec<WorkflowEntry>) -> Manifest {
516 Manifest {
517 entry_module: "workflow/a".to_owned(),
518 entry_function: "run".to_owned(),
519 input_schema: json!({ "type": "object" }),
520 output_schema: json!({ "type": "object" }),
521 timeout: primary,
522 activities: Vec::new(),
523 version: ManifestVersion::new("unstamped"),
524 format_version: CURRENT_FORMAT_VERSION,
525 additional_workflows: additional,
526 }
527 }
528
529 fn additional_entry(workflow_type: &str, timeout: Option<Duration>) -> WorkflowEntry {
530 WorkflowEntry {
531 workflow_type: workflow_type.to_owned(),
532 entry_module: "workflow/a".to_owned(),
533 entry_function: format!("{workflow_type}_run"),
534 input_schema: json!({ "type": "object" }),
535 output_schema: json!({ "type": "object" }),
536 timeout,
537 internal: true,
538 }
539 }
540
541 #[test]
542 fn content_hash_is_independent_of_insertion_order() -> Result<(), PackageError> {
543 let first = BeamSet::new(vec![
544 BeamModule::new("workflow/c", vec![3]),
545 BeamModule::new("workflow/a", vec![1]),
546 BeamModule::new("workflow/b", vec![2]),
547 ])?;
548 let second = BeamSet::new(vec![
549 BeamModule::new("workflow/b", vec![2]),
550 BeamModule::new("workflow/c", vec![3]),
551 BeamModule::new("workflow/a", vec![1]),
552 ])?;
553
554 assert_eq!(content_hash(&first), content_hash(&second));
555
556 Ok(())
557 }
558
559 #[test]
560 fn legacy_identity_remains_exactly_the_beams_only_hash() -> Result<(), PackageError> {
561 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
562 let pre_change_rule = content_hash(&beams);
563 assert_eq!(content_hash(&beams), pre_change_rule);
564 Ok(())
565 }
566
567 #[test]
568 fn explicit_timeout_identity_is_deterministic_and_value_sensitive() -> Result<(), PackageError>
569 {
570 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
571 let two_hours = content_hash_with_timeout(&beams, Duration::from_secs(7_200));
572 assert_eq!(
573 two_hours,
574 content_hash_with_timeout(&beams, Duration::from_secs(7_200))
575 );
576 assert_ne!(
577 two_hours,
578 content_hash_with_timeout(&beams, Duration::from_secs(21_600))
579 );
580 assert_ne!(
581 two_hours,
582 content_hash_with_timeout(&beams, Duration::new(7_200, 500_000_000))
583 );
584 assert_ne!(two_hours, content_hash(&beams));
585 Ok(())
586 }
587
588 #[test]
589 fn per_entry_identity_binds_every_additional_entry_timeout() -> Result<(), PackageError> {
590 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
591 let base = manifest_with(
592 Some(Duration::from_secs(60)),
593 vec![additional_entry("child", Some(Duration::from_secs(30)))],
594 );
595
596 let changed_value = manifest_with(
598 Some(Duration::from_secs(60)),
599 vec![additional_entry("child", Some(Duration::from_secs(31)))],
600 );
601 assert_ne!(
602 content_hash_with_timeouts(&beams, &base),
603 content_hash_with_timeouts(&beams, &changed_value),
604 );
605
606 let injected = manifest_with(
609 Some(Duration::from_secs(60)),
610 vec![additional_entry("child", Some(Duration::from_secs(3_600)))],
611 );
612 assert_ne!(
613 content_hash_with_timeouts(&beams, &base),
614 content_hash_with_timeouts(&beams, &injected),
615 );
616
617 let absent = manifest_with(
619 Some(Duration::from_secs(60)),
620 vec![additional_entry("child", None)],
621 );
622 assert_ne!(
623 content_hash_with_timeouts(&beams, &base),
624 content_hash_with_timeouts(&beams, &absent),
625 );
626 Ok(())
627 }
628
629 #[test]
630 fn v3_identity_binds_the_primary_routing_identity() -> Result<(), PackageError> {
631 let beams = BeamSet::new(vec![
636 BeamModule::new("workflow/a", vec![1, 2, 3]),
637 BeamModule::new("workflow/b", vec![4, 5, 6]),
638 ])?;
639 let on_a = manifest_with(Some(Duration::from_secs(60)), Vec::new());
640 let mut on_b = on_a.clone();
641 on_b.entry_module = "workflow/b".to_owned();
642 assert_ne!(
643 content_hash_with_timeouts(&beams, &on_a),
644 content_hash_with_timeouts(&beams, &on_b),
645 "re-routing the primary entry_module changes identity",
646 );
647 let stored_for_a = content_hash_with_timeouts(&beams, &on_a);
650 assert!(!has_explicit_timeout_identity(&beams, &on_b, &stored_for_a));
651 assert!(has_explicit_timeout_identity(&beams, &on_a, &stored_for_a));
652 Ok(())
653 }
654
655 #[test]
656 fn v1_single_value_archive_loads_but_reads_undeclared() -> Result<(), PackageError> {
657 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
663 let mut manifest = manifest_with(Some(Duration::from_secs(60)), Vec::new());
664 let v1 = content_hash_with_timeout(&beams, Duration::from_secs(60));
665 manifest.version = ManifestVersion::new(v1.to_string());
666 assert_eq!(verified_content_hash(&beams, &manifest)?, v1);
667 assert!(!has_explicit_timeout_identity(&beams, &manifest, &v1));
668 Ok(())
669 }
670
671 #[test]
672 fn non_v1_non_v3_timeout_identity_is_rejected() -> Result<(), PackageError> {
673 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
677 let mut manifest = manifest_with(Some(Duration::from_secs(60)), Vec::new());
678 manifest.version = ManifestVersion::new("f".repeat(64));
679 assert!(matches!(
680 verified_content_hash(&beams, &manifest),
681 Err(PackageError::IntegrityMismatch { .. })
682 ));
683 Ok(())
684 }
685
686 #[test]
687 fn mixed_archive_with_injected_additional_timeout_reads_as_undeclared()
688 -> Result<(), PackageError> {
689 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
695 let manifest = manifest_with(
696 Some(Duration::from_secs(60)),
697 vec![additional_entry("child", Some(Duration::from_secs(3_600)))],
698 );
699 let primary_only = content_hash_with_timeout(&beams, Duration::from_secs(60));
701 assert!(
702 !has_explicit_timeout_identity(&beams, &manifest, &primary_only),
703 "an injected additional timeout cannot ride the primary-only identity"
704 );
705 assert!(!has_explicit_timeout_identity(
707 &beams,
708 &manifest,
709 &content_hash(&beams)
710 ));
711 assert!(has_explicit_timeout_identity(
713 &beams,
714 &manifest,
715 &content_hash_with_timeouts(&beams, &manifest)
716 ));
717 Ok(())
718 }
719
720 #[test]
721 fn content_hash_changes_when_a_module_byte_changes() -> Result<(), PackageError> {
722 let original = BeamSet::new(vec![
723 BeamModule::new("workflow/a", vec![1, 2, 3]),
724 BeamModule::new("workflow/b", vec![4, 5, 6]),
725 ])?;
726 let changed = BeamSet::new(vec![
727 BeamModule::new("workflow/a", vec![1, 2, 3]),
728 BeamModule::new("workflow/b", vec![4, 5, 7]),
729 ])?;
730
731 assert_ne!(content_hash(&original), content_hash(&changed));
732
733 Ok(())
734 }
735
736 #[test]
737 fn content_hash_changes_when_a_module_name_changes() -> Result<(), PackageError> {
738 let original = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
739 let renamed = BeamSet::new(vec![BeamModule::new("workflow/renamed", vec![1, 2, 3])])?;
740
741 assert_ne!(content_hash(&original), content_hash(&renamed));
742
743 Ok(())
744 }
745
746 #[test]
747 fn content_hash_framing_prevents_name_bytes_boundary_ambiguity() -> Result<(), PackageError> {
748 let first = BeamSet::new(vec![BeamModule::new("ab", b"c".to_vec())])?;
749 let second = BeamSet::new(vec![BeamModule::new("a", b"bc".to_vec())])?;
750
751 assert_ne!(content_hash(&first), content_hash(&second));
752
753 Ok(())
754 }
755
756 #[test]
757 fn content_hash_text_round_trips() -> Result<(), PackageError> {
758 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![0, 1, 2, 255])])?;
759 let hash = content_hash(&beams);
760 let text = hash.to_string();
761 let parsed = text.parse::<ContentHash>();
762
763 assert_eq!(text.len(), 64);
764 assert!(
765 text.bytes()
766 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
767 );
768 assert_eq!(parsed, Ok(hash));
769
770 Ok(())
771 }
772
773 #[test]
774 fn content_hash_rejects_uppercase_text() {
775 let text = "A000000000000000000000000000000000000000000000000000000000000000";
776
777 assert!(text.parse::<ContentHash>().is_err());
778 }
779
780 fn contract_with(agent: bool, workloop: Option<crate::WorkloopContract>) -> PackageContract {
782 PackageContract {
783 input_schema: json!({"type":"object"}),
784 output_schema: json!({"type":"string"}),
785 workers: vec![WorkerContract {
786 task_queue: "fleet".to_owned(),
787 actions: vec![ActionContract {
788 name: "oversee".to_owned(),
789 input_schema: json!({"type":"object"}),
790 output_schema: json!({"type":"string"}),
791 node: None,
792 timeout: None,
793 retry: None,
794 advisory: false,
795 agent,
796 body: None,
797 }],
798 }],
799 children: Vec::new(),
800 signals: Vec::new(),
801 additional_workflows: Vec::new(),
802 unscoped_activities: Vec::new(),
803 workloop,
804 }
805 }
806
807 fn empty_workloop() -> crate::WorkloopContract {
808 crate::WorkloopContract {
809 cadence_seconds: Some(60),
810 arms: Vec::new(),
811 carries: Vec::new(),
812 invariants: Vec::new(),
813 retention_seconds: 3_600,
814 detached: Vec::new(),
815 reports: Vec::new(),
816 has_retire_body: false,
817 }
818 }
819
820 #[test]
821 fn v5_stamped_archive_re_attests_under_the_migration_accommodation() -> Result<(), PackageError>
822 {
823 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
828 let mut manifest = manifest_with(Some(Duration::from_secs(60)), Vec::new());
829 let contract = contract_with(true, None);
830 let v5 = legacy_v5_content_hash_with_contract(&beams, &manifest, &contract, &no_prior());
831 manifest.version = ManifestVersion::new(v5.to_string());
832
833 let verified =
834 verified_content_hash_with_contract(&beams, &manifest, Some(&contract), &no_prior())?;
835 assert_eq!(verified, v5, "the recorded `.v5` identity is what loads");
836 assert!(
837 has_contract_identity(&beams, &manifest, Some(&contract), &no_prior(), &v5),
838 "a re-attested `.v5` archive still vouches for its contract record"
839 );
840 Ok(())
841 }
842
843 #[test]
844 fn v5_agent_flags_are_not_bound_and_that_is_the_recorded_trust_level()
845 -> Result<(), PackageError> {
846 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
852 let manifest = manifest_with(Some(Duration::from_secs(60)), Vec::new());
853 let seam = contract_with(true, None);
854 let plain = contract_with(false, None);
855 assert_eq!(
856 legacy_v5_content_hash_with_contract(&beams, &manifest, &seam, &no_prior()),
857 legacy_v5_content_hash_with_contract(&beams, &manifest, &plain, &no_prior()),
858 );
859 assert_ne!(
860 content_hash_with_contract(&beams, &manifest, &seam),
861 content_hash_with_contract(&beams, &manifest, &plain),
862 );
863 Ok(())
864 }
865
866 #[test]
867 fn v5_stamp_never_attests_a_workloop() -> Result<(), PackageError> {
868 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
872 let mut manifest = manifest_with(Some(Duration::from_secs(60)), Vec::new());
873 let stripped = contract_with(false, None);
874 let injected = contract_with(false, Some(empty_workloop()));
875 let v5 = legacy_v5_content_hash_with_contract(&beams, &manifest, &stripped, &no_prior());
878 manifest.version = ManifestVersion::new(v5.to_string());
879 assert!(matches!(
880 verified_content_hash_with_contract(&beams, &manifest, Some(&injected), &no_prior()),
881 Err(PackageError::IntegrityMismatch { .. })
882 ));
883 assert!(!has_contract_identity(
884 &beams,
885 &manifest,
886 Some(&injected),
887 &no_prior(),
888 &v5
889 ));
890 Ok(())
891 }
892
893 #[test]
894 fn v5_tampered_contract_still_refuses() -> Result<(), PackageError> {
895 let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
899 let mut manifest = manifest_with(Some(Duration::from_secs(60)), Vec::new());
900 let recorded = contract_with(false, None);
901 let v5 = legacy_v5_content_hash_with_contract(&beams, &manifest, &recorded, &no_prior());
902 manifest.version = ManifestVersion::new(v5.to_string());
903 let mut tampered = recorded;
904 tampered.workers[0].actions[0].name = "overseen".to_owned();
905 assert!(matches!(
906 verified_content_hash_with_contract(&beams, &manifest, Some(&tampered), &no_prior()),
907 Err(PackageError::IntegrityMismatch { .. })
908 ));
909 Ok(())
910 }
911}