Skip to main content

cli/
bun_runtime.rs

1use anyhow::{Context, Result, bail};
2use serde_json::Value;
3use std::path::Path;
4use tokio::process::Command;
5
6const PACKAGE_JSON: &str = "package.json";
7const LOCK_FILE: &str = "bun.lock";
8
9#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
10pub enum BunDependencyMode {
11    #[default]
12    Disabled,
13    Locked,
14}
15
16impl BunDependencyMode {
17    pub fn as_manifest_value(self) -> Option<&'static str> {
18        match self {
19            Self::Disabled => None,
20            Self::Locked => Some("locked"),
21        }
22    }
23
24    pub fn install_arg(self) -> &'static str {
25        match self {
26            Self::Disabled => "--no-install",
27            Self::Locked => "--install=fallback",
28        }
29    }
30}
31
32#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
33pub struct BunRuntimeSpec {
34    pub dependency_mode: BunDependencyMode,
35    pub dependency_hash: Option<u64>,
36}
37
38/// Resolve the Bun dependency policy for one physical preset category.
39///
40/// Built-in scripts always disable package installation, even if an overlay adds
41/// unrelated package files. External or overlay scripts may opt in by providing
42/// both package.json and bun.lock at their category root.
43pub fn resolve(category_root: &Path, allow_external_dependencies: bool) -> Result<BunRuntimeSpec> {
44    if !allow_external_dependencies {
45        return Ok(BunRuntimeSpec::default());
46    }
47
48    let package_path = category_root.join(PACKAGE_JSON);
49    let lock_path = category_root.join(LOCK_FILE);
50    let has_package = package_path.is_file();
51    let has_lock = lock_path.is_file();
52    match (has_package, has_lock) {
53        (false, false) => return Ok(BunRuntimeSpec::default()),
54        (true, false) => bail!(
55            "external Bun preset dependency declaration requires {} beside {}",
56            lock_path.display(),
57            package_path.display()
58        ),
59        (false, true) => bail!(
60            "external Bun preset dependency lock requires {} beside {}",
61            package_path.display(),
62            lock_path.display()
63        ),
64        (true, true) => {}
65    }
66
67    let package = std::fs::read(&package_path)
68        .with_context(|| format!("reading Bun preset package: {}", package_path.display()))?;
69    let parsed: Value = serde_json::from_slice(&package)
70        .with_context(|| format!("parsing Bun preset package: {}", package_path.display()))?;
71    if parsed.get("trustedDependencies").is_some() {
72        bail!(
73            "external Bun preset package must not declare trustedDependencies: {}",
74            package_path.display()
75        );
76    }
77
78    let lock = std::fs::read(&lock_path)
79        .with_context(|| format!("reading Bun preset lock: {}", lock_path.display()))?;
80    let mut bytes = Vec::with_capacity(package.len() + lock.len() + 1);
81    bytes.extend_from_slice(&package);
82    bytes.push(0);
83    bytes.extend_from_slice(&lock);
84    Ok(BunRuntimeSpec {
85        dependency_mode: BunDependencyMode::Locked,
86        dependency_hash: Some(crate::install_core::hash_content(&bytes)),
87    })
88}
89
90pub fn command(script: &Path, spec: BunRuntimeSpec) -> Command {
91    let mut command = Command::new("bun");
92    command.arg(spec.dependency_mode.install_arg()).arg(script);
93    command
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use std::path::PathBuf;
100
101    async fn temp_dir(name: &str) -> PathBuf {
102        crate::test_support::make_temp_dir(name).await
103    }
104
105    #[tokio::test]
106    async fn built_in_scripts_ignore_package_files() {
107        let dir = temp_dir("bun-runtime-built-in").await;
108        std::fs::write(
109            dir.join(PACKAGE_JSON),
110            r#"{"dependencies":{"zod":"4.0.0"}}"#,
111        )
112        .unwrap();
113        let spec = resolve(&dir, false).unwrap();
114        assert_eq!(spec, BunRuntimeSpec::default());
115        std::fs::remove_dir_all(dir).unwrap();
116    }
117
118    #[tokio::test]
119    async fn external_scripts_require_a_complete_locked_pair() {
120        let dir = temp_dir("bun-runtime-pair").await;
121        std::fs::write(
122            dir.join(PACKAGE_JSON),
123            r#"{"dependencies":{"zod":"4.0.0"}}"#,
124        )
125        .unwrap();
126        let error = resolve(&dir, true).unwrap_err().to_string();
127        assert!(error.contains("bun.lock"));
128        std::fs::remove_file(dir.join(PACKAGE_JSON)).unwrap();
129        std::fs::write(dir.join(LOCK_FILE), "{}").unwrap();
130        let error = resolve(&dir, true).unwrap_err().to_string();
131        assert!(error.contains("package.json"));
132        std::fs::remove_dir_all(dir).unwrap();
133    }
134
135    #[tokio::test]
136    async fn external_scripts_accept_locked_dependencies_and_hash_both_files() {
137        let dir = temp_dir("bun-runtime-locked").await;
138        std::fs::write(
139            dir.join(PACKAGE_JSON),
140            r#"{"dependencies":{"zod":"4.0.0"}}"#,
141        )
142        .unwrap();
143        std::fs::write(dir.join(LOCK_FILE), "lockfileVersion = 1\n").unwrap();
144        let first = resolve(&dir, true).unwrap();
145        assert_eq!(first.dependency_mode, BunDependencyMode::Locked);
146        assert!(first.dependency_hash.is_some());
147        std::fs::write(dir.join(LOCK_FILE), "lockfileVersion = 1\n# changed\n").unwrap();
148        let second = resolve(&dir, true).unwrap();
149        assert_ne!(first.dependency_hash, second.dependency_hash);
150        std::fs::remove_dir_all(dir).unwrap();
151    }
152
153    #[tokio::test]
154    async fn external_scripts_reject_trusted_dependencies() {
155        let dir = temp_dir("bun-runtime-trusted").await;
156        std::fs::write(dir.join(PACKAGE_JSON), r#"{"trustedDependencies":[]}"#).unwrap();
157        std::fs::write(dir.join(LOCK_FILE), "{}").unwrap();
158        let error = resolve(&dir, true).unwrap_err().to_string();
159        assert!(error.contains("trustedDependencies"));
160        std::fs::remove_dir_all(dir).unwrap();
161    }
162}