From 0f55b7f9fe01b94261c8b8cdcdb7f545fa39ce7c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 08:23:51 +0000 Subject: [PATCH 1/4] Start fix for #406 Assisted-by: Claude Code:claude-opus-5-5 From 8f9129ea2d0a3f8ea6e4ef11134ca8dd87a9ec4d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 08:31:28 +0000 Subject: [PATCH 2/4] Warn when deno.lock keeps installing npm pins In a Deno project that also keeps an npm-family lock, `deno install` installs the npm dependencies from deno.lock and never reads the lock socket-patch rewires. Hosted and vendored runs reported a clean success anyway, so Deno users believed the dependency was patched while Deno kept installing the registry copy. Hosted scans now warn `redirect_npm_deno_lock` and vendored runs `vendor_npm_deno_lock` when deno.lock locks the same name@version, naming the agent-mode remedy. The deno.lock npm-section reader is shared with VEX discovery, which already omits such pins. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- .../tests/scan/hosted_failure_paths.rs | 70 +++++++++++ .../src/crawlers/deno_crawler.rs | 36 +++++- crates/socket-patch-core/src/hosted/engine.rs | 111 ++++++++++++++++++ .../src/patch/redirect/mod.rs | 20 ++++ .../src/vendor/npm_flavor.rs | 85 ++++++++++++++ .../src/vex/discover/deno.rs | 47 +++----- docs/ecosystems.md | 7 +- 8 files changed, 349 insertions(+), 31 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index e2bc9b3d9..97afa70c5 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -529,7 +529,7 @@ Discovery is read-only, never touches the network, and never fails the run: a ma | maven | `pom.xml` (+ `.mvn/maven.config`, `.mvn/checksums/checksums.sha256`) | a dependency version `-socket.` matching exactly ONE `socket-patch-` repository on the patch host | pre-v5 single-POM wiring only: `socket-patch-vendor-` repository + exactly one jar under `.socket/vendor/maven//` with a matching `.sha1`. A v5 suffixed pin served from `.socket/vendor/maven2` is no reference here: its ledger entry's wiring check gates it, so without a ledger it is not attested | Trusted Checksums line when enabled; not required (the suffixed version is the pin) | | gradle (v5.0) | `.socket/gradle/hosted-index.tsv`, the owned hosted script, and every settings script and lock file the script graph reaches | an index row whose repository URL is on the patch host and names the row's uuid, live only while the owned script is intact, every build's settings file applies it with the current index digest, every lock entry of the GA is the suffixed version, no `settings-gradle.lockfile` names the GA and no build script sets a custom `lockFile` (otherwise `patched_ref_invalid`) | none here: a vendored Gradle entry is gated by its ledger entry's wiring check | the suffixed copies installed in the Gradle cache; required (never the lock basis), because the script lets a higher upstream version resolve | | nuget | the first of `nuget.config` / `NuGet.config` / `NuGet.Config`, + `packages.lock.json` | source `socket-patch-` + its exclusive exact-id ``; version from `packages.lock.json` | the same mapping onto `.socket/vendor/nuget/`; version from the lock, else the feed's single nupkg | `contentHash`, required | -| deno | `deno.lock`'s npm section, only as evidence against an npm-family pin of a `name@version` it also locks, which `vex` then omits (see **Unattested references**, #406) | — (no hosted mode) | — (no vendored backend) | — | +| deno | `deno.lock`'s npm section, only as evidence against an npm-family pin of a `name@version` it also locks, which `vex` then omits (see **Unattested references**, #406); hosted and vendored npm runs warn about such a pin (`redirect_npm_deno_lock` / `vendor_npm_deno_lock`) | — (no hosted mode) | — (no vendored backend) | — | Recognition rules that hold for every ecosystem: @@ -1555,6 +1555,7 @@ command's integration tests check its output. | `vex_npm_shrinkwrap_only` | run warning and `failed[].reason` | vex (every form, #899): the patch is wired only in a root `npm-shrinkwrap.json` with no `package-lock.json` twin, which npm >= 12 never reads; no statement until the twin exists. `list` / `rollback` / `remove` still manage the wiring. | | `vendor_multiple_lockfiles` / `pypi_multiple_lockfiles` | `skipped` (warning) | vendor: a sibling lockfile of another package manager (for PyPI, also a root `requirements.txt` that pins the package beside the wired tool lock) will still install UNPATCHED bytes; names the wired winner + the ignored locks. Beside `Pipfile.lock` or `uv.lock`, an exact registry pin of the package in `requirements.txt` or an in-root `-r` include (`pipenv requirements` or `uv export` output) is wired with the lock instead and never named here (#612, #1368): both files then refer to the committed wheel, the ledger entry records both, every revert restores both, a superseding patch re-wires both, and a re-run over a project whose lock is already wired (a vendor from before, or an export made since) wires the export too (`pypi_requirements_sibling_wired`). A pin the requirements wiring cannot rewrite (a range, extras, an include outside the root) stays a loser. Likewise, a `pylock.toml` / `pylock..toml` that `pipenv lock` writes beside `Pipfile.lock` under `[pipenv] use_pylock = true` is wired with it (#1368), with the same ledger, revert, supersede and re-run behavior (`pypi_pipenv_pylock_wired`); a pylock the wiring cannot rewrite in place (a symlink, a non-registry source) stays a loser. | | `vendor_npm_shrinkwrap_only` | `skipped` (warning) | vendor / scan / get `--mode vendored` (npm, #899): the package was wired into `npm-shrinkwrap.json`, the only npm lock (also on an in-sync re-run). npm >= 12 never reads the shrinkwrap and installs the unpatched registry bytes; rename the lock to `package-lock.json` (or commit a copy under that name) and re-run. | +| `vendor_npm_deno_lock` | `skipped` (warning) | vendor / scan / get `--mode vendored` (npm, every lockfile flavor, #406): the package was vendored (also on an in-sync re-run), but the project root's `deno.lock` locks the same `name@version`. `deno install` installs a `package.json` project's npm dependencies from `deno.lock` and never reads the rewired lock, so installs Deno runs keep the registry copy and `vex` omits the patch (`vex_deno_lock_copy`); npm / pnpm / yarn / bun installs are patched. To patch what Deno installs, run `deno install` and then `socket-patch scan --mode agent`. | | `vendor_workspace_member_skipped` | `skipped` (warning) | vendor / scan / get `--mode vendored` (npm, #688): a lock entry with the package's `name@version` is the project's own source (a workspace member or a `file:` directory), so it is left alone while the lock's registry copies are vendored; patch that source directly if it needs the fix. When it is the only instance, vendoring refuses instead. | | `vendor_copy_unpatched` | `skipped` (warning) | vendor / scan / get `--mode vendored` (yarn berry, #939): the registry entry was wired, but `yarn.lock` also installs the same `name@version` from a `file:` tarball / directory, URL tarball or `portal:` / `link:` dependency declared under another name (`lp2@file:…`), which the name-keyed `resolutions` entry never reaches; the detail names that entry, and that copy stays unpatched (`vex` does not attest the package beside it). | | `vendor_npm_allow_file` | `skipped` (warning, printed as `Warning (vendor_npm_allow_file)`) | vendor / scan / get `--mode vendored` (package-lock, #969): the effective npm `allow-file` setting (env > project `.npmrc` > user > global > builtin) is not `all`, so npm >= 11.14 refuses the vendored `file:` tarball (EALLOWFILE) on install. The setting is respected, never rewritten; the detail names where it comes from and the remedy (`allow-file=all`). `vendor --check` fails the entry for the same reason. | @@ -1675,6 +1676,7 @@ command's integration tests check its output. | `redirect_npm_allow_scripts` | `warnings[]` (warning) | scan/get `--mode hosted` (npm, #1595): a root npm lock pins a patched package to its hosted artifact, a lock entry for it has `hasInstallScript`, and package.json `allowScripts` approves it only by version (`"pkg@x.y.z"` or `||`-joined exact versions) with no name-only approval and no deny. npm 12 matches a version pin only against a registry tarball URL, so the approved install script stops running (every install fails under `strict-allow-scripts`). The policy is never rewritten; the detail names the pinned keys and the name-only remedy. | | `redirect_vlt_no_lockfile` | `warnings[]` (warning) | scan/get `--mode hosted` (vlt): `vlt.json` or vlt's install state is present without `vlt-lock.json`; replaces `redirect_npm_no_lockfile` for vlt projects. | | `redirect_npm_shrinkwrap_only` | `warnings[]` (warning) | scan/get `--mode hosted` (npm, #899): the root `npm-shrinkwrap.json` is the only npm lock and carries a hosted redirect (this run's or an earlier one's — every run repeats it until the project gains a `package-lock.json`). npm >= 12 never reads the shrinkwrap and installs the unpatched registry bytes; npm <= 11 installs the patch. Rename the lock to `package-lock.json` (or commit a copy under that name) and re-run. | +| `redirect_npm_deno_lock` | `warnings[]` (warning) | scan/get `--mode hosted` (npm, #406): one warning naming every redirected npm `name@version` (this run's or an earlier one's) that the root `deno.lock` also locks. `deno install` installs a `package.json` project's npm dependencies from `deno.lock` and never reads an npm-family lock, so installs Deno runs keep the registry copy and `vex` omits the patch (`vex_deno_lock_copy`); npm / pnpm / yarn / bun installs are patched, and `deno.lock` is never written. To patch what Deno installs, run `deno install` and then `socket-patch scan --mode agent`. | | `redirect_vlt_artifact_unverifiable` | `warnings[]` (warning), `skipped` (hosted event) | scan/get `--mode hosted` (vlt): before any takeover or rewrite (dry runs included), each granted artifact with a default-registry instance in `vlt-lock.json` (or, for a purl a `flavor: "vlt"` vendored entry claims, its vendored node, probed before the takeover reverts it) is fetched once as vlt fetches it (`accept-encoding: gzip;q=1.0, identity;q=0.5`, no `Authorization`, up to 10 redirects) and must return 200 with no content encoding (or `identity`) and the granted sha512. On failure (`content-encoding `, `sha512 mismatch`, `http `, `fetch error `, `offline`) the dep is withheld from every rewriter when vlt drives or it is vlt-vendored (which also keeps it vendored), and from the vlt rewrite only otherwise (detail "…; vlt-lock.json was not changed for {purl}"; only the sibling lock this run rewrote can confirm it). A lock already pinned by an earlier run is left pinned, and neither confirmed nor attested. Projects without `vlt-lock.json` make no such request. The detail quotes the artifact URL (and any fetch error that echoes it) with its grant-token path level, the one just before the patch uuid, spelled ``; host, uuid and leaf stay. The in-memory hosted engine (`hosted-bundle`, the Node addon) has no network for this fetch, so it judges every in-scope artifact as `--offline` does (withheld, never pinned; the vendored takeover it refuses anyway). Exit 0. | | `redirect_vlt_reinstall_required` | `warnings[]` (advisory); rollback/remove `warnings[]` (+ human stderr) | vlt: `vlt-lock.json` pins (or, after rollback/remove, no longer pins) Socket-patched packages, and vlt never refreshes an installed copy. The heal removes `node_modules/.vlt-lock.json` and each stale `node_modules/.vlt/` of a Socket-owned node (never a link's target, never outside the project, never a copy it cannot judge) unless `--no-vlt-install-cleanup` or `--dry-run`. It never removes an optional node's copy (lock flags 1 or 3, or flags it cannot read): `vlt install` does not put a removed optional dependency back (its link dangles) unless the same install also reinstalls a non-optional node, so such a copy is left stale and the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`); vlt 0.0.0-30 … 1.0.4 install no optional dependency from the lock of a project that declares only optional dependencies, so there both commands remove the installed copy and the detail says to upgrade vlt to 1.0.5 or later first. The detail says whether copies were removed, left stale by a skipped cleanup, could not be checked, or none were stale, and adds how many optional copies were kept whenever there are any. The kept optional copies are named by what they are: `unpatched copies of optional dependencies` after `scan`/`get`, `patched copies of optional dependencies` after `rollback`/`remove`, and `installed copies of the vendored optional dependencies` after a hosted → vendored takeover (the copy the hosted pin left installed, which may still be the registry bytes). Stale or unchecked copies are not attested by the run's `--vex`, nor is a confirmed vlt pin the heal did not check (a URL on a host other than patch.socket.dev and the configured `--patch-server-url`/`--api-url`). A hidden lock that cannot be removed keeps every store entry. Invalidation failures only warn. | | `redirect_bun_reinstall_required` | rollback/remove `warnings[]` (+ human stderr), `vendor --revert` `warnings[]` | a wet hosted unwind that restored `bun.lock` / `bun.lockb` pins to the registry record while their installed copy may be kept (the `vendor_bun_reinstall_required` rule: Bun's hoisted linker keeps it through a plain `bun install`). One run-level warning naming every such `name@version` and `bun install --force` (or deleting `node_modules`). `vendor --revert` leaves it out when the vendored revert already advised `vendor_bun_reinstall_required` for every package it re-hosted. | diff --git a/crates/socket-patch-cli/tests/scan/hosted_failure_paths.rs b/crates/socket-patch-cli/tests/scan/hosted_failure_paths.rs index 9137d82e6..50711b245 100644 --- a/crates/socket-patch-cli/tests/scan/hosted_failure_paths.rs +++ b/crates/socket-patch-cli/tests/scan/hosted_failure_paths.rs @@ -1652,6 +1652,76 @@ async fn shrinkwrap_only_project_warns_npm12_ignores_it_and_vex_omits_it() { assert_eq!(doc["vex"]["statements"], 1, "{doc:#}"); } +/// REGRESSION (#406): `deno install` installs a `package.json` project's +/// npm dependencies from `deno.lock` and never reads package-lock.json. A +/// project with both is still redirected (npm installs from the lock), but +/// the run warns `redirect_npm_deno_lock`, on the idempotent re-run too, +/// leaves deno.lock alone, and the in-run `--vex` attests nothing for it +/// (`vex_deno_lock_copy`). A deno.lock of another version says nothing. +#[tokio::test] +async fn deno_lock_of_the_redirected_version_warns_and_vex_omits_it() { + let server = MockServer::start().await; + mock_discovery(&server, PURL, UUID).await; + mock_granted_reference(&server, UUID, PURL, HOSTED_URL).await; + mock_view(&server, UUID, PURL).await; + let args = [ + "--vex", + "out.vex.json", + "--vex-product", + "pkg:npm/consumer@0.0.0", + "--patch-server-url", + "http://patch.test", + ]; + let deno_lock = |version: &str| { + format!(r#"{{"version":"5","npm":{{"{NAME}@{version}":{{"integrity":"sha512-X=="}}}}}}"#) + }; + + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), NAME); + let same = deno_lock("1.0.0"); + std::fs::write(tmp.path().join("deno.lock"), &same).unwrap(); + for run in ["first run", "re-run"] { + let (code, doc) = scan_hosted_json(tmp.path(), &server.uri(), &args, &[]); + assert_eq!(hosted_pins(&doc).len(), 1, "{run}: {doc:#}"); + let lock = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); + assert!(lock.contains(HOSTED_URL), "{run}: {lock}"); + let detail = warning_detail(&doc, "redirect_npm_deno_lock"); + for needle in [ + "covgap-hosted@1.0.0", + "deno.lock", + "deno install", + "--mode agent", + ] { + assert!(detail.contains(needle), "{run}: {needle}: {detail}"); + } + assert_eq!( + std::fs::read_to_string(tmp.path().join("deno.lock")).unwrap(), + same, + "{run}: deno.lock is never written" + ); + // The in-run VEX omits it, so the requested VEX fails the run. + assert_eq!(code, 1, "{run}: {doc:#}"); + assert_eq!(doc["error"]["code"], "no_applicable_patches", "{doc:#}"); + assert!( + codes_in(&doc["warnings"]).contains(&"vex_deno_lock_copy".to_string()), + "{run}: {doc:#}" + ); + assert!(!tmp.path().join("out.vex.json").exists()); + } + + // Another version in deno.lock: no warning, and the VEX attests. + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), NAME); + std::fs::write(tmp.path().join("deno.lock"), deno_lock("0.9.0")).unwrap(); + let (code, doc) = scan_hosted_json(tmp.path(), &server.uri(), &args, &[]); + assert_eq!(code, 0, "{doc:#}"); + assert!( + !codes_in(&doc["warnings"]).contains(&"redirect_npm_deno_lock".to_string()), + "{doc:#}" + ); + assert_eq!(doc["vex"]["statements"], 1, "{doc:#}"); +} + // ───────────────── yarn classic berry-migration advisory ───────────────── /// #907: a hosted pin in a classic yarn.lock is dropped by the next yarn 2+ diff --git a/crates/socket-patch-core/src/crawlers/deno_crawler.rs b/crates/socket-patch-core/src/crawlers/deno_crawler.rs index 80c2ac948..4063b47f6 100644 --- a/crates/socket-patch-core/src/crawlers/deno_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/deno_crawler.rs @@ -40,7 +40,7 @@ //! scope: same content-addressed-by-hash storage as JSR, plus no //! upstream PURL convention. -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet}; use std::path::{Path, PathBuf}; use serde_json::{Map, Value}; @@ -219,6 +219,40 @@ pub(crate) fn lock_section<'a>(lock: &'a Value, section: &str) -> Option<&'a Map .and_then(Value::as_object) } +/// The `(name, version)` of every package in a parsed `deno.lock`'s npm +/// section ([`lock_section`]), in key order. A key is `name@version` or +/// `@scope/name@version`, either with Deno's peer suffix +/// (`name@1.0.0_peer@2.0.0`), which is dropped; a key of no such shape is +/// skipped. Shared by VEX discovery (`vex::discover::deno`) and the hosted +/// and vendored npm writers' `deno.lock` warnings (#406). +pub(crate) fn lock_npm_packages(lock: &Value) -> Vec<(&str, &str)> { + lock_section(lock, "npm") + .map(|m| m.keys().filter_map(|key| npm_key(key)).collect()) + .unwrap_or_default() +} + +/// [`lock_npm_packages`] of a `deno.lock` text, as `name@version` strings. +/// Advisory: text that is not JSON, or carries no `version` (not a lock +/// Deno wrote), locks nothing. +pub(crate) fn lock_text_npm_packages(text: &str) -> BTreeSet { + let Ok(lock) = serde_json::from_str::(crate::formats::text::strip_bom(text)) else { + return BTreeSet::new(); + }; + if lock.get("version").is_none() { + return BTreeSet::new(); + } + lock_npm_packages(&lock) + .into_iter() + .map(|(name, version)| format!("{name}@{version}")) + .collect() +} + +fn npm_key(key: &str) -> Option<(&str, &str)> { + let at = key.get(1..)?.find('@')? + 1; + let version = key[at + 1..].split('_').next()?; + (!version.is_empty()).then_some((&key[..at], version)) +} + /// Walk `/@///` and emit a /// `CrawledPackage` per (scope, name, version) tuple found. async fn scan_jsr_cache(root: &Path, seen: &mut HashSet, out: &mut Vec) { diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 85f235c54..0170e0fa9 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -2335,6 +2335,7 @@ async fn rewrite_once( .cloned() .collect(); let confirmed = confirm(&files, &rewrite, candidates, binary_bun, withheld_from_vlt); + npm_warnings.extend(npm_deno_lock(view, &overrides, &confirmed)); Rewritten { files, symlinked_reads, @@ -2972,6 +2973,40 @@ fn npm_allow_remote( (npm_warnings, npmrc_config_write) } +/// #406: `deno install` installs a `package.json` project's npm +/// dependencies from `deno.lock` and never reads an npm-family lock, so a +/// redirected npm package that `deno.lock` also locks at the same version +/// keeps installing the registry copy wherever Deno installs. Said once, +/// naming every such package; the redirect still stands for npm / pnpm / +/// yarn / bun installs. Advisory read: no readable `deno.lock`, nothing. +fn npm_deno_lock( + view: &ProjectView<'_>, + overrides: &[DepOverride], + confirmed: &[(String, String)], +) -> Option { + use crate::patch::redirect::{full_name, npm_deno_lock_detail, DENO_LOCK}; + let mut npm = overrides + .iter() + .filter(|o| o.ecosystem == "npm") + .filter(|o| confirmed.iter().any(|(_, uuid)| *uuid == o.patch_uuid)) + .peekable(); + npm.peek()?; + let text = read_advisory_text(view, DENO_LOCK)?; + let locked = crate::crawlers::deno_crawler::lock_text_npm_packages(&text); + let packages: BTreeSet = npm + .map(|o| format!("{}@{}", full_name(o), o.version)) + .filter(|key| locked.contains(key)) + .collect(); + if packages.is_empty() { + return None; + } + let packages: Vec = packages.into_iter().collect(); + Some(warning( + "redirect_npm_deno_lock", + npm_deno_lock_detail(&packages, "redirected"), + )) +} + /// A dep counts as REDIRECTED only if its hosted-artifact URL (or its /// per-dependency registry index URL) actually landed in the project's /// files — either written by this run or already present from an earlier @@ -4111,6 +4146,82 @@ mod tests { assert!(redirected(&done), "{:?}", done.rewrite.warnings); } + /// REGRESSION (#406): `deno install` installs a `package.json` + /// project's npm deps from `deno.lock` and never reads + /// package-lock.json, so a redirect of a `name@version` that deno.lock + /// also locks is SAID (`redirect_npm_deno_lock`), once, while the + /// redirect still stands for npm. Another version in deno.lock, an + /// unparseable one, or none says nothing. + #[tokio::test] + async fn issue_406_a_deno_lock_of_the_redirected_version_is_said() { + const LOCK: &str = r#"{ + "name": "app", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { "name": "app", "dependencies": { "left-pad": "1.3.0" } }, + "node_modules/left-pad": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-UPSTREAM==" + } + } +} +"#; + let deno_lock = |key: &str| { + format!(r#"{{"version":"5","npm":{{"{key}":{{"integrity":"sha512-X=="}}}}}}"#) + }; + let said = |done: &Rewritten| { + let lock = done.rewrite.files.get("package-lock.json"); + assert!( + lock.is_some_and(|l| l.contains(&left_pad_url())), + "{:?}", + done.rewrite.warnings + ); + let found: Vec<&RewriteWarning> = done + .npm_warnings + .iter() + .filter(|w| w.code == "redirect_npm_deno_lock") + .collect(); + assert!(found.len() <= 1, "{found:?}"); + found.first().map(|w| w.detail.clone()) + }; + let cases = [ + (Some(deno_lock("left-pad@1.3.0")), true), + // Deno's peer suffix still names the same version. + (Some(deno_lock("left-pad@1.3.0_react@18.2.0")), true), + (Some(deno_lock("left-pad@1.2.0")), false), + (Some("not json".to_string()), false), + (None, false), + ]; + for (deno, expect) in cases { + // In memory. + let mut p = MemoryProject::new(); + p.insert_text("package-lock.json", LOCK); + if let Some(text) = &deno { + p.insert_text("deno.lock", text); + } + let (_, done) = npm_rewrite(&ProjectView::Memory(&p), &BTreeSet::new()).await; + let detail = said(&done); + assert_eq!(detail.is_some(), expect, "{deno:?}: {detail:?}"); + if let Some(detail) = detail { + assert!(detail.starts_with("left-pad@1.3.0 redirected"), "{detail}"); + assert!(detail.contains("deno install"), "{detail}"); + assert!(detail.contains("--mode agent"), "{detail}"); + } + // On disk. + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("package-lock.json"), LOCK).unwrap(); + if let Some(text) = &deno { + std::fs::write(tmp.path().join("deno.lock"), text).unwrap(); + } + let (_, done) = npm_rewrite(&ProjectView::Disk(tmp.path()), &BTreeSet::new()).await; + assert_eq!(said(&done).is_some(), expect, "disk {deno:?}"); + // deno.lock is never written. + assert!(!done.rewrite.files.contains_key("deno.lock")); + } + } + const LEFT_PAD_V9_LOCK: &str = "lockfileVersion: '9.0' importers: diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index f31ba9fc3..36f5da6e0 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -1336,6 +1336,26 @@ pub fn npm_shrinkwrap_only_detail(packages: &[String], how: &str) -> String { ) } +/// The project's Deno lock. `deno install` installs a `package.json` +/// project's npm dependencies from it and never reads an npm-family lock. +pub const DENO_LOCK: &str = "deno.lock"; + +/// The shared detail of the hosted (`redirect_npm_deno_lock`) and vendored +/// (`vendor_npm_deno_lock`) warnings (#406): `packages` (`name@version`) +/// are wired in the npm-family lock, but `deno.lock` also locks them, so +/// installs Deno runs keep the registry copy. +pub fn npm_deno_lock_detail(packages: &[String], how: &str) -> String { + format!( + "{} {how} in the npm lockfile, but {DENO_LOCK} locks the same version and \ + `deno install` installs this project's npm dependencies from {DENO_LOCK}, never \ + from package-lock.json / pnpm-lock.yaml / yarn.lock / bun.lock — so installs run \ + by Deno stay UNPATCHED, and `vex` leaves the package out (npm / pnpm / yarn / bun \ + installs are patched); to patch what Deno installs, run `deno install` and then \ + `socket-patch scan --mode agent`", + packages.join(", ") + ) +} + /// Leave `dep` on its registry entry because the project patches it with /// npm 12's native `npm patch` (#711). npm extracts whatever tarball the /// lock names and then applies the user's diff, failing the whole install diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index c6130fb2a..86ab08c15 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -520,10 +520,36 @@ pub async fn vendor_npm_any<'a>( } } } + // Every flavor's lock is one `deno install` never reads (#406). + if result.success { + if let Some(detail) = deno_lock_detail(project_root, purl).await { + warnings.push(VendorWarning::new(DENO_LOCK_WARNING, detail)); + } + } } outcome } +/// The warning of a vendored npm package that `deno.lock` also locks. +pub const DENO_LOCK_WARNING: &str = "vendor_npm_deno_lock"; + +/// #406: `deno install` installs a `package.json` project's npm +/// dependencies from `deno.lock` and never reads the npm-family lock the +/// vendoring rewired, so when `deno.lock` locks `purl`'s `name@version` +/// the installs Deno runs keep the registry copy. Advisory read: no +/// readable `deno.lock`, no detail. +async fn deno_lock_detail(project_root: &Path, purl: &str) -> Option { + use crate::patch::redirect::{npm_deno_lock_detail, DENO_LOCK}; + let (name, version) = super::npm_common::parse_npm_purl(purl)?; + let text = crate::utils::fs::read_regular_to_string(&project_root.join(DENO_LOCK)) + .await + .ok()?; + let key = format!("{name}@{version}"); + crate::crawlers::deno_crawler::lock_text_npm_packages(&text) + .contains(&key) + .then(|| npm_deno_lock_detail(&[key], "is vendored")) +} + /// The refusal for re-vendoring `purl` under `detected` when its ledger /// entry was wired by a different flavor and either side is vlt: the other /// backend's records name a lock this run never reads, so a revert through @@ -1832,6 +1858,65 @@ mod tests { ))); } + /// REGRESSION (#406): `deno install` installs a `package.json` + /// project's npm deps from `deno.lock` and never reads the lock the + /// vendoring rewired, so a vendored `name@version` that deno.lock also + /// locks is SAID (`vendor_npm_deno_lock`), on the in-sync re-run too; + /// the lock is still wired. Another version in deno.lock says nothing. + #[tokio::test] + async fn a_deno_lock_of_the_vendored_version_is_said() { + for (key, expect) in [ + ("left-pad@1.3.0", true), + ("left-pad@1.3.0_react@18.2.0", true), + ("left-pad@1.2.0", false), + ] { + let (tmp, record) = npm_project().await; + touch( + tmp.path(), + "deno.lock", + &format!(r#"{{"version":"5","npm":{{"{key}":{{"integrity":"sha512-X=="}}}}}}"#), + ) + .await; + for run in ["first", "re-run"] { + let outcome = vendor_any(tmp.path(), &record).await; + let VendorOutcome::Done { + result, warnings, .. + } = outcome + else { + panic!("expected Done, got {outcome:?}"); + }; + assert!(result.success, "{:?}", result.error); + let found: Vec<&VendorWarning> = warnings + .iter() + .filter(|w| w.code == DENO_LOCK_WARNING) + .collect(); + assert_eq!( + found.len(), + usize::from(expect), + "{key} {run}: {warnings:?}" + ); + if let Some(w) = found.first() { + assert!( + w.detail.starts_with("left-pad@1.3.0 is vendored"), + "{}", + w.detail + ); + assert!(w.detail.contains("deno install"), "{}", w.detail); + } + } + let lock = tokio::fs::read_to_string(tmp.path().join("package-lock.json")) + .await + .unwrap(); + assert!(lock.contains(&format!( + "file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz" + ))); + assert!(tokio::fs::read_to_string(tmp.path().join("deno.lock")) + .await + .unwrap() + .contains(key)); + } + } + /// #969: a project `.npmrc` whose `allow-file` refuses the vendored /// `file:` tarball (here `root`, and the root manifest does not declare /// left-pad) still vendors, but the run SAYS every install will fail diff --git a/crates/socket-patch-core/src/vex/discover/deno.rs b/crates/socket-patch-core/src/vex/discover/deno.rs index 3f17ab52c..7e0c3d1c4 100644 --- a/crates/socket-patch-core/src/vex/discover/deno.rs +++ b/crates/socket-patch-core/src/vex/discover/deno.rs @@ -27,11 +27,9 @@ //! (`name@1.0.0_peer@2.0.0`). The read is advisory: an unreadable or //! unparseable `deno.lock` marks nothing. -use serde_json::Value; - use std::path::PathBuf; -use crate::crawlers::deno_crawler::lock_section; +use crate::crawlers::deno_crawler::lock_npm_packages; use super::{ canonical_base_purl, npm_purl, parse_json, CopyTarget, DiscoverCtx, Discovery, UnattestedKind, @@ -47,7 +45,10 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { let Ok(lock) = parse_json(DENO_LOCK, text.as_bytes()) else { return; }; - for purl in deno_npm_keys(&lock).into_iter().filter_map(deno_npm_purl) { + let purls = lock_npm_packages(&lock) + .into_iter() + .filter_map(|(name, version)| npm_purl(name, version)); + for purl in purls { out.unwired_copy(UnwiredCopy { scope: None, target: CopyTarget::Purl(canonical_base_purl(&purl)), @@ -61,23 +62,6 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { } } -/// The keys of `deno.lock`'s npm package map, in any lockfile version -/// ([`lock_section`]). -fn deno_npm_keys(lock: &Value) -> Vec<&str> { - lock_section(lock, "npm") - .map(|m| m.keys().map(String::as_str).collect()) - .unwrap_or_default() -} - -/// The purl of one npm package key (`name@version`, `@scope/name@version`, -/// either with Deno's `_peer@x` suffix). -fn deno_npm_purl(key: &str) -> Option { - let at = key.get(1..)?.find('@')? + 1; - let (name, version) = (&key[..at], &key[at + 1..]); - let version = version.split('_').next()?; - npm_purl(name, version) -} - #[cfg(test)] mod tests { use super::super::testing::*; @@ -103,11 +87,12 @@ mod tests { #[test] fn deno_npm_keys_cover_every_lock_version() { + use crate::crawlers::deno_crawler::{lock_npm_packages, lock_text_npm_packages}; let keys = |text: &str| { let lock: serde_json::Value = serde_json::from_str(text).unwrap(); - super::deno_npm_keys(&lock) + lock_npm_packages(&lock) .into_iter() - .filter_map(super::deno_npm_purl) + .filter_map(|(name, version)| super::super::npm_purl(name, version)) .collect::>() }; let want = vec!["pkg:npm/left-pad@1.3.0".to_string()]; @@ -122,15 +107,21 @@ mod tests { want ); assert_eq!(keys(r#"{"version":"5"}"#), Vec::::new()); + let packages = |text: &str| lock_text_npm_packages(text).into_iter().collect::>(); assert_eq!( - super::deno_npm_purl("@types/node@20.0.0"), - super::super::npm_purl("@types/node", "20.0.0") + packages( + r#"{"version":"5","npm":{"@types/node@20.0.0":{}, + "left-pad@1.3.0_react@18.2.0":{},"nonsense":{},"empty@":{}}}"# + ), + ["@types/node@20.0.0", "left-pad@1.3.0"] ); + // Not a lock Deno wrote, or not JSON: locks nothing. + assert!(packages(r#"{"npm":{"left-pad@1.3.0":{}}}"#).is_empty()); + assert!(packages("not json").is_empty()); assert_eq!( - super::deno_npm_purl("left-pad@1.3.0_react@18.2.0"), - super::super::npm_purl("left-pad", "1.3.0") + packages("\u{feff}{\"version\":\"5\",\"npm\":{\"a@1.0.0\":{}}}"), + ["a@1.0.0"] ); - assert_eq!(super::deno_npm_purl("nonsense"), None); } /// REGRESSION (#406): `deno install` installs a `package.json` diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 60a2a8fce..43036f8ae 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -110,7 +110,12 @@ Each Legacy format has an upgrade path and an undo path. Both work in v5: (`redirect_npm_shrinkwrap_only` / `vendor_npm_shrinkwrap_only`), and `vex` omits those patches (`vex_npm_shrinkwrap_only`) while `list`, `rollback` and `remove` still manage them; rename the lock to package-lock.json (or commit - a copy under that name) and re-run. npm 12 defaults `allow-remote=none` and refuses the + a copy under that name) and re-run. A Deno project's `deno install` reads + only `deno.lock`, never an npm-family lock, so a hosted or vendored npm pin + of a `name@version` that `deno.lock` also locks warns + (`redirect_npm_deno_lock` / `vendor_npm_deno_lock`) and `vex` omits it + (`vex_deno_lock_copy`); patch Deno installs with `--mode agent` after + `deno install`. npm 12 defaults `allow-remote=none` and refuses the redirected tarballs (EALLOWREMOTE) unless the project `.npmrc` sets `allow-remote=all`, so the hosted run writes it (new file, or one appended line; once `rollback` / `remove` / the vendored takeover has restored the last From 882d7aa803c898c8b67fc8b579846a25b2923a09 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 08:40:01 +0000 Subject: [PATCH 3/4] Fix test build for deno.lock warning Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/hosted/engine.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 0170e0fa9..3b505d03b 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -4199,7 +4199,7 @@ mod tests { let mut p = MemoryProject::new(); p.insert_text("package-lock.json", LOCK); if let Some(text) = &deno { - p.insert_text("deno.lock", text); + p.insert_text("deno.lock", text.as_str()); } let (_, done) = npm_rewrite(&ProjectView::Memory(&p), &BTreeSet::new()).await; let detail = said(&done); From c300a1f1b889cb11abe151a6daf7ec445295e1e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 08:46:07 +0000 Subject: [PATCH 4/4] Cover deno.lock in the in-memory hosted engine The in-memory engine (hosted bundle, Node addon) never fetched deno.lock, so it could not warn about it. Fetch it as an advisory file. Read a deno.lock without a `version` key the same way VEX discovery does, so the warning names exactly the pins `vex` leaves out. Reword the warning so it is also accurate for vlt and shrinkwrap-only projects. Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-core/src/crawlers/deno_crawler.rs | 8 +++----- crates/socket-patch-core/src/hosted/engine.rs | 14 +++++++++----- .../socket-patch-core/src/hosted/memory/select.rs | 7 ++++++- crates/socket-patch-core/src/patch/redirect/mod.rs | 10 +++++----- crates/socket-patch-core/src/vex/discover/deno.rs | 8 ++++++-- 5 files changed, 29 insertions(+), 18 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/deno_crawler.rs b/crates/socket-patch-core/src/crawlers/deno_crawler.rs index 4063b47f6..ca72d2a94 100644 --- a/crates/socket-patch-core/src/crawlers/deno_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/deno_crawler.rs @@ -232,15 +232,13 @@ pub(crate) fn lock_npm_packages(lock: &Value) -> Vec<(&str, &str)> { } /// [`lock_npm_packages`] of a `deno.lock` text, as `name@version` strings. -/// Advisory: text that is not JSON, or carries no `version` (not a lock -/// Deno wrote), locks nothing. +/// Advisory: text that is not JSON locks nothing. Like VEX discovery, a +/// lock without a `version` is still read, so the warnings name exactly +/// the pins `vex` omits. pub(crate) fn lock_text_npm_packages(text: &str) -> BTreeSet { let Ok(lock) = serde_json::from_str::(crate::formats::text::strip_bom(text)) else { return BTreeSet::new(); }; - if lock.get("version").is_none() { - return BTreeSet::new(); - } lock_npm_packages(&lock) .into_iter() .map(|(name, version)| format!("{name}@{version}")) diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 3b505d03b..d96ea5d94 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -2985,22 +2985,26 @@ fn npm_deno_lock( confirmed: &[(String, String)], ) -> Option { use crate::patch::redirect::{full_name, npm_deno_lock_detail, DENO_LOCK}; - let mut npm = overrides + let npm: Vec<&DepOverride> = overrides .iter() .filter(|o| o.ecosystem == "npm") .filter(|o| confirmed.iter().any(|(_, uuid)| *uuid == o.patch_uuid)) - .peekable(); - npm.peek()?; + .collect(); + if npm.is_empty() { + return None; + } let text = read_advisory_text(view, DENO_LOCK)?; let locked = crate::crawlers::deno_crawler::lock_text_npm_packages(&text); - let packages: BTreeSet = npm + let mut packages: Vec = npm + .into_iter() .map(|o| format!("{}@{}", full_name(o), o.version)) .filter(|key| locked.contains(key)) .collect(); + packages.sort(); + packages.dedup(); if packages.is_empty() { return None; } - let packages: Vec = packages.into_iter().collect(); Some(warning( "redirect_npm_deno_lock", npm_deno_lock_detail(&packages, "redirected"), diff --git a/crates/socket-patch-core/src/hosted/memory/select.rs b/crates/socket-patch-core/src/hosted/memory/select.rs index 83a53d0ae..a61e1b555 100644 --- a/crates/socket-patch-core/src/hosted/memory/select.rs +++ b/crates/socket-patch-core/src/hosted/memory/select.rs @@ -51,13 +51,16 @@ pub(crate) const MANIFEST_REL: &str = ".socket/manifest.json"; pub(crate) const NPM_MANIFEST_REL: &str = "package.json"; /// Root-relative text files read beyond `REDIRECT_CANDIDATE_FILES`. -const EXTRA_TEXT_FILES: [&str; 6] = [ +/// `deno.lock` is advisory: an npm pin it also locks is warned about and +/// left out of VEX (#406). +const EXTRA_TEXT_FILES: [&str; 7] = [ PNPM_WORKSPACE_REL, NPMRC_REL, crate::patch::redirect::YARNRC_REL, VENDOR_STATE_REL, MANIFEST_REL, NPM_MANIFEST_REL, + crate::patch::redirect::DENO_LOCK, ]; /// The one directory name the disk Cargo member walk never enters (it @@ -576,6 +579,7 @@ mod tests { let mut entries = vec![ blob("package-lock.json"), blob("package.json"), + blob("deno.lock"), blob("src/index.js"), blob(".npmrc"), blob("bun.lockb"), @@ -608,6 +612,7 @@ mod tests { s.fetch_text, vec![ ".npmrc", + "deno.lock", "package-lock.json", "package.json", "tool.py", diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 36f5da6e0..d2974c86f 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -1346,11 +1346,11 @@ pub const DENO_LOCK: &str = "deno.lock"; /// installs Deno runs keep the registry copy. pub fn npm_deno_lock_detail(packages: &[String], how: &str) -> String { format!( - "{} {how} in the npm lockfile, but {DENO_LOCK} locks the same version and \ - `deno install` installs this project's npm dependencies from {DENO_LOCK}, never \ - from package-lock.json / pnpm-lock.yaml / yarn.lock / bun.lock — so installs run \ - by Deno stay UNPATCHED, and `vex` leaves the package out (npm / pnpm / yarn / bun \ - installs are patched); to patch what Deno installs, run `deno install` and then \ + "{} {how} in the project's npm-family lock, but {DENO_LOCK} locks the same \ + version and `deno install` installs this project's npm dependencies from \ + {DENO_LOCK}, never from that lock — so installs run by Deno stay UNPATCHED, and \ + `vex` leaves the package out (installs the other package manager runs from its \ + lock are unaffected); to patch what Deno installs, run `deno install` and then \ `socket-patch scan --mode agent`", packages.join(", ") ) diff --git a/crates/socket-patch-core/src/vex/discover/deno.rs b/crates/socket-patch-core/src/vex/discover/deno.rs index 7e0c3d1c4..581e95f7f 100644 --- a/crates/socket-patch-core/src/vex/discover/deno.rs +++ b/crates/socket-patch-core/src/vex/discover/deno.rs @@ -115,8 +115,12 @@ mod tests { ), ["@types/node@20.0.0", "left-pad@1.3.0"] ); - // Not a lock Deno wrote, or not JSON: locks nothing. - assert!(packages(r#"{"npm":{"left-pad@1.3.0":{}}}"#).is_empty()); + // Read without a `version`, as discovery reads it; not JSON locks + // nothing. + assert_eq!( + packages(r#"{"npm":{"left-pad@1.3.0":{}}}"#), + ["left-pad@1.3.0"] + ); assert!(packages("not json").is_empty()); assert_eq!( packages("\u{feff}{\"version\":\"5\",\"npm\":{\"a@1.0.0\":{}}}"),