1use std::{
4 collections::BTreeMap,
5 fs::File,
6 io::{Cursor, Seek, Write},
7 path::Path,
8};
9
10use zip::{CompressionMethod, DateTime, ZipWriter, write::SimpleFileOptions};
11
12use crate::{
13 BeamSet, Manifest, ManifestVersion, PackageError, content_hash, content_hash_with_timeouts,
14};
15
16#[derive(Clone, Debug)]
18pub struct PackageBuilder {
19 manifest: Manifest,
20 beams: BeamSet,
21 source: BTreeMap<String, Vec<u8>>,
22 explicit_timeout_identity: bool,
23}
24
25impl PackageBuilder {
26 #[must_use]
28 pub fn new(manifest: Manifest, beams: BeamSet) -> Self {
29 Self {
30 manifest,
31 beams,
32 source: BTreeMap::new(),
33 explicit_timeout_identity: false,
34 }
35 }
36
37 #[must_use]
39 pub fn with_source<I, N, B>(manifest: Manifest, beams: BeamSet, source: I) -> Self
40 where
41 I: IntoIterator<Item = (N, B)>,
42 N: Into<String>,
43 B: Into<Vec<u8>>,
44 {
45 Self {
46 manifest,
47 beams,
48 source: source
49 .into_iter()
50 .map(|(name, bytes)| (name.into(), bytes.into()))
51 .collect(),
52 explicit_timeout_identity: false,
53 }
54 }
55
56 #[must_use]
58 pub const fn with_explicit_timeout_identity(mut self) -> Self {
59 self.explicit_timeout_identity = true;
60 self
61 }
62
63 pub fn finalise_manifest(&self) -> Result<Manifest, PackageError> {
70 self.stamped_manifest()
71 }
72
73 pub fn write_to_bytes(&self) -> Result<Vec<u8>, PackageError> {
80 let cursor = Cursor::new(Vec::new());
81 let manifest_bytes = self.manifest_bytes()?;
82 let cursor = self.write_archive(cursor, &manifest_bytes)?;
83 Ok(cursor.into_inner())
84 }
85
86 pub fn write_to_path(&self, path: impl AsRef<Path>) -> Result<(), PackageError> {
93 let manifest_bytes = self.manifest_bytes()?;
94 let file = File::create(path).map_err(|source| PackageError::ArchiveWriteIo { source })?;
95 self.write_archive(file, &manifest_bytes)?;
96 Ok(())
97 }
98
99 fn manifest_bytes(&self) -> Result<Vec<u8>, PackageError> {
100 let manifest = self.stamped_manifest()?;
101 serde_json::to_vec(&manifest).map_err(|source| PackageError::ManifestSerialise { source })
102 }
103
104 fn stamped_manifest(&self) -> Result<Manifest, PackageError> {
105 if self.beams.get(&self.manifest.entry_module).is_none() {
106 return Err(PackageError::MissingEntryModule {
107 module: self.manifest.entry_module.clone(),
108 });
109 }
110
111 let hash = if self.explicit_timeout_identity {
112 let any_declared = self.manifest.timeout.is_some()
116 || self
117 .manifest
118 .additional_workflows
119 .iter()
120 .any(|entry| entry.timeout.is_some());
121 if !any_declared {
122 return Err(PackageError::ExplicitTimeoutWithoutValue);
123 }
124 content_hash_with_timeouts(&self.beams, &self.manifest)
125 } else {
126 content_hash(&self.beams)
127 };
128 let mut manifest = self.manifest.clone();
129 manifest.version = ManifestVersion::new(hash.to_string());
130 Ok(manifest)
131 }
132
133 fn write_archive<W>(&self, writer: W, manifest_bytes: &[u8]) -> Result<W, PackageError>
134 where
135 W: Write + Seek,
136 {
137 let mut archive = ZipWriter::new(writer);
138 let options = deterministic_file_options();
139
140 write_entry(&mut archive, "manifest.json", manifest_bytes, options)?;
141
142 for module in self.beams.iter() {
143 let entry_name = archive_entry_name("beam", module.name(), "beam")?;
144 write_entry(&mut archive, entry_name, module.bytes(), options)?;
145 }
146
147 for (name, bytes) in &self.source {
148 let entry_name = archive_entry_name("src", name, "gleam")?;
149 write_entry(&mut archive, entry_name, bytes, options)?;
150 }
151
152 archive.finish().map_err(PackageError::ArchiveWrite)
153 }
154}
155
156fn deterministic_file_options() -> SimpleFileOptions {
157 SimpleFileOptions::default()
158 .compression_method(CompressionMethod::Stored)
159 .compression_level(None)
160 .last_modified_time(DateTime::DEFAULT)
161 .unix_permissions(0o644)
162}
163
164fn archive_entry_name(
165 prefix: &str,
166 logical_name: &str,
167 extension: &str,
168) -> Result<String, PackageError> {
169 if is_safe_logical_name(logical_name) {
170 Ok(format!("{prefix}/{logical_name}.{extension}"))
171 } else {
172 Err(PackageError::MalformedBeamEntry {
173 entry: logical_name.to_owned(),
174 })
175 }
176}
177
178#[must_use]
181pub fn is_safe_logical_name(logical_name: &str) -> bool {
182 !logical_name.is_empty()
183 && !logical_name.starts_with('/')
184 && !logical_name.starts_with('\\')
185 && !logical_name.contains('\\')
186 && !logical_name.contains(crate::namespace::DEPLOYED_NAME_SEPARATOR)
187 && logical_name
188 .split('/')
189 .all(|component| !component.is_empty() && component != "." && component != "..")
190}
191
192fn write_entry<W>(
193 archive: &mut ZipWriter<W>,
194 name: impl ToString,
195 bytes: &[u8],
196 options: SimpleFileOptions,
197) -> Result<(), PackageError>
198where
199 W: Write + Seek,
200{
201 archive
202 .start_file(name, options)
203 .map_err(PackageError::ArchiveWrite)?;
204 archive
205 .write_all(bytes)
206 .map_err(|source| PackageError::ArchiveWriteIo { source })
207}
208
209#[cfg(test)]
210mod tests {
211 use std::{collections::BTreeMap, io::Cursor, time::Duration};
212
213 use serde_json::json;
214 use zip::ZipArchive;
215
216 use super::PackageBuilder;
217 use crate::{
218 BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, Manifest, ManifestVersion,
219 PackageError, content_hash,
220 };
221
222 fn sample_manifest() -> Manifest {
223 Manifest {
224 entry_module: "workflow/order".to_owned(),
225 entry_function: "run".to_owned(),
226 input_schema: json!({ "type": "object" }),
227 output_schema: json!({ "type": "object" }),
228 timeout: Some(Duration::from_secs(30)),
229 activities: vec![DeclaredActivity {
230 activity_type: "charge_card".to_owned(),
231 }],
232 version: ManifestVersion::new("caller-supplied-version"),
233 format_version: CURRENT_FORMAT_VERSION,
234 additional_workflows: Vec::new(),
235 }
236 }
237
238 fn sample_beams() -> Result<BeamSet, PackageError> {
239 BeamSet::new(vec![
240 BeamModule::new("workflow/support", vec![4, 5, 6]),
241 BeamModule::new("workflow/order", vec![1, 2, 3]),
242 ])
243 }
244
245 #[test]
246 fn finalised_manifest_version_equals_beam_content_hash() -> Result<(), PackageError> {
247 let beams = sample_beams()?;
248 let expected = content_hash(&beams).to_string();
249 let manifest = PackageBuilder::new(sample_manifest(), beams).finalise_manifest()?;
250
251 assert_eq!(manifest.version.as_str(), expected);
252 Ok(())
253 }
254
255 #[test]
256 fn caller_supplied_manifest_version_is_overwritten() -> Result<(), PackageError> {
257 let beams = sample_beams()?;
258 let expected = content_hash(&beams).to_string();
259 let manifest = PackageBuilder::new(sample_manifest(), beams).finalise_manifest()?;
260
261 assert_ne!(manifest.version.as_str(), "caller-supplied-version");
262 assert_eq!(manifest.version.as_str(), expected);
263 Ok(())
264 }
265
266 #[test]
267 fn missing_entry_module_returns_typed_error() -> Result<(), PackageError> {
268 let beams = BeamSet::new(vec![BeamModule::new("workflow/other", vec![1])])?;
269 let result = PackageBuilder::new(sample_manifest(), beams).write_to_bytes();
270
271 assert!(matches!(
272 result,
273 Err(PackageError::MissingEntryModule { module }) if module == "workflow/order"
274 ));
275 Ok(())
276 }
277
278 #[test]
279 fn write_to_bytes_succeeds_without_source_entries() -> Result<(), PackageError> {
280 let bytes = PackageBuilder::new(sample_manifest(), sample_beams()?).write_to_bytes()?;
281 let mut archive = ZipArchive::new(Cursor::new(bytes)).map_err(PackageError::ArchiveRead)?;
282 let mut names = Vec::new();
283
284 for index in 0..archive.len() {
285 let file = archive.by_index(index).map_err(PackageError::ArchiveRead)?;
286 names.push(file.name().to_owned());
287 }
288
289 assert_eq!(
290 names,
291 vec![
292 "manifest.json",
293 "beam/workflow/order.beam",
294 "beam/workflow/support.beam",
295 ]
296 );
297 Ok(())
298 }
299
300 #[test]
301 fn identical_inputs_produce_identical_archive_bytes() -> Result<(), PackageError> {
302 let mut source = BTreeMap::new();
303 source.insert(
304 "workflow/order".to_owned(),
305 b"pub fn run() { Nil }".to_vec(),
306 );
307 let first = PackageBuilder::with_source(sample_manifest(), sample_beams()?, source.clone())
308 .write_to_bytes()?;
309 let second = PackageBuilder::with_source(sample_manifest(), sample_beams()?, source)
310 .write_to_bytes()?;
311
312 assert_eq!(first, second);
313 Ok(())
314 }
315
316 #[test]
317 fn source_inclusion_does_not_change_manifest_version() -> Result<(), PackageError> {
318 let mut source = BTreeMap::new();
319 source.insert(
320 "workflow/order".to_owned(),
321 b"pub fn run() { Nil }".to_vec(),
322 );
323 let without_source = PackageBuilder::new(sample_manifest(), sample_beams()?)
324 .finalise_manifest()?
325 .version;
326 let with_source = PackageBuilder::with_source(sample_manifest(), sample_beams()?, source)
327 .finalise_manifest()?
328 .version;
329
330 assert_eq!(without_source, with_source);
331 Ok(())
332 }
333
334 #[test]
335 fn rejects_unsafe_source_names() -> Result<(), PackageError> {
336 let mut source = BTreeMap::new();
337 source.insert("../escape".to_owned(), b"pub fn run() { Nil }".to_vec());
338
339 let result = PackageBuilder::with_source(sample_manifest(), sample_beams()?, source)
340 .write_to_bytes();
341
342 assert!(matches!(
343 result,
344 Err(PackageError::MalformedBeamEntry { entry }) if entry == "../escape"
345 ));
346 Ok(())
347 }
348
349 #[test]
350 fn rejects_logical_names_with_deployed_name_separator() -> Result<(), PackageError> {
351 let beams = BeamSet::new(vec![
352 BeamModule::new("workflow/order", vec![1, 2, 3]),
353 BeamModule::new("workflow/order$bad", vec![1]),
354 ])?;
355 let result = PackageBuilder::new(sample_manifest(), beams).write_to_bytes();
356
357 assert!(matches!(
358 result,
359 Err(PackageError::MalformedBeamEntry { entry }) if entry == "workflow/order$bad"
360 ));
361 Ok(())
362 }
363}