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 extraction::ExtractionBudget,
18 hash::{has_contract_identity, verified_content_hash_with_contract},
19 namespace::deployed_name,
20 version::WorkflowVersion,
21};
22
23const MANIFEST_ENTRY: &str = "manifest.json";
24const CONTRACT_ENTRY: &str = "contract.json";
25const BEAM_PREFIX: &str = "beam/";
26const BEAM_SUFFIX: &str = ".beam";
27const SOURCE_PREFIX: &str = "src/";
28const SOURCE_SUFFIX: &str = ".gleam";
29const AWL_PREFIX: &str = "awl/";
30
31#[derive(Clone, Debug, PartialEq)]
37pub struct Package {
38 manifest: Manifest,
39 contract: Option<PackageContract>,
40 beams: BeamSet,
41 source: BTreeMap<String, Vec<u8>>,
42 awl: Option<AwlSource>,
43 content_hash: ContentHash,
44}
45
46struct ArchiveEntries {
49 beams: BeamSet,
50 source: BTreeMap<String, Vec<u8>>,
51 awl: Option<AwlSource>,
52}
53
54impl Package {
55 pub fn load_from_path(
66 path: impl AsRef<Path>,
67 limits: ExtractionLimits,
68 ) -> Result<Self, PackageError> {
69 let file =
70 File::open(path).map_err(|source| PackageError::ArchiveRead(ZipError::Io(source)))?;
71 Self::load_from_reader(file, limits)
72 }
73
74 pub fn load_from_bytes(
85 bytes: impl AsRef<[u8]>,
86 limits: ExtractionLimits,
87 ) -> Result<Self, PackageError> {
88 Self::load_from_reader(Cursor::new(bytes.as_ref()), limits)
89 }
90
91 fn load_from_reader<R>(reader: R, limits: ExtractionLimits) -> Result<Self, PackageError>
92 where
93 R: Read + Seek,
94 {
95 let mut archive = ZipArchive::new(reader).map_err(PackageError::ArchiveRead)?;
96 let mut budget = limits.budget();
97 let manifest = read_manifest(&mut archive, &mut budget)?;
98 manifest.check_format_version()?;
99 let contract = read_contract(&mut archive, &mut budget)?;
100
101 let entries = read_archive_entries(&mut archive, &mut budget)?;
102 let ArchiveEntries { beams, source, awl } = entries;
103 let content_hash =
104 verified_content_hash_with_contract(&beams, &manifest, contract.as_ref())?;
105
106 if beams.get(&manifest.entry_module).is_none() {
107 return Err(PackageError::MissingEntryModule {
108 module: manifest.entry_module.clone(),
109 });
110 }
111
112 Ok(Self {
113 manifest,
114 contract,
115 beams,
116 source,
117 awl,
118 content_hash,
119 })
120 }
121
122 #[must_use]
124 pub const fn manifest(&self) -> &Manifest {
125 &self.manifest
126 }
127
128 #[must_use]
130 pub const fn beams(&self) -> &BeamSet {
131 &self.beams
132 }
133
134 #[must_use]
136 pub const fn source(&self) -> &BTreeMap<String, Vec<u8>> {
137 &self.source
138 }
139
140 #[must_use]
150 pub const fn awl(&self) -> Option<&AwlSource> {
151 self.awl.as_ref()
152 }
153
154 #[must_use]
156 pub const fn content_hash(&self) -> &ContentHash {
157 &self.content_hash
158 }
159
160 pub fn contract(&self) -> Result<&PackageContract, ContractIdentityError> {
167 if has_contract_identity(
168 &self.beams,
169 &self.manifest,
170 self.contract.as_ref(),
171 &self.content_hash,
172 ) {
173 self.contract
174 .as_ref()
175 .ok_or_else(|| ContractIdentityError::RedeployRequired {
176 stored_version: self.content_hash.to_string(),
177 })
178 } else {
179 Err(ContractIdentityError::RedeployRequired {
180 stored_version: self.content_hash.to_string(),
181 })
182 }
183 }
184
185 #[must_use]
200 pub fn has_declared_timeout(&self) -> bool {
201 self.manifest.timeout.is_some()
202 && has_contract_identity(
203 &self.beams,
204 &self.manifest,
205 self.contract.as_ref(),
206 &self.content_hash,
207 )
208 }
209
210 #[must_use]
216 pub fn declared_timeout(&self) -> Option<std::time::Duration> {
217 self.declared_entry_timeout(self.manifest.timeout)
218 }
219
220 #[must_use]
232 pub fn declared_entry_timeout(
233 &self,
234 entry_timeout: Option<std::time::Duration>,
235 ) -> Option<std::time::Duration> {
236 if self.has_declared_timeout() {
237 entry_timeout
238 } else {
239 None
240 }
241 }
242
243 #[must_use]
245 pub fn version_record(&self) -> WorkflowVersion {
246 WorkflowVersion {
247 entry_module: self.manifest.entry_module.clone(),
248 content_hash: self.content_hash.clone(),
249 activities: self.manifest.activities.clone(),
250 input_schema: self.manifest.input_schema.clone(),
251 output_schema: self.manifest.output_schema.clone(),
252 }
253 }
254
255 #[must_use]
260 pub fn deployed_modules(&self) -> Vec<(String, &[u8])> {
261 self.beams
262 .iter()
263 .map(|module| {
264 (
265 deployed_name(module.name(), &self.content_hash),
266 module.bytes(),
267 )
268 })
269 .collect()
270 }
271
272 #[must_use]
274 pub fn deployed_entry_module(&self) -> String {
275 deployed_name(&self.manifest.entry_module, &self.content_hash)
276 }
277
278 pub fn to_archive_bytes(&self) -> Result<Vec<u8>, PackageError> {
295 let mut builder = crate::PackageBuilder::with_source(
296 self.manifest.clone(),
297 self.beams.clone(),
298 self.source.clone(),
299 );
300 if let Some(awl) = self.awl.clone() {
301 builder = builder.with_awl_source(awl);
302 }
303 builder
304 .preserving_loaded_identity(self.content_hash.clone(), self.contract.clone())
305 .write_to_bytes()
306 }
307
308 #[cfg(any(test, feature = "test-support"))]
309 #[doc(hidden)]
310 #[must_use]
311 pub fn from_validated_parts_for_test(
312 manifest: Manifest,
313 beams: BeamSet,
314 source: BTreeMap<String, Vec<u8>>,
315 content_hash: ContentHash,
316 ) -> Self {
317 Self {
318 manifest,
319 contract: None,
320 beams,
321 source,
322 awl: None,
323 content_hash,
324 }
325 }
326}
327
328fn read_manifest<R>(
329 archive: &mut ZipArchive<R>,
330 budget: &mut ExtractionBudget,
331) -> Result<Manifest, PackageError>
332where
333 R: Read + Seek,
334{
335 let mut manifest_file = match archive.by_name(MANIFEST_ENTRY) {
336 Ok(file) => file,
337 Err(ZipError::FileNotFound) => return Err(PackageError::MissingManifest),
338 Err(error) => return Err(PackageError::ArchiveRead(error)),
339 };
340
341 let manifest_bytes = budget.read_entry(&mut manifest_file)?;
342
343 serde_json::from_slice(&manifest_bytes).map_err(|source| PackageError::ManifestParse { source })
344}
345
346fn read_contract<R>(
347 archive: &mut ZipArchive<R>,
348 budget: &mut ExtractionBudget,
349) -> Result<Option<PackageContract>, PackageError>
350where
351 R: Read + Seek,
352{
353 let mut contract_file = match archive.by_name(CONTRACT_ENTRY) {
354 Ok(file) => file,
355 Err(ZipError::FileNotFound) => return Ok(None),
356 Err(error) => return Err(PackageError::ArchiveRead(error)),
357 };
358 let contract_bytes = budget.read_entry(&mut contract_file)?;
359 let contract = serde_json::from_slice(&contract_bytes)
360 .map_err(|source| PackageError::ContractParse { source })?;
361 Ok(Some(contract))
362}
363
364fn read_archive_entries<R>(
374 archive: &mut ZipArchive<R>,
375 budget: &mut ExtractionBudget,
376) -> Result<ArchiveEntries, PackageError>
377where
378 R: Read + Seek,
379{
380 let mut modules = Vec::new();
381 let mut source = BTreeMap::new();
382 let mut document: Option<(String, String)> = None;
383 let mut schemas = BTreeMap::new();
384
385 for index in 0..archive.len() {
386 let mut file = archive.by_index(index).map_err(PackageError::ArchiveRead)?;
387 if file.is_dir() {
388 continue;
389 }
390
391 let entry = file.name().to_owned();
392 if entry == MANIFEST_ENTRY || entry == CONTRACT_ENTRY {
393 continue;
394 }
395
396 if entry.starts_with(BEAM_PREFIX) {
397 let logical = logical_name_from_entry(&entry, BEAM_PREFIX, BEAM_SUFFIX)?;
398 let bytes = budget.read_entry(&mut file)?;
399 modules.push(BeamModule::new(logical, bytes));
400 } else if entry.starts_with(SOURCE_PREFIX) {
401 let logical = logical_name_from_entry(&entry, SOURCE_PREFIX, SOURCE_SUFFIX)?;
402 let bytes = budget.read_entry(&mut file)?;
403 if source.insert(logical, bytes).is_some() {
404 return Err(PackageError::MalformedBeamEntry { entry });
405 }
406 } else if let Some(name) = entry.strip_prefix(AWL_DOCUMENT_PREFIX) {
407 if name.contains('/') {
411 return Err(PackageError::MalformedAwlEntry { entry });
412 }
413 let name = awl_relative_path(&entry, name)?;
414 let bytes = budget.read_entry(&mut file)?;
415 let text =
416 String::from_utf8(bytes).map_err(|source| PackageError::AwlDocumentNotUtf8 {
417 entry: entry.clone(),
418 source,
419 })?;
420 if document.replace((name, text)).is_some() {
421 return Err(PackageError::MalformedAwlEntry { entry });
422 }
423 } else if let Some(path) = entry.strip_prefix(AWL_SCHEMA_PREFIX) {
424 let path = awl_relative_path(&entry, path)?;
425 let bytes = budget.read_entry(&mut file)?;
426 if schemas.insert(path, bytes).is_some() {
427 return Err(PackageError::MalformedAwlEntry { entry });
428 }
429 } else if entry.starts_with(AWL_PREFIX) {
430 return Err(PackageError::MalformedAwlEntry { entry });
431 }
432 }
433
434 let awl = match document {
435 Some((name, text)) => Some(AwlSource::new(name, text, schemas)),
436 None if schemas.is_empty() => None,
437 None => return Err(PackageError::MissingAwlDocument),
438 };
439
440 let beams = BeamSet::new(modules)?;
441 Ok(ArchiveEntries { beams, source, awl })
442}
443
444fn awl_relative_path(entry: &str, relative_path: &str) -> Result<String, PackageError> {
449 if is_safe_logical_name(relative_path) {
450 Ok(relative_path.to_owned())
451 } else {
452 Err(PackageError::MalformedAwlEntry {
453 entry: entry.to_owned(),
454 })
455 }
456}
457
458fn logical_name_from_entry(
459 entry: &str,
460 prefix: &str,
461 suffix: &str,
462) -> Result<String, PackageError> {
463 let Some(without_prefix) = entry.strip_prefix(prefix) else {
464 return Err(PackageError::MalformedBeamEntry {
465 entry: entry.to_owned(),
466 });
467 };
468 let Some(logical) = without_prefix.strip_suffix(suffix) else {
469 return Err(PackageError::MalformedBeamEntry {
470 entry: entry.to_owned(),
471 });
472 };
473
474 if is_safe_logical_name(logical) {
475 Ok(logical.to_owned())
476 } else {
477 Err(PackageError::MalformedBeamEntry {
478 entry: entry.to_owned(),
479 })
480 }
481}
482
483#[cfg(test)]
484#[path = "package_tests.rs"]
485mod tests;