Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 214 additions & 4 deletions src-tauri/src/ace/scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1439,17 +1439,35 @@ impl ProjectScanner {
let mut edges = Vec::new();

// v2/v3: a `packages` map keyed by node_modules path. "" is the root.
//
// Nested keys are parents too: `node_modules/glob/node_modules/minimatch`
// is minimatch 9.0.5, whose `^2.0.1` is what holds the nested
// brace-expansion copy. First-level edges only left every such copy
// "no parent 4DA can read" (audit A7, navcal 2026-10-12).
//
// A bundled copy (`inBundle`) ships inside its bundle owner's tarball:
// no refresh moves it, only a new owner release does. It gets ONE edge,
// from the owner, pinned to the exact version shipped, and the bundle's
// inner packages declare no edges of their own (audit E8: `npm update
// debug` cannot reach debug inside nyc, npm or fsevents).
if let Some(pkgs) = lock.get("packages").and_then(|v| v.as_object()) {
for (key, value) in pkgs {
// Skip deeply nested node_modules — first-level edges only.
let name_part = key.strip_prefix("node_modules/").unwrap_or(key);
if name_part.contains("node_modules/") {
// Bundled inside a DEPENDENCY's tarball: one owner edge, no edges
// of its own. A project's or workspace member's own bundle is the
// user's tree and keeps its ordinary edges.
if crate::osv::fix_path::npm_lock_entry_bundled(pkgs, key) {
if let Some(edge) = bundled_edge(pkgs, key, value) {
edges.push(edge);
}
continue;
}
let Some(name) = package_lock_name(key) else {
continue;
};
let parent = if key.is_empty() {
ROOT_PARENT.to_string()
} else {
name_part.to_string()
name.to_string()
};
let parent_version = value
.get("version")
Expand All @@ -1464,6 +1482,18 @@ impl ProjectScanner {
] {
if let Some(deps) = value.get(section).and_then(|v| v.as_object()) {
for (child, spec) in deps {
// An owner declares what it bundles (npm requires it;
// npm 3.10.10 declares all 82 of its bundled packages).
// That copy's edge is the bundled one: its range would
// read as "a refresh is enough".
let nested = if key.is_empty() {
format!("node_modules/{child}")
} else {
format!("{key}/node_modules/{child}")
};
if crate::osv::fix_path::npm_lock_entry_bundled(pkgs, &nested) {
continue;
}
edges.push(DependencyEdge {
parent: parent.clone(),
parent_version: parent_version.clone(),
Expand Down Expand Up @@ -1599,6 +1629,44 @@ impl ProjectScanner {
}
}

/// The package a `package-lock.json` v2/v3 `packages` key installs: the name
/// after the LAST `node_modules/` (`node_modules/a/node_modules/@s/b` is
/// `@s/b`). `""` is the root. A key with no `node_modules/` (a workspace
/// member's folder, `packages/web`) is kept as written, as before.
fn package_lock_name(key: &str) -> Option<&str> {
let name = key.rsplit_once("node_modules/").map_or(key, |(_, n)| n);
(!name.is_empty() || key.is_empty()).then_some(name)
}

/// The edge for a copy bundled inside a dependency: from its bundle owner
/// (`fix_path::npm_bundle_owner`) to the exact version shipped, spelled as an
/// npm pin with the bundled marker. `None` when the owner cannot be read.
fn bundled_edge(
pkgs: &serde_json::Map<String, serde_json::Value>,
key: &str,
value: &serde_json::Value,
) -> Option<DependencyEdge> {
let child = package_lock_name(key)?.to_string();
let child_version = value.get("version")?.as_str()?.to_string();
let owner_key = crate::osv::fix_path::npm_bundle_owner(pkgs, key)?;
let owner = pkgs.get(owner_key)?;
let scope = if value.get("dev").and_then(serde_json::Value::as_bool) == Some(true) {
EdgeScope::Dev
} else {
EdgeScope::Runtime
};
Some(DependencyEdge {
parent: package_lock_name(owner_key)?.to_string(),
parent_version: owner.get("version")?.as_str().map(str::to_string),
child,
child_version: Some(format!(
"{child_version}{}",
crate::osv::fix_path::BUNDLED_SUFFIX
)),
scope,
})
}

/// Leading indentation of a pnpm-lock line, a tab counting as two spaces.
fn pnpm_indent(line: &str) -> usize {
line.chars()
Expand Down Expand Up @@ -2550,6 +2618,148 @@ dependencies = ["once_cell", "log 0.4.20"]
&& e.scope == EdgeScope::Runtime));
}

/// navcal's shape (audit A7, 2026-10-12): brace-expansion 2.0.2 is held by
/// a NESTED minimatch 9.0.5, which must be a parent like a hoisted one.
#[test]
fn package_lock_nested_packages_are_parents() {
let content = r#"{
"lockfileVersion": 3,
"packages": {
"": { "name": "app", "dependencies": { "glob": "^10.0.0", "minimatch": "^3.1.2" } },
"node_modules/minimatch": { "version": "3.1.2", "dependencies": { "brace-expansion": "^1.1.7" } },
"node_modules/brace-expansion": { "version": "1.1.12" },
"node_modules/glob": { "version": "10.4.5", "dependencies": { "minimatch": "^9.0.4" } },
"node_modules/glob/node_modules/minimatch": { "version": "9.0.5", "dependencies": { "brace-expansion": "^2.0.1" } },
"node_modules/glob/node_modules/brace-expansion": { "version": "2.0.2" },
"node_modules/@ts/est/node_modules/@scope/inner": { "version": "1.0.0", "dependencies": { "brace-expansion": "^2.0.1" } }
}
}"#;
let edges = ProjectScanner::parse_package_lock_edges(content);
let into_brace: Vec<(&str, Option<&str>, Option<&str>)> = edges
.iter()
.filter(|e| e.child == "brace-expansion")
.map(|e| {
(
e.parent.as_str(),
e.parent_version.as_deref(),
e.child_version.as_deref(),
)
})
.collect();
// The first-level edge is unchanged.
assert!(into_brace.contains(&("minimatch", Some("3.1.2"), Some("^1.1.7"))));
// The nested parent is named by its own name and version.
assert!(into_brace.contains(&("minimatch", Some("9.0.5"), Some("^2.0.1"))));
// A scoped nested parent keeps its scope.
assert!(into_brace.contains(&("@scope/inner", Some("1.0.0"), Some("^2.0.1"))));
assert!(edges
.iter()
.any(|e| e.parent == "glob" && e.child == "minimatch"));
assert_eq!(edges.iter().filter(|e| e.parent == ROOT_PARENT).count(), 2);
}

/// NodeGoat's shape (audit E8): debug inside nyc's bundle (and two levels
/// down inside npm's) ships with its owner; `npm update debug` cannot move
/// it. One edge from the owner, pinned and marked bundled; the bundle's
/// inner packages declare none.
#[test]
fn package_lock_bundled_copies_hang_off_their_owner() {
let content = r#"{
"lockfileVersion": 2,
"packages": {
"": { "name": "app", "devDependencies": { "nyc": "^14.0.0", "npm": "^6.0.0" } },
"node_modules/nyc": { "version": "14.1.1", "dev": true, "bundleDependencies": ["babel-traverse", "debug"], "dependencies": { "babel-traverse": "^6.11.4", "debug": "^2.2.0" } },
"node_modules/nyc/node_modules/babel-traverse": { "version": "6.11.4", "dev": true, "inBundle": true, "dependencies": { "debug": "^2.2.0" } },
"node_modules/nyc/node_modules/debug": { "version": "2.2.0", "dev": true, "inBundle": true },
"node_modules/npm": { "version": "6.14.18", "dependencies": { "node-gyp": "^5.1.1", "semver": "^5.7.1" } },
"node_modules/npm/node_modules/semver": { "version": "5.7.1", "inBundle": true },
"node_modules/npm/node_modules/node-gyp/node_modules/array-index": { "version": "1.0.0", "inBundle": true, "dependencies": { "debug": "^2.2.0" } },
"node_modules/npm/node_modules/node-gyp/node_modules/array-index/node_modules/debug": { "version": "2.6.9", "inBundle": true }
}
}"#;
let edges = ProjectScanner::parse_package_lock_edges(content);
let mut into_debug: Vec<(&str, Option<&str>, Option<&str>, EdgeScope)> = edges
.iter()
.filter(|e| e.child == "debug")
.map(|e| {
(
e.parent.as_str(),
e.parent_version.as_deref(),
e.child_version.as_deref(),
e.scope,
)
})
.collect();
into_debug.sort_by_key(|e| e.0);
assert_eq!(
into_debug,
vec![
(
"npm",
Some("6.14.18"),
Some("2.6.9 (bundled)"),
EdgeScope::Runtime
),
(
"nyc",
Some("14.1.1"),
Some("2.2.0 (bundled)"),
EdgeScope::Dev
),
]
);
// Bundled packages declare nothing: babel-traverse's `^2.2.0` would
// read as "a refresh is enough".
assert!(!edges.iter().any(|e| e.parent == "babel-traverse"));
assert!(!edges.iter().any(|e| e.parent == "array-index"));
// The owner itself is not bundled: an ordinary root edge.
assert!(edges
.iter()
.any(|e| e.parent == ROOT_PARENT && e.child == "nyc"));
// An owner DECLARES what it bundles (npm 3.10.10 declares all 82): its
// range must not become a second, refreshable edge to the same copy.
let into_semver: Vec<Option<&str>> = edges
.iter()
.filter(|e| e.child == "semver")
.map(|e| e.child_version.as_deref())
.collect();
assert_eq!(into_semver, vec![Some("5.7.1 (bundled)")]);
assert!(!edges
.iter()
.any(|e| e.parent == "nyc" && e.child_version.as_deref() == Some("^2.2.0")));
}

/// A project's OWN `bundleDependencies` also mark its top-level copies
/// `inBundle` (npm Arborist: the bundler can be the root). Those sit in the
/// user's tree, where `npm update` and overrides reach them: they keep
/// their ordinary edges and get no bundled marker.
#[test]
fn package_lock_root_bundles_keep_their_edges() {
let content = r#"{
"lockfileVersion": 3,
"packages": {
"": { "name": "app", "bundleDependencies": ["left-pad"], "dependencies": { "left-pad": "^1.3.0" } },
"node_modules/left-pad": { "version": "1.3.0", "inBundle": true, "dependencies": { "tiny-dep": "^2.0.0" } },
"node_modules/tiny-dep": { "version": "2.0.1", "inBundle": true },
"packages/web": { "name": "web", "bundleDependencies": ["kleur"], "dependencies": { "kleur": "^4.0.0" } },
"packages/web/node_modules/kleur": { "version": "4.1.5", "inBundle": true }
}
}"#;
let edges = ProjectScanner::parse_package_lock_edges(content);
let has = |parent: &str, child: &str, spec: &str| {
edges.iter().any(|e| {
e.parent == parent && e.child == child && e.child_version.as_deref() == Some(spec)
})
};
assert!(has(ROOT_PARENT, "left-pad", "^1.3.0"));
assert!(has("left-pad", "tiny-dep", "^2.0.0"));
assert!(has("packages/web", "kleur", "^4.0.0"));
assert!(!edges.iter().any(|e| e
.child_version
.as_deref()
.is_some_and(|v| v.ends_with("(bundled)"))));
}

#[test]
fn test_parse_package_lock_edges_v1_fallback() {
let content = r#"{
Expand Down
10 changes: 9 additions & 1 deletion src-tauri/src/brief_facts_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,15 @@ fn security_fact(
installed.as_deref(),
);
let path = with_proven_parent(path, &parent_links, &pnorm, labels);
with_parent_route(path, &parent_links, &pnorm, project, eco_norm, ctx.db)
with_parent_route(
path,
&parent_links,
&pnorm,
project,
eco_norm,
ctx.db,
installed.as_deref(),
)
}
CopyFix::PerLine(lines) => FixPath::PerLine { lines },
}
Expand Down
77 changes: 77 additions & 0 deletions src-tauri/src/brief_facts_copies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ pub(crate) fn with_parent_route(
project: &str,
eco_norm: Option<&str>,
db: &Database,
installed: Option<&str>,
) -> FixPath {
let FixPath::Parent {
parent,
Expand Down Expand Up @@ -138,9 +139,19 @@ pub(crate) fn with_parent_route(
standing: standing_of(&t.name, &t.version),
..t
}),
// No plan link names this parent (the Brief's immediate
// parent comes from its own edge walk). If the plan holds a
// blocking link for THIS copy through a bundle, no override
// reaches it; another copy's bundle says nothing about this one.
override_hint: crate::osv::fix_path::override_hint(
Manager::detect(std::path::Path::new(project), eco),
eco,
links.iter().any(|l| {
l.project == pnorm
&& installed.is_none_or(|v| l.child_version == v)
&& !l.refresh.is_enough()
&& l.is_bundled()
}),
),
}
}
Expand All @@ -155,3 +166,69 @@ pub(crate) fn with_parent_route(
route,
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::osv::fix_path::{Basis, Refresh};
use crate::osv::parent_hint::{ParentLink, ParentStanding};

const P: &str = "d:/work/web";

fn link(parent: &str, requirement: &str) -> ParentLink {
ParentLink {
project: P.to_string(),
child_version: "4.1.1".to_string(),
target: Some("4.3.1".to_string()),
parent: parent.to_string(),
parent_version: "1.2.13".to_string(),
parent_is_direct: false,
requirement: Some(requirement.to_string()),
refresh: Refresh::ParentMustMove(Basis::Requirement),
command: None,
resolutions: Vec::new(),
through: None,
standing: ParentStanding::Unknown,
override_hint: None,
}
}

/// The Brief's parent for debug 4.1.1 is chokidar; the plan's link names
/// fsevents, the bundle owner. No plan link matches chokidar, so the route
/// is read directly — and must not offer an npm override for a bundled
/// copy (audit E8: `overrides` do not reach bundled dependencies).
fn route_with(links: &[ParentLink]) -> ParentRoute {
let path = FixPath::Parent {
parent: "chokidar".to_string(),
parent_version: "2.1.8".to_string(),
to: "4.3.1".to_string(),
by_requirement: true,
proven: None,
route: ParentRoute::default(),
};
let db = crate::test_utils::test_db();
match with_parent_route(path, links, P, P, Some("npm"), &db, Some("4.1.1")) {
FixPath::Parent { route, .. } => route,
other => panic!("{other:?}"),
}
}

#[test]
fn a_bundled_copy_gets_no_override_on_the_brief_route() {
assert_eq!(
route_with(&[link("fsevents", "4.1.1 (bundled)")]).override_hint,
None
);
// An ordinary pin still gets the override.
assert!(route_with(&[link("needle", "4.1.1")])
.override_hint
.is_some());
// ANOTHER copy's bundle (debug 2.2.0 inside nyc) says nothing about
// 4.1.1: its override advice stays.
let other_copy = ParentLink {
child_version: "2.2.0".to_string(),
..link("nyc", "2.2.0 (bundled)")
};
assert!(route_with(&[other_copy]).override_hint.is_some());
}
}
15 changes: 15 additions & 0 deletions src-tauri/src/evidence/upgrade_parent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,15 @@ fn link_sentence(package: &str, l: &ParentLink, multi_project: bool) -> String {
let Some(target) = l.target.as_deref() else {
return head;
};
if l.is_bundled() {
// The edge records the shipped version, not a requirement: say what
// it is, never print the internal marker.
return format!(
"{head}, which ships it bundled inside its own package, so neither a lockfile \
refresh nor an override can move it to {target} — {}",
link_move(package, target, l)
);
}
let req = l.requirement.as_deref().unwrap_or("");
match l.refresh {
Refresh::Enough(Basis::Requirement) => format!(
Expand Down Expand Up @@ -423,6 +432,12 @@ pub(super) fn path_citations(package: &str, links: &[ParentLink]) -> Vec<Evidenc
}

fn requirement_note(l: &ParentLink) -> String {
if l.is_bundled() {
return format!(
"ships {} bundled inside its own package; only a new {} release moves it",
l.child_version, l.parent
);
}
let req = l.requirement.as_deref();
match (l.refresh, req, l.target.as_deref()) {
(Refresh::Enough(Basis::Requirement), Some(r), Some(t)) => {
Expand Down
Loading
Loading