1use std::{
4 collections::BTreeMap,
5 fs::File,
6 io::{Cursor, Read, Seek},
7 path::Path,
8};
9
10use zip::{ZipArchive, result::ZipError};
11
12use crate::{
13 BeamModule, BeamSet, ContentHash, Manifest, PackageError, builder::is_safe_logical_name,
14 content_hash, namespace::deployed_name, version::WorkflowVersion,
15};
16
17const MANIFEST_ENTRY: &str = "manifest.json";
18const BEAM_PREFIX: &str = "beam/";
19const BEAM_SUFFIX: &str = ".beam";
20const SOURCE_PREFIX: &str = "src/";
21const SOURCE_SUFFIX: &str = ".gleam";
22
23#[derive(Clone, Debug, PartialEq)]
29pub struct Package {
30 manifest: Manifest,
31 beams: BeamSet,
32 source: BTreeMap<String, Vec<u8>>,
33 content_hash: ContentHash,
34}
35
36impl Package {
37 pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, PackageError> {
45 let file =
46 File::open(path).map_err(|source| PackageError::ArchiveRead(ZipError::Io(source)))?;
47 Self::load_from_reader(file)
48 }
49
50 pub fn load_from_bytes(bytes: impl AsRef<[u8]>) -> Result<Self, PackageError> {
58 Self::load_from_reader(Cursor::new(bytes.as_ref()))
59 }
60
61 fn load_from_reader<R>(reader: R) -> Result<Self, PackageError>
62 where
63 R: Read + Seek,
64 {
65 let mut archive = ZipArchive::new(reader).map_err(PackageError::ArchiveRead)?;
66 let manifest = read_manifest(&mut archive)?;
67 manifest.check_format_version()?;
68
69 let (beams, source) = read_archive_entries(&mut archive)?;
70 let content_hash = content_hash(&beams);
71 let computed = content_hash.to_string();
72 if manifest.version.as_str() != computed {
73 return Err(PackageError::IntegrityMismatch {
74 expected: manifest.version.as_str().to_owned(),
75 computed,
76 });
77 }
78
79 if beams.get(&manifest.entry_module).is_none() {
80 return Err(PackageError::MissingEntryModule {
81 module: manifest.entry_module.clone(),
82 });
83 }
84
85 Ok(Self {
86 manifest,
87 beams,
88 source,
89 content_hash,
90 })
91 }
92
93 #[must_use]
95 pub const fn manifest(&self) -> &Manifest {
96 &self.manifest
97 }
98
99 #[must_use]
101 pub const fn beams(&self) -> &BeamSet {
102 &self.beams
103 }
104
105 #[must_use]
107 pub const fn source(&self) -> &BTreeMap<String, Vec<u8>> {
108 &self.source
109 }
110
111 #[must_use]
113 pub const fn content_hash(&self) -> &ContentHash {
114 &self.content_hash
115 }
116
117 #[must_use]
119 pub fn version_record(&self) -> WorkflowVersion {
120 WorkflowVersion {
121 entry_module: self.manifest.entry_module.clone(),
122 content_hash: self.content_hash.clone(),
123 activities: self.manifest.activities.clone(),
124 input_schema: self.manifest.input_schema.clone(),
125 output_schema: self.manifest.output_schema.clone(),
126 }
127 }
128
129 #[must_use]
134 pub fn deployed_modules(&self) -> Vec<(String, &[u8])> {
135 self.beams
136 .iter()
137 .map(|module| {
138 (
139 deployed_name(module.name(), &self.content_hash),
140 module.bytes(),
141 )
142 })
143 .collect()
144 }
145
146 #[must_use]
148 pub fn deployed_entry_module(&self) -> String {
149 deployed_name(&self.manifest.entry_module, &self.content_hash)
150 }
151
152 #[cfg(any(test, feature = "test-support"))]
153 #[doc(hidden)]
154 #[must_use]
155 pub fn from_validated_parts_for_test(
156 manifest: Manifest,
157 beams: BeamSet,
158 source: BTreeMap<String, Vec<u8>>,
159 content_hash: ContentHash,
160 ) -> Self {
161 Self {
162 manifest,
163 beams,
164 source,
165 content_hash,
166 }
167 }
168}
169
170fn read_manifest<R>(archive: &mut ZipArchive<R>) -> Result<Manifest, PackageError>
171where
172 R: Read + Seek,
173{
174 let mut manifest_file = match archive.by_name(MANIFEST_ENTRY) {
175 Ok(file) => file,
176 Err(ZipError::FileNotFound) => return Err(PackageError::MissingManifest),
177 Err(error) => return Err(PackageError::ArchiveRead(error)),
178 };
179
180 let mut manifest_bytes = Vec::new();
181 manifest_file
182 .read_to_end(&mut manifest_bytes)
183 .map_err(|source| PackageError::ArchiveRead(ZipError::Io(source)))?;
184
185 serde_json::from_slice(&manifest_bytes).map_err(|source| PackageError::ManifestParse { source })
186}
187
188fn read_archive_entries<R>(
189 archive: &mut ZipArchive<R>,
190) -> Result<(BeamSet, BTreeMap<String, Vec<u8>>), PackageError>
191where
192 R: Read + Seek,
193{
194 let mut modules = Vec::new();
195 let mut source = BTreeMap::new();
196
197 for index in 0..archive.len() {
198 let mut file = archive.by_index(index).map_err(PackageError::ArchiveRead)?;
199 if file.is_dir() {
200 continue;
201 }
202
203 let entry = file.name().to_owned();
204 if entry == MANIFEST_ENTRY {
205 continue;
206 }
207
208 if entry.starts_with(BEAM_PREFIX) {
209 let logical = logical_name_from_entry(&entry, BEAM_PREFIX, BEAM_SUFFIX)?;
210 let bytes = read_entry_bytes(&mut file)?;
211 modules.push(BeamModule::new(logical, bytes));
212 } else if entry.starts_with(SOURCE_PREFIX) {
213 let logical = logical_name_from_entry(&entry, SOURCE_PREFIX, SOURCE_SUFFIX)?;
214 let bytes = read_entry_bytes(&mut file)?;
215 if source.insert(logical, bytes).is_some() {
216 return Err(PackageError::MalformedBeamEntry { entry });
217 }
218 }
219 }
220
221 let beams = BeamSet::new(modules)?;
222 Ok((beams, source))
223}
224
225fn logical_name_from_entry(
226 entry: &str,
227 prefix: &str,
228 suffix: &str,
229) -> Result<String, PackageError> {
230 let Some(without_prefix) = entry.strip_prefix(prefix) else {
231 return Err(PackageError::MalformedBeamEntry {
232 entry: entry.to_owned(),
233 });
234 };
235 let Some(logical) = without_prefix.strip_suffix(suffix) else {
236 return Err(PackageError::MalformedBeamEntry {
237 entry: entry.to_owned(),
238 });
239 };
240
241 if is_safe_logical_name(logical) {
242 Ok(logical.to_owned())
243 } else {
244 Err(PackageError::MalformedBeamEntry {
245 entry: entry.to_owned(),
246 })
247 }
248}
249
250fn read_entry_bytes<R>(file: &mut zip::read::ZipFile<'_, R>) -> Result<Vec<u8>, PackageError>
251where
252 R: Read,
253{
254 let mut bytes = Vec::new();
255 file.read_to_end(&mut bytes)
256 .map_err(|source| PackageError::ArchiveRead(ZipError::Io(source)))?;
257 Ok(bytes)
258}
259
260#[cfg(test)]
261mod tests {
262 use std::{
263 collections::BTreeMap,
264 fs,
265 io::{Cursor, Write},
266 time::{Duration, SystemTime, UNIX_EPOCH},
267 };
268
269 use serde_json::json;
270 use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
271
272 use super::Package;
273 use crate::{
274 BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, Manifest, ManifestVersion,
275 PackageBuilder, PackageError, content_hash,
276 };
277
278 fn sample_manifest() -> Manifest {
279 Manifest {
280 entry_module: "workflow/order".to_owned(),
281 entry_function: "run".to_owned(),
282 input_schema: json!({ "type": "object" }),
283 output_schema: json!({ "type": "object" }),
284 timeout: Duration::from_secs(30),
285 activities: vec![DeclaredActivity {
286 activity_type: "charge_card".to_owned(),
287 }],
288 version: ManifestVersion::new("placeholder"),
289 format_version: CURRENT_FORMAT_VERSION,
290 }
291 }
292
293 fn sample_beams() -> Result<BeamSet, PackageError> {
294 BeamSet::new(vec![
295 BeamModule::new("workflow/support", vec![4, 5, 6]),
296 BeamModule::new("workflow/order", vec![1, 2, 3]),
297 ])
298 }
299
300 fn write_zip<I, N, B>(entries: I) -> Result<Vec<u8>, PackageError>
301 where
302 I: IntoIterator<Item = (N, B)>,
303 N: ToString,
304 B: AsRef<[u8]>,
305 {
306 let cursor = Cursor::new(Vec::new());
307 let mut archive = ZipWriter::new(cursor);
308 let options = SimpleFileOptions::default()
309 .compression_method(CompressionMethod::Stored)
310 .compression_level(None);
311
312 for (name, bytes) in entries {
313 archive
314 .start_file(name, options)
315 .map_err(PackageError::ArchiveWrite)?;
316 archive
317 .write_all(bytes.as_ref())
318 .map_err(|source| PackageError::ArchiveWriteIo { source })?;
319 }
320
321 let cursor = archive.finish().map_err(PackageError::ArchiveWrite)?;
322 Ok(cursor.into_inner())
323 }
324
325 fn archive_with_manifest(manifest: &Manifest) -> Result<Vec<u8>, PackageError> {
326 let manifest_bytes = serde_json::to_vec(manifest)
327 .map_err(|source| PackageError::ManifestSerialise { source })?;
328 write_zip([("manifest.json", manifest_bytes)])
329 }
330
331 #[test]
332 fn non_zip_input_returns_archive_read() {
333 let result = Package::load_from_bytes(b"not a zip archive");
334
335 assert!(matches!(result, Err(PackageError::ArchiveRead(_))));
336 }
337
338 #[test]
339 fn truncated_zip_input_returns_archive_read() -> Result<(), PackageError> {
340 let bytes = archive_with_manifest(&sample_manifest())?;
341 let truncated = &bytes[..bytes.len() / 2];
342 let result = Package::load_from_bytes(truncated);
343
344 assert!(matches!(result, Err(PackageError::ArchiveRead(_))));
345 Ok(())
346 }
347
348 #[test]
349 fn missing_manifest_returns_missing_manifest() -> Result<(), PackageError> {
350 let bytes = write_zip([("beam/workflow/order.beam", vec![1, 2, 3])])?;
351 let result = Package::load_from_bytes(bytes);
352
353 assert!(matches!(result, Err(PackageError::MissingManifest)));
354 Ok(())
355 }
356
357 #[test]
358 fn unparseable_manifest_returns_manifest_parse() -> Result<(), PackageError> {
359 let bytes = write_zip([("manifest.json", b"not-json".to_vec())])?;
360 let result = Package::load_from_bytes(bytes);
361
362 assert!(matches!(result, Err(PackageError::ManifestParse { .. })));
363 Ok(())
364 }
365
366 #[test]
367 fn unknown_format_version_returns_exact_variant() -> Result<(), PackageError> {
368 let mut manifest = sample_manifest();
369 manifest.format_version = CURRENT_FORMAT_VERSION + 99;
370 let bytes = archive_with_manifest(&manifest)?;
371 let result = Package::load_from_bytes(bytes);
372
373 assert!(matches!(
374 result,
375 Err(PackageError::UnknownFormatVersion { found }) if found == CURRENT_FORMAT_VERSION + 99
376 ));
377 Ok(())
378 }
379
380 #[test]
381 fn malformed_beam_entry_returns_exact_variant() -> Result<(), PackageError> {
382 let beams = sample_beams()?;
383 let mut manifest = sample_manifest();
384 manifest.version = ManifestVersion::new(content_hash(&beams).to_string());
385 let manifest_bytes = serde_json::to_vec(&manifest)
386 .map_err(|source| PackageError::ManifestSerialise { source })?;
387 let bytes = write_zip([
388 ("manifest.json", manifest_bytes),
389 ("beam/workflow/order.txt", vec![1, 2, 3]),
390 ])?;
391 let result = Package::load_from_bytes(bytes);
392
393 assert!(matches!(
394 result,
395 Err(PackageError::MalformedBeamEntry { entry }) if entry == "beam/workflow/order.txt"
396 ));
397 Ok(())
398 }
399
400 #[test]
401 fn beam_entry_with_deployed_name_separator_returns_malformed_entry() -> Result<(), PackageError>
402 {
403 let beams = sample_beams()?;
404 let mut manifest = sample_manifest();
405 manifest.version = ManifestVersion::new(content_hash(&beams).to_string());
406 let manifest_bytes = serde_json::to_vec(&manifest)
407 .map_err(|source| PackageError::ManifestSerialise { source })?;
408 let bytes = write_zip([
409 ("manifest.json", manifest_bytes),
410 ("beam/workflow/order$bad.beam", vec![1, 2, 3]),
411 ])?;
412 let result = Package::load_from_bytes(bytes);
413
414 assert!(matches!(
415 result,
416 Err(PackageError::MalformedBeamEntry { entry }) if entry == "beam/workflow/order$bad.beam"
417 ));
418 Ok(())
419 }
420
421 #[test]
422 fn invalid_source_entry_returns_malformed_entry() -> Result<(), PackageError> {
423 let beams = sample_beams()?;
424 let mut manifest = sample_manifest();
425 manifest.version = ManifestVersion::new(content_hash(&beams).to_string());
426 let mut entries = vec![
427 (
428 "manifest.json".to_owned(),
429 serde_json::to_vec(&manifest)
430 .map_err(|source| PackageError::ManifestSerialise { source })?,
431 ),
432 ("src/workflow/order.txt".to_owned(), b"source".to_vec()),
433 ];
434 entries.extend(beams.iter().map(|module| {
435 (
436 format!("beam/{}.beam", module.name()),
437 module.bytes().to_vec(),
438 )
439 }));
440 let result = Package::load_from_bytes(write_zip(entries)?);
441
442 assert!(matches!(
443 result,
444 Err(PackageError::MalformedBeamEntry { entry }) if entry == "src/workflow/order.txt"
445 ));
446 Ok(())
447 }
448
449 #[test]
450 fn missing_entry_module_returns_exact_variant_when_hash_matches() -> Result<(), PackageError> {
451 let beams = BeamSet::new(vec![BeamModule::new("workflow/support", vec![4, 5, 6])])?;
452 let mut manifest = sample_manifest();
453 manifest.version = ManifestVersion::new(content_hash(&beams).to_string());
454 let manifest_bytes = serde_json::to_vec(&manifest)
455 .map_err(|source| PackageError::ManifestSerialise { source })?;
456 let bytes = write_zip([
457 ("manifest.json", manifest_bytes),
458 ("beam/workflow/support.beam", vec![4, 5, 6]),
459 ])?;
460 let result = Package::load_from_bytes(bytes);
461
462 assert!(matches!(
463 result,
464 Err(PackageError::MissingEntryModule { module }) if module == "workflow/order"
465 ));
466 Ok(())
467 }
468
469 #[test]
470 fn builder_produced_package_loads_successfully() -> Result<(), PackageError> {
471 let bytes = PackageBuilder::with_source(
472 sample_manifest(),
473 sample_beams()?,
474 BTreeMap::from([(
475 "workflow/order".to_owned(),
476 b"pub fn run() { Nil }".to_vec(),
477 )]),
478 )
479 .write_to_bytes()?;
480
481 let package = Package::load_from_bytes(bytes)?;
482
483 assert_eq!(package.manifest().entry_module, "workflow/order");
484 assert_eq!(package.beams().len(), 2);
485 assert_eq!(
486 package.source().get("workflow/order"),
487 Some(&b"pub fn run() { Nil }".to_vec())
488 );
489 assert_eq!(
490 package.content_hash().to_string(),
491 package.manifest().version.as_str()
492 );
493 Ok(())
494 }
495
496 #[test]
497 fn load_from_path_loads_successfully() -> Result<(), Box<dyn std::error::Error>> {
498 let bytes = PackageBuilder::new(sample_manifest(), sample_beams()?).write_to_bytes()?;
499 let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
500 let path = std::env::temp_dir().join(format!("aion-package-{nanos}.aion"));
501 fs::write(&path, bytes)?;
502
503 let package_result = Package::load_from_path(&path);
504 let remove_result = fs::remove_file(&path);
505
506 let package = package_result?;
507 remove_result?;
508 assert_eq!(package.manifest().entry_module, "workflow/order");
509 Ok(())
510 }
511}