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    BeamModule, BeamSet, ContentHash, ExtractionLimits, Manifest, PackageError,
14    builder::is_safe_logical_name,
15    extraction::ExtractionBudget,
16    hash::{has_explicit_timeout_identity, verified_content_hash},
17    namespace::deployed_name,
18    version::WorkflowVersion,
19};
20
21const MANIFEST_ENTRY: &str = "manifest.json";
22const BEAM_PREFIX: &str = "beam/";
23const BEAM_SUFFIX: &str = ".beam";
24const SOURCE_PREFIX: &str = "src/";
25const SOURCE_SUFFIX: &str = ".gleam";
26
27/// A validated, integrity-checked `.aion` package loaded fully into memory.
28///
29/// The engine performs actual VM registration. This crate only supplies the
30/// validated manifest, canonical beam bytes, optional source, and deployed module
31/// names the engine can register.
32#[derive(Clone, Debug, PartialEq)]
33pub struct Package {
34    manifest: Manifest,
35    beams: BeamSet,
36    source: BTreeMap<String, Vec<u8>>,
37    content_hash: ContentHash,
38}
39
40impl Package {
41    /// Loads a `.aion` package from a filesystem path.
42    ///
43    /// The caller chooses an explicit [`ExtractionLimits`] inflate budget;
44    /// untrusted input must be bounded.
45    ///
46    /// # Errors
47    ///
48    /// Returns a typed [`PackageError`] for unreadable archives, malformed
49    /// manifests or entries, unsupported format versions, integrity mismatches,
50    /// missing entry modules, or contents inflating past `limits`.
51    pub fn load_from_path(
52        path: impl AsRef<Path>,
53        limits: ExtractionLimits,
54    ) -> Result<Self, PackageError> {
55        let file =
56            File::open(path).map_err(|source| PackageError::ArchiveRead(ZipError::Io(source)))?;
57        Self::load_from_reader(file, limits)
58    }
59
60    /// Loads a `.aion` package from an in-memory byte buffer.
61    ///
62    /// The caller chooses an explicit [`ExtractionLimits`] inflate budget;
63    /// untrusted input must be bounded.
64    ///
65    /// # Errors
66    ///
67    /// Returns a typed [`PackageError`] for unreadable archives, malformed
68    /// manifests or entries, unsupported format versions, integrity mismatches,
69    /// missing entry modules, or contents inflating past `limits`.
70    pub fn load_from_bytes(
71        bytes: impl AsRef<[u8]>,
72        limits: ExtractionLimits,
73    ) -> Result<Self, PackageError> {
74        Self::load_from_reader(Cursor::new(bytes.as_ref()), limits)
75    }
76
77    fn load_from_reader<R>(reader: R, limits: ExtractionLimits) -> Result<Self, PackageError>
78    where
79        R: Read + Seek,
80    {
81        let mut archive = ZipArchive::new(reader).map_err(PackageError::ArchiveRead)?;
82        let mut budget = limits.budget();
83        let manifest = read_manifest(&mut archive, &mut budget)?;
84        manifest.check_format_version()?;
85
86        let (beams, source) = read_archive_entries(&mut archive, &mut budget)?;
87        let content_hash = verified_content_hash(&beams, &manifest)?;
88
89        if beams.get(&manifest.entry_module).is_none() {
90            return Err(PackageError::MissingEntryModule {
91                module: manifest.entry_module.clone(),
92            });
93        }
94
95        Ok(Self {
96            manifest,
97            beams,
98            source,
99            content_hash,
100        })
101    }
102
103    /// Returns the validated manifest loaded from `manifest.json`.
104    #[must_use]
105    pub const fn manifest(&self) -> &Manifest {
106        &self.manifest
107    }
108
109    /// Returns the canonical compiled beam set extracted from `beam/` entries.
110    #[must_use]
111    pub const fn beams(&self) -> &BeamSet {
112        &self.beams
113    }
114
115    /// Returns optional Gleam source files extracted verbatim from `src/` entries.
116    #[must_use]
117    pub const fn source(&self) -> &BTreeMap<String, Vec<u8>> {
118        &self.source
119    }
120
121    /// Returns the recomputed content hash that proved package integrity.
122    #[must_use]
123    pub const fn content_hash(&self) -> &ContentHash {
124        &self.content_hash
125    }
126
127    /// Whether this package's version identity commits to an explicitly authored
128    /// workflow timeout.
129    ///
130    /// This is the single, tamper-evident authority for "did the author declare
131    /// a workflow timeout": it is true only when the content hash is the
132    /// domain-separated timeout-bearing identity over exactly `manifest.timeout`.
133    /// A legacy (beams-only) archive — even one whose manifest still carries a
134    /// defaulted `timeout` value — reads as `false`, so it can never arm a
135    /// deadline. Callers pair this with [`Self::manifest`] to read the declared
136    /// timeout: the value is trustworthy precisely because it is bound into the
137    /// version hash.
138    #[must_use]
139    pub fn has_declared_timeout(&self) -> bool {
140        has_explicit_timeout_identity(&self.beams, &self.manifest, &self.content_hash)
141    }
142
143    /// The explicitly authored workflow timeout of the primary entry, or `None`.
144    ///
145    /// Returns `Some` only when the package identity commits to a declared
146    /// timeout (see [`Self::has_declared_timeout`]); otherwise `None`, so a
147    /// legacy or defaulted manifest yields no deadline.
148    #[must_use]
149    pub fn declared_timeout(&self) -> Option<std::time::Duration> {
150        self.declared_entry_timeout(self.manifest.timeout)
151    }
152
153    /// The authenticated authored timeout for an entry carrying `entry_timeout`.
154    ///
155    /// This is the per-entry declaredness authority: the timeout-bearing
156    /// identity binds EVERY entry's timeout (primary and additional), so when
157    /// [`Self::has_declared_timeout`] is true each entry's manifest `timeout` is
158    /// authenticated and returned verbatim. When the identity is legacy
159    /// (beams-only) — or does not verify against the full per-entry timeout
160    /// vector — every entry reads as undeclared and arms nothing, regardless of
161    /// what `timeout` value a manifest entry happens to carry. Callers pass the
162    /// primary entry's `manifest.timeout` or an additional
163    /// [`crate::WorkflowEntry::timeout`]; the gate is identical for both.
164    #[must_use]
165    pub fn declared_entry_timeout(
166        &self,
167        entry_timeout: Option<std::time::Duration>,
168    ) -> Option<std::time::Duration> {
169        if self.has_declared_timeout() {
170            entry_timeout
171        } else {
172            None
173        }
174    }
175
176    /// Produces the canonical cross-system version record for this loaded package.
177    #[must_use]
178    pub fn version_record(&self) -> WorkflowVersion {
179        WorkflowVersion {
180            entry_module: self.manifest.entry_module.clone(),
181            content_hash: self.content_hash.clone(),
182            activities: self.manifest.activities.clone(),
183            input_schema: self.manifest.input_schema.clone(),
184            output_schema: self.manifest.output_schema.clone(),
185        }
186    }
187
188    /// Returns engine-ready deployed module names paired with their beam bytes.
189    ///
190    /// The engine performs the actual VM registration; this crate only supplies
191    /// the validated namespaced names and exact module bytes.
192    #[must_use]
193    pub fn deployed_modules(&self) -> Vec<(String, &[u8])> {
194        self.beams
195            .iter()
196            .map(|module| {
197                (
198                    deployed_name(module.name(), &self.content_hash),
199                    module.bytes(),
200                )
201            })
202            .collect()
203    }
204
205    /// Returns the deployed namespaced module name for the manifest entry module.
206    #[must_use]
207    pub fn deployed_entry_module(&self) -> String {
208        deployed_name(&self.manifest.entry_module, &self.content_hash)
209    }
210
211    /// Re-serialises this validated package into canonical `.aion` archive
212    /// bytes.
213    ///
214    /// The deterministic [`crate::PackageBuilder`] write path is used, so the
215    /// output round-trips through [`Self::load_from_bytes`] to a package with
216    /// the same legacy or explicit-timeout content hash, canonical manifest
217    /// digest, and source set. This is the persistence form for runtime-deployed
218    /// packages: the engine stores these bytes so a deploy survives restart.
219    ///
220    /// # Errors
221    ///
222    /// Returns [`PackageError`] variants for manifest serialisation or ZIP
223    /// writer failures; the entry module is already proven present by load
224    /// validation.
225    pub fn to_archive_bytes(&self) -> Result<Vec<u8>, PackageError> {
226        let mut builder = crate::PackageBuilder::with_source(
227            self.manifest.clone(),
228            self.beams.clone(),
229            self.source.clone(),
230        );
231        if has_explicit_timeout_identity(&self.beams, &self.manifest, &self.content_hash) {
232            builder = builder.with_explicit_timeout_identity();
233        }
234        builder.write_to_bytes()
235    }
236
237    #[cfg(any(test, feature = "test-support"))]
238    #[doc(hidden)]
239    #[must_use]
240    pub fn from_validated_parts_for_test(
241        manifest: Manifest,
242        beams: BeamSet,
243        source: BTreeMap<String, Vec<u8>>,
244        content_hash: ContentHash,
245    ) -> Self {
246        Self {
247            manifest,
248            beams,
249            source,
250            content_hash,
251        }
252    }
253}
254
255fn read_manifest<R>(
256    archive: &mut ZipArchive<R>,
257    budget: &mut ExtractionBudget,
258) -> Result<Manifest, PackageError>
259where
260    R: Read + Seek,
261{
262    let mut manifest_file = match archive.by_name(MANIFEST_ENTRY) {
263        Ok(file) => file,
264        Err(ZipError::FileNotFound) => return Err(PackageError::MissingManifest),
265        Err(error) => return Err(PackageError::ArchiveRead(error)),
266    };
267
268    let manifest_bytes = budget.read_entry(&mut manifest_file)?;
269
270    serde_json::from_slice(&manifest_bytes).map_err(|source| PackageError::ManifestParse { source })
271}
272
273fn read_archive_entries<R>(
274    archive: &mut ZipArchive<R>,
275    budget: &mut ExtractionBudget,
276) -> Result<(BeamSet, BTreeMap<String, Vec<u8>>), PackageError>
277where
278    R: Read + Seek,
279{
280    let mut modules = Vec::new();
281    let mut source = BTreeMap::new();
282
283    for index in 0..archive.len() {
284        let mut file = archive.by_index(index).map_err(PackageError::ArchiveRead)?;
285        if file.is_dir() {
286            continue;
287        }
288
289        let entry = file.name().to_owned();
290        if entry == MANIFEST_ENTRY {
291            continue;
292        }
293
294        if entry.starts_with(BEAM_PREFIX) {
295            let logical = logical_name_from_entry(&entry, BEAM_PREFIX, BEAM_SUFFIX)?;
296            let bytes = budget.read_entry(&mut file)?;
297            modules.push(BeamModule::new(logical, bytes));
298        } else if entry.starts_with(SOURCE_PREFIX) {
299            let logical = logical_name_from_entry(&entry, SOURCE_PREFIX, SOURCE_SUFFIX)?;
300            let bytes = budget.read_entry(&mut file)?;
301            if source.insert(logical, bytes).is_some() {
302                return Err(PackageError::MalformedBeamEntry { entry });
303            }
304        }
305    }
306
307    let beams = BeamSet::new(modules)?;
308    Ok((beams, source))
309}
310
311fn logical_name_from_entry(
312    entry: &str,
313    prefix: &str,
314    suffix: &str,
315) -> Result<String, PackageError> {
316    let Some(without_prefix) = entry.strip_prefix(prefix) else {
317        return Err(PackageError::MalformedBeamEntry {
318            entry: entry.to_owned(),
319        });
320    };
321    let Some(logical) = without_prefix.strip_suffix(suffix) else {
322        return Err(PackageError::MalformedBeamEntry {
323            entry: entry.to_owned(),
324        });
325    };
326
327    if is_safe_logical_name(logical) {
328        Ok(logical.to_owned())
329    } else {
330        Err(PackageError::MalformedBeamEntry {
331            entry: entry.to_owned(),
332        })
333    }
334}
335
336#[cfg(test)]
337#[path = "package_tests.rs"]
338mod tests;