Skip to main content

aion_package/
builder.rs

1//! `PackageBuilder` deterministic write path.
2//!
3//! # Archive layout
4//!
5//! Entries are written in one fixed order, so identical inputs produce
6//! byte-identical archives:
7//!
8//! ```text
9//! manifest.json                the deployment descriptor
10//! contract.json                the durable worker contract, when one is bound
11//! beam/<module>.beam           the compiled beam closure, canonical order
12//! src/<module>.gleam           optional Gleam source
13//! awl/document/<filename>      optional authored AWL document
14//! awl/schema/<relative path>   the schema files that document imports
15//! ```
16//!
17//! Only `manifest.json`, `contract.json`, and the `beam/` closure carry the
18//! package's meaning to the engine. `src/` and `awl/` are PROVENANCE: they
19//! record what the package was made from. Neither participates in package
20//! identity — the content hash is computed over beams, routing/timeouts, and
21//! the contract alone (see [`crate::hash`]), so adding, changing, or removing
22//! archived source cannot change a package version. That non-participation is
23//! a declared property of this format, pinned by
24//! `source_inclusion_does_not_change_manifest_version` and
25//! `awl_source_inclusion_does_not_change_manifest_version` below.
26
27use std::{
28    collections::BTreeMap,
29    fs::File,
30    io::{Cursor, Seek, Write},
31    path::Path,
32};
33
34use zip::{CompressionMethod, DateTime, ZipWriter, write::SimpleFileOptions};
35
36use crate::{
37    AwlSource, BeamSet, ContentHash, Manifest, ManifestVersion, PackageContract, PackageError,
38    awl::{AWL_DOCUMENT_PREFIX, AWL_SCHEMA_PREFIX},
39    content_hash_with_contract,
40};
41
42const CONTRACT_ENTRY: &str = "contract.json";
43
44/// Deterministic writer for the `.aion` ZIP container format.
45#[derive(Clone, Debug)]
46pub struct PackageBuilder {
47    manifest: Manifest,
48    beams: BeamSet,
49    source: BTreeMap<String, Vec<u8>>,
50    awl: Option<AwlSource>,
51    contract: Option<PackageContract>,
52    preserved_identity: Option<ContentHash>,
53}
54
55impl PackageBuilder {
56    /// Creates a builder without source files.
57    #[must_use]
58    pub fn new(manifest: Manifest, beams: BeamSet) -> Self {
59        let contract = PackageContract::from_manifest(&manifest);
60        Self {
61            manifest,
62            beams,
63            source: BTreeMap::new(),
64            awl: None,
65            contract: Some(contract),
66            preserved_identity: None,
67        }
68    }
69
70    /// Creates a builder with optional source files keyed by logical module name.
71    #[must_use]
72    pub fn with_source<I, N, B>(manifest: Manifest, beams: BeamSet, source: I) -> Self
73    where
74        I: IntoIterator<Item = (N, B)>,
75        N: Into<String>,
76        B: Into<Vec<u8>>,
77    {
78        let contract = PackageContract::from_manifest(&manifest);
79        Self {
80            manifest,
81            beams,
82            source: source
83                .into_iter()
84                .map(|(name, bytes)| (name.into(), bytes.into()))
85                .collect(),
86            awl: None,
87            contract: Some(contract),
88            preserved_identity: None,
89        }
90    }
91
92    /// Replaces the manifest-derived record with the complete compiled contract.
93    #[must_use]
94    pub fn with_contract(mut self, contract: PackageContract) -> Self {
95        self.contract = Some(contract);
96        self
97    }
98
99    /// Archives the authored AWL document and its imported schema files as
100    /// package provenance.
101    ///
102    /// Additive: it touches neither the manifest nor the contract, so it is
103    /// safe in any order relative to [`Self::with_contract`], and it cannot
104    /// change the package version (see this module's archive-layout note).
105    #[must_use]
106    pub fn with_awl_source(mut self, awl: AwlSource) -> Self {
107        self.awl = Some(awl);
108        self
109    }
110
111    pub(crate) fn preserving_loaded_identity(
112        mut self,
113        identity: ContentHash,
114        contract: Option<PackageContract>,
115    ) -> Self {
116        self.preserved_identity = Some(identity);
117        self.contract = contract;
118        self
119    }
120
121    /// Returns the manifest after stamping the authoritative beam content hash.
122    ///
123    /// # Errors
124    ///
125    /// Returns [`PackageError::MissingEntryModule`] when the manifest entry module
126    /// is not present in the supplied beam set.
127    pub fn finalise_manifest(&self) -> Result<Manifest, PackageError> {
128        self.stamped_manifest()
129    }
130
131    /// Writes a deterministic `.aion` archive into memory.
132    ///
133    /// # Errors
134    ///
135    /// Returns [`PackageError`] variants for missing entry modules, manifest JSON
136    /// serialisation failures, ZIP writer failures, or target I/O failures.
137    pub fn write_to_bytes(&self) -> Result<Vec<u8>, PackageError> {
138        let cursor = Cursor::new(Vec::new());
139        let (manifest_bytes, contract_bytes) = self.archive_metadata()?;
140        let cursor = self.write_archive(cursor, &manifest_bytes, contract_bytes.as_deref())?;
141        Ok(cursor.into_inner())
142    }
143
144    /// Writes a deterministic `.aion` archive to the supplied filesystem path.
145    ///
146    /// # Errors
147    ///
148    /// Returns [`PackageError`] variants for missing entry modules, manifest JSON
149    /// serialisation failures, ZIP writer failures, or target I/O failures.
150    pub fn write_to_path(&self, path: impl AsRef<Path>) -> Result<(), PackageError> {
151        let (manifest_bytes, contract_bytes) = self.archive_metadata()?;
152        let file = File::create(path).map_err(|source| PackageError::ArchiveWriteIo { source })?;
153        self.write_archive(file, &manifest_bytes, contract_bytes.as_deref())?;
154        Ok(())
155    }
156
157    fn archive_metadata(&self) -> Result<(Vec<u8>, Option<Vec<u8>>), PackageError> {
158        let manifest = self.stamped_manifest()?;
159        let manifest_bytes = serde_json::to_vec(&manifest)
160            .map_err(|source| PackageError::ManifestSerialise { source })?;
161        let contract_bytes = self
162            .contract
163            .as_ref()
164            .map(serde_json::to_vec)
165            .transpose()
166            .map_err(|source| PackageError::ContractSerialise { source })?;
167        Ok((manifest_bytes, contract_bytes))
168    }
169
170    fn stamped_manifest(&self) -> Result<Manifest, PackageError> {
171        if self.beams.get(&self.manifest.entry_module).is_none() {
172            return Err(PackageError::MissingEntryModule {
173                module: self.manifest.entry_module.clone(),
174            });
175        }
176
177        let hash = match (&self.preserved_identity, &self.contract) {
178            (Some(identity), _) => identity.clone(),
179            (None, Some(contract)) => {
180                content_hash_with_contract(&self.beams, &self.manifest, contract)
181            }
182            (None, None) => return Err(PackageError::MissingContractRecord),
183        };
184        let mut manifest = self.manifest.clone();
185        manifest.version = ManifestVersion::new(hash.to_string());
186        Ok(manifest)
187    }
188
189    fn write_archive<W>(
190        &self,
191        writer: W,
192        manifest_bytes: &[u8],
193        contract_bytes: Option<&[u8]>,
194    ) -> Result<W, PackageError>
195    where
196        W: Write + Seek,
197    {
198        let mut archive = ZipWriter::new(writer);
199        let options = deterministic_file_options();
200
201        write_entry(&mut archive, "manifest.json", manifest_bytes, options)?;
202        if let Some(contract_bytes) = contract_bytes {
203            write_entry(&mut archive, CONTRACT_ENTRY, contract_bytes, options)?;
204        }
205
206        for module in self.beams.iter() {
207            let entry_name = archive_entry_name("beam", module.name(), "beam")?;
208            write_entry(&mut archive, entry_name, module.bytes(), options)?;
209        }
210
211        for (name, bytes) in &self.source {
212            let entry_name = archive_entry_name("src", name, "gleam")?;
213            write_entry(&mut archive, entry_name, bytes, options)?;
214        }
215
216        if let Some(awl) = &self.awl {
217            let entry_name = awl_entry_name(AWL_DOCUMENT_PREFIX, awl.document_name())?;
218            write_entry(&mut archive, entry_name, awl.document().as_bytes(), options)?;
219            for (path, bytes) in awl.schemas() {
220                let entry_name = awl_entry_name(AWL_SCHEMA_PREFIX, path)?;
221                write_entry(&mut archive, entry_name, bytes, options)?;
222            }
223        }
224
225        archive.finish().map_err(PackageError::ArchiveWrite)
226    }
227}
228
229fn deterministic_file_options() -> SimpleFileOptions {
230    SimpleFileOptions::default()
231        .compression_method(CompressionMethod::Stored)
232        .compression_level(None)
233        .last_modified_time(DateTime::DEFAULT)
234        .unix_permissions(0o644)
235}
236
237fn archive_entry_name(
238    prefix: &str,
239    logical_name: &str,
240    extension: &str,
241) -> Result<String, PackageError> {
242    if is_safe_logical_name(logical_name) {
243        Ok(format!("{prefix}/{logical_name}.{extension}"))
244    } else {
245        Err(PackageError::MalformedBeamEntry {
246            entry: logical_name.to_owned(),
247        })
248    }
249}
250
251/// Builds an `awl/` entry name, keeping the supplied relative path verbatim —
252/// extension and nesting included — because a consumer must be able to stage
253/// the file back at exactly the path the document names.
254fn awl_entry_name(prefix: &str, relative_path: &str) -> Result<String, PackageError> {
255    if is_safe_logical_name(relative_path) {
256        Ok(format!("{prefix}{relative_path}"))
257    } else {
258        Err(PackageError::MalformedAwlEntry {
259            entry: relative_path.to_owned(),
260        })
261    }
262}
263
264/// Returns whether a logical module name can be represented safely as an
265/// archive entry path.
266#[must_use]
267pub fn is_safe_logical_name(logical_name: &str) -> bool {
268    !logical_name.is_empty()
269        && !logical_name.starts_with('/')
270        && !logical_name.starts_with('\\')
271        && !logical_name.contains('\\')
272        && !logical_name.contains(crate::namespace::DEPLOYED_NAME_SEPARATOR)
273        && logical_name
274            .split('/')
275            .all(|component| !component.is_empty() && component != "." && component != "..")
276}
277
278fn write_entry<W>(
279    archive: &mut ZipWriter<W>,
280    name: impl ToString,
281    bytes: &[u8],
282    options: SimpleFileOptions,
283) -> Result<(), PackageError>
284where
285    W: Write + Seek,
286{
287    archive
288        .start_file(name, options)
289        .map_err(PackageError::ArchiveWrite)?;
290    archive
291        .write_all(bytes)
292        .map_err(|source| PackageError::ArchiveWriteIo { source })
293}
294
295#[cfg(test)]
296mod tests {
297    use std::{collections::BTreeMap, io::Cursor, time::Duration};
298
299    use serde_json::json;
300    use zip::ZipArchive;
301
302    use super::PackageBuilder;
303    use crate::{
304        AwlSource, BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, Manifest,
305        ManifestVersion, PackageContract, PackageError, content_hash_with_contract,
306    };
307
308    fn sample_manifest() -> Manifest {
309        Manifest {
310            entry_module: "workflow/order".to_owned(),
311            entry_function: "run".to_owned(),
312            input_schema: json!({ "type": "object" }),
313            output_schema: json!({ "type": "object" }),
314            timeout: Some(Duration::from_secs(30)),
315            activities: vec![DeclaredActivity {
316                activity_type: "charge_card".to_owned(),
317            }],
318            version: ManifestVersion::new("caller-supplied-version"),
319            format_version: CURRENT_FORMAT_VERSION,
320            additional_workflows: Vec::new(),
321        }
322    }
323
324    fn sample_beams() -> Result<BeamSet, PackageError> {
325        BeamSet::new(vec![
326            BeamModule::new("workflow/support", vec![4, 5, 6]),
327            BeamModule::new("workflow/order", vec![1, 2, 3]),
328        ])
329    }
330
331    #[test]
332    fn finalised_manifest_version_equals_v4_contract_hash() -> Result<(), PackageError> {
333        let beams = sample_beams()?;
334        let source_manifest = sample_manifest();
335        let contract = PackageContract::from_manifest(&source_manifest);
336        let expected = content_hash_with_contract(&beams, &source_manifest, &contract).to_string();
337        let manifest = PackageBuilder::new(source_manifest, beams).finalise_manifest()?;
338
339        assert_eq!(manifest.version.as_str(), expected);
340        Ok(())
341    }
342
343    #[test]
344    fn caller_supplied_manifest_version_is_overwritten() -> Result<(), PackageError> {
345        let beams = sample_beams()?;
346        let source_manifest = sample_manifest();
347        let contract = PackageContract::from_manifest(&source_manifest);
348        let expected = content_hash_with_contract(&beams, &source_manifest, &contract).to_string();
349        let manifest = PackageBuilder::new(source_manifest, beams).finalise_manifest()?;
350
351        assert_ne!(manifest.version.as_str(), "caller-supplied-version");
352        assert_eq!(manifest.version.as_str(), expected);
353        Ok(())
354    }
355
356    #[test]
357    fn missing_entry_module_returns_typed_error() -> Result<(), PackageError> {
358        let beams = BeamSet::new(vec![BeamModule::new("workflow/other", vec![1])])?;
359        let result = PackageBuilder::new(sample_manifest(), beams).write_to_bytes();
360
361        assert!(matches!(
362            result,
363            Err(PackageError::MissingEntryModule { module }) if module == "workflow/order"
364        ));
365        Ok(())
366    }
367
368    #[test]
369    fn write_to_bytes_succeeds_without_source_entries() -> Result<(), PackageError> {
370        let bytes = PackageBuilder::new(sample_manifest(), sample_beams()?).write_to_bytes()?;
371        let mut archive = ZipArchive::new(Cursor::new(bytes)).map_err(PackageError::ArchiveRead)?;
372        let mut names = Vec::new();
373
374        for index in 0..archive.len() {
375            let file = archive.by_index(index).map_err(PackageError::ArchiveRead)?;
376            names.push(file.name().to_owned());
377        }
378
379        assert_eq!(
380            names,
381            vec![
382                "manifest.json",
383                "contract.json",
384                "beam/workflow/order.beam",
385                "beam/workflow/support.beam",
386            ]
387        );
388        Ok(())
389    }
390
391    #[test]
392    fn identical_inputs_produce_identical_archive_bytes() -> Result<(), PackageError> {
393        let mut source = BTreeMap::new();
394        source.insert(
395            "workflow/order".to_owned(),
396            b"pub fn run() { Nil }".to_vec(),
397        );
398        let first = PackageBuilder::with_source(sample_manifest(), sample_beams()?, source.clone())
399            .write_to_bytes()?;
400        let second = PackageBuilder::with_source(sample_manifest(), sample_beams()?, source)
401            .write_to_bytes()?;
402
403        assert_eq!(first, second);
404        Ok(())
405    }
406
407    #[test]
408    fn source_inclusion_does_not_change_manifest_version() -> Result<(), PackageError> {
409        let mut source = BTreeMap::new();
410        source.insert(
411            "workflow/order".to_owned(),
412            b"pub fn run() { Nil }".to_vec(),
413        );
414        let without_source = PackageBuilder::new(sample_manifest(), sample_beams()?)
415            .finalise_manifest()?
416            .version;
417        let with_source = PackageBuilder::with_source(sample_manifest(), sample_beams()?, source)
418            .finalise_manifest()?
419            .version;
420
421        assert_eq!(without_source, with_source);
422        Ok(())
423    }
424
425    /// THE RIDER, pinned: archived AWL source is provenance and is DECLARED
426    /// unbound from package identity. Three packages — one with no AWL source,
427    /// one carrying a document, and one carrying a DIFFERENT document under a
428    /// different filename with different schemas — all hash identically,
429    /// because none of those bytes reach any identity encoding in
430    /// [`crate::hash`]. If this ever fails, a workflow's version would change
431    /// when only a comment in its source changed, and every run pinned to the
432    /// old version would stop resolving.
433    #[test]
434    fn awl_source_inclusion_does_not_change_manifest_version() -> Result<(), PackageError> {
435        let bare = PackageBuilder::new(sample_manifest(), sample_beams()?)
436            .finalise_manifest()?
437            .version;
438        let carried = PackageBuilder::new(sample_manifest(), sample_beams()?)
439            .with_awl_source(AwlSource::new(
440                "order.awl",
441                "workflow order\n",
442                std::iter::empty::<(String, Vec<u8>)>(),
443            ))
444            .finalise_manifest()?
445            .version;
446        let differing = PackageBuilder::new(sample_manifest(), sample_beams()?)
447            .with_awl_source(AwlSource::new(
448                "renamed.awl",
449                "workflow order\n  timeout 6h\n",
450                [("ticket.schema.json", br#"{"type":"object"}"#.to_vec())],
451            ))
452            .finalise_manifest()?
453            .version;
454
455        assert_eq!(bare, carried, "carrying AWL source changed the version");
456        assert_eq!(
457            carried, differing,
458            "changing the archived AWL source changed the version"
459        );
460        Ok(())
461    }
462
463    /// The archived layout, pinned by name and order: the AWL families sit
464    /// under their own prefix, keep the document's original filename and each
465    /// schema's document-relative path verbatim, and are written last so the
466    /// archive stays byte-deterministic.
467    #[test]
468    fn awl_entries_are_written_under_their_own_prefixes() -> Result<(), PackageError> {
469        let bytes = PackageBuilder::new(sample_manifest(), sample_beams()?)
470            .with_awl_source(AwlSource::new(
471                "dev_brief.awl",
472                "workflow dev_brief\n",
473                [
474                    ("schemas/brief.schema.json", b"{}".to_vec()),
475                    ("ticket.schema.json", b"{}".to_vec()),
476                ],
477            ))
478            .write_to_bytes()?;
479
480        let mut archive = ZipArchive::new(Cursor::new(bytes)).map_err(PackageError::ArchiveRead)?;
481        let mut names = Vec::new();
482        for index in 0..archive.len() {
483            let file = archive.by_index(index).map_err(PackageError::ArchiveRead)?;
484            names.push(file.name().to_owned());
485        }
486
487        assert_eq!(
488            names,
489            vec![
490                "manifest.json",
491                "contract.json",
492                "beam/workflow/order.beam",
493                "beam/workflow/support.beam",
494                "awl/document/dev_brief.awl",
495                "awl/schema/schemas/brief.schema.json",
496                "awl/schema/ticket.schema.json",
497            ]
498        );
499        Ok(())
500    }
501
502    /// A schema path that could escape its archive root cannot be written: the
503    /// same guard that protects module names protects provenance paths.
504    #[test]
505    fn rejects_unsafe_awl_schema_paths() -> Result<(), PackageError> {
506        let result = PackageBuilder::new(sample_manifest(), sample_beams()?)
507            .with_awl_source(AwlSource::new(
508                "order.awl",
509                "workflow order\n",
510                [("../escape.json", b"{}".to_vec())],
511            ))
512            .write_to_bytes();
513
514        assert!(matches!(
515            result,
516            Err(PackageError::MalformedAwlEntry { entry }) if entry == "../escape.json"
517        ));
518        Ok(())
519    }
520
521    #[test]
522    fn rejects_unsafe_source_names() -> Result<(), PackageError> {
523        let mut source = BTreeMap::new();
524        source.insert("../escape".to_owned(), b"pub fn run() { Nil }".to_vec());
525
526        let result = PackageBuilder::with_source(sample_manifest(), sample_beams()?, source)
527            .write_to_bytes();
528
529        assert!(matches!(
530            result,
531            Err(PackageError::MalformedBeamEntry { entry }) if entry == "../escape"
532        ));
533        Ok(())
534    }
535
536    #[test]
537    fn rejects_logical_names_with_deployed_name_separator() -> Result<(), PackageError> {
538        let beams = BeamSet::new(vec![
539            BeamModule::new("workflow/order", vec![1, 2, 3]),
540            BeamModule::new("workflow/order$bad", vec![1]),
541        ])?;
542        let result = PackageBuilder::new(sample_manifest(), beams).write_to_bytes();
543
544        assert!(matches!(
545            result,
546            Err(PackageError::MalformedBeamEntry { entry }) if entry == "workflow/order$bad"
547        ));
548        Ok(())
549    }
550}