Skip to main content

aion_package/
package.rs

1//! `Package` load path and integrity check.
2
3use std::{
4    collections::BTreeMap,
5    fs::File,
6    io::{Cursor, Read, Seek},
7    path::Path,
8};
9
10use zip::{ZipArchive, result::ZipError};
11
12use crate::{
13    AwlSource, BeamModule, BeamSet, ContentHash, ContractIdentityError, ExtractionLimits, Manifest,
14    PackageContract, PackageError,
15    awl::{AWL_DOCUMENT_PREFIX, AWL_SCHEMA_PREFIX},
16    builder::is_safe_logical_name,
17    declared_command::{PriorCommandIdentities, prior_command_identities},
18    extraction::ExtractionBudget,
19    hash::{has_contract_identity, verified_content_hash_with_contract},
20    namespace::deployed_name,
21    version::WorkflowVersion,
22};
23
24const MANIFEST_ENTRY: &str = "manifest.json";
25const CONTRACT_ENTRY: &str = "contract.json";
26const BEAM_PREFIX: &str = "beam/";
27const BEAM_SUFFIX: &str = ".beam";
28const SOURCE_PREFIX: &str = "src/";
29const SOURCE_SUFFIX: &str = ".gleam";
30const AWL_PREFIX: &str = "awl/";
31
32/// A validated, integrity-checked `.aion` package loaded fully into memory.
33///
34/// The engine performs actual VM registration. This crate only supplies the
35/// validated manifest, canonical beam bytes, optional source, and deployed module
36/// names the engine can register.
37#[derive(Clone, Debug, PartialEq)]
38pub struct Package {
39    manifest: Manifest,
40    contract: Option<PackageContract>,
41    /// The `contract.json` entry's raw bytes, exactly as the archive carries
42    /// them. Held so persistence ([`Self::to_archive_bytes`]) writes the
43    /// entry back VERBATIM rather than re-serialising the decoded contract:
44    /// for a prior-form archive the decoded shape is the compat translation,
45    /// and re-serialising it under the preserved identity would store a
46    /// contract the stored hash no longer attests — bricking the archive at
47    /// its next open.
48    contract_raw: Option<Vec<u8>>,
49    /// The prior-form declared command identity records read from
50    /// `contract_raw` at load — empty for every current-form archive. Held so
51    /// the contract-commitment checks ([`Self::contract`],
52    /// [`Self::has_declared_timeout`]) re-attest the same bytes the load-time
53    /// verification did.
54    prior_commands: PriorCommandIdentities,
55    beams: BeamSet,
56    source: BTreeMap<String, Vec<u8>>,
57    awl: Option<AwlSource>,
58    content_hash: ContentHash,
59}
60
61/// The entry families one archive read yields, beyond the manifest and
62/// contract.
63struct ArchiveEntries {
64    beams: BeamSet,
65    source: BTreeMap<String, Vec<u8>>,
66    awl: Option<AwlSource>,
67}
68
69impl Package {
70    /// Loads a `.aion` package from a filesystem path.
71    ///
72    /// The caller chooses an explicit [`ExtractionLimits`] inflate budget;
73    /// untrusted input must be bounded.
74    ///
75    /// # Errors
76    ///
77    /// Returns a typed [`PackageError`] for unreadable archives, malformed
78    /// manifests or entries, unsupported format versions, integrity mismatches,
79    /// missing entry modules, or contents inflating past `limits`.
80    pub fn load_from_path(
81        path: impl AsRef<Path>,
82        limits: ExtractionLimits,
83    ) -> Result<Self, PackageError> {
84        let file =
85            File::open(path).map_err(|source| PackageError::ArchiveRead(ZipError::Io(source)))?;
86        Self::load_from_reader(file, limits)
87    }
88
89    /// Loads a `.aion` package from an in-memory byte buffer.
90    ///
91    /// The caller chooses an explicit [`ExtractionLimits`] inflate budget;
92    /// untrusted input must be bounded.
93    ///
94    /// # Errors
95    ///
96    /// Returns a typed [`PackageError`] for unreadable archives, malformed
97    /// manifests or entries, unsupported format versions, integrity mismatches,
98    /// missing entry modules, or contents inflating past `limits`.
99    pub fn load_from_bytes(
100        bytes: impl AsRef<[u8]>,
101        limits: ExtractionLimits,
102    ) -> Result<Self, PackageError> {
103        Self::load_from_reader(Cursor::new(bytes.as_ref()), limits)
104    }
105
106    fn load_from_reader<R>(reader: R, limits: ExtractionLimits) -> Result<Self, PackageError>
107    where
108        R: Read + Seek,
109    {
110        let mut archive = ZipArchive::new(reader).map_err(PackageError::ArchiveRead)?;
111        let mut budget = limits.budget();
112        let manifest = read_manifest(&mut archive, &mut budget)?;
113        manifest.check_format_version()?;
114        let (contract, contract_raw) = match read_contract(&mut archive, &mut budget)? {
115            Some((contract, raw)) => (Some(contract), Some(raw)),
116            None => (None, None),
117        };
118        // The prior-form command identities come from the RAW entry bytes —
119        // the typed decode above translates the prior form into the current
120        // shape, and identity must re-attest what the minting release hashed,
121        // never a translation of it.
122        let prior_commands = match &contract_raw {
123            Some(raw) => prior_command_identities(raw).map_err(|error| {
124                PackageError::ContractPriorFormMalformed {
125                    detail: error.to_string(),
126                }
127            })?,
128            None => PriorCommandIdentities::new(),
129        };
130
131        let entries = read_archive_entries(&mut archive, &mut budget)?;
132        let ArchiveEntries { beams, source, awl } = entries;
133        let content_hash = verified_content_hash_with_contract(
134            &beams,
135            &manifest,
136            contract.as_ref(),
137            &prior_commands,
138        )?;
139
140        if beams.get(&manifest.entry_module).is_none() {
141            return Err(PackageError::MissingEntryModule {
142                module: manifest.entry_module.clone(),
143            });
144        }
145
146        Ok(Self {
147            manifest,
148            contract,
149            contract_raw,
150            prior_commands,
151            beams,
152            source,
153            awl,
154            content_hash,
155        })
156    }
157
158    /// Returns the validated manifest loaded from `manifest.json`.
159    #[must_use]
160    pub const fn manifest(&self) -> &Manifest {
161        &self.manifest
162    }
163
164    /// Returns the canonical compiled beam set extracted from `beam/` entries.
165    #[must_use]
166    pub const fn beams(&self) -> &BeamSet {
167        &self.beams
168    }
169
170    /// Returns optional Gleam source files extracted verbatim from `src/` entries.
171    #[must_use]
172    pub const fn source(&self) -> &BTreeMap<String, Vec<u8>> {
173        &self.source
174    }
175
176    /// Returns the authored AWL document and its imported schema files, when
177    /// the archive carries them.
178    ///
179    /// `None` for every archive built from a Gleam project and for every AWL
180    /// archive written before the `awl/` entry families existed — the field is
181    /// provenance, never a load requirement.
182    ///
183    /// This is PROVENANCE ONLY: it never participates in the package's
184    /// [`ContentHash`], so a consumer must not treat it as a version input.
185    #[must_use]
186    pub const fn awl(&self) -> Option<&AwlSource> {
187        self.awl.as_ref()
188    }
189
190    /// Returns the recomputed content hash that proved package integrity.
191    #[must_use]
192    pub const fn content_hash(&self) -> &ContentHash {
193        &self.content_hash
194    }
195
196    /// Returns the durable contract only when the stored identity commits to it.
197    ///
198    /// # Errors
199    ///
200    /// Returns [`ContractIdentityError::RedeployRequired`] for every pre-`.v4`
201    /// identity, including integrity-valid legacy, `.v1`, and `.v3` packages.
202    pub fn contract(&self) -> Result<&PackageContract, ContractIdentityError> {
203        if has_contract_identity(
204            &self.beams,
205            &self.manifest,
206            self.contract.as_ref(),
207            &self.prior_commands,
208            &self.content_hash,
209        ) {
210            self.contract
211                .as_ref()
212                .ok_or_else(|| ContractIdentityError::RedeployRequired {
213                    stored_version: self.content_hash.to_string(),
214                })
215        } else {
216            Err(ContractIdentityError::RedeployRequired {
217                stored_version: self.content_hash.to_string(),
218            })
219        }
220    }
221
222    /// Whether this package's version identity commits to an explicitly authored
223    /// workflow timeout.
224    ///
225    /// This is the single, tamper-evident authority for "did the author declare
226    /// a workflow timeout": it is true only when the manifest carries an
227    /// authored `timeout` AND the content hash is the domain-separated
228    /// contract-bearing identity that binds it. The `.v4` identity commits to
229    /// every package's timeout vector, so identity alone does not mean a
230    /// timeout was authored — absence of the value is absence of the
231    /// declaration. A legacy (beams-only) archive — even one whose manifest
232    /// still carries a defaulted `timeout` value — reads as `false`, so it can
233    /// never arm a deadline. Callers pair this with [`Self::manifest`] to read
234    /// the declared timeout: the value is trustworthy precisely because it is
235    /// bound into the version hash.
236    #[must_use]
237    pub fn has_declared_timeout(&self) -> bool {
238        self.manifest.timeout.is_some()
239            && has_contract_identity(
240                &self.beams,
241                &self.manifest,
242                self.contract.as_ref(),
243                &self.prior_commands,
244                &self.content_hash,
245            )
246    }
247
248    /// The explicitly authored workflow timeout of the primary entry, or `None`.
249    ///
250    /// Returns `Some` only when the package identity commits to a declared
251    /// timeout (see [`Self::has_declared_timeout`]); otherwise `None`, so a
252    /// legacy or defaulted manifest yields no deadline.
253    #[must_use]
254    pub fn declared_timeout(&self) -> Option<std::time::Duration> {
255        self.declared_entry_timeout(self.manifest.timeout)
256    }
257
258    /// The authenticated authored timeout for an entry carrying `entry_timeout`.
259    ///
260    /// This is the per-entry declaredness authority: the timeout-bearing
261    /// identity binds EVERY entry's timeout (primary and additional), so when
262    /// [`Self::has_declared_timeout`] is true each entry's manifest `timeout` is
263    /// authenticated and returned verbatim. When the identity is legacy
264    /// (beams-only) — or does not verify against the full per-entry timeout
265    /// vector — every entry reads as undeclared and arms nothing, regardless of
266    /// what `timeout` value a manifest entry happens to carry. Callers pass the
267    /// primary entry's `manifest.timeout` or an additional
268    /// [`crate::WorkflowEntry::timeout`]; the gate is identical for both.
269    #[must_use]
270    pub fn declared_entry_timeout(
271        &self,
272        entry_timeout: Option<std::time::Duration>,
273    ) -> Option<std::time::Duration> {
274        if self.has_declared_timeout() {
275            entry_timeout
276        } else {
277            None
278        }
279    }
280
281    /// Produces the canonical cross-system version record for this loaded package.
282    #[must_use]
283    pub fn version_record(&self) -> WorkflowVersion {
284        WorkflowVersion {
285            entry_module: self.manifest.entry_module.clone(),
286            content_hash: self.content_hash.clone(),
287            activities: self.manifest.activities.clone(),
288            input_schema: self.manifest.input_schema.clone(),
289            output_schema: self.manifest.output_schema.clone(),
290        }
291    }
292
293    /// Returns engine-ready deployed module names paired with their beam bytes.
294    ///
295    /// The engine performs the actual VM registration; this crate only supplies
296    /// the validated namespaced names and exact module bytes.
297    #[must_use]
298    pub fn deployed_modules(&self) -> Vec<(String, &[u8])> {
299        self.beams
300            .iter()
301            .map(|module| {
302                (
303                    deployed_name(module.name(), &self.content_hash),
304                    module.bytes(),
305                )
306            })
307            .collect()
308    }
309
310    /// Returns the deployed namespaced module name for the manifest entry module.
311    #[must_use]
312    pub fn deployed_entry_module(&self) -> String {
313        deployed_name(&self.manifest.entry_module, &self.content_hash)
314    }
315
316    /// Re-serialises this validated package into canonical `.aion` archive
317    /// bytes.
318    ///
319    /// The deterministic [`crate::PackageBuilder`] write path is used, so the
320    /// output round-trips through [`Self::load_from_bytes`] to a package with
321    /// the same legacy or explicit-timeout content hash, canonical manifest
322    /// digest, source set, and AWL provenance. This is the persistence form for
323    /// runtime-deployed packages: the engine stores these bytes so a deploy
324    /// survives restart, and this path rebuilds the archive from FIELDS — so
325    /// anything the loaded package does not hold as a field is not persisted.
326    ///
327    /// # Errors
328    ///
329    /// Returns [`PackageError`] variants for manifest serialisation or ZIP
330    /// writer failures; the entry module is already proven present by load
331    /// validation.
332    pub fn to_archive_bytes(&self) -> Result<Vec<u8>, PackageError> {
333        let mut builder = crate::PackageBuilder::with_source(
334            self.manifest.clone(),
335            self.beams.clone(),
336            self.source.clone(),
337        );
338        if let Some(awl) = self.awl.clone() {
339            builder = builder.with_awl_source(awl);
340        }
341        builder
342            .preserving_loaded_identity(
343                self.content_hash.clone(),
344                self.contract.clone(),
345                self.contract_raw.clone(),
346            )
347            .write_to_bytes()
348    }
349
350    #[cfg(any(test, feature = "test-support"))]
351    #[doc(hidden)]
352    #[must_use]
353    pub fn from_validated_parts_for_test(
354        manifest: Manifest,
355        beams: BeamSet,
356        source: BTreeMap<String, Vec<u8>>,
357        content_hash: ContentHash,
358    ) -> Self {
359        Self {
360            manifest,
361            contract: None,
362            contract_raw: None,
363            prior_commands: PriorCommandIdentities::new(),
364            beams,
365            source,
366            awl: None,
367            content_hash,
368        }
369    }
370}
371
372fn read_manifest<R>(
373    archive: &mut ZipArchive<R>,
374    budget: &mut ExtractionBudget,
375) -> Result<Manifest, PackageError>
376where
377    R: Read + Seek,
378{
379    let mut manifest_file = match archive.by_name(MANIFEST_ENTRY) {
380        Ok(file) => file,
381        Err(ZipError::FileNotFound) => return Err(PackageError::MissingManifest),
382        Err(error) => return Err(PackageError::ArchiveRead(error)),
383    };
384
385    let manifest_bytes = budget.read_entry(&mut manifest_file)?;
386
387    serde_json::from_slice(&manifest_bytes).map_err(|source| PackageError::ManifestParse { source })
388}
389
390fn read_contract<R>(
391    archive: &mut ZipArchive<R>,
392    budget: &mut ExtractionBudget,
393) -> Result<Option<(PackageContract, Vec<u8>)>, PackageError>
394where
395    R: Read + Seek,
396{
397    let mut contract_file = match archive.by_name(CONTRACT_ENTRY) {
398        Ok(file) => file,
399        Err(ZipError::FileNotFound) => return Ok(None),
400        Err(error) => return Err(PackageError::ArchiveRead(error)),
401    };
402    let contract_bytes = budget.read_entry(&mut contract_file)?;
403    let contract = serde_json::from_slice(&contract_bytes)
404        .map_err(|source| PackageError::ContractParse { source })?;
405    Ok(Some((contract, contract_bytes)))
406}
407
408/// Reads every non-metadata entry into its family.
409///
410/// Entries outside the families this format defines are skipped, exactly as
411/// they always have been — with ONE deliberate exception: an entry under the
412/// `awl/` prefix that names no defined family is refused. That prefix is owned
413/// by this format, and a rewrite through [`Package::to_archive_bytes`] rebuilds
414/// the archive from fields, so silently skipping an unrecognised `awl/` entry
415/// would drop it on the next persistence write. Refusing names the problem
416/// instead of losing the bytes.
417fn read_archive_entries<R>(
418    archive: &mut ZipArchive<R>,
419    budget: &mut ExtractionBudget,
420) -> Result<ArchiveEntries, PackageError>
421where
422    R: Read + Seek,
423{
424    let mut modules = Vec::new();
425    let mut source = BTreeMap::new();
426    let mut document: Option<(String, String)> = None;
427    let mut schemas = BTreeMap::new();
428
429    for index in 0..archive.len() {
430        let mut file = archive.by_index(index).map_err(PackageError::ArchiveRead)?;
431        if file.is_dir() {
432            continue;
433        }
434
435        let entry = file.name().to_owned();
436        if entry == MANIFEST_ENTRY || entry == CONTRACT_ENTRY {
437            continue;
438        }
439
440        if entry.starts_with(BEAM_PREFIX) {
441            let logical = logical_name_from_entry(&entry, BEAM_PREFIX, BEAM_SUFFIX)?;
442            let bytes = budget.read_entry(&mut file)?;
443            modules.push(BeamModule::new(logical, bytes));
444        } else if entry.starts_with(SOURCE_PREFIX) {
445            let logical = logical_name_from_entry(&entry, SOURCE_PREFIX, SOURCE_SUFFIX)?;
446            let bytes = budget.read_entry(&mut file)?;
447            if source.insert(logical, bytes).is_some() {
448                return Err(PackageError::MalformedBeamEntry { entry });
449            }
450        } else if let Some(name) = entry.strip_prefix(AWL_DOCUMENT_PREFIX) {
451            // The document sits at the ROOT of its own directory — that is what
452            // makes every schema entry's path document-relative — so its entry
453            // carries a bare filename and never a directory part.
454            if name.contains('/') {
455                return Err(PackageError::MalformedAwlEntry { entry });
456            }
457            let name = awl_relative_path(&entry, name)?;
458            let bytes = budget.read_entry(&mut file)?;
459            let text =
460                String::from_utf8(bytes).map_err(|source| PackageError::AwlDocumentNotUtf8 {
461                    entry: entry.clone(),
462                    source,
463                })?;
464            if document.replace((name, text)).is_some() {
465                return Err(PackageError::MalformedAwlEntry { entry });
466            }
467        } else if let Some(path) = entry.strip_prefix(AWL_SCHEMA_PREFIX) {
468            let path = awl_relative_path(&entry, path)?;
469            let bytes = budget.read_entry(&mut file)?;
470            if schemas.insert(path, bytes).is_some() {
471                return Err(PackageError::MalformedAwlEntry { entry });
472            }
473        } else if entry.starts_with(AWL_PREFIX) {
474            return Err(PackageError::MalformedAwlEntry { entry });
475        }
476    }
477
478    let awl = match document {
479        Some((name, text)) => Some(AwlSource::new(name, text, schemas)),
480        None if schemas.is_empty() => None,
481        None => return Err(PackageError::MissingAwlDocument),
482    };
483
484    let beams = BeamSet::new(modules)?;
485    Ok(ArchiveEntries { beams, source, awl })
486}
487
488/// Validates the part of an `awl/` entry name that follows its family prefix.
489///
490/// The whole remainder is the name — extension and nesting included — because
491/// a consumer stages the file back at exactly that relative path.
492fn awl_relative_path(entry: &str, relative_path: &str) -> Result<String, PackageError> {
493    if is_safe_logical_name(relative_path) {
494        Ok(relative_path.to_owned())
495    } else {
496        Err(PackageError::MalformedAwlEntry {
497            entry: entry.to_owned(),
498        })
499    }
500}
501
502fn logical_name_from_entry(
503    entry: &str,
504    prefix: &str,
505    suffix: &str,
506) -> Result<String, PackageError> {
507    let Some(without_prefix) = entry.strip_prefix(prefix) else {
508        return Err(PackageError::MalformedBeamEntry {
509            entry: entry.to_owned(),
510        });
511    };
512    let Some(logical) = without_prefix.strip_suffix(suffix) else {
513        return Err(PackageError::MalformedBeamEntry {
514            entry: entry.to_owned(),
515        });
516    };
517
518    if is_safe_logical_name(logical) {
519        Ok(logical.to_owned())
520    } else {
521        Err(PackageError::MalformedBeamEntry {
522            entry: entry.to_owned(),
523        })
524    }
525}
526
527#[cfg(test)]
528#[path = "package_tests.rs"]
529mod tests;