diff --git a/src-tauri/src/ace/scanner.rs b/src-tauri/src/ace/scanner.rs index fc416260c..4108a9ff0 100644 --- a/src-tauri/src/ace/scanner.rs +++ b/src-tauri/src/ace/scanner.rs @@ -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") @@ -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(), @@ -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, + key: &str, + value: &serde_json::Value, +) -> Option { + 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() @@ -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> = 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#"{ diff --git a/src-tauri/src/brief_facts_build.rs b/src-tauri/src/brief_facts_build.rs index 3d12d3444..3dfb23c0f 100644 --- a/src-tauri/src/brief_facts_build.rs +++ b/src-tauri/src/brief_facts_build.rs @@ -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 }, } diff --git a/src-tauri/src/brief_facts_copies.rs b/src-tauri/src/brief_facts_copies.rs index 8313c1b21..32f79c506 100644 --- a/src-tauri/src/brief_facts_copies.rs +++ b/src-tauri/src/brief_facts_copies.rs @@ -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, @@ -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() + }), ), } } @@ -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()); + } +} diff --git a/src-tauri/src/evidence/upgrade_parent.rs b/src-tauri/src/evidence/upgrade_parent.rs index 1892596cb..03d7017ea 100644 --- a/src-tauri/src/evidence/upgrade_parent.rs +++ b/src-tauri/src/evidence/upgrade_parent.rs @@ -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!( @@ -423,6 +432,12 @@ pub(super) fn path_citations(package: &str, links: &[ParentLink]) -> Vec 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)) => { diff --git a/src-tauri/src/evidence/upgrade_parent_tests.rs b/src-tauri/src/evidence/upgrade_parent_tests.rs index f0b970d83..8a189c718 100644 --- a/src-tauri/src/evidence/upgrade_parent_tests.rs +++ b/src-tauri/src/evidence/upgrade_parent_tests.rs @@ -435,3 +435,120 @@ fn a_parent_with_no_registry_record_must_move_without_a_claimed_update() { uuid.explanation ); } + +const GOAT: &str = "d:/work/nodegoat"; + +/// NodeGoat (audit E8): debug 2.2.0 ships bundled inside nyc 14.1.1, the +/// declared dev tool. `npm update debug` cannot move it and npm `overrides` +/// do not reach bundled dependencies: the step says the copy is bundled, +/// offers no override, and never prints the edge's internal marker. +#[test] +fn a_bundled_copy_names_its_owner_and_no_override() { + let db = test_db(); + db.store_transitive_dependency(GOAT, "debug", Some("2.2.0"), "npm", false) + .unwrap(); + db.store_dependency_instances( + GOAT, + "npm", + &[ + instance("nyc", "14.1.1", true, true), + instance("debug", "2.2.0", false, true), + ], + ) + .unwrap(); + let edges = crate::ace::scanner::ProjectScanner::parse_package_lock_edges( + r#"{"lockfileVersion": 2, "packages": { + "": { "devDependencies": { "nyc": "^14.0.0" } }, + "node_modules/nyc": { "version": "14.1.1", "dev": true }, + "node_modules/nyc/node_modules/debug": { "version": "2.2.0", "dev": true, "inBundle": true } + }}"#, + ); + db.store_dependency_edges(GOAT, "npm", &edges).unwrap(); + advisory(&db, "GHSA-gxpj-cx7g-858c", "debug", "npm", "2.6.9", 5.3); + let plan = plan(&db); + let debug = row(&plan, "debug"); + validate_item(debug).expect("schema-valid"); + let text = &debug.explanation; + assert!( + text.contains( + "debug 2.2.0 comes in through nyc 14.1.1 (a direct dependency), which ships it \ + bundled inside its own package, so neither a lockfile refresh nor an override can \ + move it to 2.6.9" + ), + "{text}" + ); + assert!(!text.contains("(bundled)"), "{text}"); + assert!(!text.contains("overrides"), "{text}"); + assert!(!text.contains("npm update"), "{text}"); +} + +/// End to end on a real project directory (audit E8): the plan reads the +/// project's package-lock.json from disk (Manager::Npm detection, the cached +/// tree, npm's lookup). needle 2.2.4 admits debug 2.2.0 by range but resolves +/// the hoisted 2.6.9, so it is no parent of 2.2.0; connect holds 2.2.0 by +/// `~2.2.0`; nyc DECLARES and bundles its 2.2.0. No "npm update debug". +#[test] +fn the_plan_reads_the_lockfile_tree_on_disk() { + let dir = tempfile::Builder::new() + .prefix("fx-e2e-") + .tempdir_in(env!("CARGO_MANIFEST_DIR")) + .expect("tempdir"); + let project = dir.path().to_string_lossy().replace('\\', "/"); + if crate::project_inclusion::is_hard_excluded(&project) { + eprintln!("SKIPPED the_plan_reads_the_lockfile_tree_on_disk: {project} is excluded from DB writes here"); + return; + } + let lock = r#"{"name": "app", "lockfileVersion": 2, "packages": { + "": { "name": "app", "dependencies": { "needle": "2.2.4", "connect": "3.4.1", "nyc": "7.1.0" } }, + "node_modules/debug": { "version": "2.6.9" }, + "node_modules/needle": { "version": "2.2.4", "dependencies": { "debug": "^2.1.2" } }, + "node_modules/connect": { "version": "3.4.1", "dependencies": { "debug": "~2.2.0" } }, + "node_modules/connect/node_modules/debug": { "version": "2.2.0" }, + "node_modules/nyc": { "version": "7.1.0", "bundleDependencies": ["debug"], "dependencies": { "debug": "^2.2.0" } }, + "node_modules/nyc/node_modules/debug": { "version": "2.2.0", "inBundle": true } + }}"#; + std::fs::write(dir.path().join("package-lock.json"), lock).unwrap(); + std::fs::write( + dir.path().join("package.json"), + r#"{"name": "app", "dependencies": {"needle": "2.2.4", "connect": "3.4.1", "nyc": "7.1.0"}}"#, + ) + .unwrap(); + + let db = test_db(); + db.store_transitive_dependency(&project, "debug", Some("2.2.0"), "npm", false) + .unwrap(); + db.store_dependency_instances( + &project, + "npm", + &[ + instance("needle", "2.2.4", true, false), + instance("connect", "3.4.1", true, false), + instance("nyc", "7.1.0", true, false), + instance("debug", "2.6.9", false, false), + instance("debug", "2.2.0", false, false), + ], + ) + .unwrap(); + let edges = crate::ace::scanner::ProjectScanner::parse_package_lock_edges(lock); + db.store_dependency_edges(&project, "npm", &edges).unwrap(); + advisory(&db, "GHSA-gxpj-cx7g-858c", "debug", "npm", "2.6.9", 5.3); + + let plan = plan(&db); + let debug = row(&plan, "debug"); + validate_item(debug).expect("schema-valid"); + let text = &debug.explanation; + assert!( + text.contains("comes in through connect 3.4.1 (a direct dependency), whose requirement ~2.2.0 excludes 2.6.9"), + "{text}" + ); + assert!( + text.contains("comes in through nyc 7.1.0 (a direct dependency), which ships it bundled"), + "{text}" + ); + assert!( + !text.contains("needle"), + "needle resolves the hoisted 2.6.9: {text}" + ); + assert!(!text.contains("npm update debug"), "{text}"); + assert!(!text.contains("(bundled)"), "{text}"); +} diff --git a/src-tauri/src/osv/fix_path.rs b/src-tauri/src/osv/fix_path.rs index 502847e36..9fb5d1bd0 100644 --- a/src-tauri/src/osv/fix_path.rs +++ b/src-tauri/src/osv/fix_path.rs @@ -214,8 +214,17 @@ impl Manager { /// The override mechanism for a project's tool, or, when the tool is not /// known, every npm-family one (`ecosystem_norm` is OSV-canonical). `None` -/// for an ecosystem with no such mechanism. -pub fn override_hint(manager: Option, ecosystem_norm: &str) -> Option { +/// for an ecosystem with no such mechanism, and for a `bundled` copy: npm +/// `overrides` (and pnpm's and yarn's) do not reach a dependency that ships +/// inside its owner's tarball ([`BUNDLED_SUFFIX`]); only the owner can move. +pub fn override_hint( + manager: Option, + ecosystem_norm: &str, + bundled: bool, +) -> Option { + if bundled { + return None; + } match manager { Some(m) => m.override_mechanism().map(str::to_string), None if ecosystem_norm == "npm" => Some( @@ -262,6 +271,162 @@ impl NpmLockfile { } } +/// The parsed `package-lock.json` that owns the npm tree of the project at +/// `dir`: the nearest one up the same ascent [`Manager::detect`] takes (a +/// workspace member's lockfile sits at the workspace root). Parsed once per +/// file and kept while its modification time and size are unchanged: the Upgrade +/// Plan asks once per vulnerable package, and a lockfile can be megabytes. +pub(crate) fn npm_lock_tree(dir: &Path) -> Option> { + use std::collections::HashMap; + use std::sync::{Arc, LazyLock}; + type Entry = (std::time::SystemTime, u64, Arc); + static CACHE: LazyLock>> = + LazyLock::new(Default::default); + const MAX_CACHED: usize = 16; + + let mut here = Some(dir); + let mut file = None; + for _ in 0..=MAX_LOCKFILE_ASCENT { + let d = here?; + match NpmLockfile::detect(d) { + Some(NpmLockfile::Npm) => { + file = Some(d.join(NpmLockfile::Npm.file_name())); + break; + } + Some(_) => return None, // pnpm or yarn owns this tree + None => here = d.parent(), + } + } + let file = file?; + let meta = std::fs::metadata(&file).ok()?; + let stamp = (meta.modified().ok()?, meta.len()); + if let Some((modified, len, tree)) = CACHE.lock().get(&file) { + if (*modified, *len) == stamp { + return Some(Arc::clone(tree)); + } + } + let tree: Arc = + Arc::new(serde_json::from_str(&std::fs::read_to_string(&file).ok()?).ok()?); + let mut cache = CACHE.lock(); + if cache.len() >= MAX_CACHED { + cache.clear(); + } + cache.insert(file, (stamp.0, stamp.1, Arc::clone(&tree))); + Some(tree) +} + +/// A `package-lock.json` v2/v3 key that installs a DEPENDENCY (under some +/// `node_modules/`), as opposed to the root (`""`) or a workspace member's +/// folder (`packages/web`). +fn is_dependency_key(key: &str) -> bool { + key.starts_with("node_modules/") || key.contains("/node_modules/") +} + +/// The key of the package a bundled entry at `key` ships inside: the nearest +/// enclosing entry that is not itself `inBundle` (a level the lockfile does +/// not list is passed over). `None` when no enclosing entry is listed. +pub(crate) fn npm_bundle_owner<'a>( + pkgs: &'a serde_json::Map, + key: &'a str, +) -> Option<&'a str> { + let mut at = key; + loop { + let (enclosing, _) = at.rsplit_once("/node_modules/")?; + at = enclosing; + match pkgs.get(enclosing) { + Some(entry) + if !entry + .get("inBundle") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) => + { + return Some(enclosing) + } + _ => {} + } + } +} + +/// Whether the entry at `key` is a copy bundled inside a DEPENDENCY's tarball: +/// `inBundle`, with a dependency as its bundler. A project's or workspace +/// member's own `bundleDependencies` also set `inBundle` (npm Arborist's +/// `getBundler()` can be the root), but those copies sit in the user's own +/// tree, where `npm update` and overrides reach them like any other. +pub(crate) fn npm_lock_entry_bundled( + pkgs: &serde_json::Map, + key: &str, +) -> bool { + let in_bundle = pkgs + .get(key) + .and_then(|e| e.get("inBundle")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + in_bundle && npm_bundle_owner(pkgs, key).is_some_and(is_dependency_key) +} + +/// The versions of `child` that the installed `parent` `parent_version` +/// resolves to in a `package-lock.json` v2/v3 tree, by npm's own lookup: the +/// parent's folder's `node_modules`, then each enclosing folder's, up to the +/// root. A range alone cannot tell which copy a parent holds: NodeGoat's +/// needle 2.2.4 (`^2.1.2`) admits debug 2.2.0 but resolves the hoisted 2.6.9, +/// and was told `npm update debug` for a copy it never held (audit E8). +/// +/// Copies of `parent` bundled inside a dependency are skipped: what they +/// resolve is frozen in that bundle, and the bundled edge already names its +/// owner. `None` when the lockfile has no `packages` map (v1) or holds no +/// matching copy of `parent` that declares `child` (a database older than the +/// lockfile on disk), so the caller keeps its range-based attribution. +pub(crate) fn npm_lock_resolutions( + lock: &serde_json::Value, + parent: &str, + parent_version: &str, + child: &str, +) -> Option> { + let pkgs = lock.get("packages")?.as_object()?; + let mut matched = false; + let mut out: Vec = Vec::new(); + for (key, entry) in pkgs { + let name = key + .rsplit_once("node_modules/") + .map_or(key.as_str(), |(_, n)| n); + if !is_dependency_key(key) + || !name.eq_ignore_ascii_case(parent) + || entry.get("version").and_then(serde_json::Value::as_str) != Some(parent_version) + || npm_lock_entry_bundled(pkgs, key) + { + continue; + } + let declares = ["dependencies", "optionalDependencies", "peerDependencies"] + .iter() + .any(|s| entry.get(*s).and_then(|d| d.get(child)).is_some()); + if !declares { + continue; + } + matched = true; + let mut base = key.as_str(); + let resolved = loop { + let candidate = if base.is_empty() { + format!("node_modules/{child}") + } else { + format!("{base}/node_modules/{child}") + }; + if let Some(found) = pkgs.get(&candidate) { + break found.get("version").and_then(serde_json::Value::as_str); + } + if base.is_empty() { + break None; + } + base = base.rsplit_once("/node_modules/").map_or("", |(b, _)| b); + }; + if let Some(v) = resolved { + if !out.iter().any(|o| o == v) { + out.push(v.to_string()); + } + } + } + matched.then_some(out) +} + fn is_yarn_berry(lockfile: &Path) -> bool { std::fs::read_to_string(lockfile) .map(|s| s.lines().take(12).any(|l| l.starts_with("__metadata:"))) @@ -489,6 +654,18 @@ fn crate_key(name: &str) -> String { name.trim().to_lowercase().replace('_', "-") } +/// Marks a `package-lock.json` edge to a BUNDLED copy (`inBundle`): the +/// exact version the bundle owner ships, e.g. `2.2.0 (bundled)`. Every reader +/// here sees an exact pin (the text after `(` is dropped), so no refresh is +/// enough; and since npm `overrides` do not reach bundled dependencies, a +/// bundled link names no override either — only a new owner release moves it. +pub const BUNDLED_SUFFIX: &str = " (bundled)"; + +/// The edge spec names a bundled copy ([`BUNDLED_SUFFIX`]). +pub fn is_bundled(spec: &str) -> bool { + spec.ends_with(BUNDLED_SUFFIX) +} + /// The spec without a pnpm peer suffix (`4.28.1(postcss@8.4.0)`), a leading /// `=`/`v`, and surrounding whitespace. pub(crate) fn strip_spec(spec: &str) -> &str { diff --git a/src-tauri/src/osv/fix_path_tests.rs b/src-tauri/src/osv/fix_path_tests.rs index 21c86816b..035d942d2 100644 --- a/src-tauri/src/osv/fix_path_tests.rs +++ b/src-tauri/src/osv/fix_path_tests.rs @@ -333,3 +333,92 @@ fn managers_are_detected_per_ecosystem() { ); assert!(Manager::Npm.records_requirements() && !Manager::Pnpm.records_requirements()); } + +/// NodeGoat's tree (audit E8): needle 2.2.4 is hoisted and resolves the +/// hoisted debug 2.6.9 although its `^2.1.2` also admits 2.2.0; connect +/// 3.4.1 resolves its own nested 2.2.0; finalhandler, nested inside connect, +/// climbs to connect's copy. npm's lookup, not the range, decides. A scoped +/// parent resolves the same way; a workspace member is not a dependency copy +/// (no answer: the range fallback); a copy of the parent BUNDLED inside nyc +/// resolves inside the frozen bundle and must not credit the hoisted copy. +#[test] +fn npm_lock_resolutions_follow_npms_lookup() { + let lock: serde_json::Value = serde_json::from_str( + r#"{"lockfileVersion": 2, "packages": { + "": { "dependencies": { "needle": "^2.2.4", "connect": "3.4.1", "@scope/kit": "1.0.0", "babel-traverse": "6.11.4", "nyc": "7.1.0" } }, + "node_modules/debug": { "version": "2.6.9" }, + "node_modules/needle": { "version": "2.2.4", "dependencies": { "debug": "^2.1.2" } }, + "node_modules/connect": { "version": "3.4.1", "dependencies": { "debug": "~2.2.0", "finalhandler": "0.4.1" } }, + "node_modules/connect/node_modules/debug": { "version": "2.2.0" }, + "node_modules/connect/node_modules/finalhandler": { "version": "0.4.1", "dependencies": { "debug": "~2.2.0" } }, + "node_modules/@scope/kit": { "version": "1.0.0", "dependencies": { "debug": "^2.0.0" } }, + "node_modules/@scope/kit/node_modules/debug": { "version": "2.3.0" }, + "packages/web": { "name": "web", "dependencies": { "debug": "^2.6.0" } }, + "node_modules/babel-traverse": { "version": "6.11.4", "dependencies": { "debug": "^2.2.0" } }, + "node_modules/nyc": { "version": "7.1.0", "dependencies": { "babel-traverse": "6.11.4", "debug": "^2.2.0" } }, + "node_modules/nyc/node_modules/babel-traverse": { "version": "6.11.4", "inBundle": true, "dependencies": { "debug": "^2.2.0" } }, + "node_modules/nyc/node_modules/debug": { "version": "2.2.0", "inBundle": true } + }}"#, + ) + .unwrap(); + let r = |p: &str, v: &str| npm_lock_resolutions(&lock, p, v, "debug"); + let one = |v: &str| Some(vec![v.to_string()]); + assert_eq!(r("needle", "2.2.4"), one("2.6.9")); + assert_eq!(r("connect", "3.4.1"), one("2.2.0")); + assert_eq!(r("finalhandler", "0.4.1"), one("2.2.0")); + assert_eq!(r("@scope/kit", "1.0.0"), one("2.3.0")); + // Only the hoisted babel-traverse counts; its bundled twin inside nyc + // resolves nyc's frozen 2.2.0, which is not what a refresh can move. + assert_eq!(r("babel-traverse", "6.11.4"), one("2.6.9")); + // A workspace member is not a dependency copy, and nothing matches a + // version the lockfile does not hold: no answer, so the caller keeps its + // range-based attribution (a database older than the lockfile on disk). + assert_eq!(r("packages/web", "1.0.0"), None); + assert_eq!(r("needle", "9.9.9"), None); + // A v1 lockfile has no tree to read. + let v1: serde_json::Value = + serde_json::from_str(r#"{"lockfileVersion": 1, "dependencies": {}}"#).unwrap(); + assert_eq!(npm_lock_resolutions(&v1, "needle", "2.2.4", "debug"), None); +} + +/// Bundled means bundled inside a DEPENDENCY: a project's own or a workspace +/// member's `bundleDependencies` also set `inBundle`, but in the user's tree. +#[test] +fn npm_lock_entry_bundled_needs_a_dependency_as_bundler() { + let lock: serde_json::Value = serde_json::from_str( + r#"{"lockfileVersion": 3, "packages": { + "": { "bundleDependencies": ["left-pad"] }, + "node_modules/left-pad": { "version": "1.3.0", "inBundle": true }, + "packages/web/node_modules/kleur": { "version": "4.1.5", "inBundle": true }, + "packages/web": { "name": "web" }, + "node_modules/nyc": { "version": "7.1.0" }, + "node_modules/nyc/node_modules/debug": { "version": "2.2.0", "inBundle": true }, + "node_modules/npm": { "version": "3.10.10" }, + "node_modules/npm/node_modules/node-gyp/node_modules/array-index": { "version": "1.0.0", "inBundle": true } + }}"#, + ) + .unwrap(); + let pkgs = lock["packages"].as_object().unwrap(); + assert!(!npm_lock_entry_bundled(pkgs, "node_modules/left-pad")); + assert!(!npm_lock_entry_bundled( + pkgs, + "packages/web/node_modules/kleur" + )); + assert!(npm_lock_entry_bundled( + pkgs, + "node_modules/nyc/node_modules/debug" + )); + // An unlisted level (node-gyp) is passed over on the way to the owner. + assert!(npm_lock_entry_bundled( + pkgs, + "node_modules/npm/node_modules/node-gyp/node_modules/array-index" + )); + assert_eq!( + npm_bundle_owner( + pkgs, + "node_modules/npm/node_modules/node-gyp/node_modules/array-index" + ), + Some("node_modules/npm") + ); + assert!(!npm_lock_entry_bundled(pkgs, "node_modules/nyc")); +} diff --git a/src-tauri/src/osv/parent_hint.rs b/src-tauri/src/osv/parent_hint.rs index 5481bd4ee..6a6b17e18 100644 --- a/src-tauri/src/osv/parent_hint.rs +++ b/src-tauri/src/osv/parent_hint.rs @@ -35,8 +35,8 @@ use semver::Version; use crate::db::Database; use super::fix_path::{ - installed_parent_requirements, refresh_verdict, requirement_admits, strip_spec, Dialect, - Manager, Refresh, Requirement, + installed_parent_requirements, is_bundled, npm_lock_resolutions, npm_lock_tree, + refresh_verdict, requirement_admits, strip_spec, Dialect, Manager, Refresh, Requirement, }; use super::fix_target::LineTarget; use super::matching::{check_version_affected, parse_version}; @@ -296,7 +296,11 @@ fn annotate( // refresh-enough link needs neither the graph above it nor the registry. for link in links.iter_mut().filter(|l| !l.refresh.is_enough()) { link.standing = parent_standing(db, ecosystem_norm, &link.parent, &link.parent_version); - link.override_hint = super::fix_path::override_hint(manager(&link.project), ecosystem_norm); + link.override_hint = super::fix_path::override_hint( + manager(&link.project), + ecosystem_norm, + link.is_bundled(), + ); if !link.parent_is_direct { link.through = direct_through( db, @@ -314,6 +318,14 @@ fn annotate( } } +impl ParentLink { + /// The copy ships inside `parent`'s tarball (a bundled npm dependency): + /// no refresh and no override moves it, only a new `parent` release. + pub fn is_bundled(&self) -> bool { + self.requirement.as_deref().is_some_and(is_bundled) + } +} + /// A transitive copy for which no installed parent could be read (yarn /// lockfiles carry no edges; a parent may not be installed). Its route rests /// on semver compatibility alone. @@ -340,6 +352,10 @@ pub struct Readers<'a> { pub manager: &'a dyn Fn(&str) -> Option, /// `(project, manager, parent, parent_version, child)`. pub requirements: &'a dyn Fn(&str, Manager, &str, &str, &str) -> Vec, + /// `(project, parent, parent_version, child)`: the child versions that + /// installed parent resolves to, read from the lockfile tree where it + /// records one (npm v2/v3); `None` keeps the range-based attribution. + pub resolved_children: &'a dyn Fn(&str, &str, &str, &str) -> Option>, } #[cfg(test)] @@ -348,6 +364,7 @@ impl Readers<'_> { pub const NONE: Readers<'static> = Readers { manager: &|_| None, requirements: &|_, _, _, _, _| Vec::new(), + resolved_children: &|_, _, _, _| None, }; } @@ -418,9 +435,14 @@ pub fn load_routes( let requirements = |project: &str, m: Manager, parent: &str, version: &str, child: &str| { installed_parent_requirements(m, Path::new(&raw(project)), parent, version, child) }; + let resolved_children = |project: &str, parent: &str, version: &str, child: &str| { + let tree = npm_lock_tree(Path::new(&raw(project)))?; + npm_lock_resolutions(&tree, parent, version, child) + }; let readers = Readers { manager: &manager, requirements: &requirements, + resolved_children: &resolved_children, }; let mut links = parent_links_with(package, lines, &edges, &installed, &is_clear, &readers); annotate(db, ecosystem_norm, &mut links, &manager); @@ -496,9 +518,21 @@ pub fn parent_links_with( if edge.parent == ROOT_PARENT || edge.parent.eq_ignore_ascii_case(package) { continue; } - let leads_here = match edge.child_spec.as_deref() { - Some(spec) => requirement_admits(spec, &line.installed_version) == Some(true), - None => child_copies.len() == 1, + // The lockfile tree, where it records one, says which copy this + // parent holds; a range only says which copies it could. + let resolved = match (edge.child_spec.as_deref(), edge.parent_version.as_deref()) { + (Some(spec), _) if is_bundled(spec) => None, + (_, Some(pv)) => { + (readers.resolved_children)(&site.project_path, &edge.parent, pv, &child) + } + _ => None, + }; + let leads_here = match (resolved, edge.child_spec.as_deref()) { + (Some(versions), _) => versions.contains(&line.installed_version), + (None, Some(spec)) => { + requirement_admits(spec, &line.installed_version) == Some(true) + } + (None, None) => child_copies.len() == 1, }; if !leads_here { continue; @@ -562,6 +596,8 @@ fn edge_requirements( ) -> Vec { let spec = edge.child_spec.as_deref().filter(|s| !s.trim().is_empty()); match (spec, manager) { + // A bundled copy's pin is the owner's tarball itself, whatever the tool. + (Some(spec), _) if is_bundled(spec) => vec![Requirement::npm(spec)], (Some(spec), Some(m)) if m.records_requirements() => vec![Requirement::npm(spec)], (Some(spec), None) if is_range(spec) => vec![Requirement::npm(spec)], (_, Some(m)) => { diff --git a/src-tauri/src/osv/parent_hint_tests.rs b/src-tauri/src/osv/parent_hint_tests.rs index 6fe5869bc..602b95f1a 100644 --- a/src-tauri/src/osv/parent_hint_tests.rs +++ b/src-tauri/src/osv/parent_hint_tests.rs @@ -191,6 +191,112 @@ fn an_npm_range_that_excludes_the_target_says_the_parent_must_move() { ); } +/// navcal (audit A7): brace-expansion 2.0.2 sits under a NESTED minimatch +/// 9.0.5 (`^2.0.1`), while the hoisted minimatch 3.1.2 (`^1.1.7`) holds 1.1.12. +/// With the nested edge stored, 2.0.2 is attributed to 9.0.5 and its target +/// 2.1.7 is inside `^2.0.1`: a refresh, read from the requirement. +#[test] +fn a_nested_parent_is_named_for_its_own_copy() { + let navcal = "d:/runyourempire/navcal"; + let edges = vec![ + edge(navcal, "minimatch", Some("3.1.2"), Some("^1.1.7")), + edge(navcal, "minimatch", Some("9.0.5"), Some("^2.0.1")), + ]; + let installed = vec![ + inst(navcal, "brace-expansion", "1.1.12", false), + inst(navcal, "brace-expansion", "2.0.2", false), + inst(navcal, "minimatch", "3.1.2", false), + inst(navcal, "minimatch", "9.0.5", false), + ]; + let lines = [line("2.0.2", "2.1.7", navcal, false)]; + let links = parent_links("brace-expansion", &lines, &edges, &installed, &|_| true); + assert_eq!(links.len(), 1, "{links:?}"); + assert_eq!(links[0].parent_version, "9.0.5"); + assert_eq!(links[0].requirement.as_deref(), Some("^2.0.1")); + assert_eq!(links[0].refresh, Refresh::Enough(Basis::Requirement)); + let unlinked = unlinked_sites("brace-expansion", &lines, &links, &|_| None); + assert!( + unlinked.is_empty(), + "no longer 'no parent 4DA can read': {unlinked:?}" + ); +} + +/// NodeGoat (audit E8): debug 2.2.0 bundled inside nyc. `npm update debug` +/// cannot move it, so it is never "a refresh is enough" and gets no command, +/// even where the project's tool is not known. +#[test] +fn a_bundled_copy_waits_on_its_owner() { + let p = "d:/nodegoat"; + let edges = vec![edge(p, "nyc", Some("14.1.1"), Some("2.2.0 (bundled)"))]; + let installed = vec![ + inst(p, "debug", "2.2.0", false), + inst(p, "nyc", "14.1.1", true), + ]; + let lines = [line("2.2.0", "2.6.9", p, false)]; + for manager in [None, Some(Manager::Npm)] { + let readers = Readers { + manager: &|_| manager, + requirements: &|_, _, _, _, _| Vec::new(), + resolved_children: &|_, _, _, _| None, + }; + let links = parent_links_with("debug", &lines, &edges, &installed, &|_| true, &readers); + assert_eq!(links.len(), 1, "{manager:?}: {links:?}"); + assert_eq!(links[0].parent, "nyc"); + assert_eq!(links[0].requirement.as_deref(), Some("2.2.0 (bundled)")); + assert_eq!( + links[0].refresh, + Refresh::ParentMustMove(Basis::Requirement) + ); + assert_eq!(links[0].command, None); + } + // npm `overrides` cannot reach a bundled copy: no override is offered. + let links = parent_links("debug", &lines, &edges, &installed, &|_| true); + assert!(links[0].is_bundled()); + use crate::osv::fix_path::override_hint; + assert_eq!(override_hint(Some(Manager::Npm), "npm", true), None); + assert_eq!(override_hint(None, "npm", true), None); + assert!(override_hint(Some(Manager::Npm), "npm", false).is_some()); +} + +/// NodeGoat (audit E8): needle 2.2.4's `^2.1.2` admits debug 2.2.0, but the +/// lockfile tree resolves needle to the hoisted 2.6.9. Read from the tree, +/// needle is not a parent of 2.2.0, so it adds no "npm update debug" for a +/// copy it never held; connect, which does hold 2.2.0, stays. +#[test] +fn the_lockfile_tree_decides_which_copy_a_parent_holds() { + let p = "d:/nodegoat"; + let edges = vec![ + edge(p, "needle", Some("2.2.4"), Some("^2.1.2")), + edge(p, "connect", Some("3.4.1"), Some("~2.2.0")), + ]; + let installed = vec![ + inst(p, "debug", "2.2.0", false), + inst(p, "debug", "2.6.9", false), + inst(p, "needle", "2.2.4", true), + inst(p, "connect", "3.4.1", true), + ]; + let lines = [line("2.2.0", "2.6.9", p, false)]; + let tree = |_: &str, parent: &str, _: &str, _: &str| -> Option> { + Some(vec![match parent { + "needle" => "2.6.9".to_string(), + _ => "2.2.0".to_string(), + }]) + }; + let readers = Readers { + manager: &|_| Some(Manager::Npm), + requirements: &|_, _, _, _, _| Vec::new(), + resolved_children: &tree, + }; + let links = parent_links_with("debug", &lines, &edges, &installed, &|_| true, &readers); + let parents: Vec<&str> = links.iter().map(|l| l.parent.as_str()).collect(); + assert_eq!(parents, vec!["connect"], "{links:?}"); + // Without a tree (Readers::NONE) the range guess still names both. + assert_eq!( + parent_links("debug", &lines, &edges, &installed, &|_| true).len(), + 2 + ); +} + #[test] fn an_edge_whose_range_excludes_the_installed_copy_is_not_its_parent() { let p = "d:/p"; @@ -260,6 +366,7 @@ fn a_pnpm_resolution_reads_the_installed_parent_and_names_the_command() { let readers = Readers { manager: &manager, requirements: &requirements, + resolved_children: &|_, _, _, _| None, }; let links = parent_links_with("braces", &lines, &edges, &installed, &|_| true, &readers); assert_eq!(links.len(), 1, "{links:?}"); @@ -274,6 +381,7 @@ fn a_pnpm_resolution_reads_the_installed_parent_and_names_the_command() { let readers = Readers { manager: &manager, requirements: &none, + resolved_children: &|_, _, _, _| None, }; let links = parent_links_with("braces", &lines, &edges, &installed, &|_| true, &readers); assert_eq!(links[0].refresh, Refresh::Enough(Basis::Semver)); @@ -298,6 +406,7 @@ fn a_cargo_parent_requirement_is_read_with_cargo_semantics() { let readers = Readers { manager: &manager, requirements: &requirements, + resolved_children: &|_, _, _, _| None, }; let links = parent_links_with("mio", &lines, &edges, &installed, &|_| true, &readers); assert_eq!(links[0].refresh, Refresh::Enough(Basis::Requirement)); @@ -312,6 +421,7 @@ fn a_cargo_parent_requirement_is_read_with_cargo_semantics() { let readers = Readers { manager: &manager, requirements: &pinned, + resolved_children: &|_, _, _, _| None, }; let links = parent_links_with("mio", &lines, &edges, &installed, &|_| true, &readers); assert_eq!( @@ -413,20 +523,23 @@ fn a_newer_parent_release_on_record_is_named_and_lookalikes_are_not() { fn the_override_hint_names_the_projects_own_tool() { use crate::osv::fix_path::override_hint; assert_eq!( - override_hint(Some(Manager::Npm), "npm").as_deref(), + override_hint(Some(Manager::Npm), "npm", false).as_deref(), Some("an `overrides` entry in package.json (npm)") ); assert_eq!( - override_hint(Some(Manager::PnpmWorkspace), "npm").as_deref(), + override_hint(Some(Manager::PnpmWorkspace), "npm", false).as_deref(), Some("a `pnpm.overrides` entry in package.json") ); assert_eq!( - override_hint(Some(Manager::YarnClassic), "npm").as_deref(), + override_hint(Some(Manager::YarnClassic), "npm", false).as_deref(), Some("a `resolutions` entry in package.json (yarn)") ); - assert!(override_hint(None, "npm") + assert!(override_hint(None, "npm", false) .is_some_and(|h| h.contains("`overrides`") && h.contains("`resolutions`"))); // Cargo has no key that forces a crate outside its parent's line. - assert_eq!(override_hint(Some(Manager::Cargo), "crates.io"), None); - assert_eq!(override_hint(None, "crates.io"), None); + assert_eq!( + override_hint(Some(Manager::Cargo), "crates.io", false), + None + ); + assert_eq!(override_hint(None, "crates.io", false), None); } diff --git a/src-tauri/src/osv/vectors_tests.rs b/src-tauri/src/osv/vectors_tests.rs index b0d0641b7..22d0e7090 100644 --- a/src-tauri/src/osv/vectors_tests.rs +++ b/src-tauri/src/osv/vectors_tests.rs @@ -225,6 +225,10 @@ fn check_refresh_verdict(v: &Value) -> Result<(), String> { let text = s(r, "text"); let req = if s(r, "dialect") == "cargo" { Requirement::cargo(text) + } else if r.get("bundled").and_then(Value::as_bool) == Some(true) { + // A bundled copy: the owner's tarball pins the shipped version, + // spelled as the scanner stores it (audit E8). + Requirement::npm(&format!("{text}{}", crate::osv::fix_path::BUNDLED_SUFFIX)) } else { Requirement::npm(text) }; @@ -251,6 +255,20 @@ fn check_refresh_verdict(v: &Value) -> Result<(), String> { .filter_map(Value::as_str) .collect(); let want_inferred = v.get("inferred").and_then(Value::as_bool).unwrap_or(false); + // `override` (optional): whether an override may be offered for this + // copy — never when any blocking parent ships it bundled (audit E8). + if let Some(want_override) = v.get("override").and_then(Value::as_bool) { + let bundled = only + .iter() + .any(|r| crate::osv::fix_path::is_bundled(&r.text) && r.admits(target) == Some(false)); + let manager = (s(v, "eco") == "npm").then_some(crate::osv::fix_path::Manager::Npm); + let offered = crate::osv::fix_path::override_hint(manager, s(v, "eco"), bundled).is_some(); + if offered != want_override { + return Err(format!( + "override offered: got {offered}, want {want_override}" + )); + } + } if verdict != s(v, "verdict") || inferred != want_inferred || via != want_via { return Err(format!( "got ({verdict}, inferred {inferred}, via {via:?}), want ({}, inferred {want_inferred}, via {want_via:?})", diff --git a/src-tauri/tests/conformance/vectors/README.md b/src-tauri/tests/conformance/vectors/README.md index 3d890fd6d..6fad7f800 100644 --- a/src-tauri/tests/conformance/vectors/README.md +++ b/src-tauri/tests/conformance/vectors/README.md @@ -41,6 +41,7 @@ Field conventions: was read and the verdict comes from semver compatibility; `unknown` always has `inferred: true`. `via` lists the parents whose requirement was read and excludes the target, in input order. +- `requirements[].bundled: true` marks a copy shipped inside its parent's tarball (package-lock `inBundle`): `text` is the exact version shipped. The optional top-level `override` says whether an override (npm `overrides`, pnpm `pnpm.overrides`, yarn `resolutions`) may be offered for the copy; lines without it do not check it. - `requirements[].dialect` is `npm` (a bare version is an exact pin, `||` unions, spaces AND) or `cargo` (a bare version is a caret, commas AND). - A `fix_target` `target` is compared as a version (build metadata ignored). @@ -147,6 +148,7 @@ form. - Cargo requirement syntax (bare = caret, `=`, `~`, comma AND): rv-052..rv-059 - The semver fallback, including the 0.0.z caret: rv-049, rv-050, rv-060..rv-064 (0.0.z: rv-046, rv-057, rv-063, rv-064) - Nothing readable at all, so no verdict: rv-065 +- A bundled copy (package-lock `inBundle`, requirement `bundled: true`) is pinned by its owner: the parent must move and no override is offered (`override: false`): rv-066, rv-067. An ordinary exact pin also needs its parent to move but does take an override: rv-068 (audit E8) ### Command — AD-055 ¶4 diff --git a/src-tauri/tests/conformance/vectors/refresh_verdict.jsonl b/src-tauri/tests/conformance/vectors/refresh_verdict.jsonl index bde8de852..ffecb4eff 100644 --- a/src-tauri/tests/conformance/vectors/refresh_verdict.jsonl +++ b/src-tauri/tests/conformance/vectors/refresh_verdict.jsonl @@ -63,3 +63,6 @@ {"id":"rv-063","rule":"AD-055 ¶1: inferred: any bump at 0.0.z is breaking","source":"AD-055 ¶1 rule text applied by hand (npm: node-semver range semantics; Cargo: Cargo reference, 'Specifying dependencies')","eco":"crates.io","name":"pkg","installed":"0.0.3","target":"0.0.4","requirements":[],"verdict":"parent_must_move","via":[],"inferred":true} {"id":"rv-064","rule":"AD-055 ¶1: inferred: any bump at 0.0.z is breaking","source":"AD-055 ¶1 rule text applied by hand (npm: node-semver range semantics; Cargo: Cargo reference, 'Specifying dependencies')","eco":"npm","name":"pkg","installed":"0.0.3","target":"0.1.0","requirements":[],"verdict":"parent_must_move","via":[],"inferred":true} {"id":"rv-065","rule":"AD-055 ¶1: neither a requirement nor the versions can be read: no verdict (Principle 5)","source":"AD-055 ¶1 rule text applied by hand (npm: node-semver range semantics; Cargo: Cargo reference, 'Specifying dependencies')","eco":"npm","name":"pkg","installed":"not-a-version","target":"1.0.0","requirements":[],"verdict":"unknown","via":[],"inferred":true} +{"id":"rv-066","rule":"audit E8: a bundled copy (package-lock `inBundle`) is pinned by its owner's tarball: no refresh moves it and no override reaches it; only a new owner release (or replacing the owner) does","source":"NodeGoat package-lock.json (audit E8, 2026-10-12): node_modules/fsevents/node_modules/debug 4.1.1 and node_modules/nyc/node_modules/debug 2.2.0 are inBundle; npm docs (package.json, bundleDependencies; overrides) — bundled dependencies are shipped in the owner tarball and are not re-resolved","eco":"npm","name":"debug","installed":"4.1.1","target":"4.3.1","requirements":[{"parent":"fsevents","text":"4.1.1","dialect":"npm","bundled":true}],"verdict":"parent_must_move","via":["fsevents"],"inferred":false,"override":false} +{"id":"rv-067","rule":"audit E8: a bundled copy (package-lock `inBundle`) is pinned by its owner's tarball: no refresh moves it and no override reaches it; only a new owner release (or replacing the owner) does","source":"NodeGoat package-lock.json (audit E8, 2026-10-12): node_modules/fsevents/node_modules/debug 4.1.1 and node_modules/nyc/node_modules/debug 2.2.0 are inBundle; npm docs (package.json, bundleDependencies; overrides) — bundled dependencies are shipped in the owner tarball and are not re-resolved","eco":"npm","name":"debug","installed":"2.2.0","target":"2.6.9","requirements":[{"parent":"nyc","text":"2.2.0","dialect":"npm","bundled":true}],"verdict":"parent_must_move","via":["nyc"],"inferred":false,"override":false} +{"id":"rv-068","rule":"audit E8 (contrast): an ordinary exact pin also needs its parent to move, but an override in package.json does reach it","source":"NodeGoat package-lock.json: mocha 2.5.3 requires debug \"2.2.0\" (not bundled); npm docs (overrides)","eco":"npm","name":"debug","installed":"2.2.0","target":"2.6.9","requirements":[{"parent":"mocha","text":"2.2.0","dialect":"npm"}],"verdict":"parent_must_move","via":["mocha"],"inferred":false,"override":true}