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_timeouts,
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            // At least one entry (the primary or an additional workflow) must
113            // declare a timeout for the explicit-timeout identity to mean
114            // anything; an all-absent opt-in is a caller mistake.
115            let any_declared = self.manifest.timeout.is_some()
116                || self
117                    .manifest
118                    .additional_workflows
119                    .iter()
120                    .any(|entry| entry.timeout.is_some());
121            if !any_declared {
122                return Err(PackageError::ExplicitTimeoutWithoutValue);
123            }
124            content_hash_with_timeouts(&self.beams, &self.manifest)
125        } else {
126            content_hash(&self.beams)
127        };
128        let mut manifest = self.manifest.clone();
129        manifest.version = ManifestVersion::new(hash.to_string());
130        Ok(manifest)
131    }
132
133    fn write_archive<W>(&self, writer: W, manifest_bytes: &[u8]) -> Result<W, PackageError>
134    where
135        W: Write + Seek,
136    {
137        let mut archive = ZipWriter::new(writer);
138        let options = deterministic_file_options();
139
140        write_entry(&mut archive, "manifest.json", manifest_bytes, options)?;
141
142        for module in self.beams.iter() {
143            let entry_name = archive_entry_name("beam", module.name(), "beam")?;
144            write_entry(&mut archive, entry_name, module.bytes(), options)?;
145        }
146
147        for (name, bytes) in &self.source {
148            let entry_name = archive_entry_name("src", name, "gleam")?;
149            write_entry(&mut archive, entry_name, bytes, options)?;
150        }
151
152        archive.finish().map_err(PackageError::ArchiveWrite)
153    }
154}
155
156fn deterministic_file_options() -> SimpleFileOptions {
157    SimpleFileOptions::default()
158        .compression_method(CompressionMethod::Stored)
159        .compression_level(None)
160        .last_modified_time(DateTime::DEFAULT)
161        .unix_permissions(0o644)
162}
163
164fn archive_entry_name(
165    prefix: &str,
166    logical_name: &str,
167    extension: &str,
168) -> Result<String, PackageError> {
169    if is_safe_logical_name(logical_name) {
170        Ok(format!("{prefix}/{logical_name}.{extension}"))
171    } else {
172        Err(PackageError::MalformedBeamEntry {
173            entry: logical_name.to_owned(),
174        })
175    }
176}
177
178/// Returns whether a logical module name can be represented safely as an
179/// archive entry path.
180#[must_use]
181pub fn is_safe_logical_name(logical_name: &str) -> bool {
182    !logical_name.is_empty()
183        && !logical_name.starts_with('/')
184        && !logical_name.starts_with('\\')
185        && !logical_name.contains('\\')
186        && !logical_name.contains(crate::namespace::DEPLOYED_NAME_SEPARATOR)
187        && logical_name
188            .split('/')
189            .all(|component| !component.is_empty() && component != "." && component != "..")
190}
191
192fn write_entry<W>(
193    archive: &mut ZipWriter<W>,
194    name: impl ToString,
195    bytes: &[u8],
196    options: SimpleFileOptions,
197) -> Result<(), PackageError>
198where
199    W: Write + Seek,
200{
201    archive
202        .start_file(name, options)
203        .map_err(PackageError::ArchiveWrite)?;
204    archive
205        .write_all(bytes)
206        .map_err(|source| PackageError::ArchiveWriteIo { source })
207}
208
209#[cfg(test)]
210mod tests {
211    use std::{collections::BTreeMap, io::Cursor, time::Duration};
212
213    use serde_json::json;
214    use zip::ZipArchive;
215
216    use super::PackageBuilder;
217    use crate::{
218        BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, Manifest, ManifestVersion,
219        PackageError, content_hash,
220    };
221
222    fn sample_manifest() -> Manifest {
223        Manifest {
224            entry_module: "workflow/order".to_owned(),
225            entry_function: "run".to_owned(),
226            input_schema: json!({ "type": "object" }),
227            output_schema: json!({ "type": "object" }),
228            timeout: Some(Duration::from_secs(30)),
229            activities: vec![DeclaredActivity {
230                activity_type: "charge_card".to_owned(),
231            }],
232            version: ManifestVersion::new("caller-supplied-version"),
233            format_version: CURRENT_FORMAT_VERSION,
234            additional_workflows: Vec::new(),
235        }
236    }
237
238    fn sample_beams() -> Result<BeamSet, PackageError> {
239        BeamSet::new(vec![
240            BeamModule::new("workflow/support", vec![4, 5, 6]),
241            BeamModule::new("workflow/order", vec![1, 2, 3]),
242        ])
243    }
244
245    #[test]
246    fn finalised_manifest_version_equals_beam_content_hash() -> Result<(), PackageError> {
247        let beams = sample_beams()?;
248        let expected = content_hash(&beams).to_string();
249        let manifest = PackageBuilder::new(sample_manifest(), beams).finalise_manifest()?;
250
251        assert_eq!(manifest.version.as_str(), expected);
252        Ok(())
253    }
254
255    #[test]
256    fn caller_supplied_manifest_version_is_overwritten() -> Result<(), PackageError> {
257        let beams = sample_beams()?;
258        let expected = content_hash(&beams).to_string();
259        let manifest = PackageBuilder::new(sample_manifest(), beams).finalise_manifest()?;
260
261        assert_ne!(manifest.version.as_str(), "caller-supplied-version");
262        assert_eq!(manifest.version.as_str(), expected);
263        Ok(())
264    }
265
266    #[test]
267    fn missing_entry_module_returns_typed_error() -> Result<(), PackageError> {
268        let beams = BeamSet::new(vec![BeamModule::new("workflow/other", vec![1])])?;
269        let result = PackageBuilder::new(sample_manifest(), beams).write_to_bytes();
270
271        assert!(matches!(
272            result,
273            Err(PackageError::MissingEntryModule { module }) if module == "workflow/order"
274        ));
275        Ok(())
276    }
277
278    #[test]
279    fn write_to_bytes_succeeds_without_source_entries() -> Result<(), PackageError> {
280        let bytes = PackageBuilder::new(sample_manifest(), sample_beams()?).write_to_bytes()?;
281        let mut archive = ZipArchive::new(Cursor::new(bytes)).map_err(PackageError::ArchiveRead)?;
282        let mut names = Vec::new();
283
284        for index in 0..archive.len() {
285            let file = archive.by_index(index).map_err(PackageError::ArchiveRead)?;
286            names.push(file.name().to_owned());
287        }
288
289        assert_eq!(
290            names,
291            vec![
292                "manifest.json",
293                "beam/workflow/order.beam",
294                "beam/workflow/support.beam",
295            ]
296        );
297        Ok(())
298    }
299
300    #[test]
301    fn identical_inputs_produce_identical_archive_bytes() -> Result<(), PackageError> {
302        let mut source = BTreeMap::new();
303        source.insert(
304            "workflow/order".to_owned(),
305            b"pub fn run() { Nil }".to_vec(),
306        );
307        let first = PackageBuilder::with_source(sample_manifest(), sample_beams()?, source.clone())
308            .write_to_bytes()?;
309        let second = PackageBuilder::with_source(sample_manifest(), sample_beams()?, source)
310            .write_to_bytes()?;
311
312        assert_eq!(first, second);
313        Ok(())
314    }
315
316    #[test]
317    fn source_inclusion_does_not_change_manifest_version() -> Result<(), PackageError> {
318        let mut source = BTreeMap::new();
319        source.insert(
320            "workflow/order".to_owned(),
321            b"pub fn run() { Nil }".to_vec(),
322        );
323        let without_source = PackageBuilder::new(sample_manifest(), sample_beams()?)
324            .finalise_manifest()?
325            .version;
326        let with_source = PackageBuilder::with_source(sample_manifest(), sample_beams()?, source)
327            .finalise_manifest()?
328            .version;
329
330        assert_eq!(without_source, with_source);
331        Ok(())
332    }
333
334    #[test]
335    fn rejects_unsafe_source_names() -> Result<(), PackageError> {
336        let mut source = BTreeMap::new();
337        source.insert("../escape".to_owned(), b"pub fn run() { Nil }".to_vec());
338
339        let result = PackageBuilder::with_source(sample_manifest(), sample_beams()?, source)
340            .write_to_bytes();
341
342        assert!(matches!(
343            result,
344            Err(PackageError::MalformedBeamEntry { entry }) if entry == "../escape"
345        ));
346        Ok(())
347    }
348
349    #[test]
350    fn rejects_logical_names_with_deployed_name_separator() -> Result<(), PackageError> {
351        let beams = BeamSet::new(vec![
352            BeamModule::new("workflow/order", vec![1, 2, 3]),
353            BeamModule::new("workflow/order$bad", vec![1]),
354        ])?;
355        let result = PackageBuilder::new(sample_manifest(), beams).write_to_bytes();
356
357        assert!(matches!(
358            result,
359            Err(PackageError::MalformedBeamEntry { entry }) if entry == "workflow/order$bad"
360        ));
361        Ok(())
362    }
363}