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