From fc0fa86e95b36b79d041d8313befa1b90df307e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 18:22:53 +0000 Subject: [PATCH 1/6] Start fix for #1492 Assisted-by: Claude Code:claude-opus-5-5 From f0fe2764265d665b852ef5780a109377903c5ed8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 18:56:57 +0000 Subject: [PATCH 2/6] Read Bun's config-version tag from bun.lockb Bun 1.3+ writes a cNfGvRsN extension into bun.lockb, the binary twin of the text lock's configVersion. Expose it from the codec and add regression tests for #1492: a tagged lone bun.lockb root must honour "!" workspace patterns, so an excluded member's own lock is the one patched. WIP: lock_dialect does not read the tag yet. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/hosted/governing_root.rs | 44 +++++++++++++++++++ .../socket-patch-core/src/vendor/bun_lockb.rs | 24 ++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 60a5f835b..6de58256c 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1474,7 +1474,7 @@ command's integration tests check its output. | `vendor_would_revert_redirect` / `vendor_takeover_reverted_redirect` | `skipped` (advisory event) | vendor / scan / get `--mode vendored` over a hosted pin (every ecosystem, v5.0): dry run — the upstream restore was resolved (registry lookups included) and would succeed (for bun, only after the Bun vendored preflight accepted the lock; a refused lock is previewed as the wet run's `failed ` instead) / wet run — the pin's lock entries were restored to their upstream registry entry before vendoring (mode takeover; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`), so `vendor --revert` later returns to upstream. Fires on the run that takes over, not on re-runs, and not for a purl whose takeover was rolled back because the backend refused it (see "Takeover reconciliation"). | | `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the upstream restore of a hosted pin was refused (`--offline`, a registry that does not answer, a lock shape the restore refuses — for `bun.lockb`, a record the codec cannot rebuild) — detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` (for `bun.lock` / `bun.lockb` the detail also adds `, then run \`bun install --force\` (a plain \`bun install\` keeps the patched copy)`); nothing vendored for the purl, hosted wiring left in place, exit 1 `partialFailure`. | | `patch_fetch_failed` (eject) | `failed` | vendor eject (v5.0): a hosted pin's patch record could not be fetched from `…/patches/view/`; the whole eject is refused (`eject_refused`), nothing touched, exit 1. | -| `redirect_pnpm_lockfile_elsewhere` / `redirect_workspace_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `error.code` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere; npm / yarn / Bun, `redirect_workspace_lockfile_elsewhere`: no npm-family lock here, and the nearest ancestor `package.json` whose `workspaces` (array, or the object form's `packages`) matches the directory holds `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `bun.lock` or `bun.lockb`; a matching root with none of them that is itself listed by an outer root's `workspaces` hands the check to that root; vlt, same code: the nearest ancestor `vlt.json` whose `workspaces` (a string, an array, or an object of groups) matches the directory holds `vlt-lock.json`, or, as vlt falls back to it when `vlt.json` has no `workspaces` field, the `package.json` `workspaces` root above holds `vlt-lock.json`, and the nearer of a `vlt.json` and a `package.json` root is named; `workspaces` patterns use the glob grammar the package managers share: `*`, `?`, `**`, brace sets and sequences (`{a,b}`, `{1..3}`) and character classes (`[a-c]`, `[!a]`), read the way the root's lock's package manager reads them (#1097): extglobs (`@(a|b)`, `?(…)`, `*(…)`, `+(…)`, `!(…)`) match for npm and yarn but not Bun or vlt; a `!` pattern excludes what earlier patterns matched for npm, Bun 1.3+ (a `bun.lock` with a top-level `configVersion`) and vlt, excludes whatever the order for yarn berry (a `yarn.lock` with `__metadata`, where a leading `!(` is an extglob), and is ignored by yarn classic and older Bun (a `bun.lock` without `configVersion`, or `bun.lockb` alone); only the root's locks whose package manager lists the directory govern it (and are named), and a lockless root lists it when any of them would; when a pnpm workspace also governs the directory, the nearer root is named and a tie goes to `redirect_pnpm_lockfile_elsewhere`; uv (pypi, #1138), `redirect_workspace_lockfile_elsewhere`: the directory holds a `pyproject.toml`, and the nearest ancestor `pyproject.toml` declaring `[tool.uv.workspace]` lists it in `members` (and not in `exclude`), with no standalone project (`[project]`, no workspace) in between, so uv installs it from that root's `uv.lock` (a `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock` or pylock left in the member is never read and does not exempt it); the message says uv workspaces are not patched from their root yet either, and vendored refuses the same layout with `pypi_uv_workspace_unsupported` instead of rewriting a member with Hatch configuration as a lockless Hatch project; a directory whose own locks are all ones its manager never reads inside a workspace member is refused the same way, naming the ignored locks: `package-lock.json` / `npm-shrinkwrap.json` when its `package.json` `workspaces` root holds `package-lock.json` or `npm-shrinkwrap.json` (npm, #1094), `bun.lock` / `bun.lockb` when that root holds `bun.lock` or `bun.lockb` (Bun, #1101), and `vlt-lock.json` in a directory with no `vlt.json` of its own when its vlt workspace root (as above) holds `vlt-lock.json` (vlt, #1134); vendored refuses it with `vendor_lockfile_missing`, and `vex` reads the ignored lock as absent, with one `patched_ref_unattributable` warning naming it when it holds Socket references) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). | +| `redirect_pnpm_lockfile_elsewhere` / `redirect_workspace_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `error.code` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere; npm / yarn / Bun, `redirect_workspace_lockfile_elsewhere`: no npm-family lock here, and the nearest ancestor `package.json` whose `workspaces` (array, or the object form's `packages`) matches the directory holds `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `bun.lock` or `bun.lockb`; a matching root with none of them that is itself listed by an outer root's `workspaces` hands the check to that root; vlt, same code: the nearest ancestor `vlt.json` whose `workspaces` (a string, an array, or an object of groups) matches the directory holds `vlt-lock.json`, or, as vlt falls back to it when `vlt.json` has no `workspaces` field, the `package.json` `workspaces` root above holds `vlt-lock.json`, and the nearer of a `vlt.json` and a `package.json` root is named; `workspaces` patterns use the glob grammar the package managers share: `*`, `?`, `**`, brace sets and sequences (`{a,b}`, `{1..3}`) and character classes (`[a-c]`, `[!a]`), read the way the root's lock's package manager reads them (#1097): extglobs (`@(a|b)`, `?(…)`, `*(…)`, `+(…)`, `!(…)`) match for npm and yarn but not Bun or vlt; a `!` pattern excludes what earlier patterns matched for npm, Bun 1.3+ (a `bun.lock` with a top-level `configVersion`) and vlt, excludes whatever the order for yarn berry (a `yarn.lock` with `__metadata`, where a leading `!(` is an extglob), and is ignored by yarn classic and older Bun (a `bun.lock` without `configVersion`, or a `bun.lockb` alone that carries no `cNfGvRsN` config-version tag, which Bun 1.3+ writes, #1492); only the root's locks whose package manager lists the directory govern it (and are named), and a lockless root lists it when any of them would; when a pnpm workspace also governs the directory, the nearer root is named and a tie goes to `redirect_pnpm_lockfile_elsewhere`; uv (pypi, #1138), `redirect_workspace_lockfile_elsewhere`: the directory holds a `pyproject.toml`, and the nearest ancestor `pyproject.toml` declaring `[tool.uv.workspace]` lists it in `members` (and not in `exclude`), with no standalone project (`[project]`, no workspace) in between, so uv installs it from that root's `uv.lock` (a `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock` or pylock left in the member is never read and does not exempt it); the message says uv workspaces are not patched from their root yet either, and vendored refuses the same layout with `pypi_uv_workspace_unsupported` instead of rewriting a member with Hatch configuration as a lockless Hatch project; a directory whose own locks are all ones its manager never reads inside a workspace member is refused the same way, naming the ignored locks: `package-lock.json` / `npm-shrinkwrap.json` when its `package.json` `workspaces` root holds `package-lock.json` or `npm-shrinkwrap.json` (npm, #1094), `bun.lock` / `bun.lockb` when that root holds `bun.lock` or `bun.lockb` (Bun, #1101), and `vlt-lock.json` in a directory with no `vlt.json` of its own when its vlt workspace root (as above) holds `vlt-lock.json` (vlt, #1134); vendored refuses it with `vendor_lockfile_missing`, and `vex` reads the ignored lock as absent, with one `patched_ref_unattributable` warning naming it when it holds Socket references) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). | | `redirect_pnpm_settings_elsewhere` | top-level `error.code` (`status: "error"`) | scan / get `--mode hosted`: the project directory is a pnpm workspace member (listed by the `packages:` globs of the nearest ancestor `pnpm-workspace.yaml`) with its own v9 `pnpm-lock.yaml` (`sharedWorkspaceLockfile: false`) and no `pnpm-workspace.yaml` of its own, so its pnpm settings come from that ancestor file, which pnpm reads alone (a member's own file is ignored). A directory those globs do not list (no `packages:`, an empty list, a non-matching or `!`-excluded path) is a standalone project on pnpm 11.28+/12 that reads only its own file: it is pinned and gets its own `pnpm-workspace.yaml` like any single project. A root file that does not parse, or whose patterns use braces, classes or extglobs, counts as listing the project. When that file neither carries `trustLockfile: true` nor explicitly sets another value, the trust auto-config has nowhere to go: refused before any takeover or write, `--dry-run` included; the message names the root file to add `trustLockfile: true` to (or `--no-trust-lockfile-config` pins without it); exit 1. Once the root file trusts the lock (or opts out), the member is pinned and no nested `pnpm-workspace.yaml` is created; the `redirect_pnpm_trust_lockfile` warning names the root file. In memory, a member whose lock is demoted into its workspace root (#492) is never refused; one whose lock is not (the workspace root's files do not confirm it pins or ignores that lock, or socket.yml leaves the root out) is refused with this code as its project error, nothing written for it, whenever its lock is v9, the trust auto-config is on and that file may list it (listed, unreadable, or not readable as globs), whatever it says about `trustLockfile`. | | `eject_refused` | top-level `error.code` (`status: "error"`) | vendor eject (v5.0): a record fetch failed or a pin's upstream restore was refused while planning; nothing was changed, exit 1. | | `eject_planned` | `applied` (reason) | vendor eject `--dry-run` (v5.0): the pin would be restored upstream and vendored; nothing written. | diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index 8d3ccaf83..7f2c8bf70 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -1347,6 +1347,50 @@ mod tests { ); } + /// #1492: Bun 1.2+ still writes a binary `bun.lockb` under + /// `saveTextLockfile = false`, and Bun 1.3+ marks it with a + /// `cNfGvRsN` config-version tag (the twin of the text lock's + /// `configVersion`). A tagged lone `bun.lockb` honours `!`, so the + /// excluded member's own lock is the one Bun reads; an untagged one + /// (Bun 1.2.23 here) or one that does not parse keeps the wider + /// pre-1.3 reading. + #[tokio::test] + async fn bun_lockb_with_a_config_version_tag_honours_negated_workspaces() { + const BUN_1_3_14: &[u8] = include_bytes!("../../tests/fixtures/bun-lockb/1.3.14/bun.lockb"); + const BUN_1_2_23: &[u8] = include_bytes!("../../tests/fixtures/bun-lockb/1.2.23/bun.lockb"); + let refused = Some(WORKSPACE_LOCKFILE_ELSEWHERE); + for (lock, expected) in [ + (BUN_1_3_14, None), + (BUN_1_2_23, refused), + (&b"\0"[..], refused), + ] { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "package.json", + r#"{"private":true,"workspaces":["packages/*","!packages/b"]}"#, + ); + std::fs::write(tmp.path().join("bun.lockb"), lock).unwrap(); + write(tmp.path(), "packages/b/package.json", "{}"); + let member = tmp.path().join("packages/b"); + assert_eq!( + code(&member, "npm").await.as_deref(), + expected, + "bun.lockb of {} bytes", + lock.len() + ); + // The member's own binary lock is a stray only when the + // root's Bun lists the member. + std::fs::write(member.join("bun.lockb"), lock).unwrap(); + assert_eq!( + member_stray_lock(&member).await.is_some(), + expected.is_some(), + "bun.lockb of {} bytes", + lock.len() + ); + } + } + /// #1097: a lockless intermediate root reads its `workspaces` in the /// widest dialect, so a member it lists only through an extglob, or /// excludes only with `!`, still reaches the outer root's lock. diff --git a/crates/socket-patch-core/src/vendor/bun_lockb.rs b/crates/socket-patch-core/src/vendor/bun_lockb.rs index 7cb80debf..586653906 100644 --- a/crates/socket-patch-core/src/vendor/bun_lockb.rs +++ b/crates/socket-patch-core/src/vendor/bun_lockb.rs @@ -88,6 +88,9 @@ pub(crate) struct BunLockb { extensions: Vec, total: usize, unknown_extension: bool, + /// The `cNfGvRsN` extension's value: Bun's `configVersion`, which + /// Bun 1.3+ writes (#1492). + config_version: Option, } impl BunLockb { @@ -152,6 +155,7 @@ impl BunLockb { } pos += 8; let mut extensions = Vec::new(); + let mut config_version = None; // These are the append-only extension tags understood by Bun 1.x. // Unknown suffixes are retained verbatim instead of being discarded. while pos.checked_add(8).is_some_and(|end| end <= total) { @@ -165,6 +169,7 @@ impl BunLockb { if pos + 16 > total { return Err("bun.lockb: truncated config extension".into()); } + config_version = Some(u64_at(input, pos + 8)?); pos += 16; continue; } @@ -211,6 +216,7 @@ impl BunLockb { extensions, total, unknown_extension: pos < total, + config_version, }; // All strings exposed to inventory/editing must be valid before a // caller can stage any changes to the lockfile. @@ -218,6 +224,12 @@ impl BunLockb { Ok(lock) } + /// Bun's `configVersion` (the binary twin of the text lock's + /// top-level key): set only in locks written by Bun 1.3+. + pub(crate) fn config_version(&self) -> Option { + self.config_version + } + pub(crate) fn bytes(&self) -> Vec { self.data.clone() } @@ -2280,6 +2292,18 @@ mod tests { format!("sha512-{}", STANDARD.encode([42; 64])) } + /// #1492: Bun writes a `cNfGvRsN` config-version extension (the + /// twin of the text lock's `configVersion`) since a 1.3 patch + /// release; 1.3.0 and earlier writers do not. + #[test] + fn config_version_is_read_from_tagged_locks_only() { + for version in VERSIONS.iter().chain(&["prerelease-pair-1.3.14"]) { + let lock = BunLockb::parse(&fixture(version)).unwrap(); + let tagged = version.ends_with("1.3.14") || *version == "1.4.2"; + assert_eq!(lock.config_version().is_some(), tagged, "{version}"); + } + } + #[test] fn every_historical_binary_layout_parses_losslessly() { for version in VERSIONS { From 595226b6f71d6ac6d5bea0f7b5336374fe6edfe4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 19:00:50 +0000 Subject: [PATCH 3/6] Honour "!" workspaces under a Bun 1.3+ bun.lockb A Bun 1.3+ project that keeps a binary bun.lockb (saveTextLockfile = false) and excludes a member with "!packages/b" could no longer patch that member in hosted or vendored mode: socket-patch read the lone bun.lockb as older Bun, which ignores "!", and refused with redirect_workspace_lockfile_elsewhere. Read the cNfGvRsN config-version tag Bun 1.3+ writes into bun.lockb, as configVersion is read from bun.lock. Fixes #1492. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/hosted/governing_root.rs | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index 7f2c8bf70..0b94a504a 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -27,9 +27,12 @@ use crate::formats::text::strip_bom; use crate::formats::yarn::berry_gates::YARN_LOCK; use crate::formats::yarn::is_berry_lock; use crate::patch::redirect::npmrc::npmrc_top_level_value; -use crate::utils::fs::{open_regular_file, read_regular_to_string, read_regular_to_string_sync}; +use crate::utils::fs::{ + open_regular_file, read_regular_to_bytes, read_regular_to_string, read_regular_to_string_sync, +}; use crate::utils::pnpm_workspace::governing_workspace_file; use crate::utils::workspace_globs::{workspaces_include, workspaces_include_as, WorkspacesDialect}; +use crate::vendor::bun_lockb::BunLockb; use crate::vendor::cargo::NOT_WORKSPACE_ROOT; use crate::vendor::cargo_manifest; use crate::vendor::lock_inventory::ProjectView; @@ -547,10 +550,10 @@ async fn package_json_workspace_root(root: &Path) -> Option<(PathBuf, Vec<&'stat /// reads `lock` at `root` (#1097). yarn classic and berry tell apart by /// berry's `__metadata` key ([`is_berry_lock`]; an empty or unreadable /// `yarn.lock` reads as classic, the wider set), and Bun 1.3+ by the -/// top-level `configVersion` it writes into `bun.lock` (a lock without -/// it, or a binary `bun.lockb` with no `bun.lock` beside it, reads as -/// older Bun, the wider set). Only the head of a lock is read: both keys -/// come first. +/// top-level `configVersion` it writes into `bun.lock`, or the +/// `cNfGvRsN` tag that carries it in a binary `bun.lockb` (#1492; a lock +/// without it reads as older Bun, the wider set). Only the head of a text +/// lock is read: both keys come first. async fn lock_dialect(root: &Path, lock: &str) -> WorkspacesDialect { match lock { YARN_LOCK => { @@ -573,7 +576,14 @@ async fn lock_dialect(root: &Path, lock: &str) -> WorkspacesDialect { } // Bun reads the text lock when both exist. BUN_LOCKB if root.join(BUN_LOCK).is_file() => Box::pin(lock_dialect(root, BUN_LOCK)).await, - BUN_LOCKB => WorkspacesDialect::BUN_PRE_1_3, + // Bun 1.3+ marks a binary lock with its config version too + // (#1492); one that does not parse reads as older Bun. + BUN_LOCKB => match read_regular_to_bytes(&root.join(lock)).await { + Ok(bytes) if BunLockb::parse(&bytes).is_ok_and(|l| l.config_version().is_some()) => { + WorkspacesDialect::BUN + } + _ => WorkspacesDialect::BUN_PRE_1_3, + }, VLT_LOCK => WorkspacesDialect::VLT, _ => WorkspacesDialect::NPM, } From 7d05f17594810f8ffb64185ee2269374b4ee1142 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 19:25:20 +0000 Subject: [PATCH 4/6] Read the bun.lockb config tag without a parse The Bun 1.3+ check parsed the whole bun.lockb, so a tagged lock the codec rejects, or one with an extension it does not know before the tag, still read as older Bun and kept refusing an excluded member. Bun writes the cNfGvRsN extension last, right before the lock's total length, so read the header and those 16 bytes only. This also keeps the check cheap on large monorepo locks. Cover a workspace root's lock, whose tag follows its workspace and catalog extensions, and say in the docs that Bun 1.3.0 does not write the tag. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../tests/in_process_redirect_pnpm.rs | 3 +- .../src/hosted/governing_root.rs | 53 +++++++++---- .../socket-patch-core/src/vendor/bun_lockb.rs | 77 ++++++++++++++----- 4 files changed, 98 insertions(+), 37 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 6de58256c..81b4b905f 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1474,7 +1474,7 @@ command's integration tests check its output. | `vendor_would_revert_redirect` / `vendor_takeover_reverted_redirect` | `skipped` (advisory event) | vendor / scan / get `--mode vendored` over a hosted pin (every ecosystem, v5.0): dry run — the upstream restore was resolved (registry lookups included) and would succeed (for bun, only after the Bun vendored preflight accepted the lock; a refused lock is previewed as the wet run's `failed ` instead) / wet run — the pin's lock entries were restored to their upstream registry entry before vendoring (mode takeover; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`), so `vendor --revert` later returns to upstream. Fires on the run that takes over, not on re-runs, and not for a purl whose takeover was rolled back because the backend refused it (see "Takeover reconciliation"). | | `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the upstream restore of a hosted pin was refused (`--offline`, a registry that does not answer, a lock shape the restore refuses — for `bun.lockb`, a record the codec cannot rebuild) — detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` (for `bun.lock` / `bun.lockb` the detail also adds `, then run \`bun install --force\` (a plain \`bun install\` keeps the patched copy)`); nothing vendored for the purl, hosted wiring left in place, exit 1 `partialFailure`. | | `patch_fetch_failed` (eject) | `failed` | vendor eject (v5.0): a hosted pin's patch record could not be fetched from `…/patches/view/`; the whole eject is refused (`eject_refused`), nothing touched, exit 1. | -| `redirect_pnpm_lockfile_elsewhere` / `redirect_workspace_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `error.code` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere; npm / yarn / Bun, `redirect_workspace_lockfile_elsewhere`: no npm-family lock here, and the nearest ancestor `package.json` whose `workspaces` (array, or the object form's `packages`) matches the directory holds `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `bun.lock` or `bun.lockb`; a matching root with none of them that is itself listed by an outer root's `workspaces` hands the check to that root; vlt, same code: the nearest ancestor `vlt.json` whose `workspaces` (a string, an array, or an object of groups) matches the directory holds `vlt-lock.json`, or, as vlt falls back to it when `vlt.json` has no `workspaces` field, the `package.json` `workspaces` root above holds `vlt-lock.json`, and the nearer of a `vlt.json` and a `package.json` root is named; `workspaces` patterns use the glob grammar the package managers share: `*`, `?`, `**`, brace sets and sequences (`{a,b}`, `{1..3}`) and character classes (`[a-c]`, `[!a]`), read the way the root's lock's package manager reads them (#1097): extglobs (`@(a|b)`, `?(…)`, `*(…)`, `+(…)`, `!(…)`) match for npm and yarn but not Bun or vlt; a `!` pattern excludes what earlier patterns matched for npm, Bun 1.3+ (a `bun.lock` with a top-level `configVersion`) and vlt, excludes whatever the order for yarn berry (a `yarn.lock` with `__metadata`, where a leading `!(` is an extglob), and is ignored by yarn classic and older Bun (a `bun.lock` without `configVersion`, or a `bun.lockb` alone that carries no `cNfGvRsN` config-version tag, which Bun 1.3+ writes, #1492); only the root's locks whose package manager lists the directory govern it (and are named), and a lockless root lists it when any of them would; when a pnpm workspace also governs the directory, the nearer root is named and a tie goes to `redirect_pnpm_lockfile_elsewhere`; uv (pypi, #1138), `redirect_workspace_lockfile_elsewhere`: the directory holds a `pyproject.toml`, and the nearest ancestor `pyproject.toml` declaring `[tool.uv.workspace]` lists it in `members` (and not in `exclude`), with no standalone project (`[project]`, no workspace) in between, so uv installs it from that root's `uv.lock` (a `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock` or pylock left in the member is never read and does not exempt it); the message says uv workspaces are not patched from their root yet either, and vendored refuses the same layout with `pypi_uv_workspace_unsupported` instead of rewriting a member with Hatch configuration as a lockless Hatch project; a directory whose own locks are all ones its manager never reads inside a workspace member is refused the same way, naming the ignored locks: `package-lock.json` / `npm-shrinkwrap.json` when its `package.json` `workspaces` root holds `package-lock.json` or `npm-shrinkwrap.json` (npm, #1094), `bun.lock` / `bun.lockb` when that root holds `bun.lock` or `bun.lockb` (Bun, #1101), and `vlt-lock.json` in a directory with no `vlt.json` of its own when its vlt workspace root (as above) holds `vlt-lock.json` (vlt, #1134); vendored refuses it with `vendor_lockfile_missing`, and `vex` reads the ignored lock as absent, with one `patched_ref_unattributable` warning naming it when it holds Socket references) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). | +| `redirect_pnpm_lockfile_elsewhere` / `redirect_workspace_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `error.code` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere; npm / yarn / Bun, `redirect_workspace_lockfile_elsewhere`: no npm-family lock here, and the nearest ancestor `package.json` whose `workspaces` (array, or the object form's `packages`) matches the directory holds `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `bun.lock` or `bun.lockb`; a matching root with none of them that is itself listed by an outer root's `workspaces` hands the check to that root; vlt, same code: the nearest ancestor `vlt.json` whose `workspaces` (a string, an array, or an object of groups) matches the directory holds `vlt-lock.json`, or, as vlt falls back to it when `vlt.json` has no `workspaces` field, the `package.json` `workspaces` root above holds `vlt-lock.json`, and the nearer of a `vlt.json` and a `package.json` root is named; `workspaces` patterns use the glob grammar the package managers share: `*`, `?`, `**`, brace sets and sequences (`{a,b}`, `{1..3}`) and character classes (`[a-c]`, `[!a]`), read the way the root's lock's package manager reads them (#1097): extglobs (`@(a|b)`, `?(…)`, `*(…)`, `+(…)`, `!(…)`) match for npm and yarn but not Bun or vlt; a `!` pattern excludes what earlier patterns matched for npm, Bun 1.3+ (a `bun.lock` with a top-level `configVersion`) and vlt, excludes whatever the order for yarn berry (a `yarn.lock` with `__metadata`, where a leading `!(` is an extglob), and is ignored by yarn classic and older Bun (a `bun.lock` without `configVersion`, or a `bun.lockb` alone without the trailing `cNfGvRsN` config-version extension, its binary twin, #1492); only the root's locks whose package manager lists the directory govern it (and are named), and a lockless root lists it when any of them would; when a pnpm workspace also governs the directory, the nearer root is named and a tie goes to `redirect_pnpm_lockfile_elsewhere`; uv (pypi, #1138), `redirect_workspace_lockfile_elsewhere`: the directory holds a `pyproject.toml`, and the nearest ancestor `pyproject.toml` declaring `[tool.uv.workspace]` lists it in `members` (and not in `exclude`), with no standalone project (`[project]`, no workspace) in between, so uv installs it from that root's `uv.lock` (a `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock` or pylock left in the member is never read and does not exempt it); the message says uv workspaces are not patched from their root yet either, and vendored refuses the same layout with `pypi_uv_workspace_unsupported` instead of rewriting a member with Hatch configuration as a lockless Hatch project; a directory whose own locks are all ones its manager never reads inside a workspace member is refused the same way, naming the ignored locks: `package-lock.json` / `npm-shrinkwrap.json` when its `package.json` `workspaces` root holds `package-lock.json` or `npm-shrinkwrap.json` (npm, #1094), `bun.lock` / `bun.lockb` when that root holds `bun.lock` or `bun.lockb` (Bun, #1101), and `vlt-lock.json` in a directory with no `vlt.json` of its own when its vlt workspace root (as above) holds `vlt-lock.json` (vlt, #1134); vendored refuses it with `vendor_lockfile_missing`, and `vex` reads the ignored lock as absent, with one `patched_ref_unattributable` warning naming it when it holds Socket references) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). | | `redirect_pnpm_settings_elsewhere` | top-level `error.code` (`status: "error"`) | scan / get `--mode hosted`: the project directory is a pnpm workspace member (listed by the `packages:` globs of the nearest ancestor `pnpm-workspace.yaml`) with its own v9 `pnpm-lock.yaml` (`sharedWorkspaceLockfile: false`) and no `pnpm-workspace.yaml` of its own, so its pnpm settings come from that ancestor file, which pnpm reads alone (a member's own file is ignored). A directory those globs do not list (no `packages:`, an empty list, a non-matching or `!`-excluded path) is a standalone project on pnpm 11.28+/12 that reads only its own file: it is pinned and gets its own `pnpm-workspace.yaml` like any single project. A root file that does not parse, or whose patterns use braces, classes or extglobs, counts as listing the project. When that file neither carries `trustLockfile: true` nor explicitly sets another value, the trust auto-config has nowhere to go: refused before any takeover or write, `--dry-run` included; the message names the root file to add `trustLockfile: true` to (or `--no-trust-lockfile-config` pins without it); exit 1. Once the root file trusts the lock (or opts out), the member is pinned and no nested `pnpm-workspace.yaml` is created; the `redirect_pnpm_trust_lockfile` warning names the root file. In memory, a member whose lock is demoted into its workspace root (#492) is never refused; one whose lock is not (the workspace root's files do not confirm it pins or ignores that lock, or socket.yml leaves the root out) is refused with this code as its project error, nothing written for it, whenever its lock is v9, the trust auto-config is on and that file may list it (listed, unreadable, or not readable as globs), whatever it says about `trustLockfile`. | | `eject_refused` | top-level `error.code` (`status: "error"`) | vendor eject (v5.0): a record fetch failed or a pin's upstream restore was refused while planning; nothing was changed, exit 1. | | `eject_planned` | `applied` (reason) | vendor eject `--dry-run` (v5.0): the pin would be restored upstream and vendored; nothing written. | diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs index 2fdf70fd6..631699235 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -1837,7 +1837,8 @@ async fn hosted_scan_from_brace_or_class_glob_workspace_member_refuses() { /// #1097: the member matcher read every root's `workspaces` the npm way. /// yarn classic ignores `!` patterns (`packages/a` stays a member of /// `["packages/*", "!packages/a"]`), as does Bun before 1.3 (a `bun.lock` -/// with no `configVersion`, or a binary `bun.lockb`), and npm and yarn +/// with no `configVersion`, or a binary `bun.lockb` without its +/// `cNfGvRsN` twin, #1492), and npm and yarn /// match extglobs (`packages/@(a|b)`). The matcher dropped those members, /// so a hosted `scan` / `get` from one pinned nothing and exited 0. #[tokio::test] diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index 0b94a504a..df12f0c9e 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -27,12 +27,10 @@ use crate::formats::text::strip_bom; use crate::formats::yarn::berry_gates::YARN_LOCK; use crate::formats::yarn::is_berry_lock; use crate::patch::redirect::npmrc::npmrc_top_level_value; -use crate::utils::fs::{ - open_regular_file, read_regular_to_bytes, read_regular_to_string, read_regular_to_string_sync, -}; +use crate::utils::fs::{open_regular_file, read_regular_to_string, read_regular_to_string_sync}; use crate::utils::pnpm_workspace::governing_workspace_file; use crate::utils::workspace_globs::{workspaces_include, workspaces_include_as, WorkspacesDialect}; -use crate::vendor::bun_lockb::BunLockb; +use crate::vendor::bun_lockb; use crate::vendor::cargo::NOT_WORKSPACE_ROOT; use crate::vendor::cargo_manifest; use crate::vendor::lock_inventory::ProjectView; @@ -550,10 +548,11 @@ async fn package_json_workspace_root(root: &Path) -> Option<(PathBuf, Vec<&'stat /// reads `lock` at `root` (#1097). yarn classic and berry tell apart by /// berry's `__metadata` key ([`is_berry_lock`]; an empty or unreadable /// `yarn.lock` reads as classic, the wider set), and Bun 1.3+ by the -/// top-level `configVersion` it writes into `bun.lock`, or the -/// `cNfGvRsN` tag that carries it in a binary `bun.lockb` (#1492; a lock -/// without it reads as older Bun, the wider set). Only the head of a text -/// lock is read: both keys come first. +/// top-level `configVersion` it writes into `bun.lock`, or the trailing +/// `cNfGvRsN` extension that carries it in a binary `bun.lockb` (#1492; +/// a lock without it, as Bun 1.3.0 and earlier write, reads as older Bun, +/// the wider set). Only the head of a text lock is read: both keys come +/// first. async fn lock_dialect(root: &Path, lock: &str) -> WorkspacesDialect { match lock { YARN_LOCK => { @@ -576,19 +575,36 @@ async fn lock_dialect(root: &Path, lock: &str) -> WorkspacesDialect { } // Bun reads the text lock when both exist. BUN_LOCKB if root.join(BUN_LOCK).is_file() => Box::pin(lock_dialect(root, BUN_LOCK)).await, - // Bun 1.3+ marks a binary lock with its config version too - // (#1492); one that does not parse reads as older Bun. - BUN_LOCKB => match read_regular_to_bytes(&root.join(lock)).await { - Ok(bytes) if BunLockb::parse(&bytes).is_ok_and(|l| l.config_version().is_some()) => { + // Bun marks a binary lock with its config version too (#1492). + BUN_LOCKB => { + if bun_lockb_config_version(&root.join(lock)).await.is_some() { WorkspacesDialect::BUN + } else { + WorkspacesDialect::BUN_PRE_1_3 } - _ => WorkspacesDialect::BUN_PRE_1_3, - }, + } VLT_LOCK => WorkspacesDialect::VLT, _ => WorkspacesDialect::NPM, } } +/// The `configVersion` a binary `bun.lockb` carries in its trailing +/// `cNfGvRsN` extension (#1492): reads the header and those 16 bytes +/// only, through the FIFO-safe opener. `None` when the lock is not +/// readable, not a binary lock, or untagged (written before Bun wrote +/// `configVersion`). +async fn bun_lockb_config_version(path: &Path) -> Option { + use tokio::io::{AsyncReadExt, AsyncSeekExt}; + let (mut file, _) = open_regular_file(path).await.ok()?; + let mut head = vec![0; bun_lockb::CONFIG_HEAD_LEN]; + file.read_exact(&mut head).await.ok()?; + let at = bun_lockb::config_version_offset(&head)?; + file.seek(std::io::SeekFrom::Start(at as u64)).await.ok()?; + let mut extension = [0; 16]; + file.read_exact(&mut extension).await.ok()?; + bun_lockb::config_version(&extension) +} + /// How much of a lock [`lock_dialect`] reads: the yarn berry and Bun keys /// it looks for sit in the first lines. const LOCK_HEAD_BYTES: u64 = 4096; @@ -1358,19 +1374,24 @@ mod tests { } /// #1492: Bun 1.2+ still writes a binary `bun.lockb` under - /// `saveTextLockfile = false`, and Bun 1.3+ marks it with a + /// `saveTextLockfile = false`, and Bun 1.3.x+ marks it with a /// `cNfGvRsN` config-version tag (the twin of the text lock's /// `configVersion`). A tagged lone `bun.lockb` honours `!`, so the /// excluded member's own lock is the one Bun reads; an untagged one - /// (Bun 1.2.23 here) or one that does not parse keeps the wider + /// (Bun 1.2.23 here) or one that is not a binary lock keeps the wider /// pre-1.3 reading. #[tokio::test] async fn bun_lockb_with_a_config_version_tag_honours_negated_workspaces() { const BUN_1_3_14: &[u8] = include_bytes!("../../tests/fixtures/bun-lockb/1.3.14/bun.lockb"); const BUN_1_2_23: &[u8] = include_bytes!("../../tests/fixtures/bun-lockb/1.2.23/bun.lockb"); + // A workspace root's lock: the tag follows its workspace and + // catalog extensions. + const BUN_1_4_2_WORKSPACE: &[u8] = + include_bytes!("../../tests/fixtures/bun-lockb/1.4.2-extensions/bun.lockb"); let refused = Some(WORKSPACE_LOCKFILE_ELSEWHERE); for (lock, expected) in [ (BUN_1_3_14, None), + (BUN_1_4_2_WORKSPACE, None), (BUN_1_2_23, refused), (&b"\0"[..], refused), ] { diff --git a/crates/socket-patch-core/src/vendor/bun_lockb.rs b/crates/socket-patch-core/src/vendor/bun_lockb.rs index 586653906..c0d91f925 100644 --- a/crates/socket-patch-core/src/vendor/bun_lockb.rs +++ b/crates/socket-patch-core/src/vendor/bun_lockb.rs @@ -49,6 +49,34 @@ pub(crate) const NORMALIZED_FORMAT_1: u8 = 1; /// Workspace dependency behaviors were normalized (not invertible). pub(crate) const NORMALIZED_WORKSPACE: u8 = 2; +/// How many leading bytes of a `bun.lockb` [`config_version_offset`] reads. +pub(crate) const CONFIG_HEAD_LEN: usize = PACKAGES_AT; + +/// Where the 16-byte `cNfGvRsN` extension (the tag, then Bun's +/// `configVersion` as a u64) would start: Bun writes it last, right +/// before the lock's `total` byte length (every Bun 1.3.9–1.4.2 fixture, +/// workspace and catalog locks included). `None` when `head` is not a +/// binary lock header. Reading only these 16 bytes keeps the check +/// independent of the package records and of extensions this codec does +/// not know (#1492). +pub(crate) fn config_version_offset(head: &[u8]) -> Option { + if !head.starts_with(HEADER) { + return None; + } + let total = usize_at(head, TOTAL_AT).ok()?; + total.checked_sub(16).filter(|at| *at >= PACKAGES_AT) +} + +/// Bun's `configVersion` from the 16 bytes at [`config_version_offset`]: +/// the binary twin of the text lock's top-level key, written only by the +/// Bun releases that write `configVersion` (1.3.0 does not). +pub(crate) fn config_version(extension: &[u8]) -> Option { + if extension.len() != 16 || &extension[..8] != b"cNfGvRsN" { + return None; + } + u64_at(extension, 8).ok() +} + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct BinaryPackage { pub(crate) id: usize, @@ -88,9 +116,6 @@ pub(crate) struct BunLockb { extensions: Vec, total: usize, unknown_extension: bool, - /// The `cNfGvRsN` extension's value: Bun's `configVersion`, which - /// Bun 1.3+ writes (#1492). - config_version: Option, } impl BunLockb { @@ -155,7 +180,6 @@ impl BunLockb { } pos += 8; let mut extensions = Vec::new(); - let mut config_version = None; // These are the append-only extension tags understood by Bun 1.x. // Unknown suffixes are retained verbatim instead of being discarded. while pos.checked_add(8).is_some_and(|end| end <= total) { @@ -169,7 +193,6 @@ impl BunLockb { if pos + 16 > total { return Err("bun.lockb: truncated config extension".into()); } - config_version = Some(u64_at(input, pos + 8)?); pos += 16; continue; } @@ -216,7 +239,6 @@ impl BunLockb { extensions, total, unknown_extension: pos < total, - config_version, }; // All strings exposed to inventory/editing must be valid before a // caller can stage any changes to the lockfile. @@ -224,12 +246,6 @@ impl BunLockb { Ok(lock) } - /// Bun's `configVersion` (the binary twin of the text lock's - /// top-level key): set only in locks written by Bun 1.3+. - pub(crate) fn config_version(&self) -> Option { - self.config_version - } - pub(crate) fn bytes(&self) -> Vec { self.data.clone() } @@ -2292,16 +2308,39 @@ mod tests { format!("sha512-{}", STANDARD.encode([42; 64])) } - /// #1492: Bun writes a `cNfGvRsN` config-version extension (the - /// twin of the text lock's `configVersion`) since a 1.3 patch - /// release; 1.3.0 and earlier writers do not. + fn read_config_version(lock: &[u8]) -> Option { + let at = config_version_offset(&lock[..CONFIG_HEAD_LEN.min(lock.len())])?; + config_version(lock.get(at..at + 16)?) + } + + /// #1492: Bun writes a `cNfGvRsN` config-version extension (the twin + /// of the text lock's `configVersion`) since a 1.3 patch release, last + /// in the extension list, also after the workspace and catalog + /// extensions of a workspace root; 1.3.0 and earlier writers do not. #[test] fn config_version_is_read_from_tagged_locks_only() { - for version in VERSIONS.iter().chain(&["prerelease-pair-1.3.14"]) { - let lock = BunLockb::parse(&fixture(version)).unwrap(); - let tagged = version.ends_with("1.3.14") || *version == "1.4.2"; - assert_eq!(lock.config_version().is_some(), tagged, "{version}"); + let extra = [ + "prerelease-pair-1.3.14", + "1.4.2-extensions", + "1.2.23-extensions", + ]; + for version in VERSIONS.iter().chain(&extra) { + let lock = fixture(version); + let tagged = version.contains("1.3.14") || version.starts_with("1.4.2"); + assert_eq!(read_config_version(&lock), tagged.then_some(1), "{version}"); + } + for name in ["1.3.9-ws-deps-late.lockb", "1.4.2-ws-adder.lockb"] { + let lock = std::fs::read( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/bun-lockb/late-dependent") + .join(name), + ) + .unwrap(); + assert_eq!(read_config_version(&lock), Some(1), "{name}"); } + // Not a binary lock, or too short to hold the extension. + assert_eq!(read_config_version(b"\0"), None); + assert_eq!(read_config_version(HEADER), None); } #[test] From 13f02d68f7ab518429208f66d3664cbdd7837928 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 20:16:05 +0000 Subject: [PATCH 5/6] Walk bun.lockb extensions to find the config tag Bun writes the sCoPdOvR scoped-overrides extension after cNfGvRsN, so a lock with nested overrides has no tag at total - 16 and still read as older Bun, refusing a member excluded with "!". Walk the structure instead: the header, the six buffer descriptors and the extension tags, seeking past every array, so package records are never read and the tag is found wherever Bun puts it. Add a Bun 1.4.2 fixture with scoped overrides and a "!" workspace pattern. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013g2AhipQxH9Q19uPZT6Aph --- .../src/hosted/governing_root.rs | 34 ++--- .../socket-patch-core/src/vendor/bun_lockb.rs | 118 +++++++++++++----- .../1.4.2-scoped-overrides/bun.lockb | Bin 0 -> 2466 bytes .../1.4.2-scoped-overrides/bunfig.toml | 2 + .../1.4.2-scoped-overrides/package.json | 1 + .../packages/a/package.json | 1 + .../packages/b/package.json | 1 + .../1.4.2-scoped-overrides/provenance.json | 7 ++ .../tests/fixtures/bun-lockb/README.md | 3 + 9 files changed, 122 insertions(+), 45 deletions(-) create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/1.4.2-scoped-overrides/bun.lockb create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/1.4.2-scoped-overrides/bunfig.toml create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/1.4.2-scoped-overrides/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/1.4.2-scoped-overrides/packages/a/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/1.4.2-scoped-overrides/packages/b/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/1.4.2-scoped-overrides/provenance.json diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index 533f9f412..360816407 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -27,7 +27,9 @@ use crate::formats::text::strip_bom; use crate::formats::yarn::berry_gates::YARN_LOCK; use crate::formats::yarn::is_berry_lock; use crate::patch::redirect::npmrc::npmrc_top_level_value; -use crate::utils::fs::{open_regular_file, read_regular_to_string, read_regular_to_string_sync}; +use crate::utils::fs::{ + open_regular_file, open_regular_file_sync, read_regular_to_string, read_regular_to_string_sync, +}; use crate::utils::pnpm_workspace::governing_workspace_file; use crate::utils::workspace_globs::{workspaces_include, workspaces_include_as, WorkspacesDialect}; use crate::vendor::bun_lockb; @@ -588,21 +590,19 @@ async fn lock_dialect(root: &Path, lock: &str) -> WorkspacesDialect { } } -/// The `configVersion` a binary `bun.lockb` carries in its trailing -/// `cNfGvRsN` extension (#1492): reads the header and those 16 bytes -/// only, through the FIFO-safe opener. `None` when the lock is not -/// readable, not a binary lock, or untagged (written before Bun wrote -/// `configVersion`). +/// The `configVersion` a binary `bun.lockb` carries in its `cNfGvRsN` +/// extension (#1492), read through the FIFO-safe opener on the blocking +/// pool. `None` when the lock is unreadable, not a binary lock, or +/// untagged (written before Bun wrote `configVersion`). async fn bun_lockb_config_version(path: &Path) -> Option { - use tokio::io::{AsyncReadExt, AsyncSeekExt}; - let (mut file, _) = open_regular_file(path).await.ok()?; - let mut head = vec![0; bun_lockb::CONFIG_HEAD_LEN]; - file.read_exact(&mut head).await.ok()?; - let at = bun_lockb::config_version_offset(&head)?; - file.seek(std::io::SeekFrom::Start(at as u64)).await.ok()?; - let mut extension = [0; 16]; - file.read_exact(&mut extension).await.ok()?; - bun_lockb::config_version(&extension) + let path = path.to_path_buf(); + tokio::task::spawn_blocking(move || { + let (mut file, _) = open_regular_file_sync(&path).ok()?; + bun_lockb::read_config_version(&mut file) + }) + .await + .ok() + .flatten() } /// How much of a lock [`lock_dialect`] reads: the yarn berry and Bun keys @@ -1411,10 +1411,14 @@ mod tests { // catalog extensions. const BUN_1_4_2_WORKSPACE: &[u8] = include_bytes!("../../tests/fixtures/bun-lockb/1.4.2-extensions/bun.lockb"); + // Scoped overrides: Bun writes their extension after the tag. + const BUN_1_4_2_SCOPED: &[u8] = + include_bytes!("../../tests/fixtures/bun-lockb/1.4.2-scoped-overrides/bun.lockb"); let refused = Some(WORKSPACE_LOCKFILE_ELSEWHERE); for (lock, expected) in [ (BUN_1_3_14, None), (BUN_1_4_2_WORKSPACE, None), + (BUN_1_4_2_SCOPED, None), (BUN_1_2_23, refused), (&b"\0"[..], refused), ] { diff --git a/crates/socket-patch-core/src/vendor/bun_lockb.rs b/crates/socket-patch-core/src/vendor/bun_lockb.rs index c0d91f925..6d051150c 100644 --- a/crates/socket-patch-core/src/vendor/bun_lockb.rs +++ b/crates/socket-patch-core/src/vendor/bun_lockb.rs @@ -15,6 +15,7 @@ use serde_json::{json, Value}; use sha2::{Digest, Sha512_256}; use std::cmp::Ordering; use std::collections::VecDeque; +use std::io::{Read, Seek, SeekFrom}; use std::ops::Range; const HEADER: &[u8] = b"#!/usr/bin/env bun\nbun-lockfile-format-v0\n"; @@ -49,32 +50,89 @@ pub(crate) const NORMALIZED_FORMAT_1: u8 = 1; /// Workspace dependency behaviors were normalized (not invertible). pub(crate) const NORMALIZED_WORKSPACE: u8 = 2; -/// How many leading bytes of a `bun.lockb` [`config_version_offset`] reads. -pub(crate) const CONFIG_HEAD_LEN: usize = PACKAGES_AT; - -/// Where the 16-byte `cNfGvRsN` extension (the tag, then Bun's -/// `configVersion` as a u64) would start: Bun writes it last, right -/// before the lock's `total` byte length (every Bun 1.3.9–1.4.2 fixture, -/// workspace and catalog locks included). `None` when `head` is not a -/// binary lock header. Reading only these 16 bytes keeps the check -/// independent of the package records and of extensions this codec does -/// not know (#1492). -pub(crate) fn config_version_offset(head: &[u8]) -> Option { +/// Bun's `configVersion` from a binary lock's `cNfGvRsN` extension (the +/// twin of the text lock's top-level key, #1492), or `None` for a lock +/// that is not binary, is untagged (Bun 1.3.0 and earlier) or does not +/// hold together. Walks only the structure (the header, the six buffer +/// descriptors and the extension tags), seeking past every array, so +/// neither package records nor the order of the extensions matters (Bun +/// writes `sCoPdOvR` after the tag), and a large lock costs a few small +/// reads. Like [`BunLockb::parse`], it stops at an unknown tag. +pub(crate) fn read_config_version(lock: &mut R) -> Option { + let mut head = [0; PACKAGES_AT + 40]; + lock.read_exact(&mut head).ok()?; if !head.starts_with(HEADER) { return None; } - let total = usize_at(head, TOTAL_AT).ok()?; - total.checked_sub(16).filter(|at| *at >= PACKAGES_AT) + let total = usize_at(&head, TOTAL_AT).ok()?; + let mut pos = usize_at(&head, PACKAGES_AT + 32).ok()?; + for _ in 0..6 { + pos = skip_array(lock, pos, total)?.end; + } + if read_u64(lock, pos, total)? != 0 { + return None; + } + pos += 8; + while pos.checked_add(8).is_some_and(|end| end <= total) { + let mut tag = [0; 8]; + read_at(lock, pos, &mut tag, total)?; + let n = match &tag { + b"wOrKsPaC" => 4, + b"tRuStEDd" => 1, + b"eMpTrUsT" => 0, + b"oVeRriDs" | b"pAtChEdD" => 2, + b"sCoPdOvR" => 3, + b"cNfGvRsN" => return read_u64(lock, pos + 8, total), + b"cAtAlOgS" => { + pos += 8; + let mut groups = 0; + for _ in 0..3 { + let array = skip_array(lock, pos, total)?; + groups = array.len() / 8; + pos = array.end; + } + for _ in 0..groups * 2 { + pos = skip_array(lock, pos, total)?.end; + } + continue; + } + _ => return None, + }; + pos += 8; + for _ in 0..n { + pos = skip_array(lock, pos, total)?.end; + } + } + None } -/// Bun's `configVersion` from the 16 bytes at [`config_version_offset`]: -/// the binary twin of the text lock's top-level key, written only by the -/// Bun releases that write `configVersion` (1.3.0 does not). -pub(crate) fn config_version(extension: &[u8]) -> Option { - if extension.len() != 16 || &extension[..8] != b"cNfGvRsN" { +fn read_at(lock: &mut R, at: usize, buf: &mut [u8], total: usize) -> Option<()> { + if at.checked_add(buf.len())? > total { return None; } - u64_at(extension, 8).ok() + lock.seek(SeekFrom::Start(at as u64)).ok()?; + lock.read_exact(buf).ok() +} + +fn read_u64(lock: &mut R, at: usize, total: usize) -> Option { + let mut bytes = [0; 8]; + read_at(lock, at, &mut bytes, total)?; + Some(u64::from_le_bytes(bytes)) +} + +/// The data range of the array whose descriptor is at `descriptor`, with +/// [`read_array`]'s checks; each array starts past its descriptor, so a +/// walk over them always moves forward. +fn skip_array( + lock: &mut R, + descriptor: usize, + total: usize, +) -> Option> { + let mut bytes = [0; 16]; + read_at(lock, descriptor, &mut bytes, total)?; + let start = usize_at(&bytes, 0).ok()?; + let end = usize_at(&bytes, 8).ok()?; + (start >= descriptor + 16 && start <= end && end <= total).then_some(start..end) } #[derive(Clone, Debug, PartialEq, Eq)] @@ -2308,26 +2366,26 @@ mod tests { format!("sha512-{}", STANDARD.encode([42; 64])) } - fn read_config_version(lock: &[u8]) -> Option { - let at = config_version_offset(&lock[..CONFIG_HEAD_LEN.min(lock.len())])?; - config_version(lock.get(at..at + 16)?) + fn config_version_of(lock: &[u8]) -> Option { + read_config_version(&mut std::io::Cursor::new(lock)) } /// #1492: Bun writes a `cNfGvRsN` config-version extension (the twin - /// of the text lock's `configVersion`) since a 1.3 patch release, last - /// in the extension list, also after the workspace and catalog - /// extensions of a workspace root; 1.3.0 and earlier writers do not. + /// of the text lock's `configVersion`) since a 1.3 patch release, after + /// the workspace and catalog extensions of a workspace root and before + /// the scoped-overrides one; 1.3.0 and earlier writers do not. #[test] fn config_version_is_read_from_tagged_locks_only() { let extra = [ "prerelease-pair-1.3.14", "1.4.2-extensions", + "1.4.2-scoped-overrides", "1.2.23-extensions", ]; for version in VERSIONS.iter().chain(&extra) { let lock = fixture(version); let tagged = version.contains("1.3.14") || version.starts_with("1.4.2"); - assert_eq!(read_config_version(&lock), tagged.then_some(1), "{version}"); + assert_eq!(config_version_of(&lock), tagged.then_some(1), "{version}"); } for name in ["1.3.9-ws-deps-late.lockb", "1.4.2-ws-adder.lockb"] { let lock = std::fs::read( @@ -2336,11 +2394,11 @@ mod tests { .join(name), ) .unwrap(); - assert_eq!(read_config_version(&lock), Some(1), "{name}"); + assert_eq!(config_version_of(&lock), Some(1), "{name}"); } - // Not a binary lock, or too short to hold the extension. - assert_eq!(read_config_version(b"\0"), None); - assert_eq!(read_config_version(HEADER), None); + // Not a binary lock, or a header with nothing after it. + assert_eq!(config_version_of(b"\0"), None); + assert_eq!(config_version_of(HEADER), None); } #[test] diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/1.4.2-scoped-overrides/bun.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/1.4.2-scoped-overrides/bun.lockb new file mode 100644 index 0000000000000000000000000000000000000000..ae0d21b3838c60501b35bfb70a644771f3335e88 GIT binary patch literal 2466 zcmY#Z)GsYA(of3F(@)JSQ%EY!;{sycoc!eMw9K4T-L(9o+{6;yG6ODV1_p+&j=9Y; zTv;;&UObs_Ea35k=_e=1oR?^L%v7^SbIJUKe?pugdpl^+FaEVEqiwmEWb{@HYa zdDhhfOXM63cK+LYr{Tw+ja*iTHv6jet7@D(&3C`tfcMGw0~hD&3soBbzp?7#0d*vE zVc`vOE{ocU=abt$+-=HAHoJR5qIbjBcH7VUemb_j?{_+OZe@k`&Nx2xwJ&eIcv@)L zlkI4=;jmNMq5Bgn|7$mKFPmue`74sS$l*qDP#}9A$uN-HVfg_V_Y9QAKa$ZHOdL+Y zP)A1r#v9Pm;SH4LvdPQ?mIygHdazvjW@Z&A&1F++Y-9)2io+04*nr|90IEKQ2z?-XVEQIN)!&2bi#9Tg zEwunT+zf7~A*#a@f%-uJR=z2K3k*;SfTajnNyx-#pl7OQ22=|xGht;T6JwmAo)Jh& z8(Q4JXfB)L)ZDVvB0blNlGLKS#GK%gqRhN>G_IY3A;NtYaQ8((4Z8uQfx!p#iXnEN z6eK2RC#I(s>nDP$mgL+Ny{zJll9GaAD}DW<)O278UsS1=SCE@kte0Put`G5?zAluZ z3-+2`NqQANJw|`1SZYymW`3R?l(tha#TI7Zv;cDpDf9`beHY;N66anHXq77frQtCS ziD_gXqoq7ZIg{*{=3W+5><23sik Date: Tue, 13 Oct 2026 20:27:56 +0000 Subject: [PATCH 6/6] Fit the scoped-overrides bun.lockb fixture to the corpus Every bun-lockb fixture is swept by the VEX discovery tests, which rewire minimist@1.2.2 at the root and compare against the golden corpus. Regenerate the scoped-overrides lock with the corpus's minimist@1.2.2 / is-number@7.0.0 dependencies and an override nothing installs (Bun still writes sCoPdOvR after cNfGvRsN), and add its golden entries. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013g2AhipQxH9Q19uPZT6Aph --- .../1.4.2-scoped-overrides/bun.lockb | Bin 2466 -> 2498 bytes .../1.4.2-scoped-overrides/package.json | 16 ++++++++- .../1.4.2-scoped-overrides/provenance.json | 2 +- .../tests/fixtures/bun-lockb/README.md | 3 +- .../vex-discover-golden/bun-lockb.json | 33 ++++++++++++++++++ 5 files changed, 51 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/1.4.2-scoped-overrides/bun.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/1.4.2-scoped-overrides/bun.lockb index ae0d21b3838c60501b35bfb70a644771f3335e88..fea0d06ee790ed30d1e5a6fe32afb3166a5d0da4 100644 GIT binary patch delta 709 zcmZ1^d`NhLp5mr;9B$558JpSf=Iv3)i|a^wxAISsS3jV75iRm*;*JPO zBcQwmP%R@^B?AK!jGlNe0G4C?NQ)r!3;a%XBaiyOf(et)icEgB^Y!h=Hj~dXNjN+XSXAd>Rej{sRIS*pR&lz)T6+~-!cE?8 zsXpRj_v8Aj^z;L~`+ZFw9Pxf!!ewLW_4VL-vw6L#X(4Q3$6|X5=S@~+&Yvv6lCy~= zgpn2GGsel0te)~3Ko%F!OF#-7PO$J`Vl>w?&@-5PkX4k;P|rxuh+*;_)^Nr@lTFxE zj53RL^Gb7*Qj3x@^Ad|Hb&Hep3sO^b^UG3;iZWADi*?g7D@sa>QVSB3vlG)(i}e#H zFJLofG@3kzP2K{oSzlKl&ejFnrdN_)1#$=ol=~NX7Y8IdTd+g;4wFB!DGNThcA+&* z<6C9}RCLE=P4;}w7f{{@pc;Kn4GxG7gUL79^%*rLb8^UYRzSrjO*Z6ER|d(05(Uh& z5cMGP1XSH4kUA}hcus0siEcq+%Km3d5=FJI149c2BtWSR2#h!<*Kp{FV<==a)H4Q# KAtTEqMg{-^e$)s6 delta 653 zcmX>kyhwP0p5j->+-4cBtQi6?o=i9v@OZ-XlM`glOEf%Ys@bEtWPZXwp^5&67Df5_ zB@952o1KzbQ~;zi^D=WYi%Swgfb_Rcsy^|dc=y?9z^*^}*Pwc)T++M)XsEB|XZaW9)_^!e*#Rpxw< zD>-4|!Ub~o#0xT;SV9