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