pub mod content_sniff;
pub mod policy;
#[cfg(target_os = "linux")]
mod linux_jail;
#[cfg(windows)]
mod windows_job;
pub use content_sniff::{Suspicion, SuspicionKind, sniff_lifecycle};
pub use policy::{AllowDecision, BuildPolicy, BuildPolicyError, pattern_matches};
use aube_manifest::PackageJson;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default)]
pub struct ScriptSettings {
pub node_options: Option<String>,
pub script_shell: Option<PathBuf>,
pub unsafe_perm: Option<bool>,
pub shell_emulator: bool,
pub node_bin_dir: Option<PathBuf>,
pub node_program: Option<PathBuf>,
pub node_execpath: Option<PathBuf>,
pub extra_env: Vec<(std::ffi::OsString, std::ffi::OsString)>,
pub command: Option<String>,
pub node_gyp_js: Option<PathBuf>,
pub http_proxy: Option<String>,
pub https_proxy: Option<String>,
pub no_proxy: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ScriptJail {
pub package_dir: PathBuf,
pub env: Vec<String>,
pub read_paths: Vec<PathBuf>,
pub write_paths: Vec<PathBuf>,
pub network: bool,
}
impl ScriptJail {
pub fn new(package_dir: impl Into<PathBuf>) -> Self {
Self {
package_dir: package_dir.into(),
env: Vec::new(),
read_paths: Vec::new(),
write_paths: Vec::new(),
network: false,
}
}
pub fn with_env(mut self, env: impl IntoIterator<Item = String>) -> Self {
self.env = env.into_iter().collect();
self
}
pub fn with_read_paths(mut self, paths: impl IntoIterator<Item = PathBuf>) -> Self {
self.read_paths = paths.into_iter().collect();
self
}
pub fn with_write_paths(mut self, paths: impl IntoIterator<Item = PathBuf>) -> Self {
self.write_paths = paths.into_iter().collect();
self
}
pub fn with_network(mut self, network: bool) -> Self {
self.network = network;
self
}
}
pub struct ScriptJailHomeCleanup {
path: PathBuf,
}
impl ScriptJailHomeCleanup {
pub fn new(jail: &ScriptJail) -> Self {
Self {
path: jail_home(&jail.package_dir),
}
}
}
impl Drop for ScriptJailHomeCleanup {
fn drop(&mut self) {
if self.path.exists()
&& let Err(err) = std::fs::remove_dir_all(&self.path)
{
tracing::debug!("failed to clean jail HOME {}: {err}", self.path.display());
}
}
}
#[derive(Debug, Clone, Default)]
struct ScriptSettingsState {
settings: ScriptSettings,
node_bin_dir_precedes_project_bins: bool,
}
static SCRIPT_SETTINGS: std::sync::OnceLock<std::sync::RwLock<ScriptSettingsState>> =
std::sync::OnceLock::new();
type ScriptSettingsSlot = std::sync::Arc<std::sync::RwLock<ScriptSettingsState>>;
tokio::task_local! {
static INSTALL_SCRIPT_SETTINGS: ScriptSettingsSlot;
}
pub async fn scope<F: std::future::Future>(future: F) -> F::Output {
INSTALL_SCRIPT_SETTINGS
.scope(
std::sync::Arc::new(std::sync::RwLock::new(ScriptSettingsState::default())),
future,
)
.await
}
pub fn scope_current<F: std::future::Future>(
future: F,
) -> impl std::future::Future<Output = F::Output> {
let settings = INSTALL_SCRIPT_SETTINGS.try_with(std::sync::Arc::clone).ok();
async move {
match settings {
Some(settings) => INSTALL_SCRIPT_SETTINGS.scope(settings, future).await,
None => future.await,
}
}
}
fn script_settings_lock() -> &'static std::sync::RwLock<ScriptSettingsState> {
SCRIPT_SETTINGS.get_or_init(|| std::sync::RwLock::new(ScriptSettingsState::default()))
}
pub fn set_script_settings(settings: ScriptSettings) {
set_script_settings_with_path_order(settings, false);
}
#[doc(hidden)]
pub fn set_script_settings_with_path_order(
settings: ScriptSettings,
node_bin_dir_precedes_project_bins: bool,
) {
let state = ScriptSettingsState {
settings,
node_bin_dir_precedes_project_bins,
};
if INSTALL_SCRIPT_SETTINGS
.try_with(|slot| match slot.write() {
Ok(mut guard) => *guard = state.clone(),
Err(poisoned) => *poisoned.into_inner() = state.clone(),
})
.is_ok()
{
return;
}
match script_settings_lock().write() {
Ok(mut guard) => *guard = state,
Err(poisoned) => *poisoned.into_inner() = state,
}
}
fn script_settings_state() -> ScriptSettingsState {
if let Ok(state) = INSTALL_SCRIPT_SETTINGS.try_with(|slot| match slot.read() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}) {
return state;
}
match script_settings_lock().read() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
fn script_settings() -> ScriptSettings {
script_settings_state().settings
}
#[cfg(test)]
mod scoped_settings_tests {
use super::*;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn install_script_settings_are_isolated_and_propagated() {
let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(2));
let first_barrier = std::sync::Arc::clone(&barrier);
let second_barrier = std::sync::Arc::clone(&barrier);
let first = scope(async move {
set_script_settings_with_path_order(
ScriptSettings {
command: Some("first".to_string()),
..ScriptSettings::default()
},
true,
);
first_barrier.wait().await;
tokio::spawn(scope_current(async {
let state = script_settings_state();
(
state.settings.command,
state.node_bin_dir_precedes_project_bins,
)
}))
.await
.unwrap()
});
let second = scope(async move {
set_script_settings(ScriptSettings {
command: Some("second".to_string()),
..ScriptSettings::default()
});
second_barrier.wait().await;
tokio::spawn(scope_current(async {
let state = script_settings_state();
(
state.settings.command,
state.node_bin_dir_precedes_project_bins,
)
}))
.await
.unwrap()
});
let (first, second) = tokio::join!(first, second);
assert_eq!(first.0.as_deref(), Some("first"));
assert!(first.1);
assert_eq!(second.0.as_deref(), Some("second"));
assert!(!second.1);
}
}
pub fn prepend_path(bin_dir: &Path) -> std::ffi::OsString {
prepend_paths(std::slice::from_ref(&bin_dir.to_path_buf()))
}
pub fn prepend_paths(bin_dirs: &[PathBuf]) -> std::ffi::OsString {
let path = std::env::var_os("PATH").unwrap_or_default();
let mut entries: Vec<PathBuf> = bin_dirs.to_vec();
entries.extend(std::env::split_paths(&path));
std::env::join_paths(entries).unwrap_or(path)
}
pub fn order_path_entries(
mut project_bins: Vec<PathBuf>,
runtime_bin: Option<&Path>,
runtime_precedes_project_bins: bool,
) -> Vec<PathBuf> {
let Some(runtime_bin) = runtime_bin else {
return project_bins;
};
if runtime_precedes_project_bins {
project_bins.insert(0, runtime_bin.to_path_buf());
} else {
project_bins.push(runtime_bin.to_path_buf());
}
project_bins
}
#[cfg(test)]
mod path_entry_tests {
use super::*;
#[test]
fn wrapper_runtime_leads_project_bins() {
let runtime = Path::new("/shim");
let project = PathBuf::from("/project/node_modules/.bin");
assert_eq!(
order_path_entries(vec![project.clone()], Some(runtime), true),
vec![runtime.to_path_buf(), project]
);
}
#[test]
fn selector_runtime_follows_project_bins() {
let runtime = Path::new("/opt/node/bin");
let project = PathBuf::from("/project/node_modules/.bin");
assert_eq!(
order_path_entries(vec![project.clone()], Some(runtime), false),
vec![project, runtime.to_path_buf()]
);
}
}
pub fn spawn_shell(script_cmd: &str) -> tokio::process::Command {
let settings = script_settings();
spawn_shell_with_settings(script_cmd, &settings)
}
fn spawn_shell_with_settings(
script_cmd: &str,
settings: &ScriptSettings,
) -> tokio::process::Command {
#[cfg(unix)]
let mut cmd = {
let mut cmd = tokio::process::Command::new(
settings
.script_shell
.as_deref()
.unwrap_or_else(|| Path::new("sh")),
);
cmd.arg("-c").arg(script_cmd);
cmd
};
#[cfg(windows)]
let mut cmd = {
let mut cmd = tokio::process::Command::new(
settings
.script_shell
.as_deref()
.unwrap_or_else(|| Path::new("cmd.exe")),
);
if settings.script_shell.is_some() {
cmd.arg("-c").arg(script_cmd);
} else {
cmd.raw_arg("/d /s /c \"").raw_arg(script_cmd).raw_arg("\"");
}
cmd
};
apply_script_settings_env(&mut cmd, settings);
cmd.kill_on_drop(true);
cmd
}
#[cfg(target_os = "macos")]
fn sbpl_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
#[cfg(target_os = "macos")]
fn push_write_rule(rules: &mut Vec<String>, path: &Path) {
let path = sbpl_escape(&path.to_string_lossy());
let rule = format!("(allow file-write* (subpath \"{path}\"))");
if !rules.iter().any(|existing| existing == &rule) {
rules.push(rule);
}
}
#[cfg(target_os = "macos")]
fn jail_profile(jail: &ScriptJail, home: &Path) -> String {
let mut rules = vec![
"(version 1)".to_string(),
"(allow default)".to_string(),
"(allow network* (local unix))".to_string(),
"(deny file-write*)".to_string(),
];
if !jail.network {
rules.insert(2, "(deny network*)".to_string());
}
for path in [
Path::new("/tmp"),
Path::new("/private/tmp"),
Path::new("/dev"),
] {
push_write_rule(&mut rules, path);
}
for path in [&jail.package_dir, home] {
push_write_rule(&mut rules, path);
}
for path in &jail.write_paths {
push_write_rule(&mut rules, path);
}
for path in [&jail.package_dir, home] {
if let Ok(canonical) = path.canonicalize() {
push_write_rule(&mut rules, &canonical);
}
}
for path in &jail.write_paths {
if let Ok(canonical) = path.canonicalize() {
push_write_rule(&mut rules, &canonical);
}
}
rules.join("\n")
}
#[cfg(target_os = "macos")]
fn spawn_jailed_shell(
script_cmd: &str,
settings: &ScriptSettings,
jail: &ScriptJail,
home: &Path,
) -> tokio::process::Command {
let shell = settings
.script_shell
.as_deref()
.unwrap_or_else(|| Path::new("sh"));
let profile = jail_profile(jail, home);
let mut cmd = tokio::process::Command::new("sandbox-exec");
cmd.arg("-p")
.arg(profile)
.arg("--")
.arg(shell)
.arg("-c")
.arg(script_cmd);
apply_script_settings_env(&mut cmd, settings);
cmd.kill_on_drop(true);
cmd
}
#[cfg(target_os = "linux")]
fn spawn_jailed_shell(
script_cmd: &str,
settings: &ScriptSettings,
jail: &ScriptJail,
home: &Path,
) -> tokio::process::Command {
let mut cmd = spawn_shell_with_settings(script_cmd, settings);
let jail = jail.clone();
let home = home.to_path_buf();
unsafe {
cmd.pre_exec(move || {
linux_jail::apply_landlock(&jail, &home).map_err(std::io::Error::other)?;
if !jail.network {
linux_jail::apply_seccomp_net_filter().map_err(std::io::Error::other)?;
}
Ok(())
});
}
cmd
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn spawn_jailed_shell(
script_cmd: &str,
settings: &ScriptSettings,
_jail: &ScriptJail,
_home: &Path,
) -> tokio::process::Command {
spawn_shell_with_settings(script_cmd, settings)
}
pub fn shell_quote_arg(arg: &str) -> String {
#[cfg(unix)]
{
let mut out = String::with_capacity(arg.len() + 2);
out.push('\'');
for ch in arg.chars() {
if ch == '\'' {
out.push_str("'\\''");
} else {
out.push(ch);
}
}
out.push('\'');
out
}
#[cfg(windows)]
{
let mut out = String::with_capacity(arg.len() + 2);
out.push('"');
let mut backslashes: usize = 0;
for ch in arg.chars() {
match ch {
'\\' => backslashes += 1,
'"' => {
for _ in 0..backslashes * 2 + 1 {
out.push('\\');
}
out.push('"');
backslashes = 0;
}
'%' => {
for _ in 0..backslashes {
out.push('\\');
}
backslashes = 0;
out.push_str("%%");
}
_ => {
for _ in 0..backslashes {
out.push('\\');
}
backslashes = 0;
out.push(ch);
}
}
}
for _ in 0..backslashes * 2 {
out.push('\\');
}
out.push('"');
out
}
}
pub fn exit_code_from_status(status: std::process::ExitStatus) -> i32 {
if let Some(code) = status.code() {
return code;
}
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(sig) = status.signal() {
return 128 + sig;
}
}
1
}
pub fn aube_user_agent() -> String {
format!(
"{} {} {}",
aube_util::embedder().user_agent,
node_platform(),
node_arch(),
)
}
fn node_platform() -> &'static str {
match std::env::consts::OS {
"macos" => "darwin",
"windows" => "win32",
other => other,
}
}
fn node_arch() -> &'static str {
match std::env::consts::ARCH {
"x86_64" => "x64",
"aarch64" => "arm64",
"x86" => "ia32",
"powerpc" => "ppc",
"powerpc64" => "ppc64",
"loongarch64" => "loong64",
other => other,
}
}
fn apply_script_settings_env(cmd: &mut tokio::process::Command, settings: &ScriptSettings) {
cmd.env_remove("AUBE_AUTH_TOKEN");
cmd.env("npm_config_user_agent", aube_user_agent());
let aube_exe = std::env::current_exe().ok();
if let Some(exe) = aube_exe.as_deref() {
cmd.env("npm_execpath", exe);
}
let node_execpath = settings
.node_execpath
.as_deref()
.or(settings.node_program.as_deref());
if let Some(execpath) = node_execpath {
cmd.env("npm_node_execpath", execpath);
}
if let Some(node) = settings.node_program.as_deref().or(node_execpath) {
cmd.env("NODE", node);
}
if let Some(command) = settings.command.as_deref() {
cmd.env("npm_command", command);
}
if let Some(node_gyp_js) = settings.node_gyp_js.as_deref() {
cmd.env("npm_config_node_gyp", node_gyp_js);
if let Some(exe) = aube_exe.as_deref() {
cmd.env("AUBE_NODE_GYP_EXE", exe);
}
}
if let Some(node_options) = settings.node_options.as_deref() {
cmd.env("NODE_OPTIONS", node_options);
}
if let Some(unsafe_perm) = settings.unsafe_perm {
cmd.env(
"npm_config_unsafe_perm",
if unsafe_perm { "true" } else { "false" },
);
}
if settings.shell_emulator {
cmd.env("npm_config_shell_emulator", "true");
}
if settings.http_proxy.is_some() || settings.https_proxy.is_some() {
if let Some(https) = settings.https_proxy.as_deref() {
cmd.env("HTTPS_PROXY", https);
}
if let Some(http) = settings.http_proxy.as_deref() {
cmd.env("HTTP_PROXY", http);
}
if let Some(no_proxy) = settings.no_proxy.as_deref() {
cmd.env("NO_PROXY", no_proxy);
}
cmd.env("NODE_USE_ENV_PROXY", "1");
}
for (key, value) in &settings.extra_env {
cmd.env(key, value);
}
}
pub fn apply_npm_manifest_env(
cmd: &mut tokio::process::Command,
manifest: &PackageJson,
script_dir: &Path,
lifecycle_script: &str,
) {
for (key, _) in std::env::vars_os() {
if key.to_str().is_some_and(|k| k.starts_with("npm_package_")) {
cmd.env_remove(&key);
}
}
cmd.env("npm_lifecycle_script", lifecycle_script);
cmd.env("npm_package_json", script_dir.join("package.json"));
for (key, value) in manifest.npm_package_env() {
cmd.env(key, value);
}
}
fn safe_jail_env_key(key: &str) -> bool {
const EXACT: &[&str] = &[
"PATH",
"HOME",
"TERM",
"LANG",
"LC_ALL",
"INIT_CWD",
"npm_lifecycle_event",
"npm_package_name",
"npm_package_version",
];
if EXACT.contains(&key) {
return true;
}
let lower = key.to_ascii_lowercase();
if lower.contains("token")
|| lower.contains("auth")
|| lower.contains("password")
|| lower.contains("credential")
|| lower.contains("secret")
{
return false;
}
key.starts_with("npm_config_")
}
fn inherit_jail_env_key(key: &str, extra_env: &[String]) -> bool {
(safe_jail_env_key(key) || extra_env.iter().any(|env| env == key))
&& !matches!(
key,
"PATH" | "HOME" | "npm_lifecycle_event" | "npm_package_name" | "npm_package_version"
)
}
fn jail_home(package_dir: &Path) -> PathBuf {
let mut hasher = DefaultHasher::new();
package_dir.hash(&mut hasher);
let hash = hasher.finish();
let name = package_dir
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("package")
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
c
} else {
'_'
}
})
.collect::<String>();
std::env::temp_dir()
.join("aube-jail")
.join(std::process::id().to_string())
.join(format!("{name}-{hash:016x}"))
}
fn apply_jail_env(
cmd: &mut tokio::process::Command,
path_env: &std::ffi::OsStr,
home: &Path,
project_root: &Path,
manifest: &PackageJson,
script_name: &str,
extra_env: &[String],
) {
cmd.env_clear();
cmd.env("PATH", path_env)
.env("HOME", home)
.env("TMPDIR", home)
.env("TMP", home)
.env("TEMP", home)
.env("npm_lifecycle_event", script_name);
if std::env::var_os("INIT_CWD").is_none() {
cmd.env("INIT_CWD", project_root);
}
if let Some(ref name) = manifest.name {
cmd.env("npm_package_name", name);
}
if let Some(ref version) = manifest.version {
cmd.env("npm_package_version", version);
}
for (key, val) in std::env::vars_os() {
let Some(key_str) = key.to_str() else {
continue;
};
if inherit_jail_env_key(key_str, extra_env) {
cmd.env(key, val);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LifecycleHook {
PreInstall,
Install,
PostInstall,
Prepare,
}
impl LifecycleHook {
pub fn script_name(self) -> &'static str {
match self {
Self::PreInstall => "preinstall",
Self::Install => "install",
Self::PostInstall => "postinstall",
Self::Prepare => "prepare",
}
}
}
pub const DEP_LIFECYCLE_HOOKS: [LifecycleHook; 3] = [
LifecycleHook::PreInstall,
LifecycleHook::Install,
LifecycleHook::PostInstall,
];
#[cfg(unix)]
static SAVED_STDERR_FD: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
#[cfg(unix)]
pub fn set_saved_stderr_fd(fd: std::os::fd::RawFd) {
SAVED_STDERR_FD.store(fd, std::sync::atomic::Ordering::SeqCst);
}
#[cfg(not(unix))]
pub fn set_saved_stderr_fd(_fd: i32) {}
#[cfg(unix)]
pub fn child_stderr() -> std::process::Stdio {
let fd = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
if fd < 0 {
return std::process::Stdio::inherit();
}
let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
match borrowed.try_clone_to_owned() {
Ok(owned) => std::process::Stdio::from(owned),
Err(_) => std::process::Stdio::inherit(),
}
}
#[cfg(not(unix))]
pub fn child_stderr() -> std::process::Stdio {
std::process::Stdio::inherit()
}
#[cfg(unix)]
pub fn write_line_to_real_stderr(line: &str) {
use std::io::Write;
let saved = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
let fd = if saved >= 0 { saved } else { 2 };
let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
let Ok(owned) = borrowed.try_clone_to_owned() else {
return;
};
let mut file = std::fs::File::from(owned);
let mut buf = String::with_capacity(line.len() + 1);
buf.push_str(line);
buf.push('\n');
let _ = file.write_all(buf.as_bytes());
}
#[cfg(not(unix))]
pub fn write_line_to_real_stderr(line: &str) {
eprintln!("{line}");
}
async fn run_command_killing_descendants(
mut cmd: tokio::process::Command,
script_name: &str,
) -> Result<std::process::ExitStatus, Error> {
let mut child = cmd
.spawn()
.map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
#[cfg(windows)]
let _job = match windows_job::JobObject::new() {
Ok(job) => {
if let Some(handle) = child.raw_handle()
&& let Err(err) = job.assign(handle)
{
tracing::warn!(
code = aube_codes::warnings::WARN_AUBE_WINDOWS_JOB_OBJECT_UNAVAILABLE,
"windows: AssignProcessToJobObject failed for `{script_name}` shell ({err}); \
grandchildren may be orphaned if the script is aborted"
);
}
Some(job)
}
Err(err) => {
tracing::warn!(
code = aube_codes::warnings::WARN_AUBE_WINDOWS_JOB_OBJECT_UNAVAILABLE,
"windows: CreateJobObjectW failed for `{script_name}` shell ({err}); \
running without orphan-reaping — grandchildren may leak if aborted"
);
None
}
};
child
.wait()
.await
.map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))
}
#[allow(clippy::too_many_arguments)]
pub async fn run_script(
script_dir: &Path,
project_root: &Path,
modules_dir_name: &str,
manifest: &PackageJson,
script_name: &str,
script_cmd: &str,
extra_bin_dirs: &[&Path],
jail: Option<&ScriptJail>,
) -> Result<(), Error> {
let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Script, "run_script")
.with_meta_fn(|| {
let pkg = manifest.name.as_deref().unwrap_or("(root)");
format!(
r#"{{"pkg":{},"script":{}}}"#,
aube_util::diag::jstr(pkg),
aube_util::diag::jstr(script_name)
)
});
let project_bin = project_root.join(modules_dir_name).join(".bin");
let state = script_settings_state();
let settings = &state.settings;
let path = std::env::var_os("PATH").unwrap_or_default();
let mut project_bins: Vec<PathBuf> = Vec::with_capacity(extra_bin_dirs.len() + 1);
for dir in extra_bin_dirs {
project_bins.push(dir.to_path_buf());
}
project_bins.push(project_bin);
let mut entries = order_path_entries(
project_bins,
settings.node_bin_dir.as_deref(),
state.node_bin_dir_precedes_project_bins,
);
entries.extend(std::env::split_paths(&path));
let new_path = std::env::join_paths(entries).unwrap_or(path);
let jail_home = jail.map(|j| jail_home(&j.package_dir));
if let Some(home) = &jail_home {
std::fs::create_dir_all(home)
.map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
}
let mut cmd = match (jail, jail_home.as_deref()) {
(Some(jail), Some(home)) => spawn_jailed_shell(script_cmd, settings, jail, home),
_ => spawn_shell_with_settings(script_cmd, settings),
};
cmd.current_dir(script_dir)
.stderr(child_stderr())
.env("PATH", &new_path)
.env("npm_lifecycle_event", script_name);
if std::env::var_os("INIT_CWD").is_none() {
cmd.env("INIT_CWD", project_root);
}
if let (Some(jail), Some(home)) = (jail, jail_home.as_deref()) {
apply_jail_env(
&mut cmd,
&new_path,
home,
project_root,
manifest,
script_name,
&jail.env,
);
apply_script_settings_env(&mut cmd, settings);
}
apply_npm_manifest_env(&mut cmd, manifest, script_dir, script_cmd);
tracing::debug!("lifecycle: {script_name} → {script_cmd}");
let status = run_command_killing_descendants(cmd, script_name).await?;
if !status.success() {
return Err(Error::NonZeroExit {
script: script_name.to_string(),
code: status.code(),
});
}
Ok(())
}
pub async fn run_root_hook(
project_dir: &Path,
modules_dir_name: &str,
manifest: &PackageJson,
hook: LifecycleHook,
) -> Result<bool, Error> {
run_root_script_by_name(project_dir, modules_dir_name, manifest, hook.script_name()).await
}
pub async fn run_root_script_by_name(
project_dir: &Path,
modules_dir_name: &str,
manifest: &PackageJson,
name: &str,
) -> Result<bool, Error> {
let Some(script_cmd) = manifest.scripts.get(name) else {
return Ok(false);
};
run_script(
project_dir,
project_dir,
modules_dir_name,
manifest,
name,
script_cmd,
&[],
None,
)
.await?;
Ok(true)
}
pub fn implicit_install_script(
manifest: &PackageJson,
has_binding_gyp: bool,
) -> Option<&'static str> {
if !has_binding_gyp {
return None;
}
if manifest
.scripts
.contains_key(LifecycleHook::Install.script_name())
|| manifest
.scripts
.contains_key(LifecycleHook::PreInstall.script_name())
{
return None;
}
Some("node-gyp rebuild")
}
pub fn default_install_script(package_dir: &Path, manifest: &PackageJson) -> Option<&'static str> {
implicit_install_script(manifest, package_dir.join("binding.gyp").is_file())
}
pub fn has_dep_lifecycle_work(package_dir: &Path, manifest: &PackageJson) -> bool {
if DEP_LIFECYCLE_HOOKS
.iter()
.any(|h| manifest.scripts.contains_key(h.script_name()))
{
return true;
}
default_install_script(package_dir, manifest).is_some()
}
#[allow(clippy::too_many_arguments)]
pub async fn run_dep_hook(
package_dir: &Path,
dep_modules_dir: &Path,
project_root: &Path,
modules_dir_name: &str,
manifest: &PackageJson,
hook: LifecycleHook,
tool_bin_dirs: &[&Path],
jail: Option<&ScriptJail>,
) -> Result<bool, Error> {
let name = hook.script_name();
let script_cmd: &str = match manifest.scripts.get(name) {
Some(s) => s.as_str(),
None => match hook {
LifecycleHook::Install => match default_install_script(package_dir, manifest) {
Some(s) => s,
None => return Ok(false),
},
_ => return Ok(false),
},
};
let dep_bin_dir = dep_modules_dir.join(".bin");
let mut bin_dirs: Vec<&Path> = Vec::with_capacity(tool_bin_dirs.len() + 1);
bin_dirs.push(&dep_bin_dir);
bin_dirs.extend(tool_bin_dirs.iter().copied());
run_script(
package_dir,
project_root,
modules_dir_name,
manifest,
name,
script_cmd,
&bin_dirs,
jail,
)
.await?;
Ok(true)
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum Error {
#[error("failed to spawn script {0}: {1}")]
#[diagnostic(code(ERR_AUBE_SCRIPT_SPAWN))]
Spawn(String, String),
#[error("script `{script}` exited with code {code:?}")]
#[diagnostic(code(ERR_AUBE_SCRIPT_NON_ZERO_EXIT))]
NonZeroExit { script: String, code: Option<i32> },
}
#[cfg(test)]
mod user_agent_tests {
use super::*;
#[test]
fn user_agent_uses_node_style_platform_and_arch() {
let ua = aube_user_agent();
assert!(ua.starts_with("aube/"), "unexpected prefix: {ua}");
let parts: Vec<&str> = ua.split(' ').collect();
assert_eq!(parts.len(), 3, "expected 3 space-separated fields: {ua}");
let platform = parts[1];
assert!(
matches!(
platform,
"darwin" | "linux" | "win32" | "freebsd" | "openbsd" | "netbsd" | "dragonfly"
),
"platform `{platform}` should follow Node's `process.platform` vocabulary"
);
let arch = parts[2];
assert!(
matches!(
arch,
"x64"
| "arm64"
| "ia32"
| "arm"
| "ppc"
| "ppc64"
| "loong64"
| "mips"
| "riscv64"
| "s390x"
),
"arch `{arch}` should follow Node's `process.arch` vocabulary"
);
}
}
#[cfg(test)]
mod jail_tests {
use super::*;
#[test]
fn jail_home_uses_full_package_path() {
let a = jail_home(Path::new("/tmp/project/node_modules/@scope-a/native"));
let b = jail_home(Path::new("/tmp/project/node_modules/@scope-b/native"));
assert_ne!(a, b);
assert!(
a.file_name()
.unwrap()
.to_string_lossy()
.starts_with("native-")
);
assert!(
b.file_name()
.unwrap()
.to_string_lossy()
.starts_with("native-")
);
}
#[test]
fn jail_home_cleanup_removes_temp_home() {
let package_dir = std::env::temp_dir()
.join("aube-jail-cleanup-test")
.join(std::process::id().to_string())
.join("node_modules")
.join("native");
let jail = ScriptJail::new(&package_dir);
let home = jail_home(&package_dir);
std::fs::create_dir_all(home.join(".cache")).unwrap();
std::fs::write(home.join(".cache").join("marker"), "x").unwrap();
{
let _cleanup = ScriptJailHomeCleanup::new(&jail);
}
assert!(!home.exists());
}
#[test]
fn parent_env_cannot_override_explicit_jail_metadata() {
for key in [
"PATH",
"HOME",
"npm_lifecycle_event",
"npm_package_name",
"npm_package_version",
] {
assert!(!inherit_jail_env_key(key, &[]));
}
assert!(inherit_jail_env_key("INIT_CWD", &[]));
assert!(inherit_jail_env_key("npm_config_arch", &[]));
assert!(!inherit_jail_env_key("npm_config__authToken", &[]));
assert!(inherit_jail_env_key(
"SHARP_DIST_BASE_URL",
&["SHARP_DIST_BASE_URL".to_string()]
));
}
#[test]
fn jail_env_preserves_script_settings_after_clear() {
let mut cmd = tokio::process::Command::new("node");
let manifest = PackageJson {
name: Some("pkg".to_string()),
version: Some("1.2.3".to_string()),
..Default::default()
};
let settings = ScriptSettings {
node_options: Some("--conditions=aube".to_string()),
unsafe_perm: Some(false),
shell_emulator: true,
..Default::default()
};
apply_jail_env(
&mut cmd,
std::ffi::OsStr::new("/bin"),
Path::new("/tmp/aube-jail/home"),
Path::new("/tmp/project"),
&manifest,
"postinstall",
&[],
);
apply_script_settings_env(&mut cmd, &settings);
let envs = cmd.as_std().get_envs().collect::<Vec<_>>();
let env = |name: &str| {
envs.iter()
.find(|(key, _)| *key == std::ffi::OsStr::new(name))
.and_then(|(_, val)| *val)
.and_then(|val| val.to_str())
};
assert_eq!(env("NODE_OPTIONS"), Some("--conditions=aube"));
assert_eq!(env("npm_config_unsafe_perm"), Some("false"));
assert_eq!(env("npm_config_shell_emulator"), Some("true"));
assert_eq!(env("npm_lifecycle_event"), Some("postinstall"));
assert_eq!(env("npm_package_name"), Some("pkg"));
assert_eq!(env("npm_package_version"), Some("1.2.3"));
}
fn proxy_env(settings: ScriptSettings) -> impl Fn(&str) -> Option<String> {
let mut cmd = tokio::process::Command::new("node");
apply_script_settings_env(&mut cmd, &settings);
let envs: Vec<_> = cmd
.as_std()
.get_envs()
.map(|(k, v)| {
(
k.to_string_lossy().into_owned(),
v.map(|v| v.to_string_lossy().into_owned()),
)
})
.collect();
move |name: &str| {
envs.iter()
.find(|(k, _)| k == name)
.and_then(|(_, v)| v.clone())
}
}
#[test]
fn proxy_vars_stamped_when_proxy_configured() {
let env = proxy_env(ScriptSettings {
https_proxy: Some("http://proxy.example:8080".to_string()),
http_proxy: Some("http://proxy.example:8080".to_string()),
no_proxy: Some("localhost,127.0.0.1".to_string()),
..Default::default()
});
assert_eq!(
env("HTTPS_PROXY").as_deref(),
Some("http://proxy.example:8080")
);
assert_eq!(
env("HTTP_PROXY").as_deref(),
Some("http://proxy.example:8080")
);
assert_eq!(env("NO_PROXY").as_deref(), Some("localhost,127.0.0.1"));
assert_eq!(env("NODE_USE_ENV_PROXY").as_deref(), Some("1"));
}
#[test]
fn proxy_block_skipped_when_no_proxy_configured() {
let env = proxy_env(ScriptSettings::default());
assert_eq!(env("HTTPS_PROXY"), None);
assert_eq!(env("HTTP_PROXY"), None);
assert_eq!(env("NO_PROXY"), None);
assert_eq!(env("NODE_USE_ENV_PROXY"), None);
}
#[test]
fn no_proxy_alone_does_not_trigger_passthrough() {
let env = proxy_env(ScriptSettings {
no_proxy: Some("example.com".to_string()),
..Default::default()
});
assert_eq!(env("NO_PROXY"), None);
assert_eq!(env("NODE_USE_ENV_PROXY"), None);
}
#[test]
fn wrapper_node_and_execpath_are_stamped_distinctly() {
let env = proxy_env(ScriptSettings {
node_program: Some(PathBuf::from("/shim/node")),
node_execpath: Some(PathBuf::from("/real/node-24.4.1/bin/node")),
extra_env: vec![("MYTOOL_WRAPPED".into(), "1".into())],
..Default::default()
});
assert_eq!(env("NODE").as_deref(), Some("/shim/node"));
assert_eq!(
env("npm_node_execpath").as_deref(),
Some("/real/node-24.4.1/bin/node")
);
assert_eq!(env("MYTOOL_WRAPPED").as_deref(), Some("1"));
}
#[test]
fn node_execpath_falls_back_to_node_program() {
let env = proxy_env(ScriptSettings {
node_program: Some(PathBuf::from("/opt/node/bin/node")),
..Default::default()
});
assert_eq!(env("NODE").as_deref(), Some("/opt/node/bin/node"));
assert_eq!(
env("npm_node_execpath").as_deref(),
Some("/opt/node/bin/node")
);
}
}
#[cfg(all(test, windows))]
mod windows_quote_tests {
use super::shell_quote_arg;
#[test]
fn windows_path_backslash_not_doubled() {
let q = shell_quote_arg(r"C:\Users\me\file.txt");
assert_eq!(q, "\"C:\\Users\\me\\file.txt\"");
}
#[test]
fn windows_trailing_backslash_doubled_before_close_quote() {
let q = shell_quote_arg(r"C:\path\");
assert_eq!(q, "\"C:\\path\\\\\"");
}
#[test]
fn windows_quote_in_arg_escapes_with_backslash() {
assert_eq!(shell_quote_arg(r#"a"b"#), "\"a\\\"b\"");
assert_eq!(shell_quote_arg(r#"a\"b"#), "\"a\\\\\\\"b\"");
assert_eq!(shell_quote_arg(r#"a\\"b"#), "\"a\\\\\\\\\\\"b\"");
}
}
#[cfg(all(test, windows))]
mod windows_job_object_tests {
use super::*;
use std::time::{Duration, Instant};
use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
use windows_sys::Win32::System::Threading::{
GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
};
fn is_process_alive(pid: u32) -> bool {
unsafe {
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
if handle.is_null() {
return false;
}
let mut code: u32 = 0;
let ok = GetExitCodeProcess(handle, &mut code);
CloseHandle(handle);
ok != 0 && code == STILL_ACTIVE as u32
}
}
async fn wait_until<F: Fn() -> bool>(check: F, timeout: Duration) -> bool {
let start = Instant::now();
while !check() {
if start.elapsed() > timeout {
return false;
}
tokio::time::sleep(Duration::from_millis(75)).await;
}
true
}
#[tokio::test]
async fn aborting_script_kills_grandchildren() {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let pid_file = std::env::temp_dir().join(format!("aube-test-grandchild-{nanos}.pid"));
let script = format!(
"start /b powershell -NoProfile -WindowStyle Hidden -Command \
\"$pid | Out-File -Encoding ascii -FilePath '{}'; Start-Sleep 60\" \
& ping -n 10 127.0.0.1 >nul",
pid_file.display()
);
let cmd = spawn_shell_with_settings(&script, &ScriptSettings::default());
let task = tokio::spawn(async move {
let _ = run_command_killing_descendants(cmd, "test-grandchild").await;
});
let appeared = wait_until(
|| {
std::fs::read_to_string(&pid_file)
.ok()
.and_then(|pid| pid.trim().parse::<u32>().ok())
.is_some()
},
Duration::from_secs(20),
)
.await;
assert!(appeared, "grandchild never wrote pid file at {pid_file:?}");
let pid: u32 = std::fs::read_to_string(&pid_file)
.expect("read pid file")
.trim()
.parse()
.expect("pid file was parseable before reading");
assert!(
is_process_alive(pid),
"grandchild pid {pid} not alive immediately after writing pid file"
);
task.abort();
let _ = task.await;
let reaped = wait_until(|| !is_process_alive(pid), Duration::from_secs(10)).await;
let _ = std::fs::remove_file(&pid_file);
assert!(
reaped,
"grandchild pid {pid} survived parent abort — job object did not kill the tree"
);
}
}