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}
54
55impl PackageBuilder {
56 #[must_use]
58 pub fn new(manifest: Manifest, beams: BeamSet) -> Self {
59 let contract = PackageContract::from_manifest(&manifest);
60 Self {
61 manifest,
62 beams,
63 source: BTreeMap::new(),
64 awl: None,
65 contract: Some(contract),
66 preserved_identity: None,
67 }
68 }
69
70 #[must_use]
72 pub fn with_source<I, N, B>(manifest: Manifest, beams: BeamSet, source: I) -> Self
73 where
74 I: IntoIterator<Item = (N, B)>,
75 N: Into<String>,
76 B: Into<Vec<u8>>,
77 {
78 let contract = PackageContract::from_manifest(&manifest);
79 Self {
80 manifest,
81 beams,
82 source: source
83 .into_iter()
84 .map(|(name, bytes)| (name.into(), bytes.into()))
85 .collect(),
86 awl: None,
87 contract: Some(contract),
88 preserved_identity: None,
89 }
90 }
91
92 #[must_use]
94 pub fn with_contract(mut self, contract: PackageContract) -> Self {
95 self.contract = Some(contract);
96 self
97 }
98
99 #[must_use]
106 pub fn with_awl_source(mut self, awl: AwlSource) -> Self {
107 self.awl = Some(awl);
108 self
109 }
110
111 pub(crate) fn preserving_loaded_identity(
112 mut self,
113 identity: ContentHash,
114 contract: Option<PackageContract>,
115 ) -> Self {
116 self.preserved_identity = Some(identity);
117 self.contract = contract;
118 self
119 }
120
121 pub fn finalise_manifest(&self) -> Result<Manifest, PackageError> {
128 self.stamped_manifest()
129 }
130
131 pub fn write_to_bytes(&self) -> Result<Vec<u8>, PackageError> {
138 let cursor = Cursor::new(Vec::new());
139 let (manifest_bytes, contract_bytes) = self.archive_metadata()?;
140 let cursor = self.write_archive(cursor, &manifest_bytes, contract_bytes.as_deref())?;
141 Ok(cursor.into_inner())
142 }
143
144 pub fn write_to_path(&self, path: impl AsRef<Path>) -> Result<(), PackageError> {
151 let (manifest_bytes, contract_bytes) = self.archive_metadata()?;
152 let file = File::create(path).map_err(|source| PackageError::ArchiveWriteIo { source })?;
153 self.write_archive(file, &manifest_bytes, contract_bytes.as_deref())?;
154 Ok(())
155 }
156
157 fn archive_metadata(&self) -> Result<(Vec<u8>, Option<Vec<u8>>), PackageError> {
158 let manifest = self.stamped_manifest()?;
159 let manifest_bytes = serde_json::to_vec(&manifest)
160 .map_err(|source| PackageError::ManifestSerialise { source })?;
161 let contract_bytes = self
162 .contract
163 .as_ref()
164 .map(serde_json::to_vec)
165 .transpose()
166 .map_err(|source| PackageError::ContractSerialise { source })?;
167 Ok((manifest_bytes, contract_bytes))
168 }
169
170 fn stamped_manifest(&self) -> Result<Manifest, PackageError> {
171 if self.beams.get(&self.manifest.entry_module).is_none() {
172 return Err(PackageError::MissingEntryModule {
173 module: self.manifest.entry_module.clone(),
174 });
175 }
176
177 let hash = match (&self.preserved_identity, &self.contract) {
178 (Some(identity), _) => identity.clone(),
179 (None, Some(contract)) => {
180 content_hash_with_contract(&self.beams, &self.manifest, contract)
181 }
182 (None, None) => return Err(PackageError::MissingContractRecord),
183 };
184 let mut manifest = self.manifest.clone();
185 manifest.version = ManifestVersion::new(hash.to_string());
186 Ok(manifest)
187 }
188
189 fn write_archive<W>(
190 &self,
191 writer: W,
192 manifest_bytes: &[u8],
193 contract_bytes: Option<&[u8]>,
194 ) -> Result<W, PackageError>
195 where
196 W: Write + Seek,
197 {
198 let mut archive = ZipWriter::new(writer);
199 let options = deterministic_file_options();
200
201 write_entry(&mut archive, "manifest.json", manifest_bytes, options)?;
202 if let Some(contract_bytes) = contract_bytes {
203 write_entry(&mut archive, CONTRACT_ENTRY, contract_bytes, options)?;
204 }
205
206 for module in self.beams.iter() {
207 let entry_name = archive_entry_name("beam", module.name(), "beam")?;
208 write_entry(&mut archive, entry_name, module.bytes(), options)?;
209 }
210
211 for (name, bytes) in &self.source {
212 let entry_name = archive_entry_name("src", name, "gleam")?;
213 write_entry(&mut archive, entry_name, bytes, options)?;
214 }
215
216 if let Some(awl) = &self.awl {
217 let entry_name = awl_entry_name(AWL_DOCUMENT_PREFIX, awl.document_name())?;
218 write_entry(&mut archive, entry_name, awl.document().as_bytes(), options)?;
219 for (path, bytes) in awl.schemas() {
220 let entry_name = awl_entry_name(AWL_SCHEMA_PREFIX, path)?;
221 write_entry(&mut archive, entry_name, bytes, options)?;
222 }
223 }
224
225 archive.finish().map_err(PackageError::ArchiveWrite)
226 }
227}
228
229fn deterministic_file_options() -> SimpleFileOptions {
230 SimpleFileOptions::default()
231 .compression_method(CompressionMethod::Stored)
232 .compression_level(None)
233 .last_modified_time(DateTime::DEFAULT)
234 .unix_permissions(0o644)
235}
236
237fn archive_entry_name(
238 prefix: &str,
239 logical_name: &str,
240 extension: &str,
241) -> Result<String, PackageError> {
242 if is_safe_logical_name(logical_name) {
243 Ok(format!("{prefix}/{logical_name}.{extension}"))
244 } else {
245 Err(PackageError::MalformedBeamEntry {
246 entry: logical_name.to_owned(),
247 })
248 }
249}
250
251fn awl_entry_name(prefix: &str, relative_path: &str) -> Result<String, PackageError> {
255 if is_safe_logical_name(relative_path) {
256 Ok(format!("{prefix}{relative_path}"))
257 } else {
258 Err(PackageError::MalformedAwlEntry {
259 entry: relative_path.to_owned(),
260 })
261 }
262}
263
264#[must_use]
267pub fn is_safe_logical_name(logical_name: &str) -> bool {
268 !logical_name.is_empty()
269 && !logical_name.starts_with('/')
270 && !logical_name.starts_with('\\')
271 && !logical_name.contains('\\')
272 && !logical_name.contains(crate::namespace::DEPLOYED_NAME_SEPARATOR)
273 && logical_name
274 .split('/')
275 .all(|component| !component.is_empty() && component != "." && component != "..")
276}
277
278fn write_entry<W>(
279 archive: &mut ZipWriter<W>,
280 name: impl ToString,
281 bytes: &[u8],
282 options: SimpleFileOptions,
283) -> Result<(), PackageError>
284where
285 W: Write + Seek,
286{
287 archive
288 .start_file(name, options)
289 .map_err(PackageError::ArchiveWrite)?;
290 archive
291 .write_all(bytes)
292 .map_err(|source| PackageError::ArchiveWriteIo { source })
293}
294
295#[cfg(test)]
296mod tests {
297 use std::{collections::BTreeMap, io::Cursor, time::Duration};
298
299 use serde_json::json;
300 use zip::ZipArchive;
301
302 use super::PackageBuilder;
303 use crate::{
304 AwlSource, BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, Manifest,
305 ManifestVersion, PackageContract, PackageError, content_hash_with_contract,
306 };
307
308 fn sample_manifest() -> Manifest {
309 Manifest {
310 entry_module: "workflow/order".to_owned(),
311 entry_function: "run".to_owned(),
312 input_schema: json!({ "type": "object" }),
313 output_schema: json!({ "type": "object" }),
314 timeout: Some(Duration::from_secs(30)),
315 activities: vec![DeclaredActivity {
316 activity_type: "charge_card".to_owned(),
317 }],
318 version: ManifestVersion::new("caller-supplied-version"),
319 format_version: CURRENT_FORMAT_VERSION,
320 additional_workflows: Vec::new(),
321 }
322 }
323
324 fn sample_beams() -> Result<BeamSet, PackageError> {
325 BeamSet::new(vec![
326 BeamModule::new("workflow/support", vec![4, 5, 6]),
327 BeamModule::new("workflow/order", vec![1, 2, 3]),
328 ])
329 }
330
331 #[test]
332 fn finalised_manifest_version_equals_v4_contract_hash() -> Result<(), PackageError> {
333 let beams = sample_beams()?;
334 let source_manifest = sample_manifest();
335 let contract = PackageContract::from_manifest(&source_manifest);
336 let expected = content_hash_with_contract(&beams, &source_manifest, &contract).to_string();
337 let manifest = PackageBuilder::new(source_manifest, beams).finalise_manifest()?;
338
339 assert_eq!(manifest.version.as_str(), expected);
340 Ok(())
341 }
342
343 #[test]
344 fn caller_supplied_manifest_version_is_overwritten() -> Result<(), PackageError> {
345 let beams = sample_beams()?;
346 let source_manifest = sample_manifest();
347 let contract = PackageContract::from_manifest(&source_manifest);
348 let expected = content_hash_with_contract(&beams, &source_manifest, &contract).to_string();
349 let manifest = PackageBuilder::new(source_manifest, beams).finalise_manifest()?;
350
351 assert_ne!(manifest.version.as_str(), "caller-supplied-version");
352 assert_eq!(manifest.version.as_str(), expected);
353 Ok(())
354 }
355
356 #[test]
357 fn missing_entry_module_returns_typed_error() -> Result<(), PackageError> {
358 let beams = BeamSet::new(vec![BeamModule::new("workflow/other", vec![1])])?;
359 let result = PackageBuilder::new(sample_manifest(), beams).write_to_bytes();
360
361 assert!(matches!(
362 result,
363 Err(PackageError::MissingEntryModule { module }) if module == "workflow/order"
364 ));
365 Ok(())
366 }
367
368 #[test]
369 fn write_to_bytes_succeeds_without_source_entries() -> Result<(), PackageError> {
370 let bytes = PackageBuilder::new(sample_manifest(), sample_beams()?).write_to_bytes()?;
371 let mut archive = ZipArchive::new(Cursor::new(bytes)).map_err(PackageError::ArchiveRead)?;
372 let mut names = Vec::new();
373
374 for index in 0..archive.len() {
375 let file = archive.by_index(index).map_err(PackageError::ArchiveRead)?;
376 names.push(file.name().to_owned());
377 }
378
379 assert_eq!(
380 names,
381 vec![
382 "manifest.json",
383 "contract.json",
384 "beam/workflow/order.beam",
385 "beam/workflow/support.beam",
386 ]
387 );
388 Ok(())
389 }
390
391 #[test]
392 fn identical_inputs_produce_identical_archive_bytes() -> Result<(), PackageError> {
393 let mut source = BTreeMap::new();
394 source.insert(
395 "workflow/order".to_owned(),
396 b"pub fn run() { Nil }".to_vec(),
397 );
398 let first = PackageBuilder::with_source(sample_manifest(), sample_beams()?, source.clone())
399 .write_to_bytes()?;
400 let second = PackageBuilder::with_source(sample_manifest(), sample_beams()?, source)
401 .write_to_bytes()?;
402
403 assert_eq!(first, second);
404 Ok(())
405 }
406
407 #[test]
408 fn source_inclusion_does_not_change_manifest_version() -> Result<(), PackageError> {
409 let mut source = BTreeMap::new();
410 source.insert(
411 "workflow/order".to_owned(),
412 b"pub fn run() { Nil }".to_vec(),
413 );
414 let without_source = PackageBuilder::new(sample_manifest(), sample_beams()?)
415 .finalise_manifest()?
416 .version;
417 let with_source = PackageBuilder::with_source(sample_manifest(), sample_beams()?, source)
418 .finalise_manifest()?
419 .version;
420
421 assert_eq!(without_source, with_source);
422 Ok(())
423 }
424
425 #[test]
434 fn awl_source_inclusion_does_not_change_manifest_version() -> Result<(), PackageError> {
435 let bare = PackageBuilder::new(sample_manifest(), sample_beams()?)
436 .finalise_manifest()?
437 .version;
438 let carried = PackageBuilder::new(sample_manifest(), sample_beams()?)
439 .with_awl_source(AwlSource::new(
440 "order.awl",
441 "workflow order\n",
442 std::iter::empty::<(String, Vec<u8>)>(),
443 ))
444 .finalise_manifest()?
445 .version;
446 let differing = PackageBuilder::new(sample_manifest(), sample_beams()?)
447 .with_awl_source(AwlSource::new(
448 "renamed.awl",
449 "workflow order\n timeout 6h\n",
450 [("ticket.schema.json", br#"{"type":"object"}"#.to_vec())],
451 ))
452 .finalise_manifest()?
453 .version;
454
455 assert_eq!(bare, carried, "carrying AWL source changed the version");
456 assert_eq!(
457 carried, differing,
458 "changing the archived AWL source changed the version"
459 );
460 Ok(())
461 }
462
463 #[test]
468 fn awl_entries_are_written_under_their_own_prefixes() -> Result<(), PackageError> {
469 let bytes = PackageBuilder::new(sample_manifest(), sample_beams()?)
470 .with_awl_source(AwlSource::new(
471 "dev_brief.awl",
472 "workflow dev_brief\n",
473 [
474 ("schemas/brief.schema.json", b"{}".to_vec()),
475 ("ticket.schema.json", b"{}".to_vec()),
476 ],
477 ))
478 .write_to_bytes()?;
479
480 let mut archive = ZipArchive::new(Cursor::new(bytes)).map_err(PackageError::ArchiveRead)?;
481 let mut names = Vec::new();
482 for index in 0..archive.len() {
483 let file = archive.by_index(index).map_err(PackageError::ArchiveRead)?;
484 names.push(file.name().to_owned());
485 }
486
487 assert_eq!(
488 names,
489 vec![
490 "manifest.json",
491 "contract.json",
492 "beam/workflow/order.beam",
493 "beam/workflow/support.beam",
494 "awl/document/dev_brief.awl",
495 "awl/schema/schemas/brief.schema.json",
496 "awl/schema/ticket.schema.json",
497 ]
498 );
499 Ok(())
500 }
501
502 #[test]
505 fn rejects_unsafe_awl_schema_paths() -> Result<(), PackageError> {
506 let result = PackageBuilder::new(sample_manifest(), sample_beams()?)
507 .with_awl_source(AwlSource::new(
508 "order.awl",
509 "workflow order\n",
510 [("../escape.json", b"{}".to_vec())],
511 ))
512 .write_to_bytes();
513
514 assert!(matches!(
515 result,
516 Err(PackageError::MalformedAwlEntry { entry }) if entry == "../escape.json"
517 ));
518 Ok(())
519 }
520
521 #[test]
522 fn rejects_unsafe_source_names() -> Result<(), PackageError> {
523 let mut source = BTreeMap::new();
524 source.insert("../escape".to_owned(), b"pub fn run() { Nil }".to_vec());
525
526 let result = PackageBuilder::with_source(sample_manifest(), sample_beams()?, source)
527 .write_to_bytes();
528
529 assert!(matches!(
530 result,
531 Err(PackageError::MalformedBeamEntry { entry }) if entry == "../escape"
532 ));
533 Ok(())
534 }
535
536 #[test]
537 fn rejects_logical_names_with_deployed_name_separator() -> Result<(), PackageError> {
538 let beams = BeamSet::new(vec![
539 BeamModule::new("workflow/order", vec![1, 2, 3]),
540 BeamModule::new("workflow/order$bad", vec![1]),
541 ])?;
542 let result = PackageBuilder::new(sample_manifest(), beams).write_to_bytes();
543
544 assert!(matches!(
545 result,
546 Err(PackageError::MalformedBeamEntry { entry }) if entry == "workflow/order$bad"
547 ));
548 Ok(())
549 }
550}