Skip to main content

aion_package/
builder.rs

1//! `PackageBuilder` deterministic write path.
2
3use std::{
4    collections::BTreeMap,
5    fs::File,
6    io::{Cursor, Seek, Write},
7    path::Path,
8};
9
10use zip::{CompressionMethod, DateTime, ZipWriter, write::SimpleFileOptions};
11
12use crate::{
13    BeamSet, Manifest, ManifestVersion, PackageError, content_hash, content_hash_with_timeout,
14};
15
16/// Deterministic writer for the `.aion` ZIP container format.
17#[derive(Clone, Debug)]
18pub struct PackageBuilder {
19    manifest: Manifest,
20    beams: BeamSet,
21    source: BTreeMap<String, Vec<u8>>,
22    explicit_timeout_identity: bool,
23}
24
25impl PackageBuilder {
26    /// Creates a builder without source files.
27    #[must_use]
28    pub fn new(manifest: Manifest, beams: BeamSet) -> Self {
29        Self {
30            manifest,
31            beams,
32            source: BTreeMap::new(),
33            explicit_timeout_identity: false,
34        }
35    }
36
37    /// Creates a builder with optional source files keyed by logical module name.
38    #[must_use]
39    pub fn with_source<I, N, B>(manifest: Manifest, beams: BeamSet, source: I) -> Self
40    where
41        I: IntoIterator<Item = (N, B)>,
42        N: Into<String>,
43        B: Into<Vec<u8>>,
44    {
45        Self {
46            manifest,
47            beams,
48            source: source
49                .into_iter()
50                .map(|(name, bytes)| (name.into(), bytes.into()))
51                .collect(),
52            explicit_timeout_identity: false,
53        }
54    }
55
56    /// Includes the manifest workflow timeout in the package version identity.
57    #[must_use]
58    pub const fn with_explicit_timeout_identity(mut self) -> Self {
59        self.explicit_timeout_identity = true;
60        self
61    }
62
63    /// Returns the manifest after stamping the authoritative beam content hash.
64    ///
65    /// # Errors
66    ///
67    /// Returns [`PackageError::MissingEntryModule`] when the manifest entry module
68    /// is not present in the supplied beam set.
69    pub fn finalise_manifest(&self) -> Result<Manifest, PackageError> {
70        self.stamped_manifest()
71    }
72
73    /// Writes a deterministic `.aion` archive into memory.
74    ///
75    /// # Errors
76    ///
77    /// Returns [`PackageError`] variants for missing entry modules, manifest JSON
78    /// serialisation failures, ZIP writer failures, or target I/O failures.
79    pub fn write_to_bytes(&self) -> Result<Vec<u8>, PackageError> {
80        let cursor = Cursor::new(Vec::new());
81        let manifest_bytes = self.manifest_bytes()?;
82        let cursor = self.write_archive(cursor, &manifest_bytes)?;
83        Ok(cursor.into_inner())
84    }
85
86    /// Writes a deterministic `.aion` archive to the supplied filesystem path.
87    ///
88    /// # Errors
89    ///
90    /// Returns [`PackageError`] variants for missing entry modules, manifest JSON
91    /// serialisation failures, ZIP writer failures, or target I/O failures.
92    pub fn write_to_path(&self, path: impl AsRef<Path>) -> Result<(), PackageError> {
93        let manifest_bytes = self.manifest_bytes()?;
94        let file = File::create(path).map_err(|source| PackageError::ArchiveWriteIo { source })?;
95        self.write_archive(file, &manifest_bytes)?;
96        Ok(())
97    }
98
99    fn manifest_bytes(&self) -> Result<Vec<u8>, PackageError> {
100        let manifest = self.stamped_manifest()?;
101        serde_json::to_vec(&manifest).map_err(|source| PackageError::ManifestSerialise { source })
102    }
103
104    fn stamped_manifest(&self) -> Result<Manifest, PackageError> {
105        if self.beams.get(&self.manifest.entry_module).is_none() {
106            return Err(PackageError::MissingEntryModule {
107                module: self.manifest.entry_module.clone(),
108            });
109        }
110
111        let hash = if self.explicit_timeout_identity {
112            content_hash_with_timeout(&self.beams, self.manifest.timeout)
113        } else {
114            content_hash(&self.beams)
115        };
116        let mut manifest = self.manifest.clone();
117        manifest.version = ManifestVersion::new(hash.to_string());
118        Ok(manifest)
119    }
120
121    fn write_archive<W>(&self, writer: W, manifest_bytes: &[u8]) -> Result<W, PackageError>
122    where
123        W: Write + Seek,
124    {
125        let mut archive = ZipWriter::new(writer);
126        let options = deterministic_file_options();
127
128        write_entry(&mut archive, "manifest.json", manifest_bytes, options)?;
129
130        for module in self.beams.iter() {
131            let entry_name = archive_entry_name("beam", module.name(), "beam")?;
132            write_entry(&mut archive, entry_name, module.bytes(), options)?;
133        }
134
135        for (name, bytes) in &self.source {
136            let entry_name = archive_entry_name("src", name, "gleam")?;
137            write_entry(&mut archive, entry_name, bytes, options)?;
138        }
139
140        archive.finish().map_err(PackageError::ArchiveWrite)
141    }
142}
143
144fn deterministic_file_options() -> SimpleFileOptions {
145    SimpleFileOptions::default()
146        .compression_method(CompressionMethod::Stored)
147        .compression_level(None)
148        .last_modified_time(DateTime::DEFAULT)
149        .unix_permissions(0o644)
150}
151
152fn archive_entry_name(
153    prefix: &str,
154    logical_name: &str,
155    extension: &str,
156) -> Result<String, PackageError> {
157    if is_safe_logical_name(logical_name) {
158        Ok(format!("{prefix}/{logical_name}.{extension}"))
159    } else {
160        Err(PackageError::MalformedBeamEntry {
161            entry: logical_name.to_owned(),
162        })
163    }
164}
165
166/// Returns whether a logical module name can be represented safely as an
167/// archive entry path.
168#[must_use]
169pub fn is_safe_logical_name(logical_name: &str) -> bool {
170    !logical_name.is_empty()
171        && !logical_name.starts_with('/')
172        && !logical_name.starts_with('\\')
173        && !logical_name.contains('\\')
174        && !logical_name.contains(crate::namespace::DEPLOYED_NAME_SEPARATOR)
175        && logical_name
176            .split('/')
177            .all(|component| !component.is_empty() && component != "." && component != "..")
178}
179
180fn write_entry<W>(
181    archive: &mut ZipWriter<W>,
182    name: impl ToString,
183    bytes: &[u8],
184    options: SimpleFileOptions,
185) -> Result<(), PackageError>
186where
187    W: Write + Seek,
188{
189    archive
190        .start_file(name, options)
191        .map_err(PackageError::ArchiveWrite)?;
192    archive
193        .write_all(bytes)
194        .map_err(|source| PackageError::ArchiveWriteIo { source })
195}
196
197#[cfg(test)]
198mod tests {
199    use std::{collections::BTreeMap, io::Cursor, time::Duration};
200
201    use serde_json::json;
202    use zip::ZipArchive;
203
204    use super::PackageBuilder;
205    use crate::{
206        BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, Manifest, ManifestVersion,
207        PackageError, content_hash,
208    };
209
210    fn sample_manifest() -> Manifest {
211        Manifest {
212            entry_module: "workflow/order".to_owned(),
213            entry_function: "run".to_owned(),
214            input_schema: json!({ "type": "object" }),
215            output_schema: json!({ "type": "object" }),
216            timeout: Duration::from_secs(30),
217            activities: vec![DeclaredActivity {
218                activity_type: "charge_card".to_owned(),
219            }],
220            version: ManifestVersion::new("caller-supplied-version"),
221            format_version: CURRENT_FORMAT_VERSION,
222            additional_workflows: Vec::new(),
223        }
224    }
225
226    fn sample_beams() -> Result<BeamSet, PackageError> {
227        BeamSet::new(vec![
228            BeamModule::new("workflow/support", vec![4, 5, 6]),
229            BeamModule::new("workflow/order", vec![1, 2, 3]),
230        ])
231    }
232
233    #[test]
234    fn finalised_manifest_version_equals_beam_content_hash() -> Result<(), PackageError> {
235        let beams = sample_beams()?;
236        let expected = content_hash(&beams).to_string();
237        let manifest = PackageBuilder::new(sample_manifest(), beams).finalise_manifest()?;
238
239        assert_eq!(manifest.version.as_str(), expected);
240        Ok(())
241    }
242
243    #[test]
244    fn caller_supplied_manifest_version_is_overwritten() -> Result<(), PackageError> {
245        let beams = sample_beams()?;
246        let expected = content_hash(&beams).to_string();
247        let manifest = PackageBuilder::new(sample_manifest(), beams).finalise_manifest()?;
248
249        assert_ne!(manifest.version.as_str(), "caller-supplied-version");
250        assert_eq!(manifest.version.as_str(), expected);
251        Ok(())
252    }
253
254    #[test]
255    fn missing_entry_module_returns_typed_error() -> Result<(), PackageError> {
256        let beams = BeamSet::new(vec![BeamModule::new("workflow/other", vec![1])])?;
257        let result = PackageBuilder::new(sample_manifest(), beams).write_to_bytes();
258
259        assert!(matches!(
260            result,
261            Err(PackageError::MissingEntryModule { module }) if module == "workflow/order"
262        ));
263        Ok(())
264    }
265
266    #[test]
267    fn write_to_bytes_succeeds_without_source_entries() -> Result<(), PackageError> {
268        let bytes = PackageBuilder::new(sample_manifest(), sample_beams()?).write_to_bytes()?;
269        let mut archive = ZipArchive::new(Cursor::new(bytes)).map_err(PackageError::ArchiveRead)?;
270        let mut names = Vec::new();
271
272        for index in 0..archive.len() {
273            let file = archive.by_index(index).map_err(PackageError::ArchiveRead)?;
274            names.push(file.name().to_owned());
275        }
276
277        assert_eq!(
278            names,
279            vec![
280                "manifest.json",
281                "beam/workflow/order.beam",
282                "beam/workflow/support.beam",
283            ]
284        );
285        Ok(())
286    }
287
288    #[test]
289    fn identical_inputs_produce_identical_archive_bytes() -> Result<(), PackageError> {
290        let mut source = BTreeMap::new();
291        source.insert(
292            "workflow/order".to_owned(),
293            b"pub fn run() { Nil }".to_vec(),
294        );
295        let first = PackageBuilder::with_source(sample_manifest(), sample_beams()?, source.clone())
296            .write_to_bytes()?;
297        let second = PackageBuilder::with_source(sample_manifest(), sample_beams()?, source)
298            .write_to_bytes()?;
299
300        assert_eq!(first, second);
301        Ok(())
302    }
303
304    #[test]
305    fn source_inclusion_does_not_change_manifest_version() -> Result<(), PackageError> {
306        let mut source = BTreeMap::new();
307        source.insert(
308            "workflow/order".to_owned(),
309            b"pub fn run() { Nil }".to_vec(),
310        );
311        let without_source = PackageBuilder::new(sample_manifest(), sample_beams()?)
312            .finalise_manifest()?
313            .version;
314        let with_source = PackageBuilder::with_source(sample_manifest(), sample_beams()?, source)
315            .finalise_manifest()?
316            .version;
317
318        assert_eq!(without_source, with_source);
319        Ok(())
320    }
321
322    #[test]
323    fn rejects_unsafe_source_names() -> Result<(), PackageError> {
324        let mut source = BTreeMap::new();
325        source.insert("../escape".to_owned(), b"pub fn run() { Nil }".to_vec());
326
327        let result = PackageBuilder::with_source(sample_manifest(), sample_beams()?, source)
328            .write_to_bytes();
329
330        assert!(matches!(
331            result,
332            Err(PackageError::MalformedBeamEntry { entry }) if entry == "../escape"
333        ));
334        Ok(())
335    }
336
337    #[test]
338    fn rejects_logical_names_with_deployed_name_separator() -> Result<(), PackageError> {
339        let beams = BeamSet::new(vec![
340            BeamModule::new("workflow/order", vec![1, 2, 3]),
341            BeamModule::new("workflow/order$bad", vec![1]),
342        ])?;
343        let result = PackageBuilder::new(sample_manifest(), beams).write_to_bytes();
344
345        assert!(matches!(
346            result,
347            Err(PackageError::MalformedBeamEntry { entry }) if entry == "workflow/order$bad"
348        ));
349        Ok(())
350    }
351}