Skip to content

Commit 827a1fb

Browse files
Restore registry bin paths on berry rollback (#1131) (#1322)
* WIP: Fix berry restore keeping tarball bin paths (#1131) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Restore registry bin paths on berry rollback A hosted yarn berry pin writes the served tarball's own bin: map (./dist/bin/uuid), as yarn does for a tarball locator. The hosted restore (rollback, remove, the hosted half of a vendored takeover) only swapped resolution, checksum and key back, so the restored npm: entry kept the tarball spelling. The lock was not byte-exact, and hardened or --refresh-lockfile installs failed YN0028 for packages like uuid and prettier. The upstream client now reads the version document's bin, and the restore re-renders a tarball-URL pin's bin: from it, the way yarn writes the npm: entry. Fixes #1131 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 2a11629 commit 827a1fb

4 files changed

Lines changed: 145 additions & 9 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -975,7 +975,7 @@ v5.0 replaces v4's per-purl reverts and whole-ledger reverse replay (`revert_rem
975975

976976
* **Scope.** The hosted pins are what lockfile discovery finds — `(purl, patch uuid, files wiring it)`, recognized only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin. A scoped rollback (paths / identifiers / `--ecosystems`) restores exactly the pins in scope; each pin restores or refuses on its own (there is no whole-ledger replay, and a pre-v5 ledger's edits are never replayed). A pin discovery cannot see is out of reach: a lockless cargo `registry = "socket-patch-<uuid>"` pin, a nuget exact-id mapping with no `packages.lock.json`, a gem wired only in the `Gemfile` (pre-bundler-2.6 mixed state) — restore those files from version control.
977977
* **What a restore does.** Every file wiring the pin is rewritten back to the DEFAULT UPSTREAM registry entry for `name@version`, re-resolving whatever the entry pins (tarball URL, integrity, checksum, hashes) from the public registry; only the hosted entries change and every other byte stays the file's own. A pin is **all-or-nothing**: refused in one of its files, it is restored in none of them, so no pin is left half hosted. Nothing reaches disk until every pin has resolved, and `--dry-run` resolves exactly like a wet run — registry lookups included — and skips only the write. Per format:
978-
* **npm family** — `package-lock.json` / `npm-shrinkwrap.json`, `yarn.lock` (classic and berry), `pnpm-lock.yaml` / `shrinkwrap.yaml`, `bun.lock`: resolution + integrity (+ shasum where recorded) from the npm registry's version document (`SOCKET_NPM_REGISTRY`); a yarn berry lock whose `.yarnrc.yml` names another `npmRegistryServer` reads that registry's document instead, so a mirror's off-path `dist.tarball` keeps its `::__archiveUrl=` binding, and a pnpm lock whose sibling settings name a registry reads that registry's document — `.npmrc` `registry` (or, for a scoped name, `@scope:registry`); on pnpm 10 a pnpm-workspace.yaml `registries` map instead when present; on pnpm 11+ (or an unknown major) pnpm-workspace.yaml `registries."@scope"` / `registry` / `registries.default` too, ahead of the matching `.npmrc` key — so a mirror's `tarball:` comes back as pnpm recorded it and a URL conventional under that registry stays derived (falling back to the default registry, with `upstream_registry_fallback`, when the mirror can't be read; a value holding an unexpanded `${VAR}` is read as unset). Whether a restored pnpm entry gets its `tarball:` back follows `lockfileIncludeTarballUrl` as the pnpm that wrote the lock read it (#902), from the strongest evidence available: (1) the lock's own unpinned registry resolutions (a bare one proves it off; a URL pnpm could have derived proves it on; pnpm 11+'s env lockfile document does not count); else (2) the settings file the installed pnpm major reads (`node_modules/.modules.yaml` `packageManager`, else package.json `packageManager`; a pre-9 lock or shrinkwrap means pnpm <= 8): `.npmrc` `lockfile-include-tarball-url` on pnpm <= 9, pnpm-workspace.yaml `lockfileIncludeTarballUrl` on pnpm >= 11 (also assumed for a lock carrying an env lockfile document), the workspace file then `.npmrc` on pnpm 10; else (3) pnpm 10's reading. A Rush lock (`common/config/rush/pnpm-lock.yaml` or a subspace lock, with `rush.json` at the Rush root) takes its pnpm major from rush.json `pnpmVersion` instead of tier 2's install record and package.json pin. A 9.0 lock may come from pnpm 9, 10 or 11+, so when tier 3's reading differs from pnpm 9's (`.npmrc` only) or pnpm >= 11's (pnpm-workspace.yaml only) — e.g. `.npmrc` on with the workspace file silent, or the workspace file setting it with `.npmrc` silent or disagreeing — the restore follows pnpm 10 but warns `upstream_pnpm_tarball_setting_guessed` (once per lock, naming the entries); a URL pnpm records anyway (not derivable from the registry) never warns. A `bun.lock` 4-tuple's registry slot is rebuilt the way Bun writes it (#992): `""` for a package from registry.npmjs.org, otherwise the full tarball URL — Bun 1.1.39–1.3.6 read `""` as npmjs whatever the project configures. The registry is the one Bun resolves the package against: a scope's `.npmrc` `@scope:registry` or `bunfig.toml` `[install.scopes]` entry, else `BUN_CONFIG_REGISTRY` / `NPM_CONFIG_REGISTRY`, then the `.npmrc` `registry` or `bunfig.toml` `[install] registry`. Bun reads these from the files beside the lock and from the user's own (#1276): `$XDG_CONFIG_HOME/.npmrc` when it exists, else `~/.npmrc` (never `NPM_CONFIG_USERCONFIG`), and the global bunfig `$XDG_CONFIG_HOME/.bunfig.toml` when `XDG_CONFIG_HOME` is set, else `~/.bunfig.toml`. A key set beside the lock wins over the user's own file of the same kind. Between the two kinds, Bun ≤ 1.3 takes any `.npmrc` over any bunfig and Bun ≥ 1.4 any bunfig over any `.npmrc`. A lockfileVersion-2 `bun.lock` is Bun ≥ 1.4's (Bun 1.3 ignores it); when the kinds disagree on a package of a lockfileVersion-0/1 `bun.lock` (which Bun 1.4 keeps as is) or a `bun.lockb`, the restore refuses that pin rather than guess. Only the conventional token variables (`NPM_TOKEN`, `NODE_AUTH_TOKEN`, `BUN_AUTH_TOKEN`) expand in the project's files; the user's own files expand any variable, as Bun does. Its version document's `dist.tarball` fills the slot, and when it can't be read (`upstream_registry_fallback`) the default registry's conventional URL is re-based on it. The `bun.lockb` takeover restore records the same URL. Side settings: a project `.npmrc` that is exactly `allow-remote=all\n` is deleted once no root npm lock entry is hosted, otherwise a remaining top-level `allow-remote=all` warns `npm_allow_remote_left`; a `pnpm-workspace.yaml` that is exactly the scaffold hosted mode creates is deleted once `pnpm-lock.yaml` is no longer hosted, otherwise a remaining `trustLockfile: true` warns `pnpm_trust_lockfile_left`. **`bun.lockb` (binary)**: `rollback` and `remove` refuse it (the checkout remedy). The hosted → vendored takeover and the eject DO restore it, since the vendor ledger then records the rebuilt record as its pre-vendor original: the native codec turns each hosted remote-tarball record back into Bun's npm registry record for `name@version` (the registry's `dist.tarball` + `dist.integrity`, the package metadata hash re-derived, the hosted URL string dropped from the string pool). The hosted rewrite keeps the registry record's inactive bytes (padding, semver) in the tarball record, so a lock it wrote comes back byte for byte — early writers' uninitialized padding included; a record without them (an older socket-patch or a Bun re-save) is rebuilt the way Bun writes one, and refused for a prerelease/build version. A lock the hosted rewrite had to normalize is marked in the root package's resolution value bytes (which no Bun reader reads): a binary format 1 lock it promoted to format 2 is demoted back to its exact format-1 bytes (verified by promoting it again, otherwise refused), and a lock whose workspace dependency behaviors it normalized is refused with the `git checkout -- bun.lockb` remedy.
978+
* **npm family** — `package-lock.json` / `npm-shrinkwrap.json`, `yarn.lock` (classic and berry), `pnpm-lock.yaml` / `shrinkwrap.yaml`, `bun.lock`: resolution + integrity (+ shasum where recorded) from the npm registry's version document (`SOCKET_NPM_REGISTRY`); a yarn berry lock whose `.yarnrc.yml` names another `npmRegistryServer` reads that registry's document instead, so a mirror's off-path `dist.tarball` keeps its `::__archiveUrl=` binding (a berry entry the hosted pin keyed by its tarball URL also takes the version document's `bin` back, in place of the served tarball's own spelling the pin wrote, as yarn writes the `npm:` entry, #1131), and a pnpm lock whose sibling settings name a registry reads that registry's document — `.npmrc` `registry` (or, for a scoped name, `@scope:registry`); on pnpm 10 a pnpm-workspace.yaml `registries` map instead when present; on pnpm 11+ (or an unknown major) pnpm-workspace.yaml `registries."@scope"` / `registry` / `registries.default` too, ahead of the matching `.npmrc` key — so a mirror's `tarball:` comes back as pnpm recorded it and a URL conventional under that registry stays derived (falling back to the default registry, with `upstream_registry_fallback`, when the mirror can't be read; a value holding an unexpanded `${VAR}` is read as unset). Whether a restored pnpm entry gets its `tarball:` back follows `lockfileIncludeTarballUrl` as the pnpm that wrote the lock read it (#902), from the strongest evidence available: (1) the lock's own unpinned registry resolutions (a bare one proves it off; a URL pnpm could have derived proves it on; pnpm 11+'s env lockfile document does not count); else (2) the settings file the installed pnpm major reads (`node_modules/.modules.yaml` `packageManager`, else package.json `packageManager`; a pre-9 lock or shrinkwrap means pnpm <= 8): `.npmrc` `lockfile-include-tarball-url` on pnpm <= 9, pnpm-workspace.yaml `lockfileIncludeTarballUrl` on pnpm >= 11 (also assumed for a lock carrying an env lockfile document), the workspace file then `.npmrc` on pnpm 10; else (3) pnpm 10's reading. A Rush lock (`common/config/rush/pnpm-lock.yaml` or a subspace lock, with `rush.json` at the Rush root) takes its pnpm major from rush.json `pnpmVersion` instead of tier 2's install record and package.json pin. A 9.0 lock may come from pnpm 9, 10 or 11+, so when tier 3's reading differs from pnpm 9's (`.npmrc` only) or pnpm >= 11's (pnpm-workspace.yaml only) — e.g. `.npmrc` on with the workspace file silent, or the workspace file setting it with `.npmrc` silent or disagreeing — the restore follows pnpm 10 but warns `upstream_pnpm_tarball_setting_guessed` (once per lock, naming the entries); a URL pnpm records anyway (not derivable from the registry) never warns. A `bun.lock` 4-tuple's registry slot is rebuilt the way Bun writes it (#992): `""` for a package from registry.npmjs.org, otherwise the full tarball URL — Bun 1.1.39–1.3.6 read `""` as npmjs whatever the project configures. The registry is the one Bun resolves the package against: a scope's `.npmrc` `@scope:registry` or `bunfig.toml` `[install.scopes]` entry, else `BUN_CONFIG_REGISTRY` / `NPM_CONFIG_REGISTRY`, then the `.npmrc` `registry` or `bunfig.toml` `[install] registry`. Bun reads these from the files beside the lock and from the user's own (#1276): `$XDG_CONFIG_HOME/.npmrc` when it exists, else `~/.npmrc` (never `NPM_CONFIG_USERCONFIG`), and the global bunfig `$XDG_CONFIG_HOME/.bunfig.toml` when `XDG_CONFIG_HOME` is set, else `~/.bunfig.toml`. A key set beside the lock wins over the user's own file of the same kind. Between the two kinds, Bun ≤ 1.3 takes any `.npmrc` over any bunfig and Bun ≥ 1.4 any bunfig over any `.npmrc`. A lockfileVersion-2 `bun.lock` is Bun ≥ 1.4's (Bun 1.3 ignores it); when the kinds disagree on a package of a lockfileVersion-0/1 `bun.lock` (which Bun 1.4 keeps as is) or a `bun.lockb`, the restore refuses that pin rather than guess. Only the conventional token variables (`NPM_TOKEN`, `NODE_AUTH_TOKEN`, `BUN_AUTH_TOKEN`) expand in the project's files; the user's own files expand any variable, as Bun does. Its version document's `dist.tarball` fills the slot, and when it can't be read (`upstream_registry_fallback`) the default registry's conventional URL is re-based on it. The `bun.lockb` takeover restore records the same URL. Side settings: a project `.npmrc` that is exactly `allow-remote=all\n` is deleted once no root npm lock entry is hosted, otherwise a remaining top-level `allow-remote=all` warns `npm_allow_remote_left`; a `pnpm-workspace.yaml` that is exactly the scaffold hosted mode creates is deleted once `pnpm-lock.yaml` is no longer hosted, otherwise a remaining `trustLockfile: true` warns `pnpm_trust_lockfile_left`. **`bun.lockb` (binary)**: `rollback` and `remove` refuse it (the checkout remedy). The hosted → vendored takeover and the eject DO restore it, since the vendor ledger then records the rebuilt record as its pre-vendor original: the native codec turns each hosted remote-tarball record back into Bun's npm registry record for `name@version` (the registry's `dist.tarball` + `dist.integrity`, the package metadata hash re-derived, the hosted URL string dropped from the string pool). The hosted rewrite keeps the registry record's inactive bytes (padding, semver) in the tarball record, so a lock it wrote comes back byte for byte — early writers' uninitialized padding included; a record without them (an older socket-patch or a Bun re-save) is rebuilt the way Bun writes one, and refused for a prerelease/build version. A lock the hosted rewrite had to normalize is marked in the root package's resolution value bytes (which no Bun reader reads): a binary format 1 lock it promoted to format 2 is demoted back to its exact format-1 bytes (verified by promoting it again, otherwise refused), and a lock whose workspace dependency behaviors it normalized is refused with the `git checkout -- bun.lockb` remedy.
979979
* **vlt** — `vlt-lock.json`: slot [2] from the registry's `dist.integrity`, slot [3] per the lock's own convention (see the vlt hosted-mode contract); every hosted instance of the pin together.
980980
* **cargo** — `Cargo.lock` back on crates.io (source + the sparse index's checksum, `SOCKET_CRATES_INDEX`). When the lock already holds a crates.io block for the same `name@version` (a later dependent locked its own copy, #679), the Socket block merges into it instead: it is dropped (with a v1 lock's `[metadata]` line), and dependents' references take the spelling cargo writes, so no duplicate block is left that cargo cannot parse (#863), and no index lookup is needed; every `Cargo.toml` declaration loses its `registry = "socket-patch-<uuid>"` pin (the shorthand the rewriter produced collapses back); every `[registries.socket-patch-<uuid>]` block no manifest or lock still references leaves the project cargo config — including a superseded patch generation's block an earlier re-pin left behind (#864). A declaration it cannot unpin refuses.
981981
* **golang** — the hosted `replace` and the socket module's go.sum lines go; the upstream module's two go.sum lines come back, hashed from the module proxy (`SOCKET_GOPROXY`, else `GOPROXY` / `GONOPROXY` / `GOPRIVATE` as go reads them) and cross-checked against the checksum database (`SOCKET_GOSUMDB_URL`, else `sum.golang.org` unless `GOSUMDB=off` / `GONOSUMDB` / `GOPRIVATE` say go would not ask it). A `replace` the user had before the hosted run is not recorded anywhere, so the restore lands on the plain upstream module.

‎crates/socket-patch-cli/tests/in_process_redirect.rs‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6194,3 +6194,105 @@ async fn hosted_json_reports_an_unpinned_row_for_a_granted_patch_nothing_pins()
61946194
assert_eq!(rows[0]["action"], "unpinned", "{env:#}");
61956195
assert_eq!(rows[0]["errorCode"], "redirect_unconfirmed", "{env:#}");
61966196
}
6197+
6198+
// ── #1131: the restored `npm:` entry takes the registry's `bin:` back ───────
6199+
6200+
/// #1131: a hosted berry pin writes the served tarball's own `bin:`
6201+
/// (`./cli.js`, #718), but yarn writes the version document's (`cli.js`,
6202+
/// as the registry normalized it on publish) for the `npm:` entry. The
6203+
/// restore must put the registry spelling back, or the lock is not
6204+
/// byte-exact and hardened `yarn install --immutable` fails YN0028.
6205+
#[tokio::test]
6206+
#[serial]
6207+
async fn yarn_berry_rollback_restores_the_registry_bin_spelling() {
6208+
let server = MockServer::start().await;
6209+
let tarball = upstream_tarball();
6210+
let integrity = vlt_hosted_common::sha512_sri(&tarball);
6211+
let checksum =
6212+
socket_patch_core::vendor::test_support::service_fixture::berry_checksum(&tarball, NAME)
6213+
.unwrap();
6214+
Mock::given(method("GET"))
6215+
.and(path(format!("/npm-registry/{NAME}/{VERSION}")))
6216+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
6217+
"name": NAME,
6218+
"version": VERSION,
6219+
"bin": { NAME: "cli.js", "other-cli": "bin/other.js" },
6220+
"dist": {
6221+
"tarball": format!("{}/npm-registry/{NAME}/-/{NAME}-{VERSION}.tgz", server.uri()),
6222+
"integrity": integrity,
6223+
"shasum": "0".repeat(40),
6224+
}
6225+
})))
6226+
.mount(&server)
6227+
.await;
6228+
Mock::given(method("GET"))
6229+
.and(path(format!("/upstream/npm/{UUID}.json")))
6230+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
6231+
"name": NAME, "version": VERSION, "integrity": integrity, "yarnBerry10c0": checksum
6232+
})))
6233+
.mount(&server)
6234+
.await;
6235+
let hosted_url = HOSTED_URL.replace("http://patch.test", &server.uri());
6236+
6237+
let pristine_pkg = format!(
6238+
"{{\n \"name\": \"consumer\",\n \"version\": \"0.0.0\",\n \"dependencies\": {{\n \
6239+
\"{NAME}\": \"^{VERSION}\"\n }}\n}}\n"
6240+
);
6241+
let pinned_pkg = format!(
6242+
"{{\n \"name\": \"consumer\",\n \"version\": \"0.0.0\",\n \"dependencies\": {{\n \
6243+
\"{NAME}\": \"^{VERSION}\"\n }},\n \"resolutions\": {{\n \
6244+
\"{NAME}@npm:^{VERSION}\": \"{hosted_url}\"\n }}\n}}\n"
6245+
);
6246+
let lock = |key: &str, resolution: &str, bin: &str, checksum: &str| {
6247+
format!(
6248+
"# This file is generated by running \"yarn install\" inside your project.\n\
6249+
# Manual changes might be lost - proceed with caution!\n\n\
6250+
__metadata:\n version: 8\n cacheKey: 10c0\n\n\
6251+
\"consumer@workspace:.\":\n version: 0.0.0-use.local\n \
6252+
resolution: \"consumer@workspace:.\"\n dependencies:\n \
6253+
{NAME}: \"npm:^{VERSION}\"\n languageName: unknown\n linkType: soft\n\n\
6254+
\"{key}\":\n version: {VERSION}\n resolution: \"{resolution}\"\n bin:\n \
6255+
{NAME}: {bin}\n other-cli: {bin_other}\n checksum: {checksum}\n \
6256+
languageName: node\n linkType: hard\n",
6257+
bin_other = if bin.starts_with("./") {
6258+
"./bin/other.js"
6259+
} else {
6260+
"bin/other.js"
6261+
},
6262+
)
6263+
};
6264+
let pristine_lock = lock(
6265+
&format!("{NAME}@npm:^{VERSION}"),
6266+
&format!("{NAME}@npm:{VERSION}"),
6267+
"cli.js",
6268+
&checksum,
6269+
);
6270+
let pinned_lock = lock(
6271+
&format!("{NAME}@{hosted_url}"),
6272+
&format!("{NAME}@{hosted_url}"),
6273+
"./cli.js",
6274+
BERRY_CHECKSUM,
6275+
);
6276+
6277+
let tmp = tempfile::tempdir().unwrap();
6278+
std::fs::write(tmp.path().join("package.json"), &pinned_pkg).unwrap();
6279+
std::fs::write(tmp.path().join("yarn.lock"), &pinned_lock).unwrap();
6280+
std::fs::write(tmp.path().join(".yarnrc.yml"), "nodeLinker: node-modules\n").unwrap();
6281+
6282+
let (code, env) = rollback_json_with_origin(tmp.path(), &server, &server.uri());
6283+
assert_eq!(code, Some(0), "rollback: {env:#}");
6284+
assert_eq!(
6285+
env["hosted"]["reverted"],
6286+
serde_json::json!([PURL]),
6287+
"{env:#}"
6288+
);
6289+
assert_eq!(
6290+
std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(),
6291+
pristine_lock,
6292+
"the restored entry carries the registry's bin spelling"
6293+
);
6294+
assert_eq!(
6295+
std::fs::read_to_string(tmp.path().join("package.json")).unwrap(),
6296+
pristine_pkg
6297+
);
6298+
}

0 commit comments

Comments
 (0)