Skip to main content

aion_package/
hash.rs

1//! Content-hash computation over the canonical beam set.
2
3use 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";
14// `.v6` because TWO fields that are executable authority now encode, and both
15// had to land in ONE bump rather than two:
16//
17//   * `ActionContract::agent` (aion#158). Its own doc comment claimed it was
18//     identity-bound; it was referenced ZERO times by the encoder, so two
19//     declarations differing only in whether an action is an agent seam
20//     hashed identically.
21//   * `PackageContract::workloop`. A loop's cadence, tolerances, retention
22//     window and carry seeds are what the engine ACTS ON — a tolerance
23//     rewritten in storage changes when the loop alarms, a retention window
24//     changes what is destroyed.
25//
26// Both encode UNCONDITIONALLY, per the law recorded above the `.v5` body
27// block: never a second optional tail, because two optional tails are not
28// injective and a contract identity two different declarations can share is a
29// spoofable deployment.
30//
31// 🔴 MIGRATION. Every released cut from v0.19 through v0.24 minted the
32// `.v5` domain, so real stores hold `.v5`-stamped deployments that a
33// restart under this build MUST still read — the first `.v6` boot on
34// Tom's live store stranded 71 of 81 recorded deployments, which is the
35// brick class the release law exists to forbid. `verified_content_hash_with_contract`
36// therefore RE-ATTESTS a `.v5` archive under [`WORKER_CONTRACT_DOMAIN_V5`]
37// with the `.v5` canonical encoding
38// ([`PackageContract::legacy_v5_canonical_bytes`]): a match is integrity —
39// the bytes are exactly what a released cut attested — and a mismatch under
40// every accepted domain remains corruption, refused as ever. The identity
41// (the recorded hash) is never rewritten: it names pinned versions and
42// deployed modules, and identity is immutable by invariant.
43//
44// What `.v5` vouches for is what `.v5` bound: everything except the
45// per-action agent byte and the workloop block. Agent flags on a
46// re-attested archive travel as stored, at the trust level every released
47// cut gave them; a `.v5` stamp can never attest a workloop, so a
48// contract carrying one refuses. Archives hashed under `.v4` or the
49// unreleased pre-`.v5` forms still refuse: no released cut minted them.
50const 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/// A SHA-256 package version identity.
54///
55/// Legacy identities cover each module's logical name and exact `.beam` bytes
56/// in [`BeamSet`] canonical order. Explicit-timeout identities append a
57/// domain-separated timeout encoding. Archive representation and optional
58/// source inclusion never participate, so deterministic inputs keep one version.
59///
60/// "Source inclusion" is EVERY archived-source family, and this is a declared
61/// non-property rather than an accident of the current encoding: neither the
62/// Gleam `src/` entries nor the AWL `awl/document/` + `awl/schema/` entries
63/// (see [`crate::awl`]) contribute a byte to any identity above. Two packages
64/// that differ ONLY in whether they carry an authored AWL document, or in what
65/// that document says, are the SAME version — the provenance an archive shows
66/// is declared unbound from identity, never ambiently assumed to be bound.
67/// Pinned by `source_inclusion_does_not_change_manifest_version` and
68/// `awl_source_inclusion_does_not_change_manifest_version` in
69/// [`crate::builder`].
70///
71/// Its stable textual form is 64 lowercase hexadecimal characters. That text is
72/// the package version identifier stored in the manifest and the hash component
73/// embedded in namespaced deployed module names; it contains only `0-9a-f`,
74/// which is safe for a BEAM module-name component.
75#[derive(Clone, Debug, PartialEq, Eq, Hash)]
76pub struct ContentHash([u8; DIGEST_LEN]);
77
78impl ContentHash {
79    /// Creates a content hash from raw SHA-256 digest bytes.
80    #[must_use]
81    pub const fn from_bytes(bytes: [u8; DIGEST_LEN]) -> Self {
82        Self(bytes)
83    }
84
85    /// Returns the raw SHA-256 digest bytes.
86    #[must_use]
87    pub const fn as_bytes(&self) -> &[u8; DIGEST_LEN] {
88        &self.0
89    }
90}
91
92/// Errors produced when parsing a [`ContentHash`] textual form.
93#[derive(thiserror::Error, Clone, Debug, PartialEq, Eq)]
94pub enum ContentHashParseError {
95    /// The text was not exactly 64 ASCII hexadecimal characters.
96    #[error("content hash text must be 64 lowercase hexadecimal characters, found {found} bytes")]
97    InvalidLength {
98        /// Number of bytes found in the supplied text.
99        found: usize,
100    },
101
102    /// The text contained a character outside lowercase hexadecimal.
103    #[error("content hash text contains non-lowercase-hex byte 0x{byte:02x} at byte index {index}")]
104    InvalidCharacter {
105        /// Byte index of the invalid character.
106        index: usize,
107        /// Invalid byte found at `index`.
108        byte: u8,
109    },
110}
111
112/// Computes the package version hash over the canonical BEAM set only.
113///
114/// The SHA-256 algorithm is mandated by the `.aion` format contract so packers
115/// and loaders on different hosts agree. Each module contributes its logical
116/// name and exact bytes in [`BeamSet`] canonical order, with each field framed by
117/// an eight-byte big-endian length prefix. This unambiguous framing prevents a
118/// shifted name/body boundary from producing the same digest.
119#[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/// Computes an explicit-timeout package version from the canonical BEAM set,
127/// then the framed ASCII domain `aion.package.version.workflow-timeout.v1`,
128/// then exactly 12 timeout bytes: seconds as `u64` big-endian followed by
129/// subsecond nanoseconds as `u32` big-endian.
130///
131/// This single-entry form is retained for external callers; the per-entry
132/// [`content_hash_with_timeouts`] is the authority the loader trusts, because it
133/// binds every workflow entry's timeout — not only the primary — into identity.
134#[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/// Computes the per-entry timeout-bearing package version over the canonical
145/// BEAM set, then the framed ASCII domain
146/// `aion.package.version.workflow-timeouts.v3`, then a UNIFORM canonical encoding
147/// that treats the primary entry and every additional entry alike: the total
148/// entry count as `u64` big-endian, then — for the primary first and each
149/// additional entry in manifest order — the entry's framed routing identity
150/// followed by its authored timeout.
151///
152/// The framed routing identity is `manifest.entry_module` for the primary (the
153/// module the loader selects at `load.rs`) and `workflow_type` for each
154/// additional entry (its start/child-spawn routing name). Binding the primary's
155/// routing identity is what closes the v2 gap: re-pointing `entry_module` to a
156/// different module in the same beam closure re-routes entry selection, so it
157/// MUST change identity or an authenticated timeout could be reassigned to
158/// another workflow entry under an unchanged version.
159///
160/// Each authored timeout is encoded presence-first (a single `1`/`0` byte),
161/// followed — only when present — by seconds as `u64` big-endian and subsecond
162/// nanoseconds as `u32` big-endian. Binding presence AND value AND the entry's
163/// own routing identity, for every entry uniformly, means no entry's timeout or
164/// routing can be swapped, added, or removed without changing the version hash:
165/// declaredness is an authenticated per-entry property, never a package-wide
166/// inference from the primary alone.
167///
168/// This supersedes the pre-release `.v2` layout (which bound only the primary's
169/// timeout, not its routing identity); a `.v2`-stamped archive therefore decodes
170/// as wholly undeclared, exactly like any non-matching identity.
171#[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/// Computes the single `.v4` package identity over canonical BEAMs, the full
181/// `.v3` routing/timeout vector, and the canonical durable contract record.
182///
183/// This is one domain-separated digest, not a tuple of independently checkable
184/// hashes. A change to code, routing, timeouts, or any contract field therefore
185/// changes the package identity at the same boundary.
186#[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/// Whether this package's version identity commits to explicitly authored
212/// per-entry workflow timeouts.
213///
214/// True only when the stored content hash is the domain-separated per-entry
215/// timeout-bearing `.v3` identity ([`content_hash_with_timeouts`]) — never the
216/// beams-only legacy identity, and never a superseded pre-release `.v1`/`.v2`
217/// identity. A legacy (beams-only) archive, a pre-release single-value archive,
218/// or one whose routing/additional entries were not uniformly bound therefore
219/// reads as wholly NOT declared: no entry can arm a deadline. The check is
220/// tamper-evident: the timeout value returned by [`crate::Package`] for any
221/// entry is provably the one baked into the version hash, so a hand-edited or
222/// injected per-entry timeout that was not part of the identity cannot fake
223/// declaredness.
224#[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
233/// Computes the superseded `.v5` contract-bearing identity, exactly as the
234/// released cuts v0.19–v0.24 did. Verification-only (see the migration note
235/// on the domain constants); nothing mints this.
236fn 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
249/// Whether the package identity commits to its contract record — under the
250/// current domain, or under the `.v5` migration accommodation (which binds
251/// the whole contract except the agent bytes and can carry no workloop).
252pub(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
265/// Verifies an archive that may carry a `.v4` contract record.
266pub(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        // The `.v5` migration accommodation: every released cut v0.19–v0.24
277        // minted this domain, and a store restarted under the current build
278        // must still read the deployments those releases recorded. A match
279        // IS integrity — the bytes are exactly what a released cut attested.
280        // A `.v5` stamp can never attest a workloop (the field postdates the
281        // domain), so a contract carrying one never re-attests here: it
282        // falls through to the refusal below, indistinguishable from tamper
283        // and with the same cure.
284        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        // A contract-bearing archive whose stored identity matches neither
291        // the current recompute nor the `.v5` accommodation nor any legacy
292        // form is refused with the CONTRACT-BEARING hash in the report: an
293        // archive minted under the superseded `.v4` domain lands here, and
294        // the cure — redeploy under the current identity — is the same as
295        // for tampering. This build carries no `.v4` encoder by policy, so
296        // the two causes are indistinguishable and both refuse.
297        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
308/// Verifies the stored manifest version against the recomputed identities and
309/// returns the matching hash, or an integrity error.
310///
311/// Three forms load. The beams-only legacy identity and the per-entry `.v3`
312/// timeout-bearing identity both hold for freshly written archives; only the
313/// `.v3` form makes [`has_explicit_timeout_identity`] true (declaring).
314///
315/// The third is a migration accommodation: a pre-release `.v1` single-value
316/// identity ([`content_hash_with_timeout`], stamped only when a primary timeout
317/// was present) is accepted as INTEGRITY-VALID BUT WHOLLY UNDECLARING. Its beam
318/// closure is still authenticated by the `.v1` hash, so loading it is honest;
319/// but it did not bind routing identity or additional entries under the current
320/// law, so it is untrustworthy as a per-entry declaration and every entry reads
321/// undeclared (nothing arms). This lets a `.v1`-stamped deployment recover on
322/// restart instead of being skipped, without ever arming a deadline whose
323/// authorship the current identity cannot vouch for. A `.v2` archive (never
324/// released, and which likewise did not bind routing identity) is deliberately
325/// NOT accommodated: it matches none of these forms and is rejected.
326pub(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    // Migration: a pre-release `.v1` single-primary-timeout archive is accepted
340    // as integrity-valid but non-declaring. `has_explicit_timeout_identity`
341    // returns false for it (it is not the `.v3` hash), so it loads yet arms
342    // nothing.
343    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
355/// Frames one entry's optional authored timeout into the digest: a presence
356/// byte, then seconds (`u64` big-endian) and subsecond nanoseconds (`u32`
357/// big-endian) only when a timeout is present. An absent timeout contributes
358/// exactly the `0` presence byte, so presence and value are both bound.
359fn 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        // Changing an additional entry's timeout value changes the identity.
555        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        // Adding an unbound additional timeout (the mixed-archive attack) changes
565        // the identity: it cannot ride the primary's declaredness.
566        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        // Presence alone (Some vs None) changes the identity.
576        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        // Two modules in one closure. Re-pointing the primary `entry_module`
590        // from one to the other re-routes entry selection, so it MUST change the
591        // version identity — otherwise an authenticated primary timeout could be
592        // reassigned to a different selected workflow (the v2 blocker).
593        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        // The stored `.v3` hash for A does not authenticate B's selection: with
606        // B routed, A's stored identity reads as undeclared.
607        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        // A pre-release `.v1` single-primary-timeout archive is accepted by
616        // `verified_content_hash` (its beam closure is authenticated) but is
617        // wholly undeclared: nothing arms. This keeps a `.v1`-stamped deployment
618        // loadable on restart without arming a timeout the current identity law
619        // cannot vouch for.
620        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        // Any non-legacy, non-`.v1`, non-`.v3` stored value (the pre-release
632        // `.v2` shape among them) matches none of the accepted forms and is
633        // rejected rather than silently loaded.
634        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        // A package whose stored hash bound ONLY the primary timeout (the old
648        // single-entry identity) but which carries an additional entry with an
649        // unauthenticated `Some(1h)` must read as wholly undeclared under the
650        // per-entry identity: the stored hash matches neither the legacy nor the
651        // per-entry timeout-bearing hash.
652        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        // The attacker stamps the primary-only identity as the version.
658        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        // The beams-only legacy identity is likewise undeclared.
664        assert!(!has_explicit_timeout_identity(
665            &beams,
666            &manifest,
667            &content_hash(&beams)
668        ));
669        // Only the full per-entry identity authenticates every entry.
670        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    /// A minimal contract-bearing surface for the `.v5` migration tests.
739    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        // A store restarted under the `.v6` build must still read the
782        // deployments the released `.v5` cuts recorded (v0.19–v0.24): the
783        // stored identity re-attests under the `.v5` domain and encoding,
784        // and the recorded hash — the package's immutable name — survives.
785        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        // Two contracts differing ONLY in an agent flag hash identically
804        // under `.v5` — that is the aion#158 defect the `.v6` bump fixed,
805        // and it is exactly the trust level every released cut gave those
806        // flags. The migration accommodation inherits it knowingly; the
807        // `.v6` domain binds the byte.
808        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        // The workloop field postdates the `.v5` domain, so no released cut
826        // could have attested one: a `.v5`-stamped contract CARRYING a
827        // workloop record is tamper-shaped and refuses, wholly.
828        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        // The attacker stamps the hash a released cut would have minted for
833        // the workloop-free record, then injects the workloop in storage.
834        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        // The accommodation widens which DOMAIN may attest, never what
852        // counts as intact: a byte moved anywhere the `.v5` encoding covers
853        // still refuses as corruption.
854        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}