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 AwlSource, BeamModule, BeamSet, ContentHash, ContractIdentityError, ExtractionLimits, Manifest,
14 PackageContract, PackageError,
15 awl::{AWL_DOCUMENT_PREFIX, AWL_SCHEMA_PREFIX},
16 builder::is_safe_logical_name,
17 declared_command::{PriorCommandIdentities, prior_command_identities},
18 extraction::ExtractionBudget,
19 hash::{has_contract_identity, verified_content_hash_with_contract},
20 namespace::deployed_name,
21 version::WorkflowVersion,
22};
23
24const MANIFEST_ENTRY: &str = "manifest.json";
25const CONTRACT_ENTRY: &str = "contract.json";
26const BEAM_PREFIX: &str = "beam/";
27const BEAM_SUFFIX: &str = ".beam";
28const SOURCE_PREFIX: &str = "src/";
29const SOURCE_SUFFIX: &str = ".gleam";
30const AWL_PREFIX: &str = "awl/";
31
32#[derive(Clone, Debug, PartialEq)]
38pub struct Package {
39 manifest: Manifest,
40 contract: Option<PackageContract>,
41 contract_raw: Option<Vec<u8>>,
49 prior_commands: PriorCommandIdentities,
55 beams: BeamSet,
56 source: BTreeMap<String, Vec<u8>>,
57 awl: Option<AwlSource>,
58 content_hash: ContentHash,
59}
60
61struct ArchiveEntries {
64 beams: BeamSet,
65 source: BTreeMap<String, Vec<u8>>,
66 awl: Option<AwlSource>,
67}
68
69impl Package {
70 pub fn load_from_path(
81 path: impl AsRef<Path>,
82 limits: ExtractionLimits,
83 ) -> Result<Self, PackageError> {
84 let file =
85 File::open(path).map_err(|source| PackageError::ArchiveRead(ZipError::Io(source)))?;
86 Self::load_from_reader(file, limits)
87 }
88
89 pub fn load_from_bytes(
100 bytes: impl AsRef<[u8]>,
101 limits: ExtractionLimits,
102 ) -> Result<Self, PackageError> {
103 Self::load_from_reader(Cursor::new(bytes.as_ref()), limits)
104 }
105
106 fn load_from_reader<R>(reader: R, limits: ExtractionLimits) -> Result<Self, PackageError>
107 where
108 R: Read + Seek,
109 {
110 let mut archive = ZipArchive::new(reader).map_err(PackageError::ArchiveRead)?;
111 let mut budget = limits.budget();
112 let manifest = read_manifest(&mut archive, &mut budget)?;
113 manifest.check_format_version()?;
114 let (contract, contract_raw) = match read_contract(&mut archive, &mut budget)? {
115 Some((contract, raw)) => (Some(contract), Some(raw)),
116 None => (None, None),
117 };
118 let prior_commands = match &contract_raw {
123 Some(raw) => prior_command_identities(raw).map_err(|error| {
124 PackageError::ContractPriorFormMalformed {
125 detail: error.to_string(),
126 }
127 })?,
128 None => PriorCommandIdentities::new(),
129 };
130
131 let entries = read_archive_entries(&mut archive, &mut budget)?;
132 let ArchiveEntries { beams, source, awl } = entries;
133 let content_hash = verified_content_hash_with_contract(
134 &beams,
135 &manifest,
136 contract.as_ref(),
137 &prior_commands,
138 )?;
139
140 if beams.get(&manifest.entry_module).is_none() {
141 return Err(PackageError::MissingEntryModule {
142 module: manifest.entry_module.clone(),
143 });
144 }
145
146 Ok(Self {
147 manifest,
148 contract,
149 contract_raw,
150 prior_commands,
151 beams,
152 source,
153 awl,
154 content_hash,
155 })
156 }
157
158 #[must_use]
160 pub const fn manifest(&self) -> &Manifest {
161 &self.manifest
162 }
163
164 #[must_use]
166 pub const fn beams(&self) -> &BeamSet {
167 &self.beams
168 }
169
170 #[must_use]
172 pub const fn source(&self) -> &BTreeMap<String, Vec<u8>> {
173 &self.source
174 }
175
176 #[must_use]
186 pub const fn awl(&self) -> Option<&AwlSource> {
187 self.awl.as_ref()
188 }
189
190 #[must_use]
192 pub const fn content_hash(&self) -> &ContentHash {
193 &self.content_hash
194 }
195
196 pub fn contract(&self) -> Result<&PackageContract, ContractIdentityError> {
203 if has_contract_identity(
204 &self.beams,
205 &self.manifest,
206 self.contract.as_ref(),
207 &self.prior_commands,
208 &self.content_hash,
209 ) {
210 self.contract
211 .as_ref()
212 .ok_or_else(|| ContractIdentityError::RedeployRequired {
213 stored_version: self.content_hash.to_string(),
214 })
215 } else {
216 Err(ContractIdentityError::RedeployRequired {
217 stored_version: self.content_hash.to_string(),
218 })
219 }
220 }
221
222 #[must_use]
237 pub fn has_declared_timeout(&self) -> bool {
238 self.manifest.timeout.is_some()
239 && has_contract_identity(
240 &self.beams,
241 &self.manifest,
242 self.contract.as_ref(),
243 &self.prior_commands,
244 &self.content_hash,
245 )
246 }
247
248 #[must_use]
254 pub fn declared_timeout(&self) -> Option<std::time::Duration> {
255 self.declared_entry_timeout(self.manifest.timeout)
256 }
257
258 #[must_use]
270 pub fn declared_entry_timeout(
271 &self,
272 entry_timeout: Option<std::time::Duration>,
273 ) -> Option<std::time::Duration> {
274 if self.has_declared_timeout() {
275 entry_timeout
276 } else {
277 None
278 }
279 }
280
281 #[must_use]
283 pub fn version_record(&self) -> WorkflowVersion {
284 WorkflowVersion {
285 entry_module: self.manifest.entry_module.clone(),
286 content_hash: self.content_hash.clone(),
287 activities: self.manifest.activities.clone(),
288 input_schema: self.manifest.input_schema.clone(),
289 output_schema: self.manifest.output_schema.clone(),
290 }
291 }
292
293 #[must_use]
298 pub fn deployed_modules(&self) -> Vec<(String, &[u8])> {
299 self.beams
300 .iter()
301 .map(|module| {
302 (
303 deployed_name(module.name(), &self.content_hash),
304 module.bytes(),
305 )
306 })
307 .collect()
308 }
309
310 #[must_use]
312 pub fn deployed_entry_module(&self) -> String {
313 deployed_name(&self.manifest.entry_module, &self.content_hash)
314 }
315
316 pub fn to_archive_bytes(&self) -> Result<Vec<u8>, PackageError> {
333 let mut builder = crate::PackageBuilder::with_source(
334 self.manifest.clone(),
335 self.beams.clone(),
336 self.source.clone(),
337 );
338 if let Some(awl) = self.awl.clone() {
339 builder = builder.with_awl_source(awl);
340 }
341 builder
342 .preserving_loaded_identity(
343 self.content_hash.clone(),
344 self.contract.clone(),
345 self.contract_raw.clone(),
346 )
347 .write_to_bytes()
348 }
349
350 #[cfg(any(test, feature = "test-support"))]
351 #[doc(hidden)]
352 #[must_use]
353 pub fn from_validated_parts_for_test(
354 manifest: Manifest,
355 beams: BeamSet,
356 source: BTreeMap<String, Vec<u8>>,
357 content_hash: ContentHash,
358 ) -> Self {
359 Self {
360 manifest,
361 contract: None,
362 contract_raw: None,
363 prior_commands: PriorCommandIdentities::new(),
364 beams,
365 source,
366 awl: None,
367 content_hash,
368 }
369 }
370}
371
372fn read_manifest<R>(
373 archive: &mut ZipArchive<R>,
374 budget: &mut ExtractionBudget,
375) -> Result<Manifest, PackageError>
376where
377 R: Read + Seek,
378{
379 let mut manifest_file = match archive.by_name(MANIFEST_ENTRY) {
380 Ok(file) => file,
381 Err(ZipError::FileNotFound) => return Err(PackageError::MissingManifest),
382 Err(error) => return Err(PackageError::ArchiveRead(error)),
383 };
384
385 let manifest_bytes = budget.read_entry(&mut manifest_file)?;
386
387 serde_json::from_slice(&manifest_bytes).map_err(|source| PackageError::ManifestParse { source })
388}
389
390fn read_contract<R>(
391 archive: &mut ZipArchive<R>,
392 budget: &mut ExtractionBudget,
393) -> Result<Option<(PackageContract, Vec<u8>)>, PackageError>
394where
395 R: Read + Seek,
396{
397 let mut contract_file = match archive.by_name(CONTRACT_ENTRY) {
398 Ok(file) => file,
399 Err(ZipError::FileNotFound) => return Ok(None),
400 Err(error) => return Err(PackageError::ArchiveRead(error)),
401 };
402 let contract_bytes = budget.read_entry(&mut contract_file)?;
403 let contract = serde_json::from_slice(&contract_bytes)
404 .map_err(|source| PackageError::ContractParse { source })?;
405 Ok(Some((contract, contract_bytes)))
406}
407
408fn read_archive_entries<R>(
418 archive: &mut ZipArchive<R>,
419 budget: &mut ExtractionBudget,
420) -> Result<ArchiveEntries, PackageError>
421where
422 R: Read + Seek,
423{
424 let mut modules = Vec::new();
425 let mut source = BTreeMap::new();
426 let mut document: Option<(String, String)> = None;
427 let mut schemas = BTreeMap::new();
428
429 for index in 0..archive.len() {
430 let mut file = archive.by_index(index).map_err(PackageError::ArchiveRead)?;
431 if file.is_dir() {
432 continue;
433 }
434
435 let entry = file.name().to_owned();
436 if entry == MANIFEST_ENTRY || entry == CONTRACT_ENTRY {
437 continue;
438 }
439
440 if entry.starts_with(BEAM_PREFIX) {
441 let logical = logical_name_from_entry(&entry, BEAM_PREFIX, BEAM_SUFFIX)?;
442 let bytes = budget.read_entry(&mut file)?;
443 modules.push(BeamModule::new(logical, bytes));
444 } else if entry.starts_with(SOURCE_PREFIX) {
445 let logical = logical_name_from_entry(&entry, SOURCE_PREFIX, SOURCE_SUFFIX)?;
446 let bytes = budget.read_entry(&mut file)?;
447 if source.insert(logical, bytes).is_some() {
448 return Err(PackageError::MalformedBeamEntry { entry });
449 }
450 } else if let Some(name) = entry.strip_prefix(AWL_DOCUMENT_PREFIX) {
451 if name.contains('/') {
455 return Err(PackageError::MalformedAwlEntry { entry });
456 }
457 let name = awl_relative_path(&entry, name)?;
458 let bytes = budget.read_entry(&mut file)?;
459 let text =
460 String::from_utf8(bytes).map_err(|source| PackageError::AwlDocumentNotUtf8 {
461 entry: entry.clone(),
462 source,
463 })?;
464 if document.replace((name, text)).is_some() {
465 return Err(PackageError::MalformedAwlEntry { entry });
466 }
467 } else if let Some(path) = entry.strip_prefix(AWL_SCHEMA_PREFIX) {
468 let path = awl_relative_path(&entry, path)?;
469 let bytes = budget.read_entry(&mut file)?;
470 if schemas.insert(path, bytes).is_some() {
471 return Err(PackageError::MalformedAwlEntry { entry });
472 }
473 } else if entry.starts_with(AWL_PREFIX) {
474 return Err(PackageError::MalformedAwlEntry { entry });
475 }
476 }
477
478 let awl = match document {
479 Some((name, text)) => Some(AwlSource::new(name, text, schemas)),
480 None if schemas.is_empty() => None,
481 None => return Err(PackageError::MissingAwlDocument),
482 };
483
484 let beams = BeamSet::new(modules)?;
485 Ok(ArchiveEntries { beams, source, awl })
486}
487
488fn awl_relative_path(entry: &str, relative_path: &str) -> Result<String, PackageError> {
493 if is_safe_logical_name(relative_path) {
494 Ok(relative_path.to_owned())
495 } else {
496 Err(PackageError::MalformedAwlEntry {
497 entry: entry.to_owned(),
498 })
499 }
500}
501
502fn logical_name_from_entry(
503 entry: &str,
504 prefix: &str,
505 suffix: &str,
506) -> Result<String, PackageError> {
507 let Some(without_prefix) = entry.strip_prefix(prefix) else {
508 return Err(PackageError::MalformedBeamEntry {
509 entry: entry.to_owned(),
510 });
511 };
512 let Some(logical) = without_prefix.strip_suffix(suffix) else {
513 return Err(PackageError::MalformedBeamEntry {
514 entry: entry.to_owned(),
515 });
516 };
517
518 if is_safe_logical_name(logical) {
519 Ok(logical.to_owned())
520 } else {
521 Err(PackageError::MalformedBeamEntry {
522 entry: entry.to_owned(),
523 })
524 }
525}
526
527#[cfg(test)]
528#[path = "package_tests.rs"]
529mod tests;