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