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::{
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";
16// `.v6` because TWO fields that are executable authority now encode, and both
17// had to land in ONE bump rather than two:
18//
19//   * `ActionContract::agent` (aion#158). Its own doc comment claimed it was
20//     identity-bound; it was referenced ZERO times by the encoder, so two
21//     declarations differing only in whether an action is an agent seam
22//     hashed identically.
23//   * `PackageContract::workloop`. A loop's cadence, tolerances, retention
24//     window and carry seeds are what the engine ACTS ON — a tolerance
25//     rewritten in storage changes when the loop alarms, a retention window
26//     changes what is destroyed.
27//
28// Both encode UNCONDITIONALLY, per the law recorded above the `.v5` body
29// block: never a second optional tail, because two optional tails are not
30// injective and a contract identity two different declarations can share is a
31// spoofable deployment.
32//
33// 🔴 MIGRATION. Every released cut from v0.19 through v0.24 minted the
34// `.v5` domain, so real stores hold `.v5`-stamped deployments that a
35// restart under this build MUST still read — the first `.v6` boot on
36// Tom's live store stranded 71 of 81 recorded deployments, which is the
37// brick class the release law exists to forbid. `verified_content_hash_with_contract`
38// therefore RE-ATTESTS a `.v5` archive under [`WORKER_CONTRACT_DOMAIN_V5`]
39// with the `.v5` canonical encoding
40// ([`PackageContract::legacy_v5_canonical_bytes`]): a match is integrity —
41// the bytes are exactly what a released cut attested — and a mismatch under
42// every accepted domain remains corruption, refused as ever. The identity
43// (the recorded hash) is never rewritten: it names pinned versions and
44// deployed modules, and identity is immutable by invariant.
45//
46// What `.v5` vouches for is what `.v5` bound: everything except the
47// per-action agent byte and the workloop block. Agent flags on a
48// re-attested archive travel as stored, at the trust level every released
49// cut gave them; a `.v5` stamp can never attest a workloop, so a
50// contract carrying one refuses. Archives hashed under `.v4` or the
51// unreleased pre-`.v5` forms still refuse: no released cut minted them.
52const 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/// A SHA-256 package version identity.
56///
57/// Legacy identities cover each module's logical name and exact `.beam` bytes
58/// in [`BeamSet`] canonical order. Explicit-timeout identities append a
59/// domain-separated timeout encoding. Archive representation and optional
60/// source inclusion never participate, so deterministic inputs keep one version.
61///
62/// "Source inclusion" is EVERY archived-source family, and this is a declared
63/// non-property rather than an accident of the current encoding: neither the
64/// Gleam `src/` entries nor the AWL `awl/document/` + `awl/schema/` entries
65/// (see [`crate::awl`]) contribute a byte to any identity above. Two packages
66/// that differ ONLY in whether they carry an authored AWL document, or in what
67/// that document says, are the SAME version — the provenance an archive shows
68/// is declared unbound from identity, never ambiently assumed to be bound.
69/// Pinned by `source_inclusion_does_not_change_manifest_version` and
70/// `awl_source_inclusion_does_not_change_manifest_version` in
71/// [`crate::builder`].
72///
73/// Its stable textual form is 64 lowercase hexadecimal characters. That text is
74/// the package version identifier stored in the manifest and the hash component
75/// embedded in namespaced deployed module names; it contains only `0-9a-f`,
76/// which is safe for a BEAM module-name component.
77#[derive(Clone, Debug, PartialEq, Eq, Hash)]
78pub struct ContentHash([u8; DIGEST_LEN]);
79
80impl ContentHash {
81    /// Creates a content hash from raw SHA-256 digest bytes.
82    #[must_use]
83    pub const fn from_bytes(bytes: [u8; DIGEST_LEN]) -> Self {
84        Self(bytes)
85    }
86
87    /// Returns the raw SHA-256 digest bytes.
88    #[must_use]
89    pub const fn as_bytes(&self) -> &[u8; DIGEST_LEN] {
90        &self.0
91    }
92}
93
94/// Errors produced when parsing a [`ContentHash`] textual form.
95#[derive(thiserror::Error, Clone, Debug, PartialEq, Eq)]
96pub enum ContentHashParseError {
97    /// The text was not exactly 64 ASCII hexadecimal characters.
98    #[error("content hash text must be 64 lowercase hexadecimal characters, found {found} bytes")]
99    InvalidLength {
100        /// Number of bytes found in the supplied text.
101        found: usize,
102    },
103
104    /// The text contained a character outside lowercase hexadecimal.
105    #[error("content hash text contains non-lowercase-hex byte 0x{byte:02x} at byte index {index}")]
106    InvalidCharacter {
107        /// Byte index of the invalid character.
108        index: usize,
109        /// Invalid byte found at `index`.
110        byte: u8,
111    },
112}
113
114/// Computes the package version hash over the canonical BEAM set only.
115///
116/// The SHA-256 algorithm is mandated by the `.aion` format contract so packers
117/// and loaders on different hosts agree. Each module contributes its logical
118/// name and exact bytes in [`BeamSet`] canonical order, with each field framed by
119/// an eight-byte big-endian length prefix. This unambiguous framing prevents a
120/// shifted name/body boundary from producing the same digest.
121#[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/// Computes an explicit-timeout package version from the canonical BEAM set,
129/// then the framed ASCII domain `aion.package.version.workflow-timeout.v1`,
130/// then exactly 12 timeout bytes: seconds as `u64` big-endian followed by
131/// subsecond nanoseconds as `u32` big-endian.
132///
133/// This single-entry form is retained for external callers; the per-entry
134/// [`content_hash_with_timeouts`] is the authority the loader trusts, because it
135/// binds every workflow entry's timeout — not only the primary — into identity.
136#[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/// Computes the per-entry timeout-bearing package version over the canonical
147/// BEAM set, then the framed ASCII domain
148/// `aion.package.version.workflow-timeouts.v3`, then a UNIFORM canonical encoding
149/// that treats the primary entry and every additional entry alike: the total
150/// entry count as `u64` big-endian, then — for the primary first and each
151/// additional entry in manifest order — the entry's framed routing identity
152/// followed by its authored timeout.
153///
154/// The framed routing identity is `manifest.entry_module` for the primary (the
155/// module the loader selects at `load.rs`) and `workflow_type` for each
156/// additional entry (its start/child-spawn routing name). Binding the primary's
157/// routing identity is what closes the v2 gap: re-pointing `entry_module` to a
158/// different module in the same beam closure re-routes entry selection, so it
159/// MUST change identity or an authenticated timeout could be reassigned to
160/// another workflow entry under an unchanged version.
161///
162/// Each authored timeout is encoded presence-first (a single `1`/`0` byte),
163/// followed — only when present — by seconds as `u64` big-endian and subsecond
164/// nanoseconds as `u32` big-endian. Binding presence AND value AND the entry's
165/// own routing identity, for every entry uniformly, means no entry's timeout or
166/// routing can be swapped, added, or removed without changing the version hash:
167/// declaredness is an authenticated per-entry property, never a package-wide
168/// inference from the primary alone.
169///
170/// This supersedes the pre-release `.v2` layout (which bound only the primary's
171/// timeout, not its routing identity); a `.v2`-stamped archive therefore decodes
172/// as wholly undeclared, exactly like any non-matching identity.
173#[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/// Computes the single `.v4` package identity over canonical BEAMs, the full
183/// `.v3` routing/timeout vector, and the canonical durable contract record.
184///
185/// This is one domain-separated digest, not a tuple of independently checkable
186/// hashes. A change to code, routing, timeouts, or any contract field therefore
187/// changes the package identity at the same boundary.
188#[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
197/// The current-domain contract-bearing identity with prior-form declared
198/// command substitution — the verification-only seam the migration
199/// accommodation rides (see
200/// [`PackageContract::canonical_bytes_with_prior_commands`]). With `prior`
201/// empty this is exactly [`content_hash_with_contract`], which is the only
202/// form anything ever mints.
203pub(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/// Whether this package's version identity commits to explicitly authored
232/// per-entry workflow timeouts.
233///
234/// True only when the stored content hash is the domain-separated per-entry
235/// timeout-bearing `.v3` identity ([`content_hash_with_timeouts`]) — never the
236/// beams-only legacy identity, and never a superseded pre-release `.v1`/`.v2`
237/// identity. A legacy (beams-only) archive, a pre-release single-value archive,
238/// or one whose routing/additional entries were not uniformly bound therefore
239/// reads as wholly NOT declared: no entry can arm a deadline. The check is
240/// tamper-evident: the timeout value returned by [`crate::Package`] for any
241/// entry is provably the one baked into the version hash, so a hand-edited or
242/// injected per-entry timeout that was not part of the identity cannot fake
243/// declaredness.
244#[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
253/// Computes the superseded `.v5` contract-bearing identity, exactly as the
254/// released cuts v0.19–v0.24 did. Verification-only (see the migration note
255/// on the domain constants); nothing mints this.
256fn 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
273/// Whether the package identity commits to its contract record — under the
274/// current domain, or under the `.v5` migration accommodation (which binds
275/// the whole contract except the agent bytes and can carry no workloop).
276///
277/// `prior` carries the prior-form declared command identities read from the
278/// raw archive bytes at load (empty for every current-form archive), so the
279/// commitment check re-attests exactly what the minting release hashed.
280pub(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
294/// Verifies an archive that may carry a `.v4` contract record.
295///
296/// `prior` carries the prior-form declared command identities read from the
297/// raw archive bytes (see
298/// [`crate::declared_command::prior_command_identities`]); it is empty for
299/// every current-form archive, and both domain recomputes substitute it so a
300/// prior-form archive re-attests over the bytes its minting release hashed.
301pub(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        // The `.v5` migration accommodation: every released cut v0.19–v0.24
313        // minted this domain, and a store restarted under the current build
314        // must still read the deployments those releases recorded. A match
315        // IS integrity — the bytes are exactly what a released cut attested.
316        // A `.v5` stamp can never attest a workloop (the field postdates the
317        // domain), so a contract carrying one never re-attests here: it
318        // falls through to the refusal below, indistinguishable from tamper
319        // and with the same cure.
320        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        // A contract-bearing archive whose stored identity matches neither
327        // the current recompute nor the `.v5` accommodation nor any legacy
328        // form is refused with the CONTRACT-BEARING hash in the report: an
329        // archive minted under the superseded `.v4` domain lands here, and
330        // the cure — redeploy under the current identity — is the same as
331        // for tampering. This build carries no `.v4` encoder by policy, so
332        // the two causes are indistinguishable and both refuse.
333        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
344/// Verifies the stored manifest version against the recomputed identities and
345/// returns the matching hash, or an integrity error.
346///
347/// Three forms load. The beams-only legacy identity and the per-entry `.v3`
348/// timeout-bearing identity both hold for freshly written archives; only the
349/// `.v3` form makes [`has_explicit_timeout_identity`] true (declaring).
350///
351/// The third is a migration accommodation: a pre-release `.v1` single-value
352/// identity ([`content_hash_with_timeout`], stamped only when a primary timeout
353/// was present) is accepted as INTEGRITY-VALID BUT WHOLLY UNDECLARING. Its beam
354/// closure is still authenticated by the `.v1` hash, so loading it is honest;
355/// but it did not bind routing identity or additional entries under the current
356/// law, so it is untrustworthy as a per-entry declaration and every entry reads
357/// undeclared (nothing arms). This lets a `.v1`-stamped deployment recover on
358/// restart instead of being skipped, without ever arming a deadline whose
359/// authorship the current identity cannot vouch for. A `.v2` archive (never
360/// released, and which likewise did not bind routing identity) is deliberately
361/// NOT accommodated: it matches none of these forms and is rejected.
362pub(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    // Migration: a pre-release `.v1` single-primary-timeout archive is accepted
376    // as integrity-valid but non-declaring. `has_explicit_timeout_identity`
377    // returns false for it (it is not the `.v3` hash), so it loads yet arms
378    // nothing.
379    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
391/// Frames one entry's optional authored timeout into the digest: a presence
392/// byte, then seconds (`u64` big-endian) and subsecond nanoseconds (`u32`
393/// big-endian) only when a timeout is present. An absent timeout contributes
394/// exactly the `0` presence byte, so presence and value are both bound.
395fn 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    /// The ordinary case: no prior-form declared commands in the archive.
511    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        // Changing an additional entry's timeout value changes the identity.
597        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        // Adding an unbound additional timeout (the mixed-archive attack) changes
607        // the identity: it cannot ride the primary's declaredness.
608        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        // Presence alone (Some vs None) changes the identity.
618        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        // Two modules in one closure. Re-pointing the primary `entry_module`
632        // from one to the other re-routes entry selection, so it MUST change the
633        // version identity — otherwise an authenticated primary timeout could be
634        // reassigned to a different selected workflow (the v2 blocker).
635        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        // The stored `.v3` hash for A does not authenticate B's selection: with
648        // B routed, A's stored identity reads as undeclared.
649        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        // A pre-release `.v1` single-primary-timeout archive is accepted by
658        // `verified_content_hash` (its beam closure is authenticated) but is
659        // wholly undeclared: nothing arms. This keeps a `.v1`-stamped deployment
660        // loadable on restart without arming a timeout the current identity law
661        // cannot vouch for.
662        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        // Any non-legacy, non-`.v1`, non-`.v3` stored value (the pre-release
674        // `.v2` shape among them) matches none of the accepted forms and is
675        // rejected rather than silently loaded.
676        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        // A package whose stored hash bound ONLY the primary timeout (the old
690        // single-entry identity) but which carries an additional entry with an
691        // unauthenticated `Some(1h)` must read as wholly undeclared under the
692        // per-entry identity: the stored hash matches neither the legacy nor the
693        // per-entry timeout-bearing hash.
694        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        // The attacker stamps the primary-only identity as the version.
700        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        // The beams-only legacy identity is likewise undeclared.
706        assert!(!has_explicit_timeout_identity(
707            &beams,
708            &manifest,
709            &content_hash(&beams)
710        ));
711        // Only the full per-entry identity authenticates every entry.
712        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    /// A minimal contract-bearing surface for the `.v5` migration tests.
781    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        // A store restarted under the `.v6` build must still read the
824        // deployments the released `.v5` cuts recorded (v0.19–v0.24): the
825        // stored identity re-attests under the `.v5` domain and encoding,
826        // and the recorded hash — the package's immutable name — survives.
827        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        // Two contracts differing ONLY in an agent flag hash identically
847        // under `.v5` — that is the aion#158 defect the `.v6` bump fixed,
848        // and it is exactly the trust level every released cut gave those
849        // flags. The migration accommodation inherits it knowingly; the
850        // `.v6` domain binds the byte.
851        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        // The workloop field postdates the `.v5` domain, so no released cut
869        // could have attested one: a `.v5`-stamped contract CARRYING a
870        // workloop record is tamper-shaped and refuses, wholly.
871        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        // The attacker stamps the hash a released cut would have minted for
876        // the workloop-free record, then injects the workloop in storage.
877        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        // The accommodation widens which DOMAIN may attest, never what
896        // counts as intact: a byte moved anywhere the `.v5` encoding covers
897        // still refuses as corruption.
898        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}