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