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_timeout,
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 content_hash_with_timeout(&self.beams, self.manifest.timeout)
113 } else {
114 content_hash(&self.beams)
115 };
116 let mut manifest = self.manifest.clone();
117 manifest.version = ManifestVersion::new(hash.to_string());
118 Ok(manifest)
119 }
120
121 fn write_archive<W>(&self, writer: W, manifest_bytes: &[u8]) -> Result<W, PackageError>
122 where
123 W: Write + Seek,
124 {
125 let mut archive = ZipWriter::new(writer);
126 let options = deterministic_file_options();
127
128 write_entry(&mut archive, "manifest.json", manifest_bytes, options)?;
129
130 for module in self.beams.iter() {
131 let entry_name = archive_entry_name("beam", module.name(), "beam")?;
132 write_entry(&mut archive, entry_name, module.bytes(), options)?;
133 }
134
135 for (name, bytes) in &self.source {
136 let entry_name = archive_entry_name("src", name, "gleam")?;
137 write_entry(&mut archive, entry_name, bytes, options)?;
138 }
139
140 archive.finish().map_err(PackageError::ArchiveWrite)
141 }
142}
143
144fn deterministic_file_options() -> SimpleFileOptions {
145 SimpleFileOptions::default()
146 .compression_method(CompressionMethod::Stored)
147 .compression_level(None)
148 .last_modified_time(DateTime::DEFAULT)
149 .unix_permissions(0o644)
150}
151
152fn archive_entry_name(
153 prefix: &str,
154 logical_name: &str,
155 extension: &str,
156) -> Result<String, PackageError> {
157 if is_safe_logical_name(logical_name) {
158 Ok(format!("{prefix}/{logical_name}.{extension}"))
159 } else {
160 Err(PackageError::MalformedBeamEntry {
161 entry: logical_name.to_owned(),
162 })
163 }
164}
165
166#[must_use]
169pub fn is_safe_logical_name(logical_name: &str) -> bool {
170 !logical_name.is_empty()
171 && !logical_name.starts_with('/')
172 && !logical_name.starts_with('\\')
173 && !logical_name.contains('\\')
174 && !logical_name.contains(crate::namespace::DEPLOYED_NAME_SEPARATOR)
175 && logical_name
176 .split('/')
177 .all(|component| !component.is_empty() && component != "." && component != "..")
178}
179
180fn write_entry<W>(
181 archive: &mut ZipWriter<W>,
182 name: impl ToString,
183 bytes: &[u8],
184 options: SimpleFileOptions,
185) -> Result<(), PackageError>
186where
187 W: Write + Seek,
188{
189 archive
190 .start_file(name, options)
191 .map_err(PackageError::ArchiveWrite)?;
192 archive
193 .write_all(bytes)
194 .map_err(|source| PackageError::ArchiveWriteIo { source })
195}
196
197#[cfg(test)]
198mod tests {
199 use std::{collections::BTreeMap, io::Cursor, time::Duration};
200
201 use serde_json::json;
202 use zip::ZipArchive;
203
204 use super::PackageBuilder;
205 use crate::{
206 BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, Manifest, ManifestVersion,
207 PackageError, content_hash,
208 };
209
210 fn sample_manifest() -> Manifest {
211 Manifest {
212 entry_module: "workflow/order".to_owned(),
213 entry_function: "run".to_owned(),
214 input_schema: json!({ "type": "object" }),
215 output_schema: json!({ "type": "object" }),
216 timeout: Duration::from_secs(30),
217 activities: vec![DeclaredActivity {
218 activity_type: "charge_card".to_owned(),
219 }],
220 version: ManifestVersion::new("caller-supplied-version"),
221 format_version: CURRENT_FORMAT_VERSION,
222 additional_workflows: Vec::new(),
223 }
224 }
225
226 fn sample_beams() -> Result<BeamSet, PackageError> {
227 BeamSet::new(vec![
228 BeamModule::new("workflow/support", vec![4, 5, 6]),
229 BeamModule::new("workflow/order", vec![1, 2, 3]),
230 ])
231 }
232
233 #[test]
234 fn finalised_manifest_version_equals_beam_content_hash() -> Result<(), PackageError> {
235 let beams = sample_beams()?;
236 let expected = content_hash(&beams).to_string();
237 let manifest = PackageBuilder::new(sample_manifest(), beams).finalise_manifest()?;
238
239 assert_eq!(manifest.version.as_str(), expected);
240 Ok(())
241 }
242
243 #[test]
244 fn caller_supplied_manifest_version_is_overwritten() -> Result<(), PackageError> {
245 let beams = sample_beams()?;
246 let expected = content_hash(&beams).to_string();
247 let manifest = PackageBuilder::new(sample_manifest(), beams).finalise_manifest()?;
248
249 assert_ne!(manifest.version.as_str(), "caller-supplied-version");
250 assert_eq!(manifest.version.as_str(), expected);
251 Ok(())
252 }
253
254 #[test]
255 fn missing_entry_module_returns_typed_error() -> Result<(), PackageError> {
256 let beams = BeamSet::new(vec![BeamModule::new("workflow/other", vec![1])])?;
257 let result = PackageBuilder::new(sample_manifest(), beams).write_to_bytes();
258
259 assert!(matches!(
260 result,
261 Err(PackageError::MissingEntryModule { module }) if module == "workflow/order"
262 ));
263 Ok(())
264 }
265
266 #[test]
267 fn write_to_bytes_succeeds_without_source_entries() -> Result<(), PackageError> {
268 let bytes = PackageBuilder::new(sample_manifest(), sample_beams()?).write_to_bytes()?;
269 let mut archive = ZipArchive::new(Cursor::new(bytes)).map_err(PackageError::ArchiveRead)?;
270 let mut names = Vec::new();
271
272 for index in 0..archive.len() {
273 let file = archive.by_index(index).map_err(PackageError::ArchiveRead)?;
274 names.push(file.name().to_owned());
275 }
276
277 assert_eq!(
278 names,
279 vec![
280 "manifest.json",
281 "beam/workflow/order.beam",
282 "beam/workflow/support.beam",
283 ]
284 );
285 Ok(())
286 }
287
288 #[test]
289 fn identical_inputs_produce_identical_archive_bytes() -> Result<(), PackageError> {
290 let mut source = BTreeMap::new();
291 source.insert(
292 "workflow/order".to_owned(),
293 b"pub fn run() { Nil }".to_vec(),
294 );
295 let first = PackageBuilder::with_source(sample_manifest(), sample_beams()?, source.clone())
296 .write_to_bytes()?;
297 let second = PackageBuilder::with_source(sample_manifest(), sample_beams()?, source)
298 .write_to_bytes()?;
299
300 assert_eq!(first, second);
301 Ok(())
302 }
303
304 #[test]
305 fn source_inclusion_does_not_change_manifest_version() -> Result<(), PackageError> {
306 let mut source = BTreeMap::new();
307 source.insert(
308 "workflow/order".to_owned(),
309 b"pub fn run() { Nil }".to_vec(),
310 );
311 let without_source = PackageBuilder::new(sample_manifest(), sample_beams()?)
312 .finalise_manifest()?
313 .version;
314 let with_source = PackageBuilder::with_source(sample_manifest(), sample_beams()?, source)
315 .finalise_manifest()?
316 .version;
317
318 assert_eq!(without_source, with_source);
319 Ok(())
320 }
321
322 #[test]
323 fn rejects_unsafe_source_names() -> Result<(), PackageError> {
324 let mut source = BTreeMap::new();
325 source.insert("../escape".to_owned(), b"pub fn run() { Nil }".to_vec());
326
327 let result = PackageBuilder::with_source(sample_manifest(), sample_beams()?, source)
328 .write_to_bytes();
329
330 assert!(matches!(
331 result,
332 Err(PackageError::MalformedBeamEntry { entry }) if entry == "../escape"
333 ));
334 Ok(())
335 }
336
337 #[test]
338 fn rejects_logical_names_with_deployed_name_separator() -> Result<(), PackageError> {
339 let beams = BeamSet::new(vec![
340 BeamModule::new("workflow/order", vec![1, 2, 3]),
341 BeamModule::new("workflow/order$bad", vec![1]),
342 ])?;
343 let result = PackageBuilder::new(sample_manifest(), beams).write_to_bytes();
344
345 assert!(matches!(
346 result,
347 Err(PackageError::MalformedBeamEntry { entry }) if entry == "workflow/order$bad"
348 ));
349 Ok(())
350 }
351}