1use std::io;
19use std::path::{Path, PathBuf};
20
21use super::command::{CommandRunner, CommandSpec};
22
23#[derive(Copy, Clone, Debug, Eq, PartialEq)]
25pub enum ReleaseTarget {
26 LinuxX64,
28
29 LinuxArm64,
31 MacosX64,
33 MacosArm64,
35 WindowsX64,
37}
38
39impl ReleaseTarget {
40 #[must_use]
42 pub const fn all() -> &'static [ReleaseTarget] {
43 &[
44 Self::LinuxX64,
45 Self::LinuxArm64,
46 Self::MacosX64,
47 Self::MacosArm64,
48 Self::WindowsX64,
49 ]
50 }
51
52 #[must_use]
54 pub const fn triple(self) -> &'static str {
55 match self {
56 Self::LinuxX64 => "x86_64-unknown-linux-gnu",
57 Self::LinuxArm64 => "aarch64-unknown-linux-gnu",
58 Self::MacosX64 => "x86_64-apple-darwin",
59 Self::MacosArm64 => "aarch64-apple-darwin",
60 Self::WindowsX64 => "x86_64-pc-windows-msvc",
61 }
62 }
63
64 #[must_use]
66 pub const fn is_windows(self) -> bool {
67 matches!(self, Self::WindowsX64)
68 }
69
70 #[must_use]
73 pub const fn host_branch(self) -> &'static str {
74 match self {
75 Self::LinuxX64 | Self::MacosX64 | Self::WindowsX64 => "baseline-x64",
76 Self::LinuxArm64 | Self::MacosArm64 => "arm64",
77 }
78 }
79
80 #[must_use]
82 pub const fn archive_extension(self) -> &'static str {
83 if self.is_windows() { "zip" } else { "tar.gz" }
84 }
85
86 #[must_use]
88 pub const fn bun_target(self) -> &'static str {
89 match self {
90 Self::LinuxX64 => "bun-linux-x64-baseline",
91 Self::LinuxArm64 => "bun-linux-arm64",
92 Self::MacosX64 => "bun-darwin-x64-baseline",
93 Self::MacosArm64 => "bun-darwin-arm64",
94 Self::WindowsX64 => "bun-windows-x64-baseline",
95 }
96 }
97
98 #[must_use]
100 pub const fn archive_dir(self) -> &'static str {
101 match self {
102 Self::LinuxX64 => "pi-linux-x64-base",
103 Self::LinuxArm64 => "pi-linux-arm64",
104 Self::MacosX64 => "pi-darwin-x64-base",
105 Self::MacosArm64 => "pi-darwin-arm64",
106 Self::WindowsX64 => "pi-windows-x64-base",
107 }
108 }
109}
110
111#[derive(Copy, Clone, Debug, Eq, PartialEq)]
113pub enum HostVariant {
114 Compiled,
117 RuntimeFallback,
120}
121
122impl HostVariant {
123 #[must_use]
125 pub fn assets(self) -> Vec<ReleaseAsset> {
126 self.assets_for(ReleaseTarget::LinuxX64)
127 }
128
129 #[must_use]
131 pub fn assets_for(self, target: ReleaseTarget) -> Vec<ReleaseAsset> {
132 match self {
133 Self::Compiled => vec![ReleaseAsset::host_compiled_for(target)],
134 Self::RuntimeFallback => {
135 vec![
136 ReleaseAsset::host_runtime_for(target),
137 ReleaseAsset::host_script(),
138 ]
139 }
140 }
141 }
142}
143
144#[derive(Clone, Debug, Eq, PartialEq)]
146pub struct ReleaseAsset {
147 pub relative_path: String,
149 pub executable: bool,
151}
152
153impl ReleaseAsset {
154 #[must_use]
156 pub fn pi_binary(target: ReleaseTarget) -> Self {
157 let name = if target.is_windows() { "pi.exe" } else { "pi" };
158 Self {
159 relative_path: name.to_owned(),
160 executable: true,
161 }
162 }
163
164 #[must_use]
166 pub fn host_compiled() -> Self {
167 Self::host_compiled_for(ReleaseTarget::LinuxX64)
168 }
169
170 #[must_use]
172 pub fn host_compiled_for(target: ReleaseTarget) -> Self {
173 Self {
174 relative_path: if target.is_windows() {
175 "pi-extension-host.exe".to_owned()
176 } else {
177 "pi-extension-host".to_owned()
178 },
179 executable: true,
180 }
181 }
182
183 #[must_use]
185 pub fn host_runtime() -> Self {
186 Self::host_runtime_for(ReleaseTarget::LinuxX64)
187 }
188
189 #[must_use]
191 pub fn host_runtime_for(target: ReleaseTarget) -> Self {
192 Self {
193 relative_path: if target.is_windows() {
194 "bun.exe".to_owned()
195 } else {
196 "bun".to_owned()
197 },
198 executable: true,
199 }
200 }
201
202 #[must_use]
204 pub fn host_script() -> Self {
205 Self {
206 relative_path: "pi-extension-host.js".to_owned(),
207 executable: false,
208 }
209 }
210}
211
212#[derive(Clone, Debug, Eq, PartialEq)]
214pub struct ReleasePlan {
215 pub target: ReleaseTarget,
217 pub version: String,
219 pub host_variant: HostVariant,
221 pub cargo_build: CommandSpec,
223 pub host_branch: &'static str,
225 pub archive_base: String,
227 pub archive_extension: &'static str,
229 pub assets: Vec<ReleaseAsset>,
231 pub manifest: ArchiveManifest,
233}
234
235#[derive(Clone, Debug, Eq, PartialEq)]
237pub struct ArchiveManifest {
238 pub sorted_members: Vec<ReleaseAsset>,
240 pub fixed_mtime: u64,
242 pub uid: u32,
244 pub gid: u32,
246}
247
248pub const DEFAULT_FIXED_MTIME: u64 = 0;
251
252pub fn resolved_fixed_mtime(source_date_epoch: Option<&str>) -> Result<u64, String> {
260 match source_date_epoch {
261 None => Ok(DEFAULT_FIXED_MTIME),
262 Some(raw) => {
263 let trimmed = raw.trim();
264 trimmed
265 .parse::<u64>()
266 .map_err(|_| format!("invalid SOURCE_DATE_EPOCH: {raw:?}"))
267 }
268 }
269}
270
271pub fn plan_release(
285 target: ReleaseTarget,
286 version: &str,
287 host_variant: HostVariant,
288 source_date_epoch: Option<&str>,
289) -> Result<ReleasePlan, String> {
290 let fixed_mtime = resolved_fixed_mtime(source_date_epoch)?;
291 let triple = target.triple();
292 let cargo_build = CommandSpec::new(
293 "cargo",
294 [
295 "build",
296 "-p",
297 "pi-oxidized",
298 "--release",
299 "--locked",
300 "--target",
301 triple,
302 ],
303 );
304 let mut assets = vec![ReleaseAsset::pi_binary(target)];
305 assets.extend(host_variant.assets_for(target));
306 assets.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
307 for asset in &assets {
308 validate_asset_name(&asset.relative_path)?;
309 }
310 let sorted_members = assets.clone();
311 let archive_base = format!("pi-{version}-{}", target.archive_dir());
312 Ok(ReleasePlan {
313 target,
314 version: version.to_owned(),
315 host_variant,
316 cargo_build,
317 host_branch: target.host_branch(),
318 archive_base,
319 archive_extension: target.archive_extension(),
320 assets,
321 manifest: ArchiveManifest {
322 sorted_members,
323 fixed_mtime,
324 uid: 0,
325 gid: 0,
326 },
327 })
328}
329
330#[must_use]
332pub fn archive_file_name(plan: &ReleasePlan) -> String {
333 format!("{}.{}", plan.archive_base, plan.archive_extension)
334}
335
336pub fn validate_asset_name(name: &str) -> Result<(), String> {
347 if name.is_empty() {
348 return Err("asset name is empty".to_owned());
349 }
350 if name.contains('/') || name.contains('\\') {
351 return Err(format!("asset name contains a path separator: {name:?}"));
352 }
353 if name == ".." || name.contains("..") {
354 return Err(format!("asset name contains parent traversal: {name:?}"));
355 }
356 if name.starts_with('-') {
357 return Err(format!("asset name starts with '-': {name:?}"));
358 }
359 if name.len() >= 2 && name.as_bytes()[1] == b':' && name.as_bytes()[0].is_ascii_alphabetic() {
360 return Err(format!("asset name is a Windows drive prefix: {name:?}"));
361 }
362 if name.chars().any(char::is_control) {
363 return Err(format!("asset name contains a control character: {name:?}"));
364 }
365 Ok(())
366}
367
368#[must_use]
374pub fn is_within_staging(member: &Path, staging: &Path) -> bool {
375 let Ok(relative) = member.strip_prefix(staging) else {
376 return false;
377 };
378 let mut depth: i32 = 0;
379 for component in relative.components() {
380 match component {
381 std::path::Component::Normal(_) => depth += 1,
382 std::path::Component::ParentDir => depth -= 1,
383 std::path::Component::CurDir => {}
384 std::path::Component::Prefix(_) | std::path::Component::RootDir => return false,
386 }
387 if depth < 0 {
388 return false;
389 }
390 }
391 true
392}
393
394#[derive(Debug, thiserror::Error)]
396pub enum ReleaseError {
397 #[error("cargo build for {target} failed: {source}")]
399 BuildFailed {
400 target: &'static str,
402 #[source]
404 source: io::Error,
405 },
406}
407
408pub trait ReleaseRunner {
410 fn run_build(&mut self, plan: &ReleasePlan) -> Result<(), ReleaseError>;
416}
417
418pub struct SystemReleaseRunner {
420 pub runner: Box<dyn CommandRunner>,
422}
423
424impl SystemReleaseRunner {
425 #[must_use]
427 pub fn new(runner: Box<dyn CommandRunner>) -> Self {
428 Self { runner }
429 }
430}
431
432impl ReleaseRunner for SystemReleaseRunner {
433 fn run_build(&mut self, plan: &ReleasePlan) -> Result<(), ReleaseError> {
434 self.runner
435 .run(&plan.cargo_build, None)
436 .map_err(|source| ReleaseError::BuildFailed {
437 target: plan.target.triple(),
438 source,
439 })?;
440 Ok(())
441 }
442}
443
444#[must_use]
449pub fn asset_staging_path(staging: &Path, asset: &ReleaseAsset) -> PathBuf {
450 staging.join(&asset.relative_path)
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456 use crate::core::platform::command::CommandOutput;
457 use std::path::Path;
458
459 type TestResult = Result<(), Box<dyn std::error::Error>>;
460
461 #[test]
462 fn all_targets_are_unique_and_complete() {
463 let triples: Vec<&str> = ReleaseTarget::all().iter().map(|t| t.triple()).collect();
464 let mut dedup = triples.clone();
465 dedup.sort_unstable();
466 dedup.dedup();
467 assert_eq!(triples.len(), 5);
468 assert_eq!(dedup.len(), 5, "duplicate triples: {triples:?}");
469 }
470
471 #[test]
472 fn target_plans_match_the_typescript_release_contract() -> TestResult {
473 let expected = [
474 (
475 ReleaseTarget::LinuxX64,
476 "bun-linux-x64-baseline",
477 "pi-linux-x64-base",
478 "pi-extension-host",
479 "bun",
480 ),
481 (
482 ReleaseTarget::LinuxArm64,
483 "bun-linux-arm64",
484 "pi-linux-arm64",
485 "pi-extension-host",
486 "bun",
487 ),
488 (
489 ReleaseTarget::MacosX64,
490 "bun-darwin-x64-baseline",
491 "pi-darwin-x64-base",
492 "pi-extension-host",
493 "bun",
494 ),
495 (
496 ReleaseTarget::MacosArm64,
497 "bun-darwin-arm64",
498 "pi-darwin-arm64",
499 "pi-extension-host",
500 "bun",
501 ),
502 (
503 ReleaseTarget::WindowsX64,
504 "bun-windows-x64-baseline",
505 "pi-windows-x64-base",
506 "pi-extension-host.exe",
507 "bun.exe",
508 ),
509 ];
510 for (target, bun_target, archive_dir, host_name, runtime_name) in expected {
511 let compiled = plan_release(target, "1.0.0", HostVariant::Compiled, None)
512 .map_err(io::Error::other)?;
513 assert_eq!(compiled.target.bun_target(), bun_target);
514 assert_eq!(compiled.target.archive_dir(), archive_dir);
515 assert!(
516 compiled
517 .assets
518 .iter()
519 .any(|asset| asset.relative_path == host_name)
520 );
521 let fallback = plan_release(target, "1.0.0", HostVariant::RuntimeFallback, None)
522 .map_err(io::Error::other)?;
523 assert!(
524 fallback
525 .assets
526 .iter()
527 .any(|asset| asset.relative_path == runtime_name)
528 );
529 assert!(
530 fallback
531 .assets
532 .iter()
533 .any(|asset| asset.relative_path == "pi-extension-host.js")
534 );
535 }
536 Ok(())
537 }
538
539 #[test]
540 fn plan_cargo_invocation_matches_contract() -> TestResult {
541 let plan = plan_release(
542 ReleaseTarget::LinuxX64,
543 "1.2.3",
544 HostVariant::Compiled,
545 None,
546 )
547 .map_err(io::Error::other)?;
548 assert_eq!(plan.cargo_build.program, "cargo");
549 assert_eq!(
550 plan.cargo_build.args,
551 vec![
552 "build",
553 "-p",
554 "pi-oxidized",
555 "--release",
556 "--locked",
557 "--target",
558 "x86_64-unknown-linux-gnu"
559 ]
560 );
561 assert_eq!(plan.host_variant, HostVariant::Compiled);
562 assert_eq!(plan.host_branch, "baseline-x64");
563 assert_eq!(plan.archive_base, "pi-1.2.3-pi-linux-x64-base");
564 assert_eq!(plan.archive_extension, "tar.gz");
565 assert_eq!(
566 archive_file_name(&plan),
567 "pi-1.2.3-pi-linux-x64-base.tar.gz"
568 );
569 Ok(())
570 }
571
572 #[test]
573 fn windows_target_uses_zip_and_exe() -> TestResult {
574 let plan = plan_release(
575 ReleaseTarget::WindowsX64,
576 "0.1.0",
577 HostVariant::Compiled,
578 None,
579 )
580 .map_err(io::Error::other)?;
581 assert_eq!(plan.archive_extension, "zip");
582 assert!(plan.assets.iter().any(|a| a.relative_path == "pi.exe"));
583 assert!(
584 plan.assets
585 .iter()
586 .any(|a| a.relative_path == "pi-extension-host.exe")
587 );
588 assert_eq!(archive_file_name(&plan), "pi-0.1.0-pi-windows-x64-base.zip");
589 Ok(())
590 }
591
592 #[test]
593 fn arm64_uses_standard_host_branch() -> TestResult {
594 let plan = plan_release(
595 ReleaseTarget::MacosArm64,
596 "1.0.0",
597 HostVariant::Compiled,
598 None,
599 )
600 .map_err(io::Error::other)?;
601 assert_eq!(plan.host_branch, "arm64");
602 assert_eq!(plan.target.triple(), "aarch64-apple-darwin");
603 Ok(())
604 }
605
606 #[test]
607 fn runtime_fallback_swaps_host_assets() -> TestResult {
608 let plan = plan_release(
609 ReleaseTarget::LinuxX64,
610 "1.0.0",
611 HostVariant::RuntimeFallback,
612 None,
613 )
614 .map_err(io::Error::other)?;
615 let names: Vec<&str> = plan
616 .assets
617 .iter()
618 .map(|a| a.relative_path.as_str())
619 .collect();
620 assert!(names.contains(&"bun"));
621 assert!(names.contains(&"pi-extension-host.js"));
622 assert!(!names.contains(&"pi-extension-host"));
623 assert_eq!(plan.manifest.sorted_members, plan.assets);
625 Ok(())
626 }
627
628 #[test]
629 fn manifest_is_sorted_and_reproducible() -> TestResult {
630 let plan = plan_release(
631 ReleaseTarget::LinuxX64,
632 "1.0.0",
633 HostVariant::Compiled,
634 None,
635 )
636 .map_err(io::Error::other)?;
637 let names: Vec<&str> = plan
638 .manifest
639 .sorted_members
640 .iter()
641 .map(|a| a.relative_path.as_str())
642 .collect();
643 let mut sorted = names.clone();
644 sorted.sort_unstable();
645 assert_eq!(names, sorted, "members must be sorted");
646 assert_eq!(plan.manifest.fixed_mtime, DEFAULT_FIXED_MTIME);
647 assert_eq!(plan.manifest.uid, 0);
648 assert_eq!(plan.manifest.gid, 0);
649 Ok(())
650 }
651
652 #[test]
653 fn source_date_epoch_overrides_mtime() -> TestResult {
654 let plan = plan_release(
655 ReleaseTarget::LinuxX64,
656 "1.0.0",
657 HostVariant::Compiled,
658 Some("1700000000"),
659 )
660 .map_err(io::Error::other)?;
661 assert_eq!(plan.manifest.fixed_mtime, 1_700_000_000);
662 Ok(())
663 }
664
665 #[test]
666 fn malformed_source_date_epoch_is_rejected() {
667 assert!(
668 plan_release(
669 ReleaseTarget::LinuxX64,
670 "1.0.0",
671 HostVariant::Compiled,
672 Some("nope")
673 )
674 .is_err()
675 );
676 assert!(
677 plan_release(
678 ReleaseTarget::LinuxX64,
679 "1.0.0",
680 HostVariant::Compiled,
681 Some("-5")
682 )
683 .is_err()
684 );
685 }
686
687 #[test]
688 fn validate_asset_name_rejects_traversal() {
689 assert!(validate_asset_name("pi").is_ok());
690 assert!(validate_asset_name("pi.exe").is_ok());
691 assert!(validate_asset_name("pi-extension-host.js").is_ok());
692 assert!(validate_asset_name("pi-extension-host").is_ok());
693 assert!(validate_asset_name("").is_err());
694 assert!(validate_asset_name("../pi").is_err());
695 assert!(validate_asset_name("a/b").is_err());
696 assert!(validate_asset_name("a\\b").is_err());
697 assert!(validate_asset_name("-x").is_err());
698 assert!(validate_asset_name("C:pi").is_err());
699 assert!(validate_asset_name("pi\u{0000}").is_err());
700 }
701
702 #[test]
703 fn within_staging_rejects_escape() {
704 let staging = Path::new("/tmp/stage");
705 assert!(is_within_staging(&staging.join("pi"), staging));
706 assert!(is_within_staging(
707 &staging.join("dir").join("host.js"),
708 staging
709 ));
710 assert!(!is_within_staging(&staging.join("..").join("etc"), staging));
711 assert!(!is_within_staging(Path::new("/etc/passwd"), staging));
712 }
713
714 struct OkRunner;
716 impl CommandRunner for OkRunner {
717 fn run(&mut self, _spec: &CommandSpec, _stdin: Option<&[u8]>) -> io::Result<CommandOutput> {
718 Ok(CommandOutput {
719 status: 0,
720 stdout: Vec::new(),
721 stderr: Vec::new(),
722 })
723 }
724 fn spawn_detached(&mut self, _spec: &CommandSpec) -> io::Result<()> {
725 Ok(())
726 }
727 }
728
729 struct FailingRunner;
731 impl CommandRunner for FailingRunner {
732 fn run(&mut self, _spec: &CommandSpec, _stdin: Option<&[u8]>) -> io::Result<CommandOutput> {
733 Err(io::Error::other("boom"))
734 }
735 fn spawn_detached(&mut self, _spec: &CommandSpec) -> io::Result<()> {
736 Ok(())
737 }
738 }
739
740 #[test]
741 fn runner_delegates_on_success() -> TestResult {
742 let mut system = SystemReleaseRunner::new(Box::new(OkRunner));
743 let plan = plan_release(
744 ReleaseTarget::LinuxX64,
745 "1.0.0",
746 HostVariant::Compiled,
747 None,
748 )
749 .map_err(io::Error::other)?;
750 system.run_build(&plan)?;
753 Ok(())
754 }
755
756 #[test]
757 fn runner_surfaces_build_failure() -> TestResult {
758 let mut system = SystemReleaseRunner::new(Box::new(FailingRunner));
759 let plan = plan_release(
760 ReleaseTarget::LinuxX64,
761 "1.0.0",
762 HostVariant::Compiled,
763 None,
764 )
765 .map_err(io::Error::other)?;
766 let Err(err) = system.run_build(&plan) else {
767 return Err(io::Error::other("expected release build failure").into());
768 };
769 match err {
770 ReleaseError::BuildFailed { target, .. } => {
771 assert_eq!(target, "x86_64-unknown-linux-gnu");
772 }
773 }
774 Ok(())
775 }
776
777 #[test]
778 fn asset_staging_path_joins_under_staging() {
779 let staging = Path::new("/stage");
780 let asset = ReleaseAsset::host_script();
781 let path = asset_staging_path(staging, &asset);
782 assert_eq!(path, Path::new("/stage/pi-extension-host.js"));
783 assert!(is_within_staging(&path, staging));
784 }
785}