use aube_lockfile::LockfileGraph;
use aube_lockfile::dep_path_filename::dep_path_to_filename;
use aube_store::PackageIndex;
use rayon::prelude::*;
use std::collections::BTreeMap;
use std::path::Path;
#[derive(Debug)]
pub struct Mismatch {
pub package: String,
pub declared: String,
pub current: String,
}
pub fn resolve_node_version(override_: Option<&str>) -> Option<String> {
if let Some(v) = override_ {
return Some(v.trim().trim_start_matches('v').to_string());
}
static PROBED: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
PROBED.get_or_init(probe_node_version).clone()
}
fn probe_node_version() -> Option<String> {
let output = std::process::Command::new("node")
.arg("--version")
.output()
.ok()?;
if !output.status.success() {
return None;
}
let s = String::from_utf8(output.stdout).ok()?;
Some(s.trim().trim_start_matches('v').to_string())
}
fn node_range_satisfied(version: &str, range: &str) -> bool {
let Ok(v) = node_semver::Version::parse(version) else {
return true;
};
let Ok(r) = node_semver::Range::parse(range) else {
return true;
};
v.satisfies(&r)
}
fn check_engines_node(engines: &BTreeMap<String, String>, node_version: &str) -> Option<String> {
let node_range = engines.get("node")?;
if node_range_satisfied(node_version, node_range) {
None
} else {
Some(node_range.clone())
}
}
pub fn collect_dep_mismatches(
aube_dir: &Path,
graph: &LockfileGraph,
indices: &BTreeMap<String, PackageIndex>,
node_version: &str,
virtual_store_dir_max_length: usize,
) -> miette::Result<Vec<Mismatch>> {
use miette::miette;
let per_pkg: miette::Result<Vec<Option<Mismatch>>> = graph
.packages
.par_iter()
.map(|(dep_path, pkg)| -> miette::Result<Option<Mismatch>> {
let materialized = aube_dir
.join(dep_path_to_filename(dep_path, virtual_store_dir_max_length))
.join("node_modules")
.join(&pkg.name)
.join("package.json");
let content = match std::fs::read_to_string(&materialized) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let Some(stored) = indices
.get(dep_path)
.and_then(|idx| idx.get("package.json"))
else {
return Ok(None);
};
match std::fs::read_to_string(&stored.store_path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(miette!(
"failed to read CAS `package.json` for {}@{} at {}: {e}",
pkg.name,
pkg.version,
stored.store_path.display()
));
}
}
}
Err(e) => {
return Err(miette!(
"failed to read materialized `package.json` for {}@{} at {}: {e}",
pkg.name,
pkg.version,
materialized.display()
));
}
};
let parsed: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
miette!(
"failed to parse `package.json` for {}@{}: {e}",
pkg.name,
pkg.version
)
})?;
let Some(engines) = parsed.get("engines").and_then(|v| v.as_object()) else {
return Ok(None);
};
let Some(node_range) = engines.get("node").and_then(|v| v.as_str()) else {
return Ok(None);
};
if node_range_satisfied(node_version, node_range) {
Ok(None)
} else {
Ok(Some(Mismatch {
package: format!("{}@{}", pkg.name, pkg.version),
declared: node_range.to_string(),
current: node_version.to_string(),
}))
}
})
.collect();
Ok(per_pkg?.into_iter().flatten().collect())
}
pub fn check_root(manifest: &aube_manifest::PackageJson, node_version: &str) -> Option<Mismatch> {
let declared = check_engines_node(&manifest.engines, node_version)?;
Some(Mismatch {
package: manifest
.name
.clone()
.unwrap_or_else(|| "(root)".to_string()),
declared,
current: node_version.to_string(),
})
}
#[allow(clippy::too_many_arguments)]
pub fn run_checks(
aube_dir: &Path,
manifest: &aube_manifest::PackageJson,
graph: &LockfileGraph,
indices: &BTreeMap<String, PackageIndex>,
node_version: Option<&str>,
strict: bool,
virtual_store_dir_max_length: usize,
) -> miette::Result<()> {
let Some(node_version) = node_version else {
return Ok(());
};
let mut mismatches = Vec::new();
if let Some(m) = check_root(manifest, node_version) {
mismatches.push(m);
}
mismatches.extend(collect_dep_mismatches(
aube_dir,
graph,
indices,
node_version,
virtual_store_dir_max_length,
)?);
if mismatches.is_empty() {
return Ok(());
}
let header = if strict {
"Unsupported engine (engine-strict is on)"
} else {
"Unsupported engine"
};
eprintln!("warn: {header}");
for m in &mismatches {
eprintln!(
"warn: {}: wanted node {}, got {}",
m.package, m.declared, m.current,
);
}
if strict {
return Err(miette::miette!(
"engine-strict: {} package(s) require a Node version \
incompatible with {}",
mismatches.len(),
node_version,
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn range_satisfied_basic() {
assert!(node_range_satisfied("18.0.0", ">=16"));
assert!(!node_range_satisfied("14.0.0", ">=16"));
}
#[test]
fn unparseable_range_is_permissive() {
assert!(node_range_satisfied("18.0.0", "this-is-not-a-range"));
}
#[test]
fn check_root_skips_when_no_engines() {
let m = aube_manifest::PackageJson {
name: Some("x".into()),
version: None,
dependencies: Default::default(),
dev_dependencies: Default::default(),
peer_dependencies: Default::default(),
optional_dependencies: Default::default(),
update_config: None,
scripts: Default::default(),
engines: Default::default(),
workspaces: None,
bundled_dependencies: None,
extra: Default::default(),
};
assert!(check_root(&m, "18.0.0").is_none());
}
#[test]
fn check_root_flags_mismatch() {
let mut engines = BTreeMap::new();
engines.insert("node".into(), ">=20".into());
let m = aube_manifest::PackageJson {
name: Some("x".into()),
version: None,
dependencies: Default::default(),
dev_dependencies: Default::default(),
peer_dependencies: Default::default(),
optional_dependencies: Default::default(),
update_config: None,
scripts: Default::default(),
engines,
workspaces: None,
bundled_dependencies: None,
extra: Default::default(),
};
assert!(check_root(&m, "18.0.0").is_some());
}
#[test]
fn collect_dep_mismatches_reads_materialized_pkg_json() {
use aube_lockfile::dep_path_filename::DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH;
use aube_lockfile::{DepType, DirectDep, LockedPackage};
use std::collections::BTreeMap;
let tmp = tempfile::tempdir().unwrap();
let dep_path = "pkg@1.0.0";
let pkg_dir = tmp
.path()
.join("node_modules/.aube")
.join(dep_path_to_filename(
dep_path,
DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH,
))
.join("node_modules/pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(
pkg_dir.join("package.json"),
r#"{"name":"pkg","version":"1.0.0","engines":{"node":">=99"}}"#,
)
.unwrap();
let mut graph = LockfileGraph::default();
graph.packages.insert(
dep_path.into(),
LockedPackage {
name: "pkg".into(),
version: "1.0.0".into(),
..Default::default()
},
);
graph.importers.insert(
".".into(),
vec![DirectDep {
name: "pkg".into(),
dep_path: dep_path.into(),
dep_type: DepType::Production,
specifier: None,
}],
);
let indices: BTreeMap<String, PackageIndex> = BTreeMap::new();
let aube_dir = tmp.path().join("node_modules/.aube");
let mismatches = collect_dep_mismatches(
&aube_dir,
&graph,
&indices,
"18.0.0",
DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH,
)
.unwrap();
assert_eq!(mismatches.len(), 1, "engine mismatch should be surfaced");
assert_eq!(mismatches[0].package, "pkg@1.0.0");
assert_eq!(mismatches[0].declared, ">=99");
}
#[cfg(unix)]
#[test]
fn collect_dep_mismatches_propagates_non_not_found_io_errors() {
use aube_lockfile::dep_path_filename::DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH;
use aube_lockfile::{DepType, DirectDep, LockedPackage};
use std::collections::BTreeMap;
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let dep_path = "pkg@1.0.0";
let pkg_dir = tmp
.path()
.join("node_modules/.aube")
.join(dep_path_to_filename(
dep_path,
DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH,
))
.join("node_modules/pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
let pkg_json = pkg_dir.join("package.json");
std::fs::write(&pkg_json, r#"{"name":"pkg","version":"1.0.0"}"#).unwrap();
let mut perms = std::fs::metadata(&pkg_json).unwrap().permissions();
perms.set_mode(0o000);
std::fs::set_permissions(&pkg_json, perms).unwrap();
let mut graph = LockfileGraph::default();
graph.packages.insert(
dep_path.into(),
LockedPackage {
name: "pkg".into(),
version: "1.0.0".into(),
..Default::default()
},
);
graph.importers.insert(
".".into(),
vec![DirectDep {
name: "pkg".into(),
dep_path: dep_path.into(),
dep_type: DepType::Production,
specifier: None,
}],
);
let indices: BTreeMap<String, PackageIndex> = BTreeMap::new();
let aube_dir = tmp.path().join("node_modules/.aube");
let result = collect_dep_mismatches(
&aube_dir,
&graph,
&indices,
"18.0.0",
DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH,
);
let mut perms = std::fs::metadata(&pkg_json).unwrap().permissions();
perms.set_mode(0o644);
let _ = std::fs::set_permissions(&pkg_json, perms);
let is_root = unsafe { libc::geteuid() } == 0;
if is_root {
eprintln!("skipping permission-error test under root");
return;
}
assert!(
result.is_err(),
"non-NotFound I/O error must propagate, got {result:?}"
);
}
#[test]
fn resolve_node_version_strips_v_prefix() {
assert_eq!(
resolve_node_version(Some("v18.17.1")).as_deref(),
Some("18.17.1")
);
assert_eq!(
resolve_node_version(Some("20.0.0")).as_deref(),
Some("20.0.0")
);
}
}