1use 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#[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 preserved_contract_bytes: Option<Vec<u8>>,
59}
60
61impl PackageBuilder {
62 #[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 #[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 #[must_use]
102 pub fn with_contract(mut self, contract: PackageContract) -> Self {
103 self.contract = Some(contract);
104 self
105 }
106
107 #[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 pub fn finalise_manifest(&self) -> Result<Manifest, PackageError> {
138 self.stamped_manifest()
139 }
140
141 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 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 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
268fn 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#[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 #[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 #[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 #[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}