Skip to content

Commit 06d306c

Browse files
mikolalysenkoclaude
andcommitted
Stop attesting a Cargo pin a workspace member resolves from crates.io
Cargo resolves each source on its own. When one declaration of a crate pins it to `socket-patch-<uuid>` but another (the root's or a workspace member's) names no Socket registry (crates.io, another registry, a path or git) with a requirement the locked patched version satisfies, that declaration builds a separate, unpatched copy, possibly of the very version the pin covers. Discovery still attested the Cargo.lock ref because "some declaration pins the patch" was enough. The hosted rewriter pins every declaration, so this mixed state is a partial revert or a hand edit. The ref now stays (rollback, remove and the ledgers' liveness still see the pin) but is marked unattested with the new `UnattestedKind::CargoUnpinnedDeclaration`, naming the unpinned declaration's manifest. When Cargo.lock already locks a same-version copy from another source beside the Socket entry, the detail names that lock entry as direct evidence. A declaration whose requirement excludes the patched version, and a `[patch]` registry entry (which redirects declarations rather than adding one), do not withhold the statement. The CLI omits the ref as `vex_cargo_unpinned_declaration`; documented in CLI_CONTRACT.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 1a043ec commit 06d306c

4 files changed

Lines changed: 398 additions & 9 deletions

File tree

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,7 @@ Recognition rules that hold for every ecosystem:
547547
* **Patch hosts.** A hosted reference counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin, with no userinfo. The uuid is the URL's LAST canonical-uuid path segment, because grant tokens may themselves be uuid-shaped. The Go module prefix is fixed. `socket-patch-<uuid>` registry / repository / source names count only through a pin. For a URL on any other host, see **Patch hosts** above.
548548
* **Pins, not definitions.** A registry, index or source *definition* alone (cargo `[registries]`, nuget `<add>`, pom `<repository>`, uv index tables, `.npmrc`) never makes a reference, because it survives a reverted pin. Sections the package manager ignores are not read: npm's v2 `dependencies` mirror, a `.cargo/config.toml` shadowed by `.cargo/config`. A Socket pin inside a maven `<profile>` is diagnosed, never a reference.
549549
* **Contested locks.** When one lock wires a package to a patch and another lock resolves the same `name@version` from a non-Socket source, the build's bytes depend on which package manager runs. The reference is then dropped with a `patched_ref_unattributable` diagnostic naming both files. This applies across npm / pnpm / yarn / bun and across uv / pylock / poetry / pdm / Pipfile.lock / requirements. PEP 723 script locks neither contest nor are contested. The root `requirements.txt` and its in-root `-r` includes count as one lock here: pip reads them as one requirement set, where a direct reference wins over a compatible `==` pin of the same version in another file of the set (#1086). A PyPI version matches under PEP 440 equality, so a requirements line pinned in any spelling that can only install the wired release (`==1.2.3.0`, `==v1.2.3`, `===1.2.3`) contests it like `==1.2.3` does; a range or wildcard (`~=1.2.3`, `==1.2.*`) does not (#1602). A **bundled** npm copy (`inBundle: true`, or v1 `bundled: true`, beneath a dependency that bundles it; the root project's own bundles install from the lock, #1618) of the same `name@version` contests the reference too, in the same lock, in the other npm lock of a shrinkwrap/package-lock pair, or in any other lock. npm unpacks it from the parent package's tarball, so no rewire reaches it and it stays unpatched. Bun and vlt unpack bundled copies the same way (#469, #471). pnpm unpacks bundled copies too, but its lock cannot tie one to a reference (see **Unattested references** below). For Bun, that is a `bun.lock` entry whose meta is `{ "bundled": true }`, or a `bun.lockb` record that a dependency edge with the `bundled` behavior bit reaches, including one Bun shares with a regular install. Such an entry is never a reference, and it contests the reference the same way. For vlt, the lock records no node for a bundled copy, so the copy is found in the installed store: a real package directory inside a store package's own `node_modules`. yarn classic's `yarn.lock` records nothing about bundled copies either (#758), so for a lock with a Socket wiring they are found in the installed `node_modules`: each package a parent's `bundleDependencies` / `bundledDependencies` names inside the parent's own `node_modules`, the dependencies of those beside them, and everything nested under them. Such a copy contests the reference in `yarn.lock` (the diagnostic names the parent's lock entry and the copy's directory); a lock-only `vex` with no `node_modules` can't see it. Hosted and vendored scans skip these copies with `redirect_bun_bundled_instance_skipped` / `redirect_vlt_bundled_instance_skipped` / `vendor_bundled_instance_skipped`, and a yarn classic hosted scan names one with `redirect_copy_unpatched`. When a bundled copy is the only instance, vendoring refuses with `vendor_lock_entry_not_rewritable`. A reference withheld only because such an unreachable copy installs beside it (a bundled npm / Bun / vlt copy, an npm copy beneath a `hasShrinkwrap` package (#753) or one npm installs from a git / url / `file:` spec, or a yarn classic git or `file:` directory block of the same `name@version`) is still the rewriter's own wiring of exactly one package version: it is never attested, but `rollback`, `remove`, `list` and the hosted → vendored takeover treat it as an ordinary hosted pin and restore its upstream registry entry (#828), rather than refusing it with `hosted_wiring_contested`. Another entry of the **same** npm, Bun or yarn lock that resolves the wired `name@version` from a non-Socket source (for example a workspace member added after the rewire, then `npm install` / `bun install` / `yarn install`; for yarn berry, a registry locator such as `left-pad@npm:1.3.0` beside the hosted `…::__archiveUrl=` one) contests the reference too (#588). The package manager installs both entries, and that copy stays unpatched. Re-running `scan` / `vendor` rewires every copy.
550-
* **Unattested references.** Some evidence shows a build may run a copy no wiring reaches, but cannot be tied to the reference's exact `name@version` or cannot say the build runs it. The reference then stays a reference: `list`, `rollback` and `remove` find it, and the ledgers' liveness gates (`vendor --check`, `scan`) keep treating the wiring as live, because re-running `scan` / `vendor` could never clear the evidence. Only `vex` omits it, as a run warning and as the `failed[].reason`. Four cases besides Gradle's (`vex_gradle_lock_above_base`): **pnpm bundled copies** (`vex_pnpm_bundled_copy`): a `packages:` entry's `bundledDependencies:` names the copies pnpm unpacks from that package's own tarball, but pnpm never locks them, so the bundled version is not in the lock. A pnpm reference whose package name a `bundledDependencies` list in the same lock names is omitted whatever its version (a missed attestation when the bundled copy is another version, never a false one), and `bundledDependencies: true` (or a value that cannot be read) omits every reference of that lock. It reaches no other lock. **deno.lock** (`vex_deno_lock_copy`): `deno install` installs a `package.json` project's npm dependencies from `deno.lock` and never reads `package-lock.json` / `pnpm-lock.yaml` / `yarn.lock`, so an npm-family reference whose `name@version` the `deno.lock` npm section also locks is omitted (#406). Whether Deno or another package manager populates `node_modules` (`nodeModulesDir: "manual"` allows either) is not in the files, so this holds whatever `nodeModulesDir` says. **Maven reactor roots** (`vex_maven_reactor_root`): a hosted pin in a root `pom.xml` that declares `<modules>` / `<subprojects>`, or whose subprojects Maven 4 discovers (see the hosted Maven rewriter), is omitted, because a module's own literal `<version>` overrides a root pin and module poms are not read (#261); hosted scan refuses such roots (`redirect_maven_multimodule_unsupported`), so this only covers pins an older release wrote. **Maven classifier copies** (`vex_maven_classifier_unpatched`): a `<classifier>` dependency of a hosted-pinned GA is its own coordinate; the classifier-less pin does not retarget it and the grant serves only the main jar, so Maven fetches it from the next repository. When a live, non-profile classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build) resolves any version other than the suffixed pin — its own literal `<version>`, a `<dependencyManagement>` entry with the same classifier, or no version in the root pom — the pin is omitted. `sources` / `javadoc` copies never run on a classpath and do not withhold the statement; hosted scan only warns about them (`redirect_maven_classifier_unsupported`) and skips the dep when an executable copy names the patched release.
550+
* **Unattested references.** Some evidence shows a build may run a copy no wiring reaches, but cannot be tied to the reference's exact `name@version` or cannot say the build runs it. The reference then stays a reference: `list`, `rollback` and `remove` find it, and the ledgers' liveness gates (`vendor --check`, `scan`) keep treating the wiring as live, because re-running `scan` / `vendor` could never clear the evidence. Only `vex` omits it, as a run warning and as the `failed[].reason`. Five cases besides Gradle's (`vex_gradle_lock_above_base`): **pnpm bundled copies** (`vex_pnpm_bundled_copy`): a `packages:` entry's `bundledDependencies:` names the copies pnpm unpacks from that package's own tarball, but pnpm never locks them, so the bundled version is not in the lock. A pnpm reference whose package name a `bundledDependencies` list in the same lock names is omitted whatever its version (a missed attestation when the bundled copy is another version, never a false one), and `bundledDependencies: true` (or a value that cannot be read) omits every reference of that lock. It reaches no other lock. **deno.lock** (`vex_deno_lock_copy`): `deno install` installs a `package.json` project's npm dependencies from `deno.lock` and never reads `package-lock.json` / `pnpm-lock.yaml` / `yarn.lock`, so an npm-family reference whose `name@version` the `deno.lock` npm section also locks is omitted (#406). Whether Deno or another package manager populates `node_modules` (`nodeModulesDir: "manual"` allows either) is not in the files, so this holds whatever `nodeModulesDir` says. **Maven reactor roots** (`vex_maven_reactor_root`): a hosted pin in a root `pom.xml` that declares `<modules>` / `<subprojects>`, or whose subprojects Maven 4 discovers (see the hosted Maven rewriter), is omitted, because a module's own literal `<version>` overrides a root pin and module poms are not read (#261); hosted scan refuses such roots (`redirect_maven_multimodule_unsupported`), so this only covers pins an older release wrote. **Maven classifier copies** (`vex_maven_classifier_unpatched`): a `<classifier>` dependency of a hosted-pinned GA is its own coordinate; the classifier-less pin does not retarget it and the grant serves only the main jar, so Maven fetches it from the next repository. When a live, non-profile classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build) resolves any version other than the suffixed pin — its own literal `<version>`, a `<dependencyManagement>` entry with the same classifier, or no version in the root pom — the pin is omitted. `sources` / `javadoc` copies never run on a classpath and do not withhold the statement; hosted scan only warns about them (`redirect_maven_classifier_unsupported`) and skips the dep when an executable copy names the patched release. **Unpinned Cargo declarations** (`vex_cargo_unpinned_declaration`): cargo resolves each source on its own, so when one declaration of a crate pins it to `socket-patch-<uuid>` but another — the root `Cargo.toml`'s or a workspace member's, read as hosted scan reads them — names no Socket registry (crates.io, another registry, a path or git) with a version requirement the locked patched version satisfies, that declaration builds a separate, unpatched copy, possibly of the very version the pin covers. The `Cargo.lock` reference is omitted, naming the unpinned declaration's manifest, and the warning also names a same-version lock entry from another source when the lock already holds one. Hosted scan pins every declaration, so this is a partial revert or a hand edit; re-running `scan` pins a crates.io declaration again. A declaration whose requirement excludes the patched version (cargo resolves it to another version) does not withhold the statement.
551551
* **Shrinkwrap without a twin (#899).** npm >= 12 never reads `npm-shrinkwrap.json`: on a project whose only npm lock is the shrinkwrap it resolves the tree from the registry and writes a fresh `package-lock.json`, so a Socket reference there reaches npm <= 11 only. The reference is still discovered, so `list`, `rollback` and `remove` manage it, but `vex` omits the patch (`vex_npm_shrinkwrap_only`, as a run warning and as the `failed[].reason`). A `package-lock.json` beside it (wired the same way) makes it attestable again. Hosted and vendored runs still rewire the shrinkwrap and warn `redirect_npm_shrinkwrap_only` / `vendor_npm_shrinkwrap_only`.
552552
* **Lockless pins.** With no lock to name a version, a `Cargo.toml` pin (every declaration on `socket-patch-<uuid>`, that registry defined on the patch host for the same uuid) or an exclusive nuget exact-id mapping is never a reference on its own, so v5.0 does not attest it (nor does `list` show it, or `rollback` / `remove` restore it — restore those files from version control). Only a pre-v5 redirect-ledger record naming a version the pin admits keeps it live. The same holds for a gem wired only in the `Gemfile` (the pre-bundler-2.6 mixed state, lock not converged).
553553

@@ -2678,6 +2678,7 @@ match it, withholds the statement:
26782678
| `vex_deno_lock_copy` | run warning and `failed[].reason` | An npm pin is wired, but `deno.lock` locks the same `name@version`, which `deno install` installs without the wiring; no statement while it does. `vendor --check` and `scan` still treat the wiring as live. |
26792679
| `vex_maven_reactor_root` | run warning and `failed[].reason` | A hosted Maven pin sits in a root `pom.xml` that declares `<modules>` / `<subprojects>` (a profile's included), where a module's own literal `<version>` overrides it; only the root pom is read, so no statement (#261). `list`, `rollback` and `remove` still find the pin. |
26802680
| `vex_maven_classifier_unpatched` | run warning and `failed[].reason` | A hosted Maven pin's GA also has a live executable `<classifier>` copy (not `sources` / `javadoc`) at a version other than the pin, which the classifier-less pin does not retarget and the grant does not serve; no statement while it does. `list`, `rollback` and `remove` still find the pin. |
2681+
| `vex_cargo_unpinned_declaration` | run warning and `failed[].reason` | A hosted Cargo pin is wired, but another declaration of the crate (the root's or a workspace member's) names no Socket registry and accepts the patched version, so cargo builds a separate unpatched copy for it; no statement while it does. `list`, `rollback` and `remove` still find the pin. |
26812682
| `vex_gradle_derived_cache_unchecked` | run warning | The derived-cache walk was cut short (a very large transforms cache); the statement is still emitted, the copies the walk did reach were checked. |
26822683

26832684
A vendored Gradle entry attests only while its wiring is live (apply line, index rows,

‎crates/socket-patch-cli/src/commands/vex_sources.rs‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,10 @@ const NOTE_MAVEN_REACTOR_ROOT: &str = "vex_maven_reactor_root";
232232
/// classifier copy at another version, which the pin does not reach
233233
/// (`vex::Unattested`).
234234
const NOTE_MAVEN_CLASSIFIER_UNPATCHED: &str = "vex_maven_classifier_unpatched";
235+
/// Omission tag and note: a hosted Cargo pin beside another declaration of
236+
/// the crate that names no Socket registry, which cargo resolves as a
237+
/// separate, unpatched package (`vex::Unattested`).
238+
const NOTE_CARGO_UNPINNED_DECLARATION: &str = "vex_cargo_unpinned_declaration";
235239

236240
/// The omission tag of an [`Unattested`](socket_patch_core::vex::Unattested)
237241
/// ref, and the remedy its note ends with.
@@ -268,6 +272,12 @@ fn unattested_note(kind: UnattestedKind) -> (&'static str, &'static str) {
268272
"not attested while that classifier copy resolves an unpatched version; the \
269273
wiring itself is intact, so re-running `scan` does not change this",
270274
),
275+
UnattestedKind::CargoUnpinnedDeclaration => (
276+
NOTE_CARGO_UNPINNED_DECLARATION,
277+
"not attested while that declaration names no Socket registry (re-run \
278+
`socket-patch scan` to pin a crates.io declaration too; one from another \
279+
registry, a path or git cannot be pinned, so roll the patch back)",
280+
),
271281
}
272282
}
273283

@@ -1762,6 +1772,52 @@ mod tests {
17621772
assert!(live.hosted.contains_key(PURL));
17631773
}
17641774

1775+
/// A hosted Cargo pin beside an unpinned declaration of the crate is
1776+
/// omitted as `vex_cargo_unpinned_declaration`, the note naming the
1777+
/// declaration and the remedy.
1778+
#[tokio::test]
1779+
async fn a_cargo_pin_beside_an_unpinned_declaration_is_gated() {
1780+
const PURL: &str = "pkg:cargo/serde@1.0.1";
1781+
let tmp = tempfile::tempdir().unwrap();
1782+
let mut redirect = RedirectState::new();
1783+
redirect.records.insert(PURL.into(), record(U1));
1784+
let mut found = discovery(vec![hosted_ref(PURL, U1, true)]);
1785+
found.unattested.push(socket_patch_core::vex::Unattested {
1786+
purl: PURL.into(),
1787+
uuid: U1.into(),
1788+
file: "crates/a/Cargo.toml".into(),
1789+
detail: "crates/a/Cargo.toml declares serde \"1\" from crates.io".into(),
1790+
kind: UnattestedKind::CargoUnpinnedDeclaration,
1791+
});
1792+
let gated = plan(
1793+
&common(tmp.path()),
1794+
Sources {
1795+
manifest: PatchManifest::new(),
1796+
vendor: VendorState::new(),
1797+
redirect: Some(redirect),
1798+
discovery: found,
1799+
},
1800+
&[],
1801+
&RunApiClient::new(),
1802+
)
1803+
.await;
1804+
assert!(gated.view.patches.is_empty() && gated.hosted.is_empty());
1805+
assert_eq!(
1806+
gated.gated,
1807+
vec![failed(PURL, NOTE_CARGO_UNPINNED_DECLARATION)]
1808+
);
1809+
assert!(
1810+
gated
1811+
.notes
1812+
.iter()
1813+
.any(|n| n.code == NOTE_CARGO_UNPINNED_DECLARATION
1814+
&& n.detail.contains("crates/a/Cargo.toml declares serde")
1815+
&& n.detail.contains("socket-patch scan")),
1816+
"{:?}",
1817+
gated.notes
1818+
);
1819+
}
1820+
17651821
/// Ledger-only records with no wiring anywhere are gated, except the
17661822
/// manifest-owned one, which falls back to agent-mode verification; an
17671823
/// in-run confirmed purl is live by construction.

0 commit comments

Comments
 (0)