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, Manifest, PackageError, builder::is_safe_logical_name,
14    content_hash, namespace::deployed_name, version::WorkflowVersion,
15};
16
17const MANIFEST_ENTRY: &str = "manifest.json";
18const BEAM_PREFIX: &str = "beam/";
19const BEAM_SUFFIX: &str = ".beam";
20const SOURCE_PREFIX: &str = "src/";
21const SOURCE_SUFFIX: &str = ".gleam";
22
23/// A validated, integrity-checked `.aion` package loaded fully into memory.
24///
25/// The engine performs actual VM registration. This crate only supplies the
26/// validated manifest, canonical beam bytes, optional source, and deployed module
27/// names the engine can register.
28#[derive(Clone, Debug, PartialEq)]
29pub struct Package {
30    manifest: Manifest,
31    beams: BeamSet,
32    source: BTreeMap<String, Vec<u8>>,
33    content_hash: ContentHash,
34}
35
36impl Package {
37    /// Loads a `.aion` package from a filesystem path.
38    ///
39    /// # Errors
40    ///
41    /// Returns a typed [`PackageError`] for unreadable archives, malformed
42    /// manifests or entries, unsupported format versions, integrity mismatches,
43    /// or missing entry modules.
44    pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, PackageError> {
45        let file =
46            File::open(path).map_err(|source| PackageError::ArchiveRead(ZipError::Io(source)))?;
47        Self::load_from_reader(file)
48    }
49
50    /// Loads a `.aion` package from an in-memory byte buffer.
51    ///
52    /// # Errors
53    ///
54    /// Returns a typed [`PackageError`] for unreadable archives, malformed
55    /// manifests or entries, unsupported format versions, integrity mismatches,
56    /// or missing entry modules.
57    pub fn load_from_bytes(bytes: impl AsRef<[u8]>) -> Result<Self, PackageError> {
58        Self::load_from_reader(Cursor::new(bytes.as_ref()))
59    }
60
61    fn load_from_reader<R>(reader: R) -> Result<Self, PackageError>
62    where
63        R: Read + Seek,
64    {
65        let mut archive = ZipArchive::new(reader).map_err(PackageError::ArchiveRead)?;
66        let manifest = read_manifest(&mut archive)?;
67        manifest.check_format_version()?;
68
69        let (beams, source) = read_archive_entries(&mut archive)?;
70        let content_hash = content_hash(&beams);
71        let computed = content_hash.to_string();
72        if manifest.version.as_str() != computed {
73            return Err(PackageError::IntegrityMismatch {
74                expected: manifest.version.as_str().to_owned(),
75                computed,
76            });
77        }
78
79        if beams.get(&manifest.entry_module).is_none() {
80            return Err(PackageError::MissingEntryModule {
81                module: manifest.entry_module.clone(),
82            });
83        }
84
85        Ok(Self {
86            manifest,
87            beams,
88            source,
89            content_hash,
90        })
91    }
92
93    /// Returns the validated manifest loaded from `manifest.json`.
94    #[must_use]
95    pub const fn manifest(&self) -> &Manifest {
96        &self.manifest
97    }
98
99    /// Returns the canonical compiled beam set extracted from `beam/` entries.
100    #[must_use]
101    pub const fn beams(&self) -> &BeamSet {
102        &self.beams
103    }
104
105    /// Returns optional Gleam source files extracted verbatim from `src/` entries.
106    #[must_use]
107    pub const fn source(&self) -> &BTreeMap<String, Vec<u8>> {
108        &self.source
109    }
110
111    /// Returns the recomputed content hash that proved package integrity.
112    #[must_use]
113    pub const fn content_hash(&self) -> &ContentHash {
114        &self.content_hash
115    }
116
117    /// Produces the canonical cross-system version record for this loaded package.
118    #[must_use]
119    pub fn version_record(&self) -> WorkflowVersion {
120        WorkflowVersion {
121            entry_module: self.manifest.entry_module.clone(),
122            content_hash: self.content_hash.clone(),
123            activities: self.manifest.activities.clone(),
124            input_schema: self.manifest.input_schema.clone(),
125            output_schema: self.manifest.output_schema.clone(),
126        }
127    }
128
129    /// Returns engine-ready deployed module names paired with their beam bytes.
130    ///
131    /// The engine performs the actual VM registration; this crate only supplies
132    /// the validated namespaced names and exact module bytes.
133    #[must_use]
134    pub fn deployed_modules(&self) -> Vec<(String, &[u8])> {
135        self.beams
136            .iter()
137            .map(|module| {
138                (
139                    deployed_name(module.name(), &self.content_hash),
140                    module.bytes(),
141                )
142            })
143            .collect()
144    }
145
146    /// Returns the deployed namespaced module name for the manifest entry module.
147    #[must_use]
148    pub fn deployed_entry_module(&self) -> String {
149        deployed_name(&self.manifest.entry_module, &self.content_hash)
150    }
151
152    #[cfg(any(test, feature = "test-support"))]
153    #[doc(hidden)]
154    #[must_use]
155    pub fn from_validated_parts_for_test(
156        manifest: Manifest,
157        beams: BeamSet,
158        source: BTreeMap<String, Vec<u8>>,
159        content_hash: ContentHash,
160    ) -> Self {
161        Self {
162            manifest,
163            beams,
164            source,
165            content_hash,
166        }
167    }
168}
169
170fn read_manifest<R>(archive: &mut ZipArchive<R>) -> Result<Manifest, PackageError>
171where
172    R: Read + Seek,
173{
174    let mut manifest_file = match archive.by_name(MANIFEST_ENTRY) {
175        Ok(file) => file,
176        Err(ZipError::FileNotFound) => return Err(PackageError::MissingManifest),
177        Err(error) => return Err(PackageError::ArchiveRead(error)),
178    };
179
180    let mut manifest_bytes = Vec::new();
181    manifest_file
182        .read_to_end(&mut manifest_bytes)
183        .map_err(|source| PackageError::ArchiveRead(ZipError::Io(source)))?;
184
185    serde_json::from_slice(&manifest_bytes).map_err(|source| PackageError::ManifestParse { source })
186}
187
188fn read_archive_entries<R>(
189    archive: &mut ZipArchive<R>,
190) -> Result<(BeamSet, BTreeMap<String, Vec<u8>>), PackageError>
191where
192    R: Read + Seek,
193{
194    let mut modules = Vec::new();
195    let mut source = BTreeMap::new();
196
197    for index in 0..archive.len() {
198        let mut file = archive.by_index(index).map_err(PackageError::ArchiveRead)?;
199        if file.is_dir() {
200            continue;
201        }
202
203        let entry = file.name().to_owned();
204        if entry == MANIFEST_ENTRY {
205            continue;
206        }
207
208        if entry.starts_with(BEAM_PREFIX) {
209            let logical = logical_name_from_entry(&entry, BEAM_PREFIX, BEAM_SUFFIX)?;
210            let bytes = read_entry_bytes(&mut file)?;
211            modules.push(BeamModule::new(logical, bytes));
212        } else if entry.starts_with(SOURCE_PREFIX) {
213            let logical = logical_name_from_entry(&entry, SOURCE_PREFIX, SOURCE_SUFFIX)?;
214            let bytes = read_entry_bytes(&mut file)?;
215            if source.insert(logical, bytes).is_some() {
216                return Err(PackageError::MalformedBeamEntry { entry });
217            }
218        }
219    }
220
221    let beams = BeamSet::new(modules)?;
222    Ok((beams, source))
223}
224
225fn logical_name_from_entry(
226    entry: &str,
227    prefix: &str,
228    suffix: &str,
229) -> Result<String, PackageError> {
230    let Some(without_prefix) = entry.strip_prefix(prefix) else {
231        return Err(PackageError::MalformedBeamEntry {
232            entry: entry.to_owned(),
233        });
234    };
235    let Some(logical) = without_prefix.strip_suffix(suffix) else {
236        return Err(PackageError::MalformedBeamEntry {
237            entry: entry.to_owned(),
238        });
239    };
240
241    if is_safe_logical_name(logical) {
242        Ok(logical.to_owned())
243    } else {
244        Err(PackageError::MalformedBeamEntry {
245            entry: entry.to_owned(),
246        })
247    }
248}
249
250fn read_entry_bytes<R>(file: &mut zip::read::ZipFile<'_, R>) -> Result<Vec<u8>, PackageError>
251where
252    R: Read,
253{
254    let mut bytes = Vec::new();
255    file.read_to_end(&mut bytes)
256        .map_err(|source| PackageError::ArchiveRead(ZipError::Io(source)))?;
257    Ok(bytes)
258}
259
260#[cfg(test)]
261mod tests {
262    use std::{
263        collections::BTreeMap,
264        fs,
265        io::{Cursor, Write},
266        time::{Duration, SystemTime, UNIX_EPOCH},
267    };
268
269    use serde_json::json;
270    use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
271
272    use super::Package;
273    use crate::{
274        BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, Manifest, ManifestVersion,
275        PackageBuilder, PackageError, content_hash,
276    };
277
278    fn sample_manifest() -> Manifest {
279        Manifest {
280            entry_module: "workflow/order".to_owned(),
281            entry_function: "run".to_owned(),
282            input_schema: json!({ "type": "object" }),
283            output_schema: json!({ "type": "object" }),
284            timeout: Duration::from_secs(30),
285            activities: vec![DeclaredActivity {
286                activity_type: "charge_card".to_owned(),
287            }],
288            version: ManifestVersion::new("placeholder"),
289            format_version: CURRENT_FORMAT_VERSION,
290        }
291    }
292
293    fn sample_beams() -> Result<BeamSet, PackageError> {
294        BeamSet::new(vec![
295            BeamModule::new("workflow/support", vec![4, 5, 6]),
296            BeamModule::new("workflow/order", vec![1, 2, 3]),
297        ])
298    }
299
300    fn write_zip<I, N, B>(entries: I) -> Result<Vec<u8>, PackageError>
301    where
302        I: IntoIterator<Item = (N, B)>,
303        N: ToString,
304        B: AsRef<[u8]>,
305    {
306        let cursor = Cursor::new(Vec::new());
307        let mut archive = ZipWriter::new(cursor);
308        let options = SimpleFileOptions::default()
309            .compression_method(CompressionMethod::Stored)
310            .compression_level(None);
311
312        for (name, bytes) in entries {
313            archive
314                .start_file(name, options)
315                .map_err(PackageError::ArchiveWrite)?;
316            archive
317                .write_all(bytes.as_ref())
318                .map_err(|source| PackageError::ArchiveWriteIo { source })?;
319        }
320
321        let cursor = archive.finish().map_err(PackageError::ArchiveWrite)?;
322        Ok(cursor.into_inner())
323    }
324
325    fn archive_with_manifest(manifest: &Manifest) -> Result<Vec<u8>, PackageError> {
326        let manifest_bytes = serde_json::to_vec(manifest)
327            .map_err(|source| PackageError::ManifestSerialise { source })?;
328        write_zip([("manifest.json", manifest_bytes)])
329    }
330
331    #[test]
332    fn non_zip_input_returns_archive_read() {
333        let result = Package::load_from_bytes(b"not a zip archive");
334
335        assert!(matches!(result, Err(PackageError::ArchiveRead(_))));
336    }
337
338    #[test]
339    fn truncated_zip_input_returns_archive_read() -> Result<(), PackageError> {
340        let bytes = archive_with_manifest(&sample_manifest())?;
341        let truncated = &bytes[..bytes.len() / 2];
342        let result = Package::load_from_bytes(truncated);
343
344        assert!(matches!(result, Err(PackageError::ArchiveRead(_))));
345        Ok(())
346    }
347
348    #[test]
349    fn missing_manifest_returns_missing_manifest() -> Result<(), PackageError> {
350        let bytes = write_zip([("beam/workflow/order.beam", vec![1, 2, 3])])?;
351        let result = Package::load_from_bytes(bytes);
352
353        assert!(matches!(result, Err(PackageError::MissingManifest)));
354        Ok(())
355    }
356
357    #[test]
358    fn unparseable_manifest_returns_manifest_parse() -> Result<(), PackageError> {
359        let bytes = write_zip([("manifest.json", b"not-json".to_vec())])?;
360        let result = Package::load_from_bytes(bytes);
361
362        assert!(matches!(result, Err(PackageError::ManifestParse { .. })));
363        Ok(())
364    }
365
366    #[test]
367    fn unknown_format_version_returns_exact_variant() -> Result<(), PackageError> {
368        let mut manifest = sample_manifest();
369        manifest.format_version = CURRENT_FORMAT_VERSION + 99;
370        let bytes = archive_with_manifest(&manifest)?;
371        let result = Package::load_from_bytes(bytes);
372
373        assert!(matches!(
374            result,
375            Err(PackageError::UnknownFormatVersion { found }) if found == CURRENT_FORMAT_VERSION + 99
376        ));
377        Ok(())
378    }
379
380    #[test]
381    fn malformed_beam_entry_returns_exact_variant() -> Result<(), PackageError> {
382        let beams = sample_beams()?;
383        let mut manifest = sample_manifest();
384        manifest.version = ManifestVersion::new(content_hash(&beams).to_string());
385        let manifest_bytes = serde_json::to_vec(&manifest)
386            .map_err(|source| PackageError::ManifestSerialise { source })?;
387        let bytes = write_zip([
388            ("manifest.json", manifest_bytes),
389            ("beam/workflow/order.txt", vec![1, 2, 3]),
390        ])?;
391        let result = Package::load_from_bytes(bytes);
392
393        assert!(matches!(
394            result,
395            Err(PackageError::MalformedBeamEntry { entry }) if entry == "beam/workflow/order.txt"
396        ));
397        Ok(())
398    }
399
400    #[test]
401    fn beam_entry_with_deployed_name_separator_returns_malformed_entry() -> Result<(), PackageError>
402    {
403        let beams = sample_beams()?;
404        let mut manifest = sample_manifest();
405        manifest.version = ManifestVersion::new(content_hash(&beams).to_string());
406        let manifest_bytes = serde_json::to_vec(&manifest)
407            .map_err(|source| PackageError::ManifestSerialise { source })?;
408        let bytes = write_zip([
409            ("manifest.json", manifest_bytes),
410            ("beam/workflow/order$bad.beam", vec![1, 2, 3]),
411        ])?;
412        let result = Package::load_from_bytes(bytes);
413
414        assert!(matches!(
415            result,
416            Err(PackageError::MalformedBeamEntry { entry }) if entry == "beam/workflow/order$bad.beam"
417        ));
418        Ok(())
419    }
420
421    #[test]
422    fn invalid_source_entry_returns_malformed_entry() -> Result<(), PackageError> {
423        let beams = sample_beams()?;
424        let mut manifest = sample_manifest();
425        manifest.version = ManifestVersion::new(content_hash(&beams).to_string());
426        let mut entries = vec![
427            (
428                "manifest.json".to_owned(),
429                serde_json::to_vec(&manifest)
430                    .map_err(|source| PackageError::ManifestSerialise { source })?,
431            ),
432            ("src/workflow/order.txt".to_owned(), b"source".to_vec()),
433        ];
434        entries.extend(beams.iter().map(|module| {
435            (
436                format!("beam/{}.beam", module.name()),
437                module.bytes().to_vec(),
438            )
439        }));
440        let result = Package::load_from_bytes(write_zip(entries)?);
441
442        assert!(matches!(
443            result,
444            Err(PackageError::MalformedBeamEntry { entry }) if entry == "src/workflow/order.txt"
445        ));
446        Ok(())
447    }
448
449    #[test]
450    fn missing_entry_module_returns_exact_variant_when_hash_matches() -> Result<(), PackageError> {
451        let beams = BeamSet::new(vec![BeamModule::new("workflow/support", vec![4, 5, 6])])?;
452        let mut manifest = sample_manifest();
453        manifest.version = ManifestVersion::new(content_hash(&beams).to_string());
454        let manifest_bytes = serde_json::to_vec(&manifest)
455            .map_err(|source| PackageError::ManifestSerialise { source })?;
456        let bytes = write_zip([
457            ("manifest.json", manifest_bytes),
458            ("beam/workflow/support.beam", vec![4, 5, 6]),
459        ])?;
460        let result = Package::load_from_bytes(bytes);
461
462        assert!(matches!(
463            result,
464            Err(PackageError::MissingEntryModule { module }) if module == "workflow/order"
465        ));
466        Ok(())
467    }
468
469    #[test]
470    fn builder_produced_package_loads_successfully() -> Result<(), PackageError> {
471        let bytes = PackageBuilder::with_source(
472            sample_manifest(),
473            sample_beams()?,
474            BTreeMap::from([(
475                "workflow/order".to_owned(),
476                b"pub fn run() { Nil }".to_vec(),
477            )]),
478        )
479        .write_to_bytes()?;
480
481        let package = Package::load_from_bytes(bytes)?;
482
483        assert_eq!(package.manifest().entry_module, "workflow/order");
484        assert_eq!(package.beams().len(), 2);
485        assert_eq!(
486            package.source().get("workflow/order"),
487            Some(&b"pub fn run() { Nil }".to_vec())
488        );
489        assert_eq!(
490            package.content_hash().to_string(),
491            package.manifest().version.as_str()
492        );
493        Ok(())
494    }
495
496    #[test]
497    fn load_from_path_loads_successfully() -> Result<(), Box<dyn std::error::Error>> {
498        let bytes = PackageBuilder::new(sample_manifest(), sample_beams()?).write_to_bytes()?;
499        let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
500        let path = std::env::temp_dir().join(format!("aion-package-{nanos}.aion"));
501        fs::write(&path, bytes)?;
502
503        let package_result = Package::load_from_path(&path);
504        let remove_result = fs::remove_file(&path);
505
506        let package = package_result?;
507        remove_result?;
508        assert_eq!(package.manifest().entry_module, "workflow/order");
509        Ok(())
510    }
511}