1use std::collections::BTreeMap;
37use std::fs;
38use std::path::{Path, PathBuf};
39
40use crate::{BundleError, BundleHash, BundleManifest, BundleRuntime, SCHEMA_VERSION};
41
42fn io(context: impl AsRef<str>, e: std::io::Error) -> BundleError {
45 BundleError::Io(format!("{}: {e}", context.as_ref()))
46}
47
48pub type BundleFile = (String, PathBuf);
52
53#[derive(Debug, Clone)]
55pub enum ServeBinSource {
56 Local(PathBuf),
59 Pinned(BundleHash),
67}
68
69pub fn collect_dir(prefix: &str, dir: &Path) -> Result<Vec<BundleFile>, BundleError> {
72 let mut out = Vec::new();
73 let mut stack = vec![dir.to_path_buf()];
74 while let Some(d) = stack.pop() {
75 let entries = fs::read_dir(&d).map_err(|e| io(format!("reading {}", d.display()), e))?;
76 for entry in entries {
77 let entry = entry.map_err(|e| io(format!("reading {}", d.display()), e))?;
78 let ft = entry
79 .file_type()
80 .map_err(|e| io(format!("stat {}", entry.path().display()), e))?;
81 let p = entry.path();
82 if ft.is_dir() {
83 stack.push(p);
84 } else if ft.is_file() {
85 let rel = p.strip_prefix(dir).expect("walker yields paths under dir");
86 let rel_str = rel.to_string_lossy().replace('\\', "/");
87 out.push((format!("{prefix}/{rel_str}"), p));
88 }
89 }
90 }
91 out.sort();
92 Ok(out)
93}
94
95pub fn assemble_bundle(
104 dest: &Path,
105 name: &str,
106 runtime: BundleRuntime,
107 files: &[BundleFile],
108) -> Result<BundleManifest, BundleError> {
109 fs::create_dir_all(dest).map_err(|e| io(format!("creating bundle dir {}", dest.display()), e))?;
110
111 let mut content = BTreeMap::new();
112 for (bundle_path, src) in files {
113 let bytes = fs::read(src).map_err(|e| io(format!("reading {}", src.display()), e))?;
114 let hash = BundleHash::of(&bytes);
115 let out = dest.join(bundle_path);
116 if let Some(parent) = out.parent() {
117 fs::create_dir_all(parent)
118 .map_err(|e| io(format!("creating {}", parent.display()), e))?;
119 }
120 fs::write(&out, &bytes).map_err(|e| io(format!("writing {}", out.display()), e))?;
121
122 #[cfg(unix)]
124 if bundle_path.starts_with("bins/") {
125 use std::os::unix::fs::PermissionsExt;
126 let mut perm = fs::metadata(&out)
127 .map_err(|e| io(format!("stat {}", out.display()), e))?
128 .permissions();
129 perm.set_mode(0o755);
130 fs::set_permissions(&out, perm)
131 .map_err(|e| io(format!("chmod +x {}", out.display()), e))?;
132 }
133
134 content.insert(bundle_path.clone(), hash);
135 }
136
137 let manifest = BundleManifest {
142 schema_version: SCHEMA_VERSION,
143 name: name.to_string(),
144 runtime,
145 requires_contract: crate::BUNDLE_CONTRACT_VERSION,
146 content,
147 };
148 let toml = manifest.to_toml_string()?;
149 fs::write(dest.join("manifest.toml"), toml).map_err(|e| {
150 io(
151 format!("writing {}", dest.join("manifest.toml").display()),
152 e,
153 )
154 })?;
155 Ok(manifest)
156}
157
158pub fn assemble_vanilla_bundle(
173 dest: &Path,
174 name: &str,
175 runtime_version: &str,
176 project_root: &Path,
177 out_dir: &Path,
178) -> Result<BundleManifest, BundleError> {
179 crate::assets::finalize_component_output(project_root, out_dir)?;
180 let mut files = collect_dir("app/dist", out_dir)?;
181 stage_app_config(project_root, &mut files);
182 stage_data_inputs(project_root, out_dir, &mut files)?;
183 files.sort();
184 assemble_bundle(
185 dest,
186 name,
187 BundleRuntime::Mesofact {
188 version: runtime_version.to_string(),
189 },
190 &files,
191 )
192}
193
194pub fn assemble_self_bundle(
204 dest: &Path,
205 name: &str,
206 project_root: &Path,
207 out_dir: &Path,
208 serve_bins: &[(String, PathBuf)],
209) -> Result<BundleManifest, BundleError> {
210 let serve_bins: Vec<(String, ServeBinSource)> = serve_bins
211 .iter()
212 .map(|(triple, path)| (triple.clone(), ServeBinSource::Local(path.clone())))
213 .collect();
214 assemble_self_bundle_with(dest, name, project_root, out_dir, &serve_bins, &[], None)
215}
216
217pub fn assemble_self_bundle_with(
244 dest: &Path,
245 name: &str,
246 project_root: &Path,
247 out_dir: &Path,
248 serve_bins: &[(String, ServeBinSource)],
249 sidecar_bins: &[(String, String, PathBuf)],
250 built_against: Option<String>,
251) -> Result<BundleManifest, BundleError> {
252 crate::assets::finalize_component_output(project_root, out_dir)?;
253 let mut files = collect_dir("app/dist", out_dir)?;
254 stage_app_config(project_root, &mut files);
255 stage_data_inputs(project_root, out_dir, &mut files)?;
256 let mut pinned: Vec<(String, BundleHash)> = Vec::new();
257 for (triple, source) in serve_bins {
258 let bundle_path = format!("bins/{triple}/serve");
259 match source {
260 ServeBinSource::Local(bin) => files.push((bundle_path, bin.clone())),
261 ServeBinSource::Pinned(hash) => pinned.push((bundle_path, hash.clone())),
262 }
263 }
264 for (bin_name, triple, bin) in sidecar_bins {
265 if bin_name == "serve" {
268 return Err(BundleError::Io(format!(
269 "sidecar bin for {triple} is named \"serve\", which is the runtime's own \
270 binary — pick a distinct name"
271 )));
272 }
273 files.push((format!("bins/{triple}/{bin_name}"), bin.clone()));
274 }
275 files.sort();
276 let mut manifest = assemble_bundle(
277 dest,
278 name,
279 BundleRuntime::SelfContained { built_against },
280 &files,
281 )?;
282 if !pinned.is_empty() {
283 for (path, hash) in pinned {
284 manifest.content.insert(path, hash);
285 }
286 let toml = manifest.to_toml_string()?;
291 fs::write(dest.join("manifest.toml"), toml)
292 .map_err(|e| io(format!("writing {}", dest.join("manifest.toml").display()), e))?;
293 }
294 Ok(manifest)
295}
296
297pub fn assemble_bundle_from_files(
310 dest: &Path,
311 name: &str,
312 runtime_version: &str,
313 mut files: Vec<BundleFile>,
314 serve_bins: &[(String, ServeBinSource)],
315 sidecar_bins: &[(String, String, PathBuf)],
316 built_against: Option<String>,
317) -> Result<BundleManifest, BundleError> {
318 if serve_bins.is_empty() {
319 files.sort();
320 return assemble_bundle(
321 dest,
322 name,
323 BundleRuntime::Mesofact {
324 version: runtime_version.to_string(),
325 },
326 &files,
327 );
328 }
329
330 let mut pinned: Vec<(String, BundleHash)> = Vec::new();
331 for (triple, source) in serve_bins {
332 let bundle_path = format!("bins/{triple}/serve");
333 match source {
334 ServeBinSource::Local(bin) => files.push((bundle_path, bin.clone())),
335 ServeBinSource::Pinned(hash) => pinned.push((bundle_path, hash.clone())),
336 }
337 }
338 for (bin_name, triple, bin) in sidecar_bins {
339 if bin_name == "serve" {
340 return Err(BundleError::Io(format!(
341 "sidecar bin for {triple} is named \"serve\", which is the runtime's own \
342 binary — pick a distinct name"
343 )));
344 }
345 files.push((format!("bins/{triple}/{bin_name}"), bin.clone()));
346 }
347 files.sort();
348 let mut manifest = assemble_bundle(
349 dest,
350 name,
351 BundleRuntime::SelfContained { built_against },
352 &files,
353 )?;
354 if !pinned.is_empty() {
355 for (path, hash) in pinned {
356 manifest.content.insert(path, hash);
357 }
358 let toml = manifest.to_toml_string()?;
363 fs::write(dest.join("manifest.toml"), toml)
364 .map_err(|e| io(format!("writing {}", dest.join("manifest.toml").display()), e))?;
365 }
366 Ok(manifest)
367}
368
369pub fn collect_component_files(
394 project_root: &Path,
395 out_dir: &Path,
396 mount: Option<&str>,
397 include_config: bool,
398) -> Result<Vec<BundleFile>, BundleError> {
399 crate::assets::finalize_component_output(project_root, out_dir)?;
400 let dist_prefix = match mount {
401 Some(m) if !m.is_empty() => format!("app/dist/html/{m}"),
402 _ => "app/dist".to_string(),
403 };
404 let mut files = collect_dir(&dist_prefix, out_dir)?;
405 if include_config {
406 stage_app_config(project_root, &mut files);
407 stage_data_inputs(project_root, out_dir, &mut files)?;
408 }
409 Ok(files)
410}
411
412fn stage_app_config(project_root: &Path, files: &mut Vec<BundleFile>) {
420 for name in ["mesofact.routes.ts", "mesofact.config.toml"] {
421 let src = project_root.join(name);
422 if src.exists() {
423 files.push((format!("app/{name}"), src));
424 }
425 }
426}
427
428fn stage_data_inputs(
442 project_root: &Path,
443 out_dir: &Path,
444 files: &mut Vec<BundleFile>,
445) -> Result<(), BundleError> {
446 #[derive(serde::Deserialize)]
451 struct ManifestSliver {
452 #[serde(default)]
453 routes: Vec<RouteSliver>,
454 }
455 #[derive(serde::Deserialize)]
456 struct RouteSliver {
457 #[serde(default)]
458 data_inputs: Vec<String>,
459 }
460
461 let manifest_path = out_dir.join("manifest.json");
462 let Ok(raw) = fs::read_to_string(&manifest_path) else {
463 return Ok(());
464 };
465 let manifest: ManifestSliver = serde_json::from_str(&raw)
466 .map_err(|e| BundleError::Io(format!("parsing {}: {e}", manifest_path.display())))?;
467
468 let mut seen = std::collections::BTreeSet::new();
469 for rel in manifest.routes.into_iter().flat_map(|r| r.data_inputs) {
470 if Path::new(&rel)
474 .components()
475 .any(|c| matches!(c, std::path::Component::ParentDir))
476 || Path::new(&rel).is_absolute()
477 {
478 return Err(BundleError::Io(format!(
479 "data_inputs entry {rel:?} escapes the app root; declare it as a path under the project"
480 )));
481 }
482 if !seen.insert(rel.clone()) {
483 continue; }
485 let src = project_root.join(&rel);
486 if !src.is_file() {
487 return Err(BundleError::Io(format!(
488 "manifest declares data_inputs {rel:?} but {} is not a file — a --revalidate receiver would fail to re-render this route on the node",
489 src.display()
490 )));
491 }
492 files.push((format!("app/{}", rel.replace('\\', "/")), src));
493 }
494 Ok(())
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500 use tempfile::TempDir;
501
502 fn write(path: &Path, bytes: &[u8]) {
503 fs::create_dir_all(path.parent().unwrap()).unwrap();
504 fs::write(path, bytes).unwrap();
505 }
506
507 #[test]
508 fn collect_dir_walks_recursively_and_prefixes() {
509 let dir = TempDir::new().unwrap();
510 write(&dir.path().join("index.html"), b"a");
511 write(&dir.path().join("assets/main.js"), b"b");
512 write(&dir.path().join("assets/nested/x.css"), b"c");
513
514 let files = collect_dir("app/dist", dir.path()).unwrap();
515 let paths: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect();
516 assert_eq!(
517 paths,
518 vec![
519 "app/dist/assets/main.js",
520 "app/dist/assets/nested/x.css",
521 "app/dist/index.html",
522 ]
523 );
524 }
525
526 #[test]
531 fn a_mounted_component_stages_inside_the_served_root() {
532 const SERVED_ROOT: &str = "app/dist/html/";
533
534 let dir = TempDir::new().unwrap();
535 write(&dir.path().join("index.html"), b"<app>");
536 write(&dir.path().join("app-abc123_bg.wasm"), b"\0asm");
537
538 let files =
539 collect_component_files(dir.path(), dir.path(), Some("app"), false).unwrap();
540 let paths: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect();
541 assert_eq!(
545 paths,
546 vec![
547 "app/dist/html/app/.mesofact-assets.json",
548 "app/dist/html/app/app-abc123_bg.wasm",
549 "app/dist/html/app/index.html",
550 ]
551 );
552 for (path, _) in &files {
553 assert!(
554 path.starts_with(SERVED_ROOT),
555 "{path} is staged outside the served root {SERVED_ROOT} — unreachable by any URL"
556 );
557 }
558 }
559
560 #[test]
563 fn an_unmounted_component_stages_its_own_html_tree_at_the_dist_root() {
564 let dir = TempDir::new().unwrap();
565 write(&dir.path().join("html/index.html"), b"<html>");
566 write(&dir.path().join("html/market.html"), b"<html>");
567
568 let files = collect_component_files(dir.path(), dir.path(), None, false).unwrap();
569 let paths: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect();
570 assert_eq!(
571 paths,
572 vec![
573 "app/dist/html/.mesofact-assets.json",
574 "app/dist/html/index.html",
575 "app/dist/html/market.html",
576 ]
577 );
578 }
579
580 #[test]
581 fn assemble_bundle_copies_files_hashes_and_writes_manifest() {
582 let src = TempDir::new().unwrap();
583 let dest = TempDir::new().unwrap();
584 write(&src.path().join("index.html"), b"<html>home</html>");
585
586 let files = vec![("app/index.html".to_string(), src.path().join("index.html"))];
587 let manifest = assemble_bundle(
588 dest.path(),
589 "yah-marketing",
590 BundleRuntime::Mesofact {
591 version: "0.8.20".into(),
592 },
593 &files,
594 )
595 .unwrap();
596
597 assert_eq!(
599 manifest.content.get("app/index.html").unwrap(),
600 &BundleHash::of(b"<html>home</html>")
601 );
602 assert_eq!(
604 fs::read(dest.path().join("app/index.html")).unwrap(),
605 b"<html>home</html>"
606 );
607 let text = fs::read_to_string(dest.path().join("manifest.toml")).unwrap();
609 assert_eq!(BundleManifest::from_toml_str(&text).unwrap(), manifest);
610 }
611
612 #[test]
613 fn assemble_vanilla_bundle_stages_routes_and_dist() {
614 let project = TempDir::new().unwrap();
615 let out_dir = project.path().join("dist");
616 let dest = TempDir::new().unwrap();
617 write(
618 &project.path().join("mesofact.routes.ts"),
619 b"export default {}",
620 );
621 write(&out_dir.join("index.html"), b"<html>");
622 write(&out_dir.join("hydrate/app.hash.js"), b"hydrate");
623
624 let manifest =
625 assemble_vanilla_bundle(dest.path(), "yah-marketing", "0.8.20", project.path(), &out_dir)
626 .unwrap();
627
628 assert!(matches!(manifest.runtime, BundleRuntime::Mesofact { .. }));
629 assert!(manifest.content.contains_key("app/mesofact.routes.ts"));
630 assert!(manifest.content.contains_key("app/dist/index.html"));
631 assert!(manifest.content.contains_key("app/dist/hydrate/app.hash.js"));
632 assert!(!manifest.content.keys().any(|k| k.starts_with("bins/")));
634 assert!(dest.path().join("app/mesofact.routes.ts").exists());
635 }
636
637 #[test]
642 fn assemble_stages_mesofact_config_when_present() {
643 let project = TempDir::new().unwrap();
644 let out_dir = project.path().join("dist");
645 let dest = TempDir::new().unwrap();
646 write(&out_dir.join("index.html"), b"<html>");
647 write(
648 &project.path().join("mesofact.config.toml"),
649 b"[publish]\nbucket = \"yah-dev\"\nprefix = \"yah-marketing/cloud\"\n",
650 );
651
652 let manifest =
653 assemble_vanilla_bundle(dest.path(), "yah-marketing", "0.8.20", project.path(), &out_dir)
654 .unwrap();
655
656 assert!(manifest.content.contains_key("app/mesofact.config.toml"));
657 assert_eq!(
658 fs::read_to_string(dest.path().join("app/mesofact.config.toml")).unwrap(),
659 "[publish]\nbucket = \"yah-dev\"\nprefix = \"yah-marketing/cloud\"\n"
660 );
661 }
662
663 #[test]
666 fn assemble_omits_config_when_absent() {
667 let project = TempDir::new().unwrap();
668 let out_dir = project.path().join("dist");
669 let dest = TempDir::new().unwrap();
670 write(&out_dir.join("index.html"), b"<html>");
671
672 let manifest =
673 assemble_vanilla_bundle(dest.path(), "yah-marketing", "0.8.20", project.path(), &out_dir)
674 .unwrap();
675
676 assert!(!manifest.content.contains_key("app/mesofact.config.toml"));
677 }
678
679 #[test]
683 fn assemble_stages_declared_data_inputs_once_each() {
684 let project = TempDir::new().unwrap();
685 let out_dir = project.path().join("dist");
686 let dest = TempDir::new().unwrap();
687 write(&out_dir.join("index.html"), b"<html>");
688 write(
689 &out_dir.join("manifest.json"),
690 br#"{"routes":[
691 {"route":"/releases","data_inputs":["src/data/releases.json"]},
692 {"route":"/issues","data_inputs":["src/data/issues.json"]},
693 {"route":"/issues/:id","data_inputs":["src/data/issues.json"]},
694 {"route":"/"}
695 ]}"#,
696 );
697 write(
698 &project.path().join("src/data/releases.json"),
699 br#"{"releases":[]}"#,
700 );
701 write(&project.path().join("src/data/issues.json"), br#"{"items":[]}"#);
702
703 let manifest =
704 assemble_vanilla_bundle(dest.path(), "yah-marketing", "0.8.20", project.path(), &out_dir)
705 .unwrap();
706
707 assert!(manifest.content.contains_key("app/src/data/releases.json"));
708 assert!(manifest.content.contains_key("app/src/data/issues.json"));
709 assert_eq!(
710 fs::read_to_string(dest.path().join("app/src/data/releases.json")).unwrap(),
711 r#"{"releases":[]}"#
712 );
713 assert_eq!(
716 manifest
717 .content
718 .keys()
719 .filter(|k| k.as_str() == "app/src/data/issues.json")
720 .count(),
721 1
722 );
723 }
724
725 #[test]
729 fn assemble_rejects_declared_data_input_that_is_not_on_disk() {
730 let project = TempDir::new().unwrap();
731 let out_dir = project.path().join("dist");
732 let dest = TempDir::new().unwrap();
733 write(&out_dir.join("index.html"), b"<html>");
734 write(
735 &out_dir.join("manifest.json"),
736 br#"{"routes":[{"route":"/releases","data_inputs":["src/data/releases.json"]}]}"#,
737 );
738
739 let err = assemble_vanilla_bundle(
740 dest.path(),
741 "yah-marketing",
742 "0.8.20",
743 project.path(),
744 &out_dir,
745 )
746 .unwrap_err();
747 let msg = err.to_string();
748 assert!(msg.contains("src/data/releases.json"), "got {msg}");
749 assert!(msg.contains("revalidate"), "got {msg}");
750 }
751
752 #[test]
756 fn assemble_rejects_data_input_escaping_the_app_root() {
757 let project = TempDir::new().unwrap();
758 let out_dir = project.path().join("dist");
759 let dest = TempDir::new().unwrap();
760 write(&out_dir.join("index.html"), b"<html>");
761 write(
762 &out_dir.join("manifest.json"),
763 br#"{"routes":[{"route":"/x","data_inputs":["../secrets.json"]}]}"#,
764 );
765
766 let err = assemble_vanilla_bundle(
767 dest.path(),
768 "yah-marketing",
769 "0.8.20",
770 project.path(),
771 &out_dir,
772 )
773 .unwrap_err();
774 assert!(err.to_string().contains("escapes the app root"), "got {err}");
775 }
776
777 #[test]
780 fn assemble_without_a_build_manifest_stages_no_data_inputs() {
781 let project = TempDir::new().unwrap();
782 let out_dir = project.path().join("dist");
783 let dest = TempDir::new().unwrap();
784 write(&out_dir.join("index.html"), b"<html>");
785
786 let manifest =
787 assemble_vanilla_bundle(dest.path(), "yah-marketing", "0.8.20", project.path(), &out_dir)
788 .unwrap();
789
790 assert!(!manifest.content.keys().any(|k| k.starts_with("app/src/")));
791 }
792
793 #[test]
794 fn assemble_self_bundle_carries_its_serve_binary() {
795 let project = TempDir::new().unwrap();
796 let out_dir = project.path().join("dist");
797 let dest = TempDir::new().unwrap();
798 let bin = project.path().join("serve");
799 write(&out_dir.join("index.html"), b"<html>");
800 write(&bin, b"\x7fELF-ish");
801
802 let manifest = assemble_self_bundle(
803 dest.path(),
804 "yah-marketing",
805 project.path(),
806 &out_dir,
807 &[("x86_64-unknown-linux-musl".to_string(), bin)],
808 )
809 .unwrap();
810
811 assert!(matches!(manifest.runtime, BundleRuntime::SelfContained { built_against: None }));
812 assert!(manifest
813 .content
814 .contains_key("bins/x86_64-unknown-linux-musl/serve"));
815 assert!(manifest.content.contains_key("app/dist/index.html"));
816 assert_eq!(manifest.runtime.as_wire(), "self");
818 }
819
820 #[test]
824 fn assemble_self_bundle_with_pins_a_serve_bin_by_digest() {
825 let project = TempDir::new().unwrap();
826 let out_dir = project.path().join("dist");
827 let dest = TempDir::new().unwrap();
828 write(&out_dir.join("index.html"), b"<html>");
829 let pinned_hash = BundleHash::of(b"a previously published serve binary");
830
831 let manifest = assemble_self_bundle_with(
832 dest.path(),
833 "yah-marketing",
834 project.path(),
835 &out_dir,
836 &[(
837 "x86_64-unknown-linux-musl".to_string(),
838 ServeBinSource::Pinned(pinned_hash.clone()),
839 )],
840 &[],
841 Some("fp-abc123".to_string()),
842 )
843 .unwrap();
844
845 assert_eq!(
846 manifest.content.get("bins/x86_64-unknown-linux-musl/serve"),
847 Some(&pinned_hash)
848 );
849 assert!(!dest.path().join("bins/x86_64-unknown-linux-musl/serve").exists());
851 assert_eq!(
852 manifest.runtime,
853 BundleRuntime::SelfContained {
854 built_against: Some("fp-abc123".to_string())
855 }
856 );
857 let text = fs::read_to_string(dest.path().join("manifest.toml")).unwrap();
860 assert_eq!(BundleManifest::from_toml_str(&text).unwrap(), manifest);
861 }
862
863 #[test]
866 fn assemble_self_bundle_with_mixes_pinned_and_local_bins() {
867 let project = TempDir::new().unwrap();
868 let out_dir = project.path().join("dist");
869 let dest = TempDir::new().unwrap();
870 let local_bin = project.path().join("serve-aarch64");
871 write(&out_dir.join("index.html"), b"<html>");
872 write(&local_bin, b"\x7fELF-aarch64");
873 let pinned_hash = BundleHash::of(b"already published x86_64 serve");
874
875 let manifest = assemble_self_bundle_with(
876 dest.path(),
877 "yah-marketing",
878 project.path(),
879 &out_dir,
880 &[
881 (
882 "x86_64-unknown-linux-musl".to_string(),
883 ServeBinSource::Pinned(pinned_hash.clone()),
884 ),
885 (
886 "aarch64-unknown-linux-musl".to_string(),
887 ServeBinSource::Local(local_bin),
888 ),
889 ],
890 &[],
891 None,
892 )
893 .unwrap();
894
895 assert_eq!(
896 manifest.content.get("bins/x86_64-unknown-linux-musl/serve"),
897 Some(&pinned_hash)
898 );
899 assert!(dest.path().join("bins/aarch64-unknown-linux-musl/serve").exists());
900 assert!(!dest.path().join("bins/x86_64-unknown-linux-musl/serve").exists());
901 }
902
903 #[test]
907 fn assemble_self_bundle_with_carries_sidecar_bins() {
908 let project = TempDir::new().unwrap();
909 let out_dir = project.path().join("dist");
910 let dest = TempDir::new().unwrap();
911 let serve = project.path().join("serve");
912 let feed = project.path().join("almanac-feed");
913 write(&out_dir.join("index.html"), b"<html>");
914 write(&serve, b"\x7fELF-serve");
915 write(&feed, b"\x7fELF-feed");
916
917 let manifest = assemble_self_bundle_with(
918 dest.path(),
919 "yah-marketing",
920 project.path(),
921 &out_dir,
922 &[(
923 "x86_64-unknown-linux-musl".to_string(),
924 ServeBinSource::Local(serve),
925 )],
926 &[(
927 "almanac-feed".to_string(),
928 "x86_64-unknown-linux-musl".to_string(),
929 feed,
930 )],
931 None,
932 )
933 .unwrap();
934
935 assert!(manifest
936 .content
937 .contains_key("bins/x86_64-unknown-linux-musl/serve"));
938 assert!(manifest
939 .content
940 .contains_key("bins/x86_64-unknown-linux-musl/almanac-feed"));
941 }
942
943 #[test]
946 fn assemble_rejects_a_sidecar_named_serve() {
947 let project = TempDir::new().unwrap();
948 let out_dir = project.path().join("dist");
949 let dest = TempDir::new().unwrap();
950 let bin = project.path().join("bin");
951 write(&out_dir.join("index.html"), b"<html>");
952 write(&bin, b"\x7fELF-ish");
953
954 let err = assemble_self_bundle_with(
955 dest.path(),
956 "yah-marketing",
957 project.path(),
958 &out_dir,
959 &[],
960 &[(
961 "serve".to_string(),
962 "x86_64-unknown-linux-musl".to_string(),
963 bin,
964 )],
965 None,
966 )
967 .unwrap_err();
968 assert!(err.to_string().contains("runtime's own"), "got {err}");
969 }
970
971 #[test]
974 #[cfg(unix)]
975 fn sidecar_bins_are_executable() {
976 use std::os::unix::fs::PermissionsExt;
977 let project = TempDir::new().unwrap();
978 let out_dir = project.path().join("dist");
979 let dest = TempDir::new().unwrap();
980 let feed = project.path().join("almanac-feed");
981 write(&out_dir.join("index.html"), b"<html>");
982 write(&feed, b"\x7fELF-feed");
983
984 assemble_self_bundle_with(
985 dest.path(),
986 "yah-marketing",
987 project.path(),
988 &out_dir,
989 &[],
990 &[(
991 "almanac-feed".to_string(),
992 "x86_64-unknown-linux-musl".to_string(),
993 feed,
994 )],
995 None,
996 )
997 .unwrap();
998
999 let mode = fs::metadata(dest.path().join("bins/x86_64-unknown-linux-musl/almanac-feed"))
1000 .unwrap()
1001 .permissions()
1002 .mode();
1003 assert_eq!(mode & 0o111, 0o111, "sidecar must stay executable");
1004 }
1005
1006 #[test]
1007 #[cfg(unix)]
1008 fn bins_are_made_executable() {
1009 use std::os::unix::fs::PermissionsExt;
1010 let src = TempDir::new().unwrap();
1011 let dest = TempDir::new().unwrap();
1012 write(&src.path().join("serve"), b"\x7fELF-ish");
1013
1014 let files = vec![(
1015 "bins/x86_64-unknown-linux-musl/serve".to_string(),
1016 src.path().join("serve"),
1017 )];
1018 assemble_bundle(dest.path(), "app", BundleRuntime::self_contained(), &files).unwrap();
1019
1020 let mode = fs::metadata(dest.path().join("bins/x86_64-unknown-linux-musl/serve"))
1021 .unwrap()
1022 .permissions()
1023 .mode();
1024 assert_eq!(mode & 0o111, 0o111, "serve binary should be executable");
1025 }
1026}