diff --git a/crates/socket-patch-cli/tests/e2e_vex.rs b/crates/socket-patch-cli/tests/e2e_vex.rs index 7a735d568..d4fe3ebf9 100644 --- a/crates/socket-patch-cli/tests/e2e_vex.rs +++ b/crates/socket-patch-cli/tests/e2e_vex.rs @@ -993,7 +993,8 @@ fn verify_mode_requires_every_installed_copy_patched() { /// Regressions #603 and #601: the every-copy rule covers store copies /// too. `apply` patches a package's other store copies (a Deno `_1` copy -/// index, a pnpm peer variant) and a copy bundled inside another +/// index, a pnpm peer variant, an npm linked-store entry named after an +/// alias, #852) and a copy bundled inside another /// package's store entry, so `vex` must hash each of them: one pristine /// copy omits the purl, and all patched attests it. Each layout has the /// primary copy linked from the importer, as `deno install`, pnpm and vlt @@ -1029,6 +1030,11 @@ fn verify_mode_requires_every_store_copy_patched() { ".pnpm/store-pkg@1.0.0/node_modules/store-pkg", ".pnpm/bundler@1.0.0/node_modules/bundler/node_modules/store-pkg", ), + ( + "npm linked-store alias entry (#852)", + ".store/store-pkg@1.0.0-iv4j8hdajpqgDc7lVr5hdA/node_modules/store-pkg", + ".store/sp@1.0.0-NCKE2NXgCY5tgWWRE6qdYA/node_modules/sp", + ), ]; let run = |primary: &str, other: &str, other_bytes: &[u8]| { diff --git a/crates/socket-patch-core/src/crawlers/gradle_cache.rs b/crates/socket-patch-core/src/crawlers/gradle_cache.rs index ef295ee27..afd7c4fba 100644 --- a/crates/socket-patch-core/src/crawlers/gradle_cache.rs +++ b/crates/socket-patch-core/src/crawlers/gradle_cache.rs @@ -70,8 +70,7 @@ pub fn hash_eq(dir_name: &str, sha1_hex: &str) -> bool { /// Whether `bytes` are the pristine download Gradle stored in the hash /// directory `dir_name` (their sha1 names it). pub fn pristine(dir_name: &str, bytes: &[u8]) -> bool { - use sha1::{Digest, Sha1}; - hash_eq(dir_name, &hex::encode(Sha1::digest(bytes))) + hash_eq(dir_name, &crate::utils::digest::sha1_hex_of(bytes)) } /// Whether `path` is a version directory of a `files-2.1` tree @@ -432,8 +431,6 @@ impl DerivedIndex { /// The [`DerivedCopies`] of the jar `jar_leaf` whose pristine bytes /// hash to `pristine_sha1`. pub fn query(&self, jar_leaf: &str, pristine_sha1: &str) -> DerivedCopies { - use sha1::{Digest, Sha1}; - let instrumented = format!("instrumented-{jar_leaf}"); let mut out = DerivedCopies { incomplete: self.incomplete, @@ -460,7 +457,9 @@ impl DerivedIndex { out.stale.push(path.clone()); } else if name == jar_leaf || name == instrumented { match crate::utils::fs::read_regular_to_bytes_sync(path) { - Ok(bytes) if hash_eq(&hex::encode(Sha1::digest(&bytes)), pristine_sha1) => { + Ok(bytes) + if hash_eq(&crate::utils::digest::sha1_hex_of(&bytes), pristine_sha1) => + { out.stale.push(path.clone()) } Ok(_) => out.unknown.push(path.clone()), diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 443ad9f14..8acd18f16 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -1689,7 +1689,10 @@ impl NpmCrawler { .then_some((index, pkg_path)) }) .collect(); - if !store_entry { + // npm's linked store keeps an alias install in an entry named after + // the alias, its real dir `node_modules/` (#852), so those + // entries are searched for alias copies like an importer tree. + if !store_entry || is_npm_linked_store_entry(&nm_path) { matched.extend(Self::alias_copies(&nm_path, &listing, pending)); } let gvs_member = !store_entry && may_be_gvs_workspace_member(&listing); @@ -3078,15 +3081,27 @@ async fn find_store_peer_variant_copies_reusing( } in entries { // Fast advertisement filter; undecodable pnpm names stay - // probeable, undecodable vlt ids are never variants. - match (advertised, layout) { + // probeable, undecodable vlt ids are never variants. npm's + // linked store names an alias install's entry after the ALIAS + // (`.store/lp@1.3.0-/node_modules/lp` holds the real + // `left-pad@1.3.0`, #852), so there a same-version entry under + // another name is probed at its own dir. + let dir_key = match (advertised, layout) { + (Some((n, v)), StoreLayout::NpmLinked) + if n != full_name + && v == version + && n.split('/').all(is_safe_npm_component) => + { + n + } (Some((n, v)), _) if n != full_name || v != version => continue, (None, StoreLayout::Vlt) => continue, - _ => {} - } - // `full_name` may be scoped (`@s/n`) — Path::join handles the + _ => full_name.clone(), + }; + let alias_entry = dir_key != full_name; + // `dir_key` may be scoped (`@s/n`) — Path::join handles the // two-segment relative form. - let mut candidate = entry_nm.join(&full_name); + let mut candidate = entry_nm.join(&dir_key); // Real dirs only: a link here is another entry's physical // copy, reached via that entry, unless it is the entry's own // `package` dir (Yarn 4), the physical copy itself. @@ -3094,6 +3109,9 @@ async fn find_store_peer_variant_copies_reusing( continue; }; if !meta.is_dir() { + if alias_entry { + continue; + } let (nm, key) = (entry_nm.clone(), full_name.clone()); match run_walk(move || store_entry_own_package_sync(&nm, &key)).await { Some(own) => candidate = own, @@ -3204,6 +3222,30 @@ fn store_entry_package_dir_sync(entry_nm: &Path) -> Option<(PathBuf, PathBuf)> { Some((own, canonical)) } +/// Whether `entry_nm` is the `node_modules` of an entry in npm's +/// `install-strategy=linked` store: `node_modules/.store//node_modules`, +/// or `node_modules/.store/@scope//node_modules` for a scoped package. +fn is_npm_linked_store_entry(entry_nm: &Path) -> bool { + let is_named = + |dir: Option<&Path>, name: &str| dir.and_then(Path::file_name) == Some(OsStr::new(name)); + let Some(mut parent) = entry_nm.parent().and_then(Path::parent) else { + return false; + }; + if parent + .file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| name.starts_with('@')) + { + match parent.parent() { + Some(store) => parent = store, + None => return false, + } + } + is_named(Some(entry_nm), "node_modules") + && is_named(Some(parent), NPM_LINKED_STORE_NAME) + && is_named(parent.parent(), "node_modules") +} + /// Whether `pkg_path` is the physical dir one of `copies` resolves to. fn resolves_to_any_sync(pkg_path: &Path, copies: &[CrawledPackage]) -> bool { let Ok(canon) = std::fs::canonicalize(pkg_path) else { @@ -4955,6 +4997,91 @@ mod tests { ); } + /// #852: under `install-strategy=linked`, npm 9–11 store an alias + /// install (`"lp": "npm:left-pad@1.3.0"`) in an entry named after the + /// ALIAS: `.store/lp@1.3.0-/node_modules/lp` holds the real + /// `left-pad@1.3.0`, and the importer's `node_modules/lp` links to it. + /// Beside a plain copy, the plain copy is the resolver's primary and + /// the alias entry is the peer-variant fan-out's to find, or apply + /// leaves `require('lp')` unpatched while VEX attests `not_affected`. + #[tokio::test] + async fn test_npm_linked_store_alias_entry_beside_a_plain_copy_is_a_copy() { + let tmp = tempfile::tempdir().unwrap(); + let root: PathBuf = tmp.path().components().collect(); + let nm = root.join("node_modules"); + let store = nm.join(".store"); + + let plain = store.join("left-pad@1.3.0-iv4j8hdajpqgDc7lVr5hdA/node_modules/left-pad"); + write_pkg(&plain, "left-pad", "1.3.0"); + let alias = store.join("lp@1.3.0-NCKE2NXgCY5tgWWRE6qdYA/node_modules/lp"); + write_pkg(&alias, "left-pad", "1.3.0"); + // A scoped alias name, and an unscoped alias of a scoped package. + let scoped_alias = store.join("@x/pad@1.3.0-AAAAAAAAAAAAAAAAAAAAAA/node_modules/@x/pad"); + write_pkg(&scoped_alias, "left-pad", "1.3.0"); + // An alias of another version, and an alias entry whose own dir is + // a link (a dependency edge), are not copies. + let other = store.join("lp2@1.2.0-BBBBBBBBBBBBBBBBBBBBBB/node_modules/lp2"); + write_pkg(&other, "left-pad", "1.2.0"); + let edge_entry = store.join("lp3@1.3.0-CCCCCCCCCCCCCCCCCCCCCC/node_modules"); + std::fs::create_dir_all(&edge_entry).unwrap(); + link_dir(&plain, &edge_entry.join("lp3")); + link_dir(&plain, &nm.join("left-pad")); + link_dir(&alias, &nm.join("lp")); + + let purl = "pkg:npm/left-pad@1.3.0".to_string(); + let found = NpmCrawler::new() + .find_by_purls(&nm, std::slice::from_ref(&purl)) + .await + .unwrap(); + assert_eq!(copy_paths(&found, &purl), vec![nm.join("left-pad")]); + + let mut variants = find_store_peer_variant_copies(&nm.join("left-pad")).await; + variants.sort(); + let mut want = vec![alias.clone(), scoped_alias.clone()]; + want.sort(); + assert_eq!(variants, want); + + // VEX's every-installed-copy set sees the alias entries too. + let mut all = with_store_peer_variant_copies(vec![nm.join("left-pad")]).await; + all.sort(); + let mut want = vec![nm.join("left-pad"), alias.clone(), scoped_alias.clone()]; + want.sort(); + assert_eq!(all, want); + } + + /// #852: with only the alias installed, the alias-named linked store + /// entry is the purl's only copy. Apply reported it "not found on + /// disk" (exit 0) and VEX refused with `package_not_found`. + #[tokio::test] + async fn test_npm_linked_store_alias_only_install_is_resolved() { + let tmp = tempfile::tempdir().unwrap(); + let root: PathBuf = tmp.path().components().collect(); + let nm = root.join("node_modules"); + let store = nm.join(".store"); + + let alias = store.join("lp@1.3.0-NCKE2NXgCY5tgWWRE6qdYA/node_modules/lp"); + write_pkg(&alias, "left-pad", "1.3.0"); + let scoped = store.join("sp@2.0.0-DDDDDDDDDDDDDDDDDDDDDD/node_modules/sp"); + write_pkg(&scoped, "@s/pkg", "2.0.0"); + link_dir(&alias, &nm.join("lp")); + link_dir(&scoped, &nm.join("sp")); + + let pad = "pkg:npm/left-pad@1.3.0".to_string(); + let scoped_purl = "pkg:npm/%40s/pkg@2.0.0".to_string(); + let found = NpmCrawler::new() + .find_by_purls(&nm, &[pad.clone(), scoped_purl.clone()]) + .await + .unwrap(); + assert_eq!(copy_paths(&found, &pad), vec![alias.clone()]); + let copy = &found[&pad][0]; + assert_eq!( + (copy.name.as_str(), copy.version.as_str()), + ("left-pad", "1.3.0") + ); + assert_eq!(copy_paths(&found, &scoped_purl), vec![scoped.clone()]); + assert_eq!(found[&scoped_purl][0].namespace.as_deref(), Some("@s")); + } + /// #362: pnpm's `virtualStoreDir` moves the virtual store, and /// `node_modules/.modules.yaml` records where (relative to /// `node_modules`: JSON on pnpm 10+, YAML before). A relocated store diff --git a/crates/socket-patch-core/src/patch/jvm_jar.rs b/crates/socket-patch-core/src/patch/jvm_jar.rs index 82d679406..f38a84403 100644 --- a/crates/socket-patch-core/src/patch/jvm_jar.rs +++ b/crates/socket-patch-core/src/patch/jvm_jar.rs @@ -25,8 +25,6 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use crate::crawlers::gradle_cache; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; @@ -353,12 +351,11 @@ fn unpatched_members( } fn sha256_hex(bytes: &[u8]) -> String { - use sha2::Digest as _; - hex::encode(sha2::Sha256::digest(bytes)) + crate::utils::digest::sha256_hex_of(bytes) } fn sha1_hex(bytes: &[u8]) -> String { - hex::encode(sha1::Sha1::digest(bytes)) + crate::utils::digest::sha1_hex_of(bytes) } /// `/jvm-originals/.jar`. diff --git a/crates/socket-patch-core/src/patch/sidecars/maven.rs b/crates/socket-patch-core/src/patch/sidecars/maven.rs index f2f5a2466..8798bfce6 100644 --- a/crates/socket-patch-core/src/patch/sidecars/maven.rs +++ b/crates/socket-patch-core/src/patch/sidecars/maven.rs @@ -17,8 +17,6 @@ use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use super::{ SidecarAdvisory, SidecarAdvisoryCode, SidecarError, SidecarFile, SidecarFileAction, SidecarPayload, SidecarSeverity, @@ -44,7 +42,7 @@ impl Algo { fn digest(self, bytes: &[u8]) -> String { match self { - Algo::Sha1 => hex::encode(sha1::Sha1::digest(bytes)), + Algo::Sha1 => crate::utils::digest::sha1_hex_of(bytes), Algo::Md5 => hex::encode(md5(bytes)), } }