Skip to main content

pi/core/platform/
release_packaging.rs

1//! Release packaging plan, runner, path safety, and reproducibility rules.
2//!
3//! Rust-side planner for the `scripts/package-release.ts` release builder. It
4//! owns the cross-target cargo invocation, the sibling host-asset selection
5//! (compiled standalone host vs Bun-runtime-plus-JavaScript fallback), the
6//! archive layout, and the reproducibility contract. The actual archive bytes
7//! are assembled by the release script or CI; this module makes the plan
8//! deterministic, validated, and unit-testable without invoking cargo.
9//!
10//! Supported targets (baseline x64 hosts avoid Bun's AVX2 floor; arm64 hosts
11//! use the standard target):
12//! - `x86_64-unknown-linux-gnu`
13//! - `aarch64-unknown-linux-gnu`
14//! - `x86_64-apple-darwin`
15//! - `aarch64-apple-darwin`
16//! - `x86_64-pc-windows-msvc`
17
18use std::io;
19use std::path::{Path, PathBuf};
20
21use super::command::{CommandRunner, CommandSpec};
22
23/// A native release target triple and its host-asset branch.
24#[derive(Copy, Clone, Debug, Eq, PartialEq)]
25pub enum ReleaseTarget {
26    /// Linux `x86_64` (GNU).
27    LinuxX64,
28
29    /// Linux aarch64 (GNU).
30    LinuxArm64,
31    /// macOS `x86_64`.
32    MacosX64,
33    /// macOS aarch64.
34    MacosArm64,
35    /// Windows `x86_64` (MSVC).
36    WindowsX64,
37}
38
39impl ReleaseTarget {
40    /// All supported release targets.
41    #[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    /// The Rust target triple.
53    #[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    /// Whether the produced `pi` binary has an `.exe` suffix.
65    #[must_use]
66    pub const fn is_windows(self) -> bool {
67        matches!(self, Self::WindowsX64)
68    }
69
70    /// Host-asset branch name. x64 targets build the baseline-x64 host (to
71    /// avoid Bun's AVX2 floor); arm64 targets build the standard host.
72    #[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    /// Archive extension: `.tar.gz` for Unix, `.zip` for Windows.
81    #[must_use]
82    pub const fn archive_extension(self) -> &'static str {
83        if self.is_windows() { "zip" } else { "tar.gz" }
84    }
85
86    /// Bun compile target used by the TypeScript host builder.
87    #[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    /// Directory prefix inside the archive.
99    #[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/// Which host build ships beside `pi`.
112#[derive(Copy, Clone, Debug, Eq, PartialEq)]
113pub enum HostVariant {
114    /// Compiled standalone host binary (`pi-extension-host[.exe]`). Preferred
115    /// when the runtime-import fixture passes for the target.
116    Compiled,
117    /// Official Bun runtime plus the host JavaScript bundle. Fallback when the
118    /// compiled host cannot run on the target.
119    RuntimeFallback,
120}
121
122impl HostVariant {
123    /// The host-side assets this variant contributes for a Unix target.
124    #[must_use]
125    pub fn assets(self) -> Vec<ReleaseAsset> {
126        self.assets_for(ReleaseTarget::LinuxX64)
127    }
128
129    /// The target-specific host assets this variant contributes (excluding `pi`).
130    #[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/// A single asset placed beside `pi` in the staging directory.
145#[derive(Clone, Debug, Eq, PartialEq)]
146pub struct ReleaseAsset {
147    /// Archive-relative path (forward slashes, no leading separator).
148    pub relative_path: String,
149    /// Whether the asset is an executable (normalized mode 0o755).
150    pub executable: bool,
151}
152
153impl ReleaseAsset {
154    /// The `pi` binary asset for `target`.
155    #[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    /// The compiled standalone host binary asset for a Unix target.
165    #[must_use]
166    pub fn host_compiled() -> Self {
167        Self::host_compiled_for(ReleaseTarget::LinuxX64)
168    }
169
170    /// The compiled standalone host binary asset for `target`.
171    #[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    /// The Bun runtime asset used by the runtime-plus-JavaScript fallback on Unix.
184    #[must_use]
185    pub fn host_runtime() -> Self {
186        Self::host_runtime_for(ReleaseTarget::LinuxX64)
187    }
188
189    /// The target-specific Bun runtime asset used by the fallback.
190    #[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    /// The host JavaScript bundle asset.
203    #[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/// A fully planned release for one target.
213#[derive(Clone, Debug, Eq, PartialEq)]
214pub struct ReleasePlan {
215    /// Target this plan builds.
216    pub target: ReleaseTarget,
217    /// Crate version being released.
218    pub version: String,
219    /// Which host variant ships beside the binary.
220    pub host_variant: HostVariant,
221    /// Cargo invocation (`cargo build -p pi-oxidized --release --locked --target <triple>`).
222    pub cargo_build: CommandSpec,
223    /// Host branch to build alongside the binary.
224    pub host_branch: &'static str,
225    /// Archive base name without extension (`pi-<version>-<archive-dir>`).
226    pub archive_base: String,
227    /// Archive extension (`tar.gz` or `zip`).
228    pub archive_extension: &'static str,
229    /// Complete asset list (pi binary plus host assets) in archive order.
230    pub assets: Vec<ReleaseAsset>,
231    /// Reproducibility manifest for the archive.
232    pub manifest: ArchiveManifest,
233}
234
235/// Reproducibility rules for archive assembly.
236#[derive(Clone, Debug, Eq, PartialEq)]
237pub struct ArchiveManifest {
238    /// Members sorted by normalized relative path.
239    pub sorted_members: Vec<ReleaseAsset>,
240    /// Fixed modification timestamp (Unix seconds) applied to every member.
241    pub fixed_mtime: u64,
242    /// Normalized numeric owner uid.
243    pub uid: u32,
244    /// Normalized numeric owner gid.
245    pub gid: u32,
246}
247
248/// Default fixed mtime when `SOURCE_DATE_EPOCH` is unset. This matches the
249/// canonical TypeScript release path and CI environment.
250pub const DEFAULT_FIXED_MTIME: u64 = 0;
251
252/// Resolve the fixed archive mtime from `SOURCE_DATE_EPOCH`, falling back to
253/// [`DEFAULT_FIXED_MTIME`].
254///
255/// # Errors
256///
257/// Returns an error string when `SOURCE_DATE_EPOCH` is set but not a valid
258/// non-negative integer.
259pub 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
271/// Build a [`ReleasePlan`] for `target` at `version`.
272///
273/// `host_variant` selects the compiled host or the runtime-plus-JavaScript
274/// fallback so the plan's asset list and manifest always describe the exact
275/// archive that will be produced. `source_date_epoch` mirrors the
276/// `SOURCE_DATE_EPOCH` environment variable (pass
277/// `std::env::var("SOURCE_DATE_EPOCH").ok().as_deref()` in production); `None`
278/// falls back to [`DEFAULT_FIXED_MTIME`].
279///
280/// # Errors
281///
282/// Returns an error when `SOURCE_DATE_EPOCH` is malformed, or when an asset
283/// name fails [`validate_asset_name`].
284pub 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/// Full archive file name (`<archive_base>.<extension>`).
331#[must_use]
332pub fn archive_file_name(plan: &ReleasePlan) -> String {
333    format!("{}.{}", plan.archive_base, plan.archive_extension)
334}
335
336/// Validate that an archive member name is safe and portable.
337///
338/// Rejects empty names, any path separator (`/` or `\`), leading `-`
339/// (option-injection in archive tools), parent traversal (`..`), Windows drive
340/// prefixes, and non-printable control characters. A safe name is a single
341/// path component with no shell or archive-tool metacharacter risk.
342///
343/// # Errors
344///
345/// Returns a description of the violation.
346pub 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/// Returns `true` when `member` stays inside `staging` after normalization.
369///
370/// Containment is checked lexically (without following symlinks out of the
371/// staging tree) so a malicious or malformed member cannot escape the archive
372/// root via `..` segments.
373#[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            // Prefix (Windows drive) or RootDir means an absolute escape.
385            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/// Errors produced by [`ReleaseRunner::run_build`].
395#[derive(Debug, thiserror::Error)]
396pub enum ReleaseError {
397    /// The cargo build step failed.
398    #[error("cargo build for {target} failed: {source}")]
399    BuildFailed {
400        /// Target triple that failed.
401        target: &'static str,
402        /// Underlying process error.
403        #[source]
404        source: io::Error,
405    },
406}
407
408/// Injectable runner that executes the release plan's cargo build step.
409pub trait ReleaseRunner {
410    /// Run the planned cargo build for `plan`.
411    ///
412    /// # Errors
413    ///
414    /// Returns [`ReleaseError::BuildFailed`] on process failure.
415    fn run_build(&mut self, plan: &ReleasePlan) -> Result<(), ReleaseError>;
416}
417
418/// Runner backed by a [`CommandRunner`].
419pub struct SystemReleaseRunner {
420    /// Underlying process runner.
421    pub runner: Box<dyn CommandRunner>,
422}
423
424impl SystemReleaseRunner {
425    /// Construct a runner over a process [`CommandRunner`].
426    #[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/// Resolve the on-disk path of an asset within a staging directory.
445///
446/// The result is always `staging.join(relative_path)`; callers should still
447/// pass it through [`is_within_staging`] before reading or packing it.
448#[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        // manifest mirrors the asset list exactly.
624        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    /// Command runner that always succeeds with a zero exit.
715    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    /// Command runner that always fails.
730    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        // The exact cargo argv is pinned by `plan_cargo_invocation_matches_contract`;
751        // here we only assert the runner surfaces success from the command runner.
752        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}