From 42a4b55c7efc49f1f0adb8b66930d53d30474e3d Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 20 Aug 2026 11:09:45 -0400 Subject: [PATCH 1/3] =?UTF-8?q?fix(bun):=20accept=20bun.lock=20lockfileVer?= =?UTF-8?q?sion=202=20=E2=80=94=20bun=201.4=20re-versioned=20an=20unchange?= =?UTF-8?q?d=20grammar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bun 1.4.0 bumped the default bun.lock lockfileVersion to 2 (oven-sh/bun PR #31539). The bump gates stricter PARSE checks — integrity hashes required for off-registry npm tarballs, unsafe git .bun-tag values rejected — behind an UNCHANGED emitted grammar: a 1.3.14 and a 1.4.0 lock of the same fixture are byte-identical except the integer (verified empirically in docker). Our hosted/vendored tuples always carry a sha512, satisfying the new off-registry-integrity rule by construction. CI's hosted-e2e job installs floating bun@1, which now resolves to 1.4.0 — the shared version gate (check_lock_version in bun_lock_text) refused everything but 1, so bun_hosted_install_proof went red on every push to main. Widen the gate to {1, 2}; refuse anything else with the updated fail-closed message. The floating bun@1 is deliberately kept: catching exactly this drift is the hosted suite's job. Covered call sites: redirect rewriter (redirect_bun_lock_unsupported), vendor backend (vendor_lockfile_version_unsupported), lock inventory, plus a v1-only fixture assertion in the vendor bun e2e capstone. New golden fixture npm/bun/lock-v2 (Rust-authored — the depscan TS twin needs the matching acceptance + fixture sync); the existing lock-version-unsupported input re-pinned from 2 to 3 (2 is now supported). No existing expected/ bytes re-blessed. Proofs, all with real bun 1.4.0 on PATH: redirect + vendor e2e capstones green on NATIVE v2 locks (fresh-checkout frozen installs of patched bytes, tamper refusal), and the exact red CI leg bun_hosted_install_proof green against real production. Co-Authored-By: Claude Fable 5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../tests/e2e_redirect_bun_build.rs | 75 +++++++++++++++-- .../tests/e2e_vendor_bun_build.rs | 7 +- .../tests/in_process_redirect.rs | 84 ++++++++++++++++++- .../src/patch/redirect/mod.rs | 30 ++++++- .../socket-patch-core/src/vendor/bun_lock.rs | 26 +++++- .../src/vendor/bun_lock_text.rs | 55 +++++++++--- .../src/vendor/lock_inventory.rs | 44 +++++++--- .../npm/bun/lock-v2/expected-edits.json | 10 +++ .../npm/bun/lock-v2/expected/bun.lock | 14 ++++ .../redirect/npm/bun/lock-v2/input/bun.lock | 14 ++++ .../redirect/npm/bun/lock-v2/overrides.json | 13 +++ .../lock-version-unsupported/input/bun.lock | 2 +- docs/ecosystems.md | 3 +- 14 files changed, 339 insertions(+), 40 deletions(-) create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2/expected/bun.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2/input/bun.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2/overrides.json diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 8e42ada9..e777198b 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -109,7 +109,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments `scan --mode hosted` (== `--redirect`) swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL, or — for golang — the `patch.socket.dev/gopatch/` module path) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither recorded nor attested. Re-runs over already-rewritten output record zero new edits. JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. -The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock`), `requirements.txt` / `uv.lock`, `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` v1 — a binary `bun.lockb` with no text lock is auto-migrated to text via `bun install --save-text-lockfile --frozen-lockfile --lockfile-only` before the read, recorded as a `removed` FileEdit; `redirect_bun_lockb_would_migrate` on `--dry-run`, `redirect_bun_lockb_unsupported` when the migration is unavailable). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). +The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock`), `requirements.txt` / `uv.lock`, `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 1 or 2 — bun 1.3/1.4 share one emitted grammar; a binary `bun.lockb` with no text lock is auto-migrated to text via `bun install --save-text-lockfile --frozen-lockfile --lockfile-only` before the read, recorded as a `removed` FileEdit; `redirect_bun_lockb_would_migrate` on `--dry-run`, `redirect_bun_lockb_unsupported` when the migration is unavailable). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **Gem stale-install guard (additive warning — the canonical narrative; other mentions point here)**: the gem hosted rewrite is pure Gemfile/lock text, so a gem ALREADY materialized under the project's bundle paths keeps its upstream bytes — the next `bundle install` prints `Using ` and never refetches, on **every** bundler major (live-verified 2026-08-19 on 1.17.3 / 2.7.2 / 4.0.18: bundler 4's CHECKSUMS verify at download time only, and nothing is downloaded; `bundle install --force`/`--redownload` re-install from the stale cached `.gem` instead of re-fetching — bundler 1 silently, bundler 4 with an exit-37 checksum refusal that still leaves the upstream bytes installed; the **verified** remedy is removing the installed dir + cache `.gem` + `specifications` entry, then `bundle install`). After the rewrite, a hosted run therefore probes the installed-gem discovery paths (the same ruby-crawler discovery `apply` uses, honoring `--global`/`--global-prefix` like scan's own discovery) for each confirmed gem redirect and judges the materialization against the patch record's `afterHash` file map. Judgment rules: records are found **by uuid** — this run's fetched records first, then the redirect ledger's persisted ones, so a transiently failed `/patches/view` fetch cannot retire the warning (it re-fires on every re-scan until the stale materialization is gone); a materialization with every file at `afterHash` is already patched and never warns (an agent→hosted migration stays quiet by construction), and when several confirmed variant purls resolve to one installed dir, ANY of them judging it patched keeps it quiet; staleness needs **positive evidence** — at least one record file whose bytes were actually read and hash to neither state's expectation — so missing or unreadable files never produce a warning. Warnings emit `redirect_gem_stale_install` (JSON `redirect.warnings[]` + a code-tagged stderr line) in three flavors: a PROJECT-LOCAL dir gets the verified delete-list remedy (installed dir, cache `.gem`, `specifications` entry — plus the project's committed `vendor/cache/.gem` when present and not proven to be the patched artifact, since bundler installs from `vendor/cache` in preference to fetching); a SHARED gem-env home gets a caveat that the home is shared machine-wide and prefers migrating the project to a local bundle path over deleting shared files; and a committed `vendor/cache` archive whose sha256 differs from the patched artifact's warns standalone even with no installed dir at all (a fresh checkout with a committed stale cache re-materializes the upstream bytes forever). A stale-flagged purl is additionally **excluded from the same run's `--vex` `assume_applied` set** — the envelope must never attest a CVE its own warning says is live; the purl falls back to normal installed-tree verification (a patched install still attests, a stale one is omitted). The probe is read-only (nothing is deleted) and skipped on `--dry-run` — deliberately explicit, since nothing was rewritten but the ledger fallback could otherwise judge an already-redirected project. Exit code and `status` are unchanged (warning-only, the hosted-refusal posture); a same-run `--vex` may still fail on "nothing to attest" per the embedded-VEX contract. diff --git a/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs index e4ebda51..30bb0f38 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs @@ -125,13 +125,24 @@ struct BunRedirectFixture { tmp: tempfile::TempDir, proj: PathBuf, patched: Vec, + /// True when the installed bun itself wrote `"lockfileVersion": 2` + /// (bun >= 1.4) — i.e. this toolchain can also READ a v2 lock, so the + /// fresh-checkout install proof is valid on a v2 lock. + native_lock_v2: bool, _server: MockServer, } /// Steps 1–3: real install, patched tarball + API mocks, `scan --mode hosted /// --vex`, and the envelope/lockfile/ledger assertions. `tamper_served_tarball` -/// serves DIFFERENT bytes than the sha512 pinned into the lock. `None` = skip. -async fn bun_hosted_project(tag: &str, tamper_served_tarball: bool) -> Option { +/// serves DIFFERENT bytes than the sha512 pinned into the lock. +/// `force_lock_version` re-pins the fixture lock's `"lockfileVersion"` line +/// before the scan (sound because v1 and v2 share one emitted grammar — same +/// fixture locks are byte-identical except the integer). `None` = skip. +async fn bun_hosted_project( + tag: &str, + tamper_served_tarball: bool, + force_lock_version: Option, +) -> Option { if !has_command("bun") { println!("SKIP e2e_redirect_bun_build ({tag}): `bun` not installed"); return None; @@ -170,6 +181,26 @@ async fn bun_hosted_project(tag: &str, tamper_served_tarball: bool) -> Option Option Option (PathBuf, Output) { #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn bun_redirect_fresh_checkout_installs_patched_bytes() { - let Some(fx) = bun_hosted_project("main", false).await else { + let Some(fx) = bun_hosted_project("main", false, None).await else { return; }; + assert_patched_fresh_install(&fx); +} - let (fresh, ci) = fresh_checkout_bun_install(&fx); +/// Shared fresh-checkout proof: `bun install --frozen-lockfile` against an +/// empty cache must materialize the PATCHED bytes from the hosted tarball. +fn assert_patched_fresh_install(fx: &BunRedirectFixture) { + let (fresh, ci) = fresh_checkout_bun_install(fx); assert!( ci.status.success(), "fresh-checkout `bun install --frozen-lockfile` must succeed from the hosted patch \ @@ -426,12 +469,34 @@ async fn bun_redirect_fresh_checkout_installs_patched_bytes() { ); } +/// The bun 1.4 leg: `"lockfileVersion": 2` shares v1's emitted grammar (the +/// bump gates stricter parse checks — integrity hashes required for +/// off-registry npm tarballs, which our URL 3-tuple always carries), so the +/// redirect must rewrite a v2 lock exactly like a v1 lock. When the installed +/// bun is itself >= 1.4 (it WROTE v2 — older bun cannot read v2 locks), the +/// fresh-checkout frozen install must again produce the patched bytes. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn bun_redirect_lock_v2_fresh_checkout_installs_patched_bytes() { + let Some(fx) = bun_hosted_project("lock-v2", false, Some(2)).await else { + return; + }; + if !fx.native_lock_v2 { + println!( + "PARTIAL e2e_redirect_bun_build (lock-v2): installed bun writes lockfileVersion 1 \ + (< 1.4) and cannot read the forced v2 lock — rewrite proven, install proof skipped" + ); + return; + } + assert_patched_fresh_install(&fx); +} + /// Negative twin: the hosted route serves TAMPERED bytes while the lock pins /// the real sha512 — the fresh frozen install must refuse. #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn bun_redirect_tampered_hosted_tarball_fails_frozen_install() { - let Some(fx) = bun_hosted_project("tampered", true).await else { + let Some(fx) = bun_hosted_project("tampered", true, None).await else { return; }; diff --git a/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs index ebb8e04c..472e4a41 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs @@ -211,9 +211,12 @@ fn bun_vendor_fresh_checkout_frozen_install_and_revert() { let lock_before = std::fs::read(&lock_path).expect("bun.lock after bun install"); let pkg_before = std::fs::read(&pkg_path).expect("package.json"); let lock_before_str = String::from_utf8(lock_before.clone()).unwrap(); + // bun 1.3 writes lockfileVersion 1, bun 1.4 writes 2 — one emitted + // grammar; both are wirable. assert!( - lock_before_str.contains("\"lockfileVersion\": 1"), - "fixture must be a bun text lockfileVersion 1:\n{lock_before_str}" + lock_before_str.contains("\"lockfileVersion\": 1") + || lock_before_str.contains("\"lockfileVersion\": 2"), + "fixture must be a bun text lockfileVersion 1 or 2:\n{lock_before_str}" ); // Pre-vendor: the registry 4-tuple `["left-pad@1.3.0", "", {}, "sha512-…"]`. assert!( diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index a0ba4570..4be63128 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -827,8 +827,10 @@ async fn scan_redirect_rewrites_correct_entry_in_crlf_classic_lock() { ); } -/// Write a project whose only lockfile is a text `bun.lock` (registry 4-tuple). -fn write_bun_project(root: &Path) { +/// Write a project whose only lockfile is a text `bun.lock` (registry +/// 4-tuple) at the given `lockfileVersion` (bun 1.3 emits 1, bun 1.4 emits 2 +/// — same grammar either way). +fn write_bun_project(root: &Path, lock_version: u64) { std::fs::write( root.join("package.json"), format!( @@ -846,7 +848,7 @@ fn write_bun_project(root: &Path) { std::fs::write( root.join("bun.lock"), format!( - "{{\n \"lockfileVersion\": 1,\n \"packages\": {{\n \ + "{{\n \"lockfileVersion\": {lock_version},\n \"packages\": {{\n \ \"{NAME}\": [\"{NAME}@{VERSION}\", \"\", {{}}, \"sha512-UPSTREAMupstream==\"],\n \ }}\n}}\n" ), @@ -864,7 +866,7 @@ async fn scan_redirect_rewrites_bun_lock() { mock_reference(&server).await; let tmp = tempfile::tempdir().unwrap(); - write_bun_project(tmp.path()); + write_bun_project(tmp.path(), 1); let code = run(redirect_args(tmp.path(), server.uri())).await; assert_eq!(code, 0, "scan --redirect (bun) should succeed"); @@ -890,6 +892,80 @@ async fn scan_redirect_rewrites_bun_lock() { ); } +/// The bun 1.4 leg: `"lockfileVersion": 2` is the SAME emitted grammar as 1 +/// (bun 1.4 bumped the integer to gate stricter parse checks — oven-sh/bun +/// PR #31539 — same-fixture locks are byte-identical except the integer), so +/// the rewrite must proceed exactly like v1 and preserve the version line. +#[tokio::test] +#[serial] +async fn scan_redirect_rewrites_bun_lock_v2() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), 2); + + let code = run(redirect_args(tmp.path(), server.uri())).await; + assert_eq!(code, 0, "scan --redirect (bun, lock v2) should succeed"); + + let lock = std::fs::read_to_string(tmp.path().join("bun.lock")).unwrap(); + assert!( + lock.contains("\"lockfileVersion\": 2,"), + "the version line must be preserved verbatim; got:\n{lock}" + ); + assert!( + lock.contains(&format!("\"{NAME}@{HOSTED_URL}\"")), + "the tuple's spec must be name@; got:\n{lock}" + ); + assert!( + lock.contains(PATCHED_SHA512), + "integrity must be the patched sha512" + ); + assert!( + tmp.path() + .join(".socket/vendor/redirect-state.json") + .is_file(), + "a redirect ledger should be written" + ); +} + +/// A future `lockfileVersion` (3) has no byte-exact fixtures: the rewrite +/// must refuse whole (fail closed), leave the lock byte-identical, count +/// nothing redirected, and surface `redirect_bun_lock_unsupported` in the +/// `--json` envelope. Subprocess so `redirected` and `warnings[]` can be +/// read back. +#[tokio::test] +#[serial] +async fn scan_redirect_refuses_bun_lock_v3() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), 3); + let before = std::fs::read_to_string(tmp.path().join("bun.lock")).unwrap(); + + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!( + env["redirect"]["redirected"], 0, + "an unsupported lock version must redirect nothing: {env}" + ); + assert!( + warning_codes(&env).contains(&"redirect_bun_lock_unsupported".to_string()), + "the refusal must reach the envelope: {env}" + ); + let after = std::fs::read_to_string(tmp.path().join("bun.lock")).unwrap(); + assert_eq!( + after, before, + "fail-closed: the lock must be byte-untouched" + ); + assert!( + !after.contains(HOSTED_URL), + "the hosted URL must never appear in a refused lock: {after}" + ); +} + /// The bun.lockb auto-migration leg: a fake `bun` shim prepended to PATH writes /// a canned text bun.lock and deletes bun.lockb, exercising the migration /// branch of `run_redirect` without a real bun. The migration removal is diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 090f2bf8..65d98684 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -2453,7 +2453,7 @@ fn rewrite_bun_lock( if check_lock_version(content).is_err() { result.warnings.push(RewriteWarning { code: "redirect_bun_lock_unsupported".into(), - detail: "bun.lock lockfileVersion is not 1; re-lock with bun >= 1.3".into(), + detail: "bun.lock lockfileVersion is not 1 or 2; re-lock with bun >= 1.3".into(), }); return; } @@ -6248,7 +6248,9 @@ mod tests { .iter() .any(|w| w.code == "redirect_bun_lockb_unsupported")); - // Unsupported lockfileVersion → refusal. + // lockfileVersion 2 (bun >= 1.4): SAME emitted grammar as 1 — the + // bump gates stricter parse checks, not new entry shapes — so the + // rewrite proceeds and the version line survives verbatim. let mut files = BTreeMap::new(); files.insert( "bun.lock".to_string(), @@ -6259,8 +6261,32 @@ mod tests { ); let mut r = RewriteResult::default(); rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + let out = r + .files + .get("bun.lock") + .expect("a lockfileVersion-2 lock must be rewritten like a v1 lock"); + assert!(out.contains("\"lockfileVersion\": 2,"), "{out}"); + assert!(out.contains("http://p.test/lp.tgz"), "{out}"); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + + // Unsupported lockfileVersion (a future 3) → refusal. + let mut files = BTreeMap::new(); + files.insert( + "bun.lock".to_string(), + bun_lock_file( + "\"left-pad\": [\"left-pad@1.3.0\", \"\", {}, \"sha512-OLD==\"]", + 3, + ), + ); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); assert!(r.files.is_empty()); assert_eq!(r.warnings[0].code, "redirect_bun_lock_unsupported"); + assert!( + r.warnings[0].detail.contains("not 1 or 2"), + "the refusal must name the supported versions: {}", + r.warnings[0].detail + ); // Non-single-line packages section → fail-closed refusal. let mut files = BTreeMap::new(); diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 7ea831b5..ad790994 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -1130,13 +1130,35 @@ mod tests { let detail = expect_refused(fx.vendor(false).await, "vendor_lockfile_missing"); assert!(detail.contains("bun install"), "{detail}"); - let lock = BN3_BEFORE_LOCK.replace("\"lockfileVersion\": 1,", "\"lockfileVersion\": 2,"); + let lock = BN3_BEFORE_LOCK.replace("\"lockfileVersion\": 1,", "\"lockfileVersion\": 3,"); let fx = fixture_with(&lock, "node_modules/left-pad").await; let detail = expect_refused( fx.vendor(false).await, "vendor_lockfile_version_unsupported", ); - assert!(detail.contains('2'), "{detail}"); + assert!(detail.contains('3'), "{detail}"); + } + + /// bun >= 1.4 writes `"lockfileVersion": 2` over the SAME emitted grammar + /// (the bump gates stricter parse checks, not new entry shapes), so + /// vendoring must proceed exactly as on a v1 lock, and the version line + /// must survive verbatim. + #[tokio::test] + async fn lock_v2_vendors_like_v1() { + let lock = BN3_BEFORE_LOCK.replace("\"lockfileVersion\": 1,", "\"lockfileVersion\": 2,"); + assert_ne!(lock, BN3_BEFORE_LOCK, "replacement must hit"); + let fx = fixture_with(&lock, "node_modules/left-pad").await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + assert!(entry.is_some(), "the vendor run must record an entry"); + let rewritten = fx.read_lock().await; + assert!( + rewritten.contains("\"lockfileVersion\": 2,"), + "the version line must be preserved verbatim: {rewritten}" + ); + assert!( + rewritten.contains(".socket/vendor/npm/"), + "the packages entry must point at the vendored tarball: {rewritten}" + ); } #[tokio::test] diff --git a/crates/socket-patch-core/src/vendor/bun_lock_text.rs b/crates/socket-patch-core/src/vendor/bun_lock_text.rs index b2534a81..b221600a 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock_text.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock_text.rs @@ -9,9 +9,17 @@ //! backends. The vendor- and redirect-specific classification of a parsed //! entry lives with each backend. -/// The only text-lockfile version the surgery has byte-exact fixtures for -/// (bun 1.3.x; spike pinned 1.3.14). -const SUPPORTED_LOCK_VERSION: u64 = 1; +/// The text-lockfile versions the surgery has byte-exact fixtures for. +/// +/// bun 1.3.x emits 1 (spike pinned 1.3.14). bun 1.4.0 bumped the default to +/// 2 (oven-sh/bun PR #31539): the bump gates stricter PARSE checks — +/// integrity hashes required for off-registry npm tarballs, unsafe git +/// `.bun-tag` values rejected — behind an UNCHANGED emitted grammar (a +/// 1.3.14 and a 1.4.0 lock of the same fixture are byte-identical except +/// this integer; verified empirically). Our URL/local 3-tuples always carry +/// a sha512, so they satisfy the v2 off-registry-integrity rule by +/// construction. +const SUPPORTED_LOCK_VERSIONS: [u64; 2] = [1, 2]; /// One parsed single-line packages entry. pub(crate) struct BunEntry { @@ -40,7 +48,7 @@ pub(crate) fn split_name_spec(s: &str) -> Option<(&str, &str)> { } /// `"lockfileVersion": ` head check — only the fixture-pinned text -/// lockfile version is spliced (fail-closed on anything newer/older). +/// lockfile versions are spliced (fail-closed on anything newer/older). pub(crate) fn check_lock_version(text: &str) -> Result<(), String> { let version = text.lines().take(5).find_map(|line| { line.trim() @@ -48,15 +56,16 @@ pub(crate) fn check_lock_version(text: &str) -> Result<(), String> { .map(|rest| rest.trim().trim_end_matches(',').to_string()) }); match version.as_deref().map(str::parse::) { - Some(Ok(v)) if v == SUPPORTED_LOCK_VERSION => Ok(()), + Some(Ok(v)) if SUPPORTED_LOCK_VERSIONS.contains(&v) => Ok(()), Some(Ok(v)) => Err(format!( - "bun.lock has lockfileVersion {v}; only {SUPPORTED_LOCK_VERSION} is supported — \ + "bun.lock has lockfileVersion {v}; only 1 and 2 are supported — \ re-lock with bun >= 1.3" )), - _ => Err(format!( - "bun.lock has no integer lockfileVersion in its head; only \ - {SUPPORTED_LOCK_VERSION} is supported — re-lock with bun >= 1.3" - )), + _ => Err( + "bun.lock has no integer lockfileVersion in its head; only 1 and 2 \ + are supported — re-lock with bun >= 1.3" + .to_string(), + ), } } @@ -357,4 +366,30 @@ mod tests { let unterminated = "{\n \"lockfileVersion\": 1,\n \"packages\": {\n"; assert!(parse_packages_section(&to_lines(unterminated)).is_err()); } + + /// bun 1.3 emits `"lockfileVersion": 1`; bun 1.4 emits 2 over the SAME + /// grammar (the bump gates stricter parse checks, not new entry shapes — + /// same-fixture locks are byte-identical except the integer). Both must + /// pass; anything else — or a missing/non-integer head — fails closed. + #[test] + fn lock_version_gate_accepts_1_and_2_only() { + for v in [1u64, 2] { + assert!( + check_lock_version(&format!("{{\n \"lockfileVersion\": {v},\n}}\n")).is_ok(), + "lockfileVersion {v} must be accepted" + ); + } + for v in [0u64, 3, 99] { + let err = + check_lock_version(&format!("{{\n \"lockfileVersion\": {v},\n}}\n")).unwrap_err(); + assert!( + err.contains(&v.to_string()) && err.contains("re-lock with bun >= 1.3"), + "the refusal must name the found version and the remedy: {err}" + ); + } + // Missing / non-integer / string-typed heads fail closed too. + assert!(check_lock_version("{\n \"packages\": {\n }\n}\n").is_err()); + assert!(check_lock_version("{\n \"lockfileVersion\": \"1\",\n}\n").is_err()); + assert!(check_lock_version("{\n \"lockfileVersion\": one,\n}\n").is_err()); + } } diff --git a/crates/socket-patch-core/src/vendor/lock_inventory.rs b/crates/socket-patch-core/src/vendor/lock_inventory.rs index babe81c2..9bfcafd2 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory.rs @@ -2556,21 +2556,41 @@ __metadata: #[tokio::test] async fn bun_registry_tuples_parse_and_locals_are_skipped() { - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "bun.lock", BUN_LOCK).await; + // lockfileVersion 1 (bun 1.3) and 2 (bun 1.4) share one emitted + // grammar, so inventory must read both identically. + for version in [1u64, 2] { + let lock = BUN_LOCK.replace( + "\"lockfileVersion\": 1,", + &format!("\"lockfileVersion\": {version},"), + ); + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "bun.lock", &lock).await; - let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); - assert_eq!(flavor, NpmLockFlavor::Bun); + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert_eq!(flavor, NpmLockFlavor::Bun); - assert_eq!( - entry(&entries, "left-pad").integrity, - LockIntegrity::Sri("sha512-XI5MPz==".into()) - ); - assert_eq!(entry(&entries, "left-pad").resolved, None); - assert_eq!(entry(&entries, "@scope/pkg").version, "2.0.0"); - for absent in ["vendored", "linked"] { - assert!(!entries.iter().any(|e| e.name == absent), "{entries:?}"); + assert_eq!( + entry(&entries, "left-pad").integrity, + LockIntegrity::Sri("sha512-XI5MPz==".into()), + "lockfileVersion {version}" + ); + assert_eq!(entry(&entries, "left-pad").resolved, None); + assert_eq!(entry(&entries, "@scope/pkg").version, "2.0.0"); + for absent in ["vendored", "linked"] { + assert!(!entries.iter().any(|e| e.name == absent), "{entries:?}"); + } } + + // An unsupported lockfileVersion (a future 3) yields no inventory at + // all — fail closed, same posture as the vendor/redirect gates. + let lock = BUN_LOCK.replace("\"lockfileVersion\": 1,", "\"lockfileVersion\": 3,"); + assert_ne!(lock, BUN_LOCK, "replacement must hit"); + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "bun.lock", &lock).await; + assert!( + inventory_npm_lock(tmp.path()).await.unwrap().is_none(), + "a lockfileVersion-3 bun.lock must not be inventoried" + ); } // ── shared semantics ────────────────────────────────────────────────── diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2/expected-edits.json new file mode 100644 index 00000000..67303193 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2/expected-edits.json @@ -0,0 +1,10 @@ +[ + { + "path": "bun.lock", + "kind": "redirect_bun_lock_package", + "action": "rewritten", + "key": "left-pad", + "original": " \"left-pad\": [\"left-pad@1.3.0\", \"\", {}, \"sha512-OLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLD==\"],", + "new": " \"left-pad\": [\"left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz\", {}, \"sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\"]," + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2/expected/bun.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2/expected/bun.lock new file mode 100644 index 00000000..decd3a8e --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2/expected/bun.lock @@ -0,0 +1,14 @@ +{ + "lockfileVersion": 2, + "workspaces": { + "": { + "name": "consumer", + "dependencies": { + "left-pad": "^1.3.0" + } + } + }, + "packages": { + "left-pad": ["left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz", {}, "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2/input/bun.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2/input/bun.lock new file mode 100644 index 00000000..f80f15a9 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2/input/bun.lock @@ -0,0 +1,14 @@ +{ + "lockfileVersion": 2, + "workspaces": { + "": { + "name": "consumer", + "dependencies": { + "left-pad": "^1.3.0" + } + } + }, + "packages": { + "left-pad": ["left-pad@1.3.0", "", {}, "sha512-OLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLD=="], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2/overrides.json new file mode 100644 index 00000000..2b81bef3 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2/overrides.json @@ -0,0 +1,13 @@ +[ + { + "ecosystem": "npm", + "name": "left-pad", + "version": "1.3.0", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "77777777-7777-7777-7777-777777777777", + "artifactUrl": "https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz", + "integrity": { + "sha512": "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-version-unsupported/input/bun.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-version-unsupported/input/bun.lock index f80f15a9..178f677d 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-version-unsupported/input/bun.lock +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-version-unsupported/input/bun.lock @@ -1,5 +1,5 @@ { - "lockfileVersion": 2, + "lockfileVersion": 3, "workspaces": { "": { "name": "consumer", diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 59fc4b8a..152cb250 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -57,7 +57,8 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. unpatched artifact. The reverse shape — an alias of the patched NAME pointing at a different package (`"left-pad@npm:some-fork@^1.3.0"`, the fork-substitution idiom) — is never rewritten: it resolves a different package. -- **bun** — text `bun.lock` v1 only. A binary `bun.lockb` with no text lock beside it +- **bun** — text `bun.lock` lockfileVersion 1 or 2 (bun 1.3 / 1.4 — one emitted + grammar; anything else is refused). A binary `bun.lockb` with no text lock beside it is auto-migrated first: the CLI runs your installed `bun` (`bun install --save-text-lockfile --frozen-lockfile --lockfile-only`) before reading the lock — `redirect_bun_lockb_would_migrate` on `--dry-run`, From 43c92abe4d40210edd5d3090d767b46c946247d5 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 20 Aug 2026 13:56:40 -0400 Subject: [PATCH 2/3] fix(gem): name the skipped BUNDLE_PATH verbatim in the config-ignored warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gem_bundle_config_path_ignored detail interpolated the refused config value with `{value:?}` (Debug), which escapes backslashes. On Unix the two forms coincide (temp paths carry no backslashes), but on Windows the warning printed `C:\\Users\\…` for a config that says `C:\Users\…` — failing all three in_process_gem_config_warning tests on the windows-latest CI leg (red on main since #222 merged, inherited by every open PR) and mangling the path for any human copy-pasting it out of the warning. Format with Display inside manual quotes instead. The value is a single scraped config line, so Display cannot smuggle in newlines the quotes would mask. New platform-independent unit test pins it with a backslash-bearing value: `{:?}` doubles backslashes on every OS, so the pin is RED under the old format everywhere, not just on Windows. Co-Authored-By: Claude Fable 5 --- .../src/crawlers/ruby_crawler.rs | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index b6bc0f0f..0016556d 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -673,8 +673,14 @@ pub struct BundleStoreDiscovery { pub fn config_path_ignored_warning(value: &str) -> (&'static str, String) { ( "gem_bundle_config_path_ignored", + // Display inside manual quotes, NOT `{value:?}`: Debug escaping + // doubles backslashes, so on Windows the detail printed + // `C:\\Users\\…` for a config that says `C:\Users\…` — breaking + // both the substring assertions and any human copy-pasting the + // path. The value is already a single scraped line, so Display + // cannot smuggle in newlines the quotes would mask. format!( - "bundler app config BUNDLE_PATH {value:?} resolves outside the project \ + "bundler app config BUNDLE_PATH \"{value}\" resolves outside the project \ root; ignoring it as an install root (a committed .bundle/config is \ untrusted input — set BUNDLE_PATH in the environment to use an \ out-of-tree bundle path)" @@ -1532,6 +1538,27 @@ mod tests { /// the CLI's warning channels), keyed by the verbatim config value — /// and stays `None` for a contained value or a `path.system` drop /// (bundler itself ignores the path there; nothing was refused). + /// The detail must carry the config value VERBATIM. `{value:?}` (Debug) + /// escaped backslashes, so on Windows the warning printed `C:\\Users\\…` + /// for a config that says `C:\Users\…` — invisible on Unix (temp paths + /// carry no backslashes), red on the windows-latest CI leg, and wrong + /// for any human copy-pasting the path out of the warning. A + /// backslash-bearing value pins it on every platform. + #[test] + fn config_path_ignored_warning_names_the_value_verbatim() { + let value = r"C:\Users\dev\bundle store"; + let (code, detail) = config_path_ignored_warning(value); + assert_eq!(code, "gem_bundle_config_path_ignored"); + assert!( + detail.contains(value), + "detail must contain the unescaped value: {detail}" + ); + assert!( + !detail.contains(r"C:\\Users"), + "Debug escaping must not double backslashes: {detail}" + ); + } + #[tokio::test] async fn discovery_records_skipped_config_path() { let dir = tempfile::tempdir().unwrap(); From bc0c425e49cf710060c04fbf84ec4aca4316d22f Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 20 Aug 2026 15:13:46 -0400 Subject: [PATCH 3/3] =?UTF-8?q?ci:=20retry=20the=20vexctl=20install=20?= =?UTF-8?q?=E2=80=94=20sum.golang.org=20tile=20resets=20flake=20the=20test?= =?UTF-8?q?=20matrix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `go install vexctl` compiles sigstore/cosign, whose module verification reads dozens of sum.golang.org checksum tiles. Transient HTTP/2 INTERNAL_ERROR stream resets there failed the macos-latest test leg twice on this PR (~18 min apart, different modules each time — an upstream incident, not one bad tile), with the test step never reached. Retry up to 5 times with growing backoff; a persistent outage still fails loudly on the last attempt. Follow-up option if it recurs: install the pinned release binary (sha256-pinned) instead of compiling. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19364780..72ebe79e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -253,8 +253,30 @@ jobs: # that path to subsequent steps so `Command::new("vexctl")` in # the test resolves. Pinned to a tagged release rather than # @latest for reproducibility. + # + # Retried: the install compiles sigstore/cosign, whose module + # verification reads dozens of sum.golang.org checksum tiles, and + # transient INTERNAL_ERROR stream resets there have failed this + # step on otherwise-green runs (2026-08-20: two attempts ~18 min + # apart, different modules each time — an upstream incident, not + # one bad tile). The backoff rides out short resets; a persistent + # outage still fails loudly on the last attempt. Follow-up option + # if this recurs: install the pinned release BINARY (sha256-pinned) + # instead of compiling, which sidesteps module verification and + # drops ~100s of compile time per leg. + shell: bash run: | - go install github.com/openvex/vexctl@v0.3.0 + for attempt in 1 2 3 4 5; do + if go install github.com/openvex/vexctl@v0.3.0; then + break + fi + if [ "$attempt" = 5 ]; then + echo "::error::go install vexctl failed on all 5 attempts" + exit 1 + fi + echo "::warning::go install vexctl attempt $attempt failed; retrying" + sleep $((attempt * 20)) + done echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - name: Run tests