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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<base>-socket.<hex8>` matching exactly ONE `socket-patch-<uuid>` repository on the patch host | pre-v5 single-POM wiring only: `socket-patch-vendor-<uuid>` repository + exactly one jar under `.socket/vendor/maven/<uuid>/` 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-<uuid>` + its exclusive exact-id `<packageSourceMapping>`; version from `packages.lock.json` | the same mapping onto `.socket/vendor/nuget/<uuid>`; 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:

Expand Down Expand Up @@ -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.<name>.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. |
Expand Down Expand Up @@ -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 (`"[email protected]"` 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 <v>`, `sha512 mismatch`, `http <status>`, `fetch error <e>`, `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 `<redacted>`; 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/<DepID>` 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. |
Expand Down
70 changes: 70 additions & 0 deletions crates/socket-patch-cli/tests/scan/hosted_failure_paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/[email protected]",
"--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 [
"[email protected]",
"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+
Expand Down
34 changes: 33 additions & 1 deletion crates/socket-patch-core/src/crawlers/deno_crawler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -219,6 +219,38 @@ 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
/// (`[email protected][email protected]`), 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 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<String> {
let Ok(lock) = serde_json::from_str::<Value>(crate::formats::text::strip_bom(text)) else {
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 `<root>/@<scope>/<name>/<version>/` and emit a
/// `CrawledPackage` per (scope, name, version) tuple found.
async fn scan_jsr_cache(root: &Path, seen: &mut HashSet<String>, out: &mut Vec<CrawledPackage>) {
Expand Down
Loading
Loading