From c57837bbfa1111eb0abad556de4e59843d4560bb Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 18 Sep 2026 13:39:01 -0400 Subject: [PATCH 01/49] Fix Bun patch compatibility and refusals Support Bun text lock version 0 and reject workspace layouts whose tarball paths cannot survive native reinstalls. Refuse incompatible vendored downloads before recording manifest patch intent. Add native release/configuration checks for hosted, vendored and detached installs, patched bytes, integrity and rollback. Assisted-by: Codex:gpt-6-astra --- .github/workflows/bun-compatibility.yml | 87 +++++ crates/socket-patch-cli/src/commands/get.rs | 26 ++ .../src/patch/redirect/mod.rs | 17 +- .../socket-patch-core/src/vendor/bun_lock.rs | 109 +++++- .../src/vendor/bun_lock_text.rs | 51 ++- .../expected-edits.json | 1 + .../lock-v0-workspace-refusal/input/bun.lock | 15 + .../lock-v0-workspace-refusal/overrides.json | 13 + .../npm/bun/lock-v0/expected-edits.json | 10 + .../npm/bun/lock-v0/expected/bun.lock | 14 + .../redirect/npm/bun/lock-v0/input/bun.lock | 14 + .../redirect/npm/bun/lock-v0/overrides.json | 13 + docs/testing/bun-compatibility.md | 40 +++ scripts/backtest-bun.py | 311 ++++++++++++++++++ 14 files changed, 698 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/bun-compatibility.yml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0-workspace-refusal/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0-workspace-refusal/input/bun.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0-workspace-refusal/overrides.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0/expected/bun.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0/input/bun.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0/overrides.json create mode 100644 docs/testing/bun-compatibility.md create mode 100644 scripts/backtest-bun.py diff --git a/.github/workflows/bun-compatibility.yml b/.github/workflows/bun-compatibility.yml new file mode 100644 index 00000000..421ca8a5 --- /dev/null +++ b/.github/workflows/bun-compatibility.yml @@ -0,0 +1,87 @@ +name: Bun patch compatibility + +on: + pull_request: + paths: + - '.github/workflows/bun-compatibility.yml' + - 'scripts/backtest-bun.py' + - 'crates/socket-patch-core/src/vendor/**' + - 'crates/socket-patch-core/src/patch/redirect/**' + - 'crates/socket-patch-cli/src/commands/get.rs' + - 'crates/socket-patch-cli/src/commands/scan/**' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: bun-patch-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + CARGO_PROFILE_DEV_DEBUG: '0' + CARGO_INCREMENTAL: '0' + +jobs: + build: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + with: + key: bun-native + save-if: ${{ github.ref == 'refs/heads/main' }} + - run: cargo build --locked -p socket-patch-cli + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: bun-cli-${{ matrix.os }} + path: | + target/debug/socket-patch + target/debug/socket-patch.exe + if-no-files-found: error + retention-days: 7 + native: + needs: build + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + bun: ['0.8.1', '1.0.0', '1.0.36', '1.1.0', '1.1.38', '1.1.39', '1.1.45', '1.2.0', '1.2.23', '1.3.0', '1.3.14', '1.4.0', '1.4.2'] + exclude: + - {os: windows-latest, bun: '0.8.1'} + - {os: windows-latest, bun: '1.0.0'} + - {os: windows-latest, bun: '1.0.36'} + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: bun-cli-${{ matrix.os }} + path: native-cli + - name: Install, verify patched bytes, reject corruption, and roll back + shell: bash + run: | + chmod +x native-cli/socket-patch* + python scripts/backtest-bun.py --cli "native-cli/socket-patch${{ runner.os == 'Windows' && '.exe' || '' }}" --cli-revision "${{ github.event.pull_request.head.sha || github.sha }}" --output native-bun --versions '${{ matrix.bun }}' --modes hosted vendored vendored-detached --jobs 3 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: always() + with: + name: bun-results-${{ matrix.os }}-${{ matrix.bun }} + include-hidden-files: true + path: | + native-bun/summary.json + native-bun/captures/**/result.json + native-bun/captures/**/cli-output.json + native-bun/captures/**/tree/** + native-bun/captures/**/*.log + retention-days: 14 diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 12c5885a..a2acb7c4 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -1512,7 +1512,33 @@ pub async fn download_and_apply_patches( let mut patches_downloaded = 0; let mut downloaded_patches: Vec = Vec::new(); + // Vendored downloads must not claim a patch in the manifest when Bun + // cannot consume its artifact. Agent/save-only flows retain their intent. + let bun_refusal = if params.save_only && !params.persist_blobs { + socket_patch_core::vendor::bun_lock::preflight_vendor(¶ms.cwd) + .await + .err() + } else { + None + }; for search_result in &selected { + if let Some((code, detail)) = bun_refusal + .as_ref() + .filter(|_| search_result.purl.starts_with("pkg:npm/")) + { + patches_failed += 1; + downloaded_patches.push(serde_json::json!({ + "purl": search_result.purl, + "uuid": search_result.uuid, + "action": "failed", + "errorCode": code, + "error": detail, + })); + if !params.json && !params.silent { + eprintln!(" [error] {}: {detail}", search_result.purl); + } + continue; + } // org slug is already stored in the client. match api_client.fetch_patch(None, &search_result.uuid).await { Ok(Some(patch)) => { diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index e4f0d016..8a9caf6a 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -2380,7 +2380,8 @@ fn rewrite_bun_lock( result: &mut RewriteResult, ) { use crate::vendor::bun_lock_text::{ - check_lock_version, decode_json_string, parse_packages_section, + check_lock_version, decode_json_string, has_workspace_packages, lock_version, + parse_packages_section, }; let npm: Vec<&DepOverride> = overrides.iter().filter(|o| o.ecosystem == "npm").collect(); @@ -2404,7 +2405,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 or 2; re-lock with bun >= 1.3".into(), + detail: "bun.lock lockfileVersion is not 0, 1 or 2; re-lock with bun >= 1.4".into(), }); return; } @@ -2423,6 +2424,16 @@ fn rewrite_bun_lock( } }; + if lock_version(content) == Some(0) && has_workspace_packages(&entries) { + result.warnings.push(RewriteWarning { + code: "redirect_bun_workspace_unsupported".into(), + detail: "Bun version-0 workspace locks cannot preserve hosted tarballs on frozen \ + installs; upgrade Bun and regenerate the text lockfile" + .into(), + }); + return; + } + let mut changed = false; for dep in &npm { let fname = full_name(dep); @@ -6604,7 +6615,7 @@ mod tests { 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"), + r.warnings[0].detail.contains("not 0, 1 or 2"), "the refusal must name the supported versions: {}", r.warnings[0].detail ); diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 2b2d5c6b..23edaf72 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -36,8 +36,8 @@ use crate::patch::apply::PatchSources; use crate::patch::copy_tree::remove_tree; use crate::utils::fs::atomic_write_bytes_preserving_mode; use crate::vendor::bun_lock_text::{ - check_lock_version, decode_json_string, packages_bounds, parse_entry_line, - parse_packages_section, split_name_spec, BunEntry, + check_lock_version, decode_json_string, has_workspace_packages, lock_version, packages_bounds, + parse_entry_line, parse_packages_section, split_name_spec, BunEntry, }; use super::common::{already_patched_result, refused}; @@ -56,6 +56,47 @@ const BUN_LOCK: &str = "bun.lock"; /// original/new = the verbatim entry LINE. const KIND_LOCK_PACKAGE: &str = "bun_lock_package"; +fn check_workspace_compatibility( + text: &str, + entries: &[BunEntry], +) -> Result<(), (&'static str, String)> { + if lock_version(text) != Some(2) && has_workspace_packages(entries) { + return Err(( + "vendor_bun_workspace_unsupported", + "Bun text locks before version 2 resolve workspace tarballs relative to the \ + workspace rather than the lockfile; upgrade to Bun >= 1.4 and run `bun install` \ + before vendoring workspace dependencies" + .to_string(), + )); + } + Ok(()) +} + +/// Refuse incompatible Bun projects before downloading records into the manifest. +/// Other package managers are left to their own backends. +pub async fn preflight_vendor(project_root: &Path) -> Result<(), (&'static str, String)> { + let path = project_root.join(BUN_LOCK); + let text = match tokio::fs::read_to_string(&path).await { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if project_root.join("bun.lockb").exists() { + return Err(( + "vendor_bun_lockb_unsupported", + "Bun binary lockfiles cannot be vendored; upgrade Bun and generate bun.lock" + .to_string(), + )); + } + return Ok(()); + } + Err(error) => return Err(("vendor_lockfile_missing", error.to_string())), + }; + check_lock_version(&text).map_err(|detail| ("vendor_lockfile_version_unsupported", detail))?; + let lines = text.split('\n').map(str::to_string).collect::>(); + let entries = parse_packages_section(&lines) + .map_err(|detail| ("vendor_lockfile_version_unsupported", detail))?; + check_workspace_compatibility(&text, &entries) +} + /// Vendor one installed npm package into a bun project (see the module doc). /// Same contract as `npm_lock::vendor_npm`: refuse-early / wire-last, /// `entry` present iff `result.success` and not a dry run, and an in-sync @@ -107,6 +148,10 @@ pub(crate) async fn vendor_bun( } }; + if let Err((code, detail)) = check_workspace_compatibility(&lock_text, &entries) { + return refused(code, detail); + } + // ── 3. Pre-flight: at least one rewritable instance ────────────────── let target_spec = format!("{name}@{version}"); let target_leaf = tgz_rel_leaf(name, version); @@ -1226,6 +1271,66 @@ mod tests { ); } + #[tokio::test] + async fn legacy_workspace_tarballs_refuse_before_writes() { + for version in [0, 1, 2] { + let lock = BN3_BEFORE_LOCK + .replace("\"lockfileVersion\": 1", &format!("\"lockfileVersion\": {version}")) + .replace(" \"packages\": {", " \"packages\": {\n \"consumer\": [\"consumer@workspace:packages/consumer\"],"); + let fx = fixture_with(&lock, "node_modules/left-pad").await; + if version < 2 { + assert_eq!( + preflight_vendor(fx.root()).await.unwrap_err().0, + "vendor_bun_workspace_unsupported" + ); + expect_refused(fx.vendor(false).await, "vendor_bun_workspace_unsupported"); + assert_eq!(fx.read_lock().await, lock); + assert!(!fx.root().join(".socket/vendor").exists()); + } else { + assert!(preflight_vendor(fx.root()).await.is_ok()); + let (_, entry, _) = expect_done(fx.vendor(false).await); + assert!(entry.is_some()); + } + } + } + + #[tokio::test] + async fn lock_v0_vendor_and_revert_preserve_bytes() { + let lock = BN3_BEFORE_LOCK.replace("\"lockfileVersion\": 1", "\"lockfileVersion\": 0"); + let fx = fixture_with(&lock, "node_modules/left-pad").await; + assert!(preflight_vendor(fx.root()).await.is_ok()); + let (_, entry, _) = expect_done(fx.vendor(false).await); + assert!(fx.read_lock().await.contains(".socket/vendor/npm/")); + let entry = entry.unwrap(); + let result = revert_bun(&entry, fx.root(), false).await; + assert!(result.success); + assert_eq!(fx.read_lock().await, lock); + } + + #[tokio::test] + async fn download_preflight_refuses_binary_and_malformed_bun_locks() { + let root = tempfile::tempdir().unwrap(); + assert!(preflight_vendor(root.path()).await.is_ok()); + tokio::fs::write(root.path().join("bun.lockb"), b"binary") + .await + .unwrap(); + assert_eq!( + preflight_vendor(root.path()).await.unwrap_err().0, + "vendor_bun_lockb_unsupported" + ); + tokio::fs::write(root.path().join(BUN_LOCK), BN3_BEFORE_LOCK) + .await + .unwrap(); + assert!(preflight_vendor(root.path()).await.is_ok()); + tokio::fs::write(root.path().join(BUN_LOCK), "{}") + .await + .unwrap(); + assert_eq!( + preflight_vendor(root.path()).await.unwrap_err().0, + "vendor_lockfile_version_unsupported" + ); + } + /// Build a scoped-package fixture and vendor it once (not dry). async fn scoped_fixture() -> Fixture { let fx = fixture_with(SCOPED_BEFORE_LOCK, "node_modules/@scope/pkg").await; 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 e4f5c387..d30dc025 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock_text.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock_text.rs @@ -11,6 +11,7 @@ /// The text-lockfile versions the surgery has byte-exact fixtures for. /// +/// Bun 1.1.39–1.1.45 emits 0 with the same package tuple grammar. /// 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 @@ -19,7 +20,7 @@ /// 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]; +const SUPPORTED_LOCK_VERSIONS: [u64; 3] = [0, 1, 2]; /// One parsed single-line packages entry. pub(crate) struct BunEntry { @@ -50,25 +51,39 @@ pub(crate) fn split_name_spec(s: &str) -> Option<(&str, &str)> { /// `"lockfileVersion": ` head check — only the fixture-pinned text /// 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() - .strip_prefix("\"lockfileVersion\":") - .map(|rest| rest.trim().trim_end_matches(',').to_string()) - }); - match version.as_deref().map(str::parse::) { - Some(Ok(v)) if SUPPORTED_LOCK_VERSIONS.contains(&v) => Ok(()), - Some(Ok(v)) => Err(format!( - "bun.lock has lockfileVersion {v}; only 1 and 2 are supported — \ - re-lock with bun >= 1.3" + match lock_version(text) { + Some(v) if SUPPORTED_LOCK_VERSIONS.contains(&v) => Ok(()), + Some(v) => Err(format!( + "bun.lock has lockfileVersion {v}; only 0, 1 and 2 are supported — \ + re-lock with bun >= 1.4" )), - _ => Err( - "bun.lock has no integer lockfileVersion in its head; only 1 and 2 \ - are supported — re-lock with bun >= 1.3" + None => Err( + "bun.lock has no integer lockfileVersion in its head; only 0, 1 and 2 \ + are supported — re-lock with bun >= 1.4" .to_string(), ), } } +pub(crate) fn lock_version(text: &str) -> Option { + text.lines() + .take(5) + .find_map(|line| line.trim().strip_prefix("\"lockfileVersion\":")) + .and_then(|rest| rest.trim().trim_end_matches(',').parse().ok()) +} + +pub(crate) fn has_workspace_packages(entries: &[BunEntry]) -> bool { + entries.iter().any(|entry| { + entry + .elems + .first() + .and_then(|raw| decode_json_string(raw)) + .is_some_and(|spec| { + split_name_spec(&spec).is_some_and(|(_, version)| version.starts_with("workspace:")) + }) + }) +} + /// `(header_idx, close_idx)` of the `"packages": {` section. pub(crate) fn packages_bounds(lines: &[String]) -> Option<(usize, usize)> { let start = lines @@ -445,18 +460,18 @@ mod tests { /// 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] { + fn lock_version_gate_accepts_0_1_and_2_only() { + for v in [0u64, 1, 2] { assert!( check_lock_version(&format!("{{\n \"lockfileVersion\": {v},\n}}\n")).is_ok(), "lockfileVersion {v} must be accepted" ); } - for v in [0u64, 3, 99] { + for v in [3u64, 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"), + err.contains(&v.to_string()) && err.contains("re-lock with bun >= 1.4"), "the refusal must name the found version and the remedy: {err}" ); } diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0-workspace-refusal/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0-workspace-refusal/expected-edits.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0-workspace-refusal/expected-edits.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0-workspace-refusal/input/bun.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0-workspace-refusal/input/bun.lock new file mode 100644 index 00000000..5448b45d --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0-workspace-refusal/input/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 0, + "workspaces": { + "": { + "name": "consumer", + "dependencies": { + "left-pad": "^1.3.0" + } + } + }, + "packages": { + "consumer": ["consumer@workspace:packages/consumer"], + "left-pad": ["left-pad@1.3.0", "", {}, "sha512-OLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLD=="], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0-workspace-refusal/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0-workspace-refusal/overrides.json new file mode 100644 index 00000000..2b81bef3 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0-workspace-refusal/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-v0/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0/expected-edits.json new file mode 100644 index 00000000..67303193 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0/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-v0/expected/bun.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0/expected/bun.lock new file mode 100644 index 00000000..b9897c58 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0/expected/bun.lock @@ -0,0 +1,14 @@ +{ + "lockfileVersion": 0, + "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-v0/input/bun.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0/input/bun.lock new file mode 100644 index 00000000..a577caca --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0/input/bun.lock @@ -0,0 +1,14 @@ +{ + "lockfileVersion": 0, + "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-v0/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0/overrides.json new file mode 100644 index 00000000..2b81bef3 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0/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/docs/testing/bun-compatibility.md b/docs/testing/bun-compatibility.md new file mode 100644 index 00000000..580676c2 --- /dev/null +++ b/docs/testing/bun-compatibility.md @@ -0,0 +1,40 @@ +# Bun patch compatibility + +`scripts/backtest-bun.py` runs real Bun releases against the public free Socket patch for `minimist@1.2.2` (`80630680-4da6-45f9-bba8-b888e0ffd58c`). It uses the production CLI and patch service, without a token or substitute service. + +```sh +cargo build --locked -p socket-patch-cli +python3 scripts/backtest-bun.py \ + --cli target/debug/socket-patch \ + --cli-revision "$(git rev-parse HEAD)" \ + --output /tmp/bun-compatibility \ + --modes hosted vendored vendored-detached +``` + +Use `--versions 1.4.2 --shapes workspace-nested` for a focused reproduction. Windows uses `target/debug/socket-patch.exe`. The [Bun workflow](../../.github/workflows/bun-compatibility.yml) runs Linux, macOS and Windows; releases before Bun 1.1 have no Windows binary. + +The pinned matrix covers 0.8.1, 1.0.0, 1.0.36, 1.1.0, 1.1.38, 1.1.39, 1.1.45, 1.2.0, 1.2.23, 1.3.0, 1.3.14, 1.4.0 and 1.4.2. These span the binary lockfile, the first text locks (version 0), the text default (version 1), and version 2. Configurations cover direct, development, optional, peer, aliased and overridden transitive dependencies; two versions of a package; root and nested workspace dependencies; explicit registries; text-lock opt-in; production installs; projects without `node_modules`; isolated and hoisted linkers; CRLF manifests and paths containing spaces and Unicode. + +Every supported case verifies: + +- CLI output identifies the expected published patch. +- Fresh frozen and ordinary installs contain the patch record's exact `afterHash` bytes, with unchanged lockfiles. +- Repeated scans preserve lockfile bytes. +- A corrupted digest on the patched tuple is rejected on releases that enforce it. +- Rollback restores original manifest/lock bytes and a clean install reproduces the record's `beforeHash` bytes. + +The runner captures the exact project manifests, lockfiles, optional `.socket/manifest.json`, CLI JSON, file hashes and assertion results. Socket SBOM tests import these captures through their existing fixture validation framework. Vendored artifact contents are verified by the native runner; they are not needed for SBOM lockfile annotation. + +## Boundaries verified by the matrix + +| Configuration | Behavior | +| --- | --- | +| Text lock 0, 1 or 2, no workspaces | Hosted and vendored rewrites supported. | +| Binary lock only | Vendored mode refuses. Hosted mode attempts Bun's native text migration and refuses when that release cannot perform it. Without installed packages, binary locks cannot supply a package inventory. | +| Version-0 workspace lock | Hosted mode refuses because frozen installs cannot preserve the rewrite. | +| Version-0/1 workspace lock | Vendored mode refuses because Bun resolves local tarballs relative to the workspace. Upgrade to Bun 1.4 or later and regenerate the lock. | +| Version-2 workspace lock | Hosted and vendored modes supported, including nested versions. | +| Bun before 1.3.14 in this matrix | Native tarball digest enforcement is absent. The runner records the limitation rather than claiming corruption was rejected. | +| Bun 0.8.1 / 1.0.0 peers or transitive overrides | These releases do not install the selected patched version in these configurations; the CLI leaves the project unchanged. | + +Refused vendored downloads do not add a patch record to the manifest. Existing explicit manifest intent is preserved. Detached vendoring keeps its patch record in the vendor ledger and supplies the same lockfile annotation. diff --git a/scripts/backtest-bun.py b/scripts/backtest-bun.py new file mode 100644 index 00000000..257ee6ce --- /dev/null +++ b/scripts/backtest-bun.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +"""Native Bun / public Socket patch compatibility, with no service doubles.""" + +import argparse +import concurrent.futures +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import platform +import re +import shutil +import subprocess +import urllib.request +import zipfile + +VERSIONS = ['0.8.1', '1.0.0', '1.0.36', '1.1.0', '1.1.38', '1.1.39', '1.1.45', + '1.2.0', '1.2.23', '1.3.0', '1.3.14', '1.4.0', '1.4.2'] +SHAPES = ['direct', 'dev', 'optional', 'alias', 'transitive', 'two-versions', + 'workspace', 'workspace-nested', 'peer', 'crlf', 'space-unicode', + 'custom-registry', 'text', 'isolated', 'hoisted', 'lockfile-only', 'production'] +PURL = 'pkg:npm/minimist@1.2.2' +UUID = '80630680-4da6-45f9-bba8-b888e0ffd58c' + + +def save(path, data): + path.write_text(json.dumps(data, indent=2) + '\n', encoding='utf-8') + + +def run(command, cwd, env, log, required=True): + try: + result = subprocess.run([str(x) for x in command], cwd=cwd, env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + timeout=180) + except subprocess.TimeoutExpired as error: + log.write_bytes(error.stdout or b'') + raise + log.write_bytes(result.stdout) + if required and result.returncode: + raise RuntimeError(f'{command}: {result.stdout.decode(errors="replace")[-4000:]}') + return result.returncode, result.stdout.decode(errors='replace') + + +def git_hash(data): + return hashlib.sha256(f'blob {len(data)}\0'.encode() + data).hexdigest() + + +def install_tool(root, version): + system = platform.system().lower() + arch = 'aarch64' if platform.machine().lower() in ('arm64', 'aarch64') else 'x64' + if system == 'windows': + arch = 'x64' + asset = f'bun-{system}-{arch}' + directory = root / version + binary = directory / asset / ('bun.exe' if system == 'windows' else 'bun') + if not binary.exists(): + directory.mkdir(parents=True, exist_ok=True) + archive = directory / 'bun.zip' + urllib.request.urlretrieve( + f'https://github.com/oven-sh/bun/releases/download/bun-v{version}/{asset}.zip', archive) + with zipfile.ZipFile(archive) as zipped: + zipped.extractall(directory) + archive.unlink() + binary.chmod(0o755) + actual = subprocess.check_output([binary, '--version'], text=True).strip() + if actual != version: + raise RuntimeError(f'Expected Bun {version}, got {actual}') + return binary + + +def project_files(shape): + manifest = dict(name='bun-patch-backtest', version='1.0.0', private=True, + dependencies={'minimist': '1.2.2'}) + files = {} + if shape in ('dev', 'optional', 'peer'): + key = {'dev': 'devDependencies', 'optional': 'optionalDependencies', + 'peer': 'peerDependencies'}[shape] + manifest[key] = manifest.pop('dependencies') + elif shape == 'alias': + manifest['dependencies'] = {'alias': 'npm:minimist@1.2.2'} + elif shape == 'transitive': + manifest['dependencies'] = {'mkdirp': '0.5.3'} + manifest['overrides'] = {'minimist': '1.2.2'} + elif shape == 'two-versions': + manifest['dependencies']['other'] = 'npm:minimist@1.2.8' + elif shape == 'production': + manifest['devDependencies'] = {'other': 'npm:minimist@1.2.8'} + elif shape.startswith('workspace'): + manifest['workspaces'] = ['packages/*'] + manifest['dependencies'] = {'consumer': 'workspace:*'} + files['packages/consumer/package.json'] = json.dumps(dict( + name='consumer', version='1.0.0', + dependencies={'minimist': '1.2.2'})) + '\n' + if shape == 'workspace-nested': + manifest['dependencies']['minimist'] = '1.2.8' + elif shape in ('isolated', 'hoisted'): + files['bunfig.toml'] = f'[install]\nlinker = "{shape}"\n' + elif shape == 'custom-registry': + files['.npmrc'] = 'registry=https://registry.npmjs.org/\n' + files['package.json'] = json.dumps(manifest, indent=2) + '\n' + return {name: (data.replace('\n', '\r\n') if shape == 'crlf' else data).encode() + for name, data in files.items()} + + +def installed_targets(project): + targets = [] + for manifest in project.rglob('package.json'): + if 'node_modules' not in manifest.parts or '.socket' in manifest.parts: + continue + data = json.loads(manifest.read_text(encoding='utf-8')) + if data.get('name') == 'minimist' and data.get('version') == '1.2.2': + targets.append(manifest.parent) + return targets + + +def oracle(project, record, side): + targets = installed_targets(project) + checks = {} + for target in targets: + for filename, hashes in record['files'].items(): + file = target / filename.removeprefix('package/') + expected = hashes.get(side + 'Hash') + checks[str(file.relative_to(project))] = ( + git_hash(file.read_bytes()) == expected if expected and file.is_file() + else expected is None and not file.exists()) + return bool(targets) and bool(checks) and all(checks.values()), checks + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--cli', type=Path, required=True) + parser.add_argument('--cli-revision', required=True) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--tools', type=Path) + parser.add_argument('--versions', nargs='+', default=VERSIONS) + parser.add_argument('--shapes', nargs='+', default=SHAPES, choices=SHAPES) + parser.add_argument('--modes', nargs='+', default=['hosted', 'vendored'], + choices=['hosted', 'vendored', 'vendored-detached']) + parser.add_argument('--jobs', type=int, default=4) + args = parser.parse_args() + cli = args.cli.resolve() + root = args.output.resolve() + root.mkdir(parents=True, exist_ok=True) + toolroot = (args.tools or root / 'tools').resolve() + tools = {v: install_tool(toolroot, v) for v in args.versions} + base_env = {k: v for k, v in os.environ.items() + if not k.startswith(('SOCKET_', 'BUN_', 'npm_config_', 'NPM_CONFIG_'))} + base_env.update(SOCKET_NO_CONFIG='1', SOCKET_NO_UPDATE_CHECK='1', NO_COLOR='1') + provenance = dict(capturedAt=datetime.now(timezone.utc).isoformat(), + os=platform.system().lower(), platform=platform.platform(), + cliRevision=args.cli_revision, + cliSha256=hashlib.sha256(cli.read_bytes()).hexdigest()) + + def backtest(job): + version, shape, mode = job + case = root / 'captures' / f'{version}-{shape}-{mode}' + case.mkdir(parents=True, exist_ok=True) + project = case / ('project space café' if shape == 'space-unicode' else 'project') + if project.exists(): + shutil.rmtree(project) + project.mkdir() + row = dict(bun=version, shape=shape, mode=mode, passed=False, **provenance) + checks = {} + row['checks'] = checks + try: + bun = tools[version] + env = dict(base_env, PATH=str(bun.parent) + os.pathsep + base_env['PATH'], + BUN_INSTALL_CACHE_DIR=str(case / 'cache'), + BUN_INSTALL=str(case / 'bun-home')) + files = project_files(shape) + for name, data in files.items(): + (project / name).parent.mkdir(parents=True, exist_ok=True) + (project / name).write_bytes(data) + install_args = ['install', '--ignore-scripts'] + if shape == 'text': + install_args += ['--save-text-lockfile'] + run([bun, *install_args], project, env, case / 'baseline.log') + original = {name: (project / name).read_bytes() + for name in [*files, 'bun.lock', 'bun.lockb'] if (project / name).exists()} + row['originalSha256'] = {n: hashlib.sha256(b).hexdigest() for n, b in original.items()} + checks['installedBefore'] = bool(installed_targets(project)) + if shape == 'lockfile-only': + shutil.rmtree(project / 'node_modules') + command = [cli, 'scan', '--mode', 'vendored' if mode == 'vendored-detached' else mode, '--cwd', project, + '--json', '--yes', '--no-telemetry'] + if mode == 'vendored-detached': + command.append('--detached') + code, output = run(command, project, env, case / 'cli.log', False) + envelope = json.loads(output[output.index('{'):]) + save(case / 'cli-output.json', envelope) + applied = (envelope.get('redirect', {}).get('redirected', 0) if mode == 'hosted' + else envelope.get('vendor', {}).get('summary', {}).get('applied', 0)) + warnings = (envelope.get('redirect', {}).get('warnings', []) if mode == 'hosted' + else envelope.get('vendor', {}).get('events', [])) + row['refusals'] = [w.get('code', w.get('errorCode')) for w in warnings] + row['refusals'] += [p['errorCode'] for p in envelope.get('download', {}).get('patches', []) + if p.get('errorCode')] + row['applied'] = applied + if not checks['installedBefore'] and version in ['0.8.1', '1.0.0'] and shape in ['peer', 'transitive']: + row['supported'] = False + row['upstreamLimitations'] = ['This Bun release does not install the requested peer or honor the transitive override'] + del checks['installedBefore'] + checks['noPatchApplied'] = applied == 0 + checks['unchanged'] = all((project / n).read_bytes() == b for n, b in original.items()) + elif any('bun_workspace_unsupported' in (x or '') for x in row['refusals']): + row['supported'] = False + checks['refused'] = applied == 0 + checks['unchanged'] = all((project / n).read_bytes() == b for n, b in original.items()) + elif 'bun.lockb' in original and not (project / 'bun.lock').exists(): + row['supported'] = False + checks['refusedOrNoDiscoverablePackages'] = applied == 0 and ( + any('bun_lockb' in (x or '') or shape == 'alias' and x == 'package_not_installed' for x in row['refusals']) + or shape == 'lockfile-only' and envelope.get('scannedPackages') == 0 + and envelope.get('packagesWithPatches') == 0) + checks['unchanged'] = all((project / n).read_bytes() == b for n, b in original.items()) + else: + row['supported'] = True + checks['cliSuccess'] = code == 0 and applied == 1 + if not checks['cliSuccess']: + raise RuntimeError(f'Expected one applied patch: {output[-4000:]}') + ledger = project / ('.socket/vendor/redirect-state.json' if mode == 'hosted' + else '.socket/vendor/state.json' if mode == 'vendored-detached' + else '.socket/manifest.json') + state = json.loads(ledger.read_text()) + record = (state['records'][PURL] if mode == 'hosted' else + state['entries'][PURL]['record'] if mode == 'vendored-detached' else state['patches'][PURL]) + row['patchUuid'] = record['uuid'] + checks['publishedPatch'] = record['uuid'] == UUID + if mode == 'vendored-detached': + checks['noManifest'] = not (project / '.socket/manifest.json').exists() + capture = case / 'tree' + if capture.exists(): + shutil.rmtree(capture) + capture.mkdir() + names = [*files, 'bun.lock', 'bun.lockb', '.socket/manifest.json'] + for name in names: + source = project / name + if source.is_file(): + destination = capture / name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination) + row['manifestSha256'] = {p.relative_to(capture).as_posix(): hashlib.sha256(p.read_bytes()).hexdigest() + for p in capture.rglob('*') if p.is_file()} + patched_lock = (project / 'bun.lock').read_bytes() + for label, flags in [('frozen', ['--frozen-lockfile']), ('ordinary', [])]: + if shape == 'production': + flags = [*flags, '--production'] + for modules in sorted(project.rglob('node_modules'), key=lambda p: len(p.parts)): + if modules.exists() and not modules.is_symlink(): + shutil.rmtree(modules) + fresh_env = dict(env, BUN_INSTALL_CACHE_DIR=str(case / ('cache-' + label))) + run([bun, 'install', '--ignore-scripts', *flags], project, fresh_env, case / (label + '.log')) + correct, hashes = oracle(project, record, 'after') + checks[label + 'PatchedBytes'] = correct + row[label + 'Files'] = hashes + checks[label + 'StableLock'] = (project / 'bun.lock').read_bytes() == patched_lock + _, repeat = run(command, project, env, case / 'repeat.log', False) + row['repeat'] = json.loads(repeat[repeat.index('{'):]) + checks['repeatStableLock'] = (project / 'bun.lock').read_bytes() == patched_lock + tampered = b''.join(re.sub(rb'sha512-[A-Za-z0-9+/=]+(?="\])', b'sha512-' + b'A' * 86 + b'==', line) if UUID.encode() in line else line for line in patched_lock.splitlines(keepends=True)) + checks['tamperedDigest'] = tampered != patched_lock + (project / 'bun.lock').write_bytes(tampered) + for modules in sorted(project.rglob('node_modules'), key=lambda p: len(p.parts)): + if modules.exists() and not modules.is_symlink(): + shutil.rmtree(modules) + code, output = run([bun, 'install', '--ignore-scripts', '--frozen-lockfile'], project, + dict(env, BUN_INSTALL_CACHE_DIR=str(case / 'cache-corrupt')), + case / 'corrupt.log', False) + row['rejectsCorruptDigest'] = code != 0 and ('integrity' in output.lower() or 'checksum' in output.lower()) + # Older Bun accepts tarball hashes but does not enforce them. + if tuple(map(int, version.split('.'))) < (1, 3, 14): + checks['legacyDigestBehavior'] = code == 0 + row.setdefault('upstreamLimitations', []).append('Bun does not verify tarball integrity on this release') + else: + checks['rejectCorruptDigest'] = row['rejectsCorruptDigest'] + (project / 'bun.lock').write_bytes(patched_lock) + run([cli, 'rollback', '--cwd', project, '--json', '--yes', '--no-telemetry'], + project, env, case / 'rollback.log') + checks['rollbackOriginalFiles'] = all((project / n).exists() and (project / n).read_bytes() == b + for n, b in original.items()) + for modules in sorted(project.rglob('node_modules'), key=lambda p: len(p.parts)): + if modules.exists() and not modules.is_symlink(): + shutil.rmtree(modules) + run([bun, 'install', '--ignore-scripts'], project, + dict(env, BUN_INSTALL_CACHE_DIR=str(case / 'cache-rollback')), case / 'reinstall.log') + checks['rollbackOriginalBytes'], row['rollbackFiles'] = oracle(project, record, 'before') + if not row.get('supported'): + manifest = project / '.socket/manifest.json' + checks['noFalseManifestAnnotation'] = not manifest.exists() or PURL not in json.loads(manifest.read_text())['patches'] + checks['unchangedLockPresence'] = all((project / name).exists() == (name in original) + for name in ['bun.lock', 'bun.lockb']) + row['passed'] = all(checks.values()) + except Exception as error: + row['error'] = str(error) + save(case / 'result.json', row) + print(version, shape, mode, 'PASS' if row['passed'] else 'FAIL', + [k for k, v in checks.items() if not v], row.get('error', '')[:200], flush=True) + return row + + jobs = [(v, s, m) for v in args.versions for s in args.shapes for m in args.modes + if (s not in ('isolated', 'hoisted') or tuple(map(int, v.split('.'))) >= (1, 3, 0)) + and (s != 'text' or tuple(map(int, v.split('.'))) >= (1, 1, 38))] + with concurrent.futures.ThreadPoolExecutor(max_workers=args.jobs) as pool: + rows = list(pool.map(backtest, jobs)) + save(root / 'summary.json', rows) + return 0 if all(row['passed'] for row in rows) else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) From cfc1e963f1a520ef9d64af6a4eb6a3c32a8076ac Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 18 Sep 2026 13:41:21 -0400 Subject: [PATCH 02/49] Keep Bun compatibility probes nonblocking Use the regular-file reader for Bun preflight and vendoring. Verify FIFO inputs refuse promptly, and snapshot the CLI for native runs so concurrent builds cannot change the binary under test. Assisted-by: Codex:gpt-6-astra --- .../socket-patch-core/src/vendor/bun_lock.rs | 25 ++++++++++++++++--- scripts/backtest-bun.py | 3 ++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 23edaf72..66eeebda 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -34,7 +34,7 @@ use serde_json::Value; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; use crate::patch::copy_tree::remove_tree; -use crate::utils::fs::atomic_write_bytes_preserving_mode; +use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; use crate::vendor::bun_lock_text::{ check_lock_version, decode_json_string, has_workspace_packages, lock_version, packages_bounds, parse_entry_line, parse_packages_section, split_name_spec, BunEntry, @@ -76,7 +76,7 @@ fn check_workspace_compatibility( /// Other package managers are left to their own backends. pub async fn preflight_vendor(project_root: &Path) -> Result<(), (&'static str, String)> { let path = project_root.join(BUN_LOCK); - let text = match tokio::fs::read_to_string(&path).await { + let text = match read_regular_to_string(&path).await { Ok(text) => text, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { if project_root.join("bun.lockb").exists() { @@ -123,7 +123,7 @@ pub(crate) async fn vendor_bun( let (name, version) = (coords.name.as_str(), coords.version.as_str()); // ── 2. Read + strictly parse the lock (refuse before any write) ────── - let lock_text = match tokio::fs::read_to_string(project_root.join(BUN_LOCK)).await { + let lock_text = match read_regular_to_string(&project_root.join(BUN_LOCK)).await { Ok(text) => text, Err(e) => { return refused( @@ -1331,6 +1331,25 @@ mod tests { ); } + #[cfg(unix)] + #[tokio::test] + async fn download_preflight_refuses_fifo_without_blocking() { + let root = tempfile::tempdir().unwrap(); + assert!(std::process::Command::new("mkfifo") + .arg(root.path().join(BUN_LOCK)) + .status() + .unwrap() + .success()); + let refusal = tokio::time::timeout( + std::time::Duration::from_secs(2), + preflight_vendor(root.path()), + ) + .await + .expect("Bun preflight must not block on a FIFO") + .unwrap_err(); + assert_eq!(refusal.0, "vendor_lockfile_missing"); + } + /// Build a scoped-package fixture and vendor it once (not dry). async fn scoped_fixture() -> Fixture { let fx = fixture_with(SCOPED_BEFORE_LOCK, "node_modules/@scope/pkg").await; diff --git a/scripts/backtest-bun.py b/scripts/backtest-bun.py index 257ee6ce..66b82969 100644 --- a/scripts/backtest-bun.py +++ b/scripts/backtest-bun.py @@ -139,9 +139,10 @@ def main(): choices=['hosted', 'vendored', 'vendored-detached']) parser.add_argument('--jobs', type=int, default=4) args = parser.parse_args() - cli = args.cli.resolve() root = args.output.resolve() root.mkdir(parents=True, exist_ok=True) + cli = root / ('socket-patch.exe' if platform.system() == 'Windows' else 'socket-patch') + shutil.copy2(args.cli.resolve(), cli) toolroot = (args.tools or root / 'tools').resolve() tools = {v: install_tool(toolroot, v) for v in args.versions} base_env = {k: v for k, v in os.environ.items() From d47526801d074e006d09b3440c515019829d0bf6 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 18 Sep 2026 13:51:37 -0400 Subject: [PATCH 03/49] Guard Bun manifests for explicit patch retrieval Apply Bun compatibility preflight to get by UUID as well as search. Exercise both entry points across the native release matrix. Assisted-by: Codex:gpt-6-astra --- crates/socket-patch-cli/src/commands/get.rs | 25 +++++++++++++++++++++ docs/testing/bun-compatibility.md | 2 ++ scripts/backtest-bun.py | 10 ++++++--- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index a2acb7c4..dab69b7b 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -2950,6 +2950,31 @@ async fn run_get_vendored_uuid( return 0; } + if patch.purl.starts_with("pkg:npm/") { + if let Err((code, message)) = + socket_patch_core::vendor::bun_lock::preflight_vendor(&args.common.cwd).await + { + if args.common.json { + print_json(&serde_json::json!({ + "status": "error", + "found": 1, + "downloaded": 0, + "failed": 1, + "error": { "code": code, "message": message }, + "patches": [{ + "purl": patch.purl, + "uuid": patch.uuid, + "action": "failed", + "errorCode": code, + }], + })); + } else if !args.common.silent { + eprintln!("Error ({code}): {message}"); + } + return 1; + } + } + note_vendored_whole_manifest_scope(&manifest_path, &[patch.purl.as_str()], quiet).await; let action = match save_patch_record(args, patch, false, false).await { diff --git a/docs/testing/bun-compatibility.md b/docs/testing/bun-compatibility.md index 580676c2..cf9d152b 100644 --- a/docs/testing/bun-compatibility.md +++ b/docs/testing/bun-compatibility.md @@ -25,6 +25,8 @@ Every supported case verifies: The runner captures the exact project manifests, lockfiles, optional `.socket/manifest.json`, CLI JSON, file hashes and assertion results. Socket SBOM tests import these captures through their existing fixture validation framework. Vendored artifact contents are verified by the native runner; they are not needed for SBOM lockfile annotation. +The `get-uuid` and `get-search` cases also exercise explicit patch retrieval by UUID and PURL, including refusal before manifest writes on unsupported Bun projects. + ## Boundaries verified by the matrix | Configuration | Behavior | diff --git a/scripts/backtest-bun.py b/scripts/backtest-bun.py index 66b82969..4e12e6b5 100644 --- a/scripts/backtest-bun.py +++ b/scripts/backtest-bun.py @@ -19,7 +19,8 @@ '1.2.0', '1.2.23', '1.3.0', '1.3.14', '1.4.0', '1.4.2'] SHAPES = ['direct', 'dev', 'optional', 'alias', 'transitive', 'two-versions', 'workspace', 'workspace-nested', 'peer', 'crlf', 'space-unicode', - 'custom-registry', 'text', 'isolated', 'hoisted', 'lockfile-only', 'production'] + 'custom-registry', 'text', 'isolated', 'hoisted', 'lockfile-only', 'production', + 'get-uuid', 'get-search'] PURL = 'pkg:npm/minimist@1.2.2' UUID = '80630680-4da6-45f9-bba8-b888e0ffd58c' @@ -183,7 +184,8 @@ def backtest(job): checks['installedBefore'] = bool(installed_targets(project)) if shape == 'lockfile-only': shutil.rmtree(project / 'node_modules') - command = [cli, 'scan', '--mode', 'vendored' if mode == 'vendored-detached' else mode, '--cwd', project, + verb = ['get', UUID if shape == 'get-uuid' else PURL] if shape.startswith('get-') else ['scan'] + command = [cli, *verb, '--mode', 'vendored' if mode == 'vendored-detached' else mode, '--cwd', project, '--json', '--yes', '--no-telemetry'] if mode == 'vendored-detached': command.append('--detached') @@ -197,6 +199,7 @@ def backtest(job): row['refusals'] = [w.get('code', w.get('errorCode')) for w in warnings] row['refusals'] += [p['errorCode'] for p in envelope.get('download', {}).get('patches', []) if p.get('errorCode')] + row['refusals'] += [p['errorCode'] for p in envelope.get('patches', []) if p.get('errorCode')] row['applied'] = applied if not checks['installedBefore'] and version in ['0.8.1', '1.0.0'] and shape in ['peer', 'transitive']: row['supported'] = False @@ -301,7 +304,8 @@ def backtest(job): jobs = [(v, s, m) for v in args.versions for s in args.shapes for m in args.modes if (s not in ('isolated', 'hoisted') or tuple(map(int, v.split('.'))) >= (1, 3, 0)) - and (s != 'text' or tuple(map(int, v.split('.'))) >= (1, 1, 38))] + and (s != 'text' or tuple(map(int, v.split('.'))) >= (1, 1, 38)) + and (not s.startswith('get-') or m != 'vendored-detached')] with concurrent.futures.ThreadPoolExecutor(max_workers=args.jobs) as pool: rows = list(pool.map(backtest, jobs)) save(root / 'summary.json', rows) From b316a06f368ad4d748d664a17f50831c040bc602 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 18 Sep 2026 17:54:42 -0400 Subject: [PATCH 04/49] fix(bun): claim and replay bun.lock hosted edits per purl in the takeover revert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `revert_npm_redirect_purl` hard-refused any `redirect_bun_lock_package` edit whose fragments mentioned the package ("cannot replay yet"), so on a bun project every hosted->vendored conversion (`scan --mode vendored`, `get --mode vendored`, `vendor`) exited 1 with `redirect_revert_failed`, and a scoped `rollback ` / `remove ` holding a second hosted record did the same — although the whole-ledger replay already inverted the edit kind. The refusal text prescribed `bun install` (a no-op: bun keeps a URL 3-tuple byte-identically) and hand-editing the ledger. The bun rewriter records the whole packages-entry line as `original` / `new` and keys the edit by the lock MAP key (`minimist`, a nested `other/minimist`, an install alias), never `name@version`. Ownership is therefore read from the recorded line's spec, exactly the field the rewriter matched on: a registry spec equal to `@`, or a hosted http(s) URL spec for `` whose last path segment is `-.tgz` (the leaf `tgz_rel_leaf` / `is_prior_hosted_bun_spec` agree on for scoped names). Sibling versions are foreign (never claimed, never a refusal); an edit that mentions the package but parses as no bun entry line refuses with the WORKING remedy (an unscoped `rollback`). Claimed edits replay through the same whole-fragment `replacen(new, original, 1)` path as the yarn/pnpm text kinds, with the same drift refusal, so a CRLF lock round-trips byte-exactly. Tests: the fail-closed refusal test becomes a success round-trip through the real rewriter; added sibling-version non-claim, scoped re-redirect chain claimed by spec+leaf (same-leaf `@other/pkg` and bare `pkg` untouched), drift refusal, CRLF round-trip, two-records-revert-one, dry-run, undecidable-edit remedy text, alias-keyed instance, and hand-restored no-op. rollback.rs: the `defer_bun` comment no longer describes a refusal. Co-Authored-By: Claude Fable 5.1 --- .../socket-patch-cli/src/commands/rollback.rs | 10 +- .../src/patch/redirect/takeover.rs | 702 ++++++++++++++++-- 2 files changed, 665 insertions(+), 47 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index a58cbe79..9ae88d37 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -644,9 +644,13 @@ pub(crate) async fn run_hosted_leg( }; let mut out = HostedLegOutcome::default(); - // bun.lock edits hard-refuse the per-purl npm revert; when the replay - // will run it owns them instead, so npm purls on bun projects defer - // rather than fail. + // When the whole-ledger replay will run anyway (the scope covers every + // record), npm purls on bun projects defer to it: the replay stages the + // bun group all-or-nothing together with the rideshare `bun.lockb` + // migration marker the per-purl revert never claims. A SCOPED unwind of + // one of several bun records takes the per-purl revert instead, which + // claims that purl's `redirect_bun_lock_package` edits by the recorded + // line's spec and replays them like the yarn/pnpm text kinds. let has_bun_edits = state .edits .iter() diff --git a/crates/socket-patch-core/src/patch/redirect/takeover.rs b/crates/socket-patch-core/src/patch/redirect/takeover.rs index e6fdf82a..a3b8918d 100644 --- a/crates/socket-patch-core/src/patch/redirect/takeover.rs +++ b/crates/socket-patch-core/src/patch/redirect/takeover.rs @@ -13,16 +13,20 @@ //! patches crates-io-sourced deps) and the project is unbuildable in both //! modes. //! -//! npm family (package-lock/npm-shrinkwrap, yarn classic, yarn berry, pnpm): -//! each recorded lock edit's `original` fragment is replayed over its `new` -//! fragment. Here the follow-up vendor rewire happens to succeed either way -//! (the vendored wiring replaces whatever resolution is present), but -//! WITHOUT the pre-revert the vendor ledger records the grant-tokenized -//! hosted fragment as its unrecoverable pre-vendor "original" (so `vendor -//! --revert` restores an expiring hosted URL with no CLI path back to -//! registry state), and the superseded redirect records/edits survive -//! forever — a stale ledger that VEX/audits keep reading and a replay hazard -//! for any later redirect revert. +//! npm family (package-lock/npm-shrinkwrap, yarn classic, yarn berry, pnpm, +//! bun): each recorded lock edit's `original` fragment is replayed over its +//! `new` fragment. For most flavors the follow-up vendor rewire happens to +//! succeed either way (the vendored wiring replaces whatever resolution is +//! present), but WITHOUT the pre-revert the vendor ledger records the +//! grant-tokenized hosted fragment as its unrecoverable pre-vendor +//! "original" (so `vendor --revert` restores an expiring hosted URL with no +//! CLI path back to registry state), and the superseded redirect +//! records/edits survive forever — a stale ledger that VEX/audits keep +//! reading and a replay hazard for any later redirect revert. bun is +//! stricter still: its hosted rewrite REPLACES the `name@version` spec the +//! bun vendor backend keys on (registry 4-tuple → URL 3-tuple), so without +//! the pre-revert the package cannot be vendored at all +//! (`vendor_lock_entry_not_found`). //! //! FAIL CLOSED: a file that matches neither the recorded redirected fragment //! nor the recorded original has drifted — the revert refuses (`Err`) rather @@ -348,14 +352,130 @@ fn parse_npm_purl(canon: &str) -> Option<(&str, &str)> { (!name.is_empty() && !version.is_empty()).then_some((name, version)) } -/// The npm-family text-fragment edit kinds: `original`/`new` hold the whole -/// lock fragment as a string, and the revert is a `replacen(new, original)`. +/// The npm-family text-fragment edit kinds CLAIMED BY KEY: `original`/`new` +/// hold the whole lock fragment as a string, the edit's `key` embeds +/// `@`, and the revert is a `replacen(new, original)`. const NPM_TEXT_KINDS: [&str; 3] = [ "redirect_yarn_classic_entry", "redirect_yarn_berry_entry", "redirect_pnpm_resolution", ]; +/// The bun hosted rewriter's edit kind (`rewrite_bun_lock`): `original`/`new` +/// hold the whole `packages` entry LINE, so it replays exactly like the +/// [`NPM_TEXT_KINDS`]. It is CLAIMED differently: bun edits key by the +/// lock's package map key — `minimist`, a nested `other/minimist`, or the +/// alias of an `alias@npm:minimist@1.2.2` install — never by +/// `name@version`, so ownership is read from the recorded line's spec +/// (`elems[0]`), the field the rewriter itself matched on. +const BUN_TEXT_KIND: &str = "redirect_bun_lock_package"; + +/// Does this edit kind replay as a whole text fragment +/// (`content.replacen(new, original, 1)`, fail-closed on drift)? +fn replays_as_text_fragment(kind: &str) -> bool { + NPM_TEXT_KINDS.contains(&kind) || kind == BUN_TEXT_KIND +} + +/// Ownership verdict for one [`BUN_TEXT_KIND`] edit. +#[derive(Debug, PartialEq)] +enum BunClaim { + /// The edit rewrote an instance of exactly this `name@version`. + Ours, + /// Another package, or another version of this one (a nested + /// `other/minimist` instance at 1.2.8 while reverting 1.2.2): not ours + /// to touch, and no reason to refuse. + Foreign, + /// The recorded fragments mention this package but neither one parses + /// under bun's entry grammar (hand-edited or truncated ledger), so + /// ownership cannot be decided. Deciding "foreign" would drop this + /// purl's record while stranding an edit that may be its own — half a + /// takeover — so the caller refuses. + Undecidable, +} + +/// Attribute a bun.lock edit to `name@version` the way the hosted rewriter +/// matched it: by the spec of the recorded line. +/// +/// A registry 4-tuple's spec is exactly `@`; a hosted URL +/// 3-tuple's spec is `@`. Either fragment may be the +/// hosted URL (a re-redirect chain records `original` = the PRIOR hosted +/// line, `new` = the current one), so both are consulted and one match +/// claims. The URL half is discriminated by version through its tarball +/// leaf — see [`hosted_url_names`] — never by the name substring alone, +/// which would claim a sibling version's edit and silently un-host it. +fn bun_edit_ownership(edit: &FileEdit, name: &str, version: &str) -> BunClaim { + use crate::vendor::bun_lock_text::{decode_json_string, parse_entry_line}; + fn fragment(v: &Option) -> Option<&str> { + v.as_ref().and_then(Value::as_str) + } + let spec_of = |line: &str| -> Option { + let entry = parse_entry_line(line).ok()?; + decode_json_string(entry.elems.first()?) + }; + let mut parsed_any = false; + for line in [fragment(&edit.original), fragment(&edit.new)] + .into_iter() + .flatten() + { + if let Some(spec) = spec_of(line) { + parsed_any = true; + if bun_spec_names(&spec, name, version) { + return BunClaim::Ours; + } + } + } + if parsed_any { + return BunClaim::Foreign; + } + // Neither fragment is a bun entry line. Only refuse when the raw text + // so much as mentions this package; an edit naming nothing of ours is + // someone else's problem and must not block this purl's takeover. + let probe = format!("\"{name}@"); + let mentions = |v: &Option| fragment(v).is_some_and(|s| s.contains(&probe)); + if mentions(&edit.original) || mentions(&edit.new) { + BunClaim::Undecidable + } else { + BunClaim::Foreign + } +} + +/// Is `spec` (a bun.lock entry's decoded `elems[0]`) the registry spec +/// `@` or a hosted artifact URL spec for that exact +/// `name@version`? +fn bun_spec_names(spec: &str, name: &str, version: &str) -> bool { + use crate::vendor::bun_lock_text::split_name_spec; + let Some((spec_name, rest)) = split_name_spec(spec) else { + return false; + }; + spec_name == name && (rest == version || hosted_url_names(rest, name, version)) +} + +/// True when `url` is an http(s) artifact URL whose last path segment is +/// `-.tgz` — the leaf every hosted artifact URL for this +/// `name@version` ends in. `` is the name without its `@scope/`: the +/// vendor path layer (`tgz_rel_leaf`) keeps a scope as a directory level +/// (`@scope/pkg-1.0.0.tgz`), and the hosted rewriter's prior-URL match +/// (`is_prior_hosted_bun_spec`) compares the same last path segment, so +/// `pkg-1.0.0.tgz` is the one spelling both agree on. Anything that fails +/// to parse fails the match (closed). The exact-leaf comparison is the +/// version discriminator: `pkg-1.3.0.tgz` never equals `pkg-11.3.0.tgz` +/// or `pkg-1.3.0-rc1.tgz`. +fn hosted_url_names(url: &str, name: &str, version: &str) -> bool { + if !url.starts_with("https://") && !url.starts_with("http://") { + return false; + } + let scheme_end = url + .find("://") + .expect("url starts with http(s):// — checked above") + + 3; + let Some(path_start) = url[scheme_end..].find('/').map(|i| i + scheme_end) else { + return false; + }; + let leaf = url[path_start..].rsplit('/').next().unwrap_or_default(); + let bare = name.rsplit('/').next().unwrap_or(name); + !leaf.is_empty() && leaf == format!("{bare}-{version}.tgz") +} + /// Revert every hosted-redirect edit the ledger records for `purl` (an npm /// package), then drop that purl's record and edits from `state`. The caller /// persists the mutated ledger (see `persist_redirect_state`). @@ -414,10 +534,13 @@ pub async fn revert_npm_redirect_purl( // SIBLING purl's edits (left-pad@1.2.0 vs @1.3.0 both hosted-redirected, // or `npm i name@npm:other` aliasing another package onto this key path) // and replaying those silently un-hosts the other purl while dropping - // its edits. A bun.lock edit that may belong to this purl is a hard - // refusal: bun edits key by the lock's package key (not name@version) - // and their revert is not implemented, so vendoring over one would drop - // the record while stranding its edits — half a takeover. + // its edits. bun edits key by the lock's package MAP key (`minimist`, + // nested `other/minimist`, an install alias) — never `name@version` — + // so they are attributed by the spec of the recorded line, the field + // the rewriter matched on (`bun_edit_ownership`); a sibling version's + // line is foreign, and a fragment that mentions the package but cannot + // be parsed at all refuses rather than guess (dropping the record while + // stranding a possibly-own edit would be half a takeover). let mut mine: Vec = Vec::new(); for (i, e) in state.edits.iter().enumerate() { let key = e.key.as_deref().unwrap_or_default(); @@ -481,23 +604,25 @@ pub async fn revert_npm_redirect_purl( } } } - "redirect_bun_lock_package" => { - let probe = format!("\"{name}@"); - let holds = |v: &Option| { - v.as_ref() - .and_then(Value::as_str) - .is_some_and(|s| s.contains(&probe)) - }; - if holds(&e.new) || holds(&e.original) { + BUN_TEXT_KIND => match bun_edit_ownership(e, &name, &version) { + BunClaim::Ours => true, + BunClaim::Foreign => false, + // The WORKING remedy is the whole-ledger replay: a plain + // `bun install` keeps a hosted URL tuple byte-identically + // (it re-locks nothing), and hand-editing the ledger is + // exactly what the hosted flow tells users never to do. + BunClaim::Undecidable => { return Err(format!( - "the redirect ledger records a bun.lock hosted redirect \ - for {name}, which this revert cannot replay yet; \ - restore the registry wiring manually (or re-lock with \ - `bun install`), remove the ledger entry, then re-run" + "the redirect ledger records a {} hosted redirect edit \ + that mentions {name} but is not a bun packages entry \ + line, so it cannot be attributed to {lock_key}; run an \ + unscoped `socket-patch rollback` (the whole-ledger \ + replay unwinds bun.lock hosted edits), then re-run; do \ + not edit .socket/vendor/redirect-state.json by hand", + e.path )); } - false - } + }, _ => false, }; if claimed { @@ -512,7 +637,11 @@ pub async fn revert_npm_redirect_purl( // previous step's `new`). for &i in mine.iter().rev() { let edit = state.edits[i].clone(); - if NPM_TEXT_KINDS.contains(&edit.kind.as_str()) { + if replays_as_text_fragment(&edit.kind) { + // Whole-fragment replay. For bun the fragments are whole lines + // (a CRLF lock's carry their trailing `\r`), so a + // `contains`/`replacen` on the raw content restores the line + // byte-exactly whatever the line ending. let (Some(new), Some(orig)) = ( edit.new.as_ref().and_then(Value::as_str), edit.original.as_ref().and_then(Value::as_str), @@ -2217,34 +2346,519 @@ mod tests { assert!(err.contains("records no hosted redirect"), "{err}"); } + // ── bun ────────────────────────────────────────────────────────────── + + /// Real text-lock grammar (bun 1.4.2 matrix capture, lockfileVersion 2; + /// the `packages` tuple grammar is identical on 0/1/2): the root + /// `left-pad@1.3.0` registry 4-tuple, a nested `haspad/left-pad` + /// instance at the SIBLING version 1.2.0, and an unrelated `other`. + fn bun_pristine() -> String { + r#"{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "takeover-fixture", + "dependencies": { + "haspad": "1.0.0", + "left-pad": "1.3.0", + "other": "1.0.0", + }, + }, + }, + "packages": { + "haspad": ["haspad@1.0.0", "", { "dependencies": { "left-pad": "^1.2.0" } }, "sha512-hh=="], + + "left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="], + + "other": ["other@1.0.0", "", {}, "sha512-oo=="], + + "haspad/left-pad": ["left-pad@1.2.0", "", {}, "sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg=="], + } +} +"# + .to_string() + } + + /// The packages-entry line keyed `key` (verbatim, without its line + /// terminator). + fn bun_line(lock: &str, key: &str) -> String { + let prefix = format!(" \"{key}\": ["); + lock.split('\n') + .find(|l| l.starts_with(&prefix)) + .unwrap_or_else(|| panic!("no `{key}` entry in:\n{lock}")) + .trim_end_matches('\r') + .to_string() + } + + async fn read_lock(root: &Path) -> String { + tokio::fs::read_to_string(root.join("bun.lock")) + .await + .unwrap() + } + + /// The bun revert used to be a hard refusal ("cannot replay yet"); + /// now the takeover claims the purl's `redirect_bun_lock_package` edit + /// by the recorded line's spec and replays the registry line back, + /// leaving the sibling-version and foreign entries untouched. + #[tokio::test] + async fn npm_bun_lock_takeover_restores_the_registry_line_and_drops_the_ledger() { + let (tmp, mut state) = npm_redirected_fixture("bun.lock", &bun_pristine()).await; + let root = tmp.path(); + let wired = read_lock(root).await; + assert!(wired.contains(NPM_URL), "fixture is hosted-wired:\n{wired}"); + assert_eq!( + state + .edits + .iter() + .filter(|e| e.kind == BUN_TEXT_KIND) + .count(), + 1, + "one bun edit for the one 1.3.0 instance: {:?}", + state.edits + ); + + let out = revert_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect("bun takeover revert succeeds"); + assert_eq!(out.reverted_files, vec!["bun.lock".to_string()]); + assert_eq!( + read_lock(root).await, + bun_pristine(), + "bun.lock restored byte-identical" + ); + assert!(state.records.is_empty(), "record dropped"); + assert!(state.edits.is_empty(), "edit consumed"); + } + + /// (a) A hosted edit for ANOTHER VERSION of the same package (the nested + /// `haspad/left-pad` at 1.2.0) is not this purl's to claim: reverting + /// 1.3.0 leaves the 1.2.0 line hosted and its record + edit in the + /// ledger. + #[tokio::test] + async fn npm_bun_sibling_version_edit_is_neither_claimed_nor_a_refusal() { + const SIBLING: &str = "pkg:npm/left-pad@1.2.0"; + let (tmp, mut state) = npm_redirected_fixture_multi( + "bun.lock", + &bun_pristine(), + &[ + (NPM_PURL, npm_dep()), + (SIBLING, npm_dep_for("left-pad", "1.2.0")), + ], + ) + .await; + let root = tmp.path(); + let wired = read_lock(root).await; + let sibling_line = bun_line(&wired, "haspad/left-pad"); + assert!( + sibling_line.contains("/left-pad/1.2.0/") + && sibling_line.contains("left-pad-1.2.0.tgz"), + "sibling instance is hosted-wired too: {sibling_line}" + ); + assert_eq!(state.edits.len(), 2, "{:?}", state.edits); + + revert_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect("takeover of 1.3.0 succeeds"); + let after = read_lock(root).await; + assert_eq!( + bun_line(&after, "left-pad"), + bun_line(&bun_pristine(), "left-pad"), + "the 1.3.0 line is back to its registry tuple" + ); + assert_eq!( + bun_line(&after, "haspad/left-pad"), + sibling_line, + "the sibling version's hosted line is untouched" + ); + assert_eq!(state.edits.len(), 1, "{:?}", state.edits); + assert_eq!(state.edits[0].key.as_deref(), Some("haspad/left-pad")); + assert!( + state.records.contains_key(SIBLING) && !state.records.contains_key(NPM_PURL), + "{:?}", + state.records.keys() + ); + } + + /// (b) Scoped package + re-redirect chain: the hosted rewrite destroys + /// the `@scope/pkg@1.0.0` spec, so the SECOND redirect (a rotated + /// artifact URL) records a hosted-URL line as its `original`. That edit + /// is claimed through the URL's tarball leaf (`pkg-1.0.0.tgz` — the + /// scope is a path level, not part of the basename) plus the full + /// scoped name in the spec; `@other/pkg` and bare `pkg`, whose leaves + /// are identical, stay hosted. Both links unwind newest-first to the + /// registry line. #[tokio::test] - async fn npm_bun_lock_edit_is_a_fail_closed_refusal() { - // The bun revert is not implemented; a ledger claiming this purl via - // a bun.lock edit must refuse rather than drop the record while - // stranding the edit. + async fn npm_bun_scoped_package_claims_the_re_redirect_chain_by_spec_and_leaf() { + const SCOPED: &str = "pkg:npm/%40scope/pkg@1.0.0"; + const OTHER_SCOPE: &str = "pkg:npm/%40other/pkg@1.0.0"; + const BARE: &str = "pkg:npm/pkg@1.0.0"; + let pristine = r#"{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "scoped-fixture", + "dependencies": { + "@other/pkg": "1.0.0", + "@scope/pkg": "1.0.0", + "pkg": "1.0.0", + }, + }, + }, + "packages": { + "@other/pkg": ["@other/pkg@1.0.0", "", {}, "sha512-o1=="], + + "@scope/pkg": ["@scope/pkg@1.0.0", "", {}, "sha512-s1=="], + + "pkg": ["pkg@1.0.0", "", {}, "sha512-p1=="], + } +} +"#; + let (tmp, mut state) = npm_redirected_fixture_multi( + "bun.lock", + pristine, + &[ + (SCOPED, npm_dep_for("@scope/pkg", "1.0.0")), + (OTHER_SCOPE, npm_dep_for("@other/pkg", "1.0.0")), + (BARE, npm_dep_for("pkg", "1.0.0")), + ], + ) + .await; + let root = tmp.path(); + let first = read_lock(root).await; + let first_scoped_line = bun_line(&first, "@scope/pkg"); + assert!( + first_scoped_line.contains("/@scope/pkg/1.0.0/"), + "{first_scoped_line}" + ); + + // Second redirect of ONLY @scope/pkg with a rotated artifact URL + // (same origin + leaf, different uuid path segment): the rewriter's + // prior-hosted match re-pins it and records the chain link. + let mut rotated = npm_dep_for("@scope/pkg", "1.0.0"); + rotated.artifact_url = rotated.artifact_url.replace("/6b7c/", "/7c8d/"); + let mut files: BTreeMap = BTreeMap::new(); + files.insert("bun.lock".into(), first.clone()); + let rewrite = crate::patch::redirect::rewrite_registry_redirect(&files, &[rotated]); + let second = rewrite + .files + .get("bun.lock") + .unwrap_or_else(|| panic!("re-redirect must rewrite: {:?}", rewrite.warnings)) + .clone(); + assert!( + bun_line(&second, "@scope/pkg").contains("/7c8d/"), + "{second}" + ); + tokio::fs::write(root.join("bun.lock"), &second) + .await + .unwrap(); + state.edits.extend(rewrite.edits); + assert_eq!(state.edits.len(), 4, "{:?}", state.edits); + let chain_link = state.edits.last().unwrap(); + assert!( + chain_link.original.as_ref().and_then(Value::as_str) + == Some(first_scoped_line.as_str()), + "the chain link's original is the PRIOR hosted line: {chain_link:?}" + ); + + revert_redirect_purl(root, &mut state, "pkg:npm/@scope/pkg@1.0.0", false) + .await + .expect("scoped takeover succeeds"); + let after = read_lock(root).await; + assert_eq!( + bun_line(&after, "@scope/pkg"), + bun_line(pristine, "@scope/pkg"), + "both chain links unwound to the registry tuple" + ); + assert_eq!( + bun_line(&after, "@other/pkg"), + bun_line(&first, "@other/pkg"), + "same-leaf scoped sibling stays hosted" + ); + assert_eq!( + bun_line(&after, "pkg"), + bun_line(&first, "pkg"), + "same-leaf bare sibling stays hosted" + ); + assert_eq!(state.edits.len(), 2, "{:?}", state.edits); + assert!( + state + .edits + .iter() + .all(|e| matches!(e.key.as_deref(), Some("@other/pkg") | Some("pkg"))), + "{:?}", + state.edits + ); + assert!( + !state.records.contains_key(SCOPED) + && state.records.contains_key(OTHER_SCOPE) + && state.records.contains_key(BARE), + "{:?}", + state.records.keys() + ); + } + + /// The claim rule in isolation: registry spec by exact version, hosted + /// URL spec by exact tarball leaf, full (scoped) name in every case; + /// anything else — sibling versions, local vendored paths, workspace + /// specs, URLs without a path — is not ours. + #[test] + fn bun_spec_names_discriminates_name_and_version() { + assert!(bun_spec_names("left-pad@1.3.0", "left-pad", "1.3.0")); + assert!(!bun_spec_names("left-pad@1.3.0-rc1", "left-pad", "1.3.0")); + assert!(!bun_spec_names("left-pad@11.3.0", "left-pad", "1.3.0")); + assert!(!bun_spec_names("left-pad@1.3.0", "other", "1.3.0")); + assert!(bun_spec_names( + &format!("left-pad@{NPM_URL}"), + "left-pad", + "1.3.0" + )); + assert!(!bun_spec_names( + "left-pad@http://127.0.0.1:5555/p/left-pad-11.3.0.tgz", + "left-pad", + "1.3.0" + )); + assert!(!bun_spec_names( + "left-pad@http://127.0.0.1:5555/p/left-pad-1.3.0-rc1.tgz", + "left-pad", + "1.3.0" + )); + // Vendored local path, workspace and origin-only specs are never + // hosted redirects. + assert!(!bun_spec_names( + "left-pad@.socket/vendor/npm/6b7c/left-pad-1.3.0.tgz", + "left-pad", + "1.3.0" + )); + assert!(!bun_spec_names( + "left-pad@workspace:packages/left-pad", + "left-pad", + "1.3.0" + )); + assert!(!bun_spec_names( + "left-pad@https://patch.socket.dev", + "left-pad", + "1.3.0" + )); + // Scoped: the leaf is the BARE basename whether the URL keeps the + // scope as a path level (production, test fixtures) or not; the + // full scoped name must match the spec's name. + assert!(bun_spec_names( + "@scope/pkg@https://patch.socket.dev/patch/npm/@scope/pkg/1.0.0/t/u/pkg-1.0.0.tgz", + "@scope/pkg", + "1.0.0" + )); + assert!(bun_spec_names( + "@scope/pkg@http://127.0.0.1:5555/patch/npm/@scope/pkg/1.0.0/tok/6b7c/@scope/pkg-1.0.0.tgz", + "@scope/pkg", + "1.0.0" + )); + assert!(!bun_spec_names( + "@other/pkg@https://h/patch/npm/@other/pkg/1.0.0/t/u/pkg-1.0.0.tgz", + "@scope/pkg", + "1.0.0" + )); + assert!(!bun_spec_names( + "pkg@https://h/patch/npm/pkg/1.0.0/t/u/pkg-1.0.0.tgz", + "@scope/pkg", + "1.0.0" + )); + assert!(bun_spec_names("@scope/pkg@1.0.0", "@scope/pkg", "1.0.0")); + } + + /// (c) Drift: the line was re-resolved by a third party since the + /// redirect (neither the hosted nor the registry line is present) — + /// the same fail-closed refusal the yarn/pnpm text kinds give, with the + /// file and ledger left exactly as found. + #[tokio::test] + async fn npm_bun_drifted_line_refuses_fail_closed() { + let (tmp, mut state) = npm_redirected_fixture("bun.lock", &bun_pristine()).await; + let root = tmp.path(); + let wired = read_lock(root).await; + let drifted = wired.replace( + &bun_line(&wired, "left-pad"), + " \"left-pad\": [\"left-pad@https://corp.example/mirror/left-pad-1.3.0.tgz\", {}, \"sha512-corp==\"],", + ); + assert_ne!(drifted, wired); + tokio::fs::write(root.join("bun.lock"), &drifted) + .await + .unwrap(); + let records_before = state.records.len(); + let edits_before = state.edits.len(); + + let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect_err("drifted line must refuse"); + assert!(err.contains("drifted"), "{err}"); + assert!(err.contains("bun.lock"), "{err}"); + assert_eq!(read_lock(root).await, drifted, "file untouched"); + assert_eq!(state.records.len(), records_before); + assert_eq!(state.edits.len(), edits_before); + } + + /// (d) CRLF lock: the recorded lines carry (or, for a rewriter that + /// normalized the rewritten line, lack) a trailing `\r`; the whole-line + /// replace restores the pristine CRLF bytes either way. + #[tokio::test] + async fn npm_bun_crlf_lock_round_trips_byte_exact() { + let pristine = bun_pristine().replace('\n', "\r\n"); + let (tmp, mut state) = npm_redirected_fixture("bun.lock", &pristine).await; + let root = tmp.path(); + let wired = read_lock(root).await; + assert!(wired.contains(NPM_URL), "{wired}"); + assert!( + wired.contains("\r\n"), + "CRLF preserved elsewhere: {wired:?}" + ); + + revert_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect("CRLF takeover succeeds"); + assert_eq!( + read_lock(root).await, + pristine, + "CRLF bun.lock restored byte-identical" + ); + assert!(state.records.is_empty() && state.edits.is_empty()); + } + + /// (e) Two hosted records (different packages): the takeover of one + /// restores only its line and keeps the other purl's record + edit — + /// the state a scoped `rollback ` / `remove ` needs. + #[tokio::test] + async fn npm_bun_two_hosted_records_takeover_of_one_leaves_the_other_hosted() { + const OTHER: &str = "pkg:npm/other@1.0.0"; + let (tmp, mut state) = npm_redirected_fixture_multi( + "bun.lock", + &bun_pristine(), + &[ + (NPM_PURL, npm_dep()), + (OTHER, npm_dep_for("other", "1.0.0")), + ], + ) + .await; + let root = tmp.path(); + let wired = read_lock(root).await; + let other_line = bun_line(&wired, "other"); + assert!(other_line.contains("other-1.0.0.tgz"), "{other_line}"); + + let out = revert_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect("takeover succeeds"); + assert_eq!(out.reverted_files, vec!["bun.lock".to_string()]); + let after = read_lock(root).await; + assert_eq!( + bun_line(&after, "left-pad"), + bun_line(&bun_pristine(), "left-pad") + ); + assert_eq!(bun_line(&after, "other"), other_line, "other stays hosted"); + assert_eq!(state.edits.len(), 1); + assert_eq!(state.edits[0].key.as_deref(), Some("other")); + assert_eq!(state.records.len(), 1); + assert!(state.records.contains_key(OTHER)); + + // Taking over the second one finishes the job. + revert_redirect_purl(root, &mut state, OTHER, false) + .await + .expect("second takeover succeeds"); + assert_eq!(read_lock(root).await, bun_pristine()); + assert!(state.records.is_empty() && state.edits.is_empty()); + } + + /// bun's dry run mirrors the cargo/yarn contract: every inverse and + /// drift check resolves, nothing reaches disk, the in-memory ledger is + /// claimed, and the preview names the files a wet run rewrites. + #[tokio::test] + async fn npm_bun_dry_run_previews_without_touching_disk() { + let (tmp, mut state) = npm_redirected_fixture("bun.lock", &bun_pristine()).await; + let root = tmp.path(); + let wired = read_lock(root).await; + + let dry = revert_redirect_purl(root, &mut state, NPM_PURL, true) + .await + .expect("dry-run revert succeeds"); + assert_eq!(dry.reverted_files, vec!["bun.lock".to_string()]); + assert_eq!(read_lock(root).await, wired, "disk untouched"); + assert!(state.records.is_empty(), "record claimed in memory"); + assert!(state.edits.is_empty(), "edit claimed in memory"); + } + + /// A hand-edited ledger whose bun fragments mention the package but are + /// not entry lines cannot be attributed: refuse with the WORKING remedy + /// (the whole-ledger `rollback` replay) — never `bun install`, which + /// keeps a hosted URL tuple byte-identically — and keep the ledger. + #[tokio::test] + async fn npm_bun_unparseable_edit_mentioning_the_package_refuses_with_the_rollback_remedy() { let tmp = tempfile::tempdir().unwrap(); let mut state = RedirectState::new(); state.records.insert(NPM_PURL.to_string(), record()); state.edits.push(FileEdit { path: "bun.lock".into(), - kind: "redirect_bun_lock_package".into(), + kind: BUN_TEXT_KIND.into(), action: "rewritten".into(), key: Some("left-pad".into()), - original: Some(Value::String( - " \"left-pad\": [\"left-pad@1.3.0\", \"reg\", {}, \"sha512-p==\"],".into(), - )), - new: Some(Value::String(format!( - " \"left-pad\": [\"left-pad@{NPM_URL}\", {{}}, \"sha512-h==\"]," - ))), + original: Some(Value::String("\"left-pad@1.3.0\" (truncated".into())), + new: Some(Value::String(format!("\"left-pad@{NPM_URL}\" (truncated"))), }); let err = revert_npm_redirect_purl(tmp.path(), &mut state, NPM_PURL, false) .await - .expect_err("bun edits must refuse"); - assert!(err.contains("bun.lock"), "{err}"); + .expect_err("undecidable bun edit must refuse"); + assert!(err.contains("unscoped `socket-patch rollback`"), "{err}"); + assert!(err.contains("do not edit"), "{err}"); + assert!(!err.contains("bun install"), "{err}"); assert!(!state.records.is_empty(), "ledger keeps the record"); assert!(!state.edits.is_empty(), "ledger keeps the edit"); } + /// An alias install (`bun add alias@npm:left-pad@1.3.0`) keys the entry + /// by the alias; the rewriter matched it by spec, and so does the + /// claim. + #[tokio::test] + async fn npm_bun_alias_keyed_instance_is_claimed_by_spec() { + let pristine = r#"{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "alias-fixture", + "dependencies": { + "alias": "npm:left-pad@1.3.0", + }, + }, + }, + "packages": { + "alias": ["left-pad@1.3.0", "", {}, "sha512-XI5M=="], + } +} +"#; + let (tmp, mut state) = npm_redirected_fixture("bun.lock", pristine).await; + let root = tmp.path(); + assert_eq!(state.edits[0].key.as_deref(), Some("alias")); + revert_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect("alias takeover succeeds"); + assert_eq!(read_lock(root).await, pristine); + assert!(state.records.is_empty() && state.edits.is_empty()); + } + + /// A user hand-restored bun.lock (git checkout): the revert is a clean + /// no-op that still drops the ledger entries. + #[tokio::test] + async fn npm_bun_hand_restored_lock_is_a_noop_that_drops_the_ledger() { + let (tmp, mut state) = npm_redirected_fixture("bun.lock", &bun_pristine()).await; + let root = tmp.path(); + tokio::fs::write(root.join("bun.lock"), bun_pristine()) + .await + .unwrap(); + let out = revert_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect("revert succeeds"); + assert!(out.reverted_files.is_empty(), "{:?}", out.reverted_files); + assert_eq!(read_lock(root).await, bun_pristine()); + assert!(state.records.is_empty() && state.edits.is_empty()); + } + // ── refusal / degenerate arms of the fail-closed contract ──────────── #[tokio::test] From 3730d83b49d42d19946f965a88c2871668773b4f Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 18 Sep 2026 17:54:42 -0400 Subject: [PATCH 05/49] fix(vendor): probe the hosted takeover on dry runs instead of promising it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dry-run arm of the cross-mode gate emitted `vendor_would_revert_redirect` for every purl the redirect ledger claimed without ever asking the per-purl revert whether it would succeed, so `vendor --dry-run` promised a takeover the wet run could refuse (`redirect_revert_failed` on drift or a corrupt edit). It now runs `revert_redirect_purl(.., dry_run = true)` on a throwaway ledger clone — write-free, same inverses and drift checks — and surfaces a refusal with the SAME code and detail the wet run emits. On success, when the probe would rewrite bun.lock the preview stops after the advisory (which already states the whole plan: revert, then vendor): the bun backend reads the lock from disk, where the hosted URL 3-tuple has replaced the `name@version` spec it keys on, so previewing over it would emit a `vendor_lock_entry_not_found` the wet run never sees. Flavors whose hosted rewrite keeps the entry identity (yarn, pnpm, package-lock) preview exactly as before. Co-Authored-By: Claude Fable 5.1 --- .../socket-patch-cli/src/commands/vendor.rs | 80 ++++++++++++++++--- 1 file changed, 67 insertions(+), 13 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 24a4b6f1..6ef58149 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -1071,20 +1071,74 @@ pub(crate) async fn vendor_records( .as_ref() .is_some_and(|l| l.records.keys().any(|k| canon(k) == canon(candidate))); if claimed && common.dry_run { - record_warning( - env, + // Probe the takeover exactly as the wet run would — the + // per-purl revert's dry run resolves every inverse and + // drift check, flushes nothing, and mutates only this + // throwaway clone — so the preview never promises a + // takeover the wet run then refuses (a drifted lock, a + // corrupt edit): those surface here with the SAME + // `redirect_revert_failed` code and detail. + let mut probe = redirect_ledger.clone().expect("claimed implies Some"); + match socket_patch_core::patch::redirect::revert_redirect_purl( + &common.cwd, + &mut probe, candidate, - &VendorWarning::new( - "vendor_would_revert_redirect", - format!( - "{} is hosted-redirected; a non-dry-run vendor will \ - revert the hosted redirect edits first, then vendor \ - (mode takeover)", - normalize_purl(candidate) - ), - ), - common, - ); + true, + ) + .await + { + Ok(revert) => { + record_warning( + env, + candidate, + &VendorWarning::new( + "vendor_would_revert_redirect", + format!( + "{} is hosted-redirected; a non-dry-run vendor will \ + revert the hosted redirect edits first, then vendor \ + (mode takeover)", + normalize_purl(candidate) + ), + ), + common, + ); + // The backend preview below reads the lock from + // disk, where the hosted wiring is still live. A + // flavor whose hosted rewrite keeps the entry's + // `name@version` identity (yarn, pnpm, package-lock) + // previews fine over it; bun's hosted rewrite + // REPLACES that spec, so the backend would refuse a + // `vendor_lock_entry_not_found` the wet run never + // sees. When the revert would rewrite a lock this + // backend reads, the advisory already states the + // whole plan (revert, then vendor) and the preview + // stops here — the hosted dry run makes the same + // choice after `redirect_would_revert_vendored`. + if revert.reverted_files.iter().any(|f| f == "bun.lock") { + continue; + } + } + Err(detail) => { + has_errors = true; + env.record( + PatchEvent::new(PatchAction::Failed, candidate.clone()).with_error( + "redirect_revert_failed", + format!( + "cannot vendor over the live hosted redirect: \ + {detail}" + ), + ), + ); + if !common.silent && !common.json { + eprintln!( + "Cannot vendor {}: cannot revert the hosted redirect: \ + {detail}", + normalize_purl(candidate) + ); + } + continue; + } + } } else if claimed { let ledger = redirect_ledger.as_mut().expect("claimed implies Some"); match socket_patch_core::patch::redirect::revert_redirect_purl( From 53d92b821627d26daee3457aa4653ade2a7b1488 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 18 Sep 2026 17:54:42 -0400 Subject: [PATCH 06/49] test(bun): hermetic hosted->vendored takeover, dry-run and scoped unwind CLI suite Drives the built binary against a wiremock patch API over a real bun 1.4.2 lockfileVersion-2 lock (matrix-capture grammar; no bun binary needed): 1. scan --mode hosted -> scan --mode vendored succeeds with `vendor_takeover_reverted_redirect`, the redirect ledger record is dropped, bun.lock carries the `.socket/vendor/npm//` 3-tuple and no hosted URL, state.json records the PRISTINE registry line as the wiring original, a re-run is `already_vendored`, and `vendor --revert` restores the pristine bytes. 2. vendor --dry-run over the live hosted redirect previews the takeover (`vendor_would_revert_redirect`, no `vendor_lock_entry_not_found`, no writes); the wet vendor completes it. 3./4. Two hosted records: scoped `rollback ` and `remove ` (per-purl path, replay not eligible) unwind only the targeted line and record; the sibling stays hosted. Co-Authored-By: Claude Fable 5.1 --- .../tests/in_process_vendor_bun_takeover.rs | 693 ++++++++++++++++++ 1 file changed, 693 insertions(+) create mode 100644 crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs new file mode 100644 index 00000000..3295b021 --- /dev/null +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs @@ -0,0 +1,693 @@ +//! Hermetic bun mode-takeover contract tests through the built binary. +//! +//! Twin of the yarn legs in `mode_migration_npm.rs` and the pnpm +//! `hosted_to_vendor_conversion` module in `in_process_vendor.rs`, for the +//! one npm-family lock flavor whose hosted→vendored takeover used to be a +//! hard refusal (`redirect_revert_failed`, "cannot replay yet"): the bun +//! hosted rewrite REPLACES the registry 4-tuple's `name@version` spec with +//! a URL 3-tuple, so without the per-purl pre-revert the bun vendor backend +//! cannot even find the entry. The API is wiremock; no `bun` binary is +//! needed (the lock grammar is the real bun 1.4.2 lockfileVersion-2 shape +//! from the compatibility matrix captures, and the packages tuple grammar +//! is identical on versions 0/1/2). +//! +//! Scenarios: +//! 1. `scan --mode hosted` → `scan --mode vendored`: the takeover reverts +//! the hosted line, drops the redirect-ledger record, vendors from the +//! pristine registry line (the vendor ledger's `original` is the +//! REGISTRY tuple, never the hosted URL), and `vendor --revert` +//! restores the pristine bytes. +//! 2. `vendor --dry-run` over the live hosted redirect previews the +//! takeover (`vendor_would_revert_redirect`) with no false +//! `vendor_lock_entry_not_found` follow-up and no writes; the wet +//! `vendor` then completes it. +//! 3. Two hosted records: a SCOPED `rollback ` (per-purl path, the +//! whole-ledger replay is not eligible) unwinds only the targeted line +//! and record; the sibling stays hosted. +//! 4. Same ledger, `remove `. +//! +//! Every child process gets the ambient `SOCKET_*` vars scrubbed and +//! telemetry hard-disabled; each test runs in its own tempdir. + +use std::path::Path; +use std::process::Command; + +use base64::Engine as _; +use serde_json::{json, Value}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ORG: &str = "test-org"; +const NAME: &str = "left-pad"; +const VERSION: &str = "1.3.0"; +const PURL: &str = "pkg:npm/left-pad@1.3.0"; +/// Canonical-grammar patch uuid (the vendor path layer validates the uuid +/// path level fail-closed). +const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; +const HOSTED_URL: &str = "http://patch.test/patch/npm/left-pad/1.3.0/55555555-5555-4555-8555-555555555555/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz"; +const PATCHED_SHA512: &str = "sha512-PATCHEDpatchedPATCHEDpatched0123456789=="; +const ORIG_INDEX: &[u8] = b"module.exports = () => 'orig';\n"; +const PATCHED_INDEX: &[u8] = b"module.exports = () => 'patched';\n"; + +/// The second hosted record of the scoped-unwind scenarios. +const OTHER_NAME: &str = "other"; +const OTHER_PURL: &str = "pkg:npm/other@1.0.0"; +const OTHER_UUID: &str = "0a1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d"; +const OTHER_HOSTED_URL: &str = "http://patch.test/patch/npm/other/1.0.0/55555555-5555-4555-8555-555555555555/0a1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d/other-1.0.0.tgz"; + +/// The registry 4-tuple lines exactly as bun 1.4.2 emits them (matrix +/// capture grammar; the `""` registry field is the default registry). +const LEFT_PAD_REGISTRY_LINE: &str = r#" "left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="],"#; +const OTHER_REGISTRY_LINE: &str = + r#" "other": ["other@1.0.0", "", {}, "sha512-otherOTHERother0123456789=="],"#; + +/// Pristine lockfileVersion-2 bun.lock for a root-only project depending on +/// `left-pad@1.3.0` (real bun 1.4.2 shape: `configVersion`, the root +/// workspace block with trailing commas, one packages entry per line). +fn pristine_lock() -> String { + format!( + "{{\n \"lockfileVersion\": 2,\n \"configVersion\": 1,\n \"workspaces\": {{\n \"\": {{\n \"name\": \"bun-takeover-fixture\",\n \"dependencies\": {{\n \"left-pad\": \"1.3.0\",\n }},\n }},\n }},\n \"packages\": {{\n{LEFT_PAD_REGISTRY_LINE}\n }}\n}}\n" + ) +} + +/// Pristine lock with TWO registry entries (`left-pad@1.3.0`, `other@1.0.0`). +fn pristine_lock_two() -> String { + format!( + "{{\n \"lockfileVersion\": 2,\n \"configVersion\": 1,\n \"workspaces\": {{\n \"\": {{\n \"name\": \"bun-takeover-fixture\",\n \"dependencies\": {{\n \"left-pad\": \"1.3.0\",\n \"other\": \"1.0.0\",\n }},\n }},\n }},\n \"packages\": {{\n{LEFT_PAD_REGISTRY_LINE}\n\n{OTHER_REGISTRY_LINE}\n }}\n}}\n" + ) +} + +/// The URL 3-tuple line the hosted rewriter writes for `key`. +fn hosted_line(key: &str, name: &str, url: &str, sha512: &str) -> String { + format!(" \"{key}\": [\"{name}@{url}\", {{}}, \"{sha512}\"],") +} + +/// The packages-entry line keyed `key` (verbatim, no line terminator). +fn lock_line(lock: &str, key: &str) -> String { + let prefix = format!(" \"{key}\": ["); + lock.split('\n') + .find(|l| l.starts_with(&prefix)) + .unwrap_or_else(|| panic!("no `{key}` entry in:\n{lock}")) + .trim_end_matches('\r') + .to_string() +} + +fn read(root: &Path, rel: &str) -> String { + std::fs::read_to_string(root.join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}")) +} + +// ───────────────────────────── fixture ───────────────────────────── + +/// package.json + the installed (unpatched) copy under node_modules + the +/// given bun.lock. +fn write_bun_project(root: &Path, lock: &str, deps: &[(&str, &str)]) { + let dep_map: serde_json::Map = deps + .iter() + .map(|(n, v)| (n.to_string(), Value::String(v.to_string()))) + .collect(); + std::fs::write( + root.join("package.json"), + serde_json::to_vec_pretty(&json!({ + "name": "bun-takeover-fixture", + "version": "1.0.0", + "private": true, + "dependencies": Value::Object(dep_map), + })) + .unwrap(), + ) + .unwrap(); + for (name, version) in deps { + let pkg = root.join("node_modules").join(name); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{"name":"{name}","version":"{version}"}}"#), + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), ORIG_INDEX).unwrap(); + } + std::fs::write(root.join("bun.lock"), lock).unwrap(); +} + +fn patch_record(uuid: &str) -> Value { + json!({ + "uuid": uuid, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": compute_git_sha256_from_bytes(ORIG_INDEX), + "afterHash": compute_git_sha256_from_bytes(PATCHED_INDEX), + } + }, + "vulnerabilities": {}, + "description": "bun takeover fixture", + "license": "MIT", + "tier": "free" + }) +} + +/// `.socket/manifest.json` + the after-hash blob, so `vendor --offline` +/// runs fully offline (hosted mode writes no manifest — its ledger is its +/// store). +fn seed_manifest_and_blob(root: &Path) { + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let mut bytes = + serde_json::to_vec_pretty(&json!({ "patches": { PURL: patch_record(UUID) } })).unwrap(); + bytes.push(b'\n'); + std::fs::write(socket.join("manifest.json"), &bytes).unwrap(); + std::fs::write( + socket + .join("blobs") + .join(compute_git_sha256_from_bytes(PATCHED_INDEX)), + PATCHED_INDEX, + ) + .unwrap(); +} + +/// The full hosted-mode API mock set for the one patch over `PURL` +/// (discovery + by-package + grant + view). The view carries the patched +/// file's `blobContent`, so `scan --mode vendored`'s download phase can +/// stage the blob it vendors from. +async fn mock_api(server: &MockServer) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "packages": [{ + "purl": PURL, + "patches": [{ + "uuid": UUID, "purl": PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "bun takeover fixture" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "patches": [{ + "uuid": UUID, "purl": PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": { + UUID: { + "status": "granted", + "url": HOSTED_URL, + "purl": PURL, + "artifacts": [{ + "kind": "tarball", + "url": HOSTED_URL, + "integrity": { "sha512": PATCHED_SHA512 } + }], + "registryOverride": null + } + } + }))) + .mount(server) + .await; + let mut view = patch_record(UUID); + view["purl"] = json!(PURL); + view["publishedAt"] = json!("2024-01-01T00:00:00Z"); + view["files"]["package/index.js"]["blobContent"] = + json!(base64::engine::general_purpose::STANDARD.encode(PATCHED_INDEX)); + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(view)) + .mount(server) + .await; +} + +// ───────────────────────── subprocess runner ───────────────────────── + +/// Run the built `socket-patch` binary with every ambient `SOCKET_*` var +/// scrubbed (except the hermetic `SOCKET_NO_CONFIG`) and telemetry +/// hard-disabled. Returns `(exit_code, stdout, stderr)`. +fn run_cli(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + cmd.args(args).current_dir(cwd); + for (key, _) in std::env::vars() { + if key.starts_with("SOCKET_") && key != "SOCKET_NO_CONFIG" { + cmd.env_remove(key); + } + } + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + let out = cmd.output().expect("spawn socket-patch binary"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +/// `--json` invocation returning the parsed envelope. +fn run_json(cwd: &Path, args: &[&str]) -> (i32, Value) { + let (code, stdout, stderr) = run_cli(cwd, args); + // The child's stderr rides the harness's captured output so a failing + // assertion downstream shows the CLI's own diagnostics. + if !stderr.trim().is_empty() { + println!("[{}] stderr:\n{stderr}", args.first().unwrap_or(&"?")); + } + let env: Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("{args:?} must emit a JSON envelope: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}") + }); + (code, env) +} + +fn scan_mode(cwd: &Path, api_url: &str, mode: &str, extra: &[&str]) -> (i32, Value) { + let mut args = vec![ + "scan", + "--mode", + mode, + "--json", + "--yes", + "--api-url", + api_url, + "--api-token", + "fake", + "--org", + ORG, + "--cwd", + cwd.to_str().unwrap(), + ]; + args.extend_from_slice(extra); + run_json(cwd, &args) +} + +fn vendor_cli(cwd: &Path, extra: &[&str]) -> (i32, Value) { + let mut args = vec![ + "vendor", + "--json", + "--offline", + "--cwd", + cwd.to_str().unwrap(), + ]; + args.extend_from_slice(extra); + run_json(cwd, &args) +} + +fn events(envelope: &Value) -> &Vec { + envelope["events"].as_array().expect("events array") +} + +/// The first event matching `action` (+ `errorCode` when given; a plain +/// `applied` carries `errorCode: null`). +fn find_event<'a>(envelope: &'a Value, action: &str, error_code: Option<&str>) -> &'a Value { + events(envelope) + .iter() + .find(|e| e["action"] == action && error_code.is_none_or(|c| e["errorCode"] == c)) + .unwrap_or_else(|| panic!("expected a `{action}`/`{error_code:?}` event in:\n{envelope:#}")) +} + +fn assert_no_event_code(envelope: &Value, error_code: &str) { + assert!( + events(envelope) + .iter() + .all(|e| e["errorCode"] != error_code), + "unexpected `{error_code}` event in:\n{envelope:#}" + ); +} + +/// The vendored local-tarball 3-tuple bun must end up with. +fn vendored_rel_tgz() -> String { + format!(".socket/vendor/npm/{UUID}/{NAME}-{VERSION}.tgz") +} + +/// Assertions shared by the takeover scenarios once the wet vendored run +/// has happened: the redirect ledger no longer claims the purl, bun.lock +/// carries the local tuple and no hosted residue, and the vendor ledger's +/// recorded `original` is the PRISTINE registry line. +fn assert_pure_vendored(root: &Path) { + match std::fs::read_to_string(root.join(".socket/vendor/redirect-state.json")) { + Ok(text) => { + let ledger: Value = serde_json::from_str(&text).unwrap(); + assert!( + ledger["records"].get(PURL).is_none(), + "the superseded redirect record must be dropped: {ledger:#}" + ); + assert!( + ledger["edits"] + .as_array() + .is_none_or(|edits| edits.iter().all(|e| e["key"] != NAME)), + "the superseded bun.lock edit must be dropped: {ledger:#}" + ); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // An emptied ledger is deleted — the expected outcome here. + } + Err(e) => panic!("unreadable redirect ledger: {e}"), + } + + let lock = read(root, "bun.lock"); + assert!( + !lock.contains(HOSTED_URL) && !lock.contains("patch.test"), + "the hosted URL must be gone from bun.lock:\n{lock}" + ); + let line = lock_line(&lock, NAME); + assert!( + line.contains(&format!("\"{NAME}@{}\"", vendored_rel_tgz())), + "bun.lock must carry the local vendored 3-tuple:\n{line}" + ); + assert!( + root.join(vendored_rel_tgz()).is_file(), + "the committed artifact must exist" + ); + + let state: Value = serde_json::from_str(&read(root, ".socket/vendor/state.json")).unwrap(); + let wiring = state["entries"][PURL]["wiring"] + .as_array() + .unwrap_or_else(|| panic!("wiring array: {state:#}")); + let lock_wiring = wiring + .iter() + .find(|w| w["kind"] == "bun_lock_package") + .unwrap_or_else(|| panic!("bun_lock_package wiring record: {state:#}")); + assert_eq!( + lock_wiring["original"], + json!(LEFT_PAD_REGISTRY_LINE), + "the vendor ledger must record the PRISTINE registry line as its original \ + (never the grant-tokenized hosted URL line): {state:#}" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// 1. scan --mode hosted → scan --mode vendored → vendor --revert +// ───────────────────────────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread")] +async fn bun_hosted_then_scan_vendored_takeover_round_trips_to_registry() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_bun_project(root, &pristine_lock(), &[(NAME, VERSION)]); + let pristine = std::fs::read(root.join("bun.lock")).unwrap(); + + // A: hosted redirect — registry 4-tuple → URL 3-tuple, ledger claims + // the purl with one `redirect_bun_lock_package` edit whose original + // is the registry line. + let (code, env) = scan_mode(root, &server.uri(), "hosted", &[]); + assert_eq!(code, 0, "scan --mode hosted must succeed: {env:#}"); + assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + let hosted_lock = read(root, "bun.lock"); + assert_eq!( + lock_line(&hosted_lock, NAME), + hosted_line(NAME, NAME, HOSTED_URL, PATCHED_SHA512), + "hosted URL 3-tuple written:\n{hosted_lock}" + ); + let ledger: Value = + serde_json::from_str(&read(root, ".socket/vendor/redirect-state.json")).unwrap(); + assert!( + ledger["records"].get(PURL).is_some(), + "hosted run must record the purl: {ledger:#}\nhosted envelope: {env:#}" + ); + let edit = &ledger["edits"][0]; + assert_eq!(edit["kind"], "redirect_bun_lock_package", "{ledger:#}"); + assert_eq!( + edit["original"], + json!(LEFT_PAD_REGISTRY_LINE), + "{ledger:#}" + ); + + // The scan-side vendored preview is ledger-only by contract + // (`would_vendor` / `already_vendored` / `would_revendor`, CLI_CONTRACT + // "scan --vendor"): it must at least not fail and not write anything. + let (code, preview) = scan_mode(root, &server.uri(), "vendored", &["--dry-run"]); + assert_eq!(code, 0, "vendored preview must succeed: {preview:#}"); + assert_eq!( + read(root, "bun.lock"), + hosted_lock, + "a dry run must not touch bun.lock" + ); + assert!( + !root.join(".socket/vendor/state.json").exists(), + "a dry run must not create the vendor ledger" + ); + + // B: vendored scan over the LIVE hosted redirect — the takeover. Used + // to exit 1 with `redirect_revert_failed` ("cannot replay yet"). + let (code, env) = scan_mode(root, &server.uri(), "vendored", &[]); + assert_eq!( + code, 0, + "scan --mode vendored over the hosted bun project must succeed: {env:#}" + ); + assert_eq!(env["status"], "success", "{env:#}"); + let vendor = &env["vendor"]; + assert_eq!(vendor["summary"]["applied"], 1, "{env:#}"); + assert_eq!(vendor["summary"]["failed"], 0, "{env:#}"); + find_event(vendor, "skipped", Some("vendor_takeover_reverted_redirect")); + find_event(vendor, "applied", None); + assert_no_event_code(vendor, "redirect_revert_failed"); + assert_pure_vendored(root); + let manifest: Value = serde_json::from_str(&read(root, ".socket/manifest.json")).unwrap(); + assert_eq!(manifest["patches"][PURL]["uuid"], UUID, "{manifest:#}"); + + // C: a re-run is an in-sync no-op with no second takeover. + let (code, env) = scan_mode(root, &server.uri(), "vendored", &[]); + assert_eq!(code, 0, "{env:#}"); + find_event(&env["vendor"], "skipped", Some("already_vendored")); + assert_no_event_code(&env["vendor"], "vendor_takeover_reverted_redirect"); + + // D: `vendor --revert` restores the REGISTRY lock byte-exactly — the + // pre-redirect resolution the takeover carried forward, not the + // hosted splice. + let (code, env) = vendor_cli(root, &["--revert"]); + assert_eq!(code, 0, "revert must succeed: {env:#}"); + assert_eq!( + std::fs::read(root.join("bun.lock")).unwrap(), + pristine, + "bun.lock must be restored byte-identical to the pristine registry lock; got:\n{}", + read(root, "bun.lock") + ); + assert!( + !root.join(".socket/vendor").exists(), + ".socket/vendor must be fully pruned after the revert" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// 2. vendor --dry-run over the live hosted redirect, then the wet vendor +// ───────────────────────────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread")] +async fn bun_vendor_dry_run_previews_the_takeover_then_wet_vendor_completes_it() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_bun_project(root, &pristine_lock(), &[(NAME, VERSION)]); + + let (code, env) = scan_mode(root, &server.uri(), "hosted", &[]); + assert_eq!(code, 0, "{env:#}"); + let hosted_lock = std::fs::read(root.join("bun.lock")).unwrap(); + let ledger_path = root.join(".socket/vendor/redirect-state.json"); + let hosted_ledger = std::fs::read(&ledger_path).unwrap(); + + // The manifest record `vendor` acts on (offline: the staged blob). + seed_manifest_and_blob(root); + + // Dry run: the takeover is PROBED (write-free) and previewed; the + // backend preview does not run against the still-hosted lock, so no + // false `vendor_lock_entry_not_found` and no refusal. Nothing written. + let (code, env) = vendor_cli(root, &["--dry-run"]); + assert_eq!(code, 0, "vendor --dry-run must succeed: {env:#}"); + let advisory = find_event(&env, "skipped", Some("vendor_would_revert_redirect")); + assert_eq!(advisory["purl"], PURL, "{env:#}"); + assert_no_event_code(&env, "vendor_lock_entry_not_found"); + assert_no_event_code(&env, "redirect_revert_failed"); + assert_eq!( + env["summary"]["failed"], 0, + "the preview must not report a failure: {env:#}" + ); + assert_eq!( + std::fs::read(root.join("bun.lock")).unwrap(), + hosted_lock, + "a dry run must not touch bun.lock" + ); + assert_eq!( + std::fs::read(&ledger_path).unwrap(), + hosted_ledger, + "a dry run must not touch the redirect ledger" + ); + assert!( + !root.join(".socket/vendor/state.json").exists(), + "a dry run must not create the vendor ledger" + ); + + // Wet `vendor`: the takeover the preview promised. + let (code, env) = vendor_cli(root, &[]); + assert_eq!(code, 0, "vendor must succeed: {env:#}"); + assert_eq!(env["summary"]["applied"], 1, "{env:#}"); + find_event(&env, "skipped", Some("vendor_takeover_reverted_redirect")); + find_event(&env, "applied", None); + assert_pure_vendored(root); +} + +// ───────────────────────────────────────────────────────────────────── +// 3./4. scoped rollback / remove of ONE of two hosted bun records +// ───────────────────────────────────────────────────────────────────── + +/// A hosted-live bun project with TWO redirect records, written exactly as +/// the hosted flow leaves them (ledger edits = verbatim lines, lock = the +/// URL 3-tuples). Two records make a scoped unwind of one purl ineligible +/// for the whole-ledger replay, so it takes the per-purl revert — the path +/// that used to refuse for bun. +fn write_two_record_hosted_project(root: &Path) -> String { + let pristine = pristine_lock_two(); + write_bun_project(root, &pristine, &[(NAME, VERSION), (OTHER_NAME, "1.0.0")]); + let left_pad_hosted = hosted_line(NAME, NAME, HOSTED_URL, PATCHED_SHA512); + let other_hosted = hosted_line( + OTHER_NAME, + OTHER_NAME, + OTHER_HOSTED_URL, + "sha512-otherPATCHED==", + ); + let hosted = pristine + .replace(LEFT_PAD_REGISTRY_LINE, &left_pad_hosted) + .replace(OTHER_REGISTRY_LINE, &other_hosted); + assert_ne!(hosted, pristine); + std::fs::write(root.join("bun.lock"), &hosted).unwrap(); + let ledger = json!({ + "version": 1, + "mode": "hosted", + "edits": [ + { + "path": "bun.lock", + "kind": "redirect_bun_lock_package", + "action": "rewritten", + "key": NAME, + "original": LEFT_PAD_REGISTRY_LINE, + "new": left_pad_hosted, + }, + { + "path": "bun.lock", + "kind": "redirect_bun_lock_package", + "action": "rewritten", + "key": OTHER_NAME, + "original": OTHER_REGISTRY_LINE, + "new": other_hosted, + } + ], + "records": { + PURL: patch_record(UUID), + OTHER_PURL: patch_record(OTHER_UUID), + } + }); + std::fs::create_dir_all(root.join(".socket/vendor")).unwrap(); + std::fs::write( + root.join(".socket/vendor/redirect-state.json"), + serde_json::to_vec_pretty(&ledger).unwrap(), + ) + .unwrap(); + pristine +} + +/// After unwinding ONLY `left-pad`: its line is the registry tuple, `other` +/// is still hosted, and the ledger keeps exactly `other`'s record + edit. +fn assert_only_left_pad_unwound(root: &Path, pristine: &str) { + let lock = read(root, "bun.lock"); + assert_eq!( + lock_line(&lock, NAME), + lock_line(pristine, NAME), + "left-pad back to its registry tuple:\n{lock}" + ); + assert!( + lock_line(&lock, OTHER_NAME).contains(OTHER_HOSTED_URL), + "other must stay hosted:\n{lock}" + ); + let ledger: Value = + serde_json::from_str(&read(root, ".socket/vendor/redirect-state.json")).unwrap(); + assert!( + ledger["records"].get(PURL).is_none() && ledger["records"].get(OTHER_PURL).is_some(), + "{ledger:#}" + ); + let edits = ledger["edits"].as_array().unwrap(); + assert_eq!(edits.len(), 1, "{ledger:#}"); + assert_eq!(edits[0]["key"], OTHER_NAME, "{ledger:#}"); +} + +#[test] +fn bun_scoped_rollback_of_one_of_two_hosted_records_unwinds_only_that_purl() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let pristine = write_two_record_hosted_project(root); + + // Scoped rollback: per-purl path (two records ⇒ the replay is not + // eligible). Used to exit 1 with hosted.failed = ["cannot replay yet"]. + let (code, env) = run_json( + root, + &[ + "rollback", + PURL, + "--yes", + "--json", + "--cwd", + root.to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "scoped rollback must succeed: {env:#}"); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!(env["hosted"]["reverted"], json!([PURL]), "{env:#}"); + assert_eq!(env["hosted"]["failed"], json!([]), "{env:#}"); + assert_only_left_pad_unwound(root, &pristine); + + // The last record out: covers every record ⇒ whole-ledger replay; + // pristine lock, ledger deleted. + let (code, env) = run_json( + root, + &[ + "rollback", + OTHER_PURL, + "--yes", + "--json", + "--cwd", + root.to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "{env:#}"); + assert_eq!(read(root, "bun.lock"), pristine, "pristine lock restored"); + assert!( + !root.join(".socket/vendor/redirect-state.json").exists(), + "emptied ledger deleted" + ); +} + +#[test] +fn bun_scoped_remove_of_one_of_two_hosted_records_unwinds_only_that_purl() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let pristine = write_two_record_hosted_project(root); + + // `remove ` takes the same per-purl hosted leg; used to exit 1 + // with `hosted_revert_failed`. + let (code, env) = run_json( + root, + &[ + "remove", + PURL, + "--yes", + "--json", + "--cwd", + root.to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "scoped remove must succeed: {env:#}"); + assert!( + env["error"].is_null(), + "no top-level error expected: {env:#}" + ); + assert_only_left_pad_unwound(root, &pristine); +} From 65cb920a5ac689266ef0238c7981d7d80ad8cf9a Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 18 Sep 2026 17:49:15 -0400 Subject: [PATCH 07/49] =?UTF-8?q?fix(process):=20shared=20PATH=20tool=20re?= =?UTF-8?q?solver=20=E2=80=94=20absolute=20entries=20only,=20PATHEXT=20+?= =?UTF-8?q?=20cmd.exe=20shims;=20pipenv=20uses=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift resolve_on_path / is_executable / is_batch_shim / the cmd.exe /C launcher out of utils/pipenv.rs into utils::process as resolve_tool / resolve_tool_with / command_for / tool_command, so every tool the CLI spawns inside a scanned project (bun, pipenv) skips relative PATH entries (a repo-planted binary) and finds .cmd/.bat shims on Windows. pipenv.rs keeps its thin wrapper; behaviour and its tests are unchanged. Co-Authored-By: Claude Fable 5.1 --- crates/socket-patch-core/src/utils/pipenv.rs | 71 +---- crates/socket-patch-core/src/utils/process.rs | 277 ++++++++++++++++++ 2 files changed, 288 insertions(+), 60 deletions(-) diff --git a/crates/socket-patch-core/src/utils/pipenv.rs b/crates/socket-patch-core/src/utils/pipenv.rs index e0e50225..1cde9c9e 100644 --- a/crates/socket-patch-core/src/utils/pipenv.rs +++ b/crates/socket-patch-core/src/utils/pipenv.rs @@ -6,6 +6,10 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; +// Only the Windows PATHEXT test below asserts shim detection. +#[cfg(all(test, windows))] +use crate::utils::process::is_batch_shim; + /// Pins the answer without spawning anything: CI images without pipenv on /// PATH, or a project installed with a different release than the machine's /// default pipenv. @@ -44,60 +48,11 @@ fn parse_major(output: &str) -> Option { /// (`.`, an empty component) would execute a `pipenv` planted in the /// repository being scanned. On Windows every `PATHEXT` extension is tried, /// so `pipenv.exe` and the `pipenv.bat` / `pipenv.cmd` shims (pyenv-win, -/// hand-written wrappers) are both found. +/// hand-written wrappers) are both found. The rule lives in +/// [`crate::utils::process::resolve_tool_with`], shared with every other +/// tool the CLI spawns inside a project (`bun`). fn resolve_on_path(var: &impl Fn(&str) -> Option) -> Option { - let path = var("PATH")?; - let extensions: Vec = if cfg!(windows) { - var("PATHEXT") - .map(|value| { - value - .to_string_lossy() - .split(';') - .filter(|ext| !ext.is_empty()) - .map(|ext| ext.to_ascii_lowercase()) - .collect::>() - }) - .filter(|list| !list.is_empty()) - .unwrap_or_else(|| vec![".exe".into(), ".bat".into(), ".cmd".into()]) - } else { - vec![String::new()] - }; - for dir in std::env::split_paths(&path) { - if !dir.is_absolute() { - continue; - } - for ext in &extensions { - let candidate = dir.join(format!("pipenv{ext}")); - if candidate.is_file() && is_executable(&candidate) { - return Some(candidate); - } - } - } - None -} - -/// A plain file that cannot be executed (a stray `pipenv` data file on PATH) -/// is skipped in favour of the next entry, like execvp does; Windows has no -/// mode bits, PATHEXT is the executability rule there. -fn is_executable(path: &Path) -> bool { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::metadata(path).is_ok_and(|m| m.permissions().mode() & 0o111 != 0) - } - #[cfg(not(unix))] - { - let _ = path; - true - } -} - -fn is_batch_shim(program: &Path) -> bool { - cfg!(windows) - && program.extension().is_some_and(|ext| { - let ext = ext.to_string_lossy().to_ascii_lowercase(); - ext == "bat" || ext == "cmd" - }) + crate::utils::process::resolve_tool_with("pipenv", var) } /// The major of the `pipenv` on PATH (`11`, `2018`, `2026`, …), or `None` @@ -112,13 +67,9 @@ pub async fn installed_major(root: &Path) -> Option { return Some(forced); } let program = resolve_on_path(&|name| std::env::var_os(name))?; - let mut command = if is_batch_shim(&program) { - let mut command = tokio::process::Command::new("cmd.exe"); - command.arg("/C").arg(&program); - command - } else { - tokio::process::Command::new(&program) - }; + // `.bat` / `.cmd` shims launch through `cmd.exe /C`, real executables + // directly — the shared launcher decides. + let mut command = tokio::process::Command::from(crate::utils::process::command_for(&program)); // The version banner does not depend on a project, so the probe runs in a // NEUTRAL directory: with the scanned repository as cwd, Pipenv would read // its `.env`, `Pipfile` and `.venv` pointer — committed, attacker-shaped diff --git a/crates/socket-patch-core/src/utils/process.rs b/crates/socket-patch-core/src/utils/process.rs index 68d8302e..c6062318 100644 --- a/crates/socket-patch-core/src/utils/process.rs +++ b/crates/socket-patch-core/src/utils/process.rs @@ -16,8 +16,113 @@ //! production callers either build the helper with the default //! runner or thread a singleton. +use std::ffi::OsString; +use std::path::{Path, PathBuf}; use std::process::Command; +/// The executable `name` on ABSOLUTE `PATH` entries only, or `None` when +/// no entry holds one. +/// +/// Shared by every tool the CLI spawns with the scanned project as its +/// working directory (`bun`, `pipenv`): a relative `PATH` component (`.`, +/// an empty string) resolves against the child's cwd, so a bare +/// `Command::new("bun")` would execute a `bun` planted in the repository +/// being scanned — and on macOS `posix_spawnp` can run BOTH the planted +/// file and the next absolute entry's binary for one spawn. Skipping +/// non-absolute entries closes that; callers must then spawn the RESOLVED +/// path, never the bare name. +/// +/// On Windows every `PATHEXT` extension is tried (falling back to +/// `.exe`/`.bat`/`.cmd` when the variable is unset or empty), so an npm-global +/// `bun.cmd` / pyenv-win `pipenv.bat` shim is found where Rust's own +/// `Command` resolution — which appends only `.exe` — would report NotFound. +/// A plain file without execute permission is skipped like execvp does. +pub fn resolve_tool(name: &str) -> Option { + resolve_tool_with(name, &|var| std::env::var_os(var)) +} + +/// [`resolve_tool`] over an injected environment reader (tests). +pub(crate) fn resolve_tool_with( + name: &str, + var: &impl Fn(&str) -> Option, +) -> Option { + let path = var("PATH")?; + let extensions: Vec = if cfg!(windows) { + var("PATHEXT") + .map(|value| { + value + .to_string_lossy() + .split(';') + .filter(|ext| !ext.is_empty()) + .map(|ext| ext.to_ascii_lowercase()) + .collect::>() + }) + .filter(|list| !list.is_empty()) + .unwrap_or_else(|| vec![".exe".into(), ".bat".into(), ".cmd".into()]) + } else { + vec![String::new()] + }; + for dir in std::env::split_paths(&path) { + if !dir.is_absolute() { + continue; + } + for ext in &extensions { + let candidate = dir.join(format!("{name}{ext}")); + if candidate.is_file() && is_executable(&candidate) { + return Some(candidate); + } + } + } + None +} + +/// A plain file that cannot be executed (a stray `bun` data file on PATH) +/// is skipped in favour of the next entry, like execvp does; Windows has no +/// mode bits, PATHEXT is the executability rule there. +fn is_executable(path: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path).is_ok_and(|m| m.permissions().mode() & 0o111 != 0) + } + #[cfg(not(unix))] + { + let _ = path; + true + } +} + +/// `.bat` / `.cmd` files are not executables: `CreateProcess` refuses them, +/// so they must be launched through `cmd.exe /C`. Always false off Windows. +pub(crate) fn is_batch_shim(program: &Path) -> bool { + cfg!(windows) + && program.extension().is_some_and(|ext| { + let ext = ext.to_string_lossy().to_ascii_lowercase(); + ext == "bat" || ext == "cmd" + }) +} + +/// A [`Command`] that launches the RESOLVED `program`: directly for a real +/// executable, through `cmd.exe /C` for a Windows batch shim. Callers add +/// their own args / cwd / env; `tokio::process::Command::from` lifts it into +/// the async runtime unchanged. +pub fn command_for(program: &Path) -> Command { + if is_batch_shim(program) { + let mut command = Command::new("cmd.exe"); + command.arg("/C").arg(program); + command + } else { + Command::new(program) + } +} + +/// [`resolve_tool`] + [`command_for`]: the command for the tool `name` found +/// on an absolute PATH entry, or `None` when there is none — the caller +/// decides how "not installed" degrades (a warning, a refusal). +pub fn tool_command(name: &str) -> Option { + resolve_tool(name).map(|program| command_for(&program)) +} + /// Run an external binary with the given args and return its /// stdout, trimmed, when the spawn succeeded AND the process exited /// with a success status AND stdout is non-empty after trimming. @@ -150,4 +255,176 @@ mod tests { let out = runner.run("sh", &["-c", "printf '%s' \"$1\"", "sh", "forwarded"]); assert_eq!(out.as_deref(), Some("forwarded")); } + + // ───────────────────────── resolve_tool / tool_command ───────────────────────── + + /// Mark an existing file executable (no-op off Unix: PATHEXT rules there). + fn set_executable(path: &Path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + #[cfg(not(unix))] + let _ = path; + } + + /// An empty executable file — enough for the resolver, which only + /// stats candidates. + fn make_executable(path: &Path) { + std::fs::write(path, b"").unwrap(); + set_executable(path); + } + + /// The load-bearing rule: `.`, the empty component and a bare relative + /// dir name never resolve — only the absolute entry wins, even when it + /// comes LAST. A repo-planted `bun` under a relative entry is ignored. + #[test] + fn resolve_tool_skips_relative_entries_and_finds_absolute_ones() { + let tmp = tempfile::tempdir().unwrap(); + let bin = tmp.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let leaf = if cfg!(windows) { "bun.exe" } else { "bun" }; + make_executable(&bin.join(leaf)); + let planted = tmp.path().join("planted"); + std::fs::create_dir_all(&planted).unwrap(); + make_executable(&planted.join(leaf)); + let joined = std::env::join_paths([ + PathBuf::from("."), + PathBuf::from(""), + PathBuf::from("planted"), + bin.clone(), + ]) + .unwrap(); + let var = |name: &str| (name == "PATH").then(|| joined.clone()); + assert_eq!(resolve_tool_with("bun", &var), Some(bin.join(leaf))); + + let only_relative = + std::env::join_paths([PathBuf::from("."), PathBuf::from("planted")]).unwrap(); + let var = |name: &str| (name == "PATH").then(|| only_relative.clone()); + assert_eq!(resolve_tool_with("bun", &var), None); + let none = |_: &str| None::; + assert_eq!(resolve_tool_with("bun", &none), None); + } + + /// The name is honoured exactly: a `bunx` beside no `bun` is not `bun`, + /// and a directory named `bun` is not a program. + #[test] + fn resolve_tool_matches_the_exact_leaf_only() { + let tmp = tempfile::tempdir().unwrap(); + let bin = tmp.path().join("bin"); + std::fs::create_dir_all(bin.join("bun")).unwrap(); + let leaf = if cfg!(windows) { "bunx.exe" } else { "bunx" }; + make_executable(&bin.join(leaf)); + let joined = std::env::join_paths([bin.clone()]).unwrap(); + let var = |name: &str| (name == "PATH").then(|| joined.clone()); + assert_eq!(resolve_tool_with("bun", &var), None); + assert_eq!(resolve_tool_with("bunx", &var), Some(bin.join(leaf))); + } + + /// A non-executable data file squatting the name is skipped in favour of + /// the next entry's real program (execvp semantics). + #[cfg(unix)] + #[test] + fn resolve_tool_skips_non_executable_files() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let data = tmp.path().join("data"); + let bin = tmp.path().join("bin"); + std::fs::create_dir_all(&data).unwrap(); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(data.join("bun"), b"not a program").unwrap(); + std::fs::set_permissions(data.join("bun"), std::fs::Permissions::from_mode(0o644)).unwrap(); + make_executable(&bin.join("bun")); + let joined = std::env::join_paths([data.clone(), bin.clone()]).unwrap(); + let var = |name: &str| (name == "PATH").then(|| joined.clone()); + assert_eq!(resolve_tool_with("bun", &var), Some(bin.join("bun"))); + } + + /// Off Windows nothing is a batch shim (a `bun.cmd` file on a Unix PATH + /// is just a file) and the command spawns the resolved path directly — + /// the program is the absolute path, not the bare name. + #[cfg(unix)] + #[test] + fn command_for_spawns_the_resolved_path_directly_on_unix() { + let tmp = tempfile::tempdir().unwrap(); + let bin = tmp.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let shim = bin.join("bun"); + std::fs::write(&shim, "#!/bin/sh\nprintf 'resolved:%s' \"$0\"\n").unwrap(); + set_executable(&shim); + assert!(!is_batch_shim(&bin.join("bun.cmd"))); + let joined = std::env::join_paths([bin.clone()]).unwrap(); + let var = |name: &str| (name == "PATH").then(|| joined.clone()); + let program = resolve_tool_with("bun", &var).expect("the shim resolves"); + let command = command_for(&program); + assert_eq!(command.get_program(), shim.as_os_str()); + assert_eq!( + command.get_args().count(), + 0, + "no cmd.exe wrapper off Windows" + ); + let out = command_for(&program).output().expect("spawn the shim"); + assert_eq!( + String::from_utf8_lossy(&out.stdout), + format!("resolved:{}", shim.display()), + "the child sees its own absolute path as argv[0]" + ); + } + + /// Windows: every PATHEXT extension is tried (case-insensitively), so a + /// `.cmd`/`.bat` shim is found and launched through `cmd.exe /C`; an + /// unset PATHEXT falls back to the exe/bat/cmd triple. + #[cfg(windows)] + #[test] + fn resolve_tool_honours_pathext_and_wraps_batch_shims() { + let tmp = tempfile::tempdir().unwrap(); + let bin = tmp.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(bin.join("bun.cmd"), b"@echo off\r\necho 1.2.3\r\n").unwrap(); + let joined = std::env::join_paths([bin.clone()]).unwrap(); + let with_pathext = |name: &str| match name { + "PATH" => Some(joined.clone()), + "PATHEXT" => Some(OsString::from(".COM;.EXE;.BAT;.CMD")), + _ => None, + }; + let found = resolve_tool_with("bun", &with_pathext).expect("bun.cmd resolves via PATHEXT"); + assert_eq!(found, bin.join("bun.cmd")); + assert!(is_batch_shim(&found)); + let command = command_for(&found); + assert_eq!(command.get_program(), std::ffi::OsStr::new("cmd.exe")); + let args: Vec = command.get_args().map(|a| a.to_os_string()).collect(); + assert_eq!( + args, + vec![OsString::from("/C"), found.clone().into_os_string()] + ); + + // PATHEXT unset → the default triple still finds the shim. + let no_pathext = |name: &str| (name == "PATH").then(|| joined.clone()); + assert_eq!( + resolve_tool_with("bun", &no_pathext), + Some(bin.join("bun.cmd")) + ); + + // A PATHEXT that does NOT list .cmd hides the shim (Windows semantics). + let exe_only = |name: &str| match name { + "PATH" => Some(joined.clone()), + "PATHEXT" => Some(OsString::from(".EXE")), + _ => None, + }; + assert_eq!(resolve_tool_with("bun", &exe_only), None); + + // A real .exe is spawned directly, never through cmd.exe. + std::fs::write(bin.join("bun.exe"), b"").unwrap(); + let exe = resolve_tool_with("bun", &exe_only).expect("bun.exe resolves"); + assert!(!is_batch_shim(&exe)); + assert_eq!(command_for(&exe).get_program(), exe.as_os_str()); + } + + /// `tool_command` reads the REAL environment: a name that cannot be on + /// any PATH yields None (the caller's "not installed" arm). + #[test] + fn tool_command_is_none_for_an_absent_tool() { + assert!(tool_command("definitely-not-a-real-binary-1234567").is_none()); + } } From 8f28ab64185c0f8fb4996d274ea83d2e68251672 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 18 Sep 2026 17:49:15 -0400 Subject: [PATCH 08/49] fix(bun): rollback restores bun.lockb from the redirect ledger Inverse::BunLockbMigrated now decodes the pre-migration bytes (standard base64 in FileEdit.original) and writes bun.lockb back through the crate's atomic writer, leaving the migrated bun.lock in place, with the informational redirect_bun_lockb_restored warning. Without captured bytes the honest redirect_bun_lockb_unrestorable fires only when bun.lockb is actually absent; a present-but-different lock is never clobbered. The migration record now obeys the ledger path-safety rule because the replay writes its path. Co-Authored-By: Claude Fable 5.1 --- .../src/patch/redirect/replay.rs | 388 +++++++++++++++++- 1 file changed, 369 insertions(+), 19 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/replay.rs b/crates/socket-patch-core/src/patch/redirect/replay.rs index 938676af..94d8344f 100644 --- a/crates/socket-patch-core/src/patch/redirect/replay.rs +++ b/crates/socket-patch-core/src/patch/redirect/replay.rs @@ -71,8 +71,14 @@ enum Inverse { /// The pnpm `trustLockfile` auto-config (kind-specific: `created` /// deletes the scaffold, `added` removes exactly one line). PnpmTrust, - /// bun.lockb was migrated to a text bun.lock; the binary original was - /// never captured (git history is the restore path). Warn and drop. + /// bun.lockb was migrated to a text bun.lock during the redirect and + /// removed. When the writer captured the pre-migration bytes (standard + /// base64 in `original`, see the hosted flow's `LOCKB_ORIGINAL_CAP`) the + /// binary lock is written back atomically; the generated text bun.lock is + /// always left in place (its fragments replay back to registry tuples in + /// the same group). Without captured bytes — a ledger from before the + /// capture, or an oversize lock — the inverse warns only when bun.lockb is + /// actually absent on disk, and drops the edit either way. BunLockbMigrated, /// Owned by a per-purl revert (npm JSON kinds). Present here only /// when that revert failed — refuse the group rather than guess. @@ -248,10 +254,32 @@ async fn read_rel(project_root: &Path, rel: &str) -> Result, Stri Ok(Some(content)) } +/// Byte twin of [`read_rel`] for the binary bun.lockb restore — same FIFO +/// guard, `Ok(None)` when the file is absent. +async fn read_rel_bytes(project_root: &Path, rel: &str) -> Result>, String> { + use tokio::io::AsyncReadExt; + let path = project_root.join(rel); + let (mut file, metadata) = match crate::utils::fs::open_regular_file(&path).await { + Ok(pair) => pair, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(format!("read {rel}: {e}")), + }; + let mut content = Vec::with_capacity(metadata.len() as usize); + file.read_to_end(&mut content) + .await + .map_err(|e| format!("read {rel}: {e}"))?; + Ok(Some(content)) +} + /// Files the group's unwind has decided but not yet written: /// `Some(content)` to write, `None` to delete. type Staged = BTreeMap>; +/// Binary files the group's unwind restores byte-for-byte (the migrated +/// bun.lockb) — flushed through the crate's atomic writer after the text +/// stage, so a torn write can never leave a half-restored binary lock. +type StagedBytes = BTreeMap>; + async fn staged_read( staged: &Staged, project_root: &Path, @@ -342,6 +370,7 @@ pub async fn revert_remaining_redirect_edits( 'group: for (group, indices) in &groups { let mut staged: Staged = BTreeMap::new(); + let mut staged_bytes: StagedBytes = BTreeMap::new(); let mut group_drops: BTreeSet = BTreeSet::new(); let mut group_warnings: Vec<(String, String)> = Vec::new(); let files: BTreeSet = indices @@ -362,10 +391,8 @@ pub async fn revert_remaining_redirect_edits( for &idx in indices.iter().rev() { let edit = state.edits[idx].clone(); let (_, inverse) = classify(&edit.kind, &edit.action); - if !matches!( - inverse, - Inverse::NoopDrop | Inverse::BunLockbMigrated | Inverse::Unsupported - ) && !safe_rel_path(&edit.path) + if !matches!(inverse, Inverse::NoopDrop | Inverse::Unsupported) + && !safe_rel_path(&edit.path) { refuse( format!("ledger edit for {} has an unsafe path", edit.kind), @@ -380,13 +407,68 @@ pub async fn revert_remaining_redirect_edits( group_drops.insert(idx); } Inverse::BunLockbMigrated => { - group_warnings.push(( - "redirect_bun_lockb_unrestorable".into(), - "bun.lockb was migrated to a text bun.lock during the redirect and \ - its binary content was not captured — restore bun.lockb from git \ - history if the binary format is required" - .into(), - )); + // The pre-migration bytes, when the writer captured them + // (standard base64 in `original`). An undecodable payload + // (tampered / torn ledger) degrades like an absent one: + // refusing the whole bun group over the binary sibling + // would also block the bun.lock fragment restore, which + // is the part that un-redirects the install. + let recorded: Option> = str_payload(&edit.original).and_then(|b64| { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.decode(b64).ok() + }); + let on_disk = match read_rel_bytes(project_root, &edit.path).await { + Ok(current) => current, + Err(e) => { + refuse(e, &mut outcome); + refused_groups.insert(group); + continue 'group; + } + }; + match (recorded, on_disk) { + (Some(bytes), None) => { + staged_bytes.insert(edit.path.clone(), bytes); + group_warnings.push(( + "redirect_bun_lockb_restored".into(), + "bun.lockb was restored from the redirect ledger; the text \ + bun.lock generated during the redirect was left in place \ + (Bun ≥ 1.1.39 reads bun.lock when both exist) — delete \ + whichever lockfile you do not want" + .into(), + )); + } + // Already at the pre-redirect bytes (an interrupted + // earlier revert, a hand restore) — nothing to do. + (Some(bytes), Some(current)) if current == bytes => {} + // A DIFFERENT bun.lockb has appeared since (the user + // re-locked with an old bun): never clobber it. + (Some(_), Some(_)) => { + group_warnings.push(( + "redirect_bun_lockb_unrestorable".into(), + format!( + "{} already exists and differs from the pre-redirect \ + bytes recorded in the redirect ledger; it was left \ + untouched — restore it from git history if the recorded \ + binary lock is the one you want", + edit.path + ), + )); + } + (None, None) => { + group_warnings.push(( + "redirect_bun_lockb_unrestorable".into(), + "bun.lockb was migrated to a text bun.lock during the redirect \ + and its binary content was not captured — restore bun.lockb \ + from git history if the binary format is required" + .into(), + )); + } + // Present without captured bytes: the binary lock the + // redirect found is still there (bun 1.1.4x kept it and + // a pre-capture CLI recorded the removal anyway) — + // nothing to restore, nothing to say. + (None, Some(_)) => {} + } group_drops.insert(idx); } Inverse::PerPurlOnly => { @@ -417,9 +499,8 @@ pub async fn revert_remaining_redirect_edits( } Inverse::PipenvEntry => { let restored = match staged_read(&staged, project_root, &edit.path).await { - Ok(Some(content)) => { - super::pipenv::restore(&content, &edit).map(|restored| (content, restored)) - } + Ok(Some(content)) => super::pipenv::restore(&content, &edit) + .map(|restored| (content, restored)), Ok(None) => Err(format!("{} no longer exists", edit.path)), Err(error) => Err(error), }; @@ -679,10 +760,27 @@ pub async fn revert_remaining_redirect_edits( continue 'group; } } + // Binary restores go through the atomic writer (stage + fsync + + // rename): a torn bun.lockb is worse than none — bun ≤ 1.1.38 + // would read it. + for (rel, bytes) in &staged_bytes { + let path = project_root.join(rel); + if let Ok(meta) = tokio::fs::symlink_metadata(&path).await { + if !meta.is_file() { + refuse(format!("{rel} is not a regular file"), &mut outcome); + refused_groups.insert(group); + continue 'group; + } + } + if let Err(e) = crate::utils::fs::atomic_write_bytes(&path, bytes).await { + refuse(format!("write {rel}: {e}"), &mut outcome); + refused_groups.insert(group); + continue 'group; + } + } } - outcome - .reverted_files - .extend(staged.keys().cloned()); + outcome.reverted_files.extend(staged.keys().cloned()); + outcome.reverted_files.extend(staged_bytes.keys().cloned()); pending_warnings.extend(group_warnings); drop_indices.extend(group_drops); } @@ -1542,6 +1640,257 @@ mod tests { assert!(state.edits.is_empty()); } + // ---------- bun.lockb migration: byte restore from the ledger ---------- + + const LOCKB_BYTES: &[u8] = b"\x00BUN-BINARY\xff\xfe\x00LOCK"; + + fn lockb_edit(original: Option) -> FileEdit { + FileEdit { + path: "bun.lockb".into(), + kind: "redirect_bun_lockb_migrated".into(), + action: "removed".into(), + key: None, + original, + new: None, + } + } + + fn lockb_b64() -> Value { + use base64::Engine as _; + Value::String(base64::engine::general_purpose::STANDARD.encode(LOCKB_BYTES)) + } + + fn codes(out: &ReplayOutcome) -> Vec<&str> { + out.warnings.iter().map(|(c, _)| c.as_str()).collect() + } + + /// Captured bytes + bun.lockb absent (bun ≥ 1.2 deleted it, or the CLI + /// removed the 1.1.4x leftover): the binary lock comes back + /// byte-identical, the informational `restored` warning names the + /// leftover text lock, and no `unrestorable` warning fires. + #[tokio::test] + async fn bun_lockb_migration_restores_recorded_bytes() { + let dir = TempDir::new().unwrap(); + let mut state = state_with(vec![lockb_edit(Some(lockb_b64()))], &[]); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{:?}", out.refusals); + assert_eq!( + std::fs::read(dir.path().join("bun.lockb")).unwrap(), + LOCKB_BYTES, + "the recorded bytes must be written back verbatim" + ); + assert_eq!(codes(&out), vec!["redirect_bun_lockb_restored"]); + let detail = &out.warnings[0].1; + assert!( + detail.contains("bun.lock generated during the redirect was left in place") + && detail.contains("delete whichever lockfile you do not want"), + "{detail}" + ); + assert!(out.reverted_files.contains("bun.lockb")); + assert!(state.edits.is_empty(), "the replayed edit is dropped"); + assert!( + std::fs::read_dir(dir.path()).unwrap().all(|e| !e + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(".socket-stage-")), + "the atomic writer leaves no stage file behind" + ); + } + + /// The migration record rides the bun group BEHIND the bun.lock fragment + /// (ledger order: migration first, so the reverse walk replays the + /// fragment first): one pass restores the registry tuple in bun.lock AND + /// writes bun.lockb back, and never deletes the text lock. + #[tokio::test] + async fn bun_lockb_restore_rides_the_bun_group_behind_the_lock_fragment() { + let dir = TempDir::new().unwrap(); + let original = " \"lp\": [\"lp@1.0.0\", \"\", {}, \"sha512-UP==\"],"; + let redirected = + " \"lp\": [\"lp@http://p.test/lp-1.0.0.tgz\", {}, \"sha512-PATCHED==\"],"; + let lock = |entry: &str| { + format!("{{\n \"lockfileVersion\": 1,\n \"packages\": {{\n{entry}\n }}\n}}\n") + }; + std::fs::write(dir.path().join("bun.lock"), lock(redirected)).unwrap(); + let mut state = state_with( + vec![ + lockb_edit(Some(lockb_b64())), + edit( + "bun.lock", + "redirect_bun_lock_package", + "rewritten", + Some(original), + Some(redirected), + ), + ], + &[], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{:?}", out.refusals); + assert_eq!( + std::fs::read_to_string(dir.path().join("bun.lock")).unwrap(), + lock(original), + "the text lock is un-redirected and KEPT" + ); + assert_eq!( + std::fs::read(dir.path().join("bun.lockb")).unwrap(), + LOCKB_BYTES + ); + assert_eq!(codes(&out), vec!["redirect_bun_lockb_restored"]); + assert!( + out.reverted_files.contains("bun.lock") && out.reverted_files.contains("bun.lockb") + ); + assert!(state.edits.is_empty()); + } + + /// Dry-run stages the restore and reports it (warning + would-revert + /// file) but writes nothing and leaves the ledger untouched. + #[tokio::test] + async fn bun_lockb_restore_is_staged_not_written_on_dry_run() { + let dir = TempDir::new().unwrap(); + let mut state = state_with(vec![lockb_edit(Some(lockb_b64()))], &[]); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, true).await; + assert!(out.fully_reverted()); + assert!( + !dir.path().join("bun.lockb").exists(), + "dry-run must not write" + ); + assert_eq!(codes(&out), vec!["redirect_bun_lockb_restored"]); + assert!(out.reverted_files.contains("bun.lockb")); + assert_eq!(out.dropped_edits, 1); + assert_eq!(state.edits.len(), 1, "dry-run leaves the ledger alone"); + } + + /// No captured bytes (pre-capture ledger / oversize lock) and bun.lockb + /// PRESENT on disk (bun 1.1.4x kept it): nothing to restore, nothing to + /// warn about — the old unconditional `unrestorable` was a lie here. + #[tokio::test] + async fn bun_lockb_present_without_recorded_bytes_says_nothing() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("bun.lockb"), LOCKB_BYTES).unwrap(); + let mut state = state_with(vec![lockb_edit(None)], &[]); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted()); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!( + std::fs::read(dir.path().join("bun.lockb")).unwrap(), + LOCKB_BYTES + ); + assert!(!out.reverted_files.contains("bun.lockb")); + assert!(state.edits.is_empty()); + } + + /// Captured bytes and an IDENTICAL bun.lockb already on disk (an + /// interrupted earlier revert): already at the original — no write, no + /// warning, edit dropped. + #[tokio::test] + async fn bun_lockb_identical_to_recorded_bytes_is_already_restored() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("bun.lockb"), LOCKB_BYTES).unwrap(); + let mut state = state_with(vec![lockb_edit(Some(lockb_b64()))], &[]); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted()); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert!(!out.reverted_files.contains("bun.lockb")); + assert!(state.edits.is_empty()); + } + + /// Captured bytes but a DIFFERENT bun.lockb on disk (the user re-locked + /// with an old bun since): never clobbered; the honest `unrestorable` + /// says why and the edit is dropped. + #[tokio::test] + async fn bun_lockb_differing_from_recorded_bytes_is_left_untouched() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("bun.lockb"), b"a newer binary lock").unwrap(); + let mut state = state_with(vec![lockb_edit(Some(lockb_b64()))], &[]); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted()); + assert_eq!(codes(&out), vec!["redirect_bun_lockb_unrestorable"]); + assert!( + out.warnings[0] + .1 + .contains("differs from the pre-redirect bytes"), + "{}", + out.warnings[0].1 + ); + assert_eq!( + std::fs::read(dir.path().join("bun.lockb")).unwrap(), + b"a newer binary lock" + ); + assert!(state.edits.is_empty()); + } + + /// An undecodable `original` (tampered / torn ledger) degrades to the + /// no-bytes path: `unrestorable` only because the file is absent, and the + /// bun group is NOT refused over it. + #[tokio::test] + async fn bun_lockb_undecodable_original_degrades_to_unrestorable() { + let dir = TempDir::new().unwrap(); + for bad in [ + Value::String("not base64 !!".into()), + json!(42), + json!({"b": 1}), + ] { + let mut state = state_with(vec![lockb_edit(Some(bad.clone()))], &[]); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{bad}: {:?}", out.refusals); + assert_eq!( + codes(&out), + vec!["redirect_bun_lockb_unrestorable"], + "{bad}" + ); + assert!( + !dir.path().join("bun.lockb").exists(), + "{bad}: nothing may be written" + ); + assert!(state.edits.is_empty()); + } + } + + /// The restore WRITES `edit.path`, so the ledger-path safety rule now + /// applies to the migration record too: a tampered path refuses the + /// group and writes nothing outside the project. + #[tokio::test] + async fn bun_lockb_migration_with_unsafe_path_refuses() { + let dir = TempDir::new().unwrap(); + let project = dir.path().join("project"); + std::fs::create_dir_all(&project).unwrap(); + let mut bad = lockb_edit(Some(lockb_b64())); + bad.path = "../escaped.lockb".into(); + let mut state = state_with(vec![bad], &[]); + let out = revert_remaining_redirect_edits(&project, &mut state, false).await; + assert_eq!(out.refusals.len(), 1); + assert!(out.refusals[0].reason.contains("unsafe path")); + assert!(!dir.path().join("escaped.lockb").exists()); + assert_eq!(state.edits.len(), 1, "a refused group keeps its edits"); + } + + /// A FIFO squatting bun.lockb must refuse fast instead of wedging the + /// replay (the same guard every other raw read in the engine has). + #[cfg(unix)] + #[tokio::test] + async fn bun_lockb_fifo_squatting_the_path_refuses_the_group() { + let dir = TempDir::new().unwrap(); + let fifo = dir.path().join("bun.lockb"); + let c_path = std::ffi::CString::new(fifo.to_str().unwrap()).unwrap(); + // SAFETY: a valid NUL-terminated path; mkfifo has no other preconditions. + assert_eq!(unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) }, 0); + let mut state = state_with(vec![lockb_edit(Some(lockb_b64()))], &[]); + let out = tokio::time::timeout( + std::time::Duration::from_secs(10), + revert_remaining_redirect_edits(dir.path(), &mut state, false), + ) + .await + .expect("the FIFO guard must not wedge the replay"); + assert_eq!(out.refusals.len(), 1, "{:?}", out.warnings); + assert!( + out.refusals[0].reason.contains("bun.lockb"), + "{}", + out.refusals[0].reason + ); + assert_eq!(state.edits.len(), 1); + } + /// Every kind the hosted writers emit today must have a deliberate /// classification — a new writer kind landing without a replay arm /// falls to the "unknown" group, which fails closed at runtime; this @@ -1704,6 +2053,7 @@ mod tests { // return InvalidInput (open + fstat) — the fail-fast posture the // module doc claims. Every per-arm read must refuse the group with // the read error and keep the ledger. + #[allow(clippy::type_complexity)] let cases: [(&str, &str, &str, Option<&str>, Option<&str>); 4] = [ ( "composer.lock", From 77c9e9f1680c120a3fe921fc68a78aa838310bc9 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 18 Sep 2026 17:49:15 -0400 Subject: [PATCH 09/49] fix(bun): truthful bun.lockb migration ledger, manual-migration code, resolved bun spawn - After a successful migration a bun.lockb that bun 1.1.43-1.1.45 kept is removed by the CLI so the ledger's removed record is always true; the pre-migration bytes are captured as standard base64 in the FileEdit original (raw cap 8 MiB) so rollback can restore the binary lock. The zero-redirect unwind keys off an in-memory flag + bytes, not the ledger payload. - exit 0 with no bun.lock (bun 1.1.39) is redirect_bun_lockb_manual_migration naming bun install --save-text-lockfile; spawn failure / non-zero exit stays redirect_bun_lockb_unsupported and now carries bun's output tail. - bun is resolved via utils::process::tool_command (absolute PATH entries, PATHEXT, cmd.exe shims) and the resolved path is spawned, never the bare name. - The spurious redirect_npm_no_lockfile on bun.lockb-only projects is dropped by the driver. Co-Authored-By: Claude Fable 5.1 --- crates/socket-patch-cli/Cargo.toml | 4 + .../src/commands/scan/hosted.rs | 331 +++++++++++++++--- 2 files changed, 290 insertions(+), 45 deletions(-) diff --git a/crates/socket-patch-cli/Cargo.toml b/crates/socket-patch-cli/Cargo.toml index 9075a09d..2a3cc2dc 100644 --- a/crates/socket-patch-cli/Cargo.toml +++ b/crates/socket-patch-cli/Cargo.toml @@ -28,6 +28,10 @@ uuid = { workspace = true } regex = { workspace = true } glob = { workspace = true } tempfile = { workspace = true } +# The hosted redirect ledger carries a migrated bun.lockb's pre-migration +# bytes as standard base64 (`commands/scan/hosted.rs`); the core replay +# decodes them with the same crate. +base64 = { workspace = true } [target.'cfg(unix)'.dependencies] # main.rs restores the default SIGPIPE disposition so piped invocations diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 03d6b768..d7859557 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -20,6 +20,104 @@ mod python; /// of appending; see the ledger merge below. const REBASE_KINDS: &[&str] = &["redirect_poetry_lock_package", "redirect_pdm_lock_package"]; +/// Largest `bun.lockb` (raw bytes) whose pre-migration content the redirect +/// ledger captures for `rollback`. The ledger is a JSON document read whole +/// on every hosted/rollback run, and standard base64 grows the payload by a +/// third, so a lock above this cap is recorded WITHOUT its bytes (today's +/// `redirect_bun_lockb_unrestorable` path applies). Real bun.lockb files are +/// tens of KiB to low MiB; 8 MiB is far outside anything measured. +const LOCKB_ORIGINAL_CAP: usize = 8 * 1024 * 1024; + +/// The exact `bun install` flag set the lockb→text migration spawns: no +/// network, fails closed on drift, writes the text lock without touching +/// node_modules. Its per-release behaviour is documented at the call site. +const BUN_MIGRATION_ARGS: [&str; 4] = [ + "install", + "--save-text-lockfile", + "--frozen-lockfile", + "--lockfile-only", +]; + +/// How the spawned `bun install …` migration ended. +enum LockbMigration { + /// exit 0 and a text `bun.lock` now exists (bun.lockb may or may not: + /// 1.1.43–1.1.45 keep it, ≥ 1.2 delete it — the caller normalizes). + Migrated, + /// exit 0 but NO `bun.lock` was written: bun 1.1.39 accepts the flags + /// and saves nothing under `--frozen-lockfile` (bare + /// `bun install --save-text-lockfile` does write there); bun ≤ 1.1.38 has + /// no text lockfile at all. Neither is "bun failed or is unavailable". + NoLockWritten, + /// bun is not on PATH, could not be spawned, or exited non-zero — with + /// the human-readable reason (bun's own output tail included). + Failed(String), +} + +/// Spawn the lockb→text migration in `cwd`. `bun` is resolved through the +/// shared PATH resolver (absolute entries only, PATHEXT on Windows) and the +/// RESOLVED path is spawned: a bare `Command::new("bun")` would run a `bun` +/// planted in the scanned repository via a relative PATH entry (the child's +/// cwd IS the repository), and on Windows would never find the npm-global +/// `bun.cmd` shim. +fn migrate_bun_lockb(cwd: &Path) -> LockbMigration { + let Some(mut command) = socket_patch_core::utils::process::tool_command("bun") else { + return LockbMigration::Failed("bun not found on PATH".into()); + }; + // `.output()` (not `.status()`): bun's install chatter must not + // interleave with the machine `--json` envelope on stdout. + match command.args(BUN_MIGRATION_ARGS).current_dir(cwd).output() { + Err(e) => LockbMigration::Failed(format!("bun could not be spawned: {e}")), + Ok(output) if !output.status.success() => { + let tail = output_tail(&output, 10); + LockbMigration::Failed(if tail.is_empty() { + format!("bun exited with {}", output.status) + } else { + format!("bun exited with {}; bun output: {tail}", output.status) + }) + } + Ok(_) if cwd.join("bun.lock").exists() => LockbMigration::Migrated, + Ok(_) => LockbMigration::NoLockWritten, + } +} + +/// The last `max_lines` non-empty lines of a child's stderr and stdout +/// (stderr first — bun's errors go there), each capped to 200 chars, joined +/// with ` | ` so the tail fits one warning line in human output and CI logs. +fn output_tail(output: &std::process::Output, max_lines: usize) -> String { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec = stderr + .lines() + .chain(stdout.lines()) + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(|line| { + let mut line = line.to_string(); + if line.chars().count() > 200 { + line = line.chars().take(200).collect::() + "…"; + } + line + }) + .collect(); + let skip = lines.len().saturating_sub(max_lines); + lines[skip..].join(" | ") +} + +/// The ledger payload for a migrated bun.lockb's pre-migration bytes: the +/// STANDARD base64 (RFC 4648 §4, padded) of the raw file as a JSON string in +/// `FileEdit.original` — the ledger is JSON, so binary content cannot ride it +/// raw — or `None` above [`LOCKB_ORIGINAL_CAP`] (and when the read failed), +/// in which case `rollback` cannot restore the binary lock and says so. The +/// replay decodes with the same alphabet (`replay.rs`, `BunLockbMigrated`). +fn lockb_original_payload(bytes: Option<&[u8]>) -> Option { + use base64::Engine as _; + bytes + .filter(|bytes| bytes.len() <= LOCKB_ORIGINAL_CAP) + .map(|bytes| { + serde_json::Value::String(base64::engine::general_purpose::STANDARD.encode(bytes)) + }) +} + const REDIRECT_CANDIDATE_FILES: &[&str] = &[ "package-lock.json", "npm-shrinkwrap.json", @@ -1209,9 +1307,21 @@ pub(crate) async fn run_redirect_selected( // bun.lockb auto-migration: the redirect rewriter only edits the TEXT // lockfile, so a project locked to a binary `bun.lockb` must be re-locked // to `bun.lock` first. `bun install --save-text-lockfile --frozen-lockfile - // --lockfile-only` writes bun.lock, DELETES bun.lockb, needs no network, - // and fails closed on drift. Dry-run only warns; a failure degrades to the - // rewriter's own presence-only refusal (the .lockb stays a candidate file). + // --lockfile-only` needs no network and fails closed on drift, but what it + // leaves behind depends on the installed Bun (measured against real + // releases): + // • ≤ 1.1.38: no text lockfile exists — nothing usable is written; + // • 1.1.39: the flags are accepted, exit 0, and NO bun.lock is written + // (`--frozen-lockfile` suppresses the text-lock save there); + // • 1.1.43–1.1.45: bun.lock is written and bun.lockb is KEPT; + // • ≥ 1.2.0: bun.lock is written and bun.lockb is DELETED. + // Every success is normalized to the ≥ 1.2 shape — a kept bun.lockb is + // removed here — so the ledger's `removed` record is always TRUE, and the + // pre-migration bytes are captured (base64, capped) so `rollback` can put + // the binary lock back. Dry-run only warns; the exit-0-but-no-lock shape + // gets its own code naming the manual command; a missing/unspawnable bun + // or a non-zero exit degrades to `redirect_bun_lockb_unsupported` carrying + // bun's output tail (the .lockb stays in place, never parsed). // Gated on an npm-ecosystem override: the migration exists solely so the // bun rewriter has a text lock to edit — with nothing to redirect it would // re-lock (and delete) the user's lockfile as a side effect of a no-op run. @@ -1223,6 +1333,11 @@ pub(crate) async fn run_redirect_selected( // refused) must not permanently convert the user's lockfile format as a // side effect of a zero-redirect run. let mut lockb_backup: Option> = None; + // True once a migration LANDED (bun.lock written, bun.lockb gone). The + // zero-redirect unwind keys off this flag and the in-memory bytes, never + // off the ledger record's `original` — which is deliberately absent for + // an oversize or unreadable lock. + let mut lockb_migrated = false; let has_lockb = common.cwd.join("bun.lockb").exists(); let has_bun_lock = common.cwd.join("bun.lock").exists(); let has_npm_override = overrides.iter().any(|o| o.ecosystem == "npm"); @@ -1235,42 +1350,81 @@ pub(crate) async fn run_redirect_selected( re-run without --dry-run to apply", })); } else { - // Read the binary lock BEFORE bun deletes it, so a zero-rewrite - // run can restore it below. - let lockb_bytes = std::fs::read(common.cwd.join("bun.lockb")).ok(); - // `.output()` (not `.status()`): bun's install chatter must not - // interleave with the machine `--json` envelope on stdout. - let output = std::process::Command::new("bun") - .args([ - "install", - "--save-text-lockfile", - "--frozen-lockfile", - "--lockfile-only", - ]) - .current_dir(&common.cwd) - .output(); - let migrated = matches!(output, Ok(o) if o.status.success()) - && common.cwd.join("bun.lock").exists(); - if migrated { - lockb_backup = lockb_bytes; - // bun deleted bun.lockb itself. Record the removal so `--revert` - // knows the file was replaced (binary — git history is the - // restore path, so no `original` bytes are captured). - migration_edits.push(socket_patch_core::patch::redirect::FileEdit { - path: "bun.lockb".into(), - kind: "redirect_bun_lockb_migrated".into(), - action: "removed".into(), - key: None, - original: None, - new: None, - }); - } else { - migration_warnings.push(serde_json::json!({ - "code": "redirect_bun_lockb_unsupported", - "detail": "bun.lockb could not be migrated to a text bun.lock \ - (`bun install --save-text-lockfile` failed or is unavailable); \ - the redirect cannot pin a binary lockfile", - })); + let lockb_path = common.cwd.join("bun.lockb"); + // Read the binary lock BEFORE the migration replaces it: the + // zero-rewrite unwind and the ledger's restore payload both need + // the original bytes. + let lockb_bytes = std::fs::read(&lockb_path).ok(); + match migrate_bun_lockb(&common.cwd) { + LockbMigration::Migrated => { + // bun 1.1.43–1.1.45 leave bun.lockb beside the new text + // lock; bun ≥ 1.2 deletes it. Remove a kept one ourselves + // so the project is text-only on every release and the + // `removed` record below is true (a stale binary lock + // beside the redirected text lock is also what bun + // ≤ 1.1.38 would silently install the UNPATCHED bytes + // from). NotFound is the ≥ 1.2 case — already gone. + let removal = match std::fs::remove_file(&lockb_path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e), + _ => Ok(()), + }; + match removal { + Ok(()) => { + lockb_migrated = true; + lockb_backup = lockb_bytes; + // Record the removal so `rollback` knows the file + // was replaced, carrying the pre-migration bytes + // (base64, capped) it needs to put it back. + let original = lockb_original_payload(lockb_backup.as_deref()); + migration_edits.push(socket_patch_core::patch::redirect::FileEdit { + path: "bun.lockb".into(), + kind: "redirect_bun_lockb_migrated".into(), + action: "removed".into(), + key: None, + original, + new: None, + }); + } + Err(e) => { + // Fail closed: a bun.lockb we cannot remove would + // make the `removed` record a lie and leave two + // lockfiles behind. Undo the migration (drop the + // text lock bun just wrote — bun.lockb is intact) + // and refuse exactly like a failed spawn. + let _ = std::fs::remove_file(common.cwd.join("bun.lock")); + migration_warnings.push(serde_json::json!({ + "code": "redirect_bun_lockb_unsupported", + "detail": format!( + "bun.lockb could not be migrated to a text bun.lock (bun \ + wrote bun.lock but the binary lock could not be removed: \ + {e}); the redirect cannot pin a binary lockfile" + ), + })); + } + } + } + LockbMigration::NoLockWritten => { + migration_warnings.push(serde_json::json!({ + "code": "redirect_bun_lockb_manual_migration", + "detail": "bun.lockb was not migrated: the installed Bun accepted \ + `bun install --save-text-lockfile --frozen-lockfile \ + --lockfile-only` (exit 0) but wrote no text bun.lock — Bun \ + 1.1.39 behaves this way, and Bun <= 1.1.38 has no text \ + lockfile at all and must be upgraded. Run `bun install \ + --save-text-lockfile` yourself (Bun >= 1.1.39), then re-run; \ + the redirect cannot pin a binary lockfile", + })); + } + LockbMigration::Failed(why) => { + migration_warnings.push(serde_json::json!({ + "code": "redirect_bun_lockb_unsupported", + "detail": format!( + "bun.lockb could not be migrated to a text bun.lock (`bun install \ + --save-text-lockfile --frozen-lockfile --lockfile-only` failed: \ + {why}); the redirect cannot pin a binary lockfile" + ), + })); + } } } } @@ -1421,6 +1575,19 @@ pub(crate) async fn run_redirect_selected( pipenv_major, ); + // A bun.lockb-only project is a Bun project: when no text lock was + // produced (migration refused / manual / dry-run) the npm rewriter's + // "no package-lock.json" warning is noise beside the bun.lockb diagnosis + // that says what is actually wrong. The core's own sibling-lock gate + // would suppress it on a `bun.lockb` candidate key, but the driver never + // reads the binary lock into `files` (never parse a .lockb), so the + // suppression lives here. + if has_lockb && !files.contains_key("bun.lock") { + rewrite + .warnings + .retain(|w| w.code != "redirect_npm_no_lockfile"); + } + // Unknown installer → the modern `file` shape was chosen; say so only // when the lock was (or, on --dry-run, would be) rewritten. if targets_pipenv_lock && pipenv_major.is_none() && rewrite.files.contains_key("Pipfile.lock") { @@ -1439,7 +1606,9 @@ pub(crate) async fn run_redirect_selected( // remove the generated text lock, and drop the ledger removal record so // the no-op run leaves the lockfile format untouched. The rewriter's own // warning (entry-not-found / unsupported) explains WHY nothing landed. - if !migration_edits.is_empty() && !rewrite.files.contains_key("bun.lock") { + // Keyed on the in-memory flag + bytes, not on the ledger record's + // `original` (absent for an oversize lock). + if lockb_migrated && !rewrite.files.contains_key("bun.lock") { let restored = lockb_backup .as_deref() .is_some_and(|bytes| std::fs::write(common.cwd.join("bun.lockb"), bytes).is_ok()); @@ -2307,11 +2476,12 @@ pub(crate) fn boxed_run_redirect_selected<'a>( mod tests { use super::{ build_redirect_json_envelope, gem_stale_cache_warning, gem_stale_install_warning, - gem_stale_install_warnings, installed_stale_positive_evidence, parse_purl_simple, - plan_workspace_trust, pnpm_heal_root, pnpm_lock_carries_hosted_redirect, - pnpm_lock_version_major, pnpm_trust_configured_detail, pnpm_trust_legacy_detail, - pnpm_trust_manual_guidance, pnpm_trust_workspace_unreadable_detail, - read_workspace_for_trust, TrustPlan, REDIRECT_CANDIDATE_FILES, + gem_stale_install_warnings, installed_stale_positive_evidence, lockb_original_payload, + output_tail, parse_purl_simple, plan_workspace_trust, pnpm_heal_root, + pnpm_lock_carries_hosted_redirect, pnpm_lock_version_major, pnpm_trust_configured_detail, + pnpm_trust_legacy_detail, pnpm_trust_manual_guidance, + pnpm_trust_workspace_unreadable_detail, read_workspace_for_trust, TrustPlan, + LOCKB_ORIGINAL_CAP, REDIRECT_CANDIDATE_FILES, }; use socket_patch_core::constants::npm_family; use socket_patch_core::patch::redirect::DepOverride; @@ -3446,6 +3616,77 @@ mod tests { ); } + /// The ledger payload is STANDARD (padded) base64 of the raw bytes — the + /// alphabet the replay decodes — up to the cap; above it, and for a + /// failed read, nothing is recorded. + #[test] + fn lockb_original_payload_is_standard_base64_up_to_the_cap() { + assert_eq!( + lockb_original_payload(Some(b"\x00BUN\xff")), + Some(serde_json::Value::String("AEJVTv8=".into())) + ); + assert_eq!( + lockb_original_payload(Some(b"")), + Some(serde_json::Value::String(String::new())), + "an empty lock is still captured (restorable)" + ); + assert_eq!( + lockb_original_payload(None), + None, + "unreadable → nothing recorded" + ); + let at_cap = vec![0u8; LOCKB_ORIGINAL_CAP]; + assert!( + lockb_original_payload(Some(&at_cap)).is_some(), + "the cap is inclusive" + ); + let over = vec![0u8; LOCKB_ORIGINAL_CAP + 1]; + assert_eq!( + lockb_original_payload(Some(&over)), + None, + "one byte over → None" + ); + } + + /// The failure detail carries bun's own last lines: stderr first, blank + /// lines dropped, trimmed, capped to the newest `max_lines`, one line. + #[test] + fn output_tail_keeps_the_last_lines_stderr_first() { + let output = std::process::Output { + status: std::process::ExitStatus::default(), + stdout: b"bun install v1.1.39\n\n Checked 1 install (no changes) \n".to_vec(), + stderr: b"error: lockfile had changes, but lockfile is frozen\n".to_vec(), + }; + assert_eq!( + output_tail(&output, 10), + "error: lockfile had changes, but lockfile is frozen | bun install v1.1.39 | \ + Checked 1 install (no changes)" + ); + assert_eq!(output_tail(&output, 1), "Checked 1 install (no changes)"); + let empty = std::process::Output { + status: std::process::ExitStatus::default(), + stdout: b"\n \n".to_vec(), + stderr: Vec::new(), + }; + assert_eq!( + output_tail(&empty, 10), + "", + "whitespace-only output is no tail" + ); + let long = std::process::Output { + status: std::process::ExitStatus::default(), + stdout: Vec::new(), + stderr: "x".repeat(500).into_bytes(), + }; + let tail = output_tail(&long, 10); + assert_eq!( + tail.chars().count(), + 201, + "each line is capped at 200 chars + ellipsis" + ); + assert!(tail.ends_with('…')); + } + #[test] fn redirect_candidates_match_the_shared_npm_family_table() { // Drift guard, both directions, without classifying the non-npm From 93039f66af770f196cdb1730a324dcb17658efa3 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 18 Sep 2026 17:49:15 -0400 Subject: [PATCH 10/49] test(bun): shim-driven lockb migration + rollback round trips, Windows bun.cmd twins, real 3-tuple fixtures Co-Authored-By: Claude Fable 5.1 --- .../tests/covgap_commands_rollback.rs | 18 +- .../tests/covgap_commands_scan_hosted.rs | 41 +- .../tests/in_process_redirect.rs | 687 +++++++++++++++++- 3 files changed, 723 insertions(+), 23 deletions(-) diff --git a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs index 559a53c9..6515f0b4 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs @@ -1880,8 +1880,13 @@ fn hosted_persist_failure_lands_in_hosted_failed() { /// for {purl}" line prints from the deferred path. #[test] fn bun_deferred_purl_unwinds_via_replay() { - let bun_original = r#" "left-pad": ["left-pad@1.2.3", "", {}, "sha512-UPSTREAMupstream=="],"#; - let bun_redirected = format!(r#" "left-pad": ["{LP_HOSTED_URL}", "", {{}}, "sha512-PATCHEDpatched=="],"#); + let bun_original = + r#" "left-pad": ["left-pad@1.2.3", "", {}, "sha512-UPSTREAMupstream=="],"#; + // The engine's real redirected shape: registry 4-tuple → URL 3-tuple + // `["name@", {deps}, "sha512-…"]` (the registry slot is dropped). + let bun_redirected = format!( + r#" "left-pad": ["left-pad@{LP_HOSTED_URL}", {{}}, "sha512-PATCHEDpatched=="],"# + ); let bun_lock = |block: &str| { format!("{{\n \"lockfileVersion\": 1,\n \"packages\": {{\n{block}\n }}\n}}\n") }; @@ -2287,8 +2292,13 @@ fn per_purl_revert_failure_prints_human_stderr_line() { /// ledger byte-identical afterwards. #[test] fn bun_deferred_purl_dry_run_previews_via_replay() { - let bun_original = r#" "left-pad": ["left-pad@1.2.3", "", {}, "sha512-UPSTREAMupstream=="],"#; - let bun_redirected = format!(r#" "left-pad": ["{LP_HOSTED_URL}", "", {{}}, "sha512-PATCHEDpatched=="],"#); + let bun_original = + r#" "left-pad": ["left-pad@1.2.3", "", {}, "sha512-UPSTREAMupstream=="],"#; + // The engine's real redirected shape: registry 4-tuple → URL 3-tuple + // `["name@", {deps}, "sha512-…"]` (the registry slot is dropped). + let bun_redirected = format!( + r#" "left-pad": ["left-pad@{LP_HOSTED_URL}", {{}}, "sha512-PATCHEDpatched=="],"# + ); let bun_lock = |block: &str| { format!("{{\n \"lockfileVersion\": 1,\n \"packages\": {{\n{block}\n }}\n}}\n") }; diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs index 7ad275a0..cbef1d7d 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs @@ -708,6 +708,11 @@ async fn bun_lockb_dry_run_warns_would_migrate_without_spawning_bun() { "a dry-run must never report a migration ATTEMPT (proof no bun was \ spawned): {doc:#}" ); + assert!( + !warning_codes(&doc).contains(&"redirect_npm_no_lockfile".to_string()), + "a bun.lockb project is a Bun project: the npm no-lockfile warning is \ + noise beside the would-migrate preview: {doc:#}" + ); assert_eq!( std::fs::read(tmp.path().join("bun.lockb")).unwrap(), lockb_before, @@ -759,8 +764,21 @@ async fn failed_bun_lockb_migration_warns_unsupported_and_keeps_the_binary_lock( assert_eq!(code, 0, "a failed migration is a warning, not an error: {doc:#}"); let detail = warning_detail(&doc, "redirect_bun_lockb_unsupported"); assert!( - detail.contains("cannot pin a binary lockfile"), - "the unsupported warning must explain the refusal: {detail}" + detail.contains("cannot pin a binary lockfile") && detail.contains("exit status: 1"), + "the unsupported warning must explain the refusal and carry bun's exit: {detail}" + ); + let codes = warning_codes(&doc); + assert_eq!( + codes + .iter() + .filter(|c| *c == "redirect_bun_lockb_unsupported") + .count(), + 1, + "exactly one unsupported warning: {codes:?}" + ); + assert!( + !codes.contains(&"redirect_npm_no_lockfile".to_string()), + "a bun.lockb project never gets the npm no-lockfile noise: {codes:?}" ); assert_eq!(doc["redirect"]["redirected"], 0, "envelope: {doc:#}"); assert_eq!( @@ -853,12 +871,21 @@ async fn unreadable_bun_lockb_backup_keeps_the_migration_and_warns_loudly() { "bun deleted the binary lock and no backup could restore it" ); // The kept migration's removal record reaches the ledger so a future - // `--revert` knows the file was replaced. - let ledger = - std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(); + // `--revert` knows the file was replaced — WITHOUT `original`: the + // pre-migration read failed, so there are no bytes to restore from. + let ledger: Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(), + ) + .unwrap(); + let migration = &ledger["edits"][0]; + assert_eq!( + migration["kind"], "redirect_bun_lockb_migrated", + "{ledger:#}" + ); + assert_eq!(migration["action"], "removed", "{ledger:#}"); assert!( - ledger.contains("redirect_bun_lockb_migrated") && ledger.contains("\"removed\""), - "the ledger must keep the migration's removal record: {ledger}" + migration.get("original").is_none(), + "an unreadable lock is recorded without bytes: {ledger:#}" ); } diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index f034c7f6..9ac19558 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -968,14 +968,18 @@ async fn scan_redirect_refuses_bun_lock_v3() { } /// 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 -/// recorded in the ledger, and the freshly-written bun.lock is then redirected. +/// a canned text bun.lock and deletes bun.lockb (the bun ≥ 1.2 shape), +/// exercising the migration branch of `run_redirect` without a real bun. The +/// migration removal is recorded in the ledger WITH the pre-migration bytes +/// (standard base64 in `original`), and the freshly-written bun.lock is then +/// redirected. /// -/// unix-only: the shim is a `#!/bin/sh` script (Windows would need a .cmd -/// twin and `;` PATH joining). The migration path itself is OS-agnostic -/// (`Command::new("bun")` resolves bun.exe on Windows) and gets real-bun -/// coverage in the toolchain-gated e2e_redirect_bun_build capstone. +/// unix-only: this shim is a `#!/bin/sh` script; the `bun.cmd` twins further +/// down (`#[cfg(windows)]`) cover the same arms through the PATHEXT-aware +/// resolver. Real-bun coverage of the migration lives in the +/// bun-compatibility matrix (`.github/workflows/bun-compatibility.yml`, the +/// bun 1.1.45 hosted cells and the legacy-lockb shape) — NOT in +/// e2e_redirect_bun_build, whose fixture is a text lock from the start. #[cfg(unix)] #[tokio::test] #[serial] @@ -1049,13 +1053,672 @@ async fn scan_redirect_migrates_bun_lockb_then_redirects() { lock.contains(HOSTED_URL) && lock.contains(PATCHED_SHA512), "the migrated bun.lock must be redirected; got:\n{lock}" ); - // The migration removal is recorded (action "removed") for revert. - let ledger = - std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(); + // The migration removal is recorded (action "removed") for revert, and + // carries the pre-migration bytes so `rollback` can put the binary lock + // back — standard base64 of the placeholder. + let ledger = read_ledger(tmp.path()); + let migration = &ledger["edits"][0]; + assert_eq!(migration["path"], "bun.lockb", "{ledger:#}"); + assert_eq!( + migration["kind"], "redirect_bun_lockb_migrated", + "{ledger:#}" + ); + assert_eq!(migration["action"], "removed", "{ledger:#}"); + assert_eq!( + migration["original"], + serde_json::Value::String(standard_base64(b"BUN-BINARY-PLACEHOLDER")), + "the ledger must carry the pre-migration bytes: {ledger:#}" + ); + assert_eq!( + ledger["edits"][1]["kind"], "redirect_bun_lock_package", + "{ledger:#}" + ); +} + +// ───────────── bun.lockb migration truthfulness (shim-driven, subprocess) ───────────── +// +// Real Bun behaviour the shims below emulate (measured against 1.1.38 … 1.4.2): +// • ≤ 1.1.38 fail on the flags (non-zero) — `redirect_bun_lockb_unsupported`; +// • 1.1.39 accepts them, exits 0 and writes NO bun.lock — +// `redirect_bun_lockb_manual_migration`; +// • 1.1.43–1.1.45 write bun.lock and KEEP bun.lockb — the CLI removes it so +// the ledger's `removed` is true; +// • ≥ 1.2 write bun.lock and delete bun.lockb. +// Every run goes through the built binary (child-only PATH) so the `--json` +// envelope and the `rollback` warnings can be read back without touching the +// parent's environment. + +/// Placeholder binary lock: deliberately NOT valid UTF-8 (a real bun.lockb is +/// binary) so the base64 round-trip is exercised on bytes a JSON string could +/// never carry raw. +const LOCKB_BYTES: &[u8] = b"\x00BUN-BINARY\xff\xfe\x00LOCK"; + +fn standard_base64(bytes: &[u8]) -> String { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +/// The bun text lock the shims "write": the registry 4-tuple for NAME@VERSION +/// in bun's emitted single-line grammar (lockfileVersion 1). +fn canned_bun_lock() -> String { + format!( + "{{\n \"lockfileVersion\": 1,\n \"packages\": {{\n \ + \"{NAME}\": [\"{NAME}@{VERSION}\", \"\", {{}}, \"sha512-UPSTREAMupstream==\"],\n \ + }}\n}}\n" + ) +} + +/// package.json + installed copy + the placeholder bun.lockb (never parsed). +fn write_bun_lockb_project(root: &Path, lockb: &[u8]) { + std::fs::write( + root.join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{NAME}": "^{VERSION}" }} }}"# + ), + ) + .unwrap(); + let pkg = root.join("node_modules").join(NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{NAME}", "version": "{VERSION}" }}"#), + ) + .unwrap(); + std::fs::write(root.join("bun.lockb"), lockb).unwrap(); +} + +/// A fake `bun` (`#!/bin/sh` script) in `/fakebin`; returns that dir. +#[cfg(unix)] +fn install_bun_shim(root: &Path, body: &str) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + let bin_dir = root.join("fakebin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + let shim = bin_dir.join("bun"); + std::fs::write(&shim, body).unwrap(); + std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap(); + bin_dir +} + +/// A fake `bun.cmd` batch shim in `/fakebin` (the npm-global `bun` +/// layout: no bun.exe anywhere on PATH); returns that dir. `body` is joined +/// with CRLF as cmd.exe expects. +#[cfg(windows)] +fn install_bun_cmd_shim(root: &Path, lines: &[&str]) -> std::path::PathBuf { + let bin_dir = root.join("fakebin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + let mut body = String::from("@echo off\r\n"); + for line in lines { + body.push_str(line); + body.push_str("\r\n"); + } + std::fs::write(bin_dir.join("bun.cmd"), body).unwrap(); + bin_dir +} + +/// A child-only PATH with `bin_dir` first, joined with the OS separator. +fn path_with_first(bin_dir: &Path) -> std::ffi::OsString { + let mut entries = vec![bin_dir.to_path_buf()]; + entries.extend(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + )); + std::env::join_paths(entries).expect("PATH entries join") +} + +/// `scan --redirect --json --yes` as a subprocess with the given child PATH; +/// returns (exit code, parsed envelope, stderr). Asserts stdout IS JSON so a +/// leaking shim (bun chatter on stdout) fails loudly. +fn scan_redirect_json_with_path( + cwd: &Path, + api_url: &str, + path: &std::ffi::OsStr, +) -> (Option, serde_json::Value, String) { + let out = scrubbed_cli() + .args([ + "scan", + "--redirect", + "--json", + "--yes", + "--cwd", + cwd.to_str().unwrap(), + "--api-url", + api_url, + "--org", + ORG, + "--api-token", + "fake", + ]) + .env("PATH", path) + .output() + .expect("run socket-patch"); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + let env_json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| { + panic!( + "--json stdout must be a pure JSON envelope: {e}\nstdout:\n{}\nstderr:\n{stderr}", + String::from_utf8_lossy(&out.stdout) + ) + }); + (out.status.code(), env_json, stderr) +} + +/// `rollback --json --yes --offline` as a subprocess; returns (exit code, +/// parsed envelope). +fn rollback_json(cwd: &Path) -> (Option, serde_json::Value) { + let out = scrubbed_cli() + .args([ + "rollback", + "--json", + "--yes", + "--offline", + "--cwd", + cwd.to_str().unwrap(), + ]) + .output() + .expect("run socket-patch rollback"); + let env_json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| { + panic!( + "rollback --json stdout must be JSON: {e}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) + }); + (out.status.code(), env_json) +} + +/// The `code`s of a rollback envelope's top-level `warnings`. +fn rollback_warning_codes(env: &serde_json::Value) -> Vec { + env["warnings"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|w| w["code"].as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +/// The `detail` of the first redirect warning carrying `code`. +fn redirect_warning_detail(env: &serde_json::Value, code: &str) -> String { + env["redirect"]["warnings"] + .as_array() + .into_iter() + .flatten() + .find(|w| w["code"] == code) + .and_then(|w| w["detail"].as_str()) + .unwrap_or_else(|| panic!("expected a `{code}` redirect warning: {env:#}")) + .to_string() +} + +fn read_ledger(root: &Path) -> serde_json::Value { + let text = std::fs::read_to_string(root.join(".socket/vendor/redirect-state.json")).unwrap(); + serde_json::from_str(&text).expect("the ledger is JSON") +} + +/// Shared oracle for the "migration landed, then rollback" round trip +/// (unix `#!/bin/sh` shim and Windows `bun.cmd` shim twins call it). +fn assert_migration_round_trip(tmp: &Path, scan: (Option, serde_json::Value, String)) { + let (code, env, stderr) = scan; + assert_eq!( + code, + Some(0), + "scan must succeed: {env:#}\nstderr:\n{stderr}" + ); + assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + let codes = warning_codes(&env); + assert!( + !codes.iter().any(|c| c.starts_with("redirect_bun_lockb_")), + "a landed migration carries no lockb warning: {codes:?}" + ); assert!( - ledger.contains("redirect_bun_lockb_migrated") && ledger.contains("\"removed\""), - "the ledger must record the bun.lockb removal: {ledger}" + !codes.contains(&"redirect_npm_no_lockfile".to_string()), + "a bun project never gets the npm no-lockfile noise: {codes:?}" + ); + assert!( + !tmp.join("bun.lockb").exists(), + "the CLI must remove the bun.lockb the shim kept, so the project is text-only" + ); + let lock = std::fs::read_to_string(tmp.join("bun.lock")).unwrap(); + assert!( + lock.contains(HOSTED_URL) && lock.contains(PATCHED_SHA512), + "the migrated bun.lock must be redirected; got:\n{lock}" + ); + let ledger = read_ledger(tmp); + let migration = &ledger["edits"][0]; + assert_eq!(migration["path"], "bun.lockb", "{ledger:#}"); + assert_eq!( + migration["kind"], "redirect_bun_lockb_migrated", + "{ledger:#}" + ); + assert_eq!(migration["action"], "removed", "{ledger:#}"); + assert_eq!( + migration["original"], + serde_json::Value::String(standard_base64(LOCKB_BYTES)), + "the ledger carries the pre-migration bytes as standard base64: {ledger:#}" + ); + + // rollback: bun.lock un-redirected (kept), bun.lockb byte-identical. + let (code, env) = rollback_json(tmp); + assert_eq!(code, Some(0), "rollback must succeed: {env:#}"); + assert_eq!(env["status"], "success", "{env:#}"); + let codes = rollback_warning_codes(&env); + assert!( + codes.contains(&"redirect_bun_lockb_restored".to_string()), + "rollback must say it restored the binary lock: {codes:?}" + ); + assert!( + !codes.contains(&"redirect_bun_lockb_unrestorable".to_string()), + "the false unrestorable warning must be gone: {codes:?}" + ); + assert_eq!( + std::fs::read(tmp.join("bun.lockb")).unwrap(), + LOCKB_BYTES, + "bun.lockb must come back byte-identical to the pre-migration placeholder" + ); + let lock = std::fs::read_to_string(tmp.join("bun.lock")).unwrap(); + assert!( + lock.contains(&format!("\"{NAME}@{VERSION}\"")) && !lock.contains(HOSTED_URL), + "the text lock is un-redirected and LEFT IN PLACE (never deleted on rollback):\n{lock}" + ); + assert!( + !tmp.join(".socket/vendor/redirect-state.json").exists(), + "everything unwound: the ledger is gone" + ); +} + +/// bun 1.1.43–1.1.45 shape: the shim writes bun.lock but does NOT delete +/// bun.lockb. The CLI removes it itself (ledger `removed` is true, `original` +/// carries the bytes), and `rollback` restores bun.lockb byte-identical while +/// leaving the un-redirected bun.lock in place — with `redirect_bun_lockb_restored` +/// and NOT the old false `redirect_bun_lockb_unrestorable`. +#[cfg(unix)] +#[tokio::test] +#[serial] +async fn migration_that_keeps_bun_lockb_is_normalized_and_rollback_restores_it() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_bun_lockb_project(tmp.path(), LOCKB_BYTES); + let bin_dir = install_bun_shim( + tmp.path(), + &format!( + "#!/bin/sh\n\ + # bun 1.1.45: writes bun.lock, KEEPS bun.lockb\n\ + echo \"Saved bun.lock (2 packages)\"\n\ + cat > bun.lock <<'LOCK'\n{}LOCK\n\ + exit 0\n", + canned_bun_lock() + ), + ); + let scan = scan_redirect_json_with_path(tmp.path(), &server.uri(), &path_with_first(&bin_dir)); + assert_migration_round_trip(tmp.path(), scan); +} + +/// bun 1.1.39 shape: the flags are accepted, exit 0, and NO bun.lock is +/// written. That is neither "bun failed" nor "bun unavailable": the run must +/// emit `redirect_bun_lockb_manual_migration` naming the working manual +/// command, redirect nothing, leave bun.lockb untouched, write no bun.lock or +/// ledger — and must NOT add the npm "no package-lock.json" noise. +#[cfg(unix)] +#[tokio::test] +#[serial] +async fn exit_zero_without_bun_lock_reports_manual_migration() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_bun_lockb_project(tmp.path(), LOCKB_BYTES); + let bin_dir = install_bun_shim( + tmp.path(), + "#!/bin/sh\n\ + echo \"bun install v1.1.39 (aa123456)\"\n\ + echo \"Checked 1 install across 2 packages (no changes)\"\n\ + exit 0\n", + ); + let (code, env, stderr) = + scan_redirect_json_with_path(tmp.path(), &server.uri(), &path_with_first(&bin_dir)); + assert_eq!( + code, + Some(0), + "a refused migration is a warning, not an error: {env:#}\n{stderr}" + ); + assert_eq!(env["redirect"]["redirected"], 0, "{env:#}"); + let codes = warning_codes(&env); + assert_eq!( + codes + .iter() + .filter(|c| *c == "redirect_bun_lockb_manual_migration") + .count(), + 1, + "exactly one manual-migration warning: {codes:?}" + ); + assert!( + !codes.contains(&"redirect_bun_lockb_unsupported".to_string()), + "exit 0 is not 'failed or unavailable': {codes:?}" + ); + assert!( + !codes.contains(&"redirect_npm_no_lockfile".to_string()), + "a bun.lockb project never gets the npm no-lockfile noise: {codes:?}" + ); + let detail = redirect_warning_detail(&env, "redirect_bun_lockb_manual_migration"); + assert!( + detail.contains("wrote no text bun.lock") + && detail.contains("`bun install --save-text-lockfile`") + && detail.contains("1.1.38"), + "the detail must explain the no-op and name the manual command: {detail}" + ); + assert_eq!( + std::fs::read(tmp.path().join("bun.lockb")).unwrap(), + LOCKB_BYTES, + "bun.lockb must be untouched" + ); + assert!( + !tmp.path().join("bun.lock").exists(), + "no text lock may appear" + ); + assert!( + !tmp.path() + .join(".socket/vendor/redirect-state.json") + .exists(), + "nothing was redirected: no ledger" + ); +} + +/// A failing bun (non-zero exit): `redirect_bun_lockb_unsupported` exactly +/// once, with bun's own stderr/stdout tail in the detail (it used to be +/// discarded), bun.lockb untouched, no bun.lock, no npm no-lockfile noise. +#[cfg(unix)] +#[tokio::test] +#[serial] +async fn failed_migration_detail_carries_bun_output_tail() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_bun_lockb_project(tmp.path(), LOCKB_BYTES); + let bin_dir = install_bun_shim( + tmp.path(), + "#!/bin/sh\n\ + echo \"bun install v1.1.38 (bb654321)\"\n\ + echo \"error: unknown option --save-text-lockfile\" >&2\n\ + exit 1\n", + ); + let (code, env, _stderr) = + scan_redirect_json_with_path(tmp.path(), &server.uri(), &path_with_first(&bin_dir)); + assert_eq!(code, Some(0), "{env:#}"); + assert_eq!(env["redirect"]["redirected"], 0, "{env:#}"); + let codes = warning_codes(&env); + assert_eq!( + codes + .iter() + .filter(|c| *c == "redirect_bun_lockb_unsupported") + .count(), + 1, + "exactly one unsupported warning (no duplicate from the core gate): {codes:?}" + ); + assert!( + !codes.contains(&"redirect_npm_no_lockfile".to_string()), + "{codes:?}" + ); + let detail = redirect_warning_detail(&env, "redirect_bun_lockb_unsupported"); + assert!( + detail.contains("error: unknown option --save-text-lockfile") + && detail.contains("bun install v1.1.38 (bb654321)") + && detail.contains("exit status: 1") + && detail.contains("cannot pin a binary lockfile"), + "the detail must carry bun's output tail and exit status: {detail}" + ); + assert_eq!( + std::fs::read(tmp.path().join("bun.lockb")).unwrap(), + LOCKB_BYTES + ); + assert!(!tmp.path().join("bun.lock").exists()); +} + +/// A bun.lockb above the ledger's byte cap (8 MiB raw) is migrated but +/// recorded WITHOUT `original`; `rollback` then emits +/// `redirect_bun_lockb_unrestorable` — and only because the file is actually +/// absent (bun ≥ 1.2 shape: the shim deletes it). +#[cfg(unix)] +#[tokio::test] +#[serial] +async fn oversize_bun_lockb_is_recorded_without_bytes_and_rollback_says_unrestorable() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + // One byte over `LOCKB_ORIGINAL_CAP` (hosted.rs): 8 MiB + 1. + let oversize = vec![0xABu8; 8 * 1024 * 1024 + 1]; + write_bun_lockb_project(tmp.path(), &oversize); + let bin_dir = install_bun_shim( + tmp.path(), + &format!( + "#!/bin/sh\n\ + cat > bun.lock <<'LOCK'\n{}LOCK\n\ + rm -f bun.lockb\n\ + exit 0\n", + canned_bun_lock() + ), + ); + let (code, env, _stderr) = + scan_redirect_json_with_path(tmp.path(), &server.uri(), &path_with_first(&bin_dir)); + assert_eq!(code, Some(0), "{env:#}"); + assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + let ledger = read_ledger(tmp.path()); + let migration = &ledger["edits"][0]; + assert_eq!( + migration["kind"], "redirect_bun_lockb_migrated", + "{ledger:#}" + ); + assert_eq!(migration["action"], "removed", "{ledger:#}"); + assert!( + migration.get("original").is_none(), + "an oversize lock is recorded without its bytes: {migration:#}" + ); + + let (code, env) = rollback_json(tmp.path()); + assert_eq!(code, Some(0), "{env:#}"); + let codes = rollback_warning_codes(&env); + assert!( + codes.contains(&"redirect_bun_lockb_unrestorable".to_string()), + "no bytes + file absent → the honest unrestorable warning: {codes:?}" + ); + assert!( + !codes.contains(&"redirect_bun_lockb_restored".to_string()), + "{codes:?}" + ); + assert!(!tmp.path().join("bun.lockb").exists()); + let lock = std::fs::read_to_string(tmp.path().join("bun.lock")).unwrap(); + assert!( + lock.contains(&format!("\"{NAME}@{VERSION}\"")) && !lock.contains(HOSTED_URL), + "the text lock is un-redirected and kept:\n{lock}" + ); +} + +/// The resolver ignores relative PATH entries: with `.` on PATH and an +/// executable `bun` PLANTED in the scanned project, the planted file must +/// never run (the child's cwd IS the project). With no absolute entry +/// holding a bun, the run degrades to `redirect_bun_lockb_unsupported` +/// "bun not found on PATH". +#[cfg(unix)] +#[tokio::test] +#[serial] +async fn relative_path_entry_never_runs_a_repo_planted_bun() { + use std::os::unix::fs::PermissionsExt; + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_bun_lockb_project(tmp.path(), LOCKB_BYTES); + // The planted attacker script: proves execution via a marker file and + // would otherwise "migrate" the lock. + let planted = tmp.path().join("bun"); + std::fs::write( + &planted, + format!( + "#!/bin/sh\n\ + echo PLANTED > planted.marker\n\ + cat > bun.lock <<'LOCK'\n{}LOCK\n\ + rm -f bun.lockb\n\ + exit 0\n", + canned_bun_lock() + ), + ) + .unwrap(); + std::fs::set_permissions(&planted, std::fs::Permissions::from_mode(0o755)).unwrap(); + // PATH = `.` + an EMPTY absolute dir: the only bun reachable is the + // planted one, and only through the relative entry. + let empty_bin = tmp.path().join("emptybin"); + std::fs::create_dir_all(&empty_bin).unwrap(); + let path = std::env::join_paths([std::path::PathBuf::from("."), empty_bin]).unwrap(); + + let (code, env, _stderr) = scan_redirect_json_with_path(tmp.path(), &server.uri(), &path); + assert_eq!(code, Some(0), "{env:#}"); + assert!( + !tmp.path().join("planted.marker").exists(), + "the repo-planted bun must NEVER execute" + ); + assert_eq!( + std::fs::read(tmp.path().join("bun.lockb")).unwrap(), + LOCKB_BYTES, + "bun.lockb untouched" + ); + assert!(!tmp.path().join("bun.lock").exists()); + let detail = redirect_warning_detail(&env, "redirect_bun_lockb_unsupported"); + assert!( + detail.contains("bun not found on PATH"), + "with no absolute-entry bun the run says so: {detail}" + ); +} + +// ───────────── Windows twins: `bun.cmd` batch shims (npm-global bun layout) ───────────── +// +// `npm i -g bun` on Windows leaves `bun`, `bun.cmd` and `bun.ps1` shims on +// PATH and NO bun.exe; Rust's `Command::new("bun")` appends only `.exe` and +// reports NotFound. The PATHEXT-aware resolver finds `bun.cmd` and launches +// it through `cmd.exe /C`. These twins can only run on a Windows host. + +/// Windows twin of `migration_that_keeps_bun_lockb_is_normalized_and_rollback_restores_it`. +/// The shim copies a canned text lock into place (no `echo` quoting games) +/// and keeps bun.lockb. +#[cfg(windows)] +#[tokio::test] +#[serial] +async fn migration_via_bun_cmd_shim_is_normalized_and_rollback_restores_it() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_bun_lockb_project(tmp.path(), LOCKB_BYTES); + let bin_dir = install_bun_cmd_shim( + tmp.path(), + &[ + // %~dp0 = the shim's own directory (trailing backslash). + "copy /Y \"%~dp0bun.lock.canned\" \"bun.lock\" >nul", + "echo Saved bun.lock (2 packages)", + "exit /b 0", + ], + ); + std::fs::write(bin_dir.join("bun.lock.canned"), canned_bun_lock()).unwrap(); + let scan = scan_redirect_json_with_path(tmp.path(), &server.uri(), &path_with_first(&bin_dir)); + assert_migration_round_trip(tmp.path(), scan); +} + +/// Windows twin of `exit_zero_without_bun_lock_reports_manual_migration`. +#[cfg(windows)] +#[tokio::test] +#[serial] +async fn exit_zero_without_bun_lock_reports_manual_migration_via_bun_cmd() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_bun_lockb_project(tmp.path(), LOCKB_BYTES); + let bin_dir = install_bun_cmd_shim( + tmp.path(), + &[ + "echo Checked 1 install across 2 packages (no changes)", + "exit /b 0", + ], + ); + let (code, env, stderr) = + scan_redirect_json_with_path(tmp.path(), &server.uri(), &path_with_first(&bin_dir)); + assert_eq!(code, Some(0), "{env:#}\n{stderr}"); + assert_eq!(env["redirect"]["redirected"], 0, "{env:#}"); + let codes = warning_codes(&env); + assert!( + codes.contains(&"redirect_bun_lockb_manual_migration".to_string()), + "{codes:?}" + ); + assert!( + !codes.contains(&"redirect_bun_lockb_unsupported".to_string()), + "{codes:?}" + ); + assert!( + !codes.contains(&"redirect_npm_no_lockfile".to_string()), + "{codes:?}" + ); + assert_eq!( + std::fs::read(tmp.path().join("bun.lockb")).unwrap(), + LOCKB_BYTES + ); + assert!(!tmp.path().join("bun.lock").exists()); +} + +/// Windows twin of `failed_migration_detail_carries_bun_output_tail`. +#[cfg(windows)] +#[tokio::test] +#[serial] +async fn failed_migration_via_bun_cmd_carries_output_tail() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_bun_lockb_project(tmp.path(), LOCKB_BYTES); + let bin_dir = install_bun_cmd_shim( + tmp.path(), + &[ + "echo error: unknown option --save-text-lockfile 1>&2", + "exit /b 1", + ], + ); + let (code, env, _stderr) = + scan_redirect_json_with_path(tmp.path(), &server.uri(), &path_with_first(&bin_dir)); + assert_eq!(code, Some(0), "{env:#}"); + let codes = warning_codes(&env); + assert_eq!( + codes + .iter() + .filter(|c| *c == "redirect_bun_lockb_unsupported") + .count(), + 1, + "{codes:?}" + ); + assert!( + !codes.contains(&"redirect_npm_no_lockfile".to_string()), + "{codes:?}" + ); + let detail = redirect_warning_detail(&env, "redirect_bun_lockb_unsupported"); + assert!( + detail.contains("error: unknown option --save-text-lockfile") + && detail.contains("cannot pin a binary lockfile"), + "{detail}" + ); + assert_eq!( + std::fs::read(tmp.path().join("bun.lockb")).unwrap(), + LOCKB_BYTES ); + assert!(!tmp.path().join("bun.lock").exists()); } /// The lockb migration must be UNDONE when the rewrite lands nothing in the From 1d1069033b807750514a8784d3cb5574cf6a7721 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 21 Sep 2026 10:22:28 -0400 Subject: [PATCH 11/49] test(redirect): golden harness pins warning codes via optional expected-warnings.json redirect_golden.rs asserted only the changed-file set and the edits ledger, so every refusal fixture passed on ANY early return: a renamed code, a refusal firing for the wrong reason, or an entry that silently failed to match all produced the same "no files, edits == []". A case may now ship `expected-warnings.json` (JSON array of codes, order-sensitive) and the harness asserts `result.warnings[].code` equals it. The file is optional so the maven cases that legitimately rewrite AND warn keep passing unchanged; positive cases may pin `[]`. The four bun refusal fixtures now pin their codes (redirect_bun_workspace_unsupported, redirect_bun_lock_unsupported, redirect_bun_lockb_unsupported, redirect_bun_missing_sha512). Mutation- checked: renaming the workspace code at its emit site fails lock-v0-workspace-refusal with "warning codes mismatch". The depscan TS twin (golden.test.ts) consumes the same fixture tree and must gain the same optional file for the cross-language contract to hold. Findings: test-quality:golden-harness-never-asserts-warnings, hosted-engine:v0-workspace-refusal-has-no-code-asserting-test, docs-contract:hosted-workspace-refusal-code-unasserted. Co-Authored-By: Claude Fable 5.1 --- .../expected-warnings.json | 3 +++ .../expected-warnings.json | 3 +++ .../lockb-only-refusal/expected-warnings.json | 3 +++ .../bun/missing-sha512/expected-warnings.json | 3 +++ .../socket-patch-core/tests/redirect_golden.rs | 18 ++++++++++++++++++ 5 files changed, 30 insertions(+) create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0-workspace-refusal/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-version-unsupported/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lockb-only-refusal/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/missing-sha512/expected-warnings.json diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0-workspace-refusal/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0-workspace-refusal/expected-warnings.json new file mode 100644 index 00000000..da284064 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0-workspace-refusal/expected-warnings.json @@ -0,0 +1,3 @@ +[ + "redirect_bun_workspace_unsupported" +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-version-unsupported/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-version-unsupported/expected-warnings.json new file mode 100644 index 00000000..0aaa3015 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-version-unsupported/expected-warnings.json @@ -0,0 +1,3 @@ +[ + "redirect_bun_lock_unsupported" +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lockb-only-refusal/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lockb-only-refusal/expected-warnings.json new file mode 100644 index 00000000..3b4c7496 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lockb-only-refusal/expected-warnings.json @@ -0,0 +1,3 @@ +[ + "redirect_bun_lockb_unsupported" +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/missing-sha512/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/missing-sha512/expected-warnings.json new file mode 100644 index 00000000..812c3043 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/missing-sha512/expected-warnings.json @@ -0,0 +1,3 @@ +[ + "redirect_bun_missing_sha512" +] diff --git a/crates/socket-patch-core/tests/redirect_golden.rs b/crates/socket-patch-core/tests/redirect_golden.rs index ad4c4311..0fc5a6ec 100644 --- a/crates/socket-patch-core/tests/redirect_golden.rs +++ b/crates/socket-patch-core/tests/redirect_golden.rs @@ -144,6 +144,24 @@ fn redirect_golden_fixtures_match() { assert_eq!(got, expected, "{rel}: edits mismatch"); } + // Warning codes match the recorded list when a case pins one + // (`expected-warnings.json`: a JSON array of code strings, order- + // sensitive). Without it, a refusal fixture proves only "nothing + // changed" — which any refusal, or a silently non-matching entry, + // also produces — so refusal cases MUST ship this file. It is + // optional so that cases which legitimately rewrite AND warn (the + // maven advisories) keep passing unchanged; a positive case may pin + // `[]` to assert a warning-free rewrite. Codes only: the detail text + // is prose that each side may word differently. + let warnings_path = case.join("expected-warnings.json"); + if warnings_path.is_file() { + let expected: Vec = + serde_json::from_str(&fs::read_to_string(&warnings_path).unwrap()) + .unwrap_or_else(|e| panic!("{rel}: bad expected-warnings.json: {e}")); + let got: Vec = result.warnings.iter().map(|w| w.code.clone()).collect(); + assert_eq!(got, expected, "{rel}: warning codes mismatch"); + } + // Determinism: a second run yields identical bytes. let again = rewrite_registry_redirect(&files, &overrides); assert_eq!(again.files, result.files, "{rel}: non-deterministic"); From 9f92f7466a8809012dac50e883b72b15207d0661 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 21 Sep 2026 10:22:28 -0400 Subject: [PATCH 12/49] fix(bun): one actionable lockfileVersion refusal message for hosted and vendored check_lock_version accepts 0, 1 and 2 and parses a u64, so the only reachable `Some(v)` refusal is v >= 3: a lock written by a Bun NEWER than this release tests. Both the vendored gate and the hosted rewriter told the user to "re-lock with bun >= 1.4", which reproduces the same head. The `Some(v)` arm now says the lock is newer than this socket-patch release supports and to update socket-patch (or re-lock with a Bun that writes 0-2); only the `None` arm (no integer head) keeps a re-lock remedy, now "Bun >= 1.2 (`bun install`)", the first release whose default lock is text. rewrite_bun_lock pushes the gate's Err text as the redirect_bun_lock_unsupported detail instead of its own fixed string, so hosted and vendored share exactly one message and cannot drift. Unit tests in both modules pin the per-arm remedies and the equality. Finding: docs-contract:future-lockfileversion-remedy-incoherent. Co-Authored-By: Claude Fable 5.1 --- .../src/patch/redirect/mod.rs | 48 ++++++++++++++-- .../src/vendor/bun_lock_text.rs | 55 +++++++++++++++---- 2 files changed, 89 insertions(+), 14 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 8a9caf6a..11ea7656 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -2402,10 +2402,14 @@ fn rewrite_bun_lock( let Some(content) = files.get("bun.lock") else { return; }; - if check_lock_version(content).is_err() { + // The shared gate's `Err` text IS the detail: hosted and vendored refuse + // an unsupported head with one message (and one remedy per arm — a + // future version means "update socket-patch", a missing integer means + // "re-lock"), so the two modes cannot drift apart. + if let Err(detail) = check_lock_version(content) { result.warnings.push(RewriteWarning { code: "redirect_bun_lock_unsupported".into(), - detail: "bun.lock lockfileVersion is not 0, 1 or 2; re-lock with bun >= 1.4".into(), + detail, }); return; } @@ -6615,10 +6619,46 @@ mod tests { assert!(r.files.is_empty()); assert_eq!(r.warnings[0].code, "redirect_bun_lock_unsupported"); assert!( - r.warnings[0].detail.contains("not 0, 1 or 2"), - "the refusal must name the supported versions: {}", + r.warnings[0] + .detail + .contains("lockfileVersion 3, newer than this socket-patch release supports") + && r.warnings[0].detail.contains("(0, 1 and 2)") + && r.warnings[0].detail.contains("update socket-patch"), + "the refusal must name the found version, the supported set and a remedy that \ + can work (a v3 lock was written by a NEWER Bun): {}", r.warnings[0].detail ); + // One message for both modes: the hosted detail IS the shared gate's + // error text, so vendored and hosted refusals cannot drift apart. + assert_eq!( + r.warnings[0].detail, + crate::vendor::bun_lock_text::check_lock_version(&files["bun.lock"]).unwrap_err() + ); + + // No integer lockfileVersion at all → the OTHER remedy (re-lock). + let mut files = BTreeMap::new(); + files.insert( + "bun.lock".to_string(), + "{\n \"packages\": {\n \ + \"left-pad\": [\"left-pad@1.3.0\", \"\", {}, \"sha512-OLD==\"],\n }\n}\n" + .to_string(), + ); + 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("no integer lockfileVersion") + && r.warnings[0] + .detail + .contains("re-lock with Bun ≥ 1.2 (`bun install`)"), + "a head without an integer version must point at a Bun re-lock: {}", + r.warnings[0].detail + ); + assert_eq!( + r.warnings[0].detail, + crate::vendor::bun_lock_text::check_lock_version(&files["bun.lock"]).unwrap_err() + ); // Non-single-line packages section → fail-closed refusal. let mut files = BTreeMap::new(); 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 d30dc025..ce3f153e 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock_text.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock_text.rs @@ -50,16 +50,27 @@ pub(crate) fn split_name_spec(s: &str) -> Option<(&str, &str)> { /// `"lockfileVersion": ` head check — only the fixture-pinned text /// lockfile versions are spliced (fail-closed on anything newer/older). +/// +/// The `Err` text is the user-facing refusal detail for BOTH the vendored +/// and the hosted (`redirect_bun_lock_unsupported`) paths, so the two modes +/// never drift apart. Each arm's remedy is the one that can actually work: +/// every accepted version is 0, 1 or 2 and the parser yields a `u64`, so an +/// unsupported `Some(v)` is a lock newer than this release knows — written +/// by a Bun newer than any we test — and "re-lock with a newer Bun" would +/// just reproduce it; updating socket-patch (or re-locking with an older +/// Bun) is the fix. Only a head with no integer at all is a lock that a +/// current Bun re-lock repairs. pub(crate) fn check_lock_version(text: &str) -> Result<(), String> { match lock_version(text) { Some(v) if SUPPORTED_LOCK_VERSIONS.contains(&v) => Ok(()), Some(v) => Err(format!( - "bun.lock has lockfileVersion {v}; only 0, 1 and 2 are supported — \ - re-lock with bun >= 1.4" + "bun.lock has lockfileVersion {v}, newer than this socket-patch release supports \ + (0, 1 and 2) — update socket-patch, or re-lock with a Bun release that writes \ + lockfileVersion 0–2" )), None => Err( - "bun.lock has no integer lockfileVersion in its head; only 0, 1 and 2 \ - are supported — re-lock with bun >= 1.4" + "bun.lock has no integer lockfileVersion in its head; only 0, 1 and 2 are \ + supported — re-lock with Bun ≥ 1.2 (`bun install`)" .to_string(), ), } @@ -467,17 +478,41 @@ mod tests { "lockfileVersion {v} must be accepted" ); } + // Every unsupported integer is ≥ 3, i.e. written by a Bun NEWER than + // this release tests: the remedy must be "update socket-patch" (or + // downgrade the writer) — never "re-lock with a newer Bun", which + // would reproduce the same head. for v in [3u64, 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.4"), - "the refusal must name the found version and the remedy: {err}" + err.contains(&format!( + "lockfileVersion {v}, newer than this socket-patch release" + )) && err.contains("(0, 1 and 2)") + && err.contains("update socket-patch") + && err.contains("re-lock with a Bun release that writes lockfileVersion 0–2"), + "the refusal must name the found version and a remedy that can work: {err}" + ); + assert!( + !err.contains(">= 1.4"), + "a future-version refusal must not tell the user to re-lock with the Bun that \ + wrote it: {err}" + ); + } + // Missing / non-integer / string-typed heads fail closed too — and + // THIS is the arm where a plain re-lock with a current Bun is the fix. + for head in [ + "{\n \"packages\": {\n }\n}\n", + "{\n \"lockfileVersion\": \"1\",\n}\n", + "{\n \"lockfileVersion\": one,\n}\n", + ] { + let err = check_lock_version(head).unwrap_err(); + assert!( + err.contains("no integer lockfileVersion") + && err.contains("only 0, 1 and 2 are supported") + && err.contains("re-lock with Bun ≥ 1.2 (`bun install`)"), + "a head without an integer version must point at a Bun re-lock: {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()); } } From a1b0ea070d68afc1d8497aa380516e60725d46e6 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 21 Sep 2026 10:23:06 -0400 Subject: [PATCH 13/49] fix(bun): true remedy for the v0 workspace refusal, pinned on real bun grammar redirect_bun_workspace_unsupported told the user to "upgrade Bun and regenerate the text lockfile" without saying how. Verified with real Bun on a 1.1.45-written lockfileVersion-0 workspace lock: a plain `bun install` with EVERY release from 1.2.0 on (1.2.0, 1.2.23, 1.3.0, 1.3.13, 1.3.14, 1.4.0, 1.4.1, 1.4.2) rewrites it in place as lockfileVersion 1 (the root workspace dep spelling changes from a bare path to `workspace:*`, forcing the save), while 1.1.45 keeps it at 0; a v0 lock WITHOUT workspaces is kept at 0 by 1.2.x and only bumped by >= 1.3.14. The detail now names that remedy: re-lock with Bun >= 1.2 (a plain `bun install` rewrites the lock as lockfileVersion 1, which hosted mode accepts) or delete bun.lock and re-run `bun install`. End-to-end with this CLI: real 1.1.45 v0 workspace lock -> refused, bytes untouched; `bun install` with 1.2.0 -> v1 -> re-scan redirected=1, frozen install rc=0, rollback rc=0. The code was asserted by no test and no test fed the rewriter a lockfileVersion 1/2 lock containing a `workspace:` entry, so widening the gate to every workspace lock passed everything. New unit tests: the real 1.1.39-1.1.45 2-tuple `["consumer@workspace:packages/consumer", { "dependencies": {...} }]` at v0 -> files empty, exactly one warning with this code and the remedy text; the SAME entries at v1 and v2 -> rewritten with the workspace line byte-identical and no warnings (plus the real v1 1-tuple spelling); a v0 lock whose only workspace is the root "" -> rewritten. Mutation-checked: `lock_version(content).is_some()` fails the unit test and the lock-v1-workspace golden case. Fixtures now carry the grammar bun actually writes (captured from bun 1.1.45 / 1.3.14 / 1.4.2 on real workspace projects): the lock-v0-workspace-refusal input is the verbatim 1.1.45 shape (no configVersion, bare-path root workspace dep, 2-tuple member entry with its deps object, blank line between entries) and is still refused; new lock-v1-workspace (1-tuple member, root-declared dep rewritten, warnings []) and lock-v2-workspace-nested (root `left-pad` and nested `consumer/left-pad` at the same version both rewritten, nested `other/left-pad` at another version untouched). Findings: test-quality:redirect-workspace-gate-code-unasserted-no-negative-twin, hosted-engine:v0-workspace-refusal-has-no-code-asserting-test, vendored-engine:v0-fixtures-not-real-bun-grammar (golden half), test-quality:new-unit-tests-weak-oracles-and-v0-fixture-arity (fixture half). Co-Authored-By: Claude Fable 5.1 --- .../src/patch/redirect/mod.rs | 163 +++++++++++++++++- .../lock-v0-workspace-refusal/input/bun.lock | 18 +- .../bun/lock-v1-workspace/expected-edits.json | 10 ++ .../lock-v1-workspace/expected-warnings.json | 1 + .../bun/lock-v1-workspace/expected/bun.lock | 25 +++ .../npm/bun/lock-v1-workspace/input/bun.lock | 25 +++ .../npm/bun/lock-v1-workspace/overrides.json | 13 ++ .../expected-edits.json | 18 ++ .../expected-warnings.json | 1 + .../expected/bun.lock | 39 +++++ .../lock-v2-workspace-nested/input/bun.lock | 39 +++++ .../lock-v2-workspace-nested/overrides.json | 13 ++ 12 files changed, 359 insertions(+), 6 deletions(-) create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v1-workspace/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v1-workspace/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v1-workspace/expected/bun.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v1-workspace/input/bun.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v1-workspace/overrides.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-workspace-nested/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-workspace-nested/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-workspace-nested/expected/bun.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-workspace-nested/input/bun.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-workspace-nested/overrides.json diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 11ea7656..2a884fc3 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -2428,11 +2428,21 @@ fn rewrite_bun_lock( } }; + // Version-0 locks (bun 1.1.39–1.1.45's opt-in text lockfile) with a + // `workspace:` member are refused. The remedy is verified against real + // Bun: a plain `bun install` with ANY release ≥ 1.2.0 (1.2.0, 1.2.23, + // 1.3.0, 1.3.13, 1.3.14, 1.4.0–1.4.2 checked) rewrites a v0 WORKSPACE + // lock in place as lockfileVersion 1 — the root workspace dep spelling + // changes from a bare path to `workspace:*`, which forces the save — + // while 1.1.45 keeps it at 0. (A v0 lock WITHOUT workspaces is kept at + // 0 by 1.2.x and only bumped by ≥ 1.3.14; that case is accepted here.) if lock_version(content) == Some(0) && has_workspace_packages(&entries) { result.warnings.push(RewriteWarning { code: "redirect_bun_workspace_unsupported".into(), detail: "Bun version-0 workspace locks cannot preserve hosted tarballs on frozen \ - installs; upgrade Bun and regenerate the text lockfile" + installs; re-lock with Bun ≥ 1.2 (a plain `bun install` rewrites the lock \ + as lockfileVersion 1, which hosted mode accepts) or delete bun.lock and \ + re-run `bun install`" .into(), }); return; @@ -6784,6 +6794,157 @@ mod tests { assert_eq!(r.warnings[0].code, "redirect_bun_entry_not_found"); } + /// bun's REAL emitted workspace-lock shape (captured from bun 1.1.45 at + /// lockfileVersion 0 and from 1.3.14 / 1.4.2 at 1 / 2): trailing commas + /// throughout and a blank line between packages entries. Only the + /// version integer and the packages entries vary per test; the root + /// workspace dep is spelled as the bare path bun 1.1.x writes (≥ 1.2 + /// writes `workspace:*`) — the rewriter never reads that block. + fn bun_workspace_lock(version: u64, entries: &[&str]) -> String { + format!( + "{{\n \"lockfileVersion\": {version},\n \"workspaces\": {{\n \"\": {{\n \ + \"name\": \"bun-patch-backtest\",\n \"dependencies\": {{\n \ + \"consumer\": \"packages/consumer\",\n }},\n }},\n \ + \"packages/consumer\": {{\n \"name\": \"consumer\",\n \"version\": \ + \"1.0.0\",\n \"dependencies\": {{\n \"left-pad\": \"1.3.0\",\n \ + }},\n }},\n }},\n \"packages\": {{\n{}\n }}\n}}\n", + entries.join("\n\n") + ) + } + + /// The bun 1.1.39–1.1.45 (lockfileVersion 0) workspace refusal, on the + /// grammar those releases actually write — the 2-tuple + /// `["consumer@workspace:packages/consumer", { "dependencies": {…} }]` + /// (v1/v2 write a 1-tuple) — and its positive twins: the SAME entries at + /// lockfileVersion 1 and 2 must rewrite with the workspace line kept + /// byte-identical, proving the gate is version-0-only and not "any + /// workspace lock". Mutations this pins: dropping the `Some(0)` half of + /// the gate, renaming the code, or regressing the remedy text. + #[test] + fn bun_lock_v0_workspace_refuses_and_v1_v2_workspace_rewrite() { + let sha512 = format!("sha512-{}==", "A".repeat(86)); + let ovr = npm_override("left-pad", "1.3.0", "http://p.test/lp.tgz", &sha512); + let ws_v0 = " \"consumer\": [\"consumer@workspace:packages/consumer\", \ + { \"dependencies\": { \"left-pad\": \"1.3.0\" } }],"; + let registry = " \"left-pad\": [\"left-pad@1.3.0\", \"\", {}, \"sha512-OLD==\"],"; + + // Version 0 + workspace member → refused, byte-untouched, exactly one + // warning (no entry-not-found double-warn) with the verified remedy. + let mut files = BTreeMap::new(); + files.insert( + "bun.lock".to_string(), + bun_workspace_lock(0, &[ws_v0, registry]), + ); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.files.is_empty() && r.edits.is_empty(), "{:?}", r.files); + assert_eq!(r.warnings.len(), 1, "{:?}", r.warnings); + assert_eq!(r.warnings[0].code, "redirect_bun_workspace_unsupported"); + let detail = &r.warnings[0].detail; + assert!( + detail.contains("version-0 workspace") + && detail.contains("re-lock with Bun ≥ 1.2") + && detail.contains("`bun install` rewrites the lock as lockfileVersion 1") + && detail.contains("delete bun.lock and re-run `bun install`"), + "the refusal must name the verified remedy: {detail}" + ); + + // The SAME entries at lockfileVersion 1 and 2 → rewritten; the + // workspace line survives byte-for-byte; no warning of any kind. + for version in [1u64, 2] { + let mut files = BTreeMap::new(); + files.insert( + "bun.lock".to_string(), + bun_workspace_lock(version, &[ws_v0, registry]), + ); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.warnings.is_empty(), "v{version}: {:?}", r.warnings); + let out = r + .files + .get("bun.lock") + .unwrap_or_else(|| panic!("a v{version} workspace lock must be rewritten")); + assert!( + out.contains(&format!("\"lockfileVersion\": {version},")), + "{out}" + ); + assert!( + out.contains(&format!("{ws_v0}\n")), + "v{version}: the workspace line must be byte-identical: {out}" + ); + assert!( + out.contains("\"left-pad\": [\"left-pad@http://p.test/lp.tgz\", {}, \"sha512-"), + "v{version}: the registry tuple must become the URL 3-tuple: {out}" + ); + assert_eq!(r.edits.len(), 1, "v{version}: {:?}", r.edits); + assert_eq!(r.edits[0].key.as_deref(), Some("left-pad")); + assert_eq!( + out, + &bun_workspace_lock( + version, + &[ + ws_v0, + &format!( + " \"left-pad\": [\"left-pad@http://p.test/lp.tgz\", {{}}, \ + \"{sha512}\"]," + ) + ] + ), + "v{version}: only the target line may change" + ); + } + + // The 1-tuple spelling bun ≥ 1.2 actually writes for a workspace + // member is rewritten the same way (the gate reads the spec only). + let ws_v1 = " \"consumer\": [\"consumer@workspace:packages/consumer\"],"; + let mut files = BTreeMap::new(); + files.insert( + "bun.lock".to_string(), + bun_workspace_lock(1, &[ws_v1, registry]), + ); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + let out = r + .files + .get("bun.lock") + .expect("real v1 grammar must rewrite"); + assert!(out.contains(&format!("{ws_v1}\n")), "{out}"); + assert!(out.contains("left-pad@http://p.test/lp.tgz"), "{out}"); + } + + /// A version-0 lock whose only `workspaces` key is the root `""` (bun + /// 1.1.45 `--save-text-lockfile` on a plain project — captured grammar: + /// no `configVersion`, trailing commas) has no `workspace:` member and + /// must be rewritten, not refused: the gate is "v0 AND a workspace + /// member", not "v0". + #[test] + fn bun_lock_v0_root_only_workspace_rewrites() { + let sha512 = format!("sha512-{}==", "A".repeat(86)); + let ovr = npm_override("left-pad", "1.3.0", "http://p.test/lp.tgz", &sha512); + let lock = "{\n \"lockfileVersion\": 0,\n \"workspaces\": {\n \"\": {\n \ + \"name\": \"bun-patch-backtest\",\n \"dependencies\": {\n \ + \"left-pad\": \"1.3.0\",\n },\n },\n },\n \"packages\": {\n \ + \"left-pad\": [\"left-pad@1.3.0\", \"\", {}, \"sha512-OLD==\"],\n }\n}\n"; + let mut files = BTreeMap::new(); + files.insert("bun.lock".to_string(), lock.to_string()); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + let out = r + .files + .get("bun.lock") + .expect("a root-only v0 lock must be rewritten"); + assert_eq!( + out, + &lock.replace( + "[\"left-pad@1.3.0\", \"\", {}, \"sha512-OLD==\"]", + &format!("[\"left-pad@http://p.test/lp.tgz\", {{}}, \"{sha512}\"]") + ) + ); + assert_eq!(r.edits.len(), 1); + } + /// A granted dep that matches no rewritable tuple (lock re-resolved to a /// different version) must warn — mirroring pnpm/berry/uv — instead of /// silently dropping out of the `redirected` count. diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0-workspace-refusal/input/bun.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0-workspace-refusal/input/bun.lock index 5448b45d..609a38cf 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0-workspace-refusal/input/bun.lock +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v0-workspace-refusal/input/bun.lock @@ -2,14 +2,22 @@ "lockfileVersion": 0, "workspaces": { "": { + "name": "bun-patch-backtest", + "dependencies": { + "consumer": "packages/consumer", + }, + }, + "packages/consumer": { "name": "consumer", + "version": "1.0.0", "dependencies": { - "left-pad": "^1.3.0" - } - } + "left-pad": "1.3.0", + }, + }, }, "packages": { - "consumer": ["consumer@workspace:packages/consumer"], - "left-pad": ["left-pad@1.3.0", "", {}, "sha512-OLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLD=="], + "consumer": ["consumer@workspace:packages/consumer", { "dependencies": { "left-pad": "1.3.0" } }], + + "left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="], } } diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v1-workspace/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v1-workspace/expected-edits.json new file mode 100644 index 00000000..5f61e5d4 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v1-workspace/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-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==\"],", + "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-v1-workspace/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v1-workspace/expected-warnings.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v1-workspace/expected-warnings.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v1-workspace/expected/bun.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v1-workspace/expected/bun.lock new file mode 100644 index 00000000..3d444ccb --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v1-workspace/expected/bun.lock @@ -0,0 +1,25 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bun-patch-backtest", + "dependencies": { + "consumer": "workspace:*", + "left-pad": "1.3.0", + }, + }, + "packages/consumer": { + "name": "consumer", + "version": "1.0.0", + "dependencies": { + "left-pad": "1.3.0", + }, + }, + }, + "packages": { + "consumer": ["consumer@workspace:packages/consumer"], + + "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-v1-workspace/input/bun.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v1-workspace/input/bun.lock new file mode 100644 index 00000000..4a27bacb --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v1-workspace/input/bun.lock @@ -0,0 +1,25 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bun-patch-backtest", + "dependencies": { + "consumer": "workspace:*", + "left-pad": "1.3.0", + }, + }, + "packages/consumer": { + "name": "consumer", + "version": "1.0.0", + "dependencies": { + "left-pad": "1.3.0", + }, + }, + }, + "packages": { + "consumer": ["consumer@workspace:packages/consumer"], + + "left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v1-workspace/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v1-workspace/overrides.json new file mode 100644 index 00000000..2b81bef3 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v1-workspace/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-v2-workspace-nested/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-workspace-nested/expected-edits.json new file mode 100644 index 00000000..79c10958 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-workspace-nested/expected-edits.json @@ -0,0 +1,18 @@ +[ + { + "path": "bun.lock", + "kind": "redirect_bun_lock_package", + "action": "rewritten", + "key": "consumer/left-pad", + "original": " \"consumer/left-pad\": [\"left-pad@1.3.0\", \"\", {}, \"sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==\"],", + "new": " \"consumer/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==\"]," + }, + { + "path": "bun.lock", + "kind": "redirect_bun_lock_package", + "action": "rewritten", + "key": "left-pad", + "original": " \"left-pad\": [\"left-pad@1.3.0\", \"\", {}, \"sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==\"],", + "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-workspace-nested/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-workspace-nested/expected-warnings.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-workspace-nested/expected-warnings.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-workspace-nested/expected/bun.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-workspace-nested/expected/bun.lock new file mode 100644 index 00000000..d9ae1185 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-workspace-nested/expected/bun.lock @@ -0,0 +1,39 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "bun-patch-backtest", + "dependencies": { + "consumer": "workspace:*", + "left-pad": "1.3.0", + "other": "workspace:*", + }, + }, + "packages/consumer": { + "name": "consumer", + "version": "1.0.0", + "dependencies": { + "left-pad": "1.3.0", + }, + }, + "packages/other": { + "name": "other", + "version": "1.0.0", + "dependencies": { + "left-pad": "1.2.0", + }, + }, + }, + "packages": { + "consumer": ["consumer@workspace:packages/consumer"], + + "consumer/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=="], + + "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=="], + + "other": ["other@workspace:packages/other"], + + "other/left-pad": ["left-pad@1.2.0", "", {}, "sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg=="], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-workspace-nested/input/bun.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-workspace-nested/input/bun.lock new file mode 100644 index 00000000..aa0f3b2e --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-workspace-nested/input/bun.lock @@ -0,0 +1,39 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "bun-patch-backtest", + "dependencies": { + "consumer": "workspace:*", + "left-pad": "1.3.0", + "other": "workspace:*", + }, + }, + "packages/consumer": { + "name": "consumer", + "version": "1.0.0", + "dependencies": { + "left-pad": "1.3.0", + }, + }, + "packages/other": { + "name": "other", + "version": "1.0.0", + "dependencies": { + "left-pad": "1.2.0", + }, + }, + }, + "packages": { + "consumer": ["consumer@workspace:packages/consumer"], + + "consumer/left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="], + + "left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="], + + "other": ["other@workspace:packages/other"], + + "other/left-pad": ["left-pad@1.2.0", "", {}, "sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg=="], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-workspace-nested/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-workspace-nested/overrides.json new file mode 100644 index 00000000..2b81bef3 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-workspace-nested/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==" + } + } +] From 532c40b7872bb0f4c7f00e791bbf09f5c046189a Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 21 Sep 2026 10:23:06 -0400 Subject: [PATCH 14/49] fix(bun): keep CRLF on the rewritten hosted bun.lock line rewrite_bun_lock splits on '\n' and rebuilt the matched entry from its parsed parts, so on a CRLF bun.lock (Windows core.autocrlf checkout) exactly the rewritten line lost its trailing '\r': the file became mixed-EOL and the ledger `new` fragment no longer matched the on-disk bytes the way `original` did (replay matches fragments exactly, so after an autocrlf commit/checkout round-trip a revert would leave '\r\r\n'). The vendored engine (vendor/bun_lock.rs) already re-emits the '\r'; the hosted rewriter now does the same. Verified with real Bun against the production minimist@1.2.2 patch on a CRLF-converted lock: PR-head CLI -> 14 CRLF / 1 LF-only lines and ledger `new` without '\r'; this CLI -> 15/15 CRLF, `original` and `new` both carry '\r', `bun install --frozen-lockfile` rc=0 on 1.4.2 and 1.3.14, rollback restores the CRLF original byte-exact. Adds the bun_crlf_lock_keeps_crlf_on_rewritten_line unit test (modelled on the yarn classic CRLF test: every line keeps CRLF, output == LF rewrite with '\n' -> '\r\n', both ledger fragments end in '\r') and the lock-v2-crlf golden fixture (real bun 1.4.2 grammar, CRLF input + expected; .gitattributes already keeps the fixture tree -text). Mutation-checked: dropping the '\r' re-emit fails both. Findings: windows-macos:hosted-bun-rewrite-drops-cr-mixed-eol, hosted-engine:hosted-bun-rewrite-drops-cr-on-rewritten-line. Co-Authored-By: Claude Fable 5.1 --- .../src/patch/redirect/mod.rs | 71 ++++++++++++++++++- .../npm/bun/lock-v2-crlf/expected-edits.json | 10 +++ .../bun/lock-v2-crlf/expected-warnings.json | 1 + .../npm/bun/lock-v2-crlf/expected/bun.lock | 15 ++++ .../npm/bun/lock-v2-crlf/input/bun.lock | 15 ++++ .../npm/bun/lock-v2-crlf/overrides.json | 13 ++++ 6 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-crlf/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-crlf/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-crlf/expected/bun.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-crlf/input/bun.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-crlf/overrides.json diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 2a884fc3..04b4d2c9 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -2503,8 +2503,15 @@ fn rewrite_bun_lock( } matched_any = true; let original = lines[entry.line_idx].clone(); + // Lines come from a bare `split('\n')`, so a CRLF lock's lines + // carry a trailing `\r` (the grammar trims it away when parsing). + // Re-emit it verbatim — mirroring `vendor/bun_lock.rs` — so the + // rewritten line never becomes the lone LF line of a CRLF file, + // and the ledger `new` fragment matches the on-disk bytes the + // way `original` already does (replay matches fragments exactly). + let cr = if original.ends_with('\r') { "\r" } else { "" }; let rebuilt = format!( - "{indent}{key}: [{url}, {deps}, {integrity}]{comma}", + "{indent}{key}: [{url}, {deps}, {integrity}]{comma}{cr}", indent = entry.indent, key = entry.key_raw, url = serde_json::to_string(&url_spec) @@ -6986,6 +6993,68 @@ mod tests { assert!(r.warnings.is_empty(), "{:?}", r.warnings); } + /// A CRLF bun.lock (Windows `core.autocrlf` checkout) must keep CRLF on + /// the REWRITTEN line too — the vendored engine already does — so the + /// file never ends up mixed-EOL, and the ledger `new` fragment carries + /// the same on-disk `\r` as `original` (replay matches fragments + /// byte-exactly: an LF `new` would no longer be found after an autocrlf + /// commit/checkout round-trip, leaving `\r\r\n` on revert). Modelled on + /// `yarn_classic_crlf_lock_rewrites_only_the_target_entry`. + #[test] + fn bun_crlf_lock_keeps_crlf_on_rewritten_line() { + let sha512 = format!("sha512-{}==", "A".repeat(86)); + let ovr = npm_override("left-pad", "1.3.0", "http://p.test/lp.tgz", &sha512); + let decoy = " \"abbrev\": [\"abbrev@1.1.1\", \"\", {}, \"sha512-DECOYdecoy==\"],"; + let target = " \"left-pad\": [\"left-pad@1.3.0\", \"\", {}, \"sha512-OLD==\"],"; + let lf_lock = bun_workspace_lock(2, &[decoy, target]); + + let mut files = BTreeMap::new(); + files.insert("bun.lock".to_string(), lf_lock.replace('\n', "\r\n")); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.warnings.is_empty(), "clean rewrite: {:?}", r.warnings); + let out = r.files.get("bun.lock").expect("bun.lock must be rewritten"); + assert!( + out.contains(&format!("{decoy}\r\n")), + "the decoy entry must stay byte-identical: {out}" + ); + assert!( + out.contains(&format!( + " \"left-pad\": [\"left-pad@http://p.test/lp.tgz\", {{}}, \"{sha512}\"],\r\n" + )), + "the target entry must pin the hosted artifact AND keep its CRLF: {out}" + ); + assert_eq!( + out.matches('\n').count(), + out.matches("\r\n").count(), + "every line must keep its CRLF ending: {out}" + ); + + // The CRLF output is exactly the LF rewrite re-expanded. + let mut lf_files = BTreeMap::new(); + lf_files.insert("bun.lock".to_string(), lf_lock); + let mut lf_r = RewriteResult::default(); + rewrite_bun_lock(&lf_files, std::slice::from_ref(&ovr), &mut lf_r); + assert_eq!( + out, + &lf_r.files["bun.lock"].replace('\n', "\r\n"), + "CRLF rewrite must equal the LF rewrite modulo line endings" + ); + + // Ledger fragments carry the on-disk (CR-bearing) byte form on BOTH + // sides, so revert finds `new` and restores `original` byte-exactly. + assert_eq!(r.edits.len(), 1); + let original = r.edits[0].original.as_ref().unwrap().as_str().unwrap(); + let new = r.edits[0].new.as_ref().unwrap().as_str().unwrap(); + assert_eq!( + original, + format!("{target}\r"), + "original must carry the \\r" + ); + assert!(new.ends_with("],\r"), "new must carry the \\r too: {new:?}"); + assert!(!new.contains('\n') && !original.contains('\n')); + } + /// A packages header spelled any way other than bun's byte-exact emitted /// shape must fail CLOSED with the unsupported warning — not parse as an /// empty lock and silently skip the dep. diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-crlf/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-crlf/expected-edits.json new file mode 100644 index 00000000..56a82d39 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-crlf/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-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==\"],\r", + "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==\"],\r" + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-crlf/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-crlf/expected-warnings.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-crlf/expected-warnings.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-crlf/expected/bun.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-crlf/expected/bun.lock new file mode 100644 index 00000000..3ef51b75 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-crlf/expected/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "bun-patch-backtest", + "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-crlf/input/bun.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-crlf/input/bun.lock new file mode 100644 index 00000000..dd4a08c3 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-crlf/input/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "bun-patch-backtest", + "dependencies": { + "left-pad": "1.3.0", + }, + }, + }, + "packages": { + "left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-crlf/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-crlf/overrides.json new file mode 100644 index 00000000..2b81bef3 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/lock-v2-crlf/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==" + } + } +] From e1558e0a41e70a008b5f91f35b60cdde62bbf3ea Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 21 Sep 2026 10:23:06 -0400 Subject: [PATCH 15/49] test(bun): golden fixtures for the stale-URL re-pin and bun's alias spelling re-redirect-stale-url: the input already carries a hosted URL 3-tuple from an EARLIER redirect (older token + patch uuid + integrity). The registry `name@version` spec is gone, so ownership is origin + `-.tgz` leaf (is_prior_hosted_bun_spec); the entry is re-pinned to the current URL and sha, and the ledger `original` is the stale URL line. Until now this arm was covered by one unit test only, so the TS<->Rust byte-parity contract never saw it. NOTE for depscan: bun.ts has no prior-URL arm, so this case needs a TS port (or a TS_LAGGING entry) in lockstep. alias: bun's spelling for `"alias": "npm:left-pad@1.3.0"` (captured from bun 1.3.14 and 1.4.2) keys the packages entry by the ALIAS while the tuple spec is the real `left-pad@1.3.0`. The rewriter matches on the spec and re-emits the key verbatim, so the alias is rewritten (as the live 1.4.2 alias-hosted matrix cell showed) and the ledger key is the alias; this pins it. Both pin `expected-warnings.json` = []. Finding: hosted-engine:re-redirect-path-has-no-golden-or-cli-test (golden half). Co-Authored-By: Claude Fable 5.1 --- .../redirect/npm/bun/alias/expected-edits.json | 10 ++++++++++ .../redirect/npm/bun/alias/expected-warnings.json | 1 + .../redirect/npm/bun/alias/expected/bun.lock | 15 +++++++++++++++ .../redirect/npm/bun/alias/input/bun.lock | 15 +++++++++++++++ .../redirect/npm/bun/alias/overrides.json | 13 +++++++++++++ .../bun/re-redirect-stale-url/expected-edits.json | 10 ++++++++++ .../re-redirect-stale-url/expected-warnings.json | 1 + .../bun/re-redirect-stale-url/expected/bun.lock | 15 +++++++++++++++ .../npm/bun/re-redirect-stale-url/input/bun.lock | 15 +++++++++++++++ .../npm/bun/re-redirect-stale-url/overrides.json | 13 +++++++++++++ 10 files changed, 108 insertions(+) create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/alias/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/alias/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/alias/expected/bun.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/alias/input/bun.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/alias/overrides.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/re-redirect-stale-url/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/re-redirect-stale-url/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/re-redirect-stale-url/expected/bun.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/re-redirect-stale-url/input/bun.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/re-redirect-stale-url/overrides.json diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/alias/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/alias/expected-edits.json new file mode 100644 index 00000000..6cf8b17c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/alias/expected-edits.json @@ -0,0 +1,10 @@ +[ + { + "path": "bun.lock", + "kind": "redirect_bun_lock_package", + "action": "rewritten", + "key": "alias", + "original": " \"alias\": [\"left-pad@1.3.0\", \"\", {}, \"sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==\"],", + "new": " \"alias\": [\"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/alias/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/alias/expected-warnings.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/alias/expected-warnings.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/alias/expected/bun.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/alias/expected/bun.lock new file mode 100644 index 00000000..49dac57f --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/alias/expected/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "bun-patch-backtest", + "dependencies": { + "alias": "npm:left-pad@1.3.0", + }, + }, + }, + "packages": { + "alias": ["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/alias/input/bun.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/alias/input/bun.lock new file mode 100644 index 00000000..d5e87e29 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/alias/input/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "bun-patch-backtest", + "dependencies": { + "alias": "npm:left-pad@1.3.0", + }, + }, + }, + "packages": { + "alias": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/alias/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/alias/overrides.json new file mode 100644 index 00000000..2b81bef3 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/alias/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/re-redirect-stale-url/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/re-redirect-stale-url/expected-edits.json new file mode 100644 index 00000000..ad25b2af --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/re-redirect-stale-url/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@https://patch.socket.dev/patch/npm/22222222-2222-2222-2222-222222222222/66666666-6666-6666-6666-666666666666/left-pad-1.3.0.tgz\", {}, \"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/re-redirect-stale-url/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/re-redirect-stale-url/expected-warnings.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/re-redirect-stale-url/expected-warnings.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/re-redirect-stale-url/expected/bun.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/re-redirect-stale-url/expected/bun.lock new file mode 100644 index 00000000..84ef6d3c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/re-redirect-stale-url/expected/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bun-patch-backtest", + "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/re-redirect-stale-url/input/bun.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/re-redirect-stale-url/input/bun.lock new file mode 100644 index 00000000..20143dd3 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/re-redirect-stale-url/input/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bun-patch-backtest", + "dependencies": { + "left-pad": "1.3.0", + }, + }, + }, + "packages": { + "left-pad": ["left-pad@https://patch.socket.dev/patch/npm/22222222-2222-2222-2222-222222222222/66666666-6666-6666-6666-666666666666/left-pad-1.3.0.tgz", {}, "sha512-OLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLDoldOLD=="], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/re-redirect-stale-url/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/re-redirect-stale-url/overrides.json new file mode 100644 index 00000000..2b81bef3 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/re-redirect-stale-url/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==" + } + } +] From 4e1d8ddc40e0735074dd3f9bf316008e562acf8a Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 18 Sep 2026 17:53:21 -0400 Subject: [PATCH 16/49] fix(bun): gate the vendored workspace refusal on the classified instance and make its remedy converge The workspace gate added by the PR ran on the raw bun.lock before the target instances were classified, so a project vendored on a lockfileVersion 0/1 lock that later grew a workspace member was refused every maintenance verb (`vendor`, `scan --mode vendored`, `repair`) with vendor_bun_workspace_unsupported, and `repair` left the lock pointing at a tarball it declined to rebuild (cold `bun install --frozen-lockfile` then fails). The gate now runs after classification and refuses only a run that would WRITE a new local-tarball tuple (a Registry instance); in-sync re-runs and repair rebuilds (every instance already Ours) go through. It stays ahead of staging, so refusals still precede writes. The remedy could not converge: Bun 1.4.x never bumps an existing v1 lock to 2 in place (install, --save-text-lockfile, --force, add, update all keep it), so "upgrade to Bun >= 1.4 and run `bun install`" looped forever. The message now names the lock's version, says to delete bun.lock and re-lock with Bun >= 1.4, notes that an in-place install keeps the version, and offers `--mode hosted`. The gate itself is kept as a documented over-approximation (root-only declarations would work on v1, but the lock cannot cheaply prove who declares an entry). vendor_bun_lockb_unsupported had two emitters with different remedies; the preflight one dropped the contract's `bun install --save-text-lockfile` pointer and said "upgrade Bun". Both now share one const carrying the flag and its 1.1.39 floor. Module doc: integrity is enforced fail-closed by Bun >= 1.3.10 (registry tuples from >= 1.2.0); earlier releases install a tampered tarball with exit 0 (the PR's docs said 1.3.14). Tests use the real per-version workspace grammar (v0: no configVersion, bare-path root dep, 2-tuple member entry with deps; v1/v2: 1-tuple), byte-exact BN3 oracles for the v0 and v2 arms including revert, message assertions, and three new cases: in-sync re-run, rebuild-on-missing, and fresh-vendor-still-refuses on v0/v1 workspace locks. Co-Authored-By: Claude Fable 5.1 --- .../socket-patch-core/src/vendor/bun_lock.rs | 382 ++++++++++++++++-- .../src/vendor/npm_flavor.rs | 7 +- 2 files changed, 344 insertions(+), 45 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 66eeebda..48b7f0fc 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -5,8 +5,12 @@ //! `packages` entry passes `bun install --frozen-lockfile` / `bun ci`, the //! lock stays byte-stable under plain `bun install`, the entry's integrity //! (sha512 of the raw tarball bytes) is enforced fail-closed even on plain -//! installs (BN5), warm caches never shadow the tarball (BN6), and a fresh -//! checkout installs fully offline (BN7). package.json is left UNTOUCHED — +//! installs (BN5) — by Bun >= 1.3.10, the release that started verifying +//! the sha512 of URL/local-tarball tuples (registry 4-tuples are verified +//! from >= 1.2.0); every earlier release installs a tampered tarball with +//! exit 0, so on those consumers the integrity we write is a pin they +//! cannot enforce — warm caches never shadow the tarball (BN6), and a +//! fresh checkout installs fully offline (BN7). package.json is left UNTOUCHED — //! and per-entry edits give exact per-instance targeting that bun's //! name-only `overrides` cannot (BN4: a name-keyed override collapses EVERY //! version; a version-scoped override key is a silent no-op). @@ -56,24 +60,75 @@ const BUN_LOCK: &str = "bun.lock"; /// original/new = the verbatim entry LINE. const KIND_LOCK_PACKAGE: &str = "bun_lock_package"; +/// The ONE remedy text for `vendor_bun_lockb_unsupported`, shared by the +/// flavor router ([`super::npm_flavor::detect_npm_lock_flavor`], reached by +/// `vendor` and detached runs) and [`preflight_vendor`] (reached by +/// `get`/`scan --mode vendored` before any download) so the code never +/// carries two different remedies. `bun install --save-text-lockfile` is +/// the actual fix — plain `bun install` on ANY bun (1.2.x and 1.4.x +/// included) keeps an in-sync bun.lockb as-is — and the flag exists only +/// from 1.1.39 (1.1.38 accepts it silently and still writes bun.lockb), so +/// the floor is spelled out too. +pub(crate) const BUN_LOCKB_UNSUPPORTED_DETAIL: &str = + "bun.lockb is bun's legacy binary lockfile, which vendor cannot rewrite; run `bun install \ + --save-text-lockfile` (Bun >= 1.1.39), commit the resulting bun.lock, and re-run"; + +/// Workspace gate: a `workspace:` packages entry in a lock whose +/// `lockfileVersion` is below 2 refuses with `vendor_bun_workspace_unsupported`. +/// +/// WHY (measured with real binaries, cold caches): Bun 1.2.x–1.3.x resolve +/// a workspace-scoped local-tarball path relative to the workspace MEMBER +/// that declares it — our root-relative `.socket/vendor/npm/…` tuple then +/// ENOENTs on `bun install` — while 1.4.x resolves it relative to the +/// lockfile. The property belongs to the consuming bun binary, which the +/// vendoring machine cannot see; a committed lockfileVersion-2 lock is the +/// only proof that every consumer runs Bun >= 1.4, because 1.3.x cannot +/// parse v2 at all, whereas a v1 lock is readable by both. +/// +/// The gate is a DELIBERATE OVER-APPROXIMATION: a package declared only by +/// the workspace root vendors and installs correctly on every v1 release +/// too, but the lock cannot cheaply prove which workspace declares the +/// entry (hoisted entries collapse root and member declarations into one +/// key), so every pre-v2 workspace lock refuses. Hosted mode accepts these +/// locks (a URL tuple has no path to resolve), which the remedy points at. +/// +/// Bun never bumps an existing lock's version in place — 1.4.x `install`, +/// `--save-text-lockfile`, `--force`, `add` and `update` all keep a v1 lock +/// at version 1; only deleting bun.lock and re-locking writes 2 — so the +/// remedy says exactly that instead of the non-converging "upgrade and run +/// `bun install`". fn check_workspace_compatibility( text: &str, entries: &[BunEntry], ) -> Result<(), (&'static str, String)> { - if lock_version(text) != Some(2) && has_workspace_packages(entries) { - return Err(( - "vendor_bun_workspace_unsupported", - "Bun text locks before version 2 resolve workspace tarballs relative to the \ - workspace rather than the lockfile; upgrade to Bun >= 1.4 and run `bun install` \ - before vendoring workspace dependencies" - .to_string(), - )); - } - Ok(()) + // `check_lock_version` already refused a lock with no integer head, so + // `None` is unreachable here; 0 (the oldest text grammar) is the + // fail-closed reading if it ever were. + let version = lock_version(text).unwrap_or(0); + if version >= 2 || !has_workspace_packages(entries) { + return Ok(()); + } + Err(( + "vendor_bun_workspace_unsupported", + format!( + "Bun releases before 1.4 resolve a workspace-scoped local tarball path relative to \ + the workspace member, and a lockfileVersion-{version} lock may still be installed \ + by such a release; delete bun.lock and re-run `bun install` with Bun >= 1.4 (which \ + writes lockfileVersion 2) before vendoring — an in-place `bun install` keeps the \ + existing lockfileVersion — or use `--mode hosted`, which accepts version-1 \ + workspace locks" + ), + )) } -/// Refuse incompatible Bun projects before downloading records into the manifest. -/// Other package managers are left to their own backends. +/// Refuse incompatible Bun projects before downloading records into the +/// manifest. Other package managers are left to their own backends. +/// +/// PROJECT-LEVEL: this cannot see per-purl state, so it refuses a pre-v2 +/// workspace lock even when the purl in question is already vendored in +/// it (the CLI exempts already-vendored purls before calling it); +/// [`vendor_bun`] itself gates per classified instance and lets in-sync +/// re-runs and `repair` rebuilds through. pub async fn preflight_vendor(project_root: &Path) -> Result<(), (&'static str, String)> { let path = project_root.join(BUN_LOCK); let text = match read_regular_to_string(&path).await { @@ -82,8 +137,7 @@ pub async fn preflight_vendor(project_root: &Path) -> Result<(), (&'static str, if project_root.join("bun.lockb").exists() { return Err(( "vendor_bun_lockb_unsupported", - "Bun binary lockfiles cannot be vendored; upgrade Bun and generate bun.lock" - .to_string(), + BUN_LOCKB_UNSUPPORTED_DETAIL.to_string(), )); } return Ok(()); @@ -148,10 +202,6 @@ pub(crate) async fn vendor_bun( } }; - if let Err((code, detail)) = check_workspace_compatibility(&lock_text, &entries) { - return refused(code, detail); - } - // ── 3. Pre-flight: at least one rewritable instance ────────────────── let target_spec = format!("{name}@{version}"); let target_leaf = tgz_rel_leaf(name, version); @@ -167,6 +217,28 @@ pub(crate) async fn vendor_bun( ), ); } + // Workspace gate, evaluated on the CLASSIFIED target instances rather + // than the raw lock: it refuses only a run that would WRITE a new + // local-tarball tuple (a `Registry` instance) into a pre-v2 workspace + // lock. When every matching instance is already one of ours (`Ours`), + // the lock carries the local tuple regardless of what this run does — + // an in-sync re-run must synthesize AlreadyPatched and a `repair` + // rebuild of a missing/corrupt artifact must proceed (both route here), + // otherwise a project vendored before it grew a workspace member is + // refused every maintenance verb and `repair` leaves the lock pointing + // at a tarball it declined to rebuild. Still ahead of staging, so the + // refusal precedes every write. + let writes_new_local_tuple = entries.iter().any(|e| { + matches!( + classify(e, &target_spec, name, &target_leaf), + Some(TupleShape::Registry) + ) + }); + if writes_new_local_tuple { + if let Err((code, detail)) = check_workspace_compatibility(&lock_text, &entries) { + return refused(code, detail); + } + } // ── 4. Stage → patch → pack (shared flavor-agnostic pipeline) ──────── // A wiring failure past this point must unwind the uuid dir staging is @@ -1271,40 +1343,262 @@ mod tests { ); } + // ── lockfileVersion 0 + workspace locks: real per-version grammar ───── + // + // Provenance (real `bun install --save-text-lockfile` output, verified + // 2026-09-18 on a root + `packages/consumer` workspace project): + // bun 1.1.45 (v0): no `configVersion` line; the root's workspace dep + // is spelled as a bare path (`"consumer": "packages/consumer"`); + // the member's packages entry is a 2-TUPLE carrying its deps object + // (`{}` when dep-less). + // bun 1.3.14 (v1) / 1.4.2 (v2): `configVersion: 1`; `workspace:*`; + // the 1-tuple `["consumer@workspace:packages/consumer"]`. + // Entries are separated by a blank line. Registry 4-tuples are + // grammar-identical across 0/1/2. + + /// Re-head a BN3 lock (before or after) as `lockfileVersion`: the + /// integer, and — on 0 — no `configVersion` line. + fn as_lock_version(base: &str, version: u64) -> String { + let lock = base.replace( + "\"lockfileVersion\": 1,", + &format!("\"lockfileVersion\": {version},"), + ); + if version == 0 { + lock.replace(" \"configVersion\": 1,\n", "") + } else { + lock + } + } + + /// The `packages` entry bun writes for the `consumer` workspace member + /// at `lockfileVersion`. + fn workspace_entry_line(version: u64) -> &'static str { + if version == 0 { + " \"consumer\": [\"consumer@workspace:packages/consumer\", { \"dependencies\": { \"left-pad\": \"1.3.0\" } }]," + } else { + " \"consumer\": [\"consumer@workspace:packages/consumer\"]," + } + } + + /// Add the `consumer` workspace member to a BN3-shaped lock the way bun + /// of that `lockfileVersion` spells it (root dep + first packages entry + /// + blank-line separator). Works on a pre- or post-vendor lock. + fn with_workspace_member(base: &str, version: u64) -> String { + let root_dep = if version == 0 { + "packages/consumer" + } else { + "workspace:*" + }; + let lock = base + .replace( + " \"left-pad\": \"1.3.0\",", + &format!(" \"consumer\": \"{root_dep}\",\n \"left-pad\": \"1.3.0\","), + ) + .replace( + " \"packages\": {\n", + &format!(" \"packages\": {{\n{}\n\n", workspace_entry_line(version)), + ); + assert_ne!(lock, base, "the workspace splice must hit"); + lock + } + + /// A BN3 lock re-spelled as a `lockfileVersion` workspace lock. + fn as_workspace_lock(base: &str, version: u64) -> String { + with_workspace_member(&as_lock_version(base, version), version) + } + + /// The converging remedy: names the lock's version, says to DELETE the + /// lock (an in-place `bun install` keeps the version), and offers + /// hosted mode. + fn assert_workspace_remedy(detail: &str, version: u64) { + assert!(detail.contains("Bun releases before 1.4"), "{detail}"); + assert!( + detail.contains(&format!("lockfileVersion-{version} lock")), + "the detail must name the actual version integer: {detail}" + ); + assert!(detail.contains("delete bun.lock"), "{detail}"); + assert!( + detail.contains("in-place `bun install` keeps the existing lockfileVersion"), + "{detail}" + ); + assert!(detail.contains("--mode hosted"), "{detail}"); + assert!( + !detail.contains("upgrade to Bun"), + "the non-converging remedy must be gone: {detail}" + ); + } + + /// Fresh vendoring on a workspace lock: lockfileVersion 0 and 1 refuse + /// BEFORE any write (preflight and engine alike) with the converging + /// remedy; 2 vendors byte-exactly — the workspace line survives — and + /// reverts byte-exactly. #[tokio::test] async fn legacy_workspace_tarballs_refuse_before_writes() { - for version in [0, 1, 2] { - let lock = BN3_BEFORE_LOCK - .replace("\"lockfileVersion\": 1", &format!("\"lockfileVersion\": {version}")) - .replace(" \"packages\": {", " \"packages\": {\n \"consumer\": [\"consumer@workspace:packages/consumer\"],"); + for version in [0u64, 1, 2] { + let lock = as_workspace_lock(BN3_BEFORE_LOCK, version); let fx = fixture_with(&lock, "node_modules/left-pad").await; if version < 2 { + let (code, detail) = preflight_vendor(fx.root()).await.unwrap_err(); + assert_eq!(code, "vendor_bun_workspace_unsupported", "v{version}"); + assert_workspace_remedy(&detail, version); + let detail = + expect_refused(fx.vendor(false).await, "vendor_bun_workspace_unsupported"); + assert_workspace_remedy(&detail, version); assert_eq!( - preflight_vendor(fx.root()).await.unwrap_err().0, - "vendor_bun_workspace_unsupported" + fx.read_lock().await, + lock, + "v{version}: refusal writes nothing" ); - expect_refused(fx.vendor(false).await, "vendor_bun_workspace_unsupported"); - assert_eq!(fx.read_lock().await, lock); - assert!(!fx.root().join(".socket/vendor").exists()); + assert!(!fx.root().join(".socket/vendor").exists(), "v{version}"); } else { assert!(preflight_vendor(fx.root()).await.is_ok()); - let (_, entry, _) = expect_done(fx.vendor(false).await); - assert!(entry.is_some()); + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.expect("success carries a ledger entry"); + assert_eq!( + fx.read_lock().await, + as_workspace_lock(BN3_AFTER_LOCK, version) + .replace(SPIKE_INTEGRITY, &fx.actual_integrity().await), + "the BN3 transform byte-for-byte, workspace line intact" + ); + let outcome = revert_bun(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!( + fx.read_lock().await, + lock, + "revert byte-restores the workspace lock" + ); } } } + /// Fresh vendor on a v0/v1 workspace lock — a `Registry` target instance, + /// so the run WOULD write a new local-tarball tuple — still refuses, + /// even when a stale uuid dir already sits under `.socket/vendor/` + /// (classification is by lock tuple, never by artifact presence). + #[tokio::test] + async fn fresh_vendor_on_v1_workspace_lock_still_refuses() { + for version in [0u64, 1] { + let lock = as_workspace_lock(BN3_BEFORE_LOCK, version); + let fx = fixture_with(&lock, "node_modules/left-pad").await; + let stale_dir = fx.root().join(format!(".socket/vendor/npm/{UUID}")); + tokio::fs::create_dir_all(&stale_dir).await.unwrap(); + let detail = expect_refused(fx.vendor(false).await, "vendor_bun_workspace_unsupported"); + assert_workspace_remedy(&detail, version); + assert_eq!( + fx.read_lock().await, + lock, + "v{version}: refusal writes nothing" + ); + assert!( + !fx.root().join(fx.rel_tgz()).exists(), + "v{version}: nothing staged or packed" + ); + } + } + + /// The upgrade shape the gate must not regress: a plain v0/v1 lock + /// vendored (as every earlier release did), then the user adds a + /// workspace member and runs `bun install` in place — bun keeps the + /// version and leaves the vendored tuple byte-identical (verified with + /// bun 1.3.14). Returns the fixture, its ledger entry and the resulting + /// workspace lock text. + async fn vendored_then_workspace_added(version: u64) -> (Fixture, VendorEntry, String) { + let fx = fixture_with( + &as_lock_version(BN3_BEFORE_LOCK, version), + "node_modules/left-pad", + ) + .await; + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "v{version}: {:?}", result.error); + let entry = entry.expect("fresh vendor records an entry"); + let lock = with_workspace_member(&fx.read_lock().await, version); + tokio::fs::write(fx.root().join(BUN_LOCK), &lock) + .await + .unwrap(); + (fx, entry, lock) + } + + /// Every matching instance is already ours: the in-sync re-run must + /// synthesize AlreadyPatched (exit-0 `already_vendored` upstream), not + /// refuse — the lock already carries the local tuple whatever this run + /// does. The project-level preflight still refuses (it cannot see + /// per-purl state; the CLI exempts already-vendored purls before it). + #[tokio::test] + async fn in_sync_rerun_on_v1_workspace_lock_is_already_patched_not_refused() { + for version in [0u64, 1] { + let (fx, _entry, lock) = vendored_then_workspace_added(version).await; + assert_eq!( + preflight_vendor(fx.root()).await.unwrap_err().0, + "vendor_bun_workspace_unsupported", + "v{version}: the project-level gate stays blanket" + ); + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "v{version}: {:?}", result.error); + assert!( + entry.is_none(), + "v{version}: in-sync re-run records nothing" + ); + assert!( + result + .files_verified + .iter() + .all(|v| v.status == VerifyStatus::AlreadyPatched), + "v{version}: {:?}", + result.files_verified + ); + assert_eq!(fx.read_lock().await, lock, "v{version}: lock byte-stable"); + } + } + + /// `repair` on a missing artifact drives this exact call: the target + /// instance is `Ours`, so the gate is skipped, the artifact is re-packed + /// byte-identically and the lock is left alone — instead of refusing and + /// leaving the lock pointing at a tarball nobody rebuilt. + #[tokio::test] + async fn rebuild_on_missing_tarball_on_v1_workspace_lock_succeeds() { + for version in [0u64, 1] { + let (fx, _entry, lock) = vendored_then_workspace_added(version).await; + let tgz_path = fx.root().join(fx.rel_tgz()); + let tgz_bytes = tokio::fs::read(&tgz_path).await.unwrap(); + remove_tree(&fx.root().join(format!(".socket/vendor/npm/{UUID}"))) + .await + .unwrap(); + assert!(!tgz_path.exists(), "v{version}: setup deletes the artifact"); + + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "v{version}: {:?}", result.error); + assert!(entry.is_none(), "v{version}: the lock needed no edit"); + assert_eq!( + tokio::fs::read(&tgz_path).await.unwrap(), + tgz_bytes, + "v{version}: deterministic rebuild reproduces the recorded bytes" + ); + assert_eq!(fx.read_lock().await, lock, "v{version}: lock byte-stable"); + } + } + + /// A version-0 head (real bun 1.1.45 shape: no `configVersion`) vendors + /// with the exact BN3 transform and reverts byte-exactly. #[tokio::test] async fn lock_v0_vendor_and_revert_preserve_bytes() { - let lock = BN3_BEFORE_LOCK.replace("\"lockfileVersion\": 1", "\"lockfileVersion\": 0"); + let lock = as_lock_version(BN3_BEFORE_LOCK, 0); let fx = fixture_with(&lock, "node_modules/left-pad").await; assert!(preflight_vendor(fx.root()).await.is_ok()); - let (_, entry, _) = expect_done(fx.vendor(false).await); - assert!(fx.read_lock().await.contains(".socket/vendor/npm/")); - let entry = entry.unwrap(); - let result = revert_bun(&entry, fx.root(), false).await; - assert!(result.success); - assert_eq!(fx.read_lock().await, lock); + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + assert_eq!( + fx.read_lock().await, + as_lock_version(BN3_AFTER_LOCK, 0) + .replace(SPIKE_INTEGRITY, &fx.actual_integrity().await), + "the BN3 transform byte-for-byte under a version-0 head" + ); + let entry = entry.expect("success carries a ledger entry"); + let outcome = revert_bun(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!(fx.read_lock().await, lock, "lock byte-restored"); } #[tokio::test] @@ -1314,10 +1608,16 @@ mod tests { tokio::fs::write(root.path().join("bun.lockb"), b"binary") .await .unwrap(); - assert_eq!( - preflight_vendor(root.path()).await.unwrap_err().0, - "vendor_bun_lockb_unsupported" + let (code, detail) = preflight_vendor(root.path()).await.unwrap_err(); + assert_eq!(code, "vendor_bun_lockb_unsupported"); + // The contract's remedy, from the ONE shared text (the router emits + // the same string for the same code). + assert_eq!(detail, BUN_LOCKB_UNSUPPORTED_DETAIL); + assert!( + detail.contains("bun install --save-text-lockfile") && detail.contains("1.1.39"), + "remedy + version floor: {detail}" ); + assert!(!detail.contains("upgrade Bun"), "{detail}"); tokio::fs::write(root.path().join(BUN_LOCK), BN3_BEFORE_LOCK) .await .unwrap(); diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 8c244328..c4fc541e 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -161,12 +161,11 @@ pub(crate) async fn detect_npm_lock_flavor( break 'flavor NpmLockFlavor::Bun; } if exists("bun.lockb").await { + // One remedy text for this code, shared with the pre-download + // preflight (`bun_lock::preflight_vendor`). return Err(( "vendor_bun_lockb_unsupported", - "bun.lockb is bun's legacy binary lockfile, which vendor cannot rewrite; \ - run `bun install --save-text-lockfile`, commit the resulting bun.lock, \ - and re-run vendor" - .to_string(), + bun_lock::BUN_LOCKB_UNSUPPORTED_DETAIL.to_string(), )); } From 11d1d56e2d620e68cb0ce0a87ccd4f6b5738cb27 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 18 Sep 2026 17:53:21 -0400 Subject: [PATCH 17/49] fix(bun): surface a bun.lockb-only project as a scan diagnosis instead of a silent empty inventory A bun project whose only lockfile is the legacy binary bun.lockb (bun <= 1.1.38 always; 1.1.39-1.1.45 without --save-text-lockfile) with no node_modules scanned as `status: success / scannedPackages: 0` with no warnings in every mode (54 lockfile-only matrix cells passed as clean), because the lock inventory mapped the probe's vendor_bun_lockb_unsupported to the calm Ok(None) reserved for "no lockfile". The inventory now returns an UnsupportedNpmLayout with the stable code bun_lockb_unsupported and an inventory-phrased remedy, which rides scan's additive run-level warnings[] (and the human `Warning (code): detail` line) exactly like the PnP refusals; exit code and status are unchanged. Hosted mode drops that warning only on the NON-empty path, where the hosted driver runs and owns the bun.lockb story (it migrates the lock when a bun candidate exists, or reports its own redirect_bun_lockb_* outcome); the zero-package hosted envelope keeps it, because the driver never runs there and the run would otherwise be the exact silent no-op this closes. Inventory tests now loop lockfileVersion {0, 1, 2} with the real v0 2-tuple workspace entry asserted skipped, and pin the lockb-only diagnosis (plus bun.lock-beside-bun.lockb inventorying normally). Co-Authored-By: Claude Fable 5.1 --- .../socket-patch-cli/src/commands/scan/mod.rs | 27 +++- .../tests/covgap_commands_scan_mod.rs | 131 ++++++++++++++++++ .../src/vendor/lock_inventory.rs | 110 ++++++++++++--- 3 files changed, 248 insertions(+), 20 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index aa48ef6c..c587e8e4 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -1487,10 +1487,13 @@ pub async fn run(mut args: ScanArgs) -> i32 { // are flagged "not yet installed" everywhere a user could act on them. let lockfile_only = lockfile_supplement(&args.common, &all_crawled).await; // Explicit refusals for npm layouts whose packages are structurally - // unreachable (yarn PnP, pnpm node-linker=pnp). Under yarn PnP the - // crawler leg above is ALSO empty (no `node_modules/`), so without this - // channel every mode used to print a clean success with - // `scannedPackages: 0` — a silent no-op the user read as "protected". + // unreachable (yarn PnP, pnpm node-linker=pnp) — and for a bun project + // whose only lock is the legacy binary `bun.lockb` + // (`bun_lockb_unsupported`: the inventory cannot read it). Under yarn + // PnP the crawler leg above is ALSO empty (no `node_modules/`), as it + // is on a fresh clone of a bun.lockb project, so without this channel + // every mode used to print a clean success with `scannedPackages: 0` — + // a silent no-op the user read as "protected". // Surfaced as run-level `warnings[]` in the JSON envelope (omitted when // empty) and a stderr line on the human path; exit code and `status` // stay deliberately unchanged (same posture as hosted refusals, which @@ -1736,6 +1739,22 @@ pub async fn run(mut args: ScanArgs) -> i32 { return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; } + // bun.lockb-only discovery diagnosis on the NON-EMPTY hosted path: the + // hosted driver runs from here on and owns the bun.lockb story — it + // auto-migrates the binary lock to bun.lock when a bun candidate exists + // (after which "cannot be inventoried" would be stale in the same + // envelope) and otherwise reports its own `redirect_bun_lockb_*` outcome + // on `redirect.warnings` — so the discovery-side warning is dropped to + // keep one voice per file. The zero-package envelope above keeps it in + // EVERY mode: the hosted driver never runs there, and without it a + // lockb-only fresh clone is exactly the silent success-0 no-op this + // channel exists to close. Agent and vendored runs keep it on both paths. + if hosted { + layout_refusals.retain(|(code, _)| { + code != socket_patch_core::vendor::lock_inventory::BUN_LOCKB_UNSUPPORTED_CODE + }); + } + // Build ecosystem summary let mut eco_parts = Vec::new(); for eco in Ecosystem::all() { diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs index d53e6887..75ae508a 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs @@ -1205,3 +1205,134 @@ mod pty { assert_eq!(view_gets(&reqs), 0, "declining must not download the patch"); } } + +// --------------------------------------------------------------------------- +// bun.lockb-only project: a diagnosis, never a silent success-0 +// --------------------------------------------------------------------------- +// A bun project whose only lockfile is the legacy binary `bun.lockb` (bun +// <= 1.1.38 always; 1.1.39–1.1.45 without `--save-text-lockfile`) with no +// `node_modules/` (fresh clone, CI lockfile-only checkout) used to scan as +// `status: success / scannedPackages: 0` with NO warnings in every mode — +// indistinguishable from an empty project (54 such matrix cells passed as +// "clean"). The lock inventory now surfaces `bun_lockb_unsupported`, which +// rides scan's additive run-level `warnings[]` (PnP-refusal precedent); +// exit code and `status` stay unchanged. + +fn write_bun_lockb_only_project(root: &Path) { + std::fs::write( + root.join("package.json"), + r#"{ "name": "covgap-lockb-only", "version": "0.0.0", "dependencies": { "minimist": "1.2.2" } }"#, + ) + .unwrap(); + std::fs::write(root.join("bun.lockb"), b"\0binary bun lockfile").unwrap(); +} + +fn bun_lockb_warning(v: &serde_json::Value) -> Option<&serde_json::Value> { + v["warnings"] + .as_array() + .and_then(|ws| ws.iter().find(|w| w["code"] == "bun_lockb_unsupported")) +} + +/// Zero-package path, every mode (agent, vendored, hosted): exit 0, the +/// envelope stays a `success` with `scannedPackages: 0`, and `warnings[]` +/// carries `bun_lockb_unsupported` with the `--save-text-lockfile` remedy. +/// Hosted mode keeps it here because the hosted driver — which owns the +/// bun.lockb migration story on a NON-empty scan — never runs on an empty +/// one; without the warning this is exactly the old silent no-op. +#[test] +fn scan_bun_lockb_only_project_warns_instead_of_silent_success() { + for mode in [None, Some("vendored"), Some("hosted")] { + let tmp = tempfile::tempdir().unwrap(); + write_bun_lockb_only_project(tmp.path()); + let mut args = vec!["--json"]; + if let Some(mode) = mode { + args.extend(["--mode", mode]); + } + let (code, stdout, stderr) = run_scan(tmp.path(), &args); + assert_eq!( + code, 0, + "mode={mode:?}: refusals never flip the exit; stdout={stdout}; stderr={stderr}" + ); + let v: serde_json::Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|e| panic!("bad JSON ({e}): {stdout}")); + assert_eq!(v["status"], "success", "mode={mode:?}: {v}"); + assert_eq!(v["scannedPackages"], 0, "mode={mode:?}: {v}"); + assert_eq!(v["lockfileOnlyPackages"], 0, "mode={mode:?}: {v}"); + let warning = bun_lockb_warning(&v).unwrap_or_else(|| { + panic!("mode={mode:?}: warnings[] must carry bun_lockb_unsupported: {v}") + }); + let detail = warning["detail"].as_str().unwrap_or_default(); + assert!( + detail.contains("bun install --save-text-lockfile") && detail.contains("1.1.39"), + "mode={mode:?}: the remedy and its version floor: {detail}" + ); + assert!( + !stdout.contains("Warning ("), + "mode={mode:?}: the human warning line must not leak into the JSON stream: {stdout}" + ); + } + + // Human path: the same diagnosis as a stderr `Warning (code): detail` + // line, exit 0, and the generic "No packages found" hint still prints. + let tmp = tempfile::tempdir().unwrap(); + write_bun_lockb_only_project(tmp.path()); + let (code, stdout, stderr) = run_scan(tmp.path(), &[]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert!( + stderr + .contains("Warning (bun_lockb_unsupported): bun.lockb is bun's legacy binary lockfile"), + "the human path must name the layout and the code; got {stderr:?}" + ); + assert!( + stderr.contains("--save-text-lockfile"), + "the human path must carry the remedy; got {stderr:?}" + ); + assert!(stdout.contains("No packages found"), "{stdout:?}"); +} + +/// NON-empty hosted scan (an installed package beside the bun.lockb): the +/// hosted driver runs and owns the bun.lockb story (`redirect_bun_lockb_*` +/// on `redirect.warnings`, or an actual migration), so the discovery-side +/// `bun_lockb_unsupported` is dropped there — while the same project in +/// agent mode keeps it on its non-empty envelope. +#[tokio::test] +async fn scan_hosted_nonempty_drops_the_bun_lockb_discovery_warning() { + let mock = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [], + "canAccessPaidPatches": false, + }))) + .mount(&mock) + .await; + + for (mode, expect_warning) in [(None, true), (Some("hosted"), false)] { + let tmp = tempfile::tempdir().unwrap(); + write_bun_lockb_only_project(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2", b"module.exports = 1;\n"); + let uri = mock.uri(); + let mut args = vec![ + "--json", + "--api-url", + uri.as_str(), + "--api-token", + "fake-token-for-test", + "--org", + ORG_SLUG, + ]; + if let Some(mode) = mode { + args.extend(["--mode", mode]); + } + let (code, stdout, stderr) = run_scan(tmp.path(), &args); + assert_eq!(code, 0, "mode={mode:?}: stdout={stdout}; stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|e| panic!("bad JSON ({e}): {stdout}")); + assert_eq!(v["scannedPackages"], 1, "mode={mode:?}: {v}"); + assert_eq!( + bun_lockb_warning(&v).is_some(), + expect_warning, + "mode={mode:?}: hosted drops the discovery-side lockb warning on the non-empty path, agent keeps it: {v}" + ); + } +} diff --git a/crates/socket-patch-core/src/vendor/lock_inventory.rs b/crates/socket-patch-core/src/vendor/lock_inventory.rs index ab611f58..b06a1d67 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory.rs @@ -105,26 +105,47 @@ impl LockfileEntry { /// A project layout whose npm-family packages the inventory structurally /// CANNOT serve — distinct from "no lockfile" (`Ok(None)`), which is a /// normal, silent state. Today: the Plug'n'Play loaders (yarn berry's PnP, -/// and pnpm's own `node-linker=pnp` mode). Consumers surface this as an +/// and pnpm's own `node-linker=pnp` mode), and a bun project whose only +/// lockfile is the legacy binary `bun.lockb`. Consumers surface this as an /// explicit refusal instead of a silent empty inventory: under yarn PnP the /// installed-tree crawl is ALSO structurally empty (no `node_modules/`), so /// swallowing this diagnosis used to turn `scan` into a silent -/// success-0 no-op in every mode. +/// success-0 no-op in every mode — and a fresh clone of a bun.lockb project +/// (no `node_modules/`, an unreadable lock) scanned exactly the same way. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UnsupportedNpmLayout { - /// Stable diagnosis code from the flavor probe: - /// `vendor_yarn_berry_unsupported` or `vendor_pnpm_pnp_unsupported`. + /// Stable diagnosis code: the flavor probe's + /// `vendor_yarn_berry_unsupported` / `vendor_pnpm_pnp_unsupported`, or + /// [`BUN_LOCKB_UNSUPPORTED_CODE`]. pub code: &'static str, /// Human-readable diagnosis with remedy. pub detail: String, } +/// Stable diagnosis code for a bun project whose only lockfile is the +/// legacy binary `bun.lockb` (bun <= 1.1.38 always, 1.1.39–1.1.45 without +/// `--save-text-lockfile`). Spelled for scan's `warnings[]` (the CLI +/// forwards it verbatim), parallel to `yarn_pnp_unsupported` / +/// `pnpm_pnp_unsupported`; the vendor-side refusal for the same file keeps +/// its own `vendor_bun_lockb_unsupported` code. +pub const BUN_LOCKB_UNSUPPORTED_CODE: &str = "bun_lockb_unsupported"; + +/// Inventory-phrased detail for [`BUN_LOCKB_UNSUPPORTED_CODE`]: what was +/// NOT discovered and the one remedy (the flag exists from Bun 1.1.39; +/// plain `bun install` on any release keeps an in-sync bun.lockb as-is). +pub const BUN_LOCKB_UNSUPPORTED_INVENTORY_DETAIL: &str = + "bun.lockb is bun's legacy binary lockfile and cannot be inventoried; run `bun install \ + --save-text-lockfile` (Bun >= 1.1.39) so lockfile-only discovery and vendored mode can \ + read it"; + /// Inventory the project's npm-family lockfile. Routes by /// [`detect_npm_lock_flavor`]. `Ok(None)` means there is nothing to -/// inventory (missing lockfile, bun.lockb, dep-less locks); `Err` -/// propagates the probe's Plug'n'Play diagnosis — a layout whose packages -/// the inventory can NEVER serve, which callers must not conflate with the -/// calm no-lockfile case. Two pnpm-specific refusals fall back instead of +/// inventory (missing lockfile, dep-less locks); `Err` propagates the +/// probe's Plug'n'Play diagnosis — a layout whose packages the inventory +/// can NEVER serve — and the bun.lockb-only diagnosis (a lock that DOES +/// name the resolved set, in a binary encoding this reader cannot parse), +/// which callers must not conflate with the calm no-lockfile case. Two +/// pnpm-specific refusals fall back instead of /// yielding `None`: an unsupported `lockfileVersion` reads the root /// `pnpm-lock.yaml` directly — unless a live sibling lock the router would /// otherwise have chosen sits beside it (a pnpm→yarn/npm migration @@ -151,6 +172,20 @@ pub(crate) async fn inventory_npm_lock( ) { return Err(UnsupportedNpmLayout { code, detail }); } + // bun.lockb with no bun.lock is a DIAGNOSIS too, not an absence: + // the binary lock names the resolved set just as a text lock + // would, we simply cannot read it — and on a fresh clone the + // installed-tree crawl is empty as well, so the calm `Ok(None)` + // here made `scan` print a clean `scannedPackages: 0` success + // in every mode (54 lockfile-only matrix cells at bun <= + // 1.1.45). Own code + inventory-phrased remedy; the probe's + // vendor-phrased text stays with the vendor refusal. + if code == "vendor_bun_lockb_unsupported" { + return Err(UnsupportedNpmLayout { + code: BUN_LOCKB_UNSUPPORTED_CODE, + detail: BUN_LOCKB_UNSUPPORTED_INVENTORY_DETAIL.to_string(), + }); + } // The flavor probe passes only pnpm locks the WIRING backends // support (lockfileVersion 5.4/6.0/9.0), but inventory is // read-only discovery — an out-of-family (pnpm <= 6-era or @@ -2617,18 +2652,52 @@ packages: } /// Same stale-lock hazard for a pnpm→bun migration: bun.lockb refuses - /// with a bun-specific code, so the pnpm fallback must stay out. + /// with a bun-specific code, so the pnpm fallback must stay out — and + /// the refusal now surfaces as the bun.lockb diagnosis rather than a + /// silent `None`. #[tokio::test] async fn stale_pnpm_lock_behind_bun_lockb_is_not_inventoried() { let tmp = tempfile::tempdir().unwrap(); write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK).await; write(tmp.path(), "bun.lockb", "\0binary").await; - assert!( - inventory_npm_lock(tmp.path()).await.unwrap().is_none(), + let diag = inventory_npm_lock(tmp.path()).await.unwrap_err(); + assert_eq!( + diag.code, BUN_LOCKB_UNSUPPORTED_CODE, "a stale pnpm-lock.yaml behind bun.lockb must not be inventoried" ); } + /// A bun.lockb-only project (bun <= 1.1.38, or 1.1.39–1.1.45 without + /// `--save-text-lockfile`) is a diagnosis, never a silent `None`: scan + /// rides it onto `warnings[]` instead of reporting a clean empty + /// project. A text bun.lock beside it wins (bun reads bun.lock when + /// both exist), and the diagnosis reaches `inventory_project_diagnosed`. + #[tokio::test] + async fn bun_lockb_only_project_yields_a_diagnosis_not_silence() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "bun.lockb", "\0binary").await; + let diag = inventory_npm_lock(tmp.path()).await.unwrap_err(); + assert_eq!(diag.code, "bun_lockb_unsupported"); + assert!( + diag.detail.contains("bun install --save-text-lockfile") + && diag.detail.contains("1.1.39") + && diag.detail.contains("cannot be inventoried"), + "inventory-phrased remedy with the version floor: {}", + diag.detail + ); + let (entries, unsupported) = inventory_project_diagnosed(tmp.path()).await; + assert!(entries.is_empty(), "{entries:?}"); + assert_eq!(unsupported, vec![diag]); + + // Migrated: bun.lock present (bun.lockb left behind, as bun 1.1.45 + // does) → inventoried normally, no diagnosis. + write(tmp.path(), "bun.lock", BUN_LOCK).await; + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert_eq!(flavor, NpmLockFlavor::Bun); + assert_eq!(entry(&entries, "left-pad").version, "1.3.0"); + assert!(inventory_project_diagnosed(tmp.path()).await.1.is_empty()); + } + /// A pnpm-lock.yaml whose lockfileVersion the probe refuses — pnpm 6 /// wrote 5.3; only 5.4/6.0/9.0 route to a backend. This is the shape /// that reaches the version-refusal discovery fallback, where a live @@ -3060,15 +3129,20 @@ __metadata: "@scope/pkg": ["@scope/pkg@2.0.0", "", {}, "sha512-scoped=="], "vendored": ["vendored@file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/vendored-3.0.0.tgz", {}], "linked": ["linked@workspace:packages/linked", {}], + "consumer": ["consumer@workspace:packages/consumer", { "dependencies": { "left-pad": "1.3.0" } }], } } "#; #[tokio::test] async fn bun_registry_tuples_parse_and_locals_are_skipped() { - // 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] { + // lockfileVersion 0 (bun 1.1.39–1.1.45 text opt-in), 1 (bun 1.2/1.3) + // and 2 (bun 1.4) share one registry-tuple grammar, so inventory + // must read all three identically. The workspace entries carry the + // real v0 spelling — a 2-tuple with the member's deps object + // (`{}` when dep-less) — and are skipped like every non-registry + // shape. + for version in [0u64, 1, 2] { let lock = BUN_LOCK.replace( "\"lockfileVersion\": 1,", &format!("\"lockfileVersion\": {version},"), @@ -3086,9 +3160,13 @@ __metadata: ); 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:?}"); + for absent in ["vendored", "linked", "consumer"] { + assert!( + !entries.iter().any(|e| e.name == absent), + "lockfileVersion {version}: `{absent}` must be skipped: {entries:?}" + ); } + assert_eq!(entries.len(), 2, "lockfileVersion {version}: {entries:?}"); } // An unsupported lockfileVersion (a future 3) yields no inventory at From 4a767c2edf71a896998fdd02884ab530cc25fba0 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 18 Sep 2026 17:53:21 -0400 Subject: [PATCH 18/49] test(bun): run the repair flavor suite over bun lockfileVersion {0,1,2} x workspace shapes No test layer ran `repair` on a workspace-bearing or lockfileVersion-0 bun.lock, which is how the workspace-gate repair regression slipped past 1,809 green matrix rows. The bun arm of repair_rebuilds_deleted_* / repair_rebuilds_corrupt_* now covers six shapes; v0/v1 workspace shapes are reached the way real projects reach them (vendored first, member added afterwards, since a fresh vendor into such a lock is refused by design) with the real per-version workspace entry grammar, asserting a byte-identical rebuild, unchanged lock bytes and exit 0. Co-Authored-By: Claude Fable 5.1 --- .../tests/repair_vendor_flavors_e2e.rs | 215 +++++++++++++++--- 1 file changed, 185 insertions(+), 30 deletions(-) diff --git a/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e.rs b/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e.rs index a14c1b59..24fa198e 100644 --- a/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e.rs +++ b/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e.rs @@ -49,20 +49,125 @@ fn git_sha256(content: &[u8]) -> String { /// The three npm flavors this file parameterizes over. Each knows how to lay /// down its pre-vendor lockfile and how to prove the vendor lock rewrite -/// survived a repair. +/// survived a repair. The bun arm is further parameterized over the text +/// lock's `lockfileVersion` and the presence of a workspace member. #[derive(Clone, Copy)] enum Flavor { Pnpm, YarnBerry, - Bun, + Bun(BunLock), +} + +/// One bun.lock shape: `lockfileVersion` 0 (bun 1.1.39–1.1.45 text opt-in), +/// 1 (bun 1.2/1.3) or 2 (bun 1.4), with or without a `workspace:` packages +/// entry. Workspace shapes matter because the vendor engine's workspace +/// gate refuses a FRESH vendor into a pre-v2 workspace lock, while `repair` +/// (and in-sync re-runs) on a lock that ALREADY carries the vendored tuple +/// must keep working — a project vendored before it grew a workspace member +/// used to be refused every maintenance verb, with `repair` leaving the lock +/// pointing at a tarball it declined to rebuild. +#[derive(Clone, Copy)] +struct BunLock { + version: u64, + workspace: bool, +} + +impl BunLock { + /// The plain v1 shape the flavor-generic arms use. + const V1: BunLock = BunLock { + version: 1, + workspace: false, + }; + + /// lockfileVersion {0, 1, 2} × {plain, workspace}. + const MATRIX: [BunLock; 6] = [ + BunLock { + version: 0, + workspace: false, + }, + BunLock { + version: 0, + workspace: true, + }, + BunLock { + version: 1, + workspace: false, + }, + BunLock { + version: 1, + workspace: true, + }, + BunLock { + version: 2, + workspace: false, + }, + BunLock { + version: 2, + workspace: true, + }, + ]; + + /// The `packages` entry bun writes for the `consumer` workspace member — + /// the REAL per-version grammar (bun 1.1.45 vs 1.3.14/1.4.2 output): v0 + /// emits a 2-tuple carrying the member's deps object, v1/v2 the + /// 1-tuple. Followed by bun's blank-line entry separator. + fn workspace_entry(self) -> &'static str { + if self.version == 0 { + " \"consumer\": [\"consumer@workspace:packages/consumer\", { \"dependencies\": { \"left-pad\": \"1.3.0\" } }],\n\n" + } else { + " \"consumer\": [\"consumer@workspace:packages/consumer\"],\n\n" + } + } + + /// Only a v2 lock accepts a FRESH vendor with the workspace entry + /// present; the v0/v1 workspace shapes are reached the way real projects + /// reach them — vendored first, workspace member added afterwards (bun + /// keeps both the version and the vendored tuple on an in-place + /// `bun install`; see [`BunLock::add_workspace_member`]). + fn workspace_present_before_vendor(self) -> bool { + self.workspace && self.version == 2 + } + + /// The pre-vendor lock text (real bun shape: no `configVersion` line on + /// v0; the registry 4-tuple is grammar-identical across 0/1/2). + fn lock_text(self, with_workspace: bool) -> String { + format!( + "{{\n \"lockfileVersion\": {},\n \"packages\": {{\n{} \"{DEP}\": \ + [\"{DEP}@{DEP_VERSION}\", \"\", {{}}, \"sha512-orig==\"],\n }}\n}}\n", + self.version, + if with_workspace { + self.workspace_entry() + } else { + "" + }, + ) + } + + /// Splice the workspace member into an already-vendored lock — what the + /// post-vendor `bun install` leaves behind (vendored tuple byte-identical, + /// version unchanged). + fn add_workspace_member(self, root: &Path) { + let path = root.join("bun.lock"); + let lock = std::fs::read_to_string(&path).unwrap(); + let spliced = lock.replacen( + " \"packages\": {\n", + &format!(" \"packages\": {{\n{}", self.workspace_entry()), + 1, + ); + assert_ne!(spliced, lock, "the workspace splice must hit"); + std::fs::write(&path, spliced).unwrap(); + } } impl Flavor { - fn tag(self) -> &'static str { + fn tag(self) -> String { match self { - Flavor::Pnpm => "pnpm", - Flavor::YarnBerry => "yarn-berry", - Flavor::Bun => "bun", + Flavor::Pnpm => "pnpm".to_string(), + Flavor::YarnBerry => "yarn-berry".to_string(), + Flavor::Bun(BunLock { version, workspace }) => format!( + "bun(lockfileVersion {version}{})", + if workspace { ", workspace" } else { "" } + ), } } @@ -71,10 +176,23 @@ impl Flavor { match self { Flavor::Pnpm => "pnpm-lock.yaml", Flavor::YarnBerry => "yarn.lock", - Flavor::Bun => "bun.lock", + Flavor::Bun(_) => "bun.lock", } } + /// The `consumer` workspace member's manifest (bun refuses to install a + /// lock that names a missing member; the engine is lock-only, this keeps + /// the fixture honest). + fn workspace_member(self) -> bool { + matches!( + self, + Flavor::Bun(BunLock { + workspace: true, + .. + }) + ) + } + /// Write the pre-vendor lockfile (the shape each backend's capstone /// asserts as its `lock_before`). Extra files (`.yarnrc.yml` for berry) /// are laid down too. @@ -127,14 +245,10 @@ snapshots: ) .unwrap(); } - Flavor::Bun => { + Flavor::Bun(shape) => { std::fs::write( root.join("bun.lock"), - format!( - "{{\n \"lockfileVersion\": 1,\n \"packages\": {{\n \ - \"{DEP}\": [\"{DEP}@{DEP_VERSION}\", \"\", {{}}, \"sha512-orig==\"],\n \ - }}\n}}\n" - ), + shape.lock_text(shape.workspace_present_before_vendor()), ) .unwrap(); } @@ -161,7 +275,7 @@ snapshots: // berry: the `file:./` locator entry. Flavor::YarnBerry => format!("{DEP}@file:./{tgz_rel}"), // bun: the local-tarball 3-tuple element 0 `@`. - Flavor::Bun => format!("\"{DEP}@{tgz_rel}\""), + Flavor::Bun(_) => format!("\"{DEP}@{tgz_rel}\""), } } } @@ -169,14 +283,30 @@ snapshots: /// Vendorable flavor project: package.json + the flavor lockfile + the /// installed package copy the vendor backend packs from. fn write_fixture(root: &Path, flavor: Flavor) { + let workspaces = if flavor.workspace_member() { + r#","workspaces":["packages/*"]"# + } else { + "" + }; std::fs::write( root.join("package.json"), format!( - r#"{{"name":"{}","version":"0.0.0","private":true,"dependencies":{{"{DEP}":"{DEP_VERSION}"}}}}"#, + r#"{{"name":"{}","version":"0.0.0","private":true{workspaces},"dependencies":{{"{DEP}":"{DEP_VERSION}"}}}}"#, flavor.root_name() ), ) .unwrap(); + if flavor.workspace_member() { + let member = root.join("packages/consumer"); + std::fs::create_dir_all(&member).unwrap(); + std::fs::write( + member.join("package.json"), + format!( + r#"{{"name":"consumer","version":"1.0.0","dependencies":{{"{DEP}":"{DEP_VERSION}"}}}}"# + ), + ) + .unwrap(); + } flavor.write_lock(root); let pkg = root.join("node_modules").join(DEP); @@ -284,12 +414,28 @@ fn run_cli(root: &Path, mock_uri: &str, argv: &[&str]) -> (i32, String, String) } /// `scan --vendor --yes` to establish a vendored flavor project; returns the -/// vendored tarball path (identical layout for every npm flavor). -fn vendor_project(root: &Path, mock_uri: &str) -> PathBuf { +/// vendored tarball path (identical layout for every npm flavor). A v0/v1 +/// bun workspace shape gains its workspace member AFTER vendoring — the only +/// way such a lock arises (a fresh vendor into it is refused by design). +fn vendor_project(root: &Path, mock_uri: &str, flavor: Flavor) -> PathBuf { let (code, stdout, stderr) = run_cli(root, mock_uri, &["scan", "--vendor", "--yes"]); - assert_eq!(code, 0, "vendor setup failed: {stdout} {stderr}"); + assert_eq!( + code, + 0, + "{}: vendor setup failed: {stdout} {stderr}", + flavor.tag() + ); let tgz = root.join(format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz")); - assert!(tgz.is_file(), "setup must vendor the tarball: {stdout}"); + assert!( + tgz.is_file(), + "{}: setup must vendor the tarball: {stdout}", + flavor.tag() + ); + if let Flavor::Bun(shape) = flavor { + if shape.workspace && !shape.workspace_present_before_vendor() { + shape.add_workspace_member(root); + } + } tgz } @@ -321,7 +467,7 @@ async fn deleted_tarball_rebuilds(flavor: Flavor) { mount_patch_api(&mock).await; let tmp = tempfile::tempdir().unwrap(); write_fixture(tmp.path(), flavor); - let tgz = vendor_project(tmp.path(), &mock.uri()); + let tgz = vendor_project(tmp.path(), &mock.uri(), flavor); let tgz_bytes = std::fs::read(&tgz).unwrap(); let lock1 = std::fs::read(tmp.path().join(flavor.lock_name())).unwrap(); @@ -363,9 +509,16 @@ async fn repair_rebuilds_deleted_yarn_berry_tarball() { deleted_tarball_rebuilds(Flavor::YarnBerry).await; } +/// Every bun.lock shape — lockfileVersion {0, 1, 2} × {plain, workspace}. +/// `repair` rebuilds through the vendor engine, whose workspace gate must +/// let an already-vendored (`Ours`) instance through on a pre-v2 workspace +/// lock instead of refusing and leaving the lock pointing at a tarball +/// nobody rebuilt (cold `bun install --frozen-lockfile` then ENOENTs). #[tokio::test] async fn repair_rebuilds_deleted_bun_tarball() { - deleted_tarball_rebuilds(Flavor::Bun).await; + for shape in BunLock::MATRIX { + deleted_tarball_rebuilds(Flavor::Bun(shape)).await; + } } // ── (b) corrupt tarball → detected + rebuilt ─────────────────────────────── @@ -375,7 +528,7 @@ async fn corrupt_tarball_rebuilds(flavor: Flavor) { mount_patch_api(&mock).await; let tmp = tempfile::tempdir().unwrap(); write_fixture(tmp.path(), flavor); - let tgz = vendor_project(tmp.path(), &mock.uri()); + let tgz = vendor_project(tmp.path(), &mock.uri(), flavor); let tgz_bytes = std::fs::read(&tgz).unwrap(); std::fs::write(&tgz, b"\x1f\x8bgarbage").unwrap(); @@ -405,7 +558,9 @@ async fn repair_rebuilds_corrupt_yarn_berry_tarball() { #[tokio::test] async fn repair_rebuilds_corrupt_bun_tarball() { - corrupt_tarball_rebuilds(Flavor::Bun).await; + for shape in BunLock::MATRIX { + corrupt_tarball_rebuilds(Flavor::Bun(shape)).await; + } } // ── (c) tampered ledger sha → fail-closed ────────────────────────────────── @@ -415,7 +570,7 @@ async fn tampered_ledger_fails_closed(flavor: Flavor) { mount_patch_api(&mock).await; let tmp = tempfile::tempdir().unwrap(); write_fixture(tmp.path(), flavor); - let tgz = vendor_project(tmp.path(), &mock.uri()); + let tgz = vendor_project(tmp.path(), &mock.uri(), flavor); let state_path = tmp.path().join(".socket/vendor/state.json"); let state = std::fs::read_to_string(&state_path).unwrap(); @@ -452,7 +607,7 @@ async fn repair_fails_closed_on_tampered_yarn_berry_ledger_sha() { #[tokio::test] async fn repair_fails_closed_on_tampered_bun_ledger_sha() { - tampered_ledger_fails_closed(Flavor::Bun).await; + tampered_ledger_fails_closed(Flavor::Bun(BunLock::V1)).await; } // ── (d) ledger deleted wholesale → reconstruct from lockfile references ───── @@ -462,7 +617,7 @@ async fn ledger_gone_reconstructs_from_lock(flavor: Flavor) { mount_patch_api(&mock).await; let tmp = tempfile::tempdir().unwrap(); write_fixture(tmp.path(), flavor); - let tgz = vendor_project(tmp.path(), &mock.uri()); + let tgz = vendor_project(tmp.path(), &mock.uri(), flavor); let lock1 = std::fs::read(tmp.path().join(flavor.lock_name())).unwrap(); // The whole .socket/vendor tree (state.json included) is gone — only the @@ -521,7 +676,7 @@ async fn ledger_gone_drifted_copy_fails_closed(flavor: Flavor) { mount_patch_api(&mock).await; let tmp = tempfile::tempdir().unwrap(); write_fixture(tmp.path(), flavor); - let tgz = vendor_project(tmp.path(), &mock.uri()); + let tgz = vendor_project(tmp.path(), &mock.uri(), flavor); let lock1 = std::fs::read(tmp.path().join(flavor.lock_name())).unwrap(); // Drift an UNPATCHED part of the installed copy (patched-file tampering @@ -579,7 +734,7 @@ async fn repair_fails_closed_on_drifted_copy_yarn_berry() { #[tokio::test] async fn repair_fails_closed_on_drifted_copy_bun() { - ledger_gone_drifted_copy_fails_closed(Flavor::Bun).await; + ledger_gone_drifted_copy_fails_closed(Flavor::Bun(BunLock::V1)).await; } #[tokio::test] @@ -613,7 +768,7 @@ async fn revert_of_reconstructed_pnpm_entry_fails_closed_then_recovers() { write_fixture(tmp.path(), Flavor::Pnpm); let lock_pre = std::fs::read(tmp.path().join("pnpm-lock.yaml")).unwrap(); let pkg_pre = std::fs::read(tmp.path().join("package.json")).unwrap(); - let tgz = vendor_project(tmp.path(), &mock.uri()); + let tgz = vendor_project(tmp.path(), &mock.uri(), Flavor::Pnpm); let lock_vendored = std::fs::read(tmp.path().join("pnpm-lock.yaml")).unwrap(); // Ledger gone; artifact + rewired lock intact (the empirical shape). @@ -724,5 +879,5 @@ async fn repair_reconstructs_yarn_berry_ledger_from_lockfile() { #[tokio::test] async fn repair_reconstructs_bun_ledger_from_lockfile() { - ledger_gone_reconstructs_from_lock(Flavor::Bun).await; + ledger_gone_reconstructs_from_lock(Flavor::Bun(BunLock::V1)).await; } From 436891f3042e3e795cb31fd92bbb293cd1dbdc8c Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 18 Sep 2026 17:50:07 -0400 Subject: [PATCH 19/49] fix(bun): preflight every vendored download path, keep refusals visible under --silent `scan --mode vendored --detached` skipped the Bun preflight: it fetched the patch view first and, for alias-installed packages on a bun.lockb project, the vendor step then misreported `package_not_installed`. The preflight now runs once on every path that feeds the vendor engine (manifest-tracked and detached download phases, `get ` / `get --mode vendored`, and the `--dry-run` previews), BEFORE any `/patches/view/` fetch, through one shared `BunVendorRefusal` helper. - `--silent` is "errors only": the purl-path `[error]` line prints whenever not JSON and is code-tagged (`[error] (): `); the uuid path's `Error (): ` drops its `!silent` gate. - Already-vendored exemption: a purl the vendor ledger wires at the SAME uuid the run selected is never refused (in-sync re-runs and the pre-gate upgrade path reach the engine's already_vendored skip); an unreadable ledger exempts nothing (fail closed). - uuid-path envelope parity: the failed record gains `error` and the envelope gains `skipped: 0`; the refusal fires `patch_vendor_failed` telemetry. The search path and both scan arms report run-outcome telemetry (`has_errors`, download refusals included) instead of a success event on an exit-1 run. - `--dry-run` previews emit the additive `would_refuse` action (+ `errorCode` / `error`) for npm purls the wet run would refuse; status and exit code are unchanged; the human dry-run names them as `[would-refuse]` lines. Unit tests: download_patch_records refuses lockb / v1 workspace before any fetch, skips non-npm purls, exempts the in-sync ledger entry; the preview classifies would_refuse / already_vendored / lockb. Co-Authored-By: Claude Fable 5.1 --- crates/socket-patch-cli/src/commands/get.rs | 649 ++++++++++++++++-- .../src/commands/scan/vendor_flow.rs | 207 +++++- 2 files changed, 798 insertions(+), 58 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index dab69b7b..a71ff58c 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -898,8 +898,7 @@ fn purl_has_version(purl: &str) -> bool { /// positive costs one grant request the rewriter's per-dep confirmation /// then ignores. fn pnpm_lock_resolves(text: &str, name: &str, version: &str) -> bool { - let version_boundary = - |c: char| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+')); + let version_boundary = |c: char| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+')); let name_boundary = |c: char| matches!(c, ' ' | '\t' | '\n' | '\r' | '\'' | '"'); for needle in [format!("{name}@{version}"), format!("/{name}/{version}")] { for (pos, _) in text.match_indices(needle.as_str()) { @@ -908,7 +907,10 @@ fn pnpm_lock_resolves(text: &str, name: &str, version: &str) -> bool { // v5/v6's leading key delimiter — legitimate only when the // char before it is itself a boundary (otherwise this is a // scoped `@scope/` tail: a DIFFERENT package). - Some('/') => text[..pos - 1].chars().next_back().is_none_or(name_boundary), + Some('/') => text[..pos - 1] + .chars() + .next_back() + .is_none_or(name_boundary), Some(c) => name_boundary(c), }; let after_ok = text[pos + needle.len()..] @@ -1031,8 +1033,7 @@ async fn filter_to_installed_purls( // keep-branch below on version membership, so a large advisory fan-out // doesn't request grants for every version ever patched (raw // `read_to_string` matches the hosted flow's own candidate-file reads). - let pnpm_pnp_lock_text: Option = (pnp_pnpm - && mode == super::scan::ScanMode::Hosted) + let pnpm_pnp_lock_text: Option = (pnp_pnpm && mode == super::scan::ScanMode::Hosted) .then(|| std::fs::read_to_string(common.cwd.join("pnpm-lock.yaml")).ok()) .flatten(); @@ -1167,6 +1168,116 @@ async fn api_client_for(params: &DownloadParams) -> socket_patch_core::api::clie .0 } +/// The Bun vendored-mode preflight outcome shared by EVERY path that feeds +/// the vendor engine: `scan --mode vendored` (the manifest-tracked AND the +/// `--detached` download phases), `get … --mode vendored` (search and uuid +/// paths), and their `--dry-run` previews. One read-only +/// [`preflight_vendor`] per run, evaluated BEFORE any `/patches/view/` +/// fetch, so an incompatible Bun project (binary `bun.lockb` without a text +/// lock, an unreadable lock, an unsupported `lockfileVersion`, a +/// pre-version-2 `workspace:` lock) never has a patch downloaded on its +/// behalf — let alone recorded in the manifest — and every entry point +/// reports the SAME vendor code (a detached run used to fetch first and +/// then, for alias-installed packages, degrade to `package_not_installed`). +/// +/// `exempt` holds the selected purls the vendor ledger ALREADY wires at the +/// SAME uuid this run selected: in-sync re-runs, and the upgrade path of a +/// project vendored before the workspace gate existed. The refusal must not +/// pre-empt those — they flow through to the engine's `already_vendored` +/// skip exactly as on a non-Bun project. An unreadable ledger exempts +/// nothing (fail closed; the vendor step reports the corrupt ledger itself). +/// +/// [`preflight_vendor`]: socket_patch_core::vendor::bun_lock::preflight_vendor +pub(crate) struct BunVendorRefusal { + /// The stable vendor error code (`vendor_bun_lockb_unsupported`, + /// `vendor_lockfile_missing`, `vendor_lockfile_version_unsupported`, + /// `vendor_bun_workspace_unsupported`) — the same string the vendor + /// engine would have emitted as a `failed` event. + pub(crate) code: &'static str, + /// The engine's human-readable detail, relayed verbatim. + pub(crate) detail: String, + exempt: std::collections::HashSet, +} + +impl BunVendorRefusal { + /// Whether the refusal applies to `purl`: npm-family only (no other + /// ecosystem's backend consults `bun.lock`), minus the already-vendored + /// exemption. + pub(crate) fn applies_to(&self, purl: &str) -> bool { + purl.starts_with("pkg:npm/") && !self.exempt.contains(purl) + } +} + +/// Run the Bun preflight once for `selected` — only when it holds at least +/// one npm purl, since nothing else can be affected — and compute the +/// already-vendored exemption from the vendor ledger at `cwd`. `None` means +/// nothing to refuse. +pub(crate) async fn bun_vendor_preflight( + cwd: &Path, + selected: &[PatchSearchResult], +) -> Option { + if !selected.iter().any(|s| s.purl.starts_with("pkg:npm/")) { + return None; + } + let (code, detail) = socket_patch_core::vendor::bun_lock::preflight_vendor(cwd) + .await + .err()?; + let state = socket_patch_core::vendor::load_state(cwd) + .await + .unwrap_or_default(); + Some(bun_vendor_refusal_with_ledger( + code, + detail, + selected, + &state.entries, + )) +} + +/// [`bun_vendor_preflight`] for callers that already loaded the ledger (the +/// detached download phase, the dry-run preview). +pub(crate) async fn bun_vendor_preflight_with_ledger( + cwd: &Path, + selected: &[PatchSearchResult], + entries: &HashMap, +) -> Option { + if !selected.iter().any(|s| s.purl.starts_with("pkg:npm/")) { + return None; + } + let (code, detail) = socket_patch_core::vendor::bun_lock::preflight_vendor(cwd) + .await + .err()?; + Some(bun_vendor_refusal_with_ledger( + code, detail, selected, entries, + )) +} + +fn bun_vendor_refusal_with_ledger( + code: &'static str, + detail: String, + selected: &[PatchSearchResult], + entries: &HashMap, +) -> BunVendorRefusal { + let exempt = selected + .iter() + .filter(|s| { + // The ledger is keyed by the manifest purl (possibly qualified) + // and `lookup_entry` also resolves base purls; try the selected + // spelling first, then its qualifier-free base. + socket_patch_core::vendor::lookup_entry(entries, &s.purl) + .or_else(|| { + socket_patch_core::vendor::lookup_entry(entries, strip_purl_qualifiers(&s.purl)) + }) + .is_some_and(|e| e.uuid == s.uuid) + }) + .map(|s| s.purl.clone()) + .collect(); + BunVendorRefusal { + code, + detail, + exempt, + } +} + /// Download and apply a set of selected patches. /// /// Used by both `get` and `scan` commands. Returns (exit_code, json_result). @@ -1209,6 +1320,21 @@ pub(crate) async fn download_patch_records( .await .unwrap_or_default(); + // The same Bun preflight the manifest-tracked download runs (see + // `download_and_apply_patches`): a detached run feeds the same vendor + // engine, so it must refuse the same projects BEFORE fetching. Without + // it the patch view was downloaded for nothing and — for a package + // installed under an alias directory, resolvable only through the + // unreadable bun.lockb inventory — the vendor step then misreported + // `package_not_installed` instead of the real `vendor_bun_*` code. + // `persist_blobs` is never set on this (vendor-only) path; the gate + // mirrors the manifest-tracked download's posture defensively. + let bun_refusal = if params.persist_blobs { + None + } else { + bun_vendor_preflight_with_ledger(¶ms.cwd, &selected, &vendor_state.entries).await + }; + let mut records: HashMap = HashMap::new(); let mut downloaded = 0usize; let mut skipped = 0usize; @@ -1235,6 +1361,29 @@ pub(crate) async fn download_patch_records( continue; } + if let Some(refusal) = bun_refusal + .as_ref() + .filter(|r| r.applies_to(&search_result.purl)) + { + // Errors are exempt from --silent ("errors only"); JSON runs + // carry the code + detail in the envelope instead. + if !params.json { + eprintln!( + " [error] {} ({}): {}", + search_result.purl, refusal.code, refusal.detail + ); + } + failed += 1; + patch_records_json.push(serde_json::json!({ + "purl": search_result.purl, + "uuid": search_result.uuid, + "action": "failed", + "errorCode": refusal.code, + "error": refusal.detail, + })); + continue; + } + // org slug is already stored in the client. match api_client.fetch_patch(None, &search_result.uuid).await { Ok(Some(patch)) => { @@ -1513,29 +1662,37 @@ pub async fn download_and_apply_patches( let mut downloaded_patches: Vec = Vec::new(); // Vendored downloads must not claim a patch in the manifest when Bun - // cannot consume its artifact. Agent/save-only flows retain their intent. + // cannot consume its artifact (see `BunVendorRefusal`). Agent/save-only + // flows (`persist_blobs`) retain their record-only intent: the preflight + // is scoped to the `save_only && !persist_blobs` posture the vendored + // flows use, never the agent download. let bun_refusal = if params.save_only && !params.persist_blobs { - socket_patch_core::vendor::bun_lock::preflight_vendor(¶ms.cwd) - .await - .err() + bun_vendor_preflight(¶ms.cwd, &selected).await } else { None }; for search_result in &selected { - if let Some((code, detail)) = bun_refusal + if let Some(refusal) = bun_refusal .as_ref() - .filter(|_| search_result.purl.starts_with("pkg:npm/")) + .filter(|r| r.applies_to(&search_result.purl)) { patches_failed += 1; downloaded_patches.push(serde_json::json!({ "purl": search_result.purl, "uuid": search_result.uuid, "action": "failed", - "errorCode": code, - "error": detail, + "errorCode": refusal.code, + "error": refusal.detail, })); - if !params.json && !params.silent { - eprintln!(" [error] {}: {detail}", search_result.purl); + // Errors are exempt from --silent ("errors only", like the + // `[fail]` lines below); JSON runs carry the code + detail in + // the envelope instead. Code-tagged so a `--silent` operator + // can grep the stable code, not just the prose. + if !params.json { + eprintln!( + " [error] {} ({}): {}", + search_result.purl, refusal.code, refusal.detail + ); } continue; } @@ -2699,6 +2856,25 @@ fn boxed_download_and_apply<'a>( Box::pin(download_and_apply_patches(selected, params)) } +/// Human rendering of the vendored dry-run preview's `would_refuse` records +/// (see [`super::scan::preview_vendor_json`]): the count line above it still +/// says "would download and vendor", so name what the wet run would refuse +/// and why. Informational (the preview exits 0), hence behind the caller's +/// `--silent` gate. +fn print_dry_run_refusals(preview: &serde_json::Value) { + let Some(patches) = preview["patches"].as_array() else { + return; + }; + for p in patches.iter().filter(|p| p["action"] == "would_refuse") { + println!( + " [would-refuse] {} ({}): {}", + p["purl"].as_str().unwrap_or_default(), + p["errorCode"].as_str().unwrap_or_default(), + p["error"].as_str().unwrap_or_default() + ); + } +} + /// Print the whole-manifest blast-radius note for `--mode vendored`: the /// vendor step is scan's — it reconciles and (re)vendors EVERY manifest /// record, not just the one(s) this get selected. @@ -2814,6 +2990,7 @@ async fn run_get_vendored_search( "[dry-run] Would download and vendor {} patch(es).", selected.len() ); + print_dry_run_refusals(&preview); } return 0; } @@ -2867,8 +3044,11 @@ async fn run_get_vendored_search( { Ok((vendor_errors, venv)) => { has_errors |= vendor_errors; + // Telemetry follows the RUN outcome, not the vendor step alone: + // a download-phase refusal/failure exits 1 and must not report + // a successful vendoring of zero patches (scan's arms agree). crate::commands::vendor::track_outcomes_for_vendor( - vendor_errors, + has_errors, &venv, args.common.dry_run, telemetry_token, @@ -2946,33 +3126,66 @@ async fn run_get_vendored_uuid( print_json(&result); } else if !args.common.silent { println!("[dry-run] Would download and vendor 1 patch."); + print_dry_run_refusals(&preview); } return 0; } - if patch.purl.starts_with("pkg:npm/") { - if let Err((code, message)) = - socket_patch_core::vendor::bun_lock::preflight_vendor(&args.common.cwd).await - { - if args.common.json { - print_json(&serde_json::json!({ - "status": "error", - "found": 1, - "downloaded": 0, - "failed": 1, - "error": { "code": code, "message": message }, - "patches": [{ - "purl": patch.purl, - "uuid": patch.uuid, - "action": "failed", - "errorCode": code, - }], - })); - } else if !args.common.silent { - eprintln!("Error ({code}): {message}"); - } - return 1; + // Bun preflight (see `BunVendorRefusal`): refuse BEFORE the manifest + // record is saved and before the vendor step, so the tree stays exactly + // as it was (no `.socket/` is created on a fresh project). The + // already-fetched patch is the only network traffic of a refused run. + // + // JSON shape (contract: `get --mode vendored` pre-record refusal; + // the record carries BOTH `errorCode` and `error` like the search path's + // failed records, and the envelope carries `skipped` like this path's + // success shape): + // + // { + // "status": "error", + // "found": 1, "downloaded": 0, "skipped": 0, "failed": 1, + // "error": { "code": "", "message": "" }, + // "patches": [{ "purl": "…", "uuid": "…", "action": "failed", + // "errorCode": "", "error": "" }] + // } + // + // Human: `Error (): ` on stderr — an error, so it is + // exempt from `--silent` like every other `Error (…)` line here. + let selected = vec![search_result_from_response(patch)]; + if let Some(refusal) = bun_vendor_preflight(&args.common.cwd, &selected) + .await + .filter(|r| r.applies_to(&patch.purl)) + { + let BunVendorRefusal { code, detail, .. } = refusal; + // Same failure telemetry as the vendor-step Err arm below: this run + // exits 1 without vendoring anything. + socket_patch_core::telemetry::track_patch_vendor_failed( + &detail, + args.common.dry_run, + telemetry_token, + telemetry_org, + ) + .await; + if args.common.json { + print_json(&serde_json::json!({ + "status": "error", + "found": 1, + "downloaded": 0, + "skipped": 0, + "failed": 1, + "error": { "code": code, "message": detail }, + "patches": [{ + "purl": patch.purl, + "uuid": patch.uuid, + "action": "failed", + "errorCode": code, + "error": detail, + }], + })); + } else { + eprintln!("Error ({code}): {detail}"); } + return 1; } note_vendored_whole_manifest_scope(&manifest_path, &[patch.purl.as_str()], quiet).await; @@ -4208,8 +4421,16 @@ mod tests { assert!(pnpm_lock_resolves("left-pad@1.3.0:\n", "left-pad", "1.3.0")); // pos == 0, v5/v6 `/name/version` and `/name@version` spellings: the // leading `/` delimiter itself has nothing before it. - assert!(pnpm_lock_resolves("/left-pad/1.3.0:\n", "left-pad", "1.3.0")); - assert!(pnpm_lock_resolves("/left-pad@1.3.0:\n", "left-pad", "1.3.0")); + assert!(pnpm_lock_resolves( + "/left-pad/1.3.0:\n", + "left-pad", + "1.3.0" + )); + assert!(pnpm_lock_resolves( + "/left-pad@1.3.0:\n", + "left-pad", + "1.3.0" + )); // Still boundary-checked at the start of text: a scoped tail whose // name begins mid-token must NOT match. assert!(!pnpm_lock_resolves( @@ -4479,7 +4700,10 @@ mod tests { assert_eq!(code, 1, "guardrail failure must exit 1; json={json}"); assert_eq!(json["failed"], 1, "json={json}"); assert_eq!(json["downloaded"], 0, "json={json}"); - assert!(records.is_empty(), "no record may be handed to the vendor step"); + assert!( + records.is_empty(), + "no record may be handed to the vendor step" + ); assert_eq!(json["patches"][0]["action"], "failed", "json={json}"); assert_eq!( json["patches"][0]["error"], "patch has no applicable files", @@ -4553,9 +4777,10 @@ mod tests { .as_array() .unwrap_or_else(|| panic!("keep-all fallback must surface warnings; json={json}")); assert!( - warnings - .iter() - .any(|w| w.as_str().unwrap_or_default().contains("not installed locally")), + warnings.iter().any(|w| w + .as_str() + .unwrap_or_default() + .contains("not installed locally")), "warning must explain the keep-all fallback; json={json}" ); } @@ -4665,7 +4890,10 @@ mod tests { crate::commands::scan::ScanMode::Hosted, ) .await; - assert!(out.kept.is_empty(), "nothing may be kept via a corrupt ledger"); + assert!( + out.kept.is_empty(), + "nothing may be kept via a corrupt ledger" + ); assert_eq!(out.skip_records.len(), 1); assert_eq!(out.skip_records[0]["errorCode"], "package_not_installed"); } @@ -4825,7 +5053,11 @@ mod tests { ); assert!(records.is_empty()); assert!( - server.received_requests().await.unwrap_or_default().is_empty(), + server + .received_requests() + .await + .unwrap_or_default() + .is_empty(), "the failure must precede any fetch" ); assert_eq!( @@ -4877,7 +5109,10 @@ mod tests { json["patches"][0]["error"], "Blob decode or write failed", "json={json}" ); - assert!(records.is_empty(), "a blob failure must not hand back a record"); + assert!( + records.is_empty(), + "a blob failure must not hand back a record" + ); let blobs = tmp.path().join(".socket/blobs"); assert!(blobs.is_dir(), "the blobs dir itself was created"); assert_eq!( @@ -4907,7 +5142,9 @@ mod tests { let missing_purl = "pkg:npm/covgap-missing@1.0.0"; Mock::given(method("GET")) - .and(wm_path(format!("/v0/orgs/test-org/patches/view/{good_uuid}"))) + .and(wm_path(format!( + "/v0/orgs/test-org/patches/view/{good_uuid}" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "uuid": good_uuid, "purl": good_purl, "publishedAt": "2024-01-01T00:00:00Z", @@ -4960,7 +5197,10 @@ mod tests { .iter() .filter_map(|p| p["error"].as_str()) .collect(); - assert!(errors.contains(&"patch has no applicable files"), "json={json}"); + assert!( + errors.contains(&"patch has no applicable files"), + "json={json}" + ); assert!(errors.contains(&"could not fetch details"), "json={json}"); } @@ -5028,11 +5268,320 @@ mod tests { "the ledger's embedded record must be reused" ); assert!( - server.received_requests().await.unwrap_or_default().is_empty(), + server + .received_requests() + .await + .unwrap_or_default() + .is_empty(), "an already-vendored entry must never touch the network" ); } + // --- download_patch_records: Bun preflight (detached parity) ----------- + // The detached download phase must refuse the same Bun projects the + // manifest-tracked one does, BEFORE any view fetch (request-log oracle), + // and with the vendor code (never the downstream `package_not_installed` + // the alias-shaped lockb project used to degrade to). + + /// A real bun 1.3.14 lockfileVersion-1 workspace lock (matrix capture + /// grammar): 1-tuple `workspace:` entry, blank line between entries, + /// trailing commas. + const BUN_V1_WORKSPACE_LOCK: &str = r#"{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "unit-fixture", + "dependencies": { + "consumer": "workspace:*", + }, + }, + "packages/consumer": { + "name": "consumer", + "version": "1.0.0", + "dependencies": { + "covgap-bun": "1.0.0", + }, + }, + }, + "packages": { + "consumer": ["consumer@workspace:packages/consumer"], + + "covgap-bun": ["covgap-bun@1.0.0", "", {}, "sha512-AAAA=="], + } +} +"#; + + fn seed_bun_vendor_entry(root: &Path, purl: &str, uuid: &str) { + let vendor = root.join(".socket/vendor"); + std::fs::create_dir_all(&vendor).unwrap(); + std::fs::write( + vendor.join("state.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "version": 1, + "entries": { purl: { + "ecosystem": "npm", + "basePurl": purl, + "uuid": uuid, + "artifact": { + "path": format!(".socket/vendor/npm/{uuid}/covgap-bun-1.0.0.tgz"), + }, + "wiring": [], + "flavor": "bun", + }} + })) + .unwrap(), + ) + .unwrap(); + } + + #[tokio::test] + #[serial_test::serial] + async fn download_patch_records_bun_lockb_refuses_before_fetch() { + use wiremock::matchers::{method, path as wm_path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let server = MockServer::start().await; + let uuid = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + let purl = "pkg:npm/covgap-bun@1.0.0"; + // A view that WOULD succeed — proves the refusal is decided before + // the fetch, not by a failed fetch. + Mock::given(method("GET")) + .and(wm_path(format!("/v0/orgs/test-org/patches/view/{uuid}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": uuid, "purl": purl, + "publishedAt": "2024-01-01T00:00:00Z", + "files": { "package/index.js": { + "beforeHash": "0".repeat(64), "afterHash": "1".repeat(64), + "blobContent": "cGF0Y2hlZAo=", + }}, + "vulnerabilities": {}, "description": "d", "license": "MIT", "tier": "free", + }))) + .mount(&server) + .await; + + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lockb"), b"\x00binary").unwrap(); + let selected = vec![mk_patch(uuid, purl, "free", "2024-01-01")]; + let (code, json, records) = + download_patch_records(&selected, &detached_params(tmp.path(), server.uri())).await; + + assert_eq!(code, 1, "json={json}"); + assert_eq!(json["found"], 1, "json={json}"); + assert_eq!(json["downloaded"], 0, "json={json}"); + assert_eq!(json["failed"], 1, "json={json}"); + assert_eq!(json["patches"][0]["action"], "failed", "json={json}"); + assert_eq!( + json["patches"][0]["errorCode"], "vendor_bun_lockb_unsupported", + "json={json}" + ); + assert!( + json["patches"][0]["error"] + .as_str() + .is_some_and(|d| !d.is_empty()), + "the record must carry the engine's detail; json={json}" + ); + assert!(records.is_empty(), "no record may reach the vendor step"); + assert!( + server + .received_requests() + .await + .unwrap_or_default() + .is_empty(), + "a refused Bun project must never fetch the patch view" + ); + } + + #[tokio::test] + #[serial_test::serial] + async fn download_patch_records_bun_v1_workspace_refuses_before_fetch() { + use wiremock::MockServer; + + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let server = MockServer::start().await; // trap: no mounts + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lock"), BUN_V1_WORKSPACE_LOCK).unwrap(); + let uuid = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; + let purl = "pkg:npm/covgap-bun@1.0.0"; + let selected = vec![mk_patch(uuid, purl, "free", "2024-01-01")]; + + let (code, json, records) = + download_patch_records(&selected, &detached_params(tmp.path(), server.uri())).await; + + assert_eq!(code, 1, "json={json}"); + assert_eq!(json["failed"], 1, "json={json}"); + assert_eq!( + json["patches"][0]["errorCode"], "vendor_bun_workspace_unsupported", + "json={json}" + ); + assert!(records.is_empty()); + assert!( + server + .received_requests() + .await + .unwrap_or_default() + .is_empty(), + "refused before any fetch" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("bun.lock")).unwrap(), + BUN_V1_WORKSPACE_LOCK, + "the preflight is read-only" + ); + } + + /// The preflight is npm-only: a non-npm purl on a Bun-refused tree is + /// fetched as usual (here: the view is unmounted, so it fails as a fetch + /// miss — proving it reached the network, not the refusal). + #[tokio::test] + #[serial_test::serial] + async fn download_patch_records_bun_refusal_skips_non_npm_purls() { + use wiremock::MockServer; + + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let server = MockServer::start().await; + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lockb"), b"\x00binary").unwrap(); + let uuid = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"; + let purl = "pkg:pypi/covgap-not-bun@1.0.0"; + let selected = vec![mk_patch(uuid, purl, "free", "2024-01-01")]; + + let (code, json, _) = + download_patch_records(&selected, &detached_params(tmp.path(), server.uri())).await; + + assert_eq!(code, 1, "json={json}"); + assert_eq!( + json["patches"][0]["error"], "could not fetch details", + "a pypi purl must reach the fetch, not the Bun refusal; json={json}" + ); + assert!(json["patches"][0].get("errorCode").is_none(), "json={json}"); + assert_eq!( + server.received_requests().await.unwrap_or_default().len(), + 1, + "exactly the view fetch" + ); + } + + /// Already-vendored exemption: a ledger entry at the SAME uuid the run + /// selected is not refused (it proceeds to the fetch — unmounted here, + /// so it surfaces as a fetch miss), while a second purl the ledger + /// wires at an OLDER uuid is still refused before fetching. + #[tokio::test] + #[serial_test::serial] + async fn download_patch_records_bun_refusal_exempts_ledger_entry_at_same_uuid() { + use wiremock::MockServer; + + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let server = MockServer::start().await; + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lock"), BUN_V1_WORKSPACE_LOCK).unwrap(); + let same = "ffffffff-ffff-4fff-8fff-ffffffffffff"; + let older = "abababab-abab-4bab-8bab-abababababab"; + let newer = "cdcdcdcd-cdcd-4dcd-8dcd-cdcdcdcdcdcd"; + let in_sync = "pkg:npm/covgap-bun@1.0.0"; + let stale = "pkg:npm/covgap-bun-stale@1.0.0"; + // Two ledger entries: one in sync with the selection, one stale. + let vendor = tmp.path().join(".socket/vendor"); + std::fs::create_dir_all(&vendor).unwrap(); + let entry = |purl: &str, uuid: &str| { + serde_json::json!({ + "ecosystem": "npm", "basePurl": purl, "uuid": uuid, + "artifact": { "path": format!(".socket/vendor/npm/{uuid}/x.tgz") }, + "wiring": [], "flavor": "bun", + }) + }; + std::fs::write( + vendor.join("state.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "version": 1, + "entries": { in_sync: entry(in_sync, same), stale: entry(stale, older) }, + })) + .unwrap(), + ) + .unwrap(); + + let selected = vec![ + mk_patch(same, in_sync, "free", "2024-01-01"), + mk_patch(newer, stale, "free", "2024-01-01"), + ]; + let (code, json, _) = + download_patch_records(&selected, &detached_params(tmp.path(), server.uri())).await; + + assert_eq!(code, 1, "json={json}"); + let by_purl = |purl: &str| { + json["patches"] + .as_array() + .unwrap() + .iter() + .find(|p| p["purl"] == purl) + .cloned() + .unwrap_or_else(|| panic!("no record for {purl}: {json}")) + }; + let exempt = by_purl(in_sync); + assert_eq!( + exempt["error"], "could not fetch details", + "the in-sync purl must be exempt from the refusal; json={json}" + ); + assert!(exempt.get("errorCode").is_none(), "json={json}"); + let refused = by_purl(stale); + assert_eq!( + refused["errorCode"], "vendor_bun_workspace_unsupported", + "a stale-uuid entry is refused like a fresh vendoring; json={json}" + ); + let paths: Vec = server + .received_requests() + .await + .unwrap_or_default() + .iter() + .map(|r| r.url.path().to_string()) + .collect(); + assert_eq!(paths.len(), 1, "only the exempt purl may fetch: {paths:?}"); + assert!(paths[0].ends_with(same), "{paths:?}"); + } + + /// `bun_vendor_preflight` never reads the lock when nothing selected is + /// npm (no needless I/O, no spurious refusal for other ecosystems), and + /// an unreadable ledger exempts nothing (fail closed). + #[tokio::test] + async fn bun_vendor_preflight_scope_and_corrupt_ledger() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lockb"), b"\x00binary").unwrap(); + let pypi = vec![mk_patch( + "11111111-1111-4111-8111-111111111111", + "pkg:pypi/only@1.0.0", + "free", + "2024-01-01", + )]; + assert!( + bun_vendor_preflight(tmp.path(), &pypi).await.is_none(), + "no npm purl selected => no refusal" + ); + + let uuid = "22222222-2222-4222-8222-222222222222"; + let purl = "pkg:npm/covgap-bun@1.0.0"; + let npm = vec![mk_patch(uuid, purl, "free", "2024-01-01")]; + let refusal = bun_vendor_preflight(tmp.path(), &npm) + .await + .expect("lockb-only project is refused"); + assert_eq!(refusal.code, "vendor_bun_lockb_unsupported"); + assert!(refusal.applies_to(purl)); + assert!(!refusal.applies_to("pkg:pypi/only@1.0.0")); + + // Exempt when the ledger wires this purl at this uuid… + seed_bun_vendor_entry(tmp.path(), purl, uuid); + let refusal = bun_vendor_preflight(tmp.path(), &npm).await.unwrap(); + assert!(!refusal.applies_to(purl), "in-sync ledger entry is exempt"); + + // …but a corrupt ledger exempts nothing. + std::fs::write(tmp.path().join(".socket/vendor/state.json"), b"{ not json").unwrap(); + let refusal = bun_vendor_preflight(tmp.path(), &npm).await.unwrap(); + assert!( + refusal.applies_to(purl), + "an unreadable ledger must not exempt (fail closed)" + ); + } + /// An unreadable vendor ledger silences the drift warning (the main /// vendor path reports unreadable state itself) instead of panicking or /// fabricating a warning. diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index c9f9c337..05cce730 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -17,7 +17,10 @@ use std::time::Duration; use crate::args::GlobalArgs; use crate::commands::fetch_stage::{stage_vendor_sources_in_memory, MemStageOutcome}; -use crate::commands::get::{download_and_apply_patches, download_patch_records, DownloadParams}; +use crate::commands::get::{ + bun_vendor_preflight_with_ledger, download_and_apply_patches, download_patch_records, + DownloadParams, +}; use crate::commands::vendor::{ note_classic_migration_risk, reconcile_dropped, track_outcomes_for_vendor, vendor_records, }; @@ -29,22 +32,42 @@ use super::{ note_vendor_supersedes_redirect, ScanArgs, }; -/// Dry-run preview for `scan --vendor`: classify each selected patch -/// against the vendor ledger without touching disk or the network beyond -/// discovery. Action values are part of the CLI contract: -/// `would_vendor` (no ledger entry), `already_vendored` (entry at this -/// uuid), `would_revendor` + `oldUuid` (entry at an older uuid). +/// Dry-run preview for `scan --vendor` (and `get … --mode vendored +/// --dry-run`): classify each selected patch against the vendor ledger +/// without writing anything or touching the network beyond discovery. +/// Action values are part of the CLI contract: `would_vendor` (no ledger +/// entry), `already_vendored` (entry at this uuid), `would_revendor` + +/// `oldUuid` (entry at an older uuid), and — additive — `would_refuse` + +/// `errorCode` + `error` for npm purls the wet run's Bun preflight +/// ([`crate::commands::get::BunVendorRefusal`]) would refuse before any +/// download. The preview stays a ledger classification otherwise (engine +/// refusals outside the preflight are not predicted), and it never flips +/// the run's status or exit code: `would_refuse` is best-effort advice so a +/// preview never advertises vendoring the wet run is known to refuse. +/// The preflight is one read of `bun.lock`/`bun.lockb` — the only disk +/// access here — and runs only when the selection holds an npm purl. pub(crate) async fn preview_vendor_json( cwd: &Path, selected: &[PatchSearchResult], ) -> serde_json::Value { let state = load_state(cwd).await.unwrap_or_default(); + let refusal = bun_vendor_preflight_with_ledger(cwd, selected, &state.entries).await; let mut patches: Vec = selected .iter() .map(|p| match lookup_entry(&state.entries, &p.purl) { Some(e) if e.uuid == p.uuid => serde_json::json!({ "purl": p.purl, "uuid": p.uuid, "action": "already_vendored", }), + // An in-sync ledger entry is exactly the preflight's exemption, + // so this arm never shadows `already_vendored`; a stale entry + // (`would_revendor`) IS refused by the wet run, like a fresh one. + _ if refusal.as_ref().is_some_and(|r| r.applies_to(&p.purl)) => { + let r = refusal.as_ref().expect("checked by the guard"); + serde_json::json!({ + "purl": p.purl, "uuid": p.uuid, "action": "would_refuse", + "errorCode": r.code, "error": r.detail, + }) + } Some(e) => serde_json::json!({ "purl": p.purl, "uuid": p.uuid, "action": "would_revendor", "oldUuid": e.uuid, @@ -344,8 +367,11 @@ async fn run_vendor_json_path( { Ok((vendor_errors, venv)) => { has_errors |= vendor_errors; + // Telemetry follows the RUN outcome: a download-phase failure + // (a Bun refusal, a failed view fetch) exits 1 and must not + // report a successful vendoring of zero patches. track_outcomes_for_vendor( - vendor_errors, + has_errors, &venv, args.common.dry_run, telemetry_token, @@ -459,8 +485,9 @@ async fn run_vendor_interactive_path( { Ok((vendor_errors, venv)) => { has_errors |= vendor_errors; + // Run-outcome telemetry, same as the JSON arm above. track_outcomes_for_vendor( - vendor_errors, + has_errors, &venv, args.common.dry_run, telemetry_token, @@ -758,6 +785,170 @@ mod service_config_tests { } } +#[cfg(test)] +mod preview_tests { + use super::preview_vendor_json; + use socket_patch_core::api::types::PatchSearchResult; + use std::collections::HashMap; + use std::path::Path; + + const UUID: &str = "11111111-1111-4111-8111-111111111111"; + const OLD_UUID: &str = "00000000-0000-4000-8000-000000000000"; + const NPM: &str = "pkg:npm/preview-bun@1.0.0"; + const PYPI: &str = "pkg:pypi/preview-other@1.0.0"; + + /// Real bun 1.3.14 lockfileVersion-1 workspace grammar (1-tuple + /// `workspace:` entry) — the shape the wet run refuses. + const V1_WORKSPACE_LOCK: &str = r#"{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "preview-fixture", + "dependencies": { + "consumer": "workspace:*", + }, + }, + "packages/consumer": { + "name": "consumer", + "version": "1.0.0", + "dependencies": { + "preview-bun": "1.0.0", + }, + }, + }, + "packages": { + "consumer": ["consumer@workspace:packages/consumer"], + + "preview-bun": ["preview-bun@1.0.0", "", {}, "sha512-AAAA=="], + } +} +"#; + + fn sel(uuid: &str, purl: &str) -> PatchSearchResult { + PatchSearchResult { + uuid: uuid.into(), + purl: purl.into(), + published_at: "2024-01-01T00:00:00Z".into(), + description: String::new(), + license: "MIT".into(), + tier: "free".into(), + vulnerabilities: HashMap::new(), + } + } + + fn seed_entry(root: &Path, purl: &str, uuid: &str) { + let vendor = root.join(".socket/vendor"); + std::fs::create_dir_all(&vendor).unwrap(); + std::fs::write( + vendor.join("state.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "version": 1, + "entries": { purl: { + "ecosystem": "npm", "basePurl": purl, "uuid": uuid, + "artifact": { "path": format!(".socket/vendor/npm/{uuid}/x.tgz") }, + "wiring": [], "flavor": "bun", + }} + })) + .unwrap(), + ) + .unwrap(); + } + + fn action_of<'a>(preview: &'a serde_json::Value, purl: &str) -> &'a serde_json::Value { + preview["patches"] + .as_array() + .unwrap() + .iter() + .find(|p| p["purl"] == purl) + .unwrap_or_else(|| panic!("no preview record for {purl}: {preview}")) + } + + /// Without a Bun lock the preview is the pre-existing ledger + /// classification, byte for byte: `would_vendor` and nothing else. + #[tokio::test] + async fn preview_without_bun_lock_is_plain_would_vendor() { + let tmp = tempfile::tempdir().unwrap(); + let preview = preview_vendor_json(tmp.path(), &[sel(UUID, NPM)]).await; + assert_eq!( + preview, + serde_json::json!({ + "dryRun": true, + "patches": [{ "purl": NPM, "uuid": UUID, "action": "would_vendor" }], + }) + ); + } + + /// A refused Bun tree flips npm purls to the additive `would_refuse` + /// (with the vendor code + detail) and leaves other ecosystems alone. + #[tokio::test] + async fn preview_marks_would_refuse_for_refused_bun_tree() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lock"), V1_WORKSPACE_LOCK).unwrap(); + let preview = preview_vendor_json(tmp.path(), &[sel(UUID, NPM), sel(UUID, PYPI)]).await; + let npm = action_of(&preview, NPM); + assert_eq!(npm["action"], "would_refuse", "{preview}"); + assert_eq!( + npm["errorCode"], "vendor_bun_workspace_unsupported", + "{preview}" + ); + assert!( + npm["error"].as_str().is_some_and(|d| !d.is_empty()), + "{preview}" + ); + assert_eq!( + action_of(&preview, PYPI)["action"], + "would_vendor", + "{preview}" + ); + assert_eq!(preview["dryRun"], true); + // The preflight is read-only. + assert_eq!( + std::fs::read_to_string(tmp.path().join("bun.lock")).unwrap(), + V1_WORKSPACE_LOCK + ); + } + + /// The already-vendored exemption: an in-sync ledger entry keeps + /// `already_vendored` (the wet run's engine skip), while a stale entry + /// is `would_refuse` — the wet run refuses re-vendoring at a new uuid. + #[tokio::test] + async fn preview_already_vendored_wins_over_refusal_stale_entry_is_refused() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lock"), V1_WORKSPACE_LOCK).unwrap(); + + seed_entry(tmp.path(), NPM, UUID); + let preview = preview_vendor_json(tmp.path(), &[sel(UUID, NPM)]).await; + assert_eq!( + action_of(&preview, NPM)["action"], + "already_vendored", + "{preview}" + ); + + seed_entry(tmp.path(), NPM, OLD_UUID); + let preview = preview_vendor_json(tmp.path(), &[sel(UUID, NPM)]).await; + let rec = action_of(&preview, NPM); + assert_eq!(rec["action"], "would_refuse", "{preview}"); + assert!( + rec.get("oldUuid").is_none(), + "a refused record is not a revendor preview: {preview}" + ); + } + + /// bun.lockb without a text lock: `would_refuse` with the lockb code. + #[tokio::test] + async fn preview_marks_lockb_only_tree_would_refuse() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lockb"), b"\x00binary").unwrap(); + let preview = preview_vendor_json(tmp.path(), &[sel(UUID, NPM)]).await; + assert_eq!( + action_of(&preview, NPM)["errorCode"], + "vendor_bun_lockb_unsupported", + "{preview}" + ); + } +} + #[cfg(test)] mod fold_vendored_skips_tests { use super::fold_vendored_skips_into_apply; From 5bf38f243fcf30234ceb00353ce16c27fe5a68eb Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 18 Sep 2026 17:50:07 -0400 Subject: [PATCH 20/49] test(bun): hermetic CLI coverage for the vendored Bun preflight New tests/in_process_vendor_bun.rs (wiremock, real bun lock grammar: v1/v2 1-tuple workspace locks, the bun 1.1.45 v0 2-tuple lock, bun.lockb-only, malformed lockfileVersion 3, a Unix FIFO): exit codes, exact envelopes (uuid path status:error + error{code,message} + record errorCode+error; scan / purl paths partial_failure), zero view fetches on refusal, byte-identical bun.lock, no .socket/vendor, a seeded manifest record preserved (Value equality), --silent stderr carries the code with an empty stdout, --dry-run reports would_refuse on every entry point, --save-only agent runs bypass the preflight, positive controls (v2 workspace vendors; v0 direct lock vendors via get and rollback restores bytes), --detached refuses pre-fetch, the already-vendored download-phase exemption, and the workspace-member --cwd behaviour pinned as it is today. The full already_vendored re-run is #[ignore]d pending lane B1's engine-side ordering fix (verified to fail today on vendor_bun_workspace_unsupported). get_modes_e2e.rs: silent visibility (both identifiers), dry-run would_refuse, save-only exemption. scan_vendor_e2e.rs: download-phase refusal, detached twin, silent human arm. Co-Authored-By: Claude Fable 5.1 --- .../socket-patch-cli/tests/get_modes_e2e.rs | 215 ++- .../tests/in_process_vendor_bun.rs | 1261 +++++++++++++++++ .../socket-patch-cli/tests/scan_vendor_e2e.rs | 177 +++ 3 files changed, 1650 insertions(+), 3 deletions(-) create mode 100644 crates/socket-patch-cli/tests/in_process_vendor_bun.rs diff --git a/crates/socket-patch-cli/tests/get_modes_e2e.rs b/crates/socket-patch-cli/tests/get_modes_e2e.rs index f3e3c274..87387960 100644 --- a/crates/socket-patch-cli/tests/get_modes_e2e.rs +++ b/crates/socket-patch-cli/tests/get_modes_e2e.rs @@ -672,7 +672,14 @@ async fn get_vendored_then_hosted_takes_over_cleanly() { let (code, _stdout, stderr) = run_get( tmp.path(), &server.uri(), - &[UUID1, "--mode", "vendored", "--json", "--vendor-source", "build"], + &[ + UUID1, + "--mode", + "vendored", + "--json", + "--vendor-source", + "build", + ], ); assert_eq!(code, 0, "vendored step failed: {stderr}"); let artifact = tmp @@ -794,8 +801,11 @@ async fn get_hosted_runs_release_variant_filter() { ) .unwrap(); - let (code, stdout, stderr) = - run_get(tmp.path(), &server.uri(), &[GHSA, "--mode", "hosted", "--json"]); + let (code, stdout, stderr) = run_get( + tmp.path(), + &server.uri(), + &[GHSA, "--mode", "hosted", "--json"], + ); assert_eq!(code, 0, "stderr: {stderr}\nstdout: {stdout}"); let envelope = parse_single_json_doc(&stdout); let warnings = envelope["warnings"].to_string(); @@ -839,3 +849,202 @@ async fn get_pnp_only_narrowing_message_names_the_layout() { "the layout refusal warning must be on stderr; stderr:\n{stderr}" ); } + +// --------------------------------------------------------------------------- +// Bun vendored-mode preflight through `get`: --silent, --dry-run, --save-only +// --------------------------------------------------------------------------- + +const ENCODED1: &str = "pkg%3Anpm%2Fgetmodes-pkg%401.0.0"; +const BUN_WS_CODE: &str = "vendor_bun_workspace_unsupported"; + +/// The per-package search for the exact PURL identifier path. +async fn mock_by_package(server: &MockServer) { + Mock::given(method("GET")) + .and(path(format!( + "/v0/orgs/{ORG}/patches/by-package/{ENCODED1}" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": UUID1, "purl": PURL1, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "get-modes fixture", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; +} + +/// `write_project` re-locked by bun 1.3.14 as a workspace: the real +/// lockfileVersion-1 grammar (1-tuple `workspace:` entry, blank line +/// between entries, trailing commas), `getmodes-pkg` declared by the +/// member — the shape the vendored gate refuses. +fn write_bun_workspace_project(root: &Path) { + write_project(root); + std::fs::remove_file(root.join("package-lock.json")).unwrap(); + std::fs::write( + root.join("package.json"), + r#"{ "name": "consumer", "version": "0.0.0", "private": true, "workspaces": ["packages/*"], "dependencies": { "app": "workspace:*" } }"#, + ) + .unwrap(); + let app = root.join("packages/app"); + std::fs::create_dir_all(&app).unwrap(); + std::fs::write( + app.join("package.json"), + format!( + r#"{{ "name": "app", "version": "1.0.0", "dependencies": {{ "{NAME}": "1.0.0" }} }}"# + ), + ) + .unwrap(); + std::fs::write( + root.join("bun.lock"), + format!( + "{{\n \"lockfileVersion\": 1,\n \"configVersion\": 1,\n \"workspaces\": {{\n \"\": {{\n \"name\": \"consumer\",\n \"dependencies\": {{\n \"app\": \"workspace:*\",\n }},\n }},\n \"packages/app\": {{\n \"name\": \"app\",\n \"version\": \"1.0.0\",\n \"dependencies\": {{\n \"{NAME}\": \"1.0.0\",\n }},\n }},\n }},\n \"packages\": {{\n \"app\": [\"app@workspace:packages/app\"],\n\n \"{NAME}\": [\"{NAME}@1.0.0\", \"\", {{}}, \"sha512-UPSTREAMupstream==\"],\n }}\n}}\n" + ), + ) + .unwrap(); +} + +/// `--silent` is "errors only": the Bun refusal is an error, so BOTH +/// identifier kinds keep it on stderr — code-tagged — with an empty +/// stdout and exit 1. Regression guard: the refusal lines were gated on +/// `!silent`, so a `--silent` run exited 1 with no text anywhere. +#[tokio::test] +async fn get_vendored_refusal_visible_under_silent() { + let server = MockServer::start().await; + mock_view(&server, UUID1, PURL1).await; + mock_by_package(&server).await; + + for (label, ident) in [("purl", PURL1), ("uuid", UUID1)] { + let tmp = tempfile::tempdir().unwrap(); + write_bun_workspace_project(tmp.path()); + let lock_before = std::fs::read(tmp.path().join("bun.lock")).unwrap(); + let (code, stdout, stderr) = run_get( + tmp.path(), + &server.uri(), + &[ + ident, + "--mode", + "vendored", + "--vendor-source", + "build", + "--silent", + ], + ); + assert_eq!(code, 1, "{label}: stdout={stdout}\nstderr={stderr}"); + assert!( + stdout.trim().is_empty(), + "{label}: --silent must print nothing on stdout:\n{stdout}" + ); + assert!( + stderr.contains(BUN_WS_CODE), + "{label}: --silent must keep the refusal code on stderr:\n{stderr}" + ); + if label == "purl" { + assert!( + stderr.contains(&format!("[error] {PURL1} ({BUN_WS_CODE}):")), + "purl path prints the code-tagged per-patch line:\n{stderr}" + ); + } else { + assert!( + stderr.contains(&format!("Error ({BUN_WS_CODE}):")), + "uuid path prints the code-tagged Error line:\n{stderr}" + ); + } + assert_eq!( + std::fs::read(tmp.path().join("bun.lock")).unwrap(), + lock_before, + "{label}: refused runs never touch the lock" + ); + assert!(!tmp.path().join(".socket/vendor").exists(), "{label}"); + } +} + +/// The vendored dry-run preview names what the wet run would refuse: +/// `would_refuse` + `errorCode` + `error` (additive) instead of +/// `would_vendor`, on both identifier kinds — exit 0, `status:"success"`, +/// nothing written, exactly like every other vendored preview. +#[tokio::test] +async fn get_vendored_dry_run_reports_bun_refusal() { + let server = MockServer::start().await; + mock_view(&server, UUID1, PURL1).await; + mock_by_package(&server).await; + + for (label, ident) in [("purl", PURL1), ("uuid", UUID1)] { + let tmp = tempfile::tempdir().unwrap(); + write_bun_workspace_project(tmp.path()); + let lock_before = std::fs::read(tmp.path().join("bun.lock")).unwrap(); + let (code, stdout, stderr) = run_get( + tmp.path(), + &server.uri(), + &[ + ident, + "--mode", + "vendored", + "--vendor-source", + "build", + "--dry-run", + "--json", + ], + ); + assert_eq!(code, 0, "{label}: stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "success", "{label}: {v}"); + assert_eq!(v["found"], 1, "{label}: {v}"); + assert_eq!(v["vendor"]["dryRun"], true, "{label}: {v}"); + let rec = &v["vendor"]["patches"][0]; + assert_eq!(rec["purl"], PURL1, "{label}: {v}"); + assert_eq!(rec["uuid"], UUID1, "{label}: {v}"); + assert_eq!(rec["action"], "would_refuse", "{label}: {v}"); + assert_eq!(rec["errorCode"], BUN_WS_CODE, "{label}: {v}"); + assert!( + rec["error"].as_str().is_some_and(|d| !d.is_empty()), + "{label}: {v}" + ); + assert_eq!( + std::fs::read(tmp.path().join("bun.lock")).unwrap(), + lock_before, + "{label}: dry-run must not touch the lock" + ); + assert!( + !tmp.path().join(".socket").exists(), + "{label}: vendored dry-run is a preview: no manifest, no artifacts, no ledger" + ); + } +} + +/// The preflight is scoped to the vendored posture: an agent-mode +/// `get --save-only` on the same refused workspace project records the +/// patch and persists the blob like on any other project (record-only +/// intent has no Bun precondition; the fresh-clone record→vendor flow +/// keeps working). +#[tokio::test] +async fn get_save_only_agent_ignores_bun_preflight() { + let server = MockServer::start().await; + mock_view(&server, UUID1, PURL1).await; + mock_by_package(&server).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_workspace_project(tmp.path()); + + let (code, stdout, stderr) = + run_get(tmp.path(), &server.uri(), &[PURL1, "--save-only", "--json"]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "success", "{v}"); + assert_eq!(v["patches"][0]["action"], "added", "{v}"); + assert!(v["patches"][0].get("errorCode").is_none(), "{v}"); + assert_eq!(requests_containing(&server, "/patches/view/").await, 1); + let manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(), + ) + .unwrap(); + assert_eq!(manifest["patches"][PURL1]["uuid"], UUID1, "{manifest}"); + assert!( + tmp.path() + .join(".socket/blobs") + .join(common::git_sha256(AFTER_BYTES)) + .is_file(), + "the agent download persists the after-blob" + ); +} diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun.rs new file mode 100644 index 00000000..61218825 --- /dev/null +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun.rs @@ -0,0 +1,1261 @@ +//! Hermetic subprocess tests for the Bun VENDORED-mode refusals and their +//! positive twins: `scan --mode vendored` (manifest-tracked and +//! `--detached`), `get --mode vendored`, `get --mode +//! vendored`, their `--dry-run` previews, `--silent`, and the agent +//! `--save-only` exemption — driven through the built binary against a +//! wiremock patch API, on lockfiles written in the grammar REAL bun +//! releases emit (captured in the PR #245 compatibility matrix and from a +//! bun 1.1.45 `--save-text-lockfile` run): +//! +//! * lockfileVersion 1 (bun 1.2.x–1.3.x) / 2 (bun 1.4.x) workspace locks: +//! 1-tuple `"consumer": ["consumer@workspace:packages/consumer"]`, a +//! blank line between entries, trailing commas; +//! * lockfileVersion 0 (bun 1.1.39–1.1.45 `--save-text-lockfile`): no +//! `configVersion`, the root's workspace dep spelled as a bare path, and +//! the 2-tuple `["consumer@workspace:packages/consumer", { "dependencies": +//! { … } }]`; +//! * a `bun.lockb` with no text lock (bun ≤ 1.1.38, or an un-migrated repo); +//! * a malformed lock (`lockfileVersion` 3, non-canonical `"packages" : {` +//! header, unterminated entry) and — on Unix — a FIFO squatting +//! `bun.lock`. +//! +//! Every refusal test pins the whole observable contract: exit code; the +//! exact envelope shape (uuid path: `status:"error"` with `error{code, +//! message}` and a `failed` record carrying `errorCode` AND `error`; scan +//! and purl paths: `partial_failure` with the same record); ZERO +//! `/patches/view/` fetches for the refused patch (request-log oracle); a +//! byte-identical `bun.lock`; no `.socket/vendor/`; and — where a manifest +//! existed — a seeded record for another purl surviving semantically (serde +//! `Value` equality: the download phase re-serializes the manifest +//! pretty-printed by contract). +//! +//! No `#[serial]`: the child gets a scrubbed env copy (`common::run_with_env`). + +use std::path::Path; +#[cfg(unix)] +use std::process::{Command, Stdio}; +#[cfg(unix)] +use std::time::{Duration, Instant}; + +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[path = "common/mod.rs"] +mod common; + +const ORG: &str = "test-org"; +const UUID: &str = "11111111-1111-4111-8111-111111111111"; +const PURL: &str = "pkg:npm/left-pad@1.3.0"; +const ENCODED: &str = "pkg%3Anpm%2Fleft-pad%401.3.0"; +/// A second, unrelated manifest record (never installed here) used to prove +/// a pre-existing manifest survives a refused run. +const OTHER_UUID: &str = "22222222-2222-4222-8222-222222222222"; +const OTHER_PURL: &str = "pkg:npm/other-pkg@2.0.0"; +const BEFORE: &[u8] = b"before\n"; +const AFTER: &[u8] = b"after\n"; +/// The real registry integrity of left-pad@1.3.0 (spike BN3 fixture). +const LEFT_PAD_SHA512: &str = + "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="; +const WS_CODE: &str = "vendor_bun_workspace_unsupported"; +const LOCKB_CODE: &str = "vendor_bun_lockb_unsupported"; +const VERSION_CODE: &str = "vendor_lockfile_version_unsupported"; +const MISSING_CODE: &str = "vendor_lockfile_missing"; + +// --------------------------------------------------------------------------- +// Fixtures: real bun grammar +// --------------------------------------------------------------------------- + +/// The registry 4-tuple bun writes for left-pad@1.3.0 (identical across +/// lockfileVersion 0/1/2). +fn registry_line() -> String { + format!(" \"left-pad\": [\"left-pad@1.3.0\", \"\", {{}}, \"{LEFT_PAD_SHA512}\"],\n") +} + +/// bun 1.3.14 (lockfileVersion 1) / bun 1.4.2 (lockfileVersion 2) workspace +/// lock, byte-for-byte the matrix capture grammar (the two versions differ +/// only in the version integer). +const WS_V1V2_TEMPLATE: &str = r#"{ + "lockfileVersion": {VERSION}, + "configVersion": 1, + "workspaces": { + "": { + "name": "bun-fixture", + "dependencies": { + "consumer": "workspace:*", + }, + }, + "packages/consumer": { + "name": "consumer", + "version": "1.0.0", + "dependencies": { + "left-pad": "1.3.0", + }, + }, + }, + "packages": { + "consumer": ["consumer@workspace:packages/consumer"], + +{REGISTRY} } +} +"#; + +/// bun 1.1.45 `--save-text-lockfile` workspace lock: lockfileVersion 0, no +/// `configVersion`, root workspace dep as a bare path, 2-tuple workspace +/// entry carrying the member's deps object. +const WS_V0_TEMPLATE: &str = r#"{ + "lockfileVersion": 0, + "workspaces": { + "": { + "name": "bun-fixture", + "dependencies": { + "consumer": "packages/consumer", + }, + }, + "packages/consumer": { + "name": "consumer", + "version": "1.0.0", + "dependencies": { + "left-pad": "1.3.0", + }, + }, + }, + "packages": { + "consumer": ["consumer@workspace:packages/consumer", { "dependencies": { "left-pad": "1.3.0" } }], + +{REGISTRY} } +} +"#; + +/// bun 1.1.45 `--save-text-lockfile` single-package lock (matrix +/// `1.1.45-text-*` captures): lockfileVersion 0, no `configVersion`. +const DIRECT_V0_TEMPLATE: &str = r#"{ + "lockfileVersion": 0, + "workspaces": { + "": { + "name": "bun-fixture", + "dependencies": { + "left-pad": "1.3.0", + }, + }, + }, + "packages": { +{REGISTRY} } +} +"#; + +/// bun 1.3.x single-package lock (spike BN3 grammar). +const DIRECT_V1_TEMPLATE: &str = r#"{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bun-fixture", + "dependencies": { + "left-pad": "1.3.0", + }, + }, + }, + "packages": { +{REGISTRY} } +} +"#; + +/// A future/hand-edited lock the version gate refuses: lockfileVersion 3, +/// a non-canonical `"packages" : {` header and an unterminated entry. +const MALFORMED_V3_LOCK: &str = "{\n \"lockfileVersion\": 3,\n \"packages\" : {\n \"left-pad\": [\"left-pad@1.3.0\", \"\",\n }\n}\n"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum LockShape { + V0Workspace, + V1Workspace, + V2Workspace, + V0Direct, + V1Direct, + LockbOnly, + MalformedV3, +} + +impl LockShape { + fn is_workspace(self) -> bool { + matches!( + self, + LockShape::V0Workspace | LockShape::V1Workspace | LockShape::V2Workspace + ) + } + + /// The text lock for this shape (`None` = binary `bun.lockb` only). + fn lock_text(self) -> Option { + let registry = registry_line(); + let text = match self { + LockShape::V1Workspace => WS_V1V2_TEMPLATE.replace("{VERSION}", "1"), + LockShape::V2Workspace => WS_V1V2_TEMPLATE.replace("{VERSION}", "2"), + LockShape::V0Workspace => WS_V0_TEMPLATE.to_string(), + LockShape::V0Direct => DIRECT_V0_TEMPLATE.to_string(), + LockShape::V1Direct => DIRECT_V1_TEMPLATE.to_string(), + LockShape::MalformedV3 => return Some(MALFORMED_V3_LOCK.to_string()), + LockShape::LockbOnly => return None, + }; + Some(text.replace("{REGISTRY}", ®istry)) + } +} + +/// A bun project with left-pad@1.3.0 INSTALLED (hoisted `node_modules/`, +/// which every bun release through 1.2.x lays out and the crawler +/// resolves) and lock-resolved in the requested grammar. Workspace shapes +/// declare left-pad from `packages/consumer` (the member-declared case the +/// vendored gate exists for). +fn write_bun_project(root: &Path, shape: LockShape) { + let root_pkg = if shape.is_workspace() { + r#"{"name":"bun-fixture","version":"1.0.0","private":true,"workspaces":["packages/*"],"dependencies":{"consumer":"workspace:*"}}"# + } else { + r#"{"name":"bun-fixture","version":"1.0.0","private":true,"dependencies":{"left-pad":"1.3.0"}}"# + }; + std::fs::write(root.join("package.json"), root_pkg).unwrap(); + if shape.is_workspace() { + let consumer = root.join("packages/consumer"); + std::fs::create_dir_all(&consumer).unwrap(); + std::fs::write( + consumer.join("package.json"), + r#"{"name":"consumer","version":"1.0.0","dependencies":{"left-pad":"1.3.0"}}"#, + ) + .unwrap(); + } + write_installed_left_pad(root); + match shape.lock_text() { + Some(text) => std::fs::write(root.join("bun.lock"), text).unwrap(), + None => std::fs::write(root.join("bun.lockb"), b"\x00bun-lockb\x00").unwrap(), + } +} + +fn write_installed_left_pad(dir: &Path) { + let pkg = dir.join("node_modules/left-pad"); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + br#"{"name":"left-pad","version":"1.3.0"}"#, + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), BEFORE).unwrap(); +} + +/// A schema-valid manifest holding ONE record for [`OTHER_PURL`], written +/// COMPACT (single line) so a byte-level re-serialization is detectable +/// while the semantic oracle (`Value` equality) still passes. Returns the +/// seeded record. +fn seed_other_manifest_record(root: &Path) -> serde_json::Value { + let socket = root.join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + let record = serde_json::json!({ + "uuid": OTHER_UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": common::git_sha256(BEFORE), + "afterHash": common::git_sha256(AFTER), + } + }, + "vulnerabilities": {}, + "description": "seeded record", + "license": "MIT", + "tier": "free", + }); + let manifest = serde_json::json!({ "patches": { OTHER_PURL: record } }); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string(&manifest).unwrap(), + ) + .unwrap(); + record +} + +// --------------------------------------------------------------------------- +// Mock API +// --------------------------------------------------------------------------- + +fn view_body(uuid: &str, purl: &str) -> serde_json::Value { + use base64::Engine as _; + let blob_content = base64::engine::general_purpose::STANDARD.encode(AFTER); + serde_json::json!({ + "uuid": uuid, + "purl": purl, + "publishedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": common::git_sha256(BEFORE), + "afterHash": common::git_sha256(AFTER), + "blobContent": blob_content, + } + }, + "vulnerabilities": { + "GHSA-aaaa-bbbb-cccc": { + "cves": ["CVE-2026-0001"], + "summary": "bun fixture", + "severity": "high", + "description": "d" + } + }, + "description": "bun fixture", + "license": "MIT", + "tier": "free", + }) +} + +/// Discovery (batch), per-package search and the full view for [`UUID`] / +/// [`PURL`] — the same recipe as `scan_vendor_e2e.rs`. +async fn mount_patch_api(mock: &MockServer) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ + "purl": PURL, + "patches": [{ + "uuid": UUID, + "purl": PURL, + "tier": "free", + "cveIds": ["CVE-2026-0001"], + "ghsaIds": [], + "severity": "high", + "title": "bun fixture" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/by-package/{ENCODED}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": UUID, + "purl": PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "description": "bun fixture", + "license": "MIT", + "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; + mount_view(mock, UUID, PURL).await; +} + +async fn mount_view(mock: &MockServer, uuid: &str, purl: &str) { + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{uuid}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(view_body(uuid, purl))) + .mount(mock) + .await; +} + +/// `/patches/view/{uuid}` fetches the mock saw. +async fn view_requests_for(mock: &MockServer, uuid: &str) -> usize { + mock.received_requests() + .await + .unwrap_or_default() + .iter() + .filter(|r| r.url.path().ends_with(&format!("/patches/view/{uuid}"))) + .count() +} + +// --------------------------------------------------------------------------- +// Runners +// --------------------------------------------------------------------------- + +fn run(root: &Path, argv: &[&str]) -> (i32, String, String) { + common::run_with_env(root, argv, &[("SOCKET_TELEMETRY_DISABLED", "1")]) +} + +fn with_api<'a>(argv: &[&'a str], uri: &'a str) -> Vec<&'a str> { + let mut v = argv.to_vec(); + v.extend_from_slice(&[ + "--api-url", + uri, + "--api-token", + "fake-token", + "--org", + ORG, + "--yes", + ]); + v +} + +fn scan_vendored(root: &Path, uri: &str, extra: &[&str]) -> (i32, String, String) { + let mut argv = vec!["scan", "--mode", "vendored", "--vendor-source", "build"]; + argv.extend_from_slice(extra); + run(root, &with_api(&argv, uri)) +} + +fn get_vendored(root: &Path, uri: &str, ident: &str, extra: &[&str]) -> (i32, String, String) { + let mut argv = vec![ + "get", + ident, + "--mode", + "vendored", + "--vendor-source", + "build", + ]; + argv.extend_from_slice(extra); + run(root, &with_api(&argv, uri)) +} + +/// Parse stdout as exactly ONE JSON document (`from_str` rejects trailing +/// data, so a second envelope or a stray human line fails loudly). +fn parse_single_json_doc(stdout: &str) -> serde_json::Value { + let trimmed = stdout.trim(); + assert!(!trimmed.is_empty(), "expected a JSON envelope on stdout"); + serde_json::from_str(trimmed).unwrap_or_else(|e| { + panic!("stdout must be exactly one JSON document: {e}\nstdout:\n{stdout}") + }) +} + +fn lock_bytes(root: &Path) -> Vec { + std::fs::read(root.join("bun.lock")).unwrap() +} + +fn manifest_value(root: &Path) -> Option { + let body = std::fs::read_to_string(root.join(".socket/manifest.json")).ok()?; + Some(serde_json::from_str(&body).unwrap_or_else(|e| panic!("manifest not JSON: {e}\n{body}"))) +} + +/// The refused `failed` record every entry point must emit for [`PURL`]. +fn assert_refused_record(record: &serde_json::Value, code: &str, ctx: &serde_json::Value) { + assert_eq!(record["purl"], PURL, "{ctx}"); + assert_eq!(record["uuid"], UUID, "{ctx}"); + assert_eq!(record["action"], "failed", "{ctx}"); + assert_eq!(record["errorCode"], code, "{ctx}"); + assert!( + record["error"].as_str().is_some_and(|d| !d.is_empty()), + "a refused record must carry the engine's detail text: {ctx}" + ); +} + +/// The on-disk invariants of EVERY refusal: lock bytes untouched, nothing +/// vendored, no record for [`PURL`] in the manifest (if one exists). +fn assert_refusal_left_tree_alone(root: &Path, lock_before: &[u8]) { + if root.join("bun.lock").exists() { + assert_eq!( + lock_bytes(root), + lock_before, + "bun.lock must be byte-identical" + ); + } + assert!( + !root.join(".socket/vendor").exists(), + "a refused run must not create .socket/vendor/" + ); + if let Some(m) = manifest_value(root) { + assert!( + m["patches"].get(PURL).is_none(), + "the refused purl must not be recorded: {m}" + ); + } +} + +// --------------------------------------------------------------------------- +// scan --mode vendored: the download-phase refusal, per lock shape +// --------------------------------------------------------------------------- + +/// Drive `scan --mode vendored --json` on `shape` and pin the refusal +/// contract for `code`: exit 1, `partial_failure`, the download-phase +/// record, zero downloads, zero view fetches, engine untouched. +async fn assert_scan_refuses(shape: LockShape, code: &str) { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), shape); + let lock_before = if tmp.path().join("bun.lock").exists() { + lock_bytes(tmp.path()) + } else { + Vec::new() + }; + + let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--json"]); + assert_eq!(exit, 1, "{shape:?}: stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "partial_failure", "{shape:?}: {v}"); + let dl = &v["download"]; + assert_eq!(dl["found"], 1, "{v}"); + assert_eq!(dl["downloaded"], 0, "{v}"); + assert_eq!(dl["skipped"], 0, "{v}"); + assert_eq!(dl["failed"], 1, "{v}"); + assert_refused_record(&dl["patches"][0], code, &v); + assert_eq!( + v["vendor"]["summary"]["applied"], 0, + "nothing may be vendored: {v}" + ); + assert_eq!( + view_requests_for(&mock, UUID).await, + 0, + "{shape:?}: a refused patch must never be fetched" + ); + assert_refusal_left_tree_alone(tmp.path(), &lock_before); + // Today's documented contract (CLI_CONTRACT.md `scan --vendor`: "the + // download phase writes only `.socket/manifest.json`"): the manifest + // exists and is EMPTY — no record was claimed for the refused purl. + assert_eq!( + manifest_value(tmp.path()), + Some(serde_json::json!({ "patches": {} })), + "{shape:?}" + ); +} + +#[tokio::test] +async fn scan_vendored_refuses_v1_workspace_lock_before_download() { + assert_scan_refuses(LockShape::V1Workspace, WS_CODE).await; +} + +#[tokio::test] +async fn scan_vendored_refuses_v0_workspace_two_tuple_lock_before_download() { + assert_scan_refuses(LockShape::V0Workspace, WS_CODE).await; +} + +#[tokio::test] +async fn scan_vendored_refuses_bun_lockb_only_before_download() { + assert_scan_refuses(LockShape::LockbOnly, LOCKB_CODE).await; +} + +#[tokio::test] +async fn scan_vendored_refuses_malformed_v3_lock_before_download() { + assert_scan_refuses(LockShape::MalformedV3, VERSION_CODE).await; +} + +/// A refused scan on a project that ALREADY tracks another patch: that +/// record survives semantically (the download phase re-serializes the +/// manifest pretty-printed — documented, so the oracle is `Value` +/// equality, not bytes), and the refused purl is still not recorded. +#[tokio::test] +async fn scan_vendored_refusal_preserves_seeded_manifest_record() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + // The vendor step stages every manifest record's content in memory + // from the view endpoint, so the seeded record needs a view too. + mount_view(&mock, OTHER_UUID, OTHER_PURL).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::V1Workspace); + let seeded = seed_other_manifest_record(tmp.path()); + let lock_before = lock_bytes(tmp.path()); + + let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--json"]); + assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_refused_record(&v["download"]["patches"][0], WS_CODE, &v); + assert_eq!(view_requests_for(&mock, UUID).await, 0); + assert_refusal_left_tree_alone(tmp.path(), &lock_before); + + let manifest = manifest_value(tmp.path()).expect("manifest survives"); + let patches = manifest["patches"].as_object().unwrap(); + assert_eq!( + patches.keys().collect::>(), + vec![OTHER_PURL], + "exactly the seeded record remains: {manifest}" + ); + assert_eq!( + patches[OTHER_PURL], seeded, + "the seeded record must survive field for field: {manifest}" + ); +} + +// --------------------------------------------------------------------------- +// scan --mode vendored --detached: the same refusal, BEFORE any fetch +// --------------------------------------------------------------------------- + +/// The detached download phase used to skip the preflight: the patch view +/// was fetched (`download.downloaded: 1`) and the refusal only surfaced +/// from the vendor engine afterwards (degrading to `package_not_installed` +/// for alias installs). Now it refuses exactly like the manifest-tracked +/// phase — pre-fetch, with the vendor code — and, being detached, writes +/// no manifest at all. +#[tokio::test] +async fn scan_vendored_detached_refuses_v1_workspace_before_fetch() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::V1Workspace); + let lock_before = lock_bytes(tmp.path()); + + let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--detached", "--json"]); + assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "partial_failure", "{v}"); + let dl = &v["download"]; + assert_eq!(dl["detached"], true, "{v}"); + assert_eq!( + dl["downloaded"], 0, + "detached must refuse BEFORE fetching: {v}" + ); + assert_eq!(dl["failed"], 1, "{v}"); + assert_refused_record(&dl["patches"][0], WS_CODE, &v); + assert_eq!(view_requests_for(&mock, UUID).await, 0); + assert!( + !tmp.path().join(".socket/manifest.json").exists(), + "detached mode never writes a manifest" + ); + assert_refusal_left_tree_alone(tmp.path(), &lock_before); +} + +/// The lockb twin of the detached refusal: the shape that used to +/// misreport `package_not_installed` after a needless fetch. +#[tokio::test] +async fn scan_vendored_detached_refuses_bun_lockb_before_fetch() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::LockbOnly); + + let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--detached", "--json"]); + assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["download"]["downloaded"], 0, "{v}"); + assert_refused_record(&v["download"]["patches"][0], LOCKB_CODE, &v); + assert!( + !v["vendor"]["events"] + .as_array() + .unwrap_or(&vec![]) + .iter() + .any(|e| e["errorCode"] == "package_not_installed"), + "the refusal must not degrade to package_not_installed: {v}" + ); + assert_eq!(view_requests_for(&mock, UUID).await, 0); + assert!(!tmp.path().join(".socket/manifest.json").exists()); + assert!(!tmp.path().join(".socket/vendor").exists()); +} + +// --------------------------------------------------------------------------- +// get --mode vendored: the pre-record refusal envelope +// --------------------------------------------------------------------------- + +/// `get --mode vendored --json` on a refused Bun project exits 1 +/// with EXACTLY this envelope (contract: uuid-path pre-record refusal): +/// `status:"error"`, `error{code,message}`, counts, and a `failed` record +/// carrying both `errorCode` and `error`. The uuid lookup itself is the +/// only network traffic (one view fetch — that IS the identifier +/// resolution), and NOTHING is written: no `.socket/` at all. +#[tokio::test] +async fn get_uuid_vendored_refusal_envelope_is_exact_and_writes_nothing() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::V1Workspace); + let lock_before = lock_bytes(tmp.path()); + + let (exit, stdout, stderr) = get_vendored(tmp.path(), &mock.uri(), UUID, &["--json"]); + assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + let detail = v["error"]["message"] + .as_str() + .unwrap_or_else(|| panic!("error.message must be a string: {v}")) + .to_string(); + assert!(!detail.is_empty(), "{v}"); + let expected = serde_json::json!({ + "status": "error", + "found": 1, + "downloaded": 0, + "skipped": 0, + "failed": 1, + "error": { "code": WS_CODE, "message": detail }, + "patches": [{ + "purl": PURL, + "uuid": UUID, + "action": "failed", + "errorCode": WS_CODE, + "error": detail, + }], + }); + assert_eq!( + v, + expected, + "uuid-path refusal envelope drifted.\nexpected:\n{}\ngot:\n{}", + serde_json::to_string_pretty(&expected).unwrap(), + serde_json::to_string_pretty(&v).unwrap(), + ); + assert_eq!( + view_requests_for(&mock, UUID).await, + 1, + "the uuid lookup is the only fetch" + ); + assert!( + !tmp.path().join(".socket").exists(), + "the uuid path refuses before creating .socket/" + ); + assert_eq!(lock_bytes(tmp.path()), lock_before); +} + +/// Human mode prints the code-tagged `Error (…)` line to stderr, nothing +/// on stdout, exit 1 — and writes nothing. +#[tokio::test] +async fn get_uuid_vendored_refusal_human_names_code_on_stderr() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::LockbOnly); + + let (exit, stdout, stderr) = get_vendored(tmp.path(), &mock.uri(), UUID, &[]); + assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); + assert!( + stderr.contains(&format!("Error ({LOCKB_CODE}):")), + "stderr must carry the code-tagged error line:\n{stderr}" + ); + assert!( + !stdout.contains(LOCKB_CODE) && !stdout.contains("Patch record saved"), + "the refusal must not be reported as a save on stdout:\n{stdout}" + ); + assert!(!tmp.path().join(".socket").exists()); +} + +// --------------------------------------------------------------------------- +// get --mode vendored: the search-path refusal +// --------------------------------------------------------------------------- + +/// The search path shares `scan`'s download phase: `partial_failure`, the +/// same `failed` record (with `errorCode` + `error`), zero fetches, the +/// vendor step still runs over the (empty) manifest, `applied` dropped. +#[tokio::test] +async fn get_purl_vendored_refuses_v1_workspace_before_fetch() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::V1Workspace); + let lock_before = lock_bytes(tmp.path()); + + let (exit, stdout, stderr) = get_vendored(tmp.path(), &mock.uri(), PURL, &["--json"]); + assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "partial_failure", "{v}"); + assert_eq!(v["found"], 1, "{v}"); + assert_eq!(v["downloaded"], 0, "{v}"); + assert_eq!(v["skipped"], 0, "{v}"); + assert_eq!(v["failed"], 1, "{v}"); + assert!( + v.get("applied").is_none(), + "vendored mode drops `applied`: {v}" + ); + assert_refused_record(&v["patches"][0], WS_CODE, &v); + assert_eq!(v["vendor"]["summary"]["applied"], 0, "{v}"); + assert_eq!(view_requests_for(&mock, UUID).await, 0); + assert_refusal_left_tree_alone(tmp.path(), &lock_before); + assert_eq!( + manifest_value(tmp.path()), + Some(serde_json::json!({ "patches": {} })), + "the search path shares scan's manifest-writing download phase" + ); +} + +// --------------------------------------------------------------------------- +// --silent: errors only, so the refusal stays visible (code-tagged) +// --------------------------------------------------------------------------- + +/// `--silent` mutes informational chatter, never errors: each refusing +/// entry point exits 1 with an EMPTY stdout and the stable code (plus the +/// purl on the per-patch paths) on stderr. Regression guard: the refusal +/// lines were gated on `!silent` and a `--silent` run exited 1 mutely. +#[tokio::test] +async fn silent_refusals_stay_visible_on_stderr_with_empty_stdout() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + + for (label, argv) in [ + ( + "get ", + vec!["get", PURL, "--mode", "vendored", "--silent"], + ), + ( + "get ", + vec!["get", UUID, "--mode", "vendored", "--silent"], + ), + ("scan", vec!["scan", "--mode", "vendored", "--silent"]), + ] { + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::V1Workspace); + let mut argv = argv; + argv.extend_from_slice(&["--vendor-source", "build"]); + let (exit, stdout, stderr) = run(tmp.path(), &with_api(&argv, &mock.uri())); + assert_eq!(exit, 1, "{label}: stdout={stdout}\nstderr={stderr}"); + assert!( + stdout.trim().is_empty(), + "{label}: --silent must print nothing to stdout:\n{stdout}" + ); + assert!( + stderr.contains(WS_CODE), + "{label}: --silent must still name the refusal code on stderr:\n{stderr}" + ); + if label != "get " { + assert!( + stderr.contains(PURL), + "{label}: the per-patch error line must name the purl:\n{stderr}" + ); + } + } +} + +// --------------------------------------------------------------------------- +// --dry-run: the preview names the refusal (additive `would_refuse`) +// --------------------------------------------------------------------------- + +/// The vendored dry-run preview is a ledger classification by contract +/// (exit 0, `status:"success"`, nothing written); on a Bun project the +/// wet run is known to refuse, its npm records become the additive +/// `would_refuse` (+`errorCode`/`error`) instead of advertising +/// `would_vendor`. All three entry points; nothing touched on disk. +#[tokio::test] +async fn dry_run_previews_report_would_refuse_on_refused_bun_project() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + + for (label, argv) in [ + ( + "scan", + vec!["scan", "--mode", "vendored", "--dry-run", "--json"], + ), + ( + "scan --detached", + vec![ + "scan", + "--mode", + "vendored", + "--detached", + "--dry-run", + "--json", + ], + ), + ( + "get ", + vec!["get", UUID, "--mode", "vendored", "--dry-run", "--json"], + ), + ( + "get ", + vec!["get", PURL, "--mode", "vendored", "--dry-run", "--json"], + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::V1Workspace); + let lock_before = lock_bytes(tmp.path()); + let (exit, stdout, stderr) = run(tmp.path(), &with_api(&argv, &mock.uri())); + assert_eq!( + exit, 0, + "{label}: a preview never flips the exit: {stdout}\n{stderr}" + ); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "success", "{label}: {v}"); + let preview = &v["vendor"]; + assert_eq!(preview["dryRun"], true, "{label}: {v}"); + let rec = &preview["patches"][0]; + assert_eq!(rec["purl"], PURL, "{label}: {v}"); + assert_eq!(rec["uuid"], UUID, "{label}: {v}"); + assert_eq!(rec["action"], "would_refuse", "{label}: {v}"); + assert_eq!(rec["errorCode"], WS_CODE, "{label}: {v}"); + assert!( + rec["error"].as_str().is_some_and(|d| !d.is_empty()), + "{label}: {v}" + ); + assert!( + !tmp.path().join(".socket").exists(), + "{label}: a dry run writes nothing" + ); + assert_eq!(lock_bytes(tmp.path()), lock_before, "{label}"); + } +} + +/// The human dry-run keeps its count line and additionally names what the +/// wet run would refuse — under `--silent` it prints nothing at all. +#[tokio::test] +async fn dry_run_human_names_would_refuse_records() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::LockbOnly); + + let (exit, stdout, stderr) = get_vendored(tmp.path(), &mock.uri(), PURL, &["--dry-run"]); + assert_eq!(exit, 0, "stdout={stdout}\nstderr={stderr}"); + assert!( + stdout.contains("[dry-run] Would download and vendor 1 patch(es)."), + "{stdout}" + ); + assert!( + stdout.contains(&format!("[would-refuse] {PURL} ({LOCKB_CODE}):")), + "the human preview must name the refusal:\n{stdout}" + ); + assert!(!tmp.path().join(".socket").exists()); + + let (exit, stdout, _) = get_vendored(tmp.path(), &mock.uri(), PURL, &["--dry-run", "--silent"]); + assert_eq!(exit, 0); + assert!( + stdout.trim().is_empty(), + "silent dry run prints nothing:\n{stdout}" + ); +} + +// --------------------------------------------------------------------------- +// Agent --save-only: record-only intent is NOT preflighted +// --------------------------------------------------------------------------- + +/// The preflight is scoped to the vendored posture; an agent-mode +/// `get --save-only` on the same refused workspace project records the +/// patch and persists its blob exactly as on any other project (the +/// fresh-clone record→vendor workflow must keep working). +#[tokio::test] +async fn get_save_only_agent_bypasses_bun_preflight() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::V1Workspace); + + let argv = vec!["get", PURL, "--save-only", "--json"]; + let (exit, stdout, stderr) = run(tmp.path(), &with_api(&argv, &mock.uri())); + assert_eq!(exit, 0, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "success", "{v}"); + assert_eq!(v["patches"][0]["action"], "added", "{v}"); + assert!(v["patches"][0].get("errorCode").is_none(), "{v}"); + assert_eq!(view_requests_for(&mock, UUID).await, 1); + let manifest = manifest_value(tmp.path()).expect("manifest written"); + assert_eq!(manifest["patches"][PURL]["uuid"], UUID, "{manifest}"); + assert!( + tmp.path() + .join(".socket/blobs") + .join(common::git_sha256(AFTER)) + .is_file(), + "the agent download persists the after-blob" + ); +} + +// --------------------------------------------------------------------------- +// Positive controls: supported Bun shapes still vendor (and revert) +// --------------------------------------------------------------------------- + +/// A lockfileVersion-2 workspace lock (bun ≥ 1.4) is the supported +/// workspace shape: `scan --mode vendored` vendors left-pad — the registry +/// 4-tuple becomes the local-tarball 3-tuple, the workspace entry survives +/// byte-identically, the ledger records the bun flavor, the artifact is +/// committed — and the run exits 0. +#[tokio::test] +async fn scan_vendored_v2_workspace_lock_vendors() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::V2Workspace); + + let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--json"]); + assert_eq!(exit, 0, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "success", "{v}"); + assert_eq!(v["download"]["downloaded"], 1, "{v}"); + assert_eq!(v["download"]["patches"][0]["action"], "added", "{v}"); + assert_eq!(v["vendor"]["summary"]["applied"], 1, "{v}"); + + let lock = String::from_utf8(lock_bytes(tmp.path())).unwrap(); + let tgz_rel = format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"); + assert!( + lock.contains(&format!( + " \"left-pad\": [\"left-pad@{tgz_rel}\", {{}}, \"sha512-" + )), + "the registry tuple must become the local-tarball 3-tuple:\n{lock}" + ); + assert!( + lock.contains(" \"consumer\": [\"consumer@workspace:packages/consumer\"],\n"), + "the workspace entry must survive untouched:\n{lock}" + ); + assert!(lock.starts_with("{\n \"lockfileVersion\": 2,\n"), "{lock}"); + assert!(tmp.path().join(&tgz_rel).is_file(), "missing {tgz_rel}"); + let state: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/vendor/state.json")).unwrap(), + ) + .unwrap(); + assert_eq!(state["entries"][PURL]["uuid"], UUID, "{state}"); + assert_eq!(state["entries"][PURL]["flavor"], "bun", "{state}"); +} + +/// A lockfileVersion-0 single-package lock (bun 1.1.39–1.1.45 opt-in text +/// lock) is supported: `get --mode vendored --vendor-source build` +/// vendors it, and `rollback` restores the lock byte-for-byte and removes +/// the vendored tree. +#[tokio::test] +async fn get_uuid_vendored_v0_direct_lock_vendors_and_rollback_restores_bytes() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::V0Direct); + let lock_before = lock_bytes(tmp.path()); + + let (exit, stdout, stderr) = get_vendored(tmp.path(), &mock.uri(), UUID, &["--json"]); + assert_eq!(exit, 0, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "success", "{v}"); + assert_eq!(v["patches"][0]["action"], "added", "{v}"); + assert_eq!(v["vendor"]["summary"]["applied"], 1, "{v}"); + let tgz_rel = format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"); + let lock = String::from_utf8(lock_bytes(tmp.path())).unwrap(); + assert!(lock.contains(&format!("\"left-pad@{tgz_rel}\"")), "{lock}"); + assert!(lock.starts_with("{\n \"lockfileVersion\": 0,\n"), "{lock}"); + assert!(tmp.path().join(&tgz_rel).is_file()); + + let (exit, stdout, stderr) = run(tmp.path(), &with_api(&["rollback", "--json"], &mock.uri())); + assert_eq!(exit, 0, "rollback: stdout={stdout}\nstderr={stderr}"); + assert_eq!( + lock_bytes(tmp.path()), + lock_before, + "rollback must restore the v0 lock byte-for-byte" + ); + assert!( + !tmp.path().join(".socket/vendor/npm").exists(), + "rollback removes the vendored artifact tree" + ); +} + +// --------------------------------------------------------------------------- +// Already-vendored exemption (upgrade path of pre-gate projects) +// --------------------------------------------------------------------------- + +/// Vendor a supported v1 single-package project, then turn it into a +/// workspace the way a real repo evolves (add `packages/consumer`, the +/// `workspaces` field and — as `bun install` would — the 1-tuple workspace +/// entry, keeping the vendored tuple intact and the version at 1). +fn vendor_then_add_workspace(root: &Path, mock_uri: &str) -> Vec { + write_bun_project(root, LockShape::V1Direct); + let (exit, stdout, stderr) = scan_vendored(root, mock_uri, &["--json"]); + assert_eq!(exit, 0, "setup vendoring: stdout={stdout}\nstderr={stderr}"); + let lock = String::from_utf8(lock_bytes(root)).unwrap(); + assert!(lock.contains(".socket/vendor/npm/"), "setup: {lock}"); + let with_workspace = lock.replace( + " \"packages\": {\n", + " \"packages\": {\n \"consumer\": [\"consumer@workspace:packages/consumer\"],\n\n", + ); + assert_ne!(with_workspace, lock, "the workspace splice must land"); + std::fs::write(root.join("bun.lock"), &with_workspace).unwrap(); + let consumer = root.join("packages/consumer"); + std::fs::create_dir_all(&consumer).unwrap(); + std::fs::write( + consumer.join("package.json"), + r#"{"name":"consumer","version":"1.0.0","dependencies":{"other-pkg":"2.0.0"}}"#, + ) + .unwrap(); + std::fs::write( + root.join("package.json"), + r#"{"name":"bun-fixture","version":"1.0.0","private":true,"workspaces":["packages/*"],"dependencies":{"left-pad":"1.3.0","consumer":"workspace:*"}}"#, + ) + .unwrap(); + with_workspace.into_bytes() +} + +/// The download phase must NOT refuse a purl the ledger already wires at +/// the selected uuid: the re-run classifies it `skipped` (already in the +/// manifest) exactly as on a non-Bun project, instead of `failed`. Pinned +/// independently of the vendor step below so the CLI half of the +/// exemption is guarded even while the engine half lands separately. +#[tokio::test] +async fn already_vendored_v1_workspace_rerun_download_phase_is_skipped_not_refused() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + let lock_before = vendor_then_add_workspace(tmp.path(), &mock.uri()); + + let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--json"]); + let v = parse_single_json_doc(&stdout); + let rec = &v["download"]["patches"][0]; + assert_eq!( + rec["action"], "skipped", + "an in-sync vendored purl must not be refused by the preflight (exit {exit}): {v}\n{stderr}" + ); + assert!(rec.get("errorCode").is_none(), "{v}"); + assert_eq!(v["download"]["failed"], 0, "{v}"); + assert_eq!( + lock_bytes(tmp.path()), + lock_before, + "an in-sync re-run leaves the lock alone" + ); +} + +/// The full in-sync re-run on the upgraded workspace project: exit 0, the +/// download phase `skipped`, the vendor step `already_vendored`. Needs the +/// engine-side ordering fix (vendor_bun must classify the in-sync tuple +/// before applying the workspace gate — lane B1); until that lands the +/// vendor step still refuses with `vendor_bun_workspace_unsupported`. +#[tokio::test] +#[ignore = "needs lane B1: vendor_bun applies the workspace gate before the in-sync classification"] +async fn already_vendored_v1_workspace_rerun_is_already_vendored_exit_zero() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + let lock_before = vendor_then_add_workspace(tmp.path(), &mock.uri()); + + let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--json"]); + assert_eq!(exit, 0, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "success", "{v}"); + assert_eq!(v["download"]["patches"][0]["action"], "skipped", "{v}"); + let events = v["vendor"]["events"].as_array().unwrap(); + assert!( + events + .iter() + .any(|e| e["purl"] == PURL && e["action"] == "already_vendored"), + "{v}" + ); + assert_eq!(lock_bytes(tmp.path()), lock_before); +} + +// --------------------------------------------------------------------------- +// Workspace-member --cwd: today's behaviour, pinned +// --------------------------------------------------------------------------- + +/// `scan --mode vendored --cwd `: the member directory +/// holds no bun.lock, so the Bun preflight passes (it cannot see a Bun +/// project), the download phase RECORDS the patch in +/// `/.socket/manifest.json`, and the vendor engine then refuses +/// `vendor_lockfile_missing` (the flavor router finds no lockfile at cwd). +/// Pre-existing, flavor-agnostic behaviour (`--cwd` is the lockfile root +/// by contract) — pinned here so any change to it is deliberate. The root +/// tree is never touched. +#[tokio::test] +async fn scan_vendored_from_workspace_member_cwd_records_then_engine_refuses_lockfile_missing() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::V2Workspace); + // The member must have an installed copy for the crawler to find. + let member = tmp.path().join("packages/consumer"); + write_installed_left_pad(&member); + let lock_before = lock_bytes(tmp.path()); + + let member_str = member.to_str().unwrap().to_string(); + let (exit, stdout, stderr) = + scan_vendored(tmp.path(), &mock.uri(), &["--json", "--cwd", &member_str]); + assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "partial_failure", "{v}"); + assert_eq!(v["download"]["downloaded"], 1, "{v}"); + assert_eq!(v["download"]["patches"][0]["action"], "added", "{v}"); + let events = v["vendor"]["events"].as_array().unwrap(); + assert!( + events + .iter() + .any(|e| e["purl"] == PURL && e["errorCode"] == MISSING_CODE), + "the engine refuses from the member dir: {v}" + ); + let member_manifest = manifest_value(&member).expect("member manifest written"); + assert_eq!( + member_manifest["patches"][PURL]["uuid"], UUID, + "{member_manifest}" + ); + assert_eq!(lock_bytes(tmp.path()), lock_before, "root lock untouched"); + assert!(!tmp.path().join(".socket").exists(), "no root .socket/"); +} + +// --------------------------------------------------------------------------- +// FIFO bun.lock (Unix): refused fast, never wedged +// --------------------------------------------------------------------------- + +/// Spawn the binary with the same env scrub as `common::run_with_env`, but +/// kill it if it outlives `deadline` — a wedged child must fail the test, +/// not hang the suite. +#[cfg(unix)] +fn run_with_deadline(root: &Path, argv: &[&str], deadline: Duration) -> (i32, String, String) { + let out_path = root.join("child.stdout"); + let err_path = root.join("child.stderr"); + let mut cmd = Command::new(common::binary()); + cmd.args(argv).current_dir(root); + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy(); + if name.starts_with("SOCKET_") + && !name.contains("TELEMETRY") + && name != "SOCKET_NO_CONFIG" + && name != "SOCKET_NO_UPDATE_CHECK" + { + cmd.env_remove(&key); + } + } + cmd.env("SOCKET_NO_CONFIG", "1") + .env("SOCKET_NO_UPDATE_CHECK", "1") + .env("SOCKET_TELEMETRY_DISABLED", "1") + .stdin(Stdio::null()) + .stdout(std::fs::File::create(&out_path).unwrap()) + .stderr(std::fs::File::create(&err_path).unwrap()); + let mut child = cmd.spawn().expect("spawn socket-patch"); + let started = Instant::now(); + let status = loop { + if let Some(status) = child.try_wait().expect("try_wait") { + break status; + } + if started.elapsed() > deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!( + "socket-patch {argv:?} did not finish within {deadline:?} — wedged on the FIFO?\nstderr:\n{}", + std::fs::read_to_string(&err_path).unwrap_or_default() + ); + } + std::thread::sleep(Duration::from_millis(25)); + }; + ( + status.code().unwrap_or(-1), + std::fs::read_to_string(&out_path).unwrap_or_default(), + std::fs::read_to_string(&err_path).unwrap_or_default(), + ) +} + +#[cfg(unix)] +fn mkfifo(path: &Path) { + assert!( + Command::new("mkfifo").arg(path).status().unwrap().success(), + "mkfifo {}", + path.display() + ); +} + +/// A FIFO squatting `bun.lock` (a planted special file): the preflight's +/// guarded open refuses `vendor_lockfile_missing` immediately on both the +/// scan and the uuid path — no `open(2)` waiting forever for a writer. +#[cfg(unix)] +#[tokio::test] +async fn fifo_bun_lock_is_refused_without_blocking() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let uri = mock.uri(); + + // scan: discovery's lock inventory and the preflight both open the FIFO + // through the guarded path. + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::V1Direct); + std::fs::remove_file(tmp.path().join("bun.lock")).unwrap(); + mkfifo(&tmp.path().join("bun.lock")); + let argv = with_api( + &[ + "scan", + "--mode", + "vendored", + "--vendor-source", + "build", + "--json", + ], + &uri, + ); + let (exit, stdout, stderr) = run_with_deadline(tmp.path(), &argv, Duration::from_secs(20)); + assert_eq!(exit, 1, "scan: stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_refused_record(&v["download"]["patches"][0], MISSING_CODE, &v); + assert_eq!(view_requests_for(&mock, UUID).await, 0); + + // get : the FIFO is the only Bun artefact the preflight reads. + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::V1Direct); + std::fs::remove_file(tmp.path().join("bun.lock")).unwrap(); + mkfifo(&tmp.path().join("bun.lock")); + let argv = with_api( + &[ + "get", + UUID, + "--mode", + "vendored", + "--vendor-source", + "build", + "--json", + ], + &uri, + ); + let (exit, stdout, stderr) = run_with_deadline(tmp.path(), &argv, Duration::from_secs(20)); + assert_eq!(exit, 1, "get: stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "error", "{v}"); + assert_eq!(v["error"]["code"], MISSING_CODE, "{v}"); + assert!(!tmp.path().join(".socket").exists()); +} diff --git a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs index a8ff8903..a976bb03 100644 --- a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs @@ -1679,3 +1679,180 @@ async fn scan_apply_skips_lockfile_only_without_error() { "no manifest entry is written for a not-installed package" ); } + +// --------------------------------------------------------------------------- +// Bun vendored-mode preflight through `scan`: download phase, --detached, +// --silent +// --------------------------------------------------------------------------- + +const BUN_WS_CODE: &str = "vendor_bun_workspace_unsupported"; + +/// `write_fixture` re-locked by bun 1.3.14 as a workspace: the real +/// lockfileVersion-1 grammar (1-tuple `workspace:` entry, blank line +/// between entries, trailing commas; registry integrity from the BN3 spike +/// fixture), left-pad declared by the member — the shape the vendored gate +/// refuses (bun < 1.4 resolves member tarball paths relative to the member). +fn write_bun_v1_workspace_fixture(root: &Path) { + write_fixture(root); + std::fs::remove_file(root.join("package-lock.json")).unwrap(); + std::fs::write( + root.join("package.json"), + r#"{ "name": "scan-vendor-test", "version": "0.0.0", "private": true, "workspaces": ["packages/*"], "dependencies": { "consumer": "workspace:*" } }"#, + ) + .unwrap(); + let consumer = root.join("packages/consumer"); + std::fs::create_dir_all(&consumer).unwrap(); + std::fs::write( + consumer.join("package.json"), + r#"{ "name": "consumer", "version": "1.0.0", "dependencies": { "left-pad": "1.3.0" } }"#, + ) + .unwrap(); + std::fs::write( + root.join("bun.lock"), + "{\n \"lockfileVersion\": 1,\n \"configVersion\": 1,\n \"workspaces\": {\n \"\": {\n \"name\": \"scan-vendor-test\",\n \"dependencies\": {\n \"consumer\": \"workspace:*\",\n },\n },\n \"packages/consumer\": {\n \"name\": \"consumer\",\n \"version\": \"1.0.0\",\n \"dependencies\": {\n \"left-pad\": \"1.3.0\",\n },\n },\n },\n \"packages\": {\n \"consumer\": [\"consumer@workspace:packages/consumer\"],\n\n \"left-pad\": [\"left-pad@1.3.0\", \"\", {}, \"sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==\"],\n }\n}\n", + ) + .unwrap(); +} + +/// The manifest-tracked vendored scan refuses a v1 workspace lock IN THE +/// DOWNLOAD PHASE: the record is `failed` with the vendor code + detail, +/// nothing is fetched (request-log oracle), the lock is byte-identical, +/// nothing is vendored, and the manifest — written by contract — holds no +/// record for the refused purl. +#[tokio::test] +async fn scan_vendored_bun_v1_workspace_refuses_in_download_phase() { + let mock = MockServer::start().await; + mount_patch_api(&mock, UUID).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_v1_workspace_fixture(tmp.path()); + let lock_before = std::fs::read(tmp.path().join("bun.lock")).unwrap(); + + let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &["--mode", "vendored"]); + assert_eq!(code, 1, "stdout={stdout}; stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert_eq!(v["status"], "partial_failure", "envelope={v}"); + let dl = &v["download"]; + assert_eq!(dl["found"], 1, "envelope={v}"); + assert_eq!(dl["downloaded"], 0, "envelope={v}"); + assert_eq!(dl["failed"], 1, "envelope={v}"); + assert_eq!(dl["patches"][0]["purl"], PURL, "envelope={v}"); + assert_eq!(dl["patches"][0]["action"], "failed", "envelope={v}"); + assert_eq!(dl["patches"][0]["errorCode"], BUN_WS_CODE, "envelope={v}"); + assert!( + dl["patches"][0]["error"] + .as_str() + .is_some_and(|d| !d.is_empty()), + "the refused record carries the engine's detail: {v}" + ); + assert_eq!(v["vendor"]["summary"]["applied"], 0, "envelope={v}"); + + let reqs = mock.received_requests().await.unwrap(); + assert!( + !reqs.iter().any(|r| r.url.path().contains("/patches/view/")), + "a refused patch must never be fetched" + ); + assert_eq!( + std::fs::read(tmp.path().join("bun.lock")).unwrap(), + lock_before, + "bun.lock must be byte-identical" + ); + assert!(!tmp.path().join(".socket/vendor").exists()); + let manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(), + ) + .unwrap(); + assert_eq!( + manifest, + serde_json::json!({ "patches": {} }), + "no record may be claimed for the refused purl" + ); +} + +/// The `--detached` twin refuses BEFORE any fetch too (it used to fetch the +/// view and only fail in the vendor step): same record, zero downloads, +/// and — detached — no manifest at all. +#[tokio::test] +async fn scan_vendored_bun_detached_refuses_before_fetch() { + let mock = MockServer::start().await; + mount_patch_api(&mock, UUID).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_v1_workspace_fixture(tmp.path()); + let lock_before = std::fs::read(tmp.path().join("bun.lock")).unwrap(); + + let (code, stdout, stderr) = run_scan_vendor( + tmp.path(), + &mock.uri(), + &["--mode", "vendored", "--detached"], + ); + assert_eq!(code, 1, "stdout={stdout}; stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert_eq!(v["status"], "partial_failure", "envelope={v}"); + assert_eq!(v["download"]["detached"], true, "envelope={v}"); + assert_eq!(v["download"]["downloaded"], 0, "envelope={v}"); + assert_eq!(v["download"]["failed"], 1, "envelope={v}"); + assert_eq!( + v["download"]["patches"][0]["action"], "failed", + "envelope={v}" + ); + assert_eq!( + v["download"]["patches"][0]["errorCode"], BUN_WS_CODE, + "envelope={v}" + ); + let reqs = mock.received_requests().await.unwrap(); + assert!( + !reqs.iter().any(|r| r.url.path().contains("/patches/view/")), + "detached must refuse before fetching" + ); + assert!( + !tmp.path().join(".socket/manifest.json").exists(), + "detached mode never writes a manifest" + ); + assert!(!tmp.path().join(".socket/vendor").exists()); + assert_eq!( + std::fs::read(tmp.path().join("bun.lock")).unwrap(), + lock_before + ); +} + +/// The interactive (`--silent`, non-JSON) arm: "errors only" means the +/// refusal line — code-tagged, naming the purl — stays on stderr while +/// stdout is empty, exit 1. Regression guard: the line was gated on +/// `!silent`, so a `--silent` scan exited 1 with no text at all. +#[tokio::test] +async fn scan_vendored_bun_silent_human_names_code_on_stderr() { + let mock = MockServer::start().await; + mount_patch_api(&mock, UUID).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_v1_workspace_fixture(tmp.path()); + + let uri = mock.uri(); + let (code, stdout, stderr) = run_cli_env( + tmp.path(), + &[ + "scan", + "--mode", + "vendored", + "--vendor-source", + "build", + "--silent", + "--yes", + "--api-url", + &uri, + "--api-token", + "fake-token", + "--org", + ORG_SLUG, + ], + &[], + ); + assert_eq!(code, 1, "stdout={stdout}; stderr={stderr}"); + assert!( + stdout.trim().is_empty(), + "--silent must print nothing on stdout:\n{stdout}" + ); + assert!( + stderr.contains(&format!("[error] {PURL} ({BUN_WS_CODE}):")), + "--silent must keep the code-tagged refusal on stderr:\n{stderr}" + ); + assert!(!tmp.path().join(".socket/vendor").exists()); +} From 3f3e4c2e7bd3db70df0f7cad400d66978c8a34c3 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 18 Sep 2026 18:13:17 -0400 Subject: [PATCH 21/49] test(bun): hard-fail gates, lock-era assertions, 1.3.10 digest boundary, rollback + plain-install legs in the hosted real-bun suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hosted real-bun capstone soft-skipped whenever `bun` was missing, so it passed vacuously in every CI job; its tampered leg hard-asserted a frozen-install failure that bun < 1.3.10 never produces; its forced-v2 leg proved nothing distinct (native lock on >= 1.4, unreadable below); and it had no rollback leg and no plain-install lock-stability check. - `SOCKET_PATCH_BUN_E2E_REQUIRED` (set AND non-empty — CI passes an empty string for non-bun legs) turns every SKIP (no bun, fixture install failed, no text lock, unparsable version, pre-1.1.39 bun) into a hard assert; `SOCKET_PATCH_BUN_E2E_VERSION` must equal `bun --version` so a leg cannot pass on the wrong bun. - `bun --version` is parsed once; `--save-text-lockfile` is passed only for bun < 1.2.0 (the opt-in era); the fixture ASSERTS the emitted lockfileVersion matches the era table (1.1.39–1.1.x → 0, 1.2–1.3 → 1, >= 1.4 → 2) and every later assertion is version-independent. - Tampered leg gated on `TARBALL_INTEGRITY_ENFORCED_FROM = (1,3,10)`: >= 1.3.10 must fail on the integrity check; below it a DIFFERENT valid tarball must install with exit 0 and the installed bytes must be the tampered bytes (PARTIAL). Verified on 1.3.9 (accepts) vs 1.3.10 (rejects). - Forced-v2 leg replaced by the one distinct cross-version proof a single binary can give: on bun >= 1.4 the native v2 lock is relabelled to lockfileVersion 1 (configVersion kept — dropping it makes bun add `"configVersion": 0` on a plain install), the rewrite must keep the version line, and frozen + plain installs must succeed without a bump. - New rollback leg: `rollback --yes --json` restores bun.lock byte-for- byte, deletes the redirect ledger, and a fresh frozen install lands the ORIGINAL bytes. - Every install proof now also runs a plain `bun install` (node_modules removed, empty cache) and asserts the lock stays byte-identical — frozen mode never writes the lock, so only this observes re-serialization drift. - Tarballs are built with the tar crate (no system `tar`; Windows-ready), all bun installs pass `--ignore-scripts`, every CLI run passes `--no-telemetry`, and the stale lockb header comment now points at the in-process shim tests and the bun-compatibility native matrix. Verified with SOCKET_PATCH_BUN_E2E_REQUIRED=1 on real bun 1.1.39, 1.1.45, 1.2.23, 1.3.9, 1.3.10, 1.3.13, 1.3.14 and 1.4.2 (10/10 each); no bun → soft-skip, REQUIRED + no bun → loud failure. Co-Authored-By: Claude Fable 5.1 --- .../tests/e2e_redirect_bun_build.rs | 749 ++++++++++++++---- 1 file changed, 593 insertions(+), 156 deletions(-) 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 afe5e8c1..a8372d0e 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs @@ -8,35 +8,76 @@ //! `bun`: //! //! 1. `bun install` of left-pad@1.3.0 (network for fixture setup only, -//! private `BUN_INSTALL_CACHE_DIR`, text lockfile). -//! 2. Build a PATCHED tarball from the installed bytes; its sha512 is what +//! private `BUN_INSTALL_CACHE_DIR`). The text `bun.lock` is the default +//! from bun 1.2.0 (lockfileVersion 1; 2 from 1.4.0); on 1.1.39–1.1.x it +//! is the `--save-text-lockfile` opt-in (lockfileVersion 0), which the +//! fixture passes for those releases, and the fixture ASSERTS the +//! version it got matches that era table. Bun before 1.1.39 has no +//! text lockfile and the suite skips (or, under the REQUIRED gate, +//! fails — such a leg must not be scheduled). The registry 4-tuple +//! spelling is identical across 0/1/2, so everything after the +//! fixture guard is version-independent. +//! 2. Build a PATCHED tarball from the installed bytes (tar crate — no +//! system `tar`, so Windows runners need nothing); its sha512 is what //! the redirect mock hands back (bun verifies the downloaded tarball's //! sha512 directly — no cache-zip conversion like yarn berry, so no //! bootstrap is needed). //! 3. `scan --mode hosted --json --vex` (the real binary): bun.lock now -//! pins the hosted URL + the patched sha512, the ledger embeds the -//! record, the in-run VEX is the `(redirected)` attestation. +//! pins the hosted URL + the patched sha512 and keeps its own +//! lockfileVersion line, the ledger embeds the record, the in-run VEX +//! is the `(redirected)` attestation. //! 4. FRESH-CHECKOUT PROOF: only package.json + bun.lock + .socket/ travel; //! `bun install --frozen-lockfile` with a fresh `BUN_INSTALL_CACHE_DIR` -//! MUST install the patched bytes from the hosted tarball. +//! MUST install the patched bytes from the hosted tarball. Then the +//! ORDINARY install (`node_modules` removed, another empty cache, plain +//! `bun install`) MUST leave bun.lock byte-identical — frozen mode +//! never writes the lock, so only a plain install can observe bun +//! re-serializing the URL tuple (the backtest's `ordinaryStableLock` +//! is the matrix twin) — and land the marker bytes again. //! -//! The negative twin serves TAMPERED tarball bytes while the lock keeps the -//! real sha512: the fresh frozen install MUST fail with an integrity error. +//! The rollback leg continues from step 4: `rollback --yes` must restore +//! bun.lock byte-for-byte to the pre-redirect snapshot, delete the redirect +//! ledger, and a fresh frozen install of the restored lock must produce the +//! ORIGINAL registry bytes (marker gone). +//! +//! The negative twin serves TAMPERED tarball bytes (a different, valid +//! tarball) while the lock keeps the real sha512. Bun verifies URL-tarball +//! digests only from 1.3.10 (`Integrity check failed`): there the fresh +//! frozen install MUST fail, and on every older text-lock bun it MUST +//! succeed and install the tampered bytes (reported PARTIAL) — the boundary +//! is pinned from both sides as [`TARBALL_INTEGRITY_ENFORCED_FROM`]. The +//! vendored twin lives in `e2e_vendor_bun_build.rs`. //! //! The get-driven twin (v3.6) runs step 3 as `get --mode hosted` //! instead of `scan --mode hosted` — same hosted engine by construction //! (get routes through scan's `run_redirect_selected`), so the lock/ledger //! assertions and the fresh-checkout proof are shared via [`HostedDriver`]. //! +//! The lock-v1-on-newer-bun leg is the one cross-version install proof a +//! single binary can give: on bun ≥ 1.4 (native lockfileVersion 2) the +//! fixture lock is relabelled to `"lockfileVersion": 1` before the rewrite +//! (the lock a 1.3.x bun wrote — grammar-identical, `configVersion` kept), +//! because bun 1.4 reads such a lock and never bumps it in place, so an +//! upgraded team keeps installing the redirect from their committed v1 +//! lock. (The former forced-v2 leg proved nothing distinct: on ≥ 1.4 it +//! was the native lock, below 1.4 unreadable.) +//! //! `bun.lockb` (bun's legacy binary lockfile) auto-migration is NOT exercised -//! here: bun 1.3.x writes the text `bun.lock` by default and offers no flag to -//! emit the binary form, so a real lockb fixture cannot be generated on this -//! toolchain. That migration branch is covered by the in-process shim test -//! `scan_redirect_migrates_bun_lockb_then_redirects` in -//! `tests/in_process_redirect.rs`. +//! here: every bun ≥ 1.2 writes the text `bun.lock` by default and offers no +//! flag to emit the binary form, so a real lockb fixture cannot be generated +//! on the toolchains this suite is wired to. That branch is covered by the +//! in-process shim tests `scan_redirect_migrates_bun_lockb_then_redirects` +//! and siblings in `tests/in_process_redirect.rs`, and against real bun +//! 1.1.45 (lockb by default, migration-capable) by the bun-compatibility +//! native matrix (`scripts/backtest-bun.py`). //! -//! Skips (with a println) when `bun`/`tar` are missing or the fixture install -//! cannot reach the registry; every assertion after is hard. +//! Gates: without `SOCKET_PATCH_BUN_E2E_REQUIRED` (set AND non-empty — CI +//! passes an empty string for non-bun legs) a missing `bun`, a failed +//! fixture install or a bun without a text lockfile is a `println` SKIP and +//! every assertion after that is HARD. With it, those skips become hard +//! failures, and `SOCKET_PATCH_BUN_E2E_VERSION` (when set, non-empty) must +//! equal `bun --version`, so a CI leg cannot pass by running the wrong bun +//! or no bun at all. use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; @@ -56,25 +97,155 @@ const PURL: &str = "pkg:npm/left-pad@1.3.0"; const UUID: &str = "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d"; const TOKEN: &str = "22222222-2222-4222-8222-222222222222"; const MARKER: &str = "/* SOCKET-PATCHED */\n"; +/// Content of the tampered twin's served tarball — distinct from the +/// pristine AND the patched bytes so "bun installed the tampered bytes" is +/// a real assertion, not a trailing-byte no-op. +const TAMPER_MARKER: &str = "/* SOCKET-TAMPERED */\n"; const GHSA: &str = "GHSA-redirect-bun-real"; const PRODUCT: &str = "pkg:npm/app@1.0.0"; +/// `(major, minor, patch)` of the bun on PATH. +type BunVersion = (u64, u64, u64); + +/// First bun that verifies the sha512 of URL / local-tarball tuples on +/// install (1.3.9 installs a mismatched tarball with exit 0; 1.3.10 fails +/// with `Integrity check failed`). NOT 1.3.14 — that figure came from a +/// matrix that sampled only 1.3.0 and 1.3.14. Registry 4-tuples are +/// verified from 1.2.0 and are not what the hosted rewrite produces. +const TARBALL_INTEGRITY_ENFORCED_FROM: BunVersion = (1, 3, 10); +/// First bun with a text lockfile (`--save-text-lockfile` opt-in, +/// lockfileVersion 0). Older bun writes only the binary `bun.lockb`. +const TEXT_LOCK_FROM: BunVersion = (1, 1, 39); +/// Text lock becomes the default and bumps to lockfileVersion 1. +const LOCK_V1_FROM: BunVersion = (1, 2, 0); +/// lockfileVersion 2. +const LOCK_V2_FROM: BunVersion = (1, 4, 0); + // ── self-contained helpers ──────────────────────────────────────────── fn binary() -> PathBuf { PathBuf::from(env!("CARGO_BIN_EXE_socket-patch")) } -fn has_command(cmd: &str) -> bool { - let mut probe = Command::new(cmd); +/// The REQUIRED gate: set AND non-empty. CI's e2e matrix passes +/// `SOCKET_PATCH_BUN_E2E_REQUIRED: ${{ matrix.bun != '' && '1' || '' }}`, +/// so an empty value is the non-bun legs' "unset" — an `is_some()` gate +/// would turn every non-bun leg red. +fn bun_required() -> bool { + std::env::var_os("SOCKET_PATCH_BUN_E2E_REQUIRED").is_some_and(|v| !v.is_empty()) +} + +/// The exact bun the matrix leg pinned, when it pinned one. +fn pinned_bun_version() -> Option { + std::env::var("SOCKET_PATCH_BUN_E2E_VERSION") + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +/// `1.4.2` → `(1, 4, 2)`; a canary suffix (`1.4.3-canary.12+abc`) is cut at +/// the first `-`/`+`. `None` for anything that is not three integers. +fn parse_bun_version(raw: &str) -> Option { + let core = raw.trim().split(['-', '+']).next()?; + let mut parts = core.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next()?.parse().ok()?; + if parts.next().is_some() { + return None; + } + Some((major, minor, patch)) +} + +/// `bun --version` through the cache sandbox: `Some(trimmed stdout)` when +/// bun ran and exited 0, `None` when it is not on PATH (or cannot start). +fn bun_version_output() -> Option { + let mut probe = Command::new("bun"); probe.arg("--version"); + scrub_socket_env(&mut probe); cache_env::isolate(&mut probe); - probe - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) + let out = probe.stderr(Stdio::null()).output().ok()?; + out.status + .success() + .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +/// The lockfileVersion the era table says this bun writes for a FRESH +/// install: 1.1.39–1.1.x opt-in text lock → 0, 1.2–1.3 → 1, ≥ 1.4 → 2. +fn expected_lock_version(v: BunVersion) -> u64 { + if v >= LOCK_V2_FROM { + 2 + } else if v >= LOCK_V1_FROM { + 1 + } else { + 0 + } +} + +/// The fixture's `bun install` argv: lifecycle scripts never run (hygiene — +/// left-pad has none, but the fixture is a REAL registry install), and +/// `--save-text-lockfile` is passed only where the text lock is still an +/// opt-in (< 1.2.0), so newer bun is exercised exactly as users run it. +fn fixture_install_args(v: BunVersion) -> Vec<&'static str> { + let mut args = vec!["install", "--ignore-scripts"]; + if v < LOCK_V1_FROM { + args.push("--save-text-lockfile"); + } + args +} + +/// `"lockfileVersion": ` from the lock head — the same head scan as +/// `socket_patch_core::vendor::bun_lock_text::lock_version` (pub(crate) +/// there, so mirrored here). +fn lock_version(text: &str) -> Option { + text.lines() + .take(5) + .find_map(|line| line.trim().strip_prefix("\"lockfileVersion\":")) + .and_then(|rest| rest.trim().trim_end_matches(',').parse().ok()) +} + +/// The toolchain preflight every leg runs first: bun present, pinned +/// version honored, text lockfile available. `None` = this leg is skipped +/// (already reported with a println) — but under the REQUIRED gate every +/// one of those is a hard failure instead, because a CI leg that silently +/// skips is exactly the vacuous pass this suite had for months. +fn bun_toolchain(tag: &str) -> Option<(String, BunVersion)> { + let Some(raw) = bun_version_output() else { + assert!( + !bun_required(), + "SOCKET_PATCH_BUN_E2E_REQUIRED is set but `bun --version` did not run — \ + the matrix leg must install bun before running this suite" + ); + println!("SKIP e2e_redirect_bun_build ({tag}): `bun` not installed"); + return None; + }; + if let Some(pin) = pinned_bun_version() { + assert_eq!( + raw, pin, + "SOCKET_PATCH_BUN_E2E_VERSION pins bun {pin} but PATH resolves bun {raw}: the \ + matrix must run the pinned version" + ); + } + let Some(version) = parse_bun_version(&raw) else { + assert!( + !bun_required(), + "required bun toolchain reports an unparsable version {raw:?}" + ); + println!("SKIP e2e_redirect_bun_build ({tag}): unparsable `bun --version` output {raw:?}"); + return None; + }; + if version < TEXT_LOCK_FROM { + assert!( + !bun_required(), + "bun {raw} has no text lockfile (the `--save-text-lockfile` opt-in exists from \ + 1.1.39); a REQUIRED leg must not be scheduled on it" + ); + println!( + "SKIP e2e_redirect_bun_build ({tag}): bun {raw} predates the text bun.lock (1.1.39)" + ); + return None; + } + Some((raw, version)) } fn scrub_socket_env(cmd: &mut Command) { @@ -96,9 +267,11 @@ fn bun(cwd: &Path, args: &[&str], cache_dir: &Path) -> Output { cmd.output().expect("failed to run bun") } +/// The real binary with `--no-telemetry` appended: nothing in this suite +/// should ever post a telemetry event, mocked API or not. fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { let mut cmd = Command::new(binary()); - cmd.args(args).current_dir(cwd); + cmd.args(args).arg("--no-telemetry").current_dir(cwd); scrub_socket_env(&mut cmd); let out = cmd.output().expect("failed to run socket-patch binary"); ( @@ -126,14 +299,75 @@ fn copy_dir_recursive(src: &Path, dst: &Path) { } } +/// A VALID npm tarball built from the ACTUALLY-installed package with the +/// entry point swapped for `replaced_index`. Built with the tar crate +/// rather than a system `tar` so the suite has no external-binary +/// dependency (bun installs from tar-crate output fine, as pnpm does in +/// `e2e_redirect_pnpm_build.rs`). +fn make_tgz_from_installed(pkg_dir: &Path, replaced_index: &[u8]) -> Vec { + let pkg_dir = pkg_dir + .canonicalize() + .expect("installed package dir must resolve"); + let mut files: Vec = Vec::new(); + let mut stack = vec![pkg_dir.clone()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).unwrap() { + let p = entry.unwrap().path(); + if p.is_dir() { + stack.push(p); + } else { + files.push(p); + } + } + } + files.sort(); + let mut builder = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::default(), + )); + for p in &files { + let rel = p.strip_prefix(&pkg_dir).unwrap(); + // Tar entry names always use `/` regardless of host separator. + let name = format!( + "package/{}", + rel.components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect::>() + .join("/") + ); + let bytes = if rel == Path::new("index.js") { + replaced_index.to_vec() + } else { + std::fs::read(p).unwrap() + }; + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder + .append_data(&mut header, &name, bytes.as_slice()) + .unwrap(); + } + builder.into_inner().unwrap().finish().unwrap() +} + struct BunRedirectFixture { tmp: tempfile::TempDir, proj: PathBuf, + /// The pristine installed `index.js`. + orig: Vec, + /// `MARKER` + orig — what the honest hosted tarball carries. 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, + /// `TAMPER_MARKER` + orig — what the tampered twin's route serves. + tampered: Vec, + /// bun.lock as it stood right before the hosted rewrite (after any + /// relabel) — the byte-exact rollback target. + lock_before: Vec, + /// The `"lockfileVersion"` the rewrite must preserve. + lock_version: u64, + /// `bun --version`, verbatim, for messages. + bun_raw: String, + bun_version: BunVersion, _server: MockServer, } @@ -154,24 +388,37 @@ enum HostedDriver { GetUuid, } +/// Which lock the hosted rewrite runs on. +#[derive(Clone, Copy, PartialEq)] +enum LockShape { + /// Whatever the installed bun wrote (asserted against the era table). + Native, + /// On bun ≥ 1.4 only: the native lockfileVersion-2 lock relabelled to 1 + /// (`configVersion` kept — the lock a 1.3.x bun wrote; dropping it + /// would make bun add `"configVersion": 0` on the first plain install, + /// bun's own migration and not ours). Below 1.4 the leg is PARTIAL: + /// one binary cannot be both the writer and the newer reader. + V1OnNewerBun, +} + /// Steps 1–3: real install, patched tarball + API mocks, the `driver`'s /// hosted invocation, and the envelope/lockfile/ledger assertions. /// `tamper_served_tarball` serves DIFFERENT bytes than the sha512 pinned -/// into the lock. `force_lock_version` re-pins the fixture lock's -/// `"lockfileVersion"` line before the hosted run (sound because v1 and v2 -/// share one emitted grammar). `None` = skip. +/// into the lock. `None` = skip (already reported). async fn bun_hosted_project( tag: &str, tamper_served_tarball: bool, driver: HostedDriver, - force_lock_version: Option, + shape: LockShape, ) -> Option { - if !has_command("bun") { - println!("SKIP e2e_redirect_bun_build ({tag}): `bun` not installed"); - return None; - } - if !has_command("tar") { - println!("SKIP e2e_redirect_bun_build ({tag}): `tar` not installed"); + let (bun_raw, bun_version) = bun_toolchain(tag)?; + if shape == LockShape::V1OnNewerBun && bun_version < LOCK_V2_FROM { + println!( + "PARTIAL e2e_redirect_bun_build ({tag}): bun {bun_raw} writes lockfileVersion {} \ + itself, so the newer-bun-on-a-v1-lock scenario needs bun >= 1.4 — leg not \ + applicable on this toolchain", + expected_lock_version(bun_version) + ); return None; } @@ -188,8 +435,14 @@ async fn bun_hosted_project( // 1. REAL fixture: bun install (network here, private cache). Text lockfile. let cache = tmp.path().join("bun-cache"); - let install = bun(&proj, &["install", "--save-text-lockfile"], &cache); + let install = bun(&proj, &fixture_install_args(bun_version), &cache); if !install.status.success() { + assert!( + !bun_required(), + "required bun {bun_raw} fixture `bun install` failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&install.stdout), + String::from_utf8_lossy(&install.stderr) + ); println!( "SKIP e2e_redirect_bun_build ({tag}): fixture `bun install` failed (registry \ unreachable?):\n{}", @@ -197,33 +450,69 @@ async fn bun_hosted_project( ); return None; } - if !proj.join("bun.lock").is_file() { + let lock_path = proj.join("bun.lock"); + if !lock_path.is_file() { + assert!( + !bun_required(), + "required bun {bun_raw} produced no text bun.lock after {:?}", + fixture_install_args(bun_version) + ); println!( "SKIP e2e_redirect_bun_build ({tag}): bun produced no text bun.lock (binary \ lockfile?)" ); return None; } - let native_lock = std::fs::read_to_string(proj.join("bun.lock")).unwrap(); - let native_lock_v2 = native_lock.contains("\"lockfileVersion\": 2"); - if let Some(v) = force_lock_version { - // Splice ONLY the version line (v1 and v2 share one emitted grammar). - let forced: String = native_lock - .split_inclusive('\n') - .map(|line| { - if line.trim_start().starts_with("\"lockfileVersion\":") { - format!(" \"lockfileVersion\": {v},\n") - } else { - line.to_string() - } - }) - .collect(); - assert!( - forced.contains(&format!("\"lockfileVersion\": {v},")), - "the fixture lock must carry a lockfileVersion line to force:\n{native_lock}" - ); - std::fs::write(proj.join("bun.lock"), forced).unwrap(); - } + // Hermeticity guard: the install must have gone through the PRIVATE + // cache, or the fresh-checkout "empty cache" premise below is void. + assert!( + cache.is_dir() && std::fs::read_dir(&cache).unwrap().next().is_some(), + "fixture install did not populate the private BUN_INSTALL_CACHE_DIR at {}", + cache.display() + ); + let native_lock = std::fs::read_to_string(&lock_path).unwrap(); + // The era table, asserted rather than assumed: 1.1.39–1.1.x opt-in + // text lock → 0, 1.2–1.3 → 1, ≥ 1.4 → 2. Pinning the mapping is what + // makes a lock-era CI leg prove the era it claims to cover. + let native_version = lock_version(&native_lock).unwrap_or_else(|| { + panic!("fixture bun.lock has no integer lockfileVersion in its head:\n{native_lock}") + }); + assert_eq!( + native_version, + expected_lock_version(bun_version), + "bun {bun_raw} wrote lockfileVersion {native_version}; the era table expects {} \ + (1.1.39–1.1.x → 0, 1.2–1.3 → 1, ≥ 1.4 → 2):\n{native_lock}", + expected_lock_version(bun_version) + ); + assert!( + native_lock.contains(&format!("\"{DEP}@{DEP_VERSION}\", \"\"")), + "pre-redirect packages entry must be the registry 4-tuple:\n{native_lock}" + ); + let lock_version = match shape { + LockShape::Native => native_version, + LockShape::V1OnNewerBun => { + // Splice ONLY the version line (v1 and v2 share one emitted + // grammar; `configVersion` stays, see `LockShape`). + let relabelled: String = native_lock + .split_inclusive('\n') + .map(|line| { + if line.trim_start().starts_with("\"lockfileVersion\":") { + " \"lockfileVersion\": 1,\n".to_string() + } else { + line.to_string() + } + }) + .collect(); + assert_eq!( + lock_version(&relabelled), + Some(1), + "the relabelled fixture lock must read back as lockfileVersion 1:\n{relabelled}" + ); + std::fs::write(&lock_path, relabelled).unwrap(); + 1 + } + }; + let lock_before = std::fs::read(&lock_path).unwrap(); let installed_dir = proj.join("node_modules").join(DEP); let orig = std::fs::read(installed_dir.join("index.js")).expect("installed index.js"); @@ -232,26 +521,17 @@ async fn bun_hosted_project( "pristine install must not carry the marker" ); let patched: Vec = [MARKER.as_bytes(), orig.as_slice()].concat(); + let tampered: Vec = [TAMPER_MARKER.as_bytes(), orig.as_slice()].concat(); // 2. Patched tarball from the installed package; its sha512 is the pin. - let stage = tmp.path().join("tarstage"); - copy_dir_recursive(&installed_dir, &stage.join("package")); - std::fs::write(stage.join("package").join("index.js"), &patched).unwrap(); - let tgz_path = tmp.path().join(format!("{DEP}-{DEP_VERSION}.tgz")); - let tar = Command::new("tar") - .args(["-czf", tgz_path.to_str().unwrap(), "package"]) - .current_dir(&stage) - .output() - .expect("failed to run tar"); - assert!( - tar.status.success(), - "tar failed: {}", - String::from_utf8_lossy(&tar.stderr) - ); - let tgz = std::fs::read(&tgz_path).unwrap(); + // The negative twin only tampers what the route SERVES (a different, + // still-valid tarball), so the pin is what catches the swap. + let tgz = make_tgz_from_installed(&installed_dir, &patched); let sri = format!("sha512-{}", sha512_sri_b64(&tgz)); let served: Vec = if tamper_served_tarball { - [tgz.as_slice(), &[0u8][..]].concat() + let tampered_tgz = make_tgz_from_installed(&installed_dir, &tampered); + assert_ne!(tampered_tgz, tgz, "the tampered tarball must differ"); + tampered_tgz } else { tgz.clone() }; @@ -445,11 +725,11 @@ async fn bun_hosted_project( "hosted get downloads/applies nothing — those keys must be absent: {env}" ); assert!( - !proj.join(".socket/manifest.json").exists(), + !proj.join(".socket").join("manifest.json").exists(), "get --mode hosted must NOT write the manifest" ); assert!( - !proj.join(".socket/blobs").exists(), + !proj.join(".socket").join("blobs").exists(), "get --mode hosted must NOT persist blobs" ); // Anti-vacuity oracle: the grant really came from the reference @@ -468,8 +748,9 @@ async fn bun_hosted_project( } } - // Lockfile pin: the hosted URL (as the tuple spec) + the patched sha512. - let lock = std::fs::read_to_string(proj.join("bun.lock")).unwrap(); + // Lockfile pin: the hosted URL (as the tuple spec) + the patched sha512, + // the registry 4-tuple gone, and the lock's own version line kept. + let lock = std::fs::read_to_string(&lock_path).unwrap(); assert!( lock.contains(&format!("\"{DEP}@{hosted_url}\"")), "bun.lock tuple spec must be name@; got:\n{lock}" @@ -478,14 +759,17 @@ async fn bun_hosted_project( lock.contains(&sri), "bun.lock integrity must be the patched sha512 ({sri}); got:\n{lock}" ); - if let Some(v) = force_lock_version { - assert!( - lock.contains(&format!("\"lockfileVersion\": {v},")), - "the rewrite must preserve the lockfileVersion line verbatim; got:\n{lock}" - ); - } + assert!( + !lock.contains(&format!("\"{DEP}@{DEP_VERSION}\", \"\"")), + "the registry 4-tuple must be gone after the rewrite:\n{lock}" + ); + assert_eq!( + self::lock_version(&lock), + Some(lock_version), + "the rewrite must preserve the lockfileVersion line verbatim; got:\n{lock}" + ); - let ledger = std::fs::read_to_string(proj.join(".socket/vendor/redirect-state.json")).unwrap(); + let ledger = std::fs::read_to_string(redirect_ledger(&proj)).unwrap(); assert!( ledger.contains("\"records\"") && ledger.contains(GHSA), "redirect ledger must embed the patch record + vulnerability: {ledger}" @@ -494,43 +778,57 @@ async fn bun_hosted_project( Some(BunRedirectFixture { tmp, proj, + orig, patched, - native_lock_v2, + tampered, + lock_before, + lock_version, + bun_raw, + bun_version, _server: server, }) } -/// Fresh dir with only the committable files, then `bun install -/// --frozen-lockfile` against an empty cache. -fn fresh_checkout_bun_install(fx: &BunRedirectFixture) -> (PathBuf, Output) { - let fresh = fx.tmp.path().join("fresh"); +fn redirect_ledger(proj: &Path) -> PathBuf { + proj.join(".socket") + .join("vendor") + .join("redirect-state.json") +} + +/// Fresh dir `/` with only the committable files (package.json, +/// bun.lock, and `.socket/` when it exists — rollback removes it). +fn fresh_checkout(fx: &BunRedirectFixture, name: &str) -> PathBuf { + let fresh = fx.tmp.path().join(name); std::fs::create_dir_all(&fresh).unwrap(); std::fs::copy(fx.proj.join("package.json"), fresh.join("package.json")).unwrap(); std::fs::copy(fx.proj.join("bun.lock"), fresh.join("bun.lock")).unwrap(); - copy_dir_recursive(&fx.proj.join(".socket"), &fresh.join(".socket")); - let fresh_cache = fx.tmp.path().join("fresh-bun-cache"); - let ci = bun(&fresh, &["install", "--frozen-lockfile"], &fresh_cache); - (fresh, ci) + if fx.proj.join(".socket").is_dir() { + copy_dir_recursive(&fx.proj.join(".socket"), &fresh.join(".socket")); + } + fresh } -// ── the capstone ────────────────────────────────────────────────────── - -// #[serial]: bun shares an on-disk cache/registry-metadata directory across -// installs of the same URL; serializing keeps the tampered twin from reusing -// the main leg's honest bytes (each leg also uses its own cache dir). -#[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, HostedDriver::ScanVex, None).await else { - return; - }; - assert_patched_fresh_install(&fx); +/// `bun install --frozen-lockfile` in a fresh checkout named `name` against +/// an empty cache. +fn fresh_frozen_install(fx: &BunRedirectFixture, name: &str) -> (PathBuf, Output) { + let fresh = fresh_checkout(fx, name); + let fresh_cache = fx.tmp.path().join(format!("{name}-bun-cache")); + let ci = bun( + &fresh, + &["install", "--frozen-lockfile", "--ignore-scripts"], + &fresh_cache, + ); + (fresh, ci) } /// Shared fresh-checkout proof: `bun install --frozen-lockfile` against an -/// empty cache must materialize the PATCHED bytes from the hosted tarball. +/// empty cache must materialize the PATCHED bytes from the hosted tarball; +/// then an ORDINARY `bun install` (node_modules removed, another empty +/// cache) must leave bun.lock byte-identical and land the marker again. +/// Frozen mode never writes the lock, so only the plain install can catch +/// bun re-serializing the URL tuple (backtest twin: `ordinaryStableLock`). fn assert_patched_fresh_install(fx: &BunRedirectFixture) { - let (fresh, ci) = fresh_checkout_bun_install(fx); + let (fresh, ci) = fresh_frozen_install(fx, "fresh"); assert!( ci.status.success(), "fresh-checkout `bun install --frozen-lockfile` must succeed from the hosted patch \ @@ -538,7 +836,8 @@ fn assert_patched_fresh_install(fx: &BunRedirectFixture) { String::from_utf8_lossy(&ci.stdout), String::from_utf8_lossy(&ci.stderr), ); - let installed = std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); + let installed_index = fresh.join("node_modules").join(DEP).join("index.js"); + let installed = std::fs::read(&installed_index).unwrap(); assert!( installed.starts_with(MARKER.as_bytes()), "bun must install the PATCHED bytes from the hosted patch; got:\n{}", @@ -548,6 +847,50 @@ fn assert_patched_fresh_install(fx: &BunRedirectFixture) { installed, fx.patched, "fresh install must be byte-identical to the patched content" ); + eprintln!( + "FRESH INSTALL OK (bun {}, lockfileVersion {})", + fx.bun_raw, fx.lock_version + ); + + // Ordinary install: the lock must survive bun's own re-serialization. + let wired_lock = std::fs::read(fx.proj.join("bun.lock")).unwrap(); + std::fs::remove_dir_all(fresh.join("node_modules")).unwrap(); + let plain_cache = fx.tmp.path().join("fresh-plain-bun-cache"); + let plain = bun(&fresh, &["install", "--ignore-scripts"], &plain_cache); + assert!( + plain.status.success(), + "plain `bun install` on the redirected lock must succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&plain.stdout), + String::from_utf8_lossy(&plain.stderr), + ); + assert_eq!( + std::fs::read(fresh.join("bun.lock")).unwrap(), + wired_lock, + "an ORDINARY `bun install` must leave the redirected bun.lock byte-identical \ + (re-serialization drift would churn every commit)" + ); + assert_eq!( + std::fs::read(&installed_index).unwrap(), + fx.patched, + "the ordinary install must land the patched bytes too" + ); + eprintln!("PLAIN INSTALL LOCK-STABLE"); +} + +// ── the capstone ────────────────────────────────────────────────────── + +// #[serial]: bun shares an on-disk cache/registry-metadata directory across +// installs of the same URL; serializing keeps the tampered twin from reusing +// the main leg's honest bytes (each leg also uses its own cache dir). +#[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, HostedDriver::ScanVex, LockShape::Native).await + else { + return; + }; + assert_patched_fresh_install(&fx); } /// get-driven twin (v3.6): `get --mode hosted` must land the SAME @@ -558,79 +901,173 @@ fn assert_patched_fresh_install(fx: &BunRedirectFixture) { #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn bun_get_uuid_hosted_fresh_checkout_installs() { - let Some(fx) = bun_hosted_project("get-uuid", false, HostedDriver::GetUuid, None).await else { + let Some(fx) = + bun_hosted_project("get-uuid", false, HostedDriver::GetUuid, LockShape::Native).await + else { return; }; + assert_patched_fresh_install(&fx); +} - let (fresh, ci) = fresh_checkout_bun_install(&fx); - assert!( - ci.status.success(), - "fresh-checkout `bun install --frozen-lockfile` must succeed from the hosted patch \ - tarball after `get --mode hosted`.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&ci.stdout), - String::from_utf8_lossy(&ci.stderr), - ); - let installed = std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); +/// Cross-version leg: a team on bun ≥ 1.4 keeps installing from the +/// lockfileVersion-1 lock their 1.3.x wrote — bun 1.4 reads it and never +/// bumps it in place — so the hosted rewrite must land on that lock, keep +/// `"lockfileVersion": 1`, frozen-install the patched bytes, and survive an +/// ordinary install byte-for-byte (no bump to 2). PARTIAL below bun 1.4: +/// one binary cannot be both the older writer and the newer reader. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn bun_redirect_lock_v1_on_newer_bun_fresh_checkout_installs_patched_bytes() { + let Some(fx) = bun_hosted_project( + "lock-v1-on-newer-bun", + false, + HostedDriver::ScanVex, + LockShape::V1OnNewerBun, + ) + .await + else { + return; + }; assert!( - installed.starts_with(MARKER.as_bytes()), - "bun must install the PATCHED bytes from the hosted patch; got:\n{}", - String::from_utf8_lossy(&installed[..installed.len().min(120)]) + fx.bun_version >= LOCK_V2_FROM && fx.lock_version == 1, + "leg precondition: bun {} (>= 1.4) on a relabelled lockfileVersion-1 lock", + fx.bun_raw ); + assert_patched_fresh_install(&fx); + // The plain install above left the lock byte-identical; say the version + // part out loud so a future "bun 1.x bumps v1 in place" shows up by name. + let lock = std::fs::read_to_string(fx.proj.join("bun.lock")).unwrap(); assert_eq!( - installed, fx.patched, - "fresh install must be byte-identical to the patched content" + lock_version(&lock), + Some(1), + "bun {} must not bump the committed lockfileVersion-1 lock:\n{lock}", + fx.bun_raw ); } -/// 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. +/// Negative twin: the hosted route serves TAMPERED bytes (a different valid +/// tarball) while the lock pins the real sha512. From bun 1.3.10 the fresh +/// frozen install must refuse on the integrity check; earlier bun installs +/// the tampered bytes with exit 0 and the leg pins THAT (PARTIAL), so the +/// digest boundary is asserted from both sides across the lock-era legs. #[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, HostedDriver::ScanVex, Some(2)).await else { +async fn bun_redirect_tampered_hosted_tarball_digest_boundary() { + let Some(fx) = + bun_hosted_project("tampered", true, HostedDriver::ScanVex, LockShape::Native).await + else { return; }; - if !fx.native_lock_v2 { + + let (fresh, ci) = fresh_frozen_install(&fx, "fresh-tampered"); + let chatter = format!( + "{}\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr) + ); + if fx.bun_version >= TARBALL_INTEGRITY_ENFORCED_FROM { + assert!( + !ci.status.success(), + "bun {} install MUST fail when the served tarball does not match the pinned \ + sha512 (URL-tarball digests are enforced from 1.3.10).\n{chatter}", + fx.bun_raw + ); + let lower = chatter.to_lowercase(); + assert!( + lower.contains("integrity") + || lower.contains("checksum") + || lower.contains("hash") + || chatter.contains("IntegrityCheckFailed"), + "the failure must be the integrity check, not something incidental:\n{chatter}" + ); + eprintln!("TAMPER REJECTED OK (bun {})", fx.bun_raw); + } else { + assert!( + ci.status.success(), + "bun {} (< 1.3.10) does not verify URL-tarball digests, so the tampered install \ + must still exit 0 — a failure here means the boundary moved.\n{chatter}", + fx.bun_raw + ); + let installed = + std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); + assert_eq!( + installed, fx.tampered, + "bun {} installed neither the tampered bytes nor failed: the boundary model is wrong", + fx.bun_raw + ); 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" + "PARTIAL e2e_redirect_bun_build (tampered): bun {} does not verify URL tarball \ + digests (enforced from 1.3.10) — rejection proof unavailable, acceptance pinned", + fx.bun_raw ); - 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. +/// Rollback leg: after the hosted rewrite and the fresh-checkout proof, +/// `rollback --yes` (unscoped — the whole-ledger reverse replay) must +/// restore bun.lock byte-for-byte to the pre-redirect snapshot and delete +/// the redirect ledger, and a fresh frozen install of the restored lock +/// must land the ORIGINAL registry bytes — the marker gone. #[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, HostedDriver::ScanVex, None).await else { +async fn bun_redirect_rollback_restores_lock_and_original_install() { + let Some(fx) = + bun_hosted_project("rollback", false, HostedDriver::ScanVex, LockShape::Native).await + else { return; }; + assert_patched_fresh_install(&fx); - let (_fresh, ci) = fresh_checkout_bun_install(&fx); + let proj = &fx.proj; + let (code, stdout, stderr) = run_socket( + proj, + &[ + "rollback", + "--yes", + "--json", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "rollback failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("rollback --json output is not JSON: {e}\nstdout:\n{stdout}")); + assert_eq!(env["status"], "success", "rollback envelope: {env}"); + assert_eq!( + std::fs::read(proj.join("bun.lock")).unwrap(), + fx.lock_before, + "rollback must restore bun.lock byte-identical to the pre-redirect snapshot" + ); assert!( - !ci.status.success(), - "bun install MUST fail when the served tarball does not match the pinned sha512.\n\ - stdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&ci.stdout), - String::from_utf8_lossy(&ci.stderr), + !redirect_ledger(proj).exists(), + "rollback must delete the redirect ledger" ); - let chatter = format!( - "{}\n{}", + let restored = std::fs::read_to_string(proj.join("bun.lock")).unwrap(); + assert!( + restored.contains(&format!("\"{DEP}@{DEP_VERSION}\", \"\"")), + "the registry 4-tuple must be back after rollback:\n{restored}" + ); + eprintln!("ROLLBACK OK"); + + // The restored lock installs the ORIGINAL bytes from the registry. + let (fresh, ci) = fresh_frozen_install(&fx, "fresh-rolled-back"); + assert!( + ci.status.success(), + "fresh-checkout `bun install --frozen-lockfile` of the restored lock must \ + succeed.\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&ci.stdout), - String::from_utf8_lossy(&ci.stderr) + String::from_utf8_lossy(&ci.stderr), ); + let installed = std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); assert!( - chatter.to_lowercase().contains("integrity") - || chatter.to_lowercase().contains("checksum") - || chatter.to_lowercase().contains("hash") - || chatter.contains("IntegrityCheckFailed"), - "the failure must be the integrity check, not something incidental:\n{chatter}" + !installed.starts_with(MARKER.as_bytes()), + "after rollback bun must install the ORIGINAL bytes, not the patch" + ); + assert_eq!( + installed, fx.orig, + "after rollback the fresh install must be byte-identical to the pristine package" ); } From 741e94d9fd3d8a4301644d2d03d33635538bba53 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 18 Sep 2026 18:13:17 -0400 Subject: [PATCH 22/49] test(bun): accept lockfileVersion 0, hard-fail gates, tampered twin, repair + plain-install legs in the vendored real-bun suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vendored real-bun capstone hard-asserted the fixture lock was lockfileVersion 1 or 2, so on bun 1.1.39–1.1.45 (the releases whose `--save-text-lockfile` writes 0 — the very ones the PR adds support for) both tests panicked at fixture setup; it soft-skipped without bun (vacuous CI pass); it had no tampered twin, no repair leg, and its only lock-stability check ran after `--frozen-lockfile`, which never writes. - Same REQUIRED / VERSION gates and version-aware fixture as the hosted suite: `bun --version` parsed once, `--save-text-lockfile` only for bun < 1.2.0, the emitted lockfileVersion ASSERTED against the era table (0 / 1 / 2) and recorded on the fixture; the rewrite must keep the version line; the registry 4-tuple spelling is identical across eras so every downstream assertion is version-independent. - New tampered twin: the vendored `.tgz` is swapped for a DIFFERENT valid tarball; from 1.3.10 the fresh frozen install must fail on the integrity check, below it must exit 0 and install the tampered bytes (PARTIAL). Verified on 1.3.9 (accepts) vs 1.3.10 (rejects). - New repair leg inside the capstone: `.socket/vendor/npm//` is deleted, `repair --offline --yes` must rebuild the tarball byte- identically without touching bun.lock, and a cold fresh checkout must frozen-install the marker bytes from the rebuilt artifact. - Every install proof now also runs a plain `bun install` (node_modules removed, empty cache) and asserts bun.lock stays byte-identical and the marker lands again. - Tests are `#[serial]` like the hosted suite, all bun installs pass `--ignore-scripts`, every CLI run passes `--no-telemetry`, and the replacement tarball is built with the tar crate (Windows-ready). Verified with SOCKET_PATCH_BUN_E2E_REQUIRED=1 on real bun 1.1.39, 1.1.45, 1.2.23, 1.3.9, 1.3.10, 1.3.13, 1.3.14 and 1.4.2 (8/8 each); no bun → soft-skip, REQUIRED + no bun → loud failure. Co-Authored-By: Claude Fable 5.1 --- .../tests/e2e_vendor_bun_build.rs | 604 +++++++++++++++--- 1 file changed, 528 insertions(+), 76 deletions(-) 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 11d9db79..cdc90efc 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs @@ -3,36 +3,62 @@ //! //! Drives the REAL `bun` (network used for fixture setup only): //! 1. `bun install` of left-pad@1.3.0 into a tempdir (private -//! `BUN_INSTALL_CACHE_DIR`). bun 1.3.x writes the text `bun.lock` by -//! default; `--save-text-lockfile` is passed as a belt-and-braces guard -//! against a future binary-lockfile default. +//! `BUN_INSTALL_CACHE_DIR`). The text `bun.lock` is the default from +//! bun 1.2.0 (lockfileVersion 1; 2 from 1.4.0); on 1.1.39–1.1.x it is +//! the `--save-text-lockfile` opt-in (lockfileVersion 0), which the +//! fixture passes for those releases. Bun before 1.1.39 has no text +//! lockfile at all and the suite skips (or, under the REQUIRED gate, +//! fails — such a leg must not be scheduled). //! 2. Hand-stage a `.socket/` manifest + blob from the ACTUAL installed //! bytes (a marker comment prepended to `index.js`). //! 3. `socket-patch vendor --json --offline` — assert the deterministic //! tarball lands at `.socket/vendor/npm//…` and the bun.lock //! `packages` entry is rewritten from the registry 4-tuple to the //! local-tarball 3-tuple `["@", {deps}, "sha512-"]` -//! (spike BN1/BN3). package.json is left UNTOUCHED. +//! (spike BN1/BN3). package.json is left UNTOUCHED. The registry +//! 4-tuple spelling is identical across lockfileVersion 0, 1 and 2, so +//! every assertion after the fixture guard is version-independent. //! 4. **Fresh-checkout proof**: copy ONLY the committable files //! (package.json + bun.lock + .socket/) to a new dir, an EMPTY //! `BUN_INSTALL_CACHE_DIR`, and run the spike's strictest invocation //! `bun install --frozen-lockfile` — the patched bytes MUST be what bun -//! installs (BN7). -//! 5. Idempotency: re-running vendor leaves bun.lock byte-identical. -//! 6. **Revert proof**: `vendor --revert` restores bun.lock byte-for-byte +//! installs (BN7). Then the ORDINARY install: `node_modules` removed, +//! another empty cache, plain `bun install` — bun.lock must stay +//! byte-identical (frozen mode never writes the lock, so only a plain +//! install can observe re-serialization drift; the backtest's +//! `ordinaryStableLock` is the matrix twin) and the marker bytes must +//! land again. +//! 5. **Repair proof**: delete `.socket/vendor/npm//` outright, +//! `repair --offline` must rebuild the tarball byte-identically from +//! the installed copy + blob without touching bun.lock, and a fresh +//! cold-cache frozen install must again land the marker bytes. +//! 6. Idempotency: re-running vendor leaves bun.lock byte-identical. +//! 7. **Revert proof**: `vendor --revert` restores bun.lock byte-for-byte //! and removes `.socket/vendor/` entirely. //! //! The get-driven twin (v3.6) replaces steps 2–3 with a wiremock //! `view/{uuid}` (same hashes, base64 `blobContent` of the after bytes) and //! `get --mode vendored --vendor-source build` — scan's vendored //! posture end to end: manifest + committed artifact + ledger + wired lock, -//! NO `.socket/blobs` — then re-runs the same fresh-checkout frozen-install -//! proof. The revert half is not repeated there: `vendor --revert` on the +//! NO `.socket/blobs` — then re-runs the same fresh-checkout install proof. +//! The revert half is not repeated there: `vendor --revert` on the //! capstone already covers it (same ledger, same engine). //! -//! LOCAL capstone (not behind docker-e2e): skips with a `println` + return -//! when `bun` is unavailable or the fixture install cannot reach the -//! registry; every assertion after that is HARD. +//! The tampered twin swaps the committed tarball for a DIFFERENT valid +//! tarball while bun.lock keeps our sha512: bun verifies the digest of +//! local-tarball tuples only from 1.3.10 (`Integrity check failed`), so the +//! fresh frozen install MUST fail there and MUST succeed — installing the +//! tampered bytes — on every older text-lock bun (reported as PARTIAL). The +//! boundary is pinned as [`TARBALL_INTEGRITY_ENFORCED_FROM`]; the hosted +//! twin lives in `e2e_redirect_bun_build.rs`. +//! +//! Gates: without `SOCKET_PATCH_BUN_E2E_REQUIRED` (set AND non-empty — CI +//! passes an empty string for non-bun legs) a missing `bun`, a failed +//! fixture install or a bun without a text lockfile is a `println` SKIP and +//! every assertion after that is HARD. With it, those skips become hard +//! failures, and `SOCKET_PATCH_BUN_E2E_VERSION` (when set, non-empty) must +//! equal `bun --version`, so a CI leg cannot pass by running the wrong bun +//! or no bun at all. use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; @@ -46,26 +72,156 @@ mod cache_env; const UUID: &str = "1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab"; const MARKER: &str = "/* SOCKET-PATCHED */\n"; +/// Content of the tampered twin's replacement tarball — distinct from the +/// pristine AND the patched bytes so "bun installed the tampered bytes" is +/// a real assertion, not a trailing-byte no-op. +const TAMPER_MARKER: &str = "/* SOCKET-TAMPERED */\n"; const DEP: &str = "left-pad"; const DEP_VERSION: &str = "1.3.0"; const ORG: &str = "test-org"; +/// `(major, minor, patch)` of the bun on PATH. +type BunVersion = (u64, u64, u64); + +/// First bun that verifies the sha512 of URL / local-tarball tuples on +/// install (1.3.9 installs a mismatched tarball with exit 0; 1.3.10 fails +/// with `Integrity check failed`). NOT 1.3.14 — that figure came from a +/// matrix that sampled only 1.3.0 and 1.3.14. Registry 4-tuples are +/// verified from 1.2.0 and are not what the vendored rewrite produces. +const TARBALL_INTEGRITY_ENFORCED_FROM: BunVersion = (1, 3, 10); +/// First bun with a text lockfile (`--save-text-lockfile` opt-in, +/// lockfileVersion 0). Older bun writes only the binary `bun.lockb`. +const TEXT_LOCK_FROM: BunVersion = (1, 1, 39); +/// Text lock becomes the default and bumps to lockfileVersion 1. +const LOCK_V1_FROM: BunVersion = (1, 2, 0); +/// lockfileVersion 2. +const LOCK_V2_FROM: BunVersion = (1, 4, 0); + // ── self-contained helpers ──────────────────────────────────────────── fn binary() -> PathBuf { PathBuf::from(env!("CARGO_BIN_EXE_socket-patch")) } -fn has_command(cmd: &str) -> bool { - let mut probe = Command::new(cmd); +/// The REQUIRED gate: set AND non-empty. CI's e2e matrix passes +/// `SOCKET_PATCH_BUN_E2E_REQUIRED: ${{ matrix.bun != '' && '1' || '' }}`, +/// so an empty value is the non-bun legs' "unset" — an `is_some()` gate +/// would turn every non-bun leg red. +fn bun_required() -> bool { + std::env::var_os("SOCKET_PATCH_BUN_E2E_REQUIRED").is_some_and(|v| !v.is_empty()) +} + +/// The exact bun the matrix leg pinned, when it pinned one. +fn pinned_bun_version() -> Option { + std::env::var("SOCKET_PATCH_BUN_E2E_VERSION") + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +/// `1.4.2` → `(1, 4, 2)`; a canary suffix (`1.4.3-canary.12+abc`) is cut at +/// the first `-`/`+`. `None` for anything that is not three integers. +fn parse_bun_version(raw: &str) -> Option { + let core = raw.trim().split(['-', '+']).next()?; + let mut parts = core.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next()?.parse().ok()?; + if parts.next().is_some() { + return None; + } + Some((major, minor, patch)) +} + +/// `bun --version` through the cache sandbox: `Some(trimmed stdout)` when +/// bun ran and exited 0, `None` when it is not on PATH (or cannot start). +fn bun_version_output() -> Option { + let mut probe = Command::new("bun"); probe.arg("--version"); + scrub_socket_env(&mut probe); cache_env::isolate(&mut probe); - probe - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) + let out = probe.stderr(Stdio::null()).output().ok()?; + out.status + .success() + .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +/// The lockfileVersion the era table says this bun writes for a FRESH +/// install: 1.1.39–1.1.x opt-in text lock → 0, 1.2–1.3 → 1, ≥ 1.4 → 2. +fn expected_lock_version(v: BunVersion) -> u64 { + if v >= LOCK_V2_FROM { + 2 + } else if v >= LOCK_V1_FROM { + 1 + } else { + 0 + } +} + +/// The fixture's `bun install` argv: lifecycle scripts never run (hygiene — +/// left-pad has none, but the fixture is a REAL registry install), and +/// `--save-text-lockfile` is passed only where the text lock is still an +/// opt-in (< 1.2.0), so newer bun is exercised exactly as users run it. +fn fixture_install_args(v: BunVersion) -> Vec<&'static str> { + let mut args = vec!["install", "--ignore-scripts"]; + if v < LOCK_V1_FROM { + args.push("--save-text-lockfile"); + } + args +} + +/// `"lockfileVersion": ` from the lock head — the same head scan as +/// `socket_patch_core::vendor::bun_lock_text::lock_version` (pub(crate) +/// there, so mirrored here). +fn lock_version(text: &str) -> Option { + text.lines() + .take(5) + .find_map(|line| line.trim().strip_prefix("\"lockfileVersion\":")) + .and_then(|rest| rest.trim().trim_end_matches(',').parse().ok()) +} + +/// The toolchain preflight every leg runs first: bun present, pinned +/// version honored, text lockfile available. `None` = this leg is skipped +/// (already reported with a println) — but under the REQUIRED gate every +/// one of those is a hard failure instead, because a CI leg that silently +/// skips is exactly the vacuous pass this suite had for months. +fn bun_toolchain(tag: &str) -> Option<(String, BunVersion)> { + let Some(raw) = bun_version_output() else { + assert!( + !bun_required(), + "SOCKET_PATCH_BUN_E2E_REQUIRED is set but `bun --version` did not run — \ + the matrix leg must install bun before running this suite" + ); + println!("SKIP e2e_vendor_bun_build ({tag}): `bun` not installed"); + return None; + }; + if let Some(pin) = pinned_bun_version() { + assert_eq!( + raw, pin, + "SOCKET_PATCH_BUN_E2E_VERSION pins bun {pin} but PATH resolves bun {raw}: the \ + matrix must run the pinned version" + ); + } + let Some(version) = parse_bun_version(&raw) else { + assert!( + !bun_required(), + "required bun toolchain reports an unparsable version {raw:?}" + ); + println!("SKIP e2e_vendor_bun_build ({tag}): unparsable `bun --version` output {raw:?}"); + return None; + }; + if version < TEXT_LOCK_FROM { + assert!( + !bun_required(), + "bun {raw} has no text lockfile (the `--save-text-lockfile` opt-in exists from \ + 1.1.39); a REQUIRED leg must not be scheduled on it" + ); + println!( + "SKIP e2e_vendor_bun_build ({tag}): bun {raw} predates the text bun.lock (1.1.39)" + ); + return None; + } + Some((raw, version)) } /// Run `bun ` in `cwd` with the given private cache dir, the shared @@ -95,9 +251,11 @@ fn scrub_socket_env(cmd: &mut Command) { cmd.env_remove("BUN_INSTALL_CACHE_DIR"); } +/// The real binary with `--no-telemetry` appended: nothing in this suite +/// should ever post a telemetry event, mocked API or not. fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { let mut cmd = Command::new(binary()); - cmd.args(args).current_dir(cwd); + cmd.args(args).arg("--no-telemetry").current_dir(cwd); scrub_socket_env(&mut cmd); let out = cmd.output().expect("failed to run socket-patch binary"); ( @@ -162,6 +320,59 @@ fn copy_dir_recursive(src: &Path, dst: &Path) { } } +/// A VALID npm tarball built from the ACTUALLY-installed package with the +/// entry point swapped — the tampered twin's replacement artifact. Built +/// with the tar crate (no system `tar`, so Windows runners need nothing); +/// the point is a sha512 that differs from the one bun.lock pins while the +/// archive still extracts, so "bun installed the tampered bytes" can be +/// asserted on the pre-1.3.10 releases that never check the digest. +fn make_tgz_from_installed(pkg_dir: &Path, replaced_index: &[u8]) -> Vec { + let pkg_dir = pkg_dir + .canonicalize() + .expect("installed package dir must resolve"); + let mut files: Vec = Vec::new(); + let mut stack = vec![pkg_dir.clone()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).unwrap() { + let p = entry.unwrap().path(); + if p.is_dir() { + stack.push(p); + } else { + files.push(p); + } + } + } + files.sort(); + let mut builder = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::default(), + )); + for p in &files { + let rel = p.strip_prefix(&pkg_dir).unwrap(); + // Tar entry names always use `/` regardless of host separator. + let name = format!( + "package/{}", + rel.components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect::>() + .join("/") + ); + let bytes = if rel == Path::new("index.js") { + replaced_index.to_vec() + } else { + std::fs::read(p).unwrap() + }; + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder + .append_data(&mut header, &name, bytes.as_slice()) + .unwrap(); + } + builder.into_inner().unwrap().finish().unwrap() +} + // ── shared fixture (steps 1–2) ──────────────────────────────────────── /// The real-bun project both capstones drive, plus the pre-vendor snapshots @@ -174,18 +385,21 @@ struct BunProject { purl: String, lock_before: Vec, pkg_before: Vec, + /// `bun --version`, verbatim, for messages. + bun_raw: String, + bun_version: BunVersion, + /// The lockfileVersion this bun wrote — asserted against the era table. + lock_version: u64, } /// Steps 1–2 of the module doc, shared by the vendor capstone and the /// get-driven twin: a tempdir project depending on left-pad, a REAL /// `bun install` (network here, private cache) with the hermeticity guard, /// pristine-byte checks, and the patched-content twin of the installed -/// `index.js`. `None` = soft-skip, already reported with a println. +/// `index.js`. `None` = soft-skip, already reported with a println (a hard +/// failure instead under the REQUIRED gate). fn bun_project(tag: &str) -> Option { - if !has_command("bun") { - println!("SKIP e2e_vendor_bun_build ({tag}): `bun` not installed"); - return None; - } + let (bun_raw, bun_version) = bun_toolchain(tag)?; let tmp = tempfile::tempdir().unwrap(); let proj = tmp.path().join("proj"); @@ -199,11 +413,15 @@ fn bun_project(tag: &str) -> Option { .unwrap(); // 1. REAL fixture: bun install (network allowed here, private cache). - // `--save-text-lockfile` guarantees the text bun.lock vendor wires - // (bun 1.3.x already defaults to it; the flag future-proofs the test). let cache = tmp.path().join("bun-cache"); - let install = bun(&proj, &["install", "--save-text-lockfile"], &cache); + let install = bun(&proj, &fixture_install_args(bun_version), &cache); if !install.status.success() { + assert!( + !bun_required(), + "required bun {bun_raw} fixture `bun install` failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&install.stdout), + String::from_utf8_lossy(&install.stderr) + ); println!( "SKIP e2e_vendor_bun_build ({tag}): fixture `bun install` failed (registry \ unreachable?):\n{}", @@ -213,6 +431,11 @@ fn bun_project(tag: &str) -> Option { } let lock_path = proj.join("bun.lock"); if !lock_path.is_file() { + assert!( + !bun_required(), + "required bun {bun_raw} produced no text bun.lock after {:?}", + fixture_install_args(bun_version) + ); println!( "SKIP e2e_vendor_bun_build ({tag}): bun produced no text bun.lock (binary \ lockfile?) — this bun version's default lockfile is not the wirable text form" @@ -240,14 +463,22 @@ fn bun_project(tag: &str) -> Option { let lock_before = std::fs::read(&lock_path).expect("bun.lock after bun install"); let pkg_before = std::fs::read(proj.join("package.json")).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") - || lock_before_str.contains("\"lockfileVersion\": 2"), - "fixture must be a bun text lockfileVersion 1 or 2:\n{lock_before_str}" + // The era table, asserted rather than assumed: 1.1.39–1.1.x opt-in + // text lock → 0, 1.2–1.3 → 1, ≥ 1.4 → 2. All three are one emitted + // grammar for registry entries and all three are wirable; pinning the + // mapping is what makes a lock-era CI leg prove the era it claims. + let lock_version = lock_version(&lock_before_str).unwrap_or_else(|| { + panic!("fixture bun.lock has no integer lockfileVersion in its head:\n{lock_before_str}") + }); + assert_eq!( + lock_version, + expected_lock_version(bun_version), + "bun {bun_raw} wrote lockfileVersion {lock_version}; the era table expects {} \ + (1.1.39–1.1.x → 0, 1.2–1.3 → 1, ≥ 1.4 → 2):\n{lock_before_str}", + expected_lock_version(bun_version) ); - // Pre-vendor: the registry 4-tuple `["left-pad@1.3.0", "", {}, "sha512-…"]`. + // Pre-vendor: the registry 4-tuple `["left-pad@1.3.0", "", {}, "sha512-…"]` + // — the same spelling in lockfileVersion 0, 1 and 2. assert!( lock_before_str.contains(&format!("\"{DEP}@{DEP_VERSION}\", \"\"")), "pre-vendor packages entry must be the registry 4-tuple:\n{lock_before_str}" @@ -261,29 +492,47 @@ fn bun_project(tag: &str) -> Option { purl, lock_before, pkg_before, + bun_raw, + bun_version, + lock_version, }) } +fn vendored_tgz_rel() -> String { + format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz") +} + +fn vendored_dir(proj: &Path) -> PathBuf { + proj.join(".socket").join("vendor").join("npm").join(UUID) +} + +fn vendored_tgz(proj: &Path) -> PathBuf { + vendored_dir(proj).join(format!("{DEP}-{DEP_VERSION}.tgz")) +} + /// The on-disk vendored state BOTH drivers (`vendor --offline`, `get /// --mode vendored`) must produce: the committed artifact + informational /// marker + ledger, the bun.lock `packages` entry rewritten from the /// registry 4-tuple to the local-tarball 3-tuple with OUR recomputed /// integrity, and package.json untouched. -fn assert_vendored_on_disk(proj: &Path, pkg_before: &[u8]) { - let tgz_rel = format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz"); +fn assert_vendored_on_disk(fx: &BunProject) { + let proj = &fx.proj; + let tgz_rel = vendored_tgz_rel(); assert!( - proj.join(&tgz_rel).is_file(), + vendored_tgz(proj).is_file(), "vendored tarball missing at {tgz_rel}" ); assert!( - proj.join(format!( - ".socket/vendor/npm/{UUID}/socket-patch.vendor.json" - )) - .is_file(), + vendored_dir(proj) + .join("socket-patch.vendor.json") + .is_file(), "informational vendor marker missing" ); assert!( - proj.join(".socket/vendor/state.json").is_file(), + proj.join(".socket") + .join("vendor") + .join("state.json") + .is_file(), "vendor ledger missing" ); @@ -305,28 +554,54 @@ fn assert_vendored_on_disk(proj: &Path, pkg_before: &[u8]) { ), "the inherited registry integrity must NOT survive the rewrite:\n{lock_after}" ); + // The rewrite must keep the lock's own version line — a v0 lock stays + // v0, a v2 lock stays v2 (no silent format bump by the CLI). + assert_eq!( + lock_version(&lock_after), + Some(fx.lock_version), + "the vendored rewrite must preserve the lockfileVersion line verbatim:\n{lock_after}" + ); // package.json is left untouched by the lock-only bun wiring. assert_eq!( std::fs::read(proj.join("package.json")).unwrap(), - pkg_before, + fx.pkg_before, "bun vendoring is lock-only; package.json must stay byte-identical" ); } -/// Step 4, shared: fresh dir with ONLY the committable files (package.json, -/// bun.lock, and .socket/), an EMPTY `BUN_INSTALL_CACHE_DIR`, and the -/// spike-proven strictest invocation `bun install --frozen-lockfile` — the -/// patched bytes MUST be what bun installs (BN7), and the committed lock -/// must stay byte-identical. -fn fresh_checkout_frozen_install(tmp: &Path, proj: &Path, patched: &[u8]) { - let fresh = tmp.join("fresh"); +/// Fresh dir `/` holding ONLY the committable files +/// (package.json, bun.lock, and .socket/). +fn fresh_checkout(tmp: &Path, proj: &Path, name: &str) -> PathBuf { + let fresh = tmp.join(name); std::fs::create_dir_all(&fresh).unwrap(); std::fs::copy(proj.join("package.json"), fresh.join("package.json")).unwrap(); std::fs::copy(proj.join("bun.lock"), fresh.join("bun.lock")).unwrap(); copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); + fresh +} + +/// `bun install --frozen-lockfile` in a fresh checkout named `name` against +/// an EMPTY cache — the spike-proven strictest invocation. +fn fresh_frozen_install(tmp: &Path, proj: &Path, name: &str) -> (PathBuf, Output) { + let fresh = fresh_checkout(tmp, proj, name); + let fresh_cache = tmp.join(format!("{name}-bun-cache")); + let ci = bun( + &fresh, + &["install", "--frozen-lockfile", "--ignore-scripts"], + &fresh_cache, + ); + (fresh, ci) +} - let fresh_cache = tmp.join("fresh-bun-cache"); - let ci = bun(&fresh, &["install", "--frozen-lockfile"], &fresh_cache); +/// Step 4, shared: the fresh-checkout frozen install MUST land the patched +/// bytes (BN7); then the ORDINARY install (node_modules removed, another +/// empty cache, plain `bun install`) MUST leave the committed lock +/// byte-identical and land the patched bytes again. Frozen mode never +/// writes the lock, so only the plain install can observe a +/// re-serialization of the local-tarball tuple — the property the module +/// doc calls BN3 and the backtest checks as `ordinaryStableLock`. +fn fresh_checkout_install_proof(tmp: &Path, proj: &Path, patched: &[u8], name: &str) { + let (fresh, ci) = fresh_frozen_install(tmp, proj, name); assert!( ci.status.success(), "fresh-checkout `bun install --frozen-lockfile` must succeed from the vendored \ @@ -334,8 +609,8 @@ fn fresh_checkout_frozen_install(tmp: &Path, proj: &Path, patched: &[u8]) { String::from_utf8_lossy(&ci.stdout), String::from_utf8_lossy(&ci.stderr), ); - let fresh_installed = - std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); + let installed_index = fresh.join("node_modules").join(DEP).join("index.js"); + let fresh_installed = std::fs::read(&installed_index).unwrap(); assert!( fresh_installed.starts_with(MARKER.as_bytes()), "bun must install the PATCHED bytes from the vendored tarball; got:\n{}", @@ -345,19 +620,92 @@ fn fresh_checkout_frozen_install(tmp: &Path, proj: &Path, patched: &[u8]) { fresh_installed, patched, "fresh install must be byte-identical to the patched content" ); - // --frozen-lockfile would have errored if the lock drifted; prove it - // left the committed lock byte-stable. + eprintln!("FRESH INSTALL OK ({name})"); + + // Ordinary install: the lock must survive bun's own re-serialization. + let wired_lock = std::fs::read(proj.join("bun.lock")).unwrap(); + std::fs::remove_dir_all(fresh.join("node_modules")).unwrap(); + let plain_cache = tmp.join(format!("{name}-plain-bun-cache")); + let plain = bun(&fresh, &["install", "--ignore-scripts"], &plain_cache); + assert!( + plain.status.success(), + "plain `bun install` on the vendored lock must succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&plain.stdout), + String::from_utf8_lossy(&plain.stderr), + ); assert_eq!( std::fs::read(fresh.join("bun.lock")).unwrap(), - std::fs::read(proj.join("bun.lock")).unwrap(), - "--frozen-lockfile install must leave bun.lock byte-identical" + wired_lock, + "an ORDINARY `bun install` must leave the vendored bun.lock byte-identical \ + (re-serialization drift would churn every commit)" ); - eprintln!("FRESH INSTALL OK"); + assert_eq!( + std::fs::read(&installed_index).unwrap(), + patched, + "the ordinary install must land the patched bytes too" + ); + eprintln!("PLAIN INSTALL LOCK-STABLE ({name})"); +} + +/// The tampered twin's shared tail: bun.lock pins OUR sha512 while the +/// committed tarball now holds different bytes. Which outcome is correct +/// depends on the bun: from 1.3.10 the fresh frozen install MUST fail on +/// the integrity check; before it bun never verifies local-tarball digests +/// and MUST install the tampered bytes with exit 0 (reported PARTIAL — the +/// rejection proof is not available on that release, by bun's design). +fn assert_tamper_outcome(fx: &BunProject, tampered: &[u8]) { + let (fresh, ci) = fresh_frozen_install(fx.tmp.path(), &fx.proj, "fresh-tampered"); + let chatter = format!( + "{}\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr) + ); + if fx.bun_version >= TARBALL_INTEGRITY_ENFORCED_FROM { + assert!( + !ci.status.success(), + "bun {} install MUST fail when the vendored tarball does not match the pinned \ + sha512 (digests are enforced from 1.3.10).\n{chatter}", + fx.bun_raw + ); + let lower = chatter.to_lowercase(); + assert!( + lower.contains("integrity") + || lower.contains("checksum") + || lower.contains("hash") + || chatter.contains("IntegrityCheckFailed"), + "the failure must be the integrity check, not something incidental:\n{chatter}" + ); + eprintln!("TAMPER REJECTED OK (bun {})", fx.bun_raw); + } else { + assert!( + ci.status.success(), + "bun {} (< 1.3.10) does not verify local-tarball digests, so the tampered \ + install must still exit 0 — a failure here means the boundary moved.\n{chatter}", + fx.bun_raw + ); + let installed = + std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); + assert_eq!( + installed, tampered, + "bun {} installed neither the tampered bytes nor failed: the boundary model is wrong", + fx.bun_raw + ); + println!( + "PARTIAL e2e_vendor_bun_build (tampered): bun {} does not verify local tarball \ + digests (enforced from 1.3.10) — rejection proof unavailable, acceptance pinned", + fx.bun_raw + ); + } } // ── the capstone ────────────────────────────────────────────────────── +// #[serial]: each fresh install gets its own empty cache dir, but bun also +// keeps state under the sandboxed `~/.bun`; serializing keeps the tampered +// twin (same local tarball spec, different bytes) from ever racing a +// sibling's honest install. #[test] +#[serial_test::serial] fn bun_vendor_fresh_checkout_frozen_install_and_revert() { let Some(fx) = bun_project("vendor-offline") else { return; @@ -400,15 +748,70 @@ fn bun_vendor_fresh_checkout_frozen_install_and_revert() { "clean apply event: {applied}" ); - assert_vendored_on_disk(proj, &fx.pkg_before); - eprintln!("VENDOR OK"); + assert_vendored_on_disk(&fx); + eprintln!( + "VENDOR OK (bun {}, lockfileVersion {})", + fx.bun_raw, fx.lock_version + ); // 4. FRESH-CHECKOUT PROOF: committable files only, EMPTY cache, - // spike-proven `--frozen-lockfile`. - fresh_checkout_frozen_install(fx.tmp.path(), proj, &fx.patched); + // spike-proven `--frozen-lockfile`, then the ordinary-install + // lock-stability twin. + fresh_checkout_install_proof(fx.tmp.path(), proj, &fx.patched, "fresh"); - // 5. Idempotency: a re-run exits 0 and leaves bun.lock byte-stable. + // 5. REPAIR PROOF: the committed artifact dir vanishes (a botched merge, + // an over-eager clean); `repair --offline` must rebuild the tarball + // byte-identically from the installed copy + blob, leave bun.lock + // alone, and a cold fresh checkout must install the marker bytes + // from the rebuilt artifact. + let tgz_path = vendored_tgz(proj); + let tgz_bytes = std::fs::read(&tgz_path).unwrap(); let lock_wired = std::fs::read(&lock_path).unwrap(); + std::fs::remove_dir_all(vendored_dir(proj)).unwrap(); + assert!(!tgz_path.exists(), "precondition: the vendored dir is gone"); + let (code, stdout, stderr) = run_socket( + proj, + &[ + "repair", + "--json", + "--offline", + "--yes", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "repair failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let renv = parse_envelope(&stdout); + assert_eq!(renv["status"], "success", "repair envelope: {renv}"); + assert_eq!( + renv["summary"]["rebuilt"], 1, + "repair must rebuild the one deleted artifact: {renv}" + ); + assert!( + renv["events"] + .as_array() + .unwrap() + .iter() + .any(|e| e["action"] == "rebuilt" && e["purl"] == purl.as_str()), + "repair must report a rebuilt event for {purl}: {renv}" + ); + assert_eq!( + std::fs::read(&tgz_path).unwrap(), + tgz_bytes, + "the deterministic rebuild must reproduce the vendored tarball byte-for-byte" + ); + assert_eq!( + std::fs::read(&lock_path).unwrap(), + lock_wired, + "repair must not touch bun.lock" + ); + eprintln!("REPAIR OK"); + fresh_checkout_install_proof(fx.tmp.path(), proj, &fx.patched, "fresh-repaired"); + + // 6. Idempotency: a re-run exits 0 and leaves bun.lock byte-stable. let (code, stdout, stderr) = run_socket( proj, &[ @@ -431,7 +834,7 @@ fn bun_vendor_fresh_checkout_frozen_install_and_revert() { "re-vendor must leave bun.lock byte-identical" ); - // 6. REVERT PROOF: bun.lock restored byte-for-byte, artifacts gone. + // 7. REVERT PROOF: bun.lock restored byte-for-byte, artifacts gone. let (code, stdout, stderr) = run_socket( proj, &[ @@ -461,12 +864,59 @@ fn bun_vendor_fresh_checkout_frozen_install_and_revert() { "revert must leave package.json byte-identical" ); assert!( - !proj.join(".socket/vendor").exists(), + !proj.join(".socket").join("vendor").exists(), ".socket/vendor must be fully removed after revert" ); eprintln!("REVERT OK"); } +// ── the tampered twin ───────────────────────────────────────────────── + +/// Negative twin: the committed tarball is swapped for a DIFFERENT valid +/// tarball while bun.lock keeps our sha512. From bun 1.3.10 the fresh frozen +/// install must refuse on the integrity check; earlier bun installs the +/// tampered bytes with exit 0 and the leg pins THAT (PARTIAL), so the +/// digest boundary is asserted from both sides across the lock-era legs. +#[test] +#[serial_test::serial] +fn bun_vendor_tampered_tarball_digest_boundary() { + let Some(fx) = bun_project("tampered") else { + return; + }; + let proj = &fx.proj; + + stage_patch(proj, &fx.purl, "package/index.js", &fx.orig, &fx.patched); + let (code, stdout, stderr) = run_socket( + proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert_vendored_on_disk(&fx); + + // Tamper: a valid tarball with different content under the same path. + // The lock still pins the sha512 of OUR tarball. + let tampered: Vec = [TAMPER_MARKER.as_bytes(), fx.orig.as_slice()].concat(); + let tampered_tgz = make_tgz_from_installed(&proj.join("node_modules").join(DEP), &tampered); + let tgz_path = vendored_tgz(proj); + assert_ne!( + std::fs::read(&tgz_path).unwrap(), + tampered_tgz, + "the replacement tarball must differ from the vendored one" + ); + std::fs::write(&tgz_path, &tampered_tgz).unwrap(); + + assert_tamper_outcome(&fx, &tampered); +} + // ── the get-driven twin (v3.6) ──────────────────────────────────────── /// `view/{uuid}` carrying the SAME hashes the stager computes plus base64 @@ -498,11 +948,12 @@ async fn mock_view(server: &MockServer, purl: &str, before: &[u8], after: &[u8]) /// get-driven twin: `get --mode vendored` must land scan's vendored /// result — manifest record + committed artifact + ledger + wired lock, NO -/// blobs — and the fresh-checkout frozen install must materialize the +/// blobs — and the fresh-checkout install proof must materialize the /// patched bytes. The revert half is deliberately not repeated here: /// `vendor --revert` on the capstone above already proves it (same ledger, /// same engine). #[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] async fn bun_get_uuid_vendored_fresh_checkout_frozen_install() { let Some(fx) = bun_project("get-uuid-vendored") else { return; @@ -574,23 +1025,24 @@ async fn bun_get_uuid_vendored_fresh_checkout_frozen_install() { ); // Manifest yes, blobs no (scan-vendored parity: content stays in memory). - let manifest: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(proj.join(".socket/manifest.json")).unwrap()) - .unwrap(); + let manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(proj.join(".socket").join("manifest.json")).unwrap(), + ) + .unwrap(); assert_eq!( manifest["patches"][fx.purl.as_str()]["uuid"], UUID, "the manifest must record the vendored patch: {manifest}" ); assert!( - !proj.join(".socket/blobs").exists(), + !proj.join(".socket").join("blobs").exists(), "get --mode vendored must NOT persist blobs" ); - assert_vendored_on_disk(proj, &fx.pkg_before); + assert_vendored_on_disk(&fx); eprintln!("GET VENDOR OK"); // FRESH-CHECKOUT PROOF: committable files only, EMPTY cache, - // spike-proven `--frozen-lockfile`. - fresh_checkout_frozen_install(fx.tmp.path(), proj, &fx.patched); + // spike-proven `--frozen-lockfile`, then the ordinary-install twin. + fresh_checkout_install_proof(fx.tmp.path(), proj, &fx.patched, "fresh"); } From fad247f302f0972c22214e6df8294abe2d4e5b8c Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 18 Sep 2026 18:24:29 -0400 Subject: [PATCH 23/49] =?UTF-8?q?test(bun):=20real-bun=20scoped=20dependen?= =?UTF-8?q?cy-bearing=20legs=20=E2=80=94=20{dependencies,=20bin}=20meta=20?= =?UTF-8?q?must=20survive=20hosted=20and=20vendored=20rewrites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every real-bun proof so far patched left-pad, whose lock meta is `{}`, so the engines' deps-preserving branches (redirect/mod.rs deps_verbatim, vendor/bun_lock.rs) and scoped `@scope/name` keys were exercised only by parser-level goldens and unit tests. A meta-dropping regression would be SILENT under those legs: bun installs a `{}`-meta tarball tuple with exit 0, patched bytes and a stable lock — and no deps, no bin. Both suites gain a `Target` (LeftPad | ScopedWithDeps) and one leg each for `@scope/pkg@1.0.0`: a scoped package with `dependencies: {left-pad}` and a `bin`, served by a wiremock npm registry through bun's `[install.scopes]` (bunfig.toml — a committable file that travels with the fresh checkouts). Bun records it as `["@scope/pkg@1.0.0", "", { "dependencies": {…}, "bin": {…} }, "sha512-…"]`; the legs assert that exact pre-rewrite spelling, that the rewrite produces the 3-tuple with the meta object byte-identical (hosted URL / local path `.socket/vendor/npm//@scope/pkg-1.0.0.tgz`), that left-pad's own registry entry stays byte-identical, and that the fresh frozen AND plain installs land the patched bytes, install left-pad and link the bin (`node_modules/.bin/scope-pkg*`, Windows shims included). Tarballs built from the installed tree now keep file modes (the bin script stays executable); Windows falls back to 0755 under `bin/`. Verified with SOCKET_PATCH_BUN_E2E_REQUIRED=1 on real bun 1.1.39, 1.1.45, 1.2.23, 1.3.9, 1.3.10, 1.3.13, 1.3.14 and 1.4.2: redirect suite 11/11, vendor suite 9/9 on every version. Co-Authored-By: Claude Fable 5.1 --- .../tests/e2e_redirect_bun_build.rs | 462 ++++++++++-- .../tests/e2e_vendor_bun_build.rs | 679 ++++++++++++------ 2 files changed, 859 insertions(+), 282 deletions(-) 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 a8372d0e..b43ff85c 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs @@ -53,6 +53,17 @@ //! (get routes through scan's `run_redirect_selected`), so the lock/ledger //! assertions and the fresh-checkout proof are shared via [`HostedDriver`]. //! +//! The scoped leg patches a DIFFERENT target: `@scope/pkg@1.0.0`, a scoped +//! package with `dependencies` and a `bin`, served by a wiremock npm +//! registry through bun's `[install.scopes]` (a private scoped registry — +//! the common real-world shape). Bun records it as +//! `["@scope/pkg@1.0.0", "", { "dependencies": {…}, "bin": {…} +//! }, "sha512-…"]`; the rewrite must carry that meta object VERBATIM into +//! the URL 3-tuple, and the fresh install must prove bun honored it: the +//! dependency installs and the bin is linked. A meta-dropping regression is +//! silent under every left-pad leg (bun installs a `{}`-meta tuple with +//! exit 0, patched bytes and a stable lock — and no deps, no bin). +//! //! The lock-v1-on-newer-bun leg is the one cross-version install proof a //! single binary can give: on bun ≥ 1.4 (native lockfileVersion 2) the //! fixture lock is relabelled to `"lockfileVersion": 1` before the rewrite @@ -93,7 +104,6 @@ mod cache_env; const ORG: &str = "test-org"; const DEP: &str = "left-pad"; const DEP_VERSION: &str = "1.3.0"; -const PURL: &str = "pkg:npm/left-pad@1.3.0"; const UUID: &str = "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d"; const TOKEN: &str = "22222222-2222-4222-8222-222222222222"; const MARKER: &str = "/* SOCKET-PATCHED */\n"; @@ -104,6 +114,20 @@ const TAMPER_MARKER: &str = "/* SOCKET-TAMPERED */\n"; const GHSA: &str = "GHSA-redirect-bun-real"; const PRODUCT: &str = "pkg:npm/app@1.0.0"; +/// The scoped, dependency-bearing target of the meta-preserving leg. It +/// exists only in the wiremock registry this suite runs; bun fetches it +/// through `[install.scopes]` and left-pad (its one dependency) from the +/// real registry like every other fixture. +const SCOPED_NAME: &str = "@scope/pkg"; +const SCOPED_VERSION: &str = "1.0.0"; +const SCOPED_BIN: &str = "scope-pkg"; +const SCOPED_INDEX: &[u8] = b"module.exports = require('left-pad');\n"; +/// The meta object bun writes for it, byte-exact (bun serializes +/// `dependencies` before `bin`; identical on lockfileVersion 0, 1 and 2). +/// The rewrite must carry this into the 3-tuple verbatim. +const SCOPED_META: &str = + r#"{ "dependencies": { "left-pad": "1.3.0" }, "bin": { "scope-pkg": "bin/cli.js" } }"#; + /// `(major, minor, patch)` of the bun on PATH. type BunVersion = (u64, u64, u64); @@ -286,6 +310,10 @@ fn sha512_sri_b64(bytes: &[u8]) -> String { base64::engine::general_purpose::STANDARD.encode(Sha512::digest(bytes)) } +fn sri(bytes: &[u8]) -> String { + format!("sha512-{}", sha512_sri_b64(bytes)) +} + fn copy_dir_recursive(src: &Path, dst: &Path) { std::fs::create_dir_all(dst).unwrap(); for entry in std::fs::read_dir(src).unwrap() { @@ -299,11 +327,30 @@ fn copy_dir_recursive(src: &Path, dst: &Path) { } } +/// A gzipped npm tarball from `(entry name under package/, bytes, mode)` +/// triples, built with the tar crate so the suite has no system-`tar` +/// dependency (Windows runners included). +fn build_tgz(entries: &[(String, Vec, u32)]) -> Vec { + let mut builder = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::default(), + )); + for (name, bytes, mode) in entries { + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(*mode); + header.set_cksum(); + builder + .append_data(&mut header, format!("package/{name}"), bytes.as_slice()) + .unwrap(); + } + builder.into_inner().unwrap().finish().unwrap() +} + /// A VALID npm tarball built from the ACTUALLY-installed package with the -/// entry point swapped for `replaced_index`. Built with the tar crate -/// rather than a system `tar` so the suite has no external-binary -/// dependency (bun installs from tar-crate output fine, as pnpm does in -/// `e2e_redirect_pnpm_build.rs`). +/// entry point swapped for `replaced_index`. File modes travel as installed +/// (the scoped target's `bin/cli.js` keeps its exec bit); on Windows, where +/// there is no mode, `bin/` entries are marked executable. fn make_tgz_from_installed(pkg_dir: &Path, replaced_index: &[u8]) -> Vec { let pkg_dir = pkg_dir .canonicalize() @@ -321,39 +368,161 @@ fn make_tgz_from_installed(pkg_dir: &Path, replaced_index: &[u8]) -> Vec { } } files.sort(); - let mut builder = tar::Builder::new(flate2::write::GzEncoder::new( - Vec::new(), - flate2::Compression::default(), - )); - for p in &files { - let rel = p.strip_prefix(&pkg_dir).unwrap(); - // Tar entry names always use `/` regardless of host separator. - let name = format!( - "package/{}", - rel.components() + let entries: Vec<(String, Vec, u32)> = files + .iter() + .map(|p| { + let rel = p.strip_prefix(&pkg_dir).unwrap(); + // Tar entry names always use `/` regardless of host separator. + let name = rel + .components() .map(|c| c.as_os_str().to_string_lossy().into_owned()) .collect::>() - .join("/") - ); - let bytes = if rel == Path::new("index.js") { - replaced_index.to_vec() - } else { - std::fs::read(p).unwrap() - }; - let mut header = tar::Header::new_gnu(); - header.set_size(bytes.len() as u64); - header.set_mode(0o644); - header.set_cksum(); - builder - .append_data(&mut header, &name, bytes.as_slice()) - .unwrap(); + .join("/"); + let bytes = if rel == Path::new("index.js") { + replaced_index.to_vec() + } else { + std::fs::read(p).unwrap() + }; + (name.clone(), bytes, file_mode(p, &name)) + }) + .collect(); + build_tgz(&entries) +} + +#[cfg(unix)] +fn file_mode(p: &Path, _name: &str) -> u32 { + use std::os::unix::fs::PermissionsExt as _; + std::fs::metadata(p).unwrap().permissions().mode() & 0o777 +} + +#[cfg(not(unix))] +fn file_mode(_p: &Path, name: &str) -> u32 { + if name.starts_with("bin/") { + 0o755 + } else { + 0o644 + } +} + +/// The scoped target's registry tarball: package.json with the dependency +/// and the bin, the entry point, and the (executable) bin script. +fn scoped_registry_tgz() -> Vec { + let pkg_json = serde_json::json!({ + "name": SCOPED_NAME, + "version": SCOPED_VERSION, + "main": "index.js", + "dependencies": { DEP: DEP_VERSION }, + "bin": { SCOPED_BIN: "bin/cli.js" }, + }); + build_tgz(&[ + ( + "package.json".into(), + serde_json::to_vec_pretty(&pkg_json).unwrap(), + 0o644, + ), + ("index.js".into(), SCOPED_INDEX.to_vec(), 0o644), + ( + "bin/cli.js".into(), + b"#!/usr/bin/env node\nconsole.log('scope-pkg cli');\n".to_vec(), + 0o755, + ), + ]) +} + +/// A wiremock npm registry for the scoped target: the packument (bun asks +/// for `/@scope%2fpkg`) and the tarball it points at. Integrity only — bun +/// verifies the sha512 and needs no `shasum`. +async fn mount_scoped_registry(server: &MockServer, tgz: Vec) { + let tarball_url = format!("{}/@scope/pkg/-/pkg-{SCOPED_VERSION}.tgz", server.uri()); + Mock::given(method("GET")) + .and(path_regex(r"^/@scope(%2[fF]|/)pkg$")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "name": SCOPED_NAME, + "dist-tags": { "latest": SCOPED_VERSION }, + "versions": { + SCOPED_VERSION: { + "name": SCOPED_NAME, + "version": SCOPED_VERSION, + "dependencies": { DEP: DEP_VERSION }, + "bin": { SCOPED_BIN: "bin/cli.js" }, + "dist": { "tarball": tarball_url, "integrity": sri(&tgz) } + } + } + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("/@scope/pkg/-/pkg-{SCOPED_VERSION}.tgz"))) + .respond_with(ResponseTemplate::new(200).set_body_raw(tgz, "application/octet-stream")) + .mount(server) + .await; +} + +/// Which package the hosted rewrite targets. +#[derive(Clone, Copy, PartialEq, Debug)] +enum Target { + /// left-pad@1.3.0 from the real registry: unscoped, `{}` meta — the + /// original capstone target. + LeftPad, + /// `@scope/pkg@1.0.0` from the suite's wiremock registry via + /// `[install.scopes]`: scoped key, non-empty `{dependencies, bin}` meta. + ScopedWithDeps, +} + +impl Target { + fn name(self) -> &'static str { + match self { + Target::LeftPad => DEP, + Target::ScopedWithDeps => SCOPED_NAME, + } + } + fn version(self) -> &'static str { + match self { + Target::LeftPad => DEP_VERSION, + Target::ScopedWithDeps => SCOPED_VERSION, + } + } + /// The PURL the CLI derives for it (`@` is percent-encoded in npm PURLs). + fn purl(self) -> &'static str { + match self { + Target::LeftPad => "pkg:npm/left-pad@1.3.0", + Target::ScopedWithDeps => "pkg:npm/%40scope/pkg@1.0.0", + } + } + /// The tarball file name on the hosted URL. + fn hosted_leaf(self) -> String { + match self { + Target::LeftPad => format!("{DEP}-{DEP_VERSION}.tgz"), + Target::ScopedWithDeps => format!("pkg-{SCOPED_VERSION}.tgz"), + } + } + /// The meta object bun writes for it, byte-exact. + fn meta(self) -> &'static str { + match self { + Target::LeftPad => "{}", + Target::ScopedWithDeps => SCOPED_META, + } + } + fn installed_dir(self, root: &Path) -> PathBuf { + let nm = root.join("node_modules"); + match self { + Target::LeftPad => nm.join(DEP), + Target::ScopedWithDeps => nm.join("@scope").join("pkg"), + } + } + fn package_json(self) -> String { + format!( + r#"{{"name":"redirect-bun-capstone","version":"0.0.0","private":true,"dependencies":{{"{}":"{}"}}}}"#, + self.name(), + self.version() + ) } - builder.into_inner().unwrap().finish().unwrap() } struct BunRedirectFixture { tmp: tempfile::TempDir, proj: PathBuf, + target: Target, /// The pristine installed `index.js`. orig: Vec, /// `MARKER` + orig — what the honest hosted tarball carries. @@ -401,6 +570,15 @@ enum LockShape { V1OnNewerBun, } +/// The `packages` line for `name` in a bun.lock (`"name": [...]`). +fn packages_line(lock: &str, name: &str) -> String { + let key = format!("\"{name}\": ["); + lock.lines() + .find(|l| l.trim_start().starts_with(&key)) + .unwrap_or_else(|| panic!("no packages entry for {name} in:\n{lock}")) + .to_string() +} + /// Steps 1–3: real install, patched tarball + API mocks, the `driver`'s /// hosted invocation, and the envelope/lockfile/ledger assertions. /// `tamper_served_tarball` serves DIFFERENT bytes than the sha512 pinned @@ -410,6 +588,7 @@ async fn bun_hosted_project( tamper_served_tarball: bool, driver: HostedDriver, shape: LockShape, + target: Target, ) -> Option { let (bun_raw, bun_version) = bun_toolchain(tag)?; if shape == LockShape::V1OnNewerBun && bun_version < LOCK_V2_FROM { @@ -425,13 +604,29 @@ async fn bun_hosted_project( let tmp = tempfile::tempdir().unwrap(); let proj = tmp.path().join("proj"); std::fs::create_dir_all(&proj).unwrap(); - std::fs::write( - proj.join("package.json"), - format!( - r#"{{"name":"redirect-bun-capstone","version":"0.0.0","private":true,"dependencies":{{"{DEP}":"{DEP_VERSION}"}}}}"# - ), - ) - .unwrap(); + std::fs::write(proj.join("package.json"), target.package_json()).unwrap(); + + // One wiremock serves everything: the scoped target's npm registry (up + // before the fixture install), the patch API, and the hosted tarball. + let server = MockServer::start().await; + let mut registry_field = String::new(); + if target == Target::ScopedWithDeps { + let registry_tgz = scoped_registry_tgz(); + mount_scoped_registry(&server, registry_tgz).await; + // bun's scoped-registry config — a committable file, so it travels + // with every fresh checkout below. + std::fs::write( + proj.join("bunfig.toml"), + format!( + "[install.scopes]\n\"@scope\" = {{ url = \"{}/\" }}\n", + server.uri() + ), + ) + .unwrap(); + // For a non-default registry bun records the TARBALL URL as the + // 4-tuple's registry field. + registry_field = format!("{}/@scope/pkg/-/pkg-{SCOPED_VERSION}.tgz", server.uri()); + } // 1. REAL fixture: bun install (network here, private cache). Text lockfile. let cache = tmp.path().join("bun-cache"); @@ -484,9 +679,18 @@ async fn bun_hosted_project( (1.1.39–1.1.x → 0, 1.2–1.3 → 1, ≥ 1.4 → 2):\n{native_lock}", expected_lock_version(bun_version) ); + // Pre-redirect: the registry 4-tuple, with bun's real registry field + // and meta object for this target — one spelling across 0/1/2. + let registry_tuple_head = format!( + "\"{}@{}\", \"{registry_field}\", {}, \"sha512-", + target.name(), + target.version(), + target.meta() + ); assert!( - native_lock.contains(&format!("\"{DEP}@{DEP_VERSION}\", \"\"")), - "pre-redirect packages entry must be the registry 4-tuple:\n{native_lock}" + native_lock.contains(®istry_tuple_head), + "pre-redirect packages entry must be the registry 4-tuple {registry_tuple_head}…:\n\ + {native_lock}" ); let lock_version = match shape { LockShape::Native => native_version, @@ -513,13 +717,20 @@ async fn bun_hosted_project( } }; let lock_before = std::fs::read(&lock_path).unwrap(); + let lock_before_str = String::from_utf8(lock_before.clone()).unwrap(); - let installed_dir = proj.join("node_modules").join(DEP); + let installed_dir = target.installed_dir(&proj); let orig = std::fs::read(installed_dir.join("index.js")).expect("installed index.js"); assert!( !orig.starts_with(MARKER.as_bytes()), "pristine install must not carry the marker" ); + if target == Target::ScopedWithDeps { + assert_eq!( + orig, SCOPED_INDEX, + "bun must have installed the mock registry's bytes" + ); + } let patched: Vec = [MARKER.as_bytes(), orig.as_slice()].concat(); let tampered: Vec = [TAMPER_MARKER.as_bytes(), orig.as_slice()].concat(); @@ -527,7 +738,7 @@ async fn bun_hosted_project( // The negative twin only tampers what the route SERVES (a different, // still-valid tarball), so the pin is what catches the swap. let tgz = make_tgz_from_installed(&installed_dir, &patched); - let sri = format!("sha512-{}", sha512_sri_b64(&tgz)); + let patched_sri = sri(&tgz); let served: Vec = if tamper_served_tarball { let tampered_tgz = make_tgz_from_installed(&installed_dir, &tampered); assert_ne!(tampered_tgz, tgz, "the tampered tarball must differ"); @@ -537,18 +748,21 @@ async fn bun_hosted_project( }; // 3. API mocks + the hosted tarball route bun will hit at install time. - let server = MockServer::start().await; - let hosted_url = format!( - "{}/patch/npm/{DEP}/{DEP_VERSION}/{TOKEN}/{UUID}/{DEP}-{DEP_VERSION}.tgz", - server.uri() + let purl = target.purl(); + let hosted_path = format!( + "/patch/npm/{}/{}/{TOKEN}/{UUID}/{}", + target.name(), + target.version(), + target.hosted_leaf() ); + let hosted_url = format!("{}{hosted_path}", server.uri()); Mock::given(method("POST")) .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "packages": [{ - "purl": PURL, + "purl": purl, "patches": [{ - "uuid": UUID, "purl": PURL, "tier": "free", + "uuid": UUID, "purl": purl, "tier": "free", "cveIds": [], "ghsaIds": [], "severity": "high", "title": "redirect bun capstone fixture" }] @@ -563,7 +777,7 @@ async fn bun_hosted_project( ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [{ - "uuid": UUID, "purl": PURL, + "uuid": UUID, "purl": purl, "publishedAt": "2026-01-01T00:00:00Z", "description": "x", "license": "MIT", "tier": "free", "vulnerabilities": {} @@ -579,11 +793,11 @@ async fn bun_hosted_project( UUID: { "status": "granted", "url": hosted_url, - "purl": PURL, + "purl": purl, "artifacts": [{ "kind": "tarball", "url": hosted_url, - "integrity": { "sha512": sri } + "integrity": { "sha512": patched_sri } }], "registryOverride": null } @@ -595,7 +809,7 @@ async fn bun_hosted_project( .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "uuid": UUID, - "purl": PURL, + "purl": purl, "publishedAt": "2026-01-01T00:00:00Z", "files": { "package/index.js": { @@ -614,9 +828,7 @@ async fn bun_hosted_project( .mount(&server) .await; Mock::given(method("GET")) - .and(path(format!( - "/patch/npm/{DEP}/{DEP_VERSION}/{TOKEN}/{UUID}/{DEP}-{DEP_VERSION}.tgz" - ))) + .and(path(hosted_path.clone())) .respond_with(ResponseTemplate::new(200).set_body_raw(served, "application/octet-stream")) .mount(&server) .await; @@ -705,7 +917,7 @@ async fn bun_hosted_project( ); assert_eq!(stmts[0]["status"], "not_affected", "vex doc: {vex_doc}"); assert_eq!( - stmts[0]["products"][0]["subcomponents"][0]["@id"], PURL, + stmts[0]["products"][0]["subcomponents"][0]["@id"], purl, "vex doc: {vex_doc}" ); assert_eq!( @@ -748,19 +960,21 @@ async fn bun_hosted_project( } } - // Lockfile pin: the hosted URL (as the tuple spec) + the patched sha512, - // the registry 4-tuple gone, and the lock's own version line kept. + // Lockfile pin: the URL 3-tuple — hosted URL as the tuple spec, the + // meta object carried VERBATIM, the patched sha512 — with the registry + // 4-tuple gone and the lock's own version line kept. let lock = std::fs::read_to_string(&lock_path).unwrap(); - assert!( - lock.contains(&format!("\"{DEP}@{hosted_url}\"")), - "bun.lock tuple spec must be name@; got:\n{lock}" + let url_tuple = format!( + "\"{}@{hosted_url}\", {}, \"{patched_sri}\"]", + target.name(), + target.meta() ); assert!( - lock.contains(&sri), - "bun.lock integrity must be the patched sha512 ({sri}); got:\n{lock}" + lock.contains(&url_tuple), + "bun.lock packages entry must be the URL 3-tuple {url_tuple}; got:\n{lock}" ); assert!( - !lock.contains(&format!("\"{DEP}@{DEP_VERSION}\", \"\"")), + !lock.contains(®istry_tuple_head), "the registry 4-tuple must be gone after the rewrite:\n{lock}" ); assert_eq!( @@ -768,6 +982,14 @@ async fn bun_hosted_project( Some(lock_version), "the rewrite must preserve the lockfileVersion line verbatim; got:\n{lock}" ); + if target == Target::ScopedWithDeps { + // The dependency's own registry entry is not the target: untouched. + assert_eq!( + packages_line(&lock, DEP), + packages_line(&lock_before_str, DEP), + "the un-patched dependency's registry 4-tuple must be byte-identical:\n{lock}" + ); + } let ledger = std::fs::read_to_string(redirect_ledger(&proj)).unwrap(); assert!( @@ -778,6 +1000,7 @@ async fn bun_hosted_project( Some(BunRedirectFixture { tmp, proj, + target, orig, patched, tampered, @@ -796,12 +1019,16 @@ fn redirect_ledger(proj: &Path) -> PathBuf { } /// Fresh dir `/` with only the committable files (package.json, -/// bun.lock, and `.socket/` when it exists — rollback removes it). +/// bun.lock, bunfig.toml when the project has one, and `.socket/` when it +/// exists — rollback removes it). fn fresh_checkout(fx: &BunRedirectFixture, name: &str) -> PathBuf { let fresh = fx.tmp.path().join(name); std::fs::create_dir_all(&fresh).unwrap(); std::fs::copy(fx.proj.join("package.json"), fresh.join("package.json")).unwrap(); std::fs::copy(fx.proj.join("bun.lock"), fresh.join("bun.lock")).unwrap(); + if fx.proj.join("bunfig.toml").is_file() { + std::fs::copy(fx.proj.join("bunfig.toml"), fresh.join("bunfig.toml")).unwrap(); + } if fx.proj.join(".socket").is_dir() { copy_dir_recursive(&fx.proj.join(".socket"), &fresh.join(".socket")); } @@ -821,6 +1048,33 @@ fn fresh_frozen_install(fx: &BunRedirectFixture, name: &str) -> (PathBuf, Output (fresh, ci) } +/// For the scoped target: bun must have honored the meta object it read +/// from the URL 3-tuple — the dependency is installed and the bin linked +/// (as `node_modules/.bin/scope-pkg`, or its `.exe`/`.cmd` shims on +/// Windows). Neither happens when the meta is `{}`. +fn assert_scoped_meta_honored(fresh: &Path) { + assert!( + fresh + .join("node_modules") + .join(DEP) + .join("package.json") + .is_file(), + "bun must install the scoped package's `dependencies` from the 3-tuple meta" + ); + let bin_dir = fresh.join("node_modules").join(".bin"); + let linked = std::fs::read_dir(&bin_dir) + .map(|rd| { + rd.filter_map(|e| e.ok()) + .any(|e| e.file_name().to_string_lossy().starts_with(SCOPED_BIN)) + }) + .unwrap_or(false); + assert!( + linked, + "bun must link the scoped package's `bin` from the 3-tuple meta under {}", + bin_dir.display() + ); +} + /// Shared fresh-checkout proof: `bun install --frozen-lockfile` against an /// empty cache must materialize the PATCHED bytes from the hosted tarball; /// then an ORDINARY `bun install` (node_modules removed, another empty @@ -836,7 +1090,7 @@ fn assert_patched_fresh_install(fx: &BunRedirectFixture) { String::from_utf8_lossy(&ci.stdout), String::from_utf8_lossy(&ci.stderr), ); - let installed_index = fresh.join("node_modules").join(DEP).join("index.js"); + let installed_index = fx.target.installed_dir(&fresh).join("index.js"); let installed = std::fs::read(&installed_index).unwrap(); assert!( installed.starts_with(MARKER.as_bytes()), @@ -847,9 +1101,12 @@ fn assert_patched_fresh_install(fx: &BunRedirectFixture) { installed, fx.patched, "fresh install must be byte-identical to the patched content" ); + if fx.target == Target::ScopedWithDeps { + assert_scoped_meta_honored(&fresh); + } eprintln!( - "FRESH INSTALL OK (bun {}, lockfileVersion {})", - fx.bun_raw, fx.lock_version + "FRESH INSTALL OK (bun {}, lockfileVersion {}, {:?})", + fx.bun_raw, fx.lock_version, fx.target ); // Ordinary install: the lock must survive bun's own re-serialization. @@ -874,6 +1131,9 @@ fn assert_patched_fresh_install(fx: &BunRedirectFixture) { fx.patched, "the ordinary install must land the patched bytes too" ); + if fx.target == Target::ScopedWithDeps { + assert_scoped_meta_honored(&fresh); + } eprintln!("PLAIN INSTALL LOCK-STABLE"); } @@ -885,8 +1145,14 @@ fn assert_patched_fresh_install(fx: &BunRedirectFixture) { #[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, HostedDriver::ScanVex, LockShape::Native).await + let Some(fx) = bun_hosted_project( + "main", + false, + HostedDriver::ScanVex, + LockShape::Native, + Target::LeftPad, + ) + .await else { return; }; @@ -901,8 +1167,36 @@ async fn bun_redirect_fresh_checkout_installs_patched_bytes() { #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn bun_get_uuid_hosted_fresh_checkout_installs() { - let Some(fx) = - bun_hosted_project("get-uuid", false, HostedDriver::GetUuid, LockShape::Native).await + let Some(fx) = bun_hosted_project( + "get-uuid", + false, + HostedDriver::GetUuid, + LockShape::Native, + Target::LeftPad, + ) + .await + else { + return; + }; + assert_patched_fresh_install(&fx); +} + +/// Scoped, dependency-bearing target: the rewrite must carry bun's +/// `{ "dependencies": …, "bin": … }` meta object verbatim into the URL +/// 3-tuple and leave the dependency's own registry entry alone; the fresh +/// install must prove bun honored that meta — left-pad installed, the bin +/// linked — on top of the patched bytes and the stable lock. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn bun_redirect_scoped_package_keeps_deps_and_bin_meta() { + let Some(fx) = bun_hosted_project( + "scoped-with-deps", + false, + HostedDriver::ScanVex, + LockShape::Native, + Target::ScopedWithDeps, + ) + .await else { return; }; @@ -923,6 +1217,7 @@ async fn bun_redirect_lock_v1_on_newer_bun_fresh_checkout_installs_patched_bytes false, HostedDriver::ScanVex, LockShape::V1OnNewerBun, + Target::LeftPad, ) .await else { @@ -953,8 +1248,14 @@ async fn bun_redirect_lock_v1_on_newer_bun_fresh_checkout_installs_patched_bytes #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn bun_redirect_tampered_hosted_tarball_digest_boundary() { - let Some(fx) = - bun_hosted_project("tampered", true, HostedDriver::ScanVex, LockShape::Native).await + let Some(fx) = bun_hosted_project( + "tampered", + true, + HostedDriver::ScanVex, + LockShape::Native, + Target::LeftPad, + ) + .await else { return; }; @@ -988,8 +1289,7 @@ async fn bun_redirect_tampered_hosted_tarball_digest_boundary() { must still exit 0 — a failure here means the boundary moved.\n{chatter}", fx.bun_raw ); - let installed = - std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); + let installed = std::fs::read(fx.target.installed_dir(&fresh).join("index.js")).unwrap(); assert_eq!( installed, fx.tampered, "bun {} installed neither the tampered bytes nor failed: the boundary model is wrong", @@ -1011,8 +1311,14 @@ async fn bun_redirect_tampered_hosted_tarball_digest_boundary() { #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn bun_redirect_rollback_restores_lock_and_original_install() { - let Some(fx) = - bun_hosted_project("rollback", false, HostedDriver::ScanVex, LockShape::Native).await + let Some(fx) = bun_hosted_project( + "rollback", + false, + HostedDriver::ScanVex, + LockShape::Native, + Target::LeftPad, + ) + .await else { return; }; @@ -1061,7 +1367,7 @@ async fn bun_redirect_rollback_restores_lock_and_original_install() { String::from_utf8_lossy(&ci.stdout), String::from_utf8_lossy(&ci.stderr), ); - let installed = std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); + let installed = std::fs::read(fx.target.installed_dir(&fresh).join("index.js")).unwrap(); assert!( !installed.starts_with(MARKER.as_bytes()), "after rollback bun must install the ORIGINAL bytes, not the patch" 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 cdc90efc..f0d1ae69 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs @@ -6,9 +6,10 @@ //! `BUN_INSTALL_CACHE_DIR`). The text `bun.lock` is the default from //! bun 1.2.0 (lockfileVersion 1; 2 from 1.4.0); on 1.1.39–1.1.x it is //! the `--save-text-lockfile` opt-in (lockfileVersion 0), which the -//! fixture passes for those releases. Bun before 1.1.39 has no text -//! lockfile at all and the suite skips (or, under the REQUIRED gate, -//! fails — such a leg must not be scheduled). +//! fixture passes for those releases, and the fixture ASSERTS the +//! version it got matches that era table. Bun before 1.1.39 has no +//! text lockfile at all and the suite skips (or, under the REQUIRED +//! gate, fails — such a leg must not be scheduled). //! 2. Hand-stage a `.socket/` manifest + blob from the ACTUAL installed //! bytes (a marker comment prepended to `index.js`). //! 3. `socket-patch vendor --json --offline` — assert the deterministic @@ -44,6 +45,19 @@ //! The revert half is not repeated there: `vendor --revert` on the //! capstone already covers it (same ledger, same engine). //! +//! The scoped leg vendors a DIFFERENT target: `@scope/pkg@1.0.0`, a scoped +//! package with `dependencies` and a `bin`, served by a wiremock npm +//! registry through bun's `[install.scopes]` (a private scoped registry — +//! the common real-world shape). Bun records it as +//! `["@scope/pkg@1.0.0", "", { "dependencies": {…}, "bin": {…} +//! }, "sha512-…"]`; the rewrite must carry that meta object VERBATIM into +//! the local-tarball 3-tuple (whose path keeps the scope dir: +//! `.socket/vendor/npm//@scope/pkg-1.0.0.tgz`), and the fresh install +//! must prove bun honored it: the dependency installs and the bin is +//! linked. A meta-dropping regression is silent under every left-pad leg +//! (bun installs a `{}`-meta tuple with exit 0, patched bytes and a stable +//! lock — and no deps, no bin). +//! //! The tampered twin swaps the committed tarball for a DIFFERENT valid //! tarball while bun.lock keeps our sha512: bun verifies the digest of //! local-tarball tuples only from 1.3.10 (`Integrity check failed`), so the @@ -63,8 +77,8 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; -use sha2::{Digest, Sha256}; -use wiremock::matchers::{method, path}; +use sha2::{Digest, Sha256, Sha512}; +use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; #[path = "common/cache_env.rs"] @@ -80,6 +94,20 @@ const DEP: &str = "left-pad"; const DEP_VERSION: &str = "1.3.0"; const ORG: &str = "test-org"; +/// The scoped, dependency-bearing target of the meta-preserving leg. It +/// exists only in the wiremock registry this suite runs; bun fetches it +/// through `[install.scopes]` and left-pad (its one dependency) from the +/// real registry like every other fixture. +const SCOPED_NAME: &str = "@scope/pkg"; +const SCOPED_VERSION: &str = "1.0.0"; +const SCOPED_BIN: &str = "scope-pkg"; +const SCOPED_INDEX: &[u8] = b"module.exports = require('left-pad');\n"; +/// The meta object bun writes for it, byte-exact (bun serializes +/// `dependencies` before `bin`; identical on lockfileVersion 0, 1 and 2). +/// The rewrite must carry this into the 3-tuple verbatim. +const SCOPED_META: &str = + r#"{ "dependencies": { "left-pad": "1.3.0" }, "bin": { "scope-pkg": "bin/cli.js" } }"#; + /// `(major, minor, patch)` of the bun on PATH. type BunVersion = (u64, u64, u64); @@ -277,29 +305,8 @@ fn b64(bytes: &[u8]) -> String { base64::engine::general_purpose::STANDARD.encode(bytes) } -fn stage_patch(proj: &Path, purl: &str, file_key: &str, before: &[u8], after: &[u8]) { - let socket = proj.join(".socket"); - std::fs::create_dir_all(socket.join("blobs")).unwrap(); - let manifest = serde_json::json!({ - "patches": { purl: { - "uuid": UUID, - "exportedAt": "2026-01-01T00:00:00Z", - "files": { file_key: { - "beforeHash": git_sha256(before), - "afterHash": git_sha256(after), - }}, - "vulnerabilities": {}, - "description": "capstone marker patch", - "license": "MIT", - "tier": "free", - }} - }); - std::fs::write( - socket.join("manifest.json"), - serde_json::to_string_pretty(&manifest).unwrap(), - ) - .unwrap(); - std::fs::write(socket.join("blobs").join(git_sha256(after)), after).unwrap(); +fn sri(bytes: &[u8]) -> String { + format!("sha512-{}", b64(&Sha512::digest(bytes))) } fn parse_envelope(stdout: &str) -> serde_json::Value { @@ -320,10 +327,29 @@ fn copy_dir_recursive(src: &Path, dst: &Path) { } } +/// A gzipped npm tarball from `(entry name under package/, bytes, mode)` +/// triples, built with the tar crate so the suite has no system-`tar` +/// dependency (Windows runners included). +fn build_tgz(entries: &[(String, Vec, u32)]) -> Vec { + let mut builder = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::default(), + )); + for (name, bytes, mode) in entries { + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(*mode); + header.set_cksum(); + builder + .append_data(&mut header, format!("package/{name}"), bytes.as_slice()) + .unwrap(); + } + builder.into_inner().unwrap().finish().unwrap() +} + /// A VALID npm tarball built from the ACTUALLY-installed package with the -/// entry point swapped — the tampered twin's replacement artifact. Built -/// with the tar crate (no system `tar`, so Windows runners need nothing); -/// the point is a sha512 that differs from the one bun.lock pins while the +/// entry point swapped — the tampered twin's replacement artifact. The +/// point is a sha512 that differs from the one bun.lock pins while the /// archive still extracts, so "bun installed the tampered bytes" can be /// asserted on the pre-1.3.10 releases that never check the digest. fn make_tgz_from_installed(pkg_dir: &Path, replaced_index: &[u8]) -> Vec { @@ -343,34 +369,175 @@ fn make_tgz_from_installed(pkg_dir: &Path, replaced_index: &[u8]) -> Vec { } } files.sort(); - let mut builder = tar::Builder::new(flate2::write::GzEncoder::new( - Vec::new(), - flate2::Compression::default(), - )); - for p in &files { - let rel = p.strip_prefix(&pkg_dir).unwrap(); - // Tar entry names always use `/` regardless of host separator. - let name = format!( - "package/{}", - rel.components() + let entries: Vec<(String, Vec, u32)> = files + .iter() + .map(|p| { + let rel = p.strip_prefix(&pkg_dir).unwrap(); + // Tar entry names always use `/` regardless of host separator. + let name = rel + .components() .map(|c| c.as_os_str().to_string_lossy().into_owned()) .collect::>() - .join("/") - ); - let bytes = if rel == Path::new("index.js") { - replaced_index.to_vec() - } else { - std::fs::read(p).unwrap() - }; - let mut header = tar::Header::new_gnu(); - header.set_size(bytes.len() as u64); - header.set_mode(0o644); - header.set_cksum(); - builder - .append_data(&mut header, &name, bytes.as_slice()) - .unwrap(); + .join("/"); + let bytes = if rel == Path::new("index.js") { + replaced_index.to_vec() + } else { + std::fs::read(p).unwrap() + }; + (name.clone(), bytes, file_mode(p, &name)) + }) + .collect(); + build_tgz(&entries) +} + +#[cfg(unix)] +fn file_mode(p: &Path, _name: &str) -> u32 { + use std::os::unix::fs::PermissionsExt as _; + std::fs::metadata(p).unwrap().permissions().mode() & 0o777 +} + +#[cfg(not(unix))] +fn file_mode(_p: &Path, name: &str) -> u32 { + if name.starts_with("bin/") { + 0o755 + } else { + 0o644 } - builder.into_inner().unwrap().finish().unwrap() +} + +/// The scoped target's registry tarball: package.json with the dependency +/// and the bin, the entry point, and the (executable) bin script. +fn scoped_registry_tgz() -> Vec { + let pkg_json = serde_json::json!({ + "name": SCOPED_NAME, + "version": SCOPED_VERSION, + "main": "index.js", + "dependencies": { DEP: DEP_VERSION }, + "bin": { SCOPED_BIN: "bin/cli.js" }, + }); + build_tgz(&[ + ( + "package.json".into(), + serde_json::to_vec_pretty(&pkg_json).unwrap(), + 0o644, + ), + ("index.js".into(), SCOPED_INDEX.to_vec(), 0o644), + ( + "bin/cli.js".into(), + b"#!/usr/bin/env node\nconsole.log('scope-pkg cli');\n".to_vec(), + 0o755, + ), + ]) +} + +/// A wiremock npm registry for the scoped target: the packument (bun asks +/// for `/@scope%2fpkg`) and the tarball it points at. Integrity only — bun +/// verifies the sha512 and needs no `shasum`. +async fn mount_scoped_registry(server: &MockServer, tgz: Vec) { + let tarball_url = format!("{}/@scope/pkg/-/pkg-{SCOPED_VERSION}.tgz", server.uri()); + Mock::given(method("GET")) + .and(path_regex(r"^/@scope(%2[fF]|/)pkg$")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "name": SCOPED_NAME, + "dist-tags": { "latest": SCOPED_VERSION }, + "versions": { + SCOPED_VERSION: { + "name": SCOPED_NAME, + "version": SCOPED_VERSION, + "dependencies": { DEP: DEP_VERSION }, + "bin": { SCOPED_BIN: "bin/cli.js" }, + "dist": { "tarball": tarball_url, "integrity": sri(&tgz) } + } + } + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("/@scope/pkg/-/pkg-{SCOPED_VERSION}.tgz"))) + .respond_with(ResponseTemplate::new(200).set_body_raw(tgz, "application/octet-stream")) + .mount(server) + .await; +} + +/// Which package the vendored rewrite targets. +#[derive(Clone, Copy, PartialEq, Debug)] +enum Target { + /// left-pad@1.3.0 from the real registry: unscoped, `{}` meta — the + /// original capstone target. + LeftPad, + /// `@scope/pkg@1.0.0` from the suite's wiremock registry via + /// `[install.scopes]`: scoped key, non-empty `{dependencies, bin}` meta. + ScopedWithDeps, +} + +impl Target { + fn name(self) -> &'static str { + match self { + Target::LeftPad => DEP, + Target::ScopedWithDeps => SCOPED_NAME, + } + } + fn version(self) -> &'static str { + match self { + Target::LeftPad => DEP_VERSION, + Target::ScopedWithDeps => SCOPED_VERSION, + } + } + /// The PURL the CLI derives for it (`@` is percent-encoded in npm PURLs). + fn purl(self) -> &'static str { + match self { + Target::LeftPad => "pkg:npm/left-pad@1.3.0", + Target::ScopedWithDeps => "pkg:npm/%40scope/pkg@1.0.0", + } + } + /// The meta object bun writes for it, byte-exact. + fn meta(self) -> &'static str { + match self { + Target::LeftPad => "{}", + Target::ScopedWithDeps => SCOPED_META, + } + } + fn installed_dir(self, root: &Path) -> PathBuf { + let nm = root.join("node_modules"); + match self { + Target::LeftPad => nm.join(DEP), + Target::ScopedWithDeps => nm.join("@scope").join("pkg"), + } + } + /// The vendored tarball's path under `.socket/vendor/npm//` — the + /// scope dir is kept as a directory level. + fn vendored_tgz_rel(self) -> String { + match self { + Target::LeftPad => format!("{DEP}-{DEP_VERSION}.tgz"), + Target::ScopedWithDeps => format!("@scope/pkg-{SCOPED_VERSION}.tgz"), + } + } + fn package_json(self) -> String { + format!( + r#"{{"name":"bun-capstone","version":"0.0.0","private":true,"dependencies":{{"{}":"{}"}}}}"#, + self.name(), + self.version() + ) + } +} + +/// The `packages` line for `name` in a bun.lock (`"name": [...]`). +fn packages_line(lock: &str, name: &str) -> String { + let key = format!("\"{name}\": ["); + lock.lines() + .find(|l| l.trim_start().starts_with(&key)) + .unwrap_or_else(|| panic!("no packages entry for {name} in:\n{lock}")) + .to_string() +} + +/// The `sha512-…` integrity token of a packages line (its last element). +fn line_sha512(line: &str) -> String { + let start = line + .rfind("\"sha512-") + .unwrap_or_else(|| panic!("no sha512 in packages line: {line}")); + let rest = &line[start + 1..]; + let end = rest.find('"').unwrap(); + rest[..end].to_string() } // ── shared fixture (steps 1–2) ──────────────────────────────────────── @@ -380,11 +547,16 @@ fn make_tgz_from_installed(pkg_dir: &Path, replaced_index: &[u8]) -> Vec { struct BunProject { tmp: tempfile::TempDir, proj: PathBuf, + target: Target, orig: Vec, patched: Vec, - purl: String, lock_before: Vec, pkg_before: Vec, + /// bun's registry 4-tuple for the target, up to its integrity — the + /// spelling that must be GONE after the rewrite. + registry_tuple_head: String, + /// The registry integrity bun recorded — must NOT survive the rewrite. + registry_sha512: String, /// `bun --version`, verbatim, for messages. bun_raw: String, bun_version: BunVersion, @@ -392,25 +564,35 @@ struct BunProject { lock_version: u64, } -/// Steps 1–2 of the module doc, shared by the vendor capstone and the -/// get-driven twin: a tempdir project depending on left-pad, a REAL -/// `bun install` (network here, private cache) with the hermeticity guard, -/// pristine-byte checks, and the patched-content twin of the installed -/// `index.js`. `None` = soft-skip, already reported with a println (a hard -/// failure instead under the REQUIRED gate). -fn bun_project(tag: &str) -> Option { +/// Steps 1–2 of the module doc, shared by every leg: a tempdir project +/// depending on the target, a REAL `bun install` (network here, private +/// cache) with the hermeticity guard, pristine-byte checks, and the +/// patched-content twin of the installed `index.js`. `scoped_registry` is +/// the wiremock registry URI for [`Target::ScopedWithDeps`] (mounted by +/// the caller — the fixture writes the matching `bunfig.toml`). `None` = +/// soft-skip, already reported with a println (a hard failure instead +/// under the REQUIRED gate). +fn bun_project(tag: &str, target: Target, scoped_registry: Option<&str>) -> Option { let (bun_raw, bun_version) = bun_toolchain(tag)?; let tmp = tempfile::tempdir().unwrap(); let proj = tmp.path().join("proj"); std::fs::create_dir_all(&proj).unwrap(); - std::fs::write( - proj.join("package.json"), - format!( - r#"{{"name":"bun-capstone","version":"0.0.0","private":true,"dependencies":{{"{DEP}":"{DEP_VERSION}"}}}}"# - ), - ) - .unwrap(); + std::fs::write(proj.join("package.json"), target.package_json()).unwrap(); + let mut registry_field = String::new(); + if target == Target::ScopedWithDeps { + let registry = scoped_registry.expect("the scoped target needs its wiremock registry"); + // bun's scoped-registry config — a committable file, so it travels + // with every fresh checkout below. + std::fs::write( + proj.join("bunfig.toml"), + format!("[install.scopes]\n\"@scope\" = {{ url = \"{registry}/\" }}\n"), + ) + .unwrap(); + // For a non-default registry bun records the TARBALL URL as the + // 4-tuple's registry field. + registry_field = format!("{registry}/@scope/pkg/-/pkg-{SCOPED_VERSION}.tgz"); + } // 1. REAL fixture: bun install (network allowed here, private cache). let cache = tmp.path().join("bun-cache"); @@ -451,14 +633,19 @@ fn bun_project(tag: &str) -> Option { cache.display() ); - let installed_index = proj.join("node_modules").join(DEP).join("index.js"); + let installed_index = target.installed_dir(&proj).join("index.js"); let orig = std::fs::read(&installed_index).expect("installed index.js"); assert!( !orig.starts_with(MARKER.as_bytes()), "pristine install must not carry the marker" ); + if target == Target::ScopedWithDeps { + assert_eq!( + orig, SCOPED_INDEX, + "bun must have installed the mock registry's bytes" + ); + } let patched: Vec = [MARKER.as_bytes(), orig.as_slice()].concat(); - let purl = format!("pkg:npm/{DEP}@{DEP_VERSION}"); let lock_before = std::fs::read(&lock_path).expect("bun.lock after bun install"); let pkg_before = std::fs::read(proj.join("package.json")).expect("package.json"); @@ -477,49 +664,100 @@ fn bun_project(tag: &str) -> Option { (1.1.39–1.1.x → 0, 1.2–1.3 → 1, ≥ 1.4 → 2):\n{lock_before_str}", expected_lock_version(bun_version) ); - // Pre-vendor: the registry 4-tuple `["left-pad@1.3.0", "", {}, "sha512-…"]` - // — the same spelling in lockfileVersion 0, 1 and 2. + // Pre-vendor: the registry 4-tuple, with bun's real registry field and + // meta object for this target — one spelling across 0/1/2. + let registry_tuple_head = format!( + "\"{}@{}\", \"{registry_field}\", {}, \"sha512-", + target.name(), + target.version(), + target.meta() + ); assert!( - lock_before_str.contains(&format!("\"{DEP}@{DEP_VERSION}\", \"\"")), - "pre-vendor packages entry must be the registry 4-tuple:\n{lock_before_str}" + lock_before_str.contains(®istry_tuple_head), + "pre-vendor packages entry must be the registry 4-tuple {registry_tuple_head}…:\n\ + {lock_before_str}" ); + let registry_sha512 = line_sha512(&packages_line(&lock_before_str, target.name())); Some(BunProject { tmp, proj, + target, orig, patched, - purl, lock_before, pkg_before, + registry_tuple_head, + registry_sha512, bun_raw, bun_version, lock_version, }) } -fn vendored_tgz_rel() -> String { - format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz") -} - fn vendored_dir(proj: &Path) -> PathBuf { proj.join(".socket").join("vendor").join("npm").join(UUID) } -fn vendored_tgz(proj: &Path) -> PathBuf { - vendored_dir(proj).join(format!("{DEP}-{DEP_VERSION}.tgz")) +fn vendored_tgz(fx: &BunProject) -> PathBuf { + vendored_dir(&fx.proj).join(fx.target.vendored_tgz_rel()) +} + +/// Hand-stage the `.socket/` manifest + blob for the fixture's target from +/// the installed bytes (the capstone's step 2). +fn stage_patch(fx: &BunProject) { + let socket = fx.proj.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let manifest = serde_json::json!({ + "patches": { fx.target.purl(): { + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { "package/index.js": { + "beforeHash": git_sha256(&fx.orig), + "afterHash": git_sha256(&fx.patched), + }}, + "vulnerabilities": {}, + "description": "capstone marker patch", + "license": "MIT", + "tier": "free", + }} + }); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write( + socket.join("blobs").join(git_sha256(&fx.patched)), + &fx.patched, + ) + .unwrap(); +} + +/// `vendor --json --offline` over the fixture; the (code, stdout, stderr). +fn run_vendor(fx: &BunProject, extra: &[&str]) -> (i32, String, String) { + let mut args = vec![ + "vendor", + "--json", + "--offline", + "--cwd", + fx.proj.to_str().unwrap(), + ]; + args.extend_from_slice(extra); + run_socket(&fx.proj, &args) } /// The on-disk vendored state BOTH drivers (`vendor --offline`, `get /// --mode vendored`) must produce: the committed artifact + informational /// marker + ledger, the bun.lock `packages` entry rewritten from the -/// registry 4-tuple to the local-tarball 3-tuple with OUR recomputed -/// integrity, and package.json untouched. +/// registry 4-tuple to the local-tarball 3-tuple with the meta object +/// carried VERBATIM and OUR recomputed integrity, and package.json +/// untouched. fn assert_vendored_on_disk(fx: &BunProject) { let proj = &fx.proj; - let tgz_rel = vendored_tgz_rel(); + let tgz_rel = format!(".socket/vendor/npm/{UUID}/{}", fx.target.vendored_tgz_rel()); assert!( - vendored_tgz(proj).is_file(), + vendored_tgz(fx).is_file(), "vendored tarball missing at {tgz_rel}" ); assert!( @@ -537,21 +775,26 @@ fn assert_vendored_on_disk(fx: &BunProject) { ); // bun.lock packages entry rewritten to the local-tarball 3-tuple: - // element 0 = `@` (no `file:`/`./`), the deps object - // shifts to index 1, integrity is the recomputed sha512 of OUR tarball. + // element 0 = `@` (no `file:`/`./`), the meta + // object shifts to index 1 unchanged, integrity is the recomputed + // sha512 of OUR tarball. let lock_after = std::fs::read_to_string(proj.join("bun.lock")).unwrap(); + let local_tuple_head = format!( + "\"{}@{tgz_rel}\", {}, \"sha512-", + fx.target.name(), + fx.target.meta() + ); assert!( - lock_after.contains(&format!("\"{DEP}@{tgz_rel}\", {{}}, \"sha512-")), - "bun.lock packages entry must be the local-tarball 3-tuple; got:\n{lock_after}" + lock_after.contains(&local_tuple_head), + "bun.lock packages entry must be the local-tarball 3-tuple {local_tuple_head}…; got:\n\ + {lock_after}" ); assert!( - !lock_after.contains(&format!("\"{DEP}@{DEP_VERSION}\", \"\"")), + !lock_after.contains(&fx.registry_tuple_head), "the registry 4-tuple must be gone after the rewrite:\n{lock_after}" ); assert!( - !lock_after.contains( - "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==" - ), + !lock_after.contains(&fx.registry_sha512), "the inherited registry integrity must NOT survive the rewrite:\n{lock_after}" ); // The rewrite must keep the lock's own version line — a v0 lock stays @@ -561,6 +804,15 @@ fn assert_vendored_on_disk(fx: &BunProject) { Some(fx.lock_version), "the vendored rewrite must preserve the lockfileVersion line verbatim:\n{lock_after}" ); + if fx.target == Target::ScopedWithDeps { + // The dependency's own registry entry is not the target: untouched. + let before = String::from_utf8(fx.lock_before.clone()).unwrap(); + assert_eq!( + packages_line(&lock_after, DEP), + packages_line(&before, DEP), + "the un-patched dependency's registry 4-tuple must be byte-identical:\n{lock_after}" + ); + } // package.json is left untouched by the lock-only bun wiring. assert_eq!( std::fs::read(proj.join("package.json")).unwrap(), @@ -570,21 +822,25 @@ fn assert_vendored_on_disk(fx: &BunProject) { } /// Fresh dir `/` holding ONLY the committable files -/// (package.json, bun.lock, and .socket/). -fn fresh_checkout(tmp: &Path, proj: &Path, name: &str) -> PathBuf { - let fresh = tmp.join(name); +/// (package.json, bun.lock, bunfig.toml when the project has one, and +/// .socket/). +fn fresh_checkout(fx: &BunProject, name: &str) -> PathBuf { + let fresh = fx.tmp.path().join(name); std::fs::create_dir_all(&fresh).unwrap(); - std::fs::copy(proj.join("package.json"), fresh.join("package.json")).unwrap(); - std::fs::copy(proj.join("bun.lock"), fresh.join("bun.lock")).unwrap(); - copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); + std::fs::copy(fx.proj.join("package.json"), fresh.join("package.json")).unwrap(); + std::fs::copy(fx.proj.join("bun.lock"), fresh.join("bun.lock")).unwrap(); + if fx.proj.join("bunfig.toml").is_file() { + std::fs::copy(fx.proj.join("bunfig.toml"), fresh.join("bunfig.toml")).unwrap(); + } + copy_dir_recursive(&fx.proj.join(".socket"), &fresh.join(".socket")); fresh } /// `bun install --frozen-lockfile` in a fresh checkout named `name` against /// an EMPTY cache — the spike-proven strictest invocation. -fn fresh_frozen_install(tmp: &Path, proj: &Path, name: &str) -> (PathBuf, Output) { - let fresh = fresh_checkout(tmp, proj, name); - let fresh_cache = tmp.join(format!("{name}-bun-cache")); +fn fresh_frozen_install(fx: &BunProject, name: &str) -> (PathBuf, Output) { + let fresh = fresh_checkout(fx, name); + let fresh_cache = fx.tmp.path().join(format!("{name}-bun-cache")); let ci = bun( &fresh, &["install", "--frozen-lockfile", "--ignore-scripts"], @@ -593,6 +849,33 @@ fn fresh_frozen_install(tmp: &Path, proj: &Path, name: &str) -> (PathBuf, Output (fresh, ci) } +/// For the scoped target: bun must have honored the meta object it read +/// from the local-tarball 3-tuple — the dependency is installed and the +/// bin linked (as `node_modules/.bin/scope-pkg`, or its `.exe`/`.cmd` +/// shims on Windows). Neither happens when the meta is `{}`. +fn assert_scoped_meta_honored(fresh: &Path) { + assert!( + fresh + .join("node_modules") + .join(DEP) + .join("package.json") + .is_file(), + "bun must install the scoped package's `dependencies` from the 3-tuple meta" + ); + let bin_dir = fresh.join("node_modules").join(".bin"); + let linked = std::fs::read_dir(&bin_dir) + .map(|rd| { + rd.filter_map(|e| e.ok()) + .any(|e| e.file_name().to_string_lossy().starts_with(SCOPED_BIN)) + }) + .unwrap_or(false); + assert!( + linked, + "bun must link the scoped package's `bin` from the 3-tuple meta under {}", + bin_dir.display() + ); +} + /// Step 4, shared: the fresh-checkout frozen install MUST land the patched /// bytes (BN7); then the ORDINARY install (node_modules removed, another /// empty cache, plain `bun install`) MUST leave the committed lock @@ -600,8 +883,8 @@ fn fresh_frozen_install(tmp: &Path, proj: &Path, name: &str) -> (PathBuf, Output /// writes the lock, so only the plain install can observe a /// re-serialization of the local-tarball tuple — the property the module /// doc calls BN3 and the backtest checks as `ordinaryStableLock`. -fn fresh_checkout_install_proof(tmp: &Path, proj: &Path, patched: &[u8], name: &str) { - let (fresh, ci) = fresh_frozen_install(tmp, proj, name); +fn fresh_checkout_install_proof(fx: &BunProject, name: &str) { + let (fresh, ci) = fresh_frozen_install(fx, name); assert!( ci.status.success(), "fresh-checkout `bun install --frozen-lockfile` must succeed from the vendored \ @@ -609,7 +892,7 @@ fn fresh_checkout_install_proof(tmp: &Path, proj: &Path, patched: &[u8], name: & String::from_utf8_lossy(&ci.stdout), String::from_utf8_lossy(&ci.stderr), ); - let installed_index = fresh.join("node_modules").join(DEP).join("index.js"); + let installed_index = fx.target.installed_dir(&fresh).join("index.js"); let fresh_installed = std::fs::read(&installed_index).unwrap(); assert!( fresh_installed.starts_with(MARKER.as_bytes()), @@ -617,15 +900,18 @@ fn fresh_checkout_install_proof(tmp: &Path, proj: &Path, patched: &[u8], name: & String::from_utf8_lossy(&fresh_installed[..fresh_installed.len().min(120)]) ); assert_eq!( - fresh_installed, patched, + fresh_installed, fx.patched, "fresh install must be byte-identical to the patched content" ); - eprintln!("FRESH INSTALL OK ({name})"); + if fx.target == Target::ScopedWithDeps { + assert_scoped_meta_honored(&fresh); + } + eprintln!("FRESH INSTALL OK ({name}, {:?})", fx.target); // Ordinary install: the lock must survive bun's own re-serialization. - let wired_lock = std::fs::read(proj.join("bun.lock")).unwrap(); + let wired_lock = std::fs::read(fx.proj.join("bun.lock")).unwrap(); std::fs::remove_dir_all(fresh.join("node_modules")).unwrap(); - let plain_cache = tmp.join(format!("{name}-plain-bun-cache")); + let plain_cache = fx.tmp.path().join(format!("{name}-plain-bun-cache")); let plain = bun(&fresh, &["install", "--ignore-scripts"], &plain_cache); assert!( plain.status.success(), @@ -641,9 +927,12 @@ fn fresh_checkout_install_proof(tmp: &Path, proj: &Path, patched: &[u8], name: & ); assert_eq!( std::fs::read(&installed_index).unwrap(), - patched, + fx.patched, "the ordinary install must land the patched bytes too" ); + if fx.target == Target::ScopedWithDeps { + assert_scoped_meta_honored(&fresh); + } eprintln!("PLAIN INSTALL LOCK-STABLE ({name})"); } @@ -654,7 +943,7 @@ fn fresh_checkout_install_proof(tmp: &Path, proj: &Path, patched: &[u8], name: & /// and MUST install the tampered bytes with exit 0 (reported PARTIAL — the /// rejection proof is not available on that release, by bun's design). fn assert_tamper_outcome(fx: &BunProject, tampered: &[u8]) { - let (fresh, ci) = fresh_frozen_install(fx.tmp.path(), &fx.proj, "fresh-tampered"); + let (fresh, ci) = fresh_frozen_install(fx, "fresh-tampered"); let chatter = format!( "{}\n{}", String::from_utf8_lossy(&ci.stdout), @@ -683,8 +972,7 @@ fn assert_tamper_outcome(fx: &BunProject, tampered: &[u8]) { install must still exit 0 — a failure here means the boundary moved.\n{chatter}", fx.bun_raw ); - let installed = - std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); + let installed = std::fs::read(fx.target.installed_dir(&fresh).join("index.js")).unwrap(); assert_eq!( installed, tampered, "bun {} installed neither the tampered bytes nor failed: the boundary model is wrong", @@ -698,37 +986,11 @@ fn assert_tamper_outcome(fx: &BunProject, tampered: &[u8]) { } } -// ── the capstone ────────────────────────────────────────────────────── - -// #[serial]: each fresh install gets its own empty cache dir, but bun also -// keeps state under the sandboxed `~/.bun`; serializing keeps the tampered -// twin (same local tarball spec, different bytes) from ever racing a -// sibling's honest install. -#[test] -#[serial_test::serial] -fn bun_vendor_fresh_checkout_frozen_install_and_revert() { - let Some(fx) = bun_project("vendor-offline") else { - return; - }; - let proj = &fx.proj; - let lock_path = proj.join("bun.lock"); - let pkg_path = proj.join("package.json"); - let purl = &fx.purl; - - // 2. Hand-stage the .socket/ manifest + blob from the installed bytes. - stage_patch(proj, purl, "package/index.js", &fx.orig, &fx.patched); - - // 3. Vendor (offline). - let (code, stdout, stderr) = run_socket( - proj, - &[ - "vendor", - "--json", - "--offline", - "--cwd", - proj.to_str().unwrap(), - ], - ); +/// Steps 2–3 for the manifest-driven legs: stage the patch, `vendor +/// --offline`, assert the envelope and the on-disk vendored state. +fn stage_and_vendor(fx: &BunProject) { + stage_patch(fx); + let (code, stdout, stderr) = run_vendor(fx, &[]); assert_eq!( code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" @@ -737,34 +999,54 @@ fn bun_vendor_fresh_checkout_frozen_install_and_revert() { assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!(env["summary"]["applied"], 1, "one package vendored: {env}"); assert_eq!(env["summary"]["failed"], 0, "no failures: {env}"); + let purl = fx.target.purl(); let applied = env["events"] .as_array() .unwrap() .iter() - .find(|e| e["action"] == "applied" && e["purl"] == purl.as_str()) + .find(|e| e["action"] == "applied" && e["purl"] == purl) .unwrap_or_else(|| panic!("expected an applied event for {purl}: {env}")); assert!( applied.get("errorCode").is_none(), "clean apply event: {applied}" ); - - assert_vendored_on_disk(&fx); + assert_vendored_on_disk(fx); eprintln!( - "VENDOR OK (bun {}, lockfileVersion {})", - fx.bun_raw, fx.lock_version + "VENDOR OK (bun {}, lockfileVersion {}, {:?})", + fx.bun_raw, fx.lock_version, fx.target ); +} + +// ── the capstone ────────────────────────────────────────────────────── + +// #[serial]: each fresh install gets its own empty cache dir, but bun also +// keeps state under the sandboxed `~/.bun`; serializing keeps the tampered +// twin (same local tarball spec, different bytes) from ever racing a +// sibling's honest install. +#[test] +#[serial_test::serial] +fn bun_vendor_fresh_checkout_frozen_install_and_revert() { + let Some(fx) = bun_project("vendor-offline", Target::LeftPad, None) else { + return; + }; + let proj = &fx.proj; + let lock_path = proj.join("bun.lock"); + let pkg_path = proj.join("package.json"); + + // 2–3. Hand-stage the .socket/ manifest + blob, vendor (offline). + stage_and_vendor(&fx); // 4. FRESH-CHECKOUT PROOF: committable files only, EMPTY cache, // spike-proven `--frozen-lockfile`, then the ordinary-install // lock-stability twin. - fresh_checkout_install_proof(fx.tmp.path(), proj, &fx.patched, "fresh"); + fresh_checkout_install_proof(&fx, "fresh"); // 5. REPAIR PROOF: the committed artifact dir vanishes (a botched merge, // an over-eager clean); `repair --offline` must rebuild the tarball // byte-identically from the installed copy + blob, leave bun.lock // alone, and a cold fresh checkout must install the marker bytes // from the rebuilt artifact. - let tgz_path = vendored_tgz(proj); + let tgz_path = vendored_tgz(&fx); let tgz_bytes = std::fs::read(&tgz_path).unwrap(); let lock_wired = std::fs::read(&lock_path).unwrap(); std::fs::remove_dir_all(vendored_dir(proj)).unwrap(); @@ -795,8 +1077,9 @@ fn bun_vendor_fresh_checkout_frozen_install_and_revert() { .as_array() .unwrap() .iter() - .any(|e| e["action"] == "rebuilt" && e["purl"] == purl.as_str()), - "repair must report a rebuilt event for {purl}: {renv}" + .any(|e| e["action"] == "rebuilt" && e["purl"] == fx.target.purl()), + "repair must report a rebuilt event for {}: {renv}", + fx.target.purl() ); assert_eq!( std::fs::read(&tgz_path).unwrap(), @@ -809,19 +1092,10 @@ fn bun_vendor_fresh_checkout_frozen_install_and_revert() { "repair must not touch bun.lock" ); eprintln!("REPAIR OK"); - fresh_checkout_install_proof(fx.tmp.path(), proj, &fx.patched, "fresh-repaired"); + fresh_checkout_install_proof(&fx, "fresh-repaired"); // 6. Idempotency: a re-run exits 0 and leaves bun.lock byte-stable. - let (code, stdout, stderr) = run_socket( - proj, - &[ - "vendor", - "--json", - "--offline", - "--cwd", - proj.to_str().unwrap(), - ], - ); + let (code, stdout, stderr) = run_vendor(&fx, &[]); assert_eq!( code, 0, "re-vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" @@ -835,17 +1109,7 @@ fn bun_vendor_fresh_checkout_frozen_install_and_revert() { ); // 7. REVERT PROOF: bun.lock restored byte-for-byte, artifacts gone. - let (code, stdout, stderr) = run_socket( - proj, - &[ - "vendor", - "--revert", - "--json", - "--offline", - "--cwd", - proj.to_str().unwrap(), - ], - ); + let (code, stdout, stderr) = run_vendor(&fx, &["--revert"]); assert_eq!( code, 0, "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" @@ -870,6 +1134,30 @@ fn bun_vendor_fresh_checkout_frozen_install_and_revert() { eprintln!("REVERT OK"); } +// ── the scoped, dependency-bearing leg ──────────────────────────────── + +/// Scoped target with `dependencies` + `bin`: the vendored rewrite must +/// carry bun's meta object verbatim into the local-tarball 3-tuple (path +/// keeping the scope dir), leave the dependency's own registry entry +/// alone, and the fresh install must prove bun honored that meta — +/// left-pad installed, the bin linked — on top of the patched bytes and +/// the stable lock. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn bun_vendor_scoped_package_keeps_deps_and_bin_meta() { + let server = MockServer::start().await; + mount_scoped_registry(&server, scoped_registry_tgz()).await; + let Some(fx) = bun_project( + "scoped-with-deps", + Target::ScopedWithDeps, + Some(&server.uri()), + ) else { + return; + }; + stage_and_vendor(&fx); + fresh_checkout_install_proof(&fx, "fresh"); +} + // ── the tampered twin ───────────────────────────────────────────────── /// Negative twin: the committed tarball is swapped for a DIFFERENT valid @@ -880,33 +1168,16 @@ fn bun_vendor_fresh_checkout_frozen_install_and_revert() { #[test] #[serial_test::serial] fn bun_vendor_tampered_tarball_digest_boundary() { - let Some(fx) = bun_project("tampered") else { + let Some(fx) = bun_project("tampered", Target::LeftPad, None) else { return; }; - let proj = &fx.proj; - - stage_patch(proj, &fx.purl, "package/index.js", &fx.orig, &fx.patched); - let (code, stdout, stderr) = run_socket( - proj, - &[ - "vendor", - "--json", - "--offline", - "--cwd", - proj.to_str().unwrap(), - ], - ); - assert_eq!( - code, 0, - "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" - ); - assert_vendored_on_disk(&fx); + stage_and_vendor(&fx); // Tamper: a valid tarball with different content under the same path. // The lock still pins the sha512 of OUR tarball. let tampered: Vec = [TAMPER_MARKER.as_bytes(), fx.orig.as_slice()].concat(); - let tampered_tgz = make_tgz_from_installed(&proj.join("node_modules").join(DEP), &tampered); - let tgz_path = vendored_tgz(proj); + let tampered_tgz = make_tgz_from_installed(&fx.target.installed_dir(&fx.proj), &tampered); + let tgz_path = vendored_tgz(&fx); assert_ne!( std::fs::read(&tgz_path).unwrap(), tampered_tgz, @@ -955,17 +1226,18 @@ async fn mock_view(server: &MockServer, purl: &str, before: &[u8], after: &[u8]) #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn bun_get_uuid_vendored_fresh_checkout_frozen_install() { - let Some(fx) = bun_project("get-uuid-vendored") else { + let Some(fx) = bun_project("get-uuid-vendored", Target::LeftPad, None) else { return; }; let proj = &fx.proj; + let purl = fx.target.purl(); // Steps 2–3, get-driven: the patch record comes from a mocked // `view/{uuid}` instead of a hand-staged `.socket/`, and the vendor step // builds the artifact locally (`--vendor-source build` — no vendoring // service, so no grant/tarball mocks are needed). let server = MockServer::start().await; - mock_view(&server, &fx.purl, &fx.orig, &fx.patched).await; + mock_view(&server, purl, &fx.orig, &fx.patched).await; let server_uri = server.uri(); let (code, stdout, stderr) = run_socket( @@ -1030,8 +1302,7 @@ async fn bun_get_uuid_vendored_fresh_checkout_frozen_install() { ) .unwrap(); assert_eq!( - manifest["patches"][fx.purl.as_str()]["uuid"], - UUID, + manifest["patches"][purl]["uuid"], UUID, "the manifest must record the vendored patch: {manifest}" ); assert!( @@ -1044,5 +1315,5 @@ async fn bun_get_uuid_vendored_fresh_checkout_frozen_install() { // FRESH-CHECKOUT PROOF: committable files only, EMPTY cache, // spike-proven `--frozen-lockfile`, then the ordinary-install twin. - fresh_checkout_install_proof(fx.tmp.path(), proj, &fx.patched, "fresh"); + fresh_checkout_install_proof(&fx, "fresh"); } From c9efd6134300a0587c913a4d7a2f8b6f644a7b0a Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 18 Sep 2026 17:34:09 -0400 Subject: [PATCH 24/49] ci: run the hermetic real-bun e2e suites in the e2e matrix e2e_redirect_bun_build and e2e_vendor_bun_build have never executed a bun command in CI: the `test` job runs them on runners that ship no bun, so both soft-skip and report "ok" in 0.00s on all three OS, and the `e2e` matrix had no bun leg. Add legs for both suites plus the new mode_migration_bun suite on ubuntu/macos/windows with bun 1.4.2 (lockfileVersion 2), and ubuntu lock-era legs with 1.1.45 (v0 opt-in text lock) and 1.2.23 (v1 default; 1.3.14 for mode_migration_bun), installed by SHA-pinned oven-sh/setup-bun v2.2.0. `test_filter: --include-ignored` is mandatory on every bun leg: the suites carry no #[ignore] tests, so the job default `-- --ignored` would select nothing and pass vacuously (the e2e_composer trap). The run step exports SOCKET_PATCH_BUN_E2E_REQUIRED=1 and SOCKET_PATCH_BUN_E2E_VERSION= on bun legs (empty string elsewhere) so the suites hard-fail instead of skipping when bun is missing or the wrong release. The rust-cache key gains the bun release so several legs of one suite on one OS no longer collide. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 103 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc53ce89..ad6721ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -732,6 +732,86 @@ jobs: suite: e2e_redirect_rush_sim - os: windows-latest suite: e2e_redirect_rush_sim + # Hermetic real-bun capstones (wiremock patch service, real `bun + # install`): hosted (`e2e_redirect_bun_build`), vendored + # (`e2e_vendor_bun_build`) and the hosted⇄vendored takeover / + # scoped-rollback suite (`mode_migration_bun`). They are NOT + # `#[ignore]`-gated, so `test_filter: --include-ignored` is + # mandatory — the job default `-- --ignored` would select zero + # tests and pass vacuously (the e2e_composer trap above). The + # runner images ship no bun, so without the `bun:` key below the + # suites soft-skip; `bun:` installs that exact release via + # setup-bun and exports SOCKET_PATCH_BUN_E2E_REQUIRED=1 (+ the + # pinned version), under which the suites hard-fail instead of + # skipping when bun is missing, the wrong version, or the fixture + # install produces no text lock. + # + # Lock-era legs (ubuntu only): bun's text lock has three + # grammars — lockfileVersion 0 (opt-in `--save-text-lockfile`, + # 1.1.39–1.1.45; 2-tuple workspace entries), 1 (default from + # 1.2.0 through 1.3.x) and 2 (1.4.0+). Registry 4-tuples are + # identical across them but the workspace grammar, the lockb + # migration recipe and tarball digest enforcement (URL/local + # tarball sha512 checked only from 1.3.10) all differ, so the + # latest release alone cannot prove the rewrite + fresh install + # round-trip on the locks real projects commit. 1.1.45 = last v0 + # writer, 1.2.23 = v1, 1.3.14 = last pre-v2 default, 1.4.2 = v2. + - os: ubuntu-latest + suite: e2e_redirect_bun_build + bun: '1.4.2' + test_filter: --include-ignored + - os: macos-latest + suite: e2e_redirect_bun_build + bun: '1.4.2' + test_filter: --include-ignored + - os: windows-latest + suite: e2e_redirect_bun_build + bun: '1.4.2' + test_filter: --include-ignored + - os: ubuntu-latest + suite: e2e_redirect_bun_build + bun: '1.1.45' + test_filter: --include-ignored + - os: ubuntu-latest + suite: e2e_redirect_bun_build + bun: '1.2.23' + test_filter: --include-ignored + - os: ubuntu-latest + suite: e2e_vendor_bun_build + bun: '1.4.2' + test_filter: --include-ignored + - os: macos-latest + suite: e2e_vendor_bun_build + bun: '1.4.2' + test_filter: --include-ignored + - os: windows-latest + suite: e2e_vendor_bun_build + bun: '1.4.2' + test_filter: --include-ignored + - os: ubuntu-latest + suite: e2e_vendor_bun_build + bun: '1.1.45' + test_filter: --include-ignored + - os: ubuntu-latest + suite: e2e_vendor_bun_build + bun: '1.2.23' + test_filter: --include-ignored + - os: ubuntu-latest + suite: mode_migration_bun + bun: '1.4.2' + test_filter: --include-ignored + - os: macos-latest + suite: mode_migration_bun + bun: '1.4.2' + test_filter: --include-ignored + - os: windows-latest + suite: mode_migration_bun + bun: '1.4.2' + test_filter: --include-ignored + - os: ubuntu-latest + suite: mode_migration_bun + bun: '1.3.14' + test_filter: --include-ignored runs-on: ${{ matrix.os }} timeout-minutes: 25 steps: @@ -759,7 +839,9 @@ jobs: with: # Matrix suites otherwise collide on one key: only one of the ~9 # same-OS legs wins the cache reserve and the rest fail to save. - key: ${{ matrix.suite }} + # The bun suites run several legs of one suite per OS (one per + # pinned bun release), so the release is part of the key too. + key: ${{ matrix.suite }}-${{ matrix.bun || 'default' }} save-if: ${{ github.ref == 'refs/heads/main' }} - name: Setup Node.js @@ -810,10 +892,29 @@ jobs: php-version: '8.2' tools: composer:2 + - name: Setup Bun + if: matrix.bun != '' + # Installs the exact bun release the leg pins (setup-bun resolves a + # strict semver straight to the `bun-v` GitHub release, so the + # lock-era legs get the historical writer, not `latest`). Works on + # all three runner OSes (windows → bun-windows-x64.zip / bun.exe). + # SHA resolved from `gh api repos/oven-sh/setup-bun/git/ref/tags/v2.2.0`. + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: ${{ matrix.bun }} + - name: Run e2e tests # Suites are `#[ignore]`-gated out of the unpinned `test` job by # default, hence `--ignored`; an entry that sets `test_filter` # overrides the selector for itself only. + env: + # Bun legs only: turn the bun suites' "bun not installed / no text + # lock" soft-skips into hard failures and make them assert the + # pinned release, so a leg can never report green on an + # unexercised toolchain. Both are the EMPTY string on non-bun legs, + # which the suites treat as unset. + SOCKET_PATCH_BUN_E2E_REQUIRED: ${{ matrix.bun != '' && '1' || '' }} + SOCKET_PATCH_BUN_E2E_VERSION: ${{ matrix.bun }} run: cargo test -p socket-patch-cli --all-features --test ${{ matrix.suite }} -- ${{ matrix.test_filter || '--ignored' }} # ---------------------------------------------------------------------- From 811a42595fdabc494867cb135585806ba0caf2e5 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 18 Sep 2026 17:34:09 -0400 Subject: [PATCH 25/49] ci(bun): main trigger, wider paths, verified retried bun download, dispatch inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native bun matrix ran only on path-filtered pull_request events, so post-merge main was never exercised and its rust-cache (save-if main) was never written: every build restored nothing and compiled cold. Add a path-filtered `push: branches: [main]` (pdm shape) and gate cancel-in-progress on non-main so the seeding run is never cancelled mid-save. Widen the pull_request filter to the code the backtest actually drives (rollback/vendor/repair_vendor/remove commands, npm crawler and pkg_managers detection, constants, utils/process, bun_lock_text, the bun redirect fixtures, the doc and Cargo.lock). scripts/backtest-bun.py fetches each release with one un-retried urlretrieve before any case runs; a transient GitHub 500 killed a whole cell on the workflow's first run. Add a step that pre-populates the exact `tools///bun[.exe]` layout install_tool() looks up with a 5-attempt backoff loop, verifies the archive against the release's SHASUMS256.txt before extracting (fail closed) and checks `bun --version`, then pass `--tools native-bun/tools` so the script only sees a verified, cached binary. Also: add 1.1.43 (first `--lockfile-only`), 1.3.9 and 1.3.10 (URL/local tarball sha512 enforcement boundary) to the matrix — all three ship a Windows asset, so the exclude list is unchanged; add workflow_dispatch inputs versions/shapes/modes wired like pdm-compatibility.yml; record provenance as both `--cli-revision` (branch-resolvable head SHA, via env) and CLI_BUILD_SHA (the SHA actions/checkout actually built); add the `# vX.Y.Z` comments on every SHA pin, name every step, add setup-python + `python3` and `chmod || true` per the sibling workflows, and a header comment pointing at docs/testing/bun-compatibility.md. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/bun-compatibility.yml | 213 ++++++++++++++++++++++-- 1 file changed, 202 insertions(+), 11 deletions(-) diff --git a/.github/workflows/bun-compatibility.yml b/.github/workflows/bun-compatibility.yml index 421ca8a5..a6bde82f 100644 --- a/.github/workflows/bun-compatibility.yml +++ b/.github/workflows/bun-compatibility.yml @@ -1,22 +1,77 @@ name: Bun patch compatibility +# Native Bun installer matrix: builds the CLI once per OS, downloads each +# pinned Bun release straight from its GitHub release (retried, SHA-256 +# verified against the release's SHASUMS256.txt) and runs +# `scripts/backtest-bun.py` — hosted, vendored and vendored-detached mode +# against the public minimist free patch, verifying the INSTALLED bytes, +# lock stability, digest rejection and rollback on Linux, macOS and Windows. +# No Socket API token is needed. See docs/testing/bun-compatibility.md. +# +# The hermetic (wiremock) real-bun suites run on every PR in ci.yml's `e2e` +# matrix; this workflow is the production-service, many-release twin. + on: pull_request: paths: - '.github/workflows/bun-compatibility.yml' - 'scripts/backtest-bun.py' + - 'docs/testing/bun-compatibility.md' + - 'Cargo.lock' - 'crates/socket-patch-core/src/vendor/**' - 'crates/socket-patch-core/src/patch/redirect/**' + - 'crates/socket-patch-core/src/patch/bun_lock_text.rs' + - 'crates/socket-patch-core/src/crawlers/npm_crawler.rs' + - 'crates/socket-patch-core/src/crawlers/pkg_managers.rs' + - 'crates/socket-patch-core/src/constants.rs' + - 'crates/socket-patch-core/src/utils/process.rs' + - 'crates/socket-patch-core/tests/fixtures/redirect/npm/bun/**' - 'crates/socket-patch-cli/src/commands/get.rs' - 'crates/socket-patch-cli/src/commands/scan/**' + - 'crates/socket-patch-cli/src/commands/rollback.rs' + - 'crates/socket-patch-cli/src/commands/vendor.rs' + - 'crates/socket-patch-cli/src/commands/repair_vendor.rs' + - 'crates/socket-patch-cli/src/commands/remove.rs' + # Main runs are the only rust-cache writers (save-if below), so a + # path-filtered push trigger is what seeds the cache the PR builds restore. + push: + branches: [main] + paths: + - '.github/workflows/bun-compatibility.yml' + - 'scripts/backtest-bun.py' + - 'crates/socket-patch-core/src/vendor/bun_lock.rs' + - 'crates/socket-patch-core/src/vendor/npm_flavor.rs' + - 'crates/socket-patch-core/src/vendor/lock_inventory.rs' + - 'crates/socket-patch-core/src/patch/bun_lock_text.rs' + - 'crates/socket-patch-core/src/patch/redirect/mod.rs' + - 'crates/socket-patch-core/src/patch/redirect/replay.rs' + - 'crates/socket-patch-core/src/patch/redirect/takeover.rs' + - 'crates/socket-patch-cli/src/commands/get.rs' + - 'crates/socket-patch-cli/src/commands/scan/hosted.rs' + - 'crates/socket-patch-cli/src/commands/rollback.rs' workflow_dispatch: + inputs: + versions: + description: 'Space-separated Bun versions (empty = the pinned matrix below; every cell runs the override)' + required: false + default: '' + shapes: + description: 'Space-separated shapes (empty = every shape)' + required: false + default: '' + modes: + description: 'Space-separated modes from hosted / vendored / vendored-detached (empty = all three)' + required: false + default: '' permissions: contents: read +# Supersede stale PR runs. The `main` guard is load-bearing: main runs are the +# ONLY rust-cache writers (save-if), so they must never be cancelled mid-save. concurrency: group: bun-patch-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: CARGO_PROFILE_DEV_DEBUG: '0' @@ -31,15 +86,25 @@ jobs: runs-on: ${{ matrix.os }} timeout-minutes: 30 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + + - name: Cache cargo + # save-if keeps writes on main so open PRs do not churn the repo's + # 10 GiB cache budget; rust-cache's automatic key already includes + # the runner OS, so one logical key serves all three builds. + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: key: bun-native save-if: ${{ github.ref == 'refs/heads/main' }} - - run: cargo build --locked -p socket-patch-cli - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + + - name: Build CLI + run: cargo build --locked -p socket-patch-cli + + - name: Upload CLI + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: bun-cli-${{ matrix.os }} path: | @@ -47,33 +112,159 @@ jobs: target/debug/socket-patch.exe if-no-files-found: error retention-days: 7 + native: needs: build strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - bun: ['0.8.1', '1.0.0', '1.0.36', '1.1.0', '1.1.38', '1.1.39', '1.1.45', '1.2.0', '1.2.23', '1.3.0', '1.3.14', '1.4.0', '1.4.2'] + # Every lock-format and behaviour boundary the CLI has to survive: + # 0.8.1 / 1.0.0 / 1.0.36 / 1.1.0 / 1.1.38 binary bun.lockb only + # 1.1.39 first text lock (lockfileVersion 0, opt-in) + # 1.1.43 first `--lockfile-only` (the lockb migration recipe) + # 1.1.45 last v0 writer + # 1.2.0 / 1.2.23 / 1.3.0 text default, lockfileVersion 1 + # 1.3.9 / 1.3.10 URL/local tarball sha512 enforcement boundary + # (1.3.9 installs a tampered tarball, 1.3.10 refuses) + # 1.3.14 last pre-v2 default + # 1.4.0 / 1.4.2 lockfileVersion 2 + bun: ['0.8.1', '1.0.0', '1.0.36', '1.1.0', '1.1.38', '1.1.39', '1.1.43', '1.1.45', '1.2.0', '1.2.23', '1.3.0', '1.3.9', '1.3.10', '1.3.14', '1.4.0', '1.4.2'] exclude: + # No Windows binary before Bun 1.1.0 (every later release above + # ships bun-windows-x64.zip). - {os: windows-latest, bun: '0.8.1'} - {os: windows-latest, bun: '1.0.0'} - {os: windows-latest, bun: '1.0.36'} runs-on: ${{ matrix.os }} timeout-minutes: 30 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + + - name: Download CLI + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: bun-cli-${{ matrix.os }} path: native-cli + + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + + - name: Download Bun ${{ matrix.bun }} + # The script's install_tool() fetches each release with one + # un-retried urlretrieve, before any case runs — a single transient + # GitHub 5xx (seen on this workflow's first run) kills the whole + # cell with no summary.json. Pre-populate the exact directory layout + # install_tool() looks up (`tools///bun[.exe]`) with + # a backoff loop, and verify the archive against the release's own + # SHASUMS256.txt before extracting (fail closed), so the script only + # ever finds a verified, cached binary. + shell: bash + env: + MATRIX_BUN: ${{ matrix.bun }} + VERSIONS_OVERRIDE: ${{ github.event.inputs.versions }} + run: | + set -euo pipefail + case "$RUNNER_OS" in + Linux) system=linux ;; + macOS) system=darwin ;; + Windows) system=windows ;; + *) echo "::error::unsupported runner OS: $RUNNER_OS"; exit 1 ;; + esac + # Same asset choice as install_tool(): aarch64 only for arm64 + # Linux/macOS; Windows is always x64. + arch=x64 + if [ "$RUNNER_ARCH" = "ARM64" ] && [ "$system" != windows ]; then arch=aarch64; fi + asset="bun-${system}-${arch}" + fetch() { + # curl --retry only covers timeouts and 408/429/5xx; the outer + # loop also rides out connection resets and truncated bodies. + for attempt in 1 2 3 4 5; do + if curl -fsSL --retry 5 --retry-delay 5 -o "$2" "$1"; then return 0; fi + rm -f "$2" + if [ "$attempt" = 5 ]; then + echo "::error::download of $1 failed on all 5 attempts" + return 1 + fi + echo "::warning::download of $1 failed (attempt $attempt); retrying" + sleep $((10 * attempt)) + done + } + versions="$MATRIX_BUN" + if [ -n "$VERSIONS_OVERRIDE" ]; then versions="$VERSIONS_OVERRIDE"; fi + for version in $versions; do + base="https://github.com/oven-sh/bun/releases/download/bun-v${version}" + dir="native-bun/tools/${version}" + mkdir -p "$dir" + fetch "${base}/SHASUMS256.txt" "${dir}/SHASUMS256.txt" + fetch "${base}/${asset}.zip" "${dir}/${asset}.zip" + expected="$(awk -v name="${asset}.zip" '{ sub(/\r$/, "") } $2 == name { print $1 }' "${dir}/SHASUMS256.txt")" + if [ -z "$expected" ]; then + echo "::error::${asset}.zip is not listed in SHASUMS256.txt for bun-v${version}" + exit 1 + fi + python3 - "${dir}/${asset}.zip" "$expected" "$dir" <<'PY' + import hashlib, pathlib, sys, zipfile + archive, expected, dest = pathlib.Path(sys.argv[1]), sys.argv[2], pathlib.Path(sys.argv[3]) + actual = hashlib.sha256(archive.read_bytes()).hexdigest() + if actual != expected: + sys.exit(f'::error::SHA-256 mismatch for {archive}: expected {expected}, got {actual}') + with zipfile.ZipFile(archive) as zipped: + zipped.extractall(dest) + archive.unlink() + print(f'{archive.name} sha256 {actual} verified') + PY + binary="${dir}/${asset}/bun" + if [ "$system" = windows ]; then binary="${binary}.exe"; fi + chmod +x "$binary" || true + actual="$("$binary" --version)" + if [ "$actual" != "$version" ]; then + echo "::error::expected bun ${version} at ${binary}, got ${actual}" + exit 1 + fi + done + - name: Install, verify patched bytes, reject corruption, and roll back shell: bash + env: + MATRIX_BUN: ${{ matrix.bun }} + # Provenance for the captures the depscan SBOM fixtures import: + # `cliRevision` is the branch-resolvable head commit (PR head, or the + # pushed commit on main); CLI_BUILD_SHA is the commit actions/checkout + # actually built (refs/pull/N/merge on PRs) — they diverge once main + # advances past the PR's merge-base, so the script gets both. + CLI_REVISION: ${{ github.event.pull_request.head.sha || github.sha }} + CLI_BUILD_SHA: ${{ github.sha }} + VERSIONS_OVERRIDE: ${{ github.event.inputs.versions }} + SHAPES_OVERRIDE: ${{ github.event.inputs.shapes }} + MODES_OVERRIDE: ${{ github.event.inputs.modes }} run: | - chmod +x native-cli/socket-patch* - python scripts/backtest-bun.py --cli "native-cli/socket-patch${{ runner.os == 'Windows' && '.exe' || '' }}" --cli-revision "${{ github.event.pull_request.head.sha || github.sha }}" --output native-bun --versions '${{ matrix.bun }}' --modes hosted vendored vendored-detached --jobs 3 - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + chmod +x native-cli/socket-patch* || true + cli="native-cli/socket-patch" + if [ "$RUNNER_OS" = "Windows" ]; then cli="native-cli/socket-patch.exe"; fi + versions="$MATRIX_BUN" + if [ -n "$VERSIONS_OVERRIDE" ]; then versions="$VERSIONS_OVERRIDE"; fi + modes="hosted vendored vendored-detached" + if [ -n "$MODES_OVERRIDE" ]; then modes="$MODES_OVERRIDE"; fi + shapes_arg=() + if [ -n "$SHAPES_OVERRIDE" ]; then shapes_arg=(--shapes $SHAPES_OVERRIDE); fi + python3 scripts/backtest-bun.py \ + --cli "$cli" \ + --cli-revision "$CLI_REVISION" \ + --output native-bun \ + --tools native-bun/tools \ + --versions $versions \ + --modes $modes \ + "${shapes_arg[@]}" \ + --jobs 3 + + - name: Upload results + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: always() with: name: bun-results-${{ matrix.os }}-${{ matrix.bun }} From 55e2124ad01bef8eff5b9f49531017b8d72c60ec Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 21 Sep 2026 10:31:19 -0400 Subject: [PATCH 26/49] test(bun): un-ignore the already-vendored workspace re-run now the gate is instance-scoped Lane B2 wrote this scenario against the pre-fix engine, where vendor_bun applied the workspace gate before classifying the in-sync tuple, and parked it behind #[ignore]. With the gate now evaluated per classified instance the re-run reports the documented `skipped`/`already_vendored` event; assert that shape (action `skipped`, errorCode `already_vendored`) instead of a bare `already_vendored` action that the CLI never emits. Co-Authored-By: Claude Fable 5.1 --- .../tests/in_process_vendor_bun.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun.rs index 61218825..5175a619 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_bun.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun.rs @@ -1067,12 +1067,11 @@ async fn already_vendored_v1_workspace_rerun_download_phase_is_skipped_not_refus } /// The full in-sync re-run on the upgraded workspace project: exit 0, the -/// download phase `skipped`, the vendor step `already_vendored`. Needs the -/// engine-side ordering fix (vendor_bun must classify the in-sync tuple -/// before applying the workspace gate — lane B1); until that lands the -/// vendor step still refuses with `vendor_bun_workspace_unsupported`. +/// download phase `skipped` (the CLI exempts already-vendored purls from the +/// Bun preflight), the vendor step a `skipped`/`already_vendored` event — +/// `vendor_bun` classifies the in-sync tuple BEFORE applying the workspace +/// gate, so a pre-1.4 workspace project vendored earlier keeps working. #[tokio::test] -#[ignore = "needs lane B1: vendor_bun applies the workspace gate before the in-sync classification"] async fn already_vendored_v1_workspace_rerun_is_already_vendored_exit_zero() { let mock = MockServer::start().await; mount_patch_api(&mock).await; @@ -1086,9 +1085,9 @@ async fn already_vendored_v1_workspace_rerun_is_already_vendored_exit_zero() { assert_eq!(v["download"]["patches"][0]["action"], "skipped", "{v}"); let events = v["vendor"]["events"].as_array().unwrap(); assert!( - events - .iter() - .any(|e| e["purl"] == PURL && e["action"] == "already_vendored"), + events.iter().any(|e| e["purl"] == PURL + && e["action"] == "skipped" + && e["errorCode"] == "already_vendored"), "{v}" ); assert_eq!(lock_bytes(tmp.path()), lock_before); From 4562de5f52c0eb2b1134b2833c5c187d01e06d75 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 21 Sep 2026 10:48:48 -0400 Subject: [PATCH 27/49] test(bun): real-bun hosted<->vendored mode-migration suite (takeover, dry-run parity, scoped unwind, rollback) Adds crates/socket-patch-cli/tests/mode_migration_bun.rs, the bun twin of mode_migration_npm.rs (yarn) and mode_migration_cargo.rs: a two-dep project (left-pad@1.3.0 patched, is-number@7.0.0 bystander / second record) installed by REAL `bun install --ignore-scripts` (`--save-text-lockfile` below 1.2.0), private BUN_INSTALL + cache per project, wiremock patch API, patched tarballs built from the installed bytes. The native lockfileVersion is asserted against the era table (0 for 1.1.39-1.1.x, 1 for 1.2-1.3, 2 for 1.4+), and every terminal state ends with a fresh checkout's `bun install --frozen-lockfile` from an EMPTY cache proving the bytes the lock claims. 1. vendored -> hosted: `redirect_takeover_reverted_vendored`, vendored ledger entry + artifact gone, URL 3-tuple line, redirect-ledger `original` == the PRISTINE registry line, marker bytes installed; `rollback` -> pristine bytes, original bytes installed. 2. hosted -> vendored via BOTH `vendor --offline` and `scan --mode vendored` (copies of one hosted project): `vendor_takeover_reverted_redirect`, redirect record + edit dropped, local `.socket/vendor/npm//` 3-tuple, vendor-ledger `original` == pristine line, marker bytes installed, re-run `already_vendored`; `vendor --revert` -> pristine. 3. dry-run parity: `vendor --dry-run` previews `vendor_would_revert_redirect` (no `vendor_lock_entry_not_found`, no `redirect_revert_failed`), `scan --mode vendored --dry-run` classifies `would_vendor` (never `would_refuse`), `scan --mode hosted --dry-run` over a vendored state previews `redirect_would_revert_vendored`; a whole-tree snapshot proves none of them writes a byte; the wet runs land the previewed takeovers. 4. two hosted records in one scan; scoped `rollback ` and `remove ` (per-purl path) unwind only that line/record/edit, the sibling stays hosted, a fresh install lands a's original + b's marker bytes, then the unscoped rollback restores pristine. 5. unscoped `rollback` from each mixed state restores pristine bytes and leaves no vendor artifacts or ledgers. Gates mirror the two bun capstones: soft-skip without bun unless SOCKET_PATCH_BUN_E2E_REQUIRED is set and non-empty (then hard failure), and SOCKET_PATCH_BUN_E2E_VERSION must equal `bun --version`. Verified green (10/10 each) against real bun 1.4.2 (v2), 1.3.14 (v1) and 1.1.45 (v0); the CI legs for this suite were added by the ci.yml e2e matrix already. Co-Authored-By: Claude Fable 5.1 --- .../tests/mode_migration_bun.rs | 1790 +++++++++++++++++ 1 file changed, 1790 insertions(+) create mode 100644 crates/socket-patch-cli/tests/mode_migration_bun.rs diff --git a/crates/socket-patch-cli/tests/mode_migration_bun.rs b/crates/socket-patch-cli/tests/mode_migration_bun.rs new file mode 100644 index 00000000..a5239944 --- /dev/null +++ b/crates/socket-patch-cli/tests/mode_migration_bun.rs @@ -0,0 +1,1790 @@ +//! Real-bun mode-migration e2e: hosted ⇄ vendored takeovers on a bun +//! (text `bun.lock`) project must leave the project FULLY in the new mode, +//! preview exactly what the wet run does, and unwind — scoped or whole — +//! back to the pristine registry lock. +//! +//! Twin of `mode_migration_npm.rs` (yarn classic + berry) and +//! `mode_migration_cargo.rs` for the one npm-family lock flavor whose +//! hosted rewrite REPLACES the entry's `name@version` spec: the registry +//! 4-tuple `["left-pad@1.3.0", "", {}, "sha512-…"]` becomes the URL 3-tuple +//! `["left-pad@https://…/left-pad-1.3.0.tgz", {}, "sha512-…"]`, so the +//! per-purl revert cannot key the edit by `name@version` the way the +//! yarn/pnpm/package-lock reverts do. Until the per-purl bun claim landed, +//! `vendor` / `scan --mode vendored` over a live hosted bun redirect was a +//! hard `redirect_revert_failed` refusal (with a circular remedy), a +//! scoped `rollback ` / `remove ` on a project holding two +//! hosted records failed the same way, and `vendor --dry-run` promised a +//! takeover the wet run refused. The hermetic (no-bun) contract twin is +//! `in_process_vendor_bun_takeover.rs`; THIS suite proves the same +//! contract against REAL bun, ending every terminal state with the proof +//! that matters — a fresh checkout's `bun install --frozen-lockfile` from +//! an EMPTY cache materializes the bytes the lock claims. +//! +//! Fixture: `package.json` with two real registry deps, `left-pad@1.3.0` +//! (the patched target) and `is-number@7.0.0` (dependency-free; the +//! untouched bystander in the single-patch legs, the second hosted record +//! in the scoped-unwind leg), installed by REAL `bun install +//! --ignore-scripts` (network for the fixture install only; a private +//! `BUN_INSTALL` + `BUN_INSTALL_CACHE_DIR` per project). The text lock is +//! the default from bun 1.2.0 (lockfileVersion 1; 2 from 1.4.0); on +//! 1.1.39–1.1.x the fixture passes the `--save-text-lockfile` opt-in +//! (lockfileVersion 0). The version bun wrote is ASSERTED against that +//! era table, so a lock-era CI leg proves the era it claims. Registry +//! 4-tuples are one emitted grammar across 0/1/2, so everything after the +//! fixture guard is version-independent. Patched tarballs are built from +//! the installed bytes (marker comment prepended to `index.js`) exactly +//! like the yarn twin; the patch API is wiremock (the bun rewriter needs +//! `artifacts[kind=tarball].integrity.sha512` from the grant, and the +//! `view/{uuid}` route carries `blobContent` so `scan --mode vendored` can +//! stage the patched content). +//! +//! Scenarios: +//! 1. vendored → hosted (`vendor --offline`, then `scan --mode hosted`): +//! `redirect_takeover_reverted_vendored`, vendored ledger entry + +//! committed artifact gone, bun.lock = the hosted URL 3-tuple with no +//! `.socket/vendor/` residue, the redirect ledger's `original` is the +//! PRISTINE registry line (originals chain intact across migrations), +//! fresh frozen install → marker bytes; `rollback` → pristine bytes, +//! no vendor artifacts or ledgers, fresh install → original bytes. +//! 2. hosted → vendored, BOTH drivers on copies of one hosted project: +//! `vendor --offline` (staged manifest) and `scan --mode vendored`: +//! `vendor_takeover_reverted_redirect`, redirect ledger record + edit +//! dropped (file removed when emptied), bun.lock carries the local +//! `.socket/vendor/npm//` 3-tuple and not the hosted URL, the +//! vendor ledger's `original` is the pristine registry line, fresh +//! frozen install → marker bytes; `vendor --revert` → pristine bytes. +//! 3. dry-run parity: over a live hosted redirect `vendor --dry-run` +//! previews the takeover (`vendor_would_revert_redirect`, no false +//! `vendor_lock_entry_not_found`, no refusal) and `scan --mode +//! vendored --dry-run` classifies `would_vendor` (never `would_refuse`); +//! over a live vendored state `scan --mode hosted --dry-run` previews +//! `redirect_would_revert_vendored`; none of the previews writes a +//! byte (bun.lock, both ledgers, every file under `.socket/`), and the +//! wet runs then land exactly the takeovers previewed. +//! 4. two hosted records in ONE scan; scoped `rollback ` and, on +//! a fresh copy, `remove ` (per-purl path — the whole-ledger +//! replay is not eligible) unwind ONLY a's line/record/edit; a fresh +//! frozen install lands a's ORIGINAL bytes and b's MARKER bytes; the +//! unscoped `rollback` that follows restores the pristine lock. +//! 5. unscoped `rollback` from each mixed state — after (1) and after +//! (2) — restores bun.lock byte-exactly, leaves no `.socket/vendor/` +//! artifacts or ledgers, and a fresh install reproduces the original +//! bytes. +//! +//! Gates (identical to `e2e_redirect_bun_build.rs` / `e2e_vendor_bun_build.rs`): +//! without `SOCKET_PATCH_BUN_E2E_REQUIRED` (set AND non-empty — CI passes +//! an empty string for non-bun legs) a missing `bun`, a failed fixture +//! install or a bun without a text lockfile is a `println` SKIP and every +//! assertion after that is HARD. With it, those skips become hard +//! failures, and `SOCKET_PATCH_BUN_E2E_VERSION` (when set, non-empty) must +//! equal `bun --version`, so a CI leg cannot pass by running the wrong bun +//! or no bun at all. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +use serde_json::{json, Value}; +use sha2::{Digest, Sha512}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[path = "common/cache_env.rs"] +mod cache_env; + +const SUITE: &str = "mode_migration_bun"; +const ORG: &str = "test-org"; +const TOKEN: &str = "33333333-3333-4333-8333-333333333333"; +const MARKER: &str = "/* SOCKET-PATCHED */\n"; +const GHSA: &str = "GHSA-migr-bun-test"; + +/// A fixture dependency and the two patch identities the legs use for it. +struct Dep { + name: &'static str, + version: &'static str, + purl: &'static str, + /// Vendored patch uuid: the manifest record `vendor --offline` acts on + /// (the `.socket/vendor/npm//` path level of a manifest-driven + /// vendor). + uuid_v: &'static str, + /// Hosted patch uuid: what the API mocks discover and grant (embedded in + /// the hosted artifact URL), and therefore also the uuid a + /// `scan --mode vendored` download records and vendors under. + uuid_h: &'static str, +} + +/// The patched target of every leg. +const DEP_A: Dep = Dep { + name: "left-pad", + version: "1.3.0", + purl: "pkg:npm/left-pad@1.3.0", + uuid_v: "3c4d5e6f-7a8b-4c1d-8e2f-0123456789ab", + uuid_h: "8d9e0f1a-2b3c-4d4e-8f5a-6b7c8d9e0f1a", +}; +/// The bystander (single-patch legs) / second hosted record (scoped leg). +const DEP_B: Dep = Dep { + name: "is-number", + version: "7.0.0", + purl: "pkg:npm/is-number@7.0.0", + uuid_v: "4d5e6f7a-8b9c-4d2e-9f3a-123456789abc", + uuid_h: "9e0f1a2b-3c4d-4e5f-9a6b-7c8d9e0f1a2b", +}; + +impl Dep { + /// `-.tgz` — the artifact leaf on the hosted URL and + /// under the vendored dir. + fn tgz_leaf(&self) -> String { + format!("{}-{}.tgz", self.name, self.version) + } + /// The hosted tarball path on the mock patch server. + fn hosted_path(&self) -> String { + format!( + "/patch/npm/{}/{}/{TOKEN}/{}/{}", + self.name, + self.version, + self.uuid_h, + self.tgz_leaf() + ) + } + fn hosted_url(&self, server_uri: &str) -> String { + format!("{server_uri}{}", self.hosted_path()) + } + /// The lock-relative local tarball path the vendored rewrite writes. + fn vendored_rel(&self, uuid: &str) -> String { + format!(".socket/vendor/npm/{uuid}/{}", self.tgz_leaf()) + } + fn installed_dir(&self, proj: &Path) -> PathBuf { + proj.join("node_modules").join(self.name) + } + /// The URL 3-tuple line the hosted rewriter writes for this dep (bun's + /// `{}` meta for a dependency-free package; 4-space indent, trailing + /// comma — the packages-entry grammar on lockfileVersion 0/1/2). + fn hosted_line(&self, hosted_url: &str, sri: &str) -> String { + format!( + " \"{}\": [\"{}@{hosted_url}\", {{}}, \"{sri}\"],", + self.name, self.name + ) + } +} + +/// `(major, minor, patch)` of the bun on PATH. +type BunVersion = (u64, u64, u64); + +/// First bun with a text lockfile (`--save-text-lockfile` opt-in, +/// lockfileVersion 0). Older bun writes only the binary `bun.lockb`. +const TEXT_LOCK_FROM: BunVersion = (1, 1, 39); +/// Text lock becomes the default and bumps to lockfileVersion 1. +const LOCK_V1_FROM: BunVersion = (1, 2, 0); +/// lockfileVersion 2. +const LOCK_V2_FROM: BunVersion = (1, 4, 0); + +// ── toolchain gate (shared semantics with the two bun capstones) ────────── + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_socket-patch")) +} + +/// The REQUIRED gate: set AND non-empty. CI's e2e matrix passes +/// `SOCKET_PATCH_BUN_E2E_REQUIRED: ${{ matrix.bun != '' && '1' || '' }}`, +/// so an empty value is the non-bun legs' "unset" — an `is_some()` gate +/// would turn every non-bun leg red. +fn bun_required() -> bool { + std::env::var_os("SOCKET_PATCH_BUN_E2E_REQUIRED").is_some_and(|v| !v.is_empty()) +} + +/// The exact bun the matrix leg pinned, when it pinned one. +fn pinned_bun_version() -> Option { + std::env::var("SOCKET_PATCH_BUN_E2E_VERSION") + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +/// `1.4.2` → `(1, 4, 2)`; a canary suffix (`1.4.3-canary.12+abc`) is cut at +/// the first `-`/`+`. `None` for anything that is not three integers. +fn parse_bun_version(raw: &str) -> Option { + let core = raw.trim().split(['-', '+']).next()?; + let mut parts = core.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next()?.parse().ok()?; + if parts.next().is_some() { + return None; + } + Some((major, minor, patch)) +} + +/// `bun --version` through the cache sandbox: `Some(trimmed stdout)` when +/// bun ran and exited 0, `None` when it is not on PATH (or cannot start). +fn bun_version_output() -> Option { + let mut probe = Command::new("bun"); + probe.arg("--version"); + scrub_env(&mut probe); + cache_env::isolate(&mut probe); + let out = probe.stderr(Stdio::null()).output().ok()?; + out.status + .success() + .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +/// The lockfileVersion the era table says this bun writes for a FRESH +/// install: 1.1.39–1.1.x opt-in text lock → 0, 1.2–1.3 → 1, ≥ 1.4 → 2. +fn expected_lock_version(v: BunVersion) -> u64 { + if v >= LOCK_V2_FROM { + 2 + } else if v >= LOCK_V1_FROM { + 1 + } else { + 0 + } +} + +/// The fixture's `bun install` argv: lifecycle scripts never run (hygiene — +/// neither dep has any, but the fixture is a REAL registry install), and +/// `--save-text-lockfile` is passed only where the text lock is still an +/// opt-in (< 1.2.0), so newer bun is exercised exactly as users run it. +fn fixture_install_args(v: BunVersion) -> Vec<&'static str> { + let mut args = vec!["install", "--ignore-scripts"]; + if v < LOCK_V1_FROM { + args.push("--save-text-lockfile"); + } + args +} + +/// `"lockfileVersion": ` from the lock head — the same head scan as +/// `socket_patch_core::vendor::bun_lock_text::lock_version` (pub(crate) +/// there, so mirrored here). +fn lock_version(text: &str) -> Option { + text.lines() + .take(5) + .find_map(|line| line.trim().strip_prefix("\"lockfileVersion\":")) + .and_then(|rest| rest.trim().trim_end_matches(',').parse().ok()) +} + +/// The toolchain preflight every leg runs first: bun present, pinned +/// version honored, text lockfile available. `None` = this leg is skipped +/// (already reported with a println) — but under the REQUIRED gate every +/// one of those is a hard failure instead, because a CI leg that silently +/// skips is exactly the vacuous pass the bun suites had for months. +fn bun_toolchain(tag: &str) -> Option<(String, BunVersion)> { + let Some(raw) = bun_version_output() else { + assert!( + !bun_required(), + "SOCKET_PATCH_BUN_E2E_REQUIRED is set but `bun --version` did not run — \ + the matrix leg must install bun before running this suite" + ); + println!("SKIP {SUITE} ({tag}): `bun` not installed"); + return None; + }; + if let Some(pin) = pinned_bun_version() { + assert_eq!( + raw, pin, + "SOCKET_PATCH_BUN_E2E_VERSION pins bun {pin} but PATH resolves bun {raw}: the \ + matrix must run the pinned version" + ); + } + let Some(version) = parse_bun_version(&raw) else { + assert!( + !bun_required(), + "required bun toolchain reports an unparsable version {raw:?}" + ); + println!("SKIP {SUITE} ({tag}): unparsable `bun --version` output {raw:?}"); + return None; + }; + if version < TEXT_LOCK_FROM { + assert!( + !bun_required(), + "bun {raw} has no text lockfile (the `--save-text-lockfile` opt-in exists from \ + 1.1.39); a REQUIRED leg must not be scheduled on it" + ); + println!("SKIP {SUITE} ({tag}): bun {raw} predates the text bun.lock (1.1.39)"); + return None; + } + Some((raw, version)) +} + +// ── process helpers ──────────────────────────────────────────────────────── + +/// Remove ambient `SOCKET_*` (except the hermetic `SOCKET_NO_CONFIG`), +/// every `BUN_*` var (the harness passes bun's install/cache dirs +/// explicitly per project) and `npm_config_*` (bun reads npm's registry +/// config; an ambient mirror or auth token would change what the fixture +/// install resolves against). +fn scrub_env(cmd: &mut Command) { + for (k, _) in std::env::vars_os() { + let key = k.to_string_lossy(); + if (key.starts_with("SOCKET_") && key != "SOCKET_NO_CONFIG") + || key.starts_with("BUN_") + || key.to_ascii_lowercase().starts_with("npm_config_") + { + cmd.env_remove(&k); + } + } + cmd.env_remove("VIRTUAL_ENV"); +} + +/// Run `bun ` in `cwd` with a PRIVATE `BUN_INSTALL` + cache under +/// `bun_home` (created here; a brand-new dir per call site is what makes +/// the fresh-checkout legs' "empty cache" premise true), the shared cache +/// sandbox for everything else bun keeps outside those dirs, and the +/// ambient env scrubbed. Scrub BEFORE seeding: `Command`'s last env call +/// for a name wins. +fn bun(cwd: &Path, args: &[&str], bun_home: &Path) -> Output { + let cache = bun_home.join("cache"); + let install = bun_home.join("install"); + std::fs::create_dir_all(&cache).unwrap(); + std::fs::create_dir_all(&install).unwrap(); + let mut cmd = Command::new("bun"); + cmd.args(args).current_dir(cwd); + scrub_env(&mut cmd); + cache_env::isolate(&mut cmd); + cmd.env("BUN_INSTALL", &install) + .env("BUN_INSTALL_CACHE_DIR", &cache); + cmd.output().expect("failed to run bun") +} + +/// The real binary with `--no-telemetry` appended: nothing in this suite +/// should ever post a telemetry event, mocked API or not. +fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let mut cmd = Command::new(binary()); + cmd.args(args).arg("--no-telemetry").current_dir(cwd); + scrub_env(&mut cmd); + let out = cmd.output().expect("failed to run socket-patch binary"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +/// Parse a `--json` envelope, or fail with the raw output attached. +fn envelope(stdout: &str, stderr: &str) -> Value { + serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("--json output is not a JSON envelope: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}") + }) +} + +fn hosted_scan(proj: &Path, api: &str, extra: &[&str]) -> (i32, String, String) { + let mut args = vec![ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--cwd", + proj.to_str().unwrap(), + "--api-url", + api, + "--org", + ORG, + "--api-token", + "fake", + ]; + args.extend_from_slice(extra); + run_socket(proj, &args) +} + +/// `scan --mode vendored` builds the artifact locally (`--vendor-source +/// build`): no vendoring-service round trip, so the only network is the +/// wiremock patch API. +fn vendored_scan(proj: &Path, api: &str, extra: &[&str]) -> (i32, String, String) { + let mut args = vec![ + "scan", + "--mode", + "vendored", + "--json", + "--yes", + "--cwd", + proj.to_str().unwrap(), + "--api-url", + api, + "--org", + ORG, + "--api-token", + "fake", + "--vendor-source", + "build", + ]; + args.extend_from_slice(extra); + run_socket(proj, &args) +} + +/// `vendor --json --offline` (+ extra) over the staged manifest. +fn vendor_cmd(proj: &Path, extra: &[&str]) -> (i32, String, String) { + let mut args = vec![ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ]; + args.extend_from_slice(extra); + run_socket(proj, &args) +} + +/// `rollback [targets…] --yes --json`. +fn rollback_cmd(proj: &Path, targets: &[&str]) -> (i32, String, String) { + let mut args = vec!["rollback"]; + args.extend_from_slice(targets); + args.extend_from_slice(&["--yes", "--json", "--cwd", proj.to_str().unwrap()]); + run_socket(proj, &args) +} + +// ── bytes / files ────────────────────────────────────────────────────────── + +fn sri(bytes: &[u8]) -> String { + use base64::Engine as _; + format!( + "sha512-{}", + base64::engine::general_purpose::STANDARD.encode(Sha512::digest(bytes)) + ) +} + +fn b64(bytes: &[u8]) -> String { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +fn read(proj: &Path, rel: &str) -> String { + std::fs::read_to_string(proj.join(rel)).unwrap_or_default() +} + +fn read_json(proj: &Path, rel: &str) -> Value { + let text = std::fs::read_to_string(proj.join(rel)) + .unwrap_or_else(|e| panic!("read {rel} under {}: {e}", proj.display())); + serde_json::from_str(&text).unwrap_or_else(|e| panic!("{rel} is not JSON: {e}\n{text}")) +} + +fn copy_dir_recursive(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).unwrap(); + for entry in std::fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let to = dst.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_dir_recursive(&entry.path(), &to); + } else { + std::fs::copy(entry.path(), &to).unwrap(); + } + } +} + +/// Every regular file under `root` (relative `/`-joined path → bytes), +/// `node_modules` excluded — the write-free oracle for the dry-run legs. +/// `.socket/apply.lock` is excluded too: it is the apply lock's flock +/// target, created by every run that takes the lock (dry runs included, +/// since they read state a concurrent wet run could be mutating) and left +/// in place by design — a lock file, not project state. +fn snapshot(root: &Path) -> BTreeMap> { + fn walk(root: &Path, dir: &Path, out: &mut BTreeMap>) { + for entry in std::fs::read_dir(dir).unwrap() { + let p = entry.unwrap().path(); + if p.is_dir() { + if p.file_name().is_some_and(|n| n == "node_modules") { + continue; + } + walk(root, &p, out); + } else { + let rel = p + .strip_prefix(root) + .unwrap() + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect::>() + .join("/"); + if rel == ".socket/apply.lock" { + continue; + } + out.insert(rel, std::fs::read(&p).unwrap()); + } + } + } + let mut out = BTreeMap::new(); + walk(root, root, &mut out); + out +} + +fn assert_unchanged(before: &BTreeMap>, proj: &Path, what: &str) { + let after = snapshot(proj); + let added: Vec<&String> = after.keys().filter(|k| !before.contains_key(*k)).collect(); + let removed: Vec<&String> = before.keys().filter(|k| !after.contains_key(*k)).collect(); + let changed: Vec<&String> = before + .iter() + .filter(|(k, v)| after.get(*k).is_some_and(|a| a != *v)) + .map(|(k, _)| k) + .collect(); + assert!( + added.is_empty() && removed.is_empty() && changed.is_empty(), + "{what} must not write a byte: added {added:?}, removed {removed:?}, changed {changed:?}" + ); +} + +/// A gzipped npm tarball (`package/` prefix) built from the ACTUALLY +/// installed package with `index.js` swapped for `replaced_index`. Built +/// with the tar crate — no system `tar`, so Windows runners need nothing — +/// and file modes travel as installed (0o644/0o755 where the host has no +/// mode). +fn make_tgz_from_installed(pkg_dir: &Path, replaced_index: &[u8]) -> Vec { + let pkg_dir = pkg_dir + .canonicalize() + .expect("installed package dir must resolve"); + let mut files: Vec = Vec::new(); + let mut stack = vec![pkg_dir.clone()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).unwrap() { + let p = entry.unwrap().path(); + if p.is_dir() { + stack.push(p); + } else { + files.push(p); + } + } + } + files.sort(); + let mut builder = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::default(), + )); + for p in &files { + let rel = p.strip_prefix(&pkg_dir).unwrap(); + let name = rel + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect::>() + .join("/"); + let bytes = if rel == Path::new("index.js") { + replaced_index.to_vec() + } else { + std::fs::read(p).unwrap() + }; + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(file_mode(p, &name)); + header.set_mtime(0); + header.set_cksum(); + builder + .append_data(&mut header, format!("package/{name}"), bytes.as_slice()) + .unwrap(); + } + builder.into_inner().unwrap().finish().unwrap() +} + +#[cfg(unix)] +fn file_mode(p: &Path, _name: &str) -> u32 { + use std::os::unix::fs::PermissionsExt as _; + std::fs::metadata(p).unwrap().permissions().mode() & 0o777 +} + +#[cfg(not(unix))] +fn file_mode(_p: &Path, name: &str) -> u32 { + if name.starts_with("bin/") { + 0o755 + } else { + 0o644 + } +} + +/// The `packages` line for `name` in a bun.lock (` "name": [...]`, +/// verbatim, no line terminator). +fn packages_line(lock: &str, name: &str) -> String { + let key = format!("\"{name}\": ["); + lock.lines() + .find(|l| l.trim_start().starts_with(&key)) + .unwrap_or_else(|| panic!("no packages entry for {name} in:\n{lock}")) + .to_string() +} + +/// The redirect ledger's `redirect_bun_lock_package` edit keyed by the +/// lock's package-map key (`name`), if any. +fn ledger_edit_for(ledger: &Value, name: &str) -> Option { + ledger["edits"].as_array().and_then(|edits| { + edits + .iter() + .find(|e| e["kind"] == "redirect_bun_lock_package" && e["key"] == name) + .cloned() + }) +} + +fn warning_codes(v: &Value) -> Vec { + v.as_array() + .map(|w| { + w.iter() + .filter_map(|x| x["code"].as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +fn event_codes(v: &Value) -> Vec { + v["events"] + .as_array() + .map(|evs| { + evs.iter() + .filter_map(|e| e["errorCode"].as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +// ── fixture ──────────────────────────────────────────────────────────────── + +/// The pristine and marker-patched `index.js` of one installed dep. +struct DepBytes { + orig: Vec, + patched: Vec, +} + +/// The two-dep fixture project after a REAL `bun install`, plus the +/// pristine snapshots every unwind assertion diffs against. +struct Fixture { + tmp: tempfile::TempDir, + proj: PathBuf, + bun_raw: String, + lock_version: u64, + lock_pristine: Vec, + pkg_json_pristine: Vec, + a: DepBytes, + b: DepBytes, +} + +impl Fixture { + fn lock_pristine_str(&self) -> String { + String::from_utf8(self.lock_pristine.clone()).expect("bun.lock is UTF-8") + } + /// The pristine registry 4-tuple line for `dep`, verbatim. + fn pristine_line(&self, dep: &Dep) -> String { + packages_line(&self.lock_pristine_str(), dep.name) + } + fn bytes(&self, dep: &Dep) -> &DepBytes { + if dep.name == DEP_A.name { + &self.a + } else { + &self.b + } + } + /// A fresh dir under the fixture tempdir. + fn dir(&self, name: &str) -> PathBuf { + let d = self.tmp.path().join(name); + std::fs::create_dir_all(&d).unwrap(); + d + } + /// The patched tarball for `dep`, from the fixture's installed copy. + fn patched_tgz(&self, dep: &Dep) -> Vec { + make_tgz_from_installed(&dep.installed_dir(&self.proj), &self.bytes(dep).patched) + } +} + +/// package.json + real install + era assertions. `None` = skip (already +/// reported), or a hard failure under the REQUIRED gate. +fn stage_fixture(tag: &str) -> Option { + let (bun_raw, bun_version) = bun_toolchain(tag)?; + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + let pkg_json = format!( + r#"{{"name":"mode-migration-bun","version":"0.0.0","private":true,"dependencies":{{"{}":"{}","{}":"{}"}}}}"#, + DEP_A.name, DEP_A.version, DEP_B.name, DEP_B.version + ); + std::fs::write(proj.join("package.json"), &pkg_json).unwrap(); + + let bun_home = tmp.path().join("fixture-bun-home"); + let install = bun(&proj, &fixture_install_args(bun_version), &bun_home); + if !install.status.success() { + assert!( + !bun_required(), + "required bun {bun_raw} fixture `bun install` failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&install.stdout), + String::from_utf8_lossy(&install.stderr) + ); + println!( + "SKIP {SUITE} ({tag}): fixture `bun install` failed (registry unreachable?):\n{}", + String::from_utf8_lossy(&install.stderr) + ); + return None; + } + let lock_path = proj.join("bun.lock"); + if !lock_path.is_file() { + assert!( + !bun_required(), + "required bun {bun_raw} produced no text bun.lock after {:?}", + fixture_install_args(bun_version) + ); + println!("SKIP {SUITE} ({tag}): bun produced no text bun.lock (binary lockfile?)"); + return None; + } + // Hermeticity guard: the install must have gone through the PRIVATE + // cache, or the fresh-checkout "empty cache" premise below is void. + let cache = bun_home.join("cache"); + assert!( + cache.is_dir() && std::fs::read_dir(&cache).unwrap().next().is_some(), + "fixture install did not populate the private BUN_INSTALL_CACHE_DIR at {}", + cache.display() + ); + + let lock_pristine = std::fs::read(&lock_path).unwrap(); + let lock_text = String::from_utf8(lock_pristine.clone()).expect("bun.lock is UTF-8"); + // The era table, asserted rather than assumed — pinning the mapping is + // what makes a lock-era CI leg prove the era it claims to cover. + let native_version = lock_version(&lock_text).unwrap_or_else(|| { + panic!("fixture bun.lock has no integer lockfileVersion in its head:\n{lock_text}") + }); + assert_eq!( + native_version, + expected_lock_version(bun_version), + "bun {bun_raw} wrote lockfileVersion {native_version}; the era table expects {} \ + (1.1.39–1.1.x → 0, 1.2–1.3 → 1, ≥ 1.4 → 2):\n{lock_text}", + expected_lock_version(bun_version) + ); + let mut bytes: Vec = Vec::new(); + for dep in [&DEP_A, &DEP_B] { + // Pre-migration: the registry 4-tuple with bun's default-registry + // `""` field and `{}` meta — one spelling across 0/1/2. + let head = format!("\"{}@{}\", \"\", {{}}, \"sha512-", dep.name, dep.version); + assert!( + packages_line(&lock_text, dep.name).contains(&head), + "pristine packages entry for {} must be the registry 4-tuple {head}…:\n{lock_text}", + dep.name + ); + let orig = std::fs::read(dep.installed_dir(&proj).join("index.js")) + .unwrap_or_else(|e| panic!("installed {}/index.js: {e}", dep.name)); + assert!( + !orig.starts_with(MARKER.as_bytes()), + "pristine install of {} must not carry the marker", + dep.name + ); + let patched = [MARKER.as_bytes(), orig.as_slice()].concat(); + bytes.push(DepBytes { orig, patched }); + } + let b = bytes.pop().unwrap(); + let a = bytes.pop().unwrap(); + eprintln!("FIXTURE OK (bun {bun_raw}, lockfileVersion {native_version}, {tag})"); + Some(Fixture { + tmp, + proj, + bun_raw, + lock_version: native_version, + lock_pristine, + pkg_json_pristine: pkg_json.into_bytes(), + a, + b, + }) +} + +/// Copy a WORKING project (committable files + the installed tree) so two +/// drivers can start from one identical state without a second registry +/// install. +fn copy_project(from: &Path, to: &Path) { + std::fs::create_dir_all(to).unwrap(); + std::fs::copy(from.join("package.json"), to.join("package.json")).unwrap(); + std::fs::copy(from.join("bun.lock"), to.join("bun.lock")).unwrap(); + if from.join(".socket").is_dir() { + copy_dir_recursive(&from.join(".socket"), &to.join(".socket")); + } + copy_dir_recursive(&from.join("node_modules"), &to.join("node_modules")); +} + +/// ONLY the committable files (package.json, bun.lock, `.socket/` when it +/// exists — rollback removes it) into a fresh dir: the fresh-checkout proof. +fn fresh_checkout(from: &Path, to: &Path) { + std::fs::create_dir_all(to).unwrap(); + std::fs::copy(from.join("package.json"), to.join("package.json")).unwrap(); + std::fs::copy(from.join("bun.lock"), to.join("bun.lock")).unwrap(); + if from.join(".socket").is_dir() { + copy_dir_recursive(&from.join(".socket"), &to.join(".socket")); + } +} + +/// Fresh checkout of `proj` named `name` + `bun install --frozen-lockfile +/// --ignore-scripts` against a brand-new (EMPTY) bun home; asserts success +/// and returns the checkout dir so callers probe the installed bytes. +fn fresh_frozen_install(fx: &Fixture, proj: &Path, name: &str) -> PathBuf { + let fresh = fx.tmp.path().join(name); + fresh_checkout(proj, &fresh); + let ci = bun( + &fresh, + &["install", "--frozen-lockfile", "--ignore-scripts"], + &fx.tmp.path().join(format!("{name}-bun-home")), + ); + assert!( + ci.status.success(), + "fresh-checkout `bun install --frozen-lockfile` ({name}) must succeed.\nbun.lock:\n{}\n\ + stdout:\n{}\nstderr:\n{}", + read(&fresh, "bun.lock"), + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr), + ); + fresh +} + +/// The installed `index.js` of `dep` in `root` must be exactly `expected` +/// (the marker-patched or the pristine bytes). +fn assert_installed(root: &Path, dep: &Dep, expected: &[u8], what: &str) { + let installed = std::fs::read(dep.installed_dir(root).join("index.js")) + .unwrap_or_else(|e| panic!("{what}: installed {}/index.js: {e}", dep.name)); + assert_eq!( + installed, + expected, + "{what}: {} must install the {} bytes; got:\n{}", + dep.name, + if expected.starts_with(MARKER.as_bytes()) { + "PATCHED (marker)" + } else { + "ORIGINAL" + }, + String::from_utf8_lossy(&installed[..installed.len().min(120)]) + ); +} + +/// Write `.socket/manifest.json` + the after-hash blob for `dep` at +/// `uuid_v` so `vendor --offline` runs fully offline (npm-family file keys +/// carry the `package/` prefix). Hosted mode writes no manifest — its +/// ledger is its store — so this is the yarn twin's `stage_patch`. +fn stage_manifest(fx: &Fixture, proj: &Path, dep: &Dep) { + let bytes = fx.bytes(dep); + let socket = proj.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let manifest = json!({ + "patches": { dep.purl: { + "uuid": dep.uuid_v, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { "package/index.js": { + "beforeHash": compute_git_sha256_from_bytes(&bytes.orig), + "afterHash": compute_git_sha256_from_bytes(&bytes.patched), + }}, + "vulnerabilities": { GHSA: { + "cves": ["CVE-2026-99999"], + "summary": "migration vuln", "severity": "high", "description": "d", + }}, + "description": "migration patch", "license": "MIT", "tier": "free", + }} + }); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write( + socket + .join("blobs") + .join(compute_git_sha256_from_bytes(&bytes.patched)), + &bytes.patched, + ) + .unwrap(); +} + +/// One hosted patch the mock API serves: its tarball (built from the +/// installed bytes) and the hosted URL it lands on. +struct HostedPatch { + dep: &'static Dep, + tgz: Vec, + url: String, +} + +impl HostedPatch { + fn sri(&self) -> String { + sri(&self.tgz) + } +} + +/// Mount the full hosted-mode mock set for `deps` over one wiremock: +/// discovery (`batch`), the per-package detail query scan runs for EVERY +/// discovered package (`by-package/` — matched per dep on +/// the name inside the encoded purl, so a two-record scan gets each dep's +/// own patch back), the grant (`package`, results for every uuid; the bun +/// rewriter needs `artifacts[kind=tarball].integrity.sha512`), the patch +/// view with `blobContent` (what `scan --mode vendored` stages from), and +/// the hosted tarball routes bun downloads at install time. +async fn mount_hosted_api( + server: &MockServer, + fx: &Fixture, + deps: &[&'static Dep], +) -> Vec { + let patches: Vec = deps + .iter() + .map(|dep| HostedPatch { + dep, + tgz: fx.patched_tgz(dep), + url: dep.hosted_url(&server.uri()), + }) + .collect(); + + let batch_packages: Vec = patches + .iter() + .map(|p| { + json!({ + "purl": p.dep.purl, + "patches": [{ + "uuid": p.dep.uuid_h, "purl": p.dep.purl, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": format!("bun migration fixture {}", p.dep.name) + }] + }) + }) + .collect(); + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "packages": batch_packages, + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + + let mut results = serde_json::Map::new(); + for p in &patches { + let dep = p.dep; + let bytes = fx.bytes(dep); + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.*{}.*$", + dep.name + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "patches": [{ + "uuid": dep.uuid_h, "purl": dep.purl, + "publishedAt": "2026-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + results.insert( + dep.uuid_h.to_string(), + json!({ + "status": "granted", + "url": p.url, + "purl": dep.purl, + "artifacts": [{ + "kind": "tarball", "url": p.url, + "integrity": { "sha512": p.sri() } + }], + "registryOverride": null + }), + ); + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{}", dep.uuid_h))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "uuid": dep.uuid_h, + "purl": dep.purl, + "publishedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": compute_git_sha256_from_bytes(&bytes.orig), + "afterHash": compute_git_sha256_from_bytes(&bytes.patched), + "blobContent": b64(&bytes.patched), + } + }, + "vulnerabilities": { + GHSA: { + "cves": ["CVE-2026-3333"], + "summary": "migration vuln", "severity": "high", "description": "d" + } + }, + "description": "x", "license": "MIT", "tier": "free" + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(dep.hosted_path())) + .respond_with( + ResponseTemplate::new(200).set_body_raw(p.tgz.clone(), "application/octet-stream"), + ) + .mount(server) + .await; + } + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({ "results": Value::Object(results) })), + ) + .mount(server) + .await; + patches +} + +// ── shared state assertions ──────────────────────────────────────────────── + +/// `proj` is PURELY hosted for `hp.dep`: no vendored ledger claim, no +/// committed artifact (at either uuid), no `.socket/vendor/` residue in the +/// lock, the packages line IS the URL 3-tuple, and the redirect ledger's +/// record + edit are present with `original` == the PRISTINE registry line +/// and `new` == the live line. Every other dep's line is byte-identical to +/// the pristine lock. +fn assert_pure_hosted(fx: &Fixture, proj: &Path, hp: &HostedPatch) { + let dep = hp.dep; + let state = read(proj, ".socket/vendor/state.json"); + assert!( + !state.contains(dep.purl), + "the displaced vendored ledger entry must be dropped: {state}" + ); + for uuid in [dep.uuid_v, dep.uuid_h] { + assert!( + !proj.join(".socket/vendor/npm").join(uuid).exists(), + "the orphaned committed artifact dir .socket/vendor/npm/{uuid} must be removed" + ); + } + let lock = read(proj, "bun.lock"); + assert_eq!( + packages_line(&lock, dep.name), + dep.hosted_line(&hp.url, &hp.sri()), + "bun.lock must carry the hosted URL 3-tuple for {}:\n{lock}", + dep.name + ); + assert!( + !lock.contains(".socket/vendor/"), + "no vendored residue may survive in the lock:\n{lock}" + ); + assert_eq!( + lock_version(&lock), + Some(fx.lock_version), + "the rewrite must keep the lock's own lockfileVersion line:\n{lock}" + ); + for other in [&DEP_A, &DEP_B].into_iter().filter(|d| d.name != dep.name) { + assert_eq!( + packages_line(&lock, other.name), + fx.pristine_line(other), + "the un-patched {}'s registry 4-tuple must be byte-identical:\n{lock}", + other.name + ); + } + let ledger = read_json(proj, ".socket/vendor/redirect-state.json"); + assert_eq!( + ledger["records"][dep.purl]["uuid"], dep.uuid_h, + "the redirect ledger must record the hosted patch: {ledger:#}" + ); + let edit = ledger_edit_for(&ledger, dep.name).unwrap_or_else(|| { + panic!( + "no redirect_bun_lock_package edit for {}: {ledger:#}", + dep.name + ) + }); + assert_eq!(edit["path"], "bun.lock", "{edit:#}"); + assert_eq!( + edit["original"], + json!(fx.pristine_line(dep)), + "the redirect ledger's `original` must be the PRISTINE registry line — never a \ + `.socket/vendor/` local-path line (originals chain intact across migrations): {edit:#}" + ); + assert_eq!( + edit["new"], + json!(packages_line(&lock, dep.name)), + "the redirect ledger's `new` must be the live lock line: {edit:#}" + ); +} + +/// `proj` is PURELY vendored for `dep` at `uuid`: the redirect ledger no +/// longer claims the purl (record and edit both gone; file removed when +/// emptied), the packages line carries the local `.socket/vendor/npm//` +/// 3-tuple and no hosted URL, the artifact is committed, and the vendor +/// ledger's `bun_lock_package` wiring records the PRISTINE registry line as +/// its `original` (never the grant-tokenized hosted URL line). Every other +/// dep's line is byte-identical to the pristine lock. +fn assert_pure_vendored(fx: &Fixture, proj: &Path, dep: &Dep, uuid: &str, hosted_url: &str) { + match std::fs::read_to_string(proj.join(".socket/vendor/redirect-state.json")) { + Ok(text) => { + let ledger: Value = serde_json::from_str(&text).unwrap(); + assert!( + ledger["records"].get(dep.purl).is_none(), + "the superseded redirect record must be dropped: {ledger:#}" + ); + assert!( + ledger_edit_for(&ledger, dep.name).is_none(), + "the superseded bun.lock edit must be dropped: {ledger:#}" + ); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // An emptied ledger is deleted — the expected outcome when this + // was the only hosted record. + } + Err(e) => panic!("unreadable redirect ledger: {e}"), + } + let lock = read(proj, "bun.lock"); + assert!( + !lock.contains(hosted_url) && !lock.contains("/patch/npm/"), + "the hosted URL must be gone from bun.lock:\n{lock}" + ); + let rel = dep.vendored_rel(uuid); + let line = packages_line(&lock, dep.name); + assert!( + line.starts_with(&format!( + " \"{}\": [\"{}@{rel}\", {{}}, \"sha512-", + dep.name, dep.name + )), + "bun.lock must carry the local vendored 3-tuple for {}:\n{line}", + dep.name + ); + assert_eq!( + lock_version(&lock), + Some(fx.lock_version), + "the rewrite must keep the lock's own lockfileVersion line:\n{lock}" + ); + for other in [&DEP_A, &DEP_B].into_iter().filter(|d| d.name != dep.name) { + assert_eq!( + packages_line(&lock, other.name), + fx.pristine_line(other), + "the un-patched {}'s registry 4-tuple must be byte-identical:\n{lock}", + other.name + ); + } + assert!( + proj.join(&rel).is_file(), + "the committed artifact {rel} must exist" + ); + let state = read_json(proj, ".socket/vendor/state.json"); + let wiring = state["entries"][dep.purl]["wiring"] + .as_array() + .unwrap_or_else(|| panic!("wiring array for {}: {state:#}", dep.purl)); + let lock_wiring = wiring + .iter() + .find(|w| w["kind"] == "bun_lock_package") + .unwrap_or_else(|| panic!("bun_lock_package wiring record: {state:#}")); + assert_eq!( + lock_wiring["original"], + json!(fx.pristine_line(dep)), + "the vendor ledger must record the PRISTINE registry line as its original (never \ + the grant-tokenized hosted URL line): {state:#}" + ); + let state_text = read(proj, ".socket/vendor/state.json"); + assert!( + !state_text.contains("/patch/npm/"), + "the vendor ledger must NOT record the hosted fragment anywhere: {state_text}" + ); +} + +/// After a full unwind: bun.lock and package.json byte-identical to the +/// pristine snapshots, and no `.socket/vendor/` artifacts or ledgers left. +/// The vendor ledger's delete-when-empty prunes the emptied `.socket/vendor/` +/// dir itself (`expect_dir_pruned` — `vendor --revert`); the redirect +/// ledger's removes only its file, so a `rollback` whose last act is the +/// hosted unwind leaves the empty dir behind — asserted EMPTY, never +/// holding a ledger or an `npm/` artifact dir. +fn assert_pristine_unwound(fx: &Fixture, proj: &Path, what: &str, expect_dir_pruned: bool) { + assert_eq!( + std::fs::read(proj.join("bun.lock")).unwrap(), + fx.lock_pristine, + "{what}: bun.lock must be byte-identical to the pristine registry lock; got:\n{}", + read(proj, "bun.lock") + ); + assert_eq!( + std::fs::read(proj.join("package.json")).unwrap(), + fx.pkg_json_pristine, + "{what}: package.json must be untouched" + ); + let vendor = proj.join(".socket/vendor"); + assert!( + !vendor.join("state.json").exists() && !vendor.join("redirect-state.json").exists(), + "{what}: no ledger may survive under .socket/vendor/" + ); + assert!( + !vendor.join("npm").exists(), + "{what}: no committed artifact may survive under .socket/vendor/npm/" + ); + if expect_dir_pruned { + assert!( + !vendor.exists(), + "{what}: the emptied .socket/vendor/ dir must be pruned" + ); + } else if vendor.exists() { + let leftovers: Vec = std::fs::read_dir(&vendor) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert!( + leftovers.is_empty(), + "{what}: .socket/vendor/ must be empty after the unwind; found {leftovers:?}" + ); + } +} + +/// `rollback --yes --json` (unscoped) must exit 0 with `status: success` +/// and land the pristine state; a fresh frozen install then reproduces the +/// ORIGINAL bytes for every dep. +fn assert_unscoped_rollback_restores_pristine(fx: &Fixture, proj: &Path, tag: &str) { + let (code, stdout, stderr) = rollback_cmd(proj, &[]); + assert_eq!(code, 0, "rollback failed ({tag}): {stdout}\n{stderr}"); + let env = envelope(&stdout, &stderr); + assert_eq!( + env["status"], "success", + "rollback envelope ({tag}): {env:#}" + ); + assert_eq!( + env["hosted"]["failed"], + json!([]), + "rollback ({tag}) must not fail any hosted purl: {env:#}" + ); + assert_pristine_unwound(fx, proj, &format!("rollback ({tag})"), false); + let fresh = fresh_frozen_install(fx, proj, &format!("fresh-rolled-back-{tag}")); + assert_installed(&fresh, &DEP_A, &fx.a.orig, "after rollback"); + assert_installed(&fresh, &DEP_B, &fx.b.orig, "after rollback"); + eprintln!("ROLLBACK OK ({tag}, bun {})", fx.bun_raw); +} + +/// The vendored → hosted takeover on `proj` (already vendored for DEP_A at +/// `uuid_v`): `scan --mode hosted` must announce the takeover, leave the +/// project purely hosted, and a fresh frozen install from an empty cache +/// must land the MARKER bytes from the hosted tarball. +fn take_over_to_hosted(fx: &Fixture, proj: &Path, api: &str, hp: &HostedPatch, tag: &str) { + let (code, stdout, stderr) = hosted_scan(proj, api, &[]); + assert_eq!(code, 0, "hosted scan failed ({tag}): {stdout}\n{stderr}"); + let env = envelope(&stdout, &stderr); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + let codes = warning_codes(&env["redirect"]["warnings"]); + assert!( + codes + .iter() + .any(|c| c == "redirect_takeover_reverted_vendored"), + "the takeover must be announced ({tag}): redirect.warnings codes = {codes:?}\n{env:#}" + ); + assert!( + !codes.iter().any(|c| c == "redirect_vendored_revert_failed"), + "the vendored revert must not be refused ({tag}): {env:#}" + ); + assert_pure_hosted(fx, proj, hp); + let fresh = fresh_frozen_install(fx, proj, &format!("fresh-hosted-{tag}")); + assert_installed(&fresh, &DEP_A, &fx.a.patched, "hosted fresh install"); + assert_installed( + &fresh, + &DEP_B, + &fx.b.orig, + "hosted fresh install (bystander)", + ); + eprintln!("VENDORED→HOSTED OK ({tag}, bun {})", fx.bun_raw); +} + +/// The hosted → vendored takeover on `proj` (already hosted for DEP_A), +/// driven by `driver`: the takeover must be announced, the project left +/// purely vendored at the uuid the driver vendors under, the manifest must +/// hold that record, and a fresh frozen install from an empty cache must +/// land the MARKER bytes from the committed artifact. Returns that uuid. +#[derive(Clone, Copy, PartialEq, Debug)] +enum VendoredDriver { + /// `vendor --json --offline` over a hand-staged manifest (uuid_v). + VendorOffline, + /// `scan --mode vendored --json --yes` — discovery + download from the + /// mock API (uuid_h), then the same vendor engine. + ScanVendored, +} + +fn take_over_to_vendored( + fx: &Fixture, + proj: &Path, + api: &str, + hp: &HostedPatch, + driver: VendoredDriver, + tag: &str, +) -> &'static str { + let dep = hp.dep; + let (uuid, vendor_env) = match driver { + VendoredDriver::VendorOffline => { + stage_manifest(fx, proj, dep); + let (code, stdout, stderr) = vendor_cmd(proj, &[]); + assert_eq!(code, 0, "vendor failed ({tag}): {stdout}\n{stderr}"); + (dep.uuid_v, envelope(&stdout, &stderr)) + } + VendoredDriver::ScanVendored => { + let (code, stdout, stderr) = vendored_scan(proj, api, &[]); + assert_eq!( + code, 0, + "scan --mode vendored failed ({tag}): {stdout}\n{stderr}" + ); + let env = envelope(&stdout, &stderr); + assert_eq!(env["status"], "success", "{env:#}"); + (dep.uuid_h, env["vendor"].clone()) + } + }; + assert_eq!(vendor_env["status"], "success", "{vendor_env:#}"); + assert_eq!(vendor_env["summary"]["applied"], 1, "{vendor_env:#}"); + assert_eq!(vendor_env["summary"]["failed"], 0, "{vendor_env:#}"); + let codes = event_codes(&vendor_env); + assert!( + codes + .iter() + .any(|c| c == "vendor_takeover_reverted_redirect"), + "the takeover must be announced ({tag}, {driver:?}): event codes = {codes:?}\n\ + {vendor_env:#}" + ); + assert!( + !codes.iter().any(|c| c == "redirect_revert_failed"), + "the hosted revert must not be refused ({tag}, {driver:?}): {vendor_env:#}" + ); + assert_pure_vendored(fx, proj, dep, uuid, &hp.url); + let manifest = read_json(proj, ".socket/manifest.json"); + assert_eq!( + manifest["patches"][dep.purl]["uuid"], uuid, + "the manifest must record the vendored patch ({tag}, {driver:?}): {manifest:#}" + ); + let fresh = fresh_frozen_install(fx, proj, &format!("fresh-vendored-{tag}")); + assert_installed(&fresh, &DEP_A, &fx.a.patched, "vendored fresh install"); + assert_installed( + &fresh, + &DEP_B, + &fx.b.orig, + "vendored fresh install (bystander)", + ); + eprintln!("HOSTED→VENDORED OK ({tag}, {driver:?}, bun {})", fx.bun_raw); + uuid +} + +/// `vendor --revert` must restore the REGISTRY lock byte-identically (the +/// pre-redirect resolution the takeover carried forward, not the hosted +/// splice) and prune `.socket/vendor/`. +fn assert_vendor_revert_restores_pristine(fx: &Fixture, proj: &Path, tag: &str) { + let (code, stdout, stderr) = vendor_cmd(proj, &["--revert"]); + assert_eq!( + code, 0, + "vendor --revert failed ({tag}): {stdout}\n{stderr}" + ); + let env = envelope(&stdout, &stderr); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!(env["summary"]["removed"], 1, "{env:#}"); + assert_pristine_unwound(fx, proj, &format!("vendor --revert ({tag})"), true); + eprintln!("VENDOR REVERT OK ({tag})"); +} + +// ───────────────────────────────────────────────────────────────────────── +// 1. vendored → hosted takeover leaves the project purely hosted +// ───────────────────────────────────────────────────────────────────────── + +// #[serial]: bun keeps state under the sandboxed `~/.bun` besides the +// per-project cache dirs; serializing keeps legs that install the same +// hosted URL / local tarball spec from ever racing each other. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn bun_vendored_then_hosted_takeover_leaves_pure_hosted() { + let Some(fx) = stage_fixture("vendored-then-hosted") else { + return; + }; + let proj = fx.proj.clone(); + + // A: vendor (offline) from the staged manifest. + stage_manifest(&fx, &proj, &DEP_A); + let (code, stdout, stderr) = vendor_cmd(&proj, &[]); + assert_eq!(code, 0, "vendor failed: {stdout}\n{stderr}"); + let env = envelope(&stdout, &stderr); + assert_eq!(env["summary"]["applied"], 1, "{env:#}"); + assert!( + read(&proj, ".socket/vendor/state.json").contains(DEP_A.purl), + "the vendored ledger must claim the purl" + ); + assert!( + read(&proj, "bun.lock").contains(&DEP_A.vendored_rel(DEP_A.uuid_v)), + "bun.lock must be vendored-wired before the takeover:\n{}", + read(&proj, "bun.lock") + ); + + // B: hosted redirect over the vendored state — the takeover — then the + // fresh-checkout marker proof. + let server = MockServer::start().await; + let patches = mount_hosted_api(&server, &fx, &[&DEP_A]).await; + take_over_to_hosted(&fx, &proj, &server.uri(), &patches[0], "rev"); + + // C: unscoped rollback → pristine bytes, no vendor artifacts or + // ledgers, fresh install → original bytes. + assert_unscoped_rollback_restores_pristine(&fx, &proj, "after-vendored-then-hosted"); +} + +// ───────────────────────────────────────────────────────────────────────── +// 2. hosted → vendored takeover round-trips to the registry (both drivers) +// ───────────────────────────────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn bun_hosted_then_vendored_takeover_round_trips_to_registry() { + let Some(fx) = stage_fixture("hosted-then-vendored") else { + return; + }; + let proj = fx.proj.clone(); + let server = MockServer::start().await; + let patches = mount_hosted_api(&server, &fx, &[&DEP_A]).await; + let hp = &patches[0]; + + // A: hosted redirect: registry 4-tuple → URL 3-tuple, ledger claims the + // purl with one `redirect_bun_lock_package` edit whose original is + // the pristine registry line; a fresh frozen install lands the + // patched tree. + let (code, stdout, stderr) = hosted_scan(&proj, &server.uri(), &[]); + assert_eq!(code, 0, "hosted scan failed: {stdout}\n{stderr}"); + let env = envelope(&stdout, &stderr); + assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_pure_hosted(&fx, &proj, hp); + let fresh = fresh_frozen_install(&fx, &proj, "fresh-hosted"); + assert_installed(&fresh, &DEP_A, &fx.a.patched, "hosted fresh install"); + + // B: BOTH vendored drivers, each on its own copy of the hosted project. + let by_vendor = fx.dir("hosted-copy-vendor"); + copy_project(&proj, &by_vendor); + let by_scan = fx.dir("hosted-copy-scan"); + copy_project(&proj, &by_scan); + + take_over_to_vendored( + &fx, + &by_vendor, + &server.uri(), + hp, + VendoredDriver::VendorOffline, + "vendor-offline", + ); + assert_vendor_revert_restores_pristine(&fx, &by_vendor, "vendor-offline"); + + take_over_to_vendored( + &fx, + &by_scan, + &server.uri(), + hp, + VendoredDriver::ScanVendored, + "scan-vendored", + ); + // A re-run is an in-sync no-op with no second takeover. + let (code, stdout, stderr) = vendored_scan(&by_scan, &server.uri(), &[]); + assert_eq!(code, 0, "vendored re-run failed: {stdout}\n{stderr}"); + let rerun = envelope(&stdout, &stderr); + let codes = event_codes(&rerun["vendor"]); + assert!( + codes.iter().any(|c| c == "already_vendored") + && !codes + .iter() + .any(|c| c == "vendor_takeover_reverted_redirect"), + "the re-run must be `already_vendored` with no second takeover: {codes:?}\n{rerun:#}" + ); + assert_vendor_revert_restores_pristine(&fx, &by_scan, "scan-vendored"); +} + +// ───────────────────────────────────────────────────────────────────────── +// 3. dry-run previews match the wet outcomes, and write nothing +// ───────────────────────────────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn bun_dry_run_previews_match_wet_outcomes() { + let Some(fx) = stage_fixture("dry-run") else { + return; + }; + let proj = fx.proj.clone(); + let server = MockServer::start().await; + let patches = mount_hosted_api(&server, &fx, &[&DEP_A]).await; + let hp = &patches[0]; + let api = server.uri(); + + // ── over a LIVE HOSTED redirect ────────────────────────────────────── + let hosted = fx.dir("live-hosted"); + copy_project(&proj, &hosted); + let (code, stdout, stderr) = hosted_scan(&hosted, &api, &[]); + assert_eq!(code, 0, "hosted scan failed: {stdout}\n{stderr}"); + assert_pure_hosted(&fx, &hosted, hp); + // The manifest record `vendor` acts on (offline: the staged blob). + stage_manifest(&fx, &hosted, &DEP_A); + let before = snapshot(&hosted); + + // `vendor --dry-run`: the takeover is PROBED (write-free per-purl revert + // on a ledger clone) and previewed; the backend preview does not run + // against the still-hosted lock, so no false `vendor_lock_entry_not_found` + // and no refusal. + let (code, stdout, stderr) = vendor_cmd(&hosted, &["--dry-run"]); + assert_eq!(code, 0, "vendor --dry-run must succeed: {stdout}\n{stderr}"); + let env = envelope(&stdout, &stderr); + assert_eq!(env["dryRun"], true, "{env:#}"); + assert_eq!(env["summary"]["failed"], 0, "{env:#}"); + let advisory = env["events"] + .as_array() + .and_then(|evs| { + evs.iter() + .find(|e| e["errorCode"] == "vendor_would_revert_redirect") + }) + .unwrap_or_else(|| panic!("expected a `vendor_would_revert_redirect` preview: {env:#}")); + assert_eq!(advisory["action"], "skipped", "{advisory:#}"); + assert_eq!(advisory["purl"], DEP_A.purl, "{advisory:#}"); + let codes = event_codes(&env); + for forbidden in ["vendor_lock_entry_not_found", "redirect_revert_failed"] { + assert!( + !codes.iter().any(|c| c == forbidden), + "vendor --dry-run must not emit `{forbidden}` over a takeover it can perform: \ + {env:#}" + ); + } + assert_unchanged(&before, &hosted, "vendor --dry-run"); + + // `scan --mode vendored --dry-run`: the scan-side preview is a ledger + // classification by contract (`would_vendor` | `already_vendored` | + // `would_revendor`, plus the additive Bun-preflight `would_refuse`); over + // a takeover the wet run performs it must say `would_vendor` — never + // `would_refuse`, never a refusal — and write nothing. + let (code, stdout, stderr) = vendored_scan(&hosted, &api, &["--dry-run"]); + assert_eq!( + code, 0, + "scan --mode vendored --dry-run must succeed: {stdout}\n{stderr}" + ); + let env = envelope(&stdout, &stderr); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!(env["vendor"]["dryRun"], true, "{env:#}"); + let preview = env["vendor"]["patches"] + .as_array() + .and_then(|p| p.iter().find(|p| p["purl"] == DEP_A.purl)) + .unwrap_or_else(|| { + panic!( + "expected a vendored preview record for {}: {env:#}", + DEP_A.purl + ) + }); + assert_eq!( + preview["action"], "would_vendor", + "the vendored preview over a live hosted bun redirect must classify `would_vendor` \ + (the wet run takes over and vendors): {env:#}" + ); + assert!( + !stdout.contains("redirect_revert_failed") && !stdout.contains("would_refuse"), + "the vendored preview must not advertise a refusal the wet run never makes:\n{stdout}" + ); + assert_unchanged(&before, &hosted, "scan --mode vendored --dry-run"); + + // The WET vendor lands exactly the takeover previewed. + let (code, stdout, stderr) = vendor_cmd(&hosted, &[]); + assert_eq!(code, 0, "wet vendor failed: {stdout}\n{stderr}"); + let env = envelope(&stdout, &stderr); + assert_eq!(env["summary"]["applied"], 1, "{env:#}"); + assert!( + event_codes(&env) + .iter() + .any(|c| c == "vendor_takeover_reverted_redirect"), + "the wet vendor must perform the takeover the preview promised: {env:#}" + ); + assert_pure_vendored(&fx, &hosted, &DEP_A, DEP_A.uuid_v, &hp.url); + + // ── over a LIVE VENDORED state ─────────────────────────────────────── + let vendored = fx.dir("live-vendored"); + copy_project(&proj, &vendored); + stage_manifest(&fx, &vendored, &DEP_A); + let (code, stdout, stderr) = vendor_cmd(&vendored, &[]); + assert_eq!(code, 0, "vendor failed: {stdout}\n{stderr}"); + assert_pure_vendored(&fx, &vendored, &DEP_A, DEP_A.uuid_v, &hp.url); + let before = snapshot(&vendored); + + // `scan --mode hosted --dry-run`: the vendored revert is probed + // write-free and the takeover previewed; nothing is rewritten, no ledger + // is written. + let (code, stdout, stderr) = hosted_scan(&vendored, &api, &["--dry-run"]); + assert_eq!( + code, 0, + "scan --mode hosted --dry-run must succeed: {stdout}\n{stderr}" + ); + let env = envelope(&stdout, &stderr); + assert_eq!(env["redirect"]["dryRun"], true, "{env:#}"); + let codes = warning_codes(&env["redirect"]["warnings"]); + assert!( + codes.iter().any(|c| c == "redirect_would_revert_vendored"), + "the hosted preview must announce the vendored takeover: {codes:?}\n{env:#}" + ); + assert!( + !codes.iter().any(|c| c == "redirect_vendored_revert_failed"), + "the hosted preview must not refuse a takeover the wet run performs: {env:#}" + ); + assert_unchanged(&before, &vendored, "scan --mode hosted --dry-run"); + + // The WET hosted scan lands exactly the takeover previewed. + take_over_to_hosted(&fx, &vendored, &api, hp, "after-dry-run"); +} + +// ───────────────────────────────────────────────────────────────────────── +// 4. scoped rollback / remove of ONE of two hosted records +// ───────────────────────────────────────────────────────────────────────── + +/// After unwinding ONLY DEP_A: its line is the pristine registry tuple, +/// DEP_B is still hosted, the ledger keeps exactly DEP_B's record + edit, +/// and a fresh frozen install lands A's ORIGINAL and B's MARKER bytes. +fn assert_only_a_unwound(fx: &Fixture, proj: &Path, b: &HostedPatch, tag: &str) { + let lock = read(proj, "bun.lock"); + assert_eq!( + packages_line(&lock, DEP_A.name), + fx.pristine_line(&DEP_A), + "{tag}: {} must be back to its registry tuple:\n{lock}", + DEP_A.name + ); + assert_eq!( + packages_line(&lock, DEP_B.name), + DEP_B.hosted_line(&b.url, &b.sri()), + "{tag}: {} must stay hosted:\n{lock}", + DEP_B.name + ); + let ledger = read_json(proj, ".socket/vendor/redirect-state.json"); + assert!( + ledger["records"].get(DEP_A.purl).is_none(), + "{tag}: A's record must be dropped: {ledger:#}" + ); + assert_eq!( + ledger["records"][DEP_B.purl]["uuid"], DEP_B.uuid_h, + "{tag}: B's record must stay: {ledger:#}" + ); + let edits = ledger["edits"].as_array().unwrap(); + assert_eq!( + edits.len(), + 1, + "{tag}: exactly B's edit must stay: {ledger:#}" + ); + assert_eq!(edits[0]["key"], DEP_B.name, "{tag}: {ledger:#}"); + let fresh = fresh_frozen_install(fx, proj, &format!("fresh-{tag}")); + assert_installed(&fresh, &DEP_A, &fx.a.orig, tag); + assert_installed(&fresh, &DEP_B, &fx.b.patched, tag); + eprintln!("SCOPED UNWIND OK ({tag}, bun {})", fx.bun_raw); +} + +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn bun_scoped_rollback_and_remove_unwind_one_of_two_hosted_records() { + let Some(fx) = stage_fixture("scoped-unwind") else { + return; + }; + let proj = fx.proj.clone(); + let server = MockServer::start().await; + let patches = mount_hosted_api(&server, &fx, &[&DEP_A, &DEP_B]).await; + let (a, b) = (&patches[0], &patches[1]); + + // Both deps hosted-redirected in ONE scan: two records, two edits. + let (code, stdout, stderr) = hosted_scan(&proj, &server.uri(), &[]); + assert_eq!(code, 0, "hosted scan failed: {stdout}\n{stderr}"); + let env = envelope(&stdout, &stderr); + assert_eq!(env["redirect"]["redirected"], 2, "{env:#}"); + let lock = read(&proj, "bun.lock"); + for hp in [a, b] { + assert_eq!( + packages_line(&lock, hp.dep.name), + hp.dep.hosted_line(&hp.url, &hp.sri()), + "{} must be hosted:\n{lock}", + hp.dep.name + ); + } + let ledger = read_json(&proj, ".socket/vendor/redirect-state.json"); + assert_eq!( + ledger["records"].as_object().map(|m| m.len()), + Some(2), + "{ledger:#}" + ); + assert_eq!( + ledger["edits"].as_array().map(|e| e.len()), + Some(2), + "{ledger:#}" + ); + for hp in [a, b] { + let edit = ledger_edit_for(&ledger, hp.dep.name) + .unwrap_or_else(|| panic!("no edit for {}: {ledger:#}", hp.dep.name)); + assert_eq!( + edit["original"], + json!(fx.pristine_line(hp.dep)), + "{edit:#}" + ); + } + let fresh = fresh_frozen_install(&fx, &proj, "fresh-two-hosted"); + assert_installed(&fresh, &DEP_A, &fx.a.patched, "two hosted records"); + assert_installed(&fresh, &DEP_B, &fx.b.patched, "two hosted records"); + + // Scoped rollback of A: per-purl path (two records ⇒ the whole-ledger + // replay is not eligible). Used to exit 1 with hosted.failed = ["cannot + // replay yet"]. + let by_rollback = fx.dir("two-hosted-copy-rollback"); + copy_project(&proj, &by_rollback); + let (code, stdout, stderr) = rollback_cmd(&by_rollback, &[DEP_A.purl]); + assert_eq!(code, 0, "scoped rollback must succeed: {stdout}\n{stderr}"); + let env = envelope(&stdout, &stderr); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!(env["hosted"]["reverted"], json!([DEP_A.purl]), "{env:#}"); + assert_eq!(env["hosted"]["failed"], json!([]), "{env:#}"); + assert_eq!(env["hosted"]["unsupported"], json!([]), "{env:#}"); + assert_only_a_unwound(&fx, &by_rollback, b, "scoped-rollback"); + // Then the unscoped rollback: covers the last record ⇒ whole-ledger + // replay ⇒ pristine. + assert_unscoped_rollback_restores_pristine(&fx, &by_rollback, "after-scoped-rollback"); + + // `remove ` takes the same per-purl hosted leg; used to exit 1 + // with `hosted_revert_failed`. + let by_remove = fx.dir("two-hosted-copy-remove"); + copy_project(&proj, &by_remove); + let (code, stdout, stderr) = run_socket( + &by_remove, + &[ + "remove", + DEP_A.purl, + "--yes", + "--json", + "--cwd", + by_remove.to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "scoped remove must succeed: {stdout}\n{stderr}"); + let env = envelope(&stdout, &stderr); + assert!( + env["error"].is_null(), + "remove must report no top-level error: {env:#}" + ); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!(env["summary"]["removed"], 1, "{env:#}"); + assert_eq!(env["summary"]["failed"], 0, "{env:#}"); + // The hosted unwind rides a `removed` event tagged `hosted_reverted` + // (remove's per-purl hosted leg; a `hosted_revert_failed` top-level + // error was the pre-fix shape). + assert!( + env["events"] + .as_array() + .is_some_and(|evs| evs.iter().any(|e| { + e["action"] == "removed" + && e["errorCode"] == "hosted_reverted" + && e["purl"] == DEP_A.purl + })), + "remove must report the hosted unwind of {}: {env:#}", + DEP_A.purl + ); + assert_only_a_unwound(&fx, &by_remove, b, "scoped-remove"); + assert_unscoped_rollback_restores_pristine(&fx, &by_remove, "after-scoped-remove"); +} + +// ───────────────────────────────────────────────────────────────────────── +// 5. unscoped rollback from each mixed state restores pristine +// ───────────────────────────────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn bun_rollback_from_each_mixed_state_restores_pristine() { + let Some(fx) = stage_fixture("rollback-mixed") else { + return; + }; + let proj = fx.proj.clone(); + let server = MockServer::start().await; + let patches = mount_hosted_api(&server, &fx, &[&DEP_A]).await; + let hp = &patches[0]; + let api = server.uri(); + + // State (1): vendored → hosted, then rollback. The manifest still holds + // the vendored record the hosted takeover superseded; rollback's manifest + // leg retires it alongside the hosted unwind. + let one = fx.dir("mixed-vendored-then-hosted"); + copy_project(&proj, &one); + stage_manifest(&fx, &one, &DEP_A); + let (code, stdout, stderr) = vendor_cmd(&one, &[]); + assert_eq!(code, 0, "vendor failed: {stdout}\n{stderr}"); + take_over_to_hosted(&fx, &one, &api, hp, "mixed-1"); + assert_unscoped_rollback_restores_pristine(&fx, &one, "mixed-1"); + + // State (2): hosted → vendored (scan-driven), then rollback: the + // vendored leg unwires + removes the artifact, the (emptied) redirect + // ledger is already gone, the manifest record is retired. + let two = fx.dir("mixed-hosted-then-vendored"); + copy_project(&proj, &two); + let (code, stdout, stderr) = hosted_scan(&two, &api, &[]); + assert_eq!(code, 0, "hosted scan failed: {stdout}\n{stderr}"); + take_over_to_vendored(&fx, &two, &api, hp, VendoredDriver::ScanVendored, "mixed-2"); + let (code, stdout, stderr) = rollback_cmd(&two, &[]); + assert_eq!(code, 0, "rollback failed (mixed-2): {stdout}\n{stderr}"); + let env = envelope(&stdout, &stderr); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!( + env["vendoredReverted"], + json!([DEP_A.purl]), + "rollback must unwire the vendored purl: {env:#}" + ); + assert_eq!(env["vendoredFailed"], json!([]), "{env:#}"); + assert_pristine_unwound(&fx, &two, "rollback (mixed-2)", false); + let fresh = fresh_frozen_install(&fx, &two, "fresh-rolled-back-mixed-2"); + assert_installed(&fresh, &DEP_A, &fx.a.orig, "after rollback (mixed-2)"); + assert_installed(&fresh, &DEP_B, &fx.b.orig, "after rollback (mixed-2)"); + eprintln!("ROLLBACK OK (mixed-2, bun {})", fx.bun_raw); +} From b4a9abd56ed382401b0578e217b7be5225264e45 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 21 Sep 2026 11:21:16 -0400 Subject: [PATCH 28/49] test(bun): backtest oracle of documented boundaries, exit-code/envelope assertions, conversion + legacy-lockb + CRLF + workspace shapes, verified retried downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runner classified cells from the CLI's own refusal codes, so a CLI regression that refused a supported configuration (or "supported" a refused one) passed as an unsupported cell. Every cell is now judged against `expected_outcome(version, shape, mode)`, which encodes the measured Bun boundaries: bun.lockb-only releases (<= 1.1.38 by default, 1.1.39-1.1.42 for the CLI's migration recipe -> redirect_bun_lockb_manual_migration, 1.1.43+ migrate), the version-0 workspace hosted refusal, the pre-v2 workspace vendored refusal, the vendored bun.lockb refusal (scan adds the bun_lockb_unsupported diagnosis), the 0.8.1/1.0.0 peer/override upstream limitation, everything else supported. Refusal codes must match the expectation EXACTLY after an explicit informational allowlist; regression codes (migration reverted / entry not found / revert failed) fail a supported cell. Every CLI invocation records its exit code (main, repeat, rollback, conversion, repair): supported -> 0, hosted refusals -> 0 with redirected 0, vendored / detached / get refusals -> non-zero with no download and no stray manifest record. The repeat run must be the documented no-op (hosted: redirected 1, no warnings; vendored: applied 0 / skipped 1 / one already_vendored event). Rollback must exit 0 and satisfy the lockfile presence rules (text projects: bun.lock back, no bun.lockb; migrated projects: bun.lockb restored from the ledger with redirect_bun_lockb_restored, bun.lock kept). Digest boundary: TARBALL_INTEGRITY_ENFORCED_FROM = 1.3.10 (1.3.9 installs a tampered tarball, 1.3.10 refuses); below it the observation is recorded, not asserted. A new registryDigestEnforced probe proves the registry tuple IS verified on every text-lock release, documenting the downgrade the rewrite introduces below 1.3.10. Bun 1.3.9/1.3.10 print the integrity error and never exit on a workspace project; the tamper installs tolerate that hang. New shapes: hosted-then-vendored / vendored-then-hosted (takeover round trips, ledger and manifest contracts pinned), legacy-lockb (bun.lockb written by 1.1.38, the matrix release migrates it; rollback restores the binary lock byte-identically), crlf-lock (every line stays CRLF through rewrite, repeat and rollback), text-workspace (a REAL version-0 workspace lock), workspace-root, workspace-get-uuid / -search, already-vendored-workspace (re-run over a grown workspace lock, then `repair` rebuilds a deleted artifact), preexisting-manifest (a foreign manifest record with its blob survives a refused vendored run). custom-registry now injects bun's full-URL registry slot and asserts the rewrite drops it; the text gate is >= 1.1.39 and asserts the text lock was written; the alias/package_not_installed carve-out is gone. install_tool downloads with backoff (5xx/429/connection/stall/truncated zip), verifies the zip against the release's SHASUMS256.txt (fail closed), records bunSha256 / bunArchiveSha256 per row, and a tool-install failure still writes summary.json for the artifact upload. Flags and the tools layout stay compatible with bun-compatibility.yml. Verified on macOS against the production patch service with real Bun 1.1.38 1.1.39 1.1.43 1.1.45 1.2.23 1.3.9 1.3.10 1.3.14 1.4.2 over 11 shapes x 3 modes. The only failing cells are already-vendored-workspace on Bun < 1.3.10: those releases re-save URL/local tarball tuples WITHOUT their sha512 (a 2-tuple) whenever bun.lock changes, after which the CLI no longer recognizes its own wiring (redirect_bun_entry_not_found / vendor_lock_entry_not_found) and rollback refuses on drift — a CLI gap the oracle deliberately keeps visible. Co-Authored-By: Claude Fable 5.1 --- scripts/backtest-bun.py | 899 ++++++++++++++++++++++++++++++++++------ 1 file changed, 765 insertions(+), 134 deletions(-) diff --git a/scripts/backtest-bun.py b/scripts/backtest-bun.py index 4e12e6b5..f6572aea 100644 --- a/scripts/backtest-bun.py +++ b/scripts/backtest-bun.py @@ -1,10 +1,70 @@ #!/usr/bin/env python3 -"""Native Bun / public Socket patch compatibility, with no service doubles.""" +"""Native Bun / public Socket patch compatibility, with no service doubles. + +Every (bun release, project shape, mode) cell builds an isolated project with a +REAL Bun binary, runs the production CLI against the public free minimist@1.2.2 +patch, and checks the outcome against `expected_outcome` — an oracle of the +DOCUMENTED boundaries, never the CLI's own refusal codes — so a CLI regression +that refuses a supported configuration (or "supports" a refused one) fails the +cell instead of being recorded as an unsupported PASS. + +Shapes (project layouts; `--shapes`): + direct / dev / optional / peer the dependency section minimist lives in + alias {"alias": "npm:minimist@1.2.2"} + transitive mkdirp@0.5.3 + overrides.minimist=1.2.2 + two-versions / production a second minimist (npm:minimist@1.2.8) as a + dep / devDep (production installs --production) + workspace / workspace-nested a member declares minimist (nested: the root + pins 1.2.8 beside it) + workspace-root the root declares minimist, the member left-pad + text-workspace the workspace project with a REAL version-0 + text lock (--save-text-lockfile, 1.1.39-1.1.45) + workspace-get-uuid / -search `get ` / `get ` on the workspace + crlf / crlf-lock CRLF package.json / a CRLF-converted bun.lock + space-unicode project path with a space and a non-ASCII char + custom-registry bun's full-URL registry slot injected into the + registry tuple (a non-default registry) + text text-lock opt-in on 1.1.39-1.1.45 + legacy-lockb bun.lockb written by Bun 1.1.38, then the + matrix release runs the CLI (the migration path) + isolated / hoisted bunfig [install] linker (>= 1.3.0) + lockfile-only node_modules removed before the CLI runs + get-uuid / get-search `get ` / `get ` instead of `scan` + hosted-then-vendored hosted redirect, then the vendored takeover + vendored-then-hosted vendored, then the hosted takeover + already-vendored-workspace wire a plain project (the cell's mode), add a + workspace member, `bun install`, re-run the same + mode (a clean no-op: already_vendored / redirected + 1), then `repair` rebuilds a deleted artifact + preexisting-manifest a foreign .socket/manifest.json record must + survive a refused vendored run + +Boundaries the oracle encodes (measured against real releases): + <= 1.1.38 binary bun.lockb only; 1.1.39-1.1.45 write it by default + 1.1.39 first text lock (lockfileVersion 0, --save-text-lockfile) + 1.1.43 first `--lockfile-only`: the CLI's bun.lockb->bun.lock + migration writes bun.lock (1.1.39-1.1.42 accept the + flags, exit 0 and write nothing -> + redirect_bun_lockb_manual_migration) + 1.2.0 text default, lockfileVersion 1; 1.4.0: lockfileVersion 2 + version-0 workspace lock hosted refuses (redirect_bun_workspace_unsupported) + pre-v2 workspace lock vendored/detached refuse (vendor_bun_workspace_unsupported) + bun.lockb, vendored vendor_bun_lockb_unsupported before any download + 1.3.10 URL/local tarball sha512 enforced (registry tuples are + enforced on every text-lock release) + 0.8.1 / 1.0.0 peers not installed, overrides ignored (upstream) + +Every cell records the CLI exit codes (main, repeat, rollback, conversion), +the exact refusal-code set, the repeat-run envelope semantics, digest +enforcement, and after rollback the lockfile presence rules and byte identity. +""" import argparse +import base64 import concurrent.futures from datetime import datetime, timezone import hashlib +import http.client import json import os from pathlib import Path @@ -12,30 +72,82 @@ import re import shutil import subprocess +import time +import urllib.error import urllib.request import zipfile -VERSIONS = ['0.8.1', '1.0.0', '1.0.36', '1.1.0', '1.1.38', '1.1.39', '1.1.45', - '1.2.0', '1.2.23', '1.3.0', '1.3.14', '1.4.0', '1.4.2'] +VERSIONS = ['0.8.1', '1.0.0', '1.0.36', '1.1.0', '1.1.38', '1.1.39', '1.1.43', '1.1.45', + '1.2.0', '1.2.23', '1.3.0', '1.3.9', '1.3.10', '1.3.14', '1.4.0', '1.4.2'] SHAPES = ['direct', 'dev', 'optional', 'alias', 'transitive', 'two-versions', - 'workspace', 'workspace-nested', 'peer', 'crlf', 'space-unicode', - 'custom-registry', 'text', 'isolated', 'hoisted', 'lockfile-only', 'production', - 'get-uuid', 'get-search'] + 'workspace', 'workspace-nested', 'workspace-root', 'text-workspace', + 'workspace-get-uuid', 'workspace-get-search', 'peer', 'crlf', 'crlf-lock', + 'space-unicode', 'custom-registry', 'text', 'legacy-lockb', 'isolated', 'hoisted', + 'lockfile-only', 'production', 'get-uuid', 'get-search', + 'hosted-then-vendored', 'vendored-then-hosted', 'already-vendored-workspace', + 'preexisting-manifest'] +MODES = ['hosted', 'vendored', 'vendored-detached'] PURL = 'pkg:npm/minimist@1.2.2' UUID = '80630680-4da6-45f9-bba8-b888e0ffd58c' +# The registry slot bun writes for a non-default registry: the full tarball URL. +REGISTRY_SLOT = 'https://registry.npmjs.org/minimist/-/minimist-1.2.2.tgz' +LOCAL_TUPLE_SPEC = f'minimist@.socket/vendor/npm/{UUID}/minimist-1.2.2.tgz' +HOSTED_TUPLE_PREFIX = 'minimist@https://patch.socket.dev/' +# A record for ANOTHER purl seeded into .socket/manifest.json by the +# `preexisting-manifest` shape; a refused vendored run must leave it intact. +OTHER_PURL = 'pkg:npm/left-pad@1.3.0' + +# Release boundaries, measured against real binaries (docs/testing/bun-compatibility.md). +LEGACY_BUN = '1.1.38' # last binary-only release; legacy-lockb baseline +TEXT_LOCK_FROM = (1, 1, 39) # --save-text-lockfile (lockfileVersion 0) +LOCKFILE_ONLY_FROM = (1, 1, 43) # --lockfile-only: the lockb migration recipe works +TEXT_DEFAULT_FROM = (1, 2, 0) # bun.lock by default (lockfileVersion 1) +LOCK_V2_FROM = (1, 4, 0) # fresh locks are lockfileVersion 2 +LINKER_FROM = (1, 3, 0) # bunfig [install] linker +TARBALL_INTEGRITY_ENFORCED_FROM = (1, 3, 10) # URL/local tarball sha512 verified +NO_PEER_OR_OVERRIDE = ('0.8.1', '1.0.0') # peers not installed, overrides ignored + +# Advisory codes a SUPPORTED run may carry; everything else is a refusal. +INFORMATIONAL = { + 'vendor_prebuilt_downloaded', 'vendor_prebuilt_unavailable', 'vendor_prebuilt_pending', + 'vendor_fetched_missing', 'reinstall_required', 'redirect_bun_lockb_restored', + 'vendor_takeover_reverted_redirect', 'redirect_takeover_reverted_vendored', +} +# Codes that mean the rewriter or the takeover broke on a supported configuration. +REGRESSION_CODES = { + 'redirect_bun_lockb_migration_reverted', 'redirect_bun_lockb_migrated_without_redirect', + 'redirect_bun_entry_not_found', 'redirect_revert_failed', +} + +WORKSPACE_SHAPES = {'workspace', 'workspace-nested', 'workspace-root', 'text-workspace', + 'workspace-get-uuid', 'workspace-get-search', 'preexisting-manifest'} +GET_SHAPES = {'get-uuid', 'get-search', 'workspace-get-uuid', 'workspace-get-search'} +TEXT_OPT_IN_SHAPES = {'text', 'text-workspace'} +CONVERSION_SHAPES = {'hosted-then-vendored', 'vendored-then-hosted', 'already-vendored-workspace'} + + +def ver(version): + return tuple(map(int, version.split('.'))) def save(path, data): path.write_text(json.dumps(data, indent=2) + '\n', encoding='utf-8') -def run(command, cwd, env, log, required=True): +def run(command, cwd, env, log, required=True, timeout=180, tolerate_timeout=False): + """(exit code, combined output). A hang is an error — except for the + digest-tamper installs (`tolerate_timeout`), where Bun 1.3.9 and 1.3.10 + print the integrity error and then never exit on a workspace project: + those return (None, output-so-far) so the caller can still judge the + rejection (the error was reported and nothing was installed).""" try: result = subprocess.run([str(x) for x in command], cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - timeout=180) + timeout=timeout) except subprocess.TimeoutExpired as error: log.write_bytes(error.stdout or b'') + if tolerate_timeout: + return None, (error.stdout or b'').decode(errors='replace') raise log.write_bytes(result.stdout) if required and result.returncode: @@ -47,22 +159,82 @@ def git_hash(data): return hashlib.sha256(f'blob {len(data)}\0'.encode() + data).hexdigest() -def install_tool(root, version): +def sha256(data): + return hashlib.sha256(data).hexdigest() + + +class DownloadError(RuntimeError): + """A release asset arrived but failed verification (truncated / tampered).""" + + +RETRYABLE = (urllib.error.URLError, TimeoutError, ConnectionError, http.client.HTTPException) + + +def fetch(url, dest, attempts=5): + """Download `url` to `dest` with backoff. 5xx / 429 / connection errors / + stalls retry; any other HTTP error is final.""" + for attempt in range(1, attempts + 1): + try: + with urllib.request.urlopen(url, timeout=60) as response, open(dest, 'wb') as out: + shutil.copyfileobj(response, out) + return + except RETRYABLE as error: + final = isinstance(error, urllib.error.HTTPError) and error.code < 500 and error.code != 429 + if final or attempt == attempts: + raise + print(f'download {url} attempt {attempt} failed: {error}; retrying', flush=True) + time.sleep(10 * attempt) + + +def listed_sha256(sums, name): + """The SHA-256 SHASUMS256.txt lists for `name`; fail closed when absent.""" + for line in sums.read_text(encoding='utf-8').splitlines(): + parts = line.strip().split() + if len(parts) == 2 and parts[1] == name: + return parts[0] + raise RuntimeError(f'{name} is not listed in {sums}') + + +def bun_asset(): system = platform.system().lower() arch = 'aarch64' if platform.machine().lower() in ('arm64', 'aarch64') else 'x64' if system == 'windows': arch = 'x64' - asset = f'bun-{system}-{arch}' + return system, f'bun-{system}-{arch}' + + +def install_tool(root, version): + """Return the Bun binary for `version` under `root///`, + downloading the release zip (retried, verified against the release's own + SHASUMS256.txt, fail closed) when it is not already there — the CI + pre-download step leaves the same layout plus SHASUMS256.txt behind.""" + system, asset = bun_asset() directory = root / version binary = directory / asset / ('bun.exe' if system == 'windows' else 'bun') if not binary.exists(): directory.mkdir(parents=True, exist_ok=True) - archive = directory / 'bun.zip' - urllib.request.urlretrieve( - f'https://github.com/oven-sh/bun/releases/download/bun-v{version}/{asset}.zip', archive) - with zipfile.ZipFile(archive) as zipped: - zipped.extractall(directory) - archive.unlink() + base = f'https://github.com/oven-sh/bun/releases/download/bun-v{version}' + archive = directory / f'{asset}.zip' + sums = directory / 'SHASUMS256.txt' + for attempt in range(1, 6): + try: + fetch(f'{base}/SHASUMS256.txt', sums) + fetch(f'{base}/{asset}.zip', archive) + expected = listed_sha256(sums, f'{asset}.zip') + actual = sha256(archive.read_bytes()) + if actual != expected: + raise DownloadError(f'{asset}.zip sha256 {actual} != SHASUMS256.txt {expected}') + with zipfile.ZipFile(archive) as zipped: + zipped.extractall(directory) + break + except (zipfile.BadZipFile, DownloadError, *RETRYABLE) as error: + final = isinstance(error, urllib.error.HTTPError) and error.code < 500 and error.code != 429 + if final or attempt == 5: + raise + print(f'bun {version} attempt {attempt} failed: {error}; retrying', flush=True) + time.sleep(10 * attempt) + finally: + archive.unlink(missing_ok=True) binary.chmod(0o755) actual = subprocess.check_output([binary, '--version'], text=True).strip() if actual != version: @@ -70,40 +242,194 @@ def install_tool(root, version): return binary +def archive_sha256(root, version): + """The verified release-zip SHA-256 when SHASUMS256.txt sits beside the + tool (written by install_tool or the CI pre-download step).""" + sums = root / version / 'SHASUMS256.txt' + try: + return listed_sha256(sums, f'{bun_asset()[1]}.zip') if sums.exists() else None + except RuntimeError: + return None + + +def base_shape(shape): + """The project layout a shape starts from.""" + if shape in ('text-workspace', 'workspace-get-uuid', 'workspace-get-search', + 'preexisting-manifest'): + return 'workspace' + if shape in ('crlf-lock', 'legacy-lockb', 'custom-registry', *CONVERSION_SHAPES): + return 'direct' + return shape + + def project_files(shape): + layout = base_shape(shape) manifest = dict(name='bun-patch-backtest', version='1.0.0', private=True, dependencies={'minimist': '1.2.2'}) files = {} - if shape in ('dev', 'optional', 'peer'): + if layout in ('dev', 'optional', 'peer'): key = {'dev': 'devDependencies', 'optional': 'optionalDependencies', - 'peer': 'peerDependencies'}[shape] + 'peer': 'peerDependencies'}[layout] manifest[key] = manifest.pop('dependencies') - elif shape == 'alias': + elif layout == 'alias': manifest['dependencies'] = {'alias': 'npm:minimist@1.2.2'} - elif shape == 'transitive': + elif layout == 'transitive': manifest['dependencies'] = {'mkdirp': '0.5.3'} manifest['overrides'] = {'minimist': '1.2.2'} - elif shape == 'two-versions': + elif layout == 'two-versions': manifest['dependencies']['other'] = 'npm:minimist@1.2.8' - elif shape == 'production': + elif layout == 'production': manifest['devDependencies'] = {'other': 'npm:minimist@1.2.8'} - elif shape.startswith('workspace'): + elif layout.startswith('workspace'): manifest['workspaces'] = ['packages/*'] manifest['dependencies'] = {'consumer': 'workspace:*'} - files['packages/consumer/package.json'] = json.dumps(dict( - name='consumer', version='1.0.0', - dependencies={'minimist': '1.2.2'})) + '\n' - if shape == 'workspace-nested': + member = {'minimist': '1.2.2'} + if layout == 'workspace-nested': manifest['dependencies']['minimist'] = '1.2.8' - elif shape in ('isolated', 'hoisted'): - files['bunfig.toml'] = f'[install]\nlinker = "{shape}"\n' - elif shape == 'custom-registry': - files['.npmrc'] = 'registry=https://registry.npmjs.org/\n' + elif layout == 'workspace-root': + # The root declares the patched package; the member something else. + manifest['dependencies']['minimist'] = '1.2.2' + member = {'left-pad': '1.3.0'} + files['packages/consumer/package.json'] = json.dumps(dict( + name='consumer', version='1.0.0', dependencies=member)) + '\n' + elif layout in ('isolated', 'hoisted'): + files['bunfig.toml'] = f'[install]\nlinker = "{layout}"\n' files['package.json'] = json.dumps(manifest, indent=2) + '\n' return {name: (data.replace('\n', '\r\n') if shape == 'crlf' else data).encode() for name, data in files.items()} +def add_workspace_member(project): + """Turn a plain project into a workspace with one member (left-pad) in + place; returns the files it wrote/rewrote.""" + manifest = json.loads((project / 'package.json').read_text(encoding='utf-8')) + manifest['workspaces'] = ['packages/*'] + manifest['dependencies']['consumer'] = 'workspace:*' + files = {'package.json': (json.dumps(manifest, indent=2) + '\n').encode(), + 'packages/consumer/package.json': (json.dumps(dict( + name='consumer', version='1.0.0', + dependencies={'left-pad': '1.3.0'})) + '\n').encode()} + for name, data in files.items(): + (project / name).parent.mkdir(parents=True, exist_ok=True) + (project / name).write_bytes(data) + return files + + +def seed_manifest(project): + """Write a .socket/manifest.json holding one record for a purl the project + does not install (the schema the CLI writes: uuid, exportedAt, files, + vulnerabilities, description, license, tier) plus its after-blob under + .socket/blobs/, so the record is locally satisfied exactly like + a committed one — without the blob the vendor step aborts the whole run + with `no_local_source` before it ever reaches the bun refusal.""" + after = b'// seeded by backtest-bun.py\n' + manifest = {'patches': {OTHER_PURL: { + 'uuid': '00000000-0000-4000-8000-000000000000', + 'exportedAt': 'Mon, 05 Jan 2026 17:03:26 GMT', + 'files': {'package/index.js': {'beforeHash': git_hash(b'// pristine\n'), + 'afterHash': git_hash(after)}}, + 'vulnerabilities': {}, 'description': 'seeded by backtest-bun.py', + 'license': 'MIT', 'tier': 'free'}}} + (project / '.socket/blobs').mkdir(parents=True) + (project / '.socket/blobs' / git_hash(after)).write_bytes(after) + save(project / '.socket/manifest.json', manifest) + return manifest + + +def get_verb(shape): + if shape in ('get-uuid', 'workspace-get-uuid'): + return ['get', UUID] + if shape in ('get-search', 'workspace-get-search'): + return ['get', PURL] + return ['scan'] + + +def cell_applies(version, shape, mode): + """Which (version, shape, mode) cells exist: shapes need the Bun feature + they exercise, and the two-mode shapes run only where both modes are + supported (their `mode` is the vendored flavor).""" + v = ver(version) + if shape in ('isolated', 'hoisted') and v < LINKER_FROM: + return False + if shape in TEXT_OPT_IN_SHAPES and v < TEXT_LOCK_FROM: + return False + if shape == 'text-workspace' and v >= TEXT_DEFAULT_FROM: + return False # the text lock is the default there: identical to `workspace` + if shape == 'legacy-lockb' and v < TEXT_LOCK_FROM: + return False # the matrix release would be the legacy writer itself + if shape in ('crlf-lock', 'custom-registry') and v < TEXT_DEFAULT_FROM: + return False # both need a default text bun.lock to edit + if shape in GET_SHAPES and mode == 'vendored-detached': + return False # `get` has no --detached + if shape in CONVERSION_SHAPES and v < TEXT_DEFAULT_FROM: + return False + if shape in ('hosted-then-vendored', 'vendored-then-hosted') and mode == 'hosted': + return False # their `mode` is the vendored flavor of the conversion + if shape == 'preexisting-manifest' and (mode == 'hosted' or v >= LOCK_V2_FROM): + return False # always a refused vendored run (bun.lockb or a pre-v2 workspace) + return True + + +def expected_outcome(version, shape, mode): + """The DOCUMENTED outcome of one cell — never derived from the CLI's output. + + supported: whether the patch must land; codes: the EXACT refusal-code set + (after removing INFORMATIONAL); exit: 'zero' (supported, hosted refusals, + upstream limitations) or 'nonzero' (vendored / detached / get refusals); + limitation: the row annotation for an unsupported cell; rerun: the main + command is a documented no-op re-run (already_vendored) rather than a + first application.""" + v = ver(version) + hosted = mode == 'hosted' + scan = shape not in GET_SHAPES + workspace = shape in WORKSPACE_SHAPES + text_lock = (v >= TEXT_DEFAULT_FROM or (shape in TEXT_OPT_IN_SHAPES and v >= TEXT_LOCK_FROM)) \ + and shape != 'legacy-lockb' + # lockfileVersion of the text lock this release writes (fresh or migrated). + lock_version = 2 if v >= LOCK_V2_FROM else 1 if v >= TEXT_DEFAULT_FROM else 0 + + def outcome(supported, codes=(), exit='zero', limitation=None, rerun=False): + return dict(supported=supported, codes=set(codes), exit=exit, + limitation=limitation, rerun=rerun) + + if version in NO_PEER_OR_OVERRIDE and shape in ('peer', 'transitive'): + return outcome(False, limitation='This Bun release does not install the requested ' + 'peer or honor the transitive override') + if shape == 'already-vendored-workspace': + return outcome(True, rerun=True) + if not text_lock: + if shape == 'lockfile-only': + return outcome(False, {'bun_lockb_unsupported'}, + limitation='A binary bun.lockb without node_modules supplies no ' + 'package inventory') + if hosted: + if v < LOCKFILE_ONLY_FROM: + return outcome(False, {'redirect_bun_lockb_manual_migration'}, + limitation='This Bun release accepts `bun install ' + '--save-text-lockfile --frozen-lockfile ' + '--lockfile-only` but writes no bun.lock') + if workspace and lock_version == 0: + # Migration lands a version-0 lock; its workspace entries are + # refused and the migration is unwound. + return outcome(False, {'redirect_bun_workspace_unsupported', + 'redirect_bun_lockb_migration_reverted'}, + limitation='The migrated version-0 workspace lock cannot ' + 'carry hosted tarballs; bun.lockb restored') + return outcome(True) + codes = {'vendor_bun_lockb_unsupported'} | ({'bun_lockb_unsupported'} if scan else set()) + return outcome(False, codes, 'nonzero', + limitation='Vendored mode cannot rewrite a binary bun.lockb') + if workspace: + if hosted and lock_version == 0: + return outcome(False, {'redirect_bun_workspace_unsupported'}, + limitation='Version-0 workspace locks cannot carry hosted tarballs') + if not hosted and lock_version < 2: + return outcome(False, {'vendor_bun_workspace_unsupported'}, 'nonzero', + limitation='Pre-v2 workspace locks may be installed by Bun < 1.4, ' + 'which resolves local tarballs relative to the member') + return outcome(True) + + def installed_targets(project): targets = [] for manifest in project.rglob('package.json'): @@ -128,16 +454,120 @@ def oracle(project, record, side): return bool(targets) and bool(checks) and all(checks.values()), checks +def parse_envelope(output): + return json.loads(output[output.index('{'):]) + + +def envelope_codes(envelope): + """(codes about the minimist patch or carrying no purl, codes about other + purls) — every channel the CLI reports on: redirect.warnings, top-level + warnings, vendor.events, download.patches, patches, error.""" + mine, others = [], [] + + def take(purl, code): + if code: + (mine if purl in (None, PURL) else others).append(code) + for w in envelope.get('redirect', {}).get('warnings', []): + take(None, w.get('code')) + for w in envelope.get('warnings', []): + take(None, w.get('code') if isinstance(w, dict) else w) + for e in envelope.get('vendor', {}).get('events', []): + take(e.get('purl'), e.get('errorCode')) + for p in envelope.get('download', {}).get('patches', []): + take(p.get('purl'), p.get('errorCode')) + for p in envelope.get('patches', []): + take(p.get('purl'), p.get('errorCode')) + if isinstance(envelope.get('error'), dict): + take(None, envelope['error'].get('code')) + return mine, others + + +def applied_count(envelope, mode): + if mode == 'hosted': + return envelope.get('redirect', {}).get('redirected', 0) + return envelope.get('vendor', {}).get('summary', {}).get('applied', 0) + + +def downloaded_count(envelope): + return envelope.get('download', {}).get('downloaded', envelope.get('downloaded', 0)) + + +def rerun_clean(code, envelope, mode): + """The documented no-op re-run: hosted re-confirms the wiring (redirected 1, + nothing rewritten, no warnings beyond advisories); vendored / detached + skips exactly one already_vendored purl with nothing failed.""" + if code != 0 or envelope.get('status') != 'success': + return False + codes, _ = envelope_codes(envelope) + if mode == 'hosted': + return (envelope.get('redirect', {}).get('redirected') == 1 + and not set(codes) - INFORMATIONAL) + vendor = envelope.get('vendor', {}) + summary, events = vendor.get('summary', {}), vendor.get('events', []) + return (summary.get('applied') == 0 and summary.get('skipped') == 1 + and summary.get('failed') == 0 + and sum(e.get('errorCode') == 'already_vendored' for e in events) == 1 + and not any(e.get('action') == 'failed' for e in events) + and not set(codes) - INFORMATIONAL - {'already_vendored'}) + + +def ledger_record(project, mode): + """The patch record the mode's ledger holds for PURL (None when absent).""" + path = project / ('.socket/vendor/redirect-state.json' if mode == 'hosted' + else '.socket/vendor/state.json' if mode == 'vendored-detached' + else '.socket/manifest.json') + if not path.is_file(): + return None + state = json.loads(path.read_text(encoding='utf-8')) + if mode == 'hosted': + return state.get('records', {}).get(PURL) + if mode == 'vendored-detached': + return state.get('entries', {}).get(PURL, {}).get('record') + return state.get('patches', {}).get(PURL) + + +def load_json(path): + return json.loads(path.read_text(encoding='utf-8')) if path.is_file() else None + + +def wired_fragments(project, mode): + """The {original, new} bun.lock line pair the mode's ledger recorded for + PURL: the hosted ledger's `redirect_bun_lock_package` edit, or the + vendored ledger's `bun_lock_package` wiring.""" + if mode == 'hosted': + edits = load_json(project / '.socket/vendor/redirect-state.json')['edits'] + return next(e for e in edits if e['path'] == 'bun.lock' and e['kind'] == 'redirect_bun_lock_package') + wiring = load_json(project / '.socket/vendor/state.json')['entries'][PURL]['wiring'] + return next(w for w in wiring if w['file'] == 'bun.lock') + + +def crlf_only(data): + return all(line.endswith(b'\r\n') for line in data.splitlines(keepends=True) if line.strip()) + + +def tamper_digests(lock, marker): + """Replace the sha512 on every tuple line carrying `marker`.""" + return b''.join( + re.sub(rb'sha512-[A-Za-z0-9+/=]+(?="\])', b'sha512-' + b'A' * 86 + b'==', line) + if marker in line else line for line in lock.splitlines(keepends=True)) + + +def remove_node_modules(project): + for modules in sorted(project.rglob('node_modules'), key=lambda p: len(p.parts)): + if modules.exists() and not modules.is_symlink(): + shutil.rmtree(modules) + + def main(): - parser = argparse.ArgumentParser(description=__doc__) + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument('--cli', type=Path, required=True) parser.add_argument('--cli-revision', required=True) parser.add_argument('--output', type=Path, required=True) parser.add_argument('--tools', type=Path) parser.add_argument('--versions', nargs='+', default=VERSIONS) parser.add_argument('--shapes', nargs='+', default=SHAPES, choices=SHAPES) - parser.add_argument('--modes', nargs='+', default=['hosted', 'vendored'], - choices=['hosted', 'vendored', 'vendored-detached']) + parser.add_argument('--modes', nargs='+', default=['hosted', 'vendored'], choices=MODES) parser.add_argument('--jobs', type=int, default=4) args = parser.parse_args() root = args.output.resolve() @@ -145,157 +575,355 @@ def main(): cli = root / ('socket-patch.exe' if platform.system() == 'Windows' else 'socket-patch') shutil.copy2(args.cli.resolve(), cli) toolroot = (args.tools or root / 'tools').resolve() - tools = {v: install_tool(toolroot, v) for v in args.versions} + provenance = dict(capturedAt=datetime.now(timezone.utc).isoformat(), + os=platform.system().lower(), platform=platform.platform(), + cliRevision=args.cli_revision, cliSha256=sha256(cli.read_bytes())) + # The legacy-lockb shape baselines every project with the last binary-only + # release, whatever the matrix version. + needed = list(dict.fromkeys([*args.versions, + *([LEGACY_BUN] if 'legacy-lockb' in args.shapes else [])])) + try: + tools = {v: install_tool(toolroot, v) for v in needed} + except Exception as error: # noqa: BLE001 — the artifact must explain the run + rows = [dict(bun=v, shape='*', mode='*', passed=False, + error=f'tool install failed: {error}', **provenance) for v in args.versions] + save(root / 'summary.json', rows) + print('tool install failed:', error, flush=True) + return 1 + tool_sha = {v: dict(bunSha256=sha256(path.read_bytes()), + bunArchiveSha256=archive_sha256(toolroot, v)) for v, path in tools.items()} base_env = {k: v for k, v in os.environ.items() if not k.startswith(('SOCKET_', 'BUN_', 'npm_config_', 'NPM_CONFIG_'))} base_env.update(SOCKET_NO_CONFIG='1', SOCKET_NO_UPDATE_CHECK='1', NO_COLOR='1') - provenance = dict(capturedAt=datetime.now(timezone.utc).isoformat(), - os=platform.system().lower(), platform=platform.platform(), - cliRevision=args.cli_revision, - cliSha256=hashlib.sha256(cli.read_bytes()).hexdigest()) def backtest(job): version, shape, mode = job + expected = expected_outcome(version, shape, mode) case = root / 'captures' / f'{version}-{shape}-{mode}' case.mkdir(parents=True, exist_ok=True) project = case / ('project space café' if shape == 'space-unicode' else 'project') if project.exists(): shutil.rmtree(project) project.mkdir() - row = dict(bun=version, shape=shape, mode=mode, passed=False, **provenance) - checks = {} + row = dict(bun=version, shape=shape, mode=mode, passed=False, **provenance, + **tool_sha[version], supported=expected['supported'], + expected=dict(supported=expected['supported'], + refusals=sorted(expected['codes']), exit=expected['exit'])) + checks, exit_codes = {}, {} row['checks'] = checks + row['exitCodes'] = exit_codes + # The two-mode shapes: `pre_mode` is the mode applied first, `main_mode` + # the mode of the command the row is about (its ledger, its envelope). + main_mode = 'hosted' if shape == 'vendored-then-hosted' else mode + pre_mode = 'hosted' if shape == 'hosted-then-vendored' else mode try: bun = tools[version] - env = dict(base_env, PATH=str(bun.parent) + os.pathsep + base_env['PATH'], - BUN_INSTALL_CACHE_DIR=str(case / 'cache'), - BUN_INSTALL=str(case / 'bun-home')) + + def env_for(binary, cache): + return dict(base_env, PATH=str(binary.parent) + os.pathsep + base_env['PATH'], + BUN_INSTALL_CACHE_DIR=str(case / cache), + BUN_INSTALL=str(case / 'bun-home')) + env = env_for(bun, 'cache') + + def cli_command(verb, run_mode): + command = [cli, *verb, '--mode', 'vendored' if run_mode == 'vendored-detached' else run_mode, + '--cwd', project, '--json', '--yes', '--no-telemetry'] + if run_mode == 'vendored-detached': + command.append('--detached') + return command + + def install(binary, label, flags=(), cache=None): + remove_node_modules(project) + return run([binary, 'install', '--ignore-scripts', *flags], project, + env_for(binary, cache or 'cache-' + label), case / (label + '.log'), False) + files = project_files(shape) for name, data in files.items(): (project / name).parent.mkdir(parents=True, exist_ok=True) (project / name).write_bytes(data) + baseline_bun = tools[LEGACY_BUN] if shape == 'legacy-lockb' else bun install_args = ['install', '--ignore-scripts'] - if shape == 'text': + if shape in TEXT_OPT_IN_SHAPES: install_args += ['--save-text-lockfile'] - run([bun, *install_args], project, env, case / 'baseline.log') + # The legacy writer never shares a cache with the matrix release. + run([baseline_bun, *install_args], project, + env_for(baseline_bun, 'cache-legacy' if shape == 'legacy-lockb' else 'cache'), + case / 'baseline.log') + lock = project / 'bun.lock' + if shape == 'crlf-lock': + lock.write_bytes(lock.read_bytes().replace(b'\r\n', b'\n').replace(b'\n', b'\r\n')) + code, _ = install(bun, 'crlf-accepted', ['--frozen-lockfile']) + checks['crlfLockAccepted'] = code == 0 and crlf_only(lock.read_bytes()) + if shape == 'custom-registry': + # bun writes "" for its default registry, so `.npmrc` alone never + # fills the slot: inject the full-URL form bun emits for any other + # registry and prove bun installs from it before the CLI runs. + text = lock.read_text(encoding='utf-8') + injected = text.replace('["minimist@1.2.2", ""', f'["minimist@1.2.2", "{REGISTRY_SLOT}"') + checks['registrySlotInjected'] = injected != text + lock.write_text(injected, encoding='utf-8') + code, _ = install(bun, 'registry-accepted', ['--frozen-lockfile']) + checks['registrySlotAccepted'] = code == 0 and lock.read_text(encoding='utf-8') == injected original = {name: (project / name).read_bytes() for name in [*files, 'bun.lock', 'bun.lockb'] if (project / name).exists()} - row['originalSha256'] = {n: hashlib.sha256(b).hexdigest() for n, b in original.items()} - checks['installedBefore'] = bool(installed_targets(project)) + row['originalSha256'] = {n: sha256(b) for n, b in original.items()} + if shape in TEXT_OPT_IN_SHAPES: + checks['textLockWritten'] = 'bun.lock' in original and 'bun.lockb' not in original + if shape == 'legacy-lockb': + checks['legacyLockbBaseline'] = 'bun.lockb' in original and 'bun.lock' not in original + installed = bool(installed_targets(project)) + if version in NO_PEER_OR_OVERRIDE and shape in ('peer', 'transitive'): + checks['upstreamNotInstalled'] = not installed + else: + checks['installedBefore'] = installed + if 'bun.lock' in original: + # Registry 4-tuples are digest-verified on every text-lock + # release: the baseline every rewrite is compared against. + scratch = case / 'registry-tamper' + if scratch.exists(): + shutil.rmtree(scratch) + shutil.copytree(project, scratch, ignore=shutil.ignore_patterns('node_modules', '.socket')) + tampered = tamper_digests(original['bun.lock'], b'minimist@1.2.2"') + (scratch / 'bun.lock').write_bytes(tampered) + code, output = run([bun, 'install', '--ignore-scripts', '--frozen-lockfile'], scratch, + env_for(bun, 'cache-registry-tamper'), case / 'registry-tamper.log', + False, timeout=60, tolerate_timeout=True) + checks['registryDigestEnforced'] = ( + tampered != original['bun.lock'] and code != 0 + and ('integrity' in output.lower() or 'checksum' in output.lower())) + if code is None: + row.setdefault('notes', []).append( + 'bun printed the registry integrity error but never exited (killed after 60 s)') + shutil.rmtree(scratch, ignore_errors=True) if shape == 'lockfile-only': shutil.rmtree(project / 'node_modules') - verb = ['get', UUID if shape == 'get-uuid' else PURL] if shape.startswith('get-') else ['scan'] - command = [cli, *verb, '--mode', 'vendored' if mode == 'vendored-detached' else mode, '--cwd', project, - '--json', '--yes', '--no-telemetry'] - if mode == 'vendored-detached': - command.append('--detached') + seeded = seed_manifest(project) if shape == 'preexisting-manifest' else None + + if shape in CONVERSION_SHAPES: + # First mode (or the first vendoring): must land and install. + code, output = run(cli_command(['scan'], pre_mode), project, env, case / 'conversion.log', False) + exit_codes['conversion'] = code + pre_envelope = parse_envelope(output) + save(case / 'conversion-output.json', pre_envelope) + pre_record = ledger_record(project, pre_mode) + checks['conversionApplied'] = (code == 0 and applied_count(pre_envelope, pre_mode) == 1 + and pre_record is not None) + if not checks['conversionApplied']: + raise RuntimeError(f'Expected the first mode to apply: {output[-4000:]}') + code, _ = install(bun, 'conversion-frozen', ['--frozen-lockfile']) + checks['conversionPatchedBytes'] = code == 0 and oracle(project, pre_record, 'after')[0] + if shape == 'already-vendored-workspace': + # Grow the wired project into a workspace with the SAME bun + # (bun re-saves the lock: its version is kept, and the + # wired tuple must survive verbatim), so the re-run meets + # an already-wired purl inside a workspace lock. + files = {**files, **add_workspace_member(project)} + code, _ = install(bun, 'member', cache='cache') + text = lock.read_text(encoding='utf-8') + checks['memberInstall'] = code == 0 and 'workspace:packages/consumer' in text + wiring = wired_fragments(project, pre_mode) + # Bun < 1.3.10 re-saves URL/local tarball tuples WITHOUT + # their sha512 (a 2-tuple), which the CLI then no longer + # recognizes as its own wiring: re-runs and rollback break. + checks['wiringSurvivesInstall'] = wiring['new'] in text + # Rollback restores the wired line only: the pristine lock + # is the grown lock with the registry line put back. + original = {name: (project / name).read_bytes() for name in files} + original['bun.lock'] = lock.read_bytes().replace( + wiring['new'].encode(), wiring['original'].encode()) + + command = cli_command(get_verb(shape), main_mode) code, output = run(command, project, env, case / 'cli.log', False) - envelope = json.loads(output[output.index('{'):]) + exit_codes['main'] = code + row['exitCode'] = code + envelope = parse_envelope(output) save(case / 'cli-output.json', envelope) - applied = (envelope.get('redirect', {}).get('redirected', 0) if mode == 'hosted' - else envelope.get('vendor', {}).get('summary', {}).get('applied', 0)) - warnings = (envelope.get('redirect', {}).get('warnings', []) if mode == 'hosted' - else envelope.get('vendor', {}).get('events', [])) - row['refusals'] = [w.get('code', w.get('errorCode')) for w in warnings] - row['refusals'] += [p['errorCode'] for p in envelope.get('download', {}).get('patches', []) - if p.get('errorCode')] - row['refusals'] += [p['errorCode'] for p in envelope.get('patches', []) if p.get('errorCode')] + applied = applied_count(envelope, main_mode) + codes, other_codes = envelope_codes(envelope) + # A documented no-op re-run skips its purl as already_vendored; + # that skip reason is the expected outcome there, not a refusal. + expected_skips = {'already_vendored'} if expected['rerun'] else set() + refusals = set(codes) - INFORMATIONAL - expected_skips + row['codes'] = sorted(set(codes)) + row['otherPurlCodes'] = sorted(set(other_codes)) + row['refusals'] = sorted(refusals) row['applied'] = applied - if not checks['installedBefore'] and version in ['0.8.1', '1.0.0'] and shape in ['peer', 'transitive']: - row['supported'] = False - row['upstreamLimitations'] = ['This Bun release does not install the requested peer or honor the transitive override'] - del checks['installedBefore'] + checks['refusalCodesExact'] = refusals == expected['codes'] + if expected['supported']: + checks['noRegressionCodes'] = not set(codes) & REGRESSION_CODES + manifest = project / '.socket/manifest.json' + if not expected['supported']: + row['upstreamLimitations'] = [expected['limitation']] checks['noPatchApplied'] = applied == 0 checks['unchanged'] = all((project / n).read_bytes() == b for n, b in original.items()) - elif any('bun_workspace_unsupported' in (x or '') for x in row['refusals']): - row['supported'] = False - checks['refused'] = applied == 0 - checks['unchanged'] = all((project / n).read_bytes() == b for n, b in original.items()) - elif 'bun.lockb' in original and not (project / 'bun.lock').exists(): - row['supported'] = False - checks['refusedOrNoDiscoverablePackages'] = applied == 0 and ( - any('bun_lockb' in (x or '') or shape == 'alias' and x == 'package_not_installed' for x in row['refusals']) - or shape == 'lockfile-only' and envelope.get('scannedPackages') == 0 - and envelope.get('packagesWithPatches') == 0) - checks['unchanged'] = all((project / n).read_bytes() == b for n, b in original.items()) + checks['unchangedLockPresence'] = all((project / name).exists() == (name in original) + for name in ['bun.lock', 'bun.lockb']) + # Hosted refusals exit 0 with redirected 0 (documented posture); + # vendored / detached / get refusals exit non-zero and never fetch. + checks['exitCodeContract'] = code == 0 if expected['exit'] == 'zero' else code != 0 + if expected['exit'] == 'nonzero': + checks['noDownloadOnRefusal'] = downloaded_count(envelope) == 0 + after = load_json(manifest) + checks['noStrayManifestRecord'] = after is None or PURL not in after.get('patches', {}) + if seeded is not None: + checks['preexistingManifestPreserved'] = ( + after is not None and after.get('patches', {}).get(OTHER_PURL) == seeded['patches'][OTHER_PURL]) else: - row['supported'] = True - checks['cliSuccess'] = code == 0 and applied == 1 - if not checks['cliSuccess']: - raise RuntimeError(f'Expected one applied patch: {output[-4000:]}') - ledger = project / ('.socket/vendor/redirect-state.json' if mode == 'hosted' - else '.socket/vendor/state.json' if mode == 'vendored-detached' - else '.socket/manifest.json') - state = json.loads(ledger.read_text()) - record = (state['records'][PURL] if mode == 'hosted' else - state['entries'][PURL]['record'] if mode == 'vendored-detached' else state['patches'][PURL]) + if expected['rerun']: + checks['rerunClean'] = rerun_clean(code, envelope, main_mode) + if not checks['rerunClean']: + raise RuntimeError(f'Expected a clean no-op re-run: {output[-4000:]}') + else: + checks['cliSuccess'] = code == 0 and applied == 1 + if not checks['cliSuccess']: + raise RuntimeError(f'Expected one applied patch: {output[-4000:]}') + record = ledger_record(project, main_mode) + if record is None: + raise RuntimeError(f'No ledger record for {PURL} in {main_mode} mode') row['patchUuid'] = record['uuid'] checks['publishedPatch'] = record['uuid'] == UUID - if mode == 'vendored-detached': - checks['noManifest'] = not (project / '.socket/manifest.json').exists() + if main_mode == 'vendored-detached': + checks['noManifest'] = not manifest.exists() + patched_lock = lock.read_bytes() + lock_text = patched_lock.decode('utf-8') + if shape == 'hosted-then-vendored': + checks['takeoverReported'] = 'vendor_takeover_reverted_redirect' in codes + checks['localPathTuple'] = LOCAL_TUPLE_SPEC in lock_text + redirect_ledger = load_json(project / '.socket/vendor/redirect-state.json') + checks['redirectLedgerRecordGone'] = (redirect_ledger is None + or PURL not in redirect_ledger.get('records', {})) + pristine_line = next(line for line in original['bun.lock'].decode('utf-8').split('\n') + if '"minimist@1.2.2"' in line) + checks['vendorLedgerOriginalPristine'] = ( + wired_fragments(project, main_mode)['original'] == pristine_line) + elif shape == 'vendored-then-hosted': + checks['takeoverReported'] = 'redirect_takeover_reverted_vendored' in codes + checks['urlTuple'] = HOSTED_TUPLE_PREFIX in lock_text + checks['vendorArtifactGone'] = not (project / '.socket/vendor/npm' / UUID).exists() + vendor_ledger = load_json(project / '.socket/vendor/state.json') + checks['vendorLedgerEntryGone'] = (vendor_ledger is None + or PURL not in vendor_ledger.get('entries', {})) + # Observed contract: the hosted takeover unwinds the vendored + # wiring, ledger entry and artifact but leaves the vendored-era + # manifest record in place (rollback removes it); detached + # vendoring never wrote one. + after = load_json(manifest) + if mode == 'vendored': + checks['manifestRecordKeptAfterHostedTakeover'] = ( + after is not None and PURL in after.get('patches', {})) + else: + checks['noManifest'] = after is None + if shape == 'custom-registry': + checks['registrySlotDropped'] = REGISTRY_SLOT not in lock_text + if shape == 'crlf-lock': + checks['lockEolPreserved'] = crlf_only(patched_lock) + lockb_origin = 'bun.lockb' in original and 'bun.lock' not in original + if lockb_origin: + # Migration normalizes every release to the >= 1.2 shape: text + # lock present, bun.lockb gone, and the ledger holds its bytes. + checks['lockbMigrated'] = lock.exists() and not (project / 'bun.lockb').exists() + edits = load_json(project / '.socket/vendor/redirect-state.json').get('edits', []) + removal = [e for e in edits if e.get('path') == 'bun.lockb' + and e.get('kind') == 'redirect_bun_lockb_migrated' and e.get('action') == 'removed'] + checks['lockbLedgerRecord'] = len(removal) == 1 and isinstance(removal[0].get('original'), str) \ + and base64.b64decode(removal[0]['original']) == original['bun.lockb'] capture = case / 'tree' if capture.exists(): shutil.rmtree(capture) capture.mkdir() - names = [*files, 'bun.lock', 'bun.lockb', '.socket/manifest.json'] - for name in names: + for name in [*files, 'bun.lock', 'bun.lockb', '.socket/manifest.json']: source = project / name if source.is_file(): destination = capture / name destination.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(source, destination) - row['manifestSha256'] = {p.relative_to(capture).as_posix(): hashlib.sha256(p.read_bytes()).hexdigest() + row['manifestSha256'] = {p.relative_to(capture).as_posix(): sha256(p.read_bytes()) for p in capture.rglob('*') if p.is_file()} - patched_lock = (project / 'bun.lock').read_bytes() for label, flags in [('frozen', ['--frozen-lockfile']), ('ordinary', [])]: if shape == 'production': flags = [*flags, '--production'] - for modules in sorted(project.rglob('node_modules'), key=lambda p: len(p.parts)): - if modules.exists() and not modules.is_symlink(): - shutil.rmtree(modules) - fresh_env = dict(env, BUN_INSTALL_CACHE_DIR=str(case / ('cache-' + label))) - run([bun, 'install', '--ignore-scripts', *flags], project, fresh_env, case / (label + '.log')) + code, _ = install(bun, label, flags) correct, hashes = oracle(project, record, 'after') - checks[label + 'PatchedBytes'] = correct + checks[label + 'PatchedBytes'] = code == 0 and correct row[label + 'Files'] = hashes - checks[label + 'StableLock'] = (project / 'bun.lock').read_bytes() == patched_lock - _, repeat = run(command, project, env, case / 'repeat.log', False) - row['repeat'] = json.loads(repeat[repeat.index('{'):]) - checks['repeatStableLock'] = (project / 'bun.lock').read_bytes() == patched_lock - tampered = b''.join(re.sub(rb'sha512-[A-Za-z0-9+/=]+(?="\])', b'sha512-' + b'A' * 86 + b'==', line) if UUID.encode() in line else line for line in patched_lock.splitlines(keepends=True)) + checks[label + 'StableLock'] = lock.read_bytes() == patched_lock + code, repeat = run(command, project, env, case / 'repeat.log', False) + exit_codes['repeat'] = code + row['repeat'] = parse_envelope(repeat) + checks['repeatStableLock'] = lock.read_bytes() == patched_lock + checks['repeatClean'] = rerun_clean(code, row['repeat'], main_mode) + if shape == 'already-vendored-workspace' and main_mode != 'hosted': + # A deleted committed artifact is rebuilt by `repair` + # (locally, so its tarball digest may differ from the + # service prebuilt one and the lock line is re-pinned) and + # a cold frozen install from the repaired lock still yields + # the patched bytes. Every later step judges the REPAIRED lock. + artifact = project / '.socket/vendor/npm' / UUID / 'minimist-1.2.2.tgz' + artifact.unlink() + code, output = run([cli, 'repair', '--cwd', project, '--json', '--yes', '--no-telemetry'], + project, env, case / 'repair.log', False) + exit_codes['repair'] = code + repaired = parse_envelope(output) + row['repair'] = repaired + checks['repairRebuilt'] = ( + code == 0 and artifact.is_file() + and any(e.get('action') == 'rebuilt' and e.get('purl') == PURL + for e in repaired.get('events', []))) + patched_lock = lock.read_bytes() + checks['repairKeepsLocalTuple'] = LOCAL_TUPLE_SPEC in patched_lock.decode('utf-8') + code, _ = install(bun, 'repair-frozen', ['--frozen-lockfile']) + checks['repairFrozenPatchedBytes'] = code == 0 and oracle(project, record, 'after')[0] + checks['repairStableLock'] = lock.read_bytes() == patched_lock + tampered = tamper_digests(patched_lock, UUID.encode()) checks['tamperedDigest'] = tampered != patched_lock - (project / 'bun.lock').write_bytes(tampered) - for modules in sorted(project.rglob('node_modules'), key=lambda p: len(p.parts)): - if modules.exists() and not modules.is_symlink(): - shutil.rmtree(modules) + lock.write_bytes(tampered) + remove_node_modules(project) code, output = run([bun, 'install', '--ignore-scripts', '--frozen-lockfile'], project, - dict(env, BUN_INSTALL_CACHE_DIR=str(case / 'cache-corrupt')), - case / 'corrupt.log', False) - row['rejectsCorruptDigest'] = code != 0 and ('integrity' in output.lower() or 'checksum' in output.lower()) - # Older Bun accepts tarball hashes but does not enforce them. - if tuple(map(int, version.split('.'))) < (1, 3, 14): - checks['legacyDigestBehavior'] = code == 0 - row.setdefault('upstreamLimitations', []).append('Bun does not verify tarball integrity on this release') - else: + env_for(bun, 'cache-corrupt'), case / 'corrupt.log', False, + timeout=60, tolerate_timeout=True) + row['rejectsCorruptDigest'] = code != 0 and ('integrity' in output.lower() + or 'checksum' in output.lower()) + if code is None: + row.setdefault('notes', []).append( + 'bun printed the tarball integrity error but never exited (killed after 60 s)') + if ver(version) >= TARBALL_INTEGRITY_ENFORCED_FROM: checks['rejectCorruptDigest'] = row['rejectsCorruptDigest'] - (project / 'bun.lock').write_bytes(patched_lock) - run([cli, 'rollback', '--cwd', project, '--json', '--yes', '--no-telemetry'], - project, env, case / 'rollback.log') + else: + # Below the boundary bun's behaviour is RECORDED, not asserted: + # the rewrite trades a verified registry tuple for an unverified + # tarball tuple on these releases. + checks['legacyDigestBehavior'] = True + row.setdefault('upstreamLimitations', []).append( + 'Bun %s %s a tampered digest on the patched tarball tuple (enforcement ' + 'documented from %s); registry tuples are verified' % ( + version, 'rejected' if row['rejectsCorruptDigest'] else 'accepted', + '.'.join(map(str, TARBALL_INTEGRITY_ENFORCED_FROM)))) + lock.write_bytes(patched_lock) + code, output = run([cli, 'rollback', '--cwd', project, '--json', '--yes', '--no-telemetry'], + project, env, case / 'rollback.log', False) + exit_codes['rollback'] = code + rolled = parse_envelope(output) + rollback_codes = [w.get('code') for w in rolled.get('warnings', [])] + row['rollbackWarnings'] = rollback_codes + checks['rollbackSucceeded'] = code == 0 and rolled.get('status') == 'success' checks['rollbackOriginalFiles'] = all((project / n).exists() and (project / n).read_bytes() == b for n, b in original.items()) - for modules in sorted(project.rglob('node_modules'), key=lambda p: len(p.parts)): - if modules.exists() and not modules.is_symlink(): - shutil.rmtree(modules) - run([bun, 'install', '--ignore-scripts'], project, - dict(env, BUN_INSTALL_CACHE_DIR=str(case / 'cache-rollback')), case / 'reinstall.log') + if lockb_origin: + # bun.lockb comes back from the ledger; the text lock generated + # during the redirect stays (Bun >= 1.1.39 reads bun.lock). + checks['rollbackLockPresence'] = (project / 'bun.lockb').exists() and lock.exists() + checks['lockbRestoredWarning'] = ('redirect_bun_lockb_restored' in rollback_codes + and 'redirect_bun_lockb_unrestorable' not in rollback_codes) + else: + checks['rollbackLockPresence'] = lock.exists() and not (project / 'bun.lockb').exists() + checks['rollbackWarningsClean'] = not set(rollback_codes) - INFORMATIONAL + if shape == 'crlf-lock': + checks['rollbackEolPreserved'] = crlf_only(lock.read_bytes()) + code, _ = install(bun, 'reinstall', cache='cache-rollback') checks['rollbackOriginalBytes'], row['rollbackFiles'] = oracle(project, record, 'before') - if not row.get('supported'): - manifest = project / '.socket/manifest.json' - checks['noFalseManifestAnnotation'] = not manifest.exists() or PURL not in json.loads(manifest.read_text())['patches'] - checks['unchangedLockPresence'] = all((project / name).exists() == (name in original) - for name in ['bun.lock', 'bun.lockb']) + checks['rollbackOriginalBytes'] = code == 0 and checks['rollbackOriginalBytes'] row['passed'] = all(checks.values()) - except Exception as error: + except Exception as error: # noqa: BLE001 — every cell must produce a row row['error'] = str(error) save(case / 'result.json', row) print(version, shape, mode, 'PASS' if row['passed'] else 'FAIL', @@ -303,13 +931,16 @@ def backtest(job): return row jobs = [(v, s, m) for v in args.versions for s in args.shapes for m in args.modes - if (s not in ('isolated', 'hoisted') or tuple(map(int, v.split('.'))) >= (1, 3, 0)) - and (s != 'text' or tuple(map(int, v.split('.'))) >= (1, 1, 38)) - and (not s.startswith('get-') or m != 'vendored-detached')] + if cell_applies(v, s, m)] with concurrent.futures.ThreadPoolExecutor(max_workers=args.jobs) as pool: rows = list(pool.map(backtest, jobs)) save(root / 'summary.json', rows) - return 0 if all(row['passed'] for row in rows) else 1 + for version in args.versions: + mine = [r for r in rows if r['bun'] == version] + print(f'bun {version}: {sum(r["passed"] for r in mine)}/{len(mine)} passed, ' + f'{sum(bool(r.get("supported")) for r in mine)} supported, ' + f'{sum(not r.get("supported") for r in mine)} unsupported', flush=True) + return 0 if rows and all(row['passed'] for row in rows) else 1 if __name__ == '__main__': From be4c165a791e987e15ad45641344f19e92df8e9d Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 21 Sep 2026 10:52:26 -0400 Subject: [PATCH 29/49] docs(bun): contract, ecosystem matrix and changelog for the merged Bun fixes CLI_CONTRACT.md now describes the MERGED bun behaviour: text bun.lock lockfileVersion 0/1/2 (0 = Bun 1.1.39-1.1.45 --save-text-lockfile, 1 = 1.2-1.3, 2 = 1.4+) with the shared version-gate message, the version-0 workspace refusal (redirect_bun_workspace_unsupported) and its verified remedy, the truthful bun.lockb migration (PATH-resolved bun incl. Windows bun.cmd shims, works from 1.1.43, CLI removes a surviving bun.lockb so the `removed` ledger edit is true, pre-migration bytes kept base64 up to 8 MiB, redirect_bun_lockb_manual_migration for 1.1.39-1.1.42, output tail on redirect_bun_lockb_unsupported), rollback's redirect_bun_lockb_restored / narrowed redirect_bun_lockb_unrestorable, bun's participation in the per-purl hosted revert (hosted->vendored takeover, scoped rollback/remove, dry-run probe), the vendored pre-download preflight on every get/scan/ detached path (codes, no fetch, search-path partial_failure records with errorCode+error, uuid-path status:"error" envelope, already-vendored exemption, --silent visibility, --dry-run would_refuse), the vendored workspace policy gate (pre-v2 workspace locks; delete bun.lock + re-lock with Bun >= 1.4, or hosted) and the measured digest boundary (URL/local tarball sha512 enforced by Bun >= 1.3.10, registry tuples from 1.2.0). Error-code table rows for every bun code; patches[] shape notes that a failed record may carry errorCode. Everything is additive (MINOR). docs/ecosystems.md: bun rows carry the same facts, short. CHANGELOG.md [Unreleased] ### Fixed: one house-style bun entry covering lockfileVersion 0, the workspace refusals + remedies, the pre-download preflight per path, the working takeover + scoped unwind, the truthful lockb migration + restore, bun_lockb_unsupported, --silent, detached parity, would_refuse, CRLF, the real-bun CI legs and the corrected digest boundary, pointing at docs/testing/bun-compatibility.md and scripts/backtest-bun.py (#245). Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 53 +++++++++++++++++++++++++ crates/socket-patch-cli/CLI_CONTRACT.md | 50 +++++++++++++++++------ docs/ecosystems.md | 38 ++++++++++++++---- 3 files changed, 120 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad24f5d4..42b692d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -264,6 +264,59 @@ into the new version's section — see docs/releasing.md. ### Fixed +- **Bun projects: every text-lock generation is accepted, vendored refusals + fire before any write, and every mode change unwinds.** `bun.lock` + `lockfileVersion` 0 — the opt-in text lock Bun 1.1.39–1.1.45 write with + `--save-text-lockfile` — joins 1 and 2 in the shared version gate, so hosted + mode redirects it (golden fixture `npm/bun/lock-v0`), vendored mode wires it + and the lockfile inventory discovers it; a newer version is now refused with + "update socket-patch" instead of a re-lock that would reproduce it. Workspace + locks are refused only where Bun cannot consume the rewrite: hosted mode + refuses a version-0 lock holding `workspace:` packages + (`redirect_bun_workspace_unsupported`; a plain `bun install` with Bun ≥ 1.2 + rewrites it in place as version 1, which is accepted) and vendored mode + refuses any pre-version-2 workspace lock before writing + (`vendor_bun_workspace_unsupported` — Bun 1.2–1.3 resolve a workspace + member's local tarball path relative to the member; delete `bun.lock` and + re-lock with Bun ≥ 1.4, since an in-place `bun install` keeps the existing + version, or use hosted mode), while already-vendored purls, re-runs and + `repair` on such a lock keep working. `scan --mode vendored`, + `get --mode vendored` (search and uuid paths) and `--detached` runs now + preflight the Bun lock BEFORE any download: a binary-only, unreadable, + unsupported-version or pre-version-2 workspace lock marks the npm patches + `failed` with the vendor refusal code and detail, fetches nothing and + records no patch — the `scan` / `get ` path still writes an unchanged + `.socket/manifest.json` and exits `partial_failure`, `get --mode + vendored` exits 1 with `status: "error"` and writes nothing — where + previously the record landed in the manifest and the vendor step failed + afterwards (and a detached run over an alias install misreported + `package_not_installed`). The refusals stay visible under `--silent` + (code-tagged stderr line), `--dry-run` previews them as the additive + `would_refuse` action, the `bun.lockb` refusal carries one remedy on every + path (`bun install --save-text-lockfile`, Bun ≥ 1.1.39), and a `scan` on a + `bun.lockb`-only project warns `bun_lockb_unsupported` instead of reporting + a clean empty inventory. Hosted → vendored takeover now works for bun — + `scan`/`get --mode vendored` and `vendor` over a hosted-redirected `bun.lock` + claim and replay that purl's hosted edit instead of refusing + `redirect_revert_failed`, and `vendor --dry-run` probes the takeover instead + of promising it — as do `rollback ` / `remove ` of one of + several hosted bun records. The hosted `bun.lockb` migration is truthful: + `bun` is resolved on absolute `PATH` entries (Windows `bun.cmd` shims + included), a `bun.lockb` that Bun 1.1.43–1.1.45 keep beside the new text + lock is removed by the CLI so the ledger's `removed` edit is true, the + pre-migration bytes ride the ledger and `rollback` restores `bun.lockb` + (`redirect_bun_lockb_restored`; the generated `bun.lock` is kept), Bun + 1.1.39–1.1.42 — which accept the flags but write nothing — get + `redirect_bun_lockb_manual_migration` instead of a false "unavailable", and + a failed spawn's `redirect_bun_lockb_unsupported` carries bun's output tail. + The hosted rewrite keeps CRLF on the rewritten `bun.lock` line. Real-Bun + coverage now runs in CI: the hermetic hosted and vendored suites on Linux, + macOS and Windows (Bun 1.4.2, plus 1.1.45 and 1.2.23 lock-era legs), and + the production native matrix — 16 releases from 0.8.1 to 1.4.2 in hosted, + vendored and detached-vendored mode — on pull requests and `main`, with + the corrected digest boundary (Bun verifies URL/local tarball sha512 from + 1.3.10, not 1.3.14). See `docs/testing/bun-compatibility.md` and + `scripts/backtest-bun.py`. (#245) - **Rollback after a Pipenv relock no longer refuses forever.** `pipenv lock` (and `update`, and `install ` before 2024) regenerates a redirected or vendored entry to registry shape on every Pipenv major; that is now the diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 6558d95a..07d427f0 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -94,7 +94,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc `redirect_pnpm_no_lockfile` names pnpm when installer markers exist without a lock; `redirect_pnpm_entry_vendored` identifies a vendored entry instead of reporting it missing. Supported `shrinkwrap.yaml` files are writable lockfiles, not read-only markers. -**Takeover reconciliation (npm family)**: vendoring over a hosted-redirected purl drops that purl's records + package edits from `redirect-state.json` (the vendor wiring embeds the hosted-spliced fragments as `original`, so `vendor --revert` byte-restores the hosted lock); the `vendor_supersedes_redirect` warning fires exactly once, on the run that reconciles. +**Takeover reconciliation (npm family, bun included)**: vendoring over a hosted-redirected purl (`vendor`, `scan --mode vendored`, `get --mode vendored`) first REVERTS that purl's hosted lockfile edits to their pre-redirect registry values through the per-purl redirect revert, drops the purl's record + package edits from `redirect-state.json`, and then vendors — so the vendor ledger records the PRISTINE registry fragment as its wiring `original` and `vendor --revert` lands back on registry state, never on an expiring hosted URL. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; the human path prints `Warning (vendor_takeover_reverted_redirect): …`). `--dry-run` PROBES the same revert against an in-memory ledger clone instead of promising it: a clean probe reports `vendor_would_revert_redirect`, and a drifted lock or an undecidable ledger edit surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview stops at the advisory instead of reading the still-hosted lock). A purl whose hosted edits cannot be cleanly reverted fails `redirect_revert_failed` (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place, the remedy in the detail). **bun** participates like every other npm-family flavor: its `redirect_bun_lock_package` edits are claimed by the recorded line's spec — the registry spec `@`, or a hosted URL whose tarball leaf is `-.tgz` — so a sibling version's or an aliased sibling's edit is neither claimed nor a refusal, and only an edit that mentions the package without being a bun packages-entry line refuses (remedy: an unscoped `socket-patch rollback`, whose whole-ledger replay unwinds bun.lock hosted edits; never hand-edit the ledger). The same claim rule serves scoped `rollback ` / `remove ` of one of several hosted bun records (see "Hosted unwind coverage"). Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on bun locks of every supported lockfileVersion; pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. The separate run-level `vendor_supersedes_redirect` warning covers the reconcile-only case — a live lock that already proves vendored won over a stale hosted ledger record (the vendor wiring then holds the hosted-spliced fragment as `original`) — and fires exactly once, on the run that drops the stale records. `scan --apply` opts JSON callers into the full discover → select → apply pipeline. Without it, `scan --json` stays read-only (discovery + the `updates` array + the `redirectState` state block below). No effect outside `--json` mode — the non-JSON path always prompts the user interactively. @@ -114,13 +114,13 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Path-scoped scans (`scan [PATHS]...`, v5.0)**: optional variadic positional path globs scope DISCOVERY at the **purl level** — a package is in scope iff ANY of its crawled installed copies sits under a matching path, and a selected package is then handled with ALL its copies (scoping selects which packages are considered, never which copies). Glob semantics (shared with `rollback`'s path targets, `src/path_scope.rs`): Unix-shell globs with `require_literal_separator` — `*`/`?` never cross a `/`, `**` spans directories; a pattern matching any **ancestor** directory of the copy path also matches, so a bare `scan packages/foo` scopes the whole subtree without `/**`; relative patterns match against the copy path relativized to `--cwd`, absolute patterns against the absolute path (the ONLY way to reach paths outside the project tree, e.g. `--global` stores — a relative pattern never matches outside `--cwd`); leading `./` and trailing `/` are normalized away, matching is purely textual (no filesystem access or symlink resolution), case-sensitive except on Windows (whose filesystems are not); an unparseable or empty pattern is a usage error (exit 2). **The prune universe is never narrowed**: the path filter is applied strictly AFTER the `scanned_purls` capture (and after `--ecosystems`), so `scan PATHS --prune` prunes exactly what an unscoped `scan --prune` would — a scoped scan can never treat an out-of-scope package as uninstalled (the same fail-safe as the `--ecosystems` filter). Lockfile-only and vendor-ledger supplement records have no installed path and are EXCLUDED from a path-scoped scan, surfaced as one run-level `path_scope_excluded_supplements` warning carrying the count. A scope matching nothing is a normal empty scan — exit 0, zero packages, **no GC** (the zero-package early return fires before any GC). `PATHS` with `--mode hosted` or `--mode vendored` is a usage error (exit 2, `resolve_mode_flags`: "path targeting … applies to agent-mode and read-only scans" — their lockfile rewiring is whole-project by construction); `PATHS` with `--apply`/`--sync`/`--prune`/`--global` is fine. Every scan JSON shape (success, zero-package, and error alike) gains an additive always-present `paths` key echoing the patterns verbatim (empty array when unscoped). One-sentence duality rule: **a target that selects nothing is an error on `rollback` (exit 1) and an empty scan on `scan` (exit 0)**. -`scan --vendor` swaps the in-place apply for the vendor pipeline: discover → download (manifest written, as `--apply`) → vendor every patched dependency via the same engine as the `vendor` command (under the same lock). The whole manifest is vendored, so a package vendored at an older patch uuid is **re-vendored automatically** (its old uuid dir is removed — `vendor_stale_artifact_removed`); same-uuid re-runs are `already_vendored` skips. With `--prune`, GC runs **before** the vendor step so stale manifest entries don't fail vendoring with `package_not_installed`. JSON output gains a `download` sub-object (the download phase; no `applied` field — nothing is applied in place) and a `vendor` sub-object (a full vendor Envelope). The download phase writes only `.socket/manifest.json`; patch blobs are held in memory (see "Patch sources stay in memory" under the vendor contract). `--dry-run` previews per-patch `would_vendor` | `would_revendor` (+`oldUuid`) | `already_vendored` without network downloads or disk writes. Interactive mode prompts "Download and vendor N patch(es)?". +`scan --vendor` swaps the in-place apply for the vendor pipeline: discover → download (manifest written, as `--apply`) → vendor every patched dependency via the same engine as the `vendor` command (under the same lock). The whole manifest is vendored, so a package vendored at an older patch uuid is **re-vendored automatically** (its old uuid dir is removed — `vendor_stale_artifact_removed`); same-uuid re-runs are `already_vendored` skips. With `--prune`, GC runs **before** the vendor step so stale manifest entries don't fail vendoring with `package_not_installed`. JSON output gains a `download` sub-object (the download phase; no `applied` field — nothing is applied in place) and a `vendor` sub-object (a full vendor Envelope). The download phase writes only `.socket/manifest.json`; patch blobs are held in memory (see "Patch sources stay in memory" under the vendor contract). `--dry-run` previews per-patch `would_vendor` | `would_revendor` (+`oldUuid`) | `already_vendored` — plus, additive, `would_refuse` (+`errorCode`, `error`) for npm purls the wet run's Bun preflight (see the `get --mode vendored` bullet below) would refuse — without network downloads or disk writes; the preview never flips status or exit (the human path prints `[would-refuse] (): ` lines behind the `--silent` gate). Interactive mode prompts "Download and vendor N patch(es)?". `scan --vendor --detached` performs the same vendoring **without ever writing `.socket/manifest.json`**: records are fetched into memory (`download.detached: true`), the artifacts are built + wired, and the ledger entry carries `detached: true` plus an embedded copy of the patch record (`record`) as the verification source. Detached patches are invisible to apply and repair (nothing is in the manifest), exempt from `vendor`'s manifest reconcile, and exit via `remove ` (which reverts them), `vendor --revert`, or — as of v5.0 — `rollback`, whose vendored leg reverts detached ledger entries alongside manifest-tracked ones (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). Idempotent re-runs reuse the embedded record and skip the patch-view fetch entirely. `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`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock`), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `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). +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`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock`), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `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 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the verified remedy is a plain `bun install` with any Bun ≥ 1.2, which rewrites such a lock in place as version 1 (accepted), or deleting `bun.lock` and re-locking. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **`bun.lockb` auto-migration**: a binary `bun.lockb` with no text lock beside it is migrated to text before the read — only when an npm override was granted (with nothing to redirect the user's lockfile format is never touched) and never on `--dry-run` (`redirect_bun_lockb_would_migrate`, nothing spawned). The CLI spawns the `bun` resolved on ABSOLUTE `PATH` entries only (a relative entry would run a `bun` planted in the scanned repository; on Windows `PATHEXT` finds the npm-global `bun.cmd` shim, launched through `cmd.exe /C`) as `bun install --save-text-lockfile --frozen-lockfile --lockfile-only` (offline, fails closed on drift; bun's chatter never reaches stdout). The recipe works from Bun 1.1.43: 1.1.43–1.1.45 write a version-0 `bun.lock` and KEEP `bun.lockb`, ≥ 1.2 write version 1 and delete it — the CLI removes a surviving `bun.lockb` itself, so the ledger's `redirect_bun_lockb_migrated` / action `removed` FileEdit is always true and Bun ≤ 1.1.38 can never silently install the unpatched bytes from a stale binary lock beside the redirected text lock; the edit's `original` carries the pre-migration bytes as standard base64 (locks up to 8 MiB; larger ones are recorded without bytes), which `rollback` restores (see "Hosted unwind coverage"). Bun 1.1.39–1.1.42 accept the flags, exit 0 and write NO text lock (`--frozen-lockfile` suppresses the save there; Bun ≤ 1.1.38 has no text lockfile at all): `redirect_bun_lockb_manual_migration`, whose detail names the manual step (`bun install --save-text-lockfile`, Bun ≥ 1.1.39) and re-run. A missing or unspawnable `bun`, a non-zero exit (the detail carries bun's output tail) or a surviving `bun.lockb` that cannot be removed (the text lock bun wrote is dropped again) is `redirect_bun_lockb_unsupported`; the binary lock is never parsed. A migration whose rewrite then lands nothing in the new `bun.lock` is undone — `bun.lockb` bytes restored, generated `bun.lock` removed, no ledger record — and reported `redirect_bun_lockb_migration_reverted` (`redirect_bun_lockb_migrated_without_redirect` when that restore itself fails; git history is the restore path); the rewriter's own warning says why nothing landed. The `redirect_npm_no_lockfile` noise is suppressed on a `bun.lockb` project. Every bun code above rides `redirect.warnings[]` with the hosted-refusal posture (exit 0, `redirected: 0`); all of it is additive (MINOR). Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`). **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. @@ -134,11 +134,11 @@ The rewriter reads a fixed set of candidate files from the project root: the npm **get --mode and installed narrowing (v3.6).** `get --mode hosted|vendored` consumes the resolved patch(es) through the SAME engines as `scan --mode hosted|vendored`, so for the same selected (purl, uuid) set the on-disk result is identical by construction — this is the per-advisory selector hosted/vendored previously lacked (the old workaround, `get --save-only` then `vendor`, still works but is superseded). Semantics: * **Hosted** (`get GHSA-… --mode hosted`): resolves the advisory, then hands the selected (purl, uuid) pairs to scan's hosted engine — reference grants, cross-mode takeover pre-revert, lockfile rewrite, `redirect-state.json` ledger (merge-never-clobber), gem stale-install probe, warnings, confirmation rules (cargo via `confirmed_cargo_uuids` only) all identical to `scan --mode hosted`. **No manifest write, no blobs** — the ledger is the persistence. JSON: get's legacy envelope gains the same nested `redirect` sub-object as scan's (`{mode:"hosted", redirected, rewrittenFiles, skipped, warnings, dryRun}`); the top-level shape is `{status, found, patches:[], warnings?}` — `downloaded`/`applied` are absent (nothing is downloaded into `.socket/`). Exit codes follow scan's hosted semantics: skipped grants and rewriter warnings never flip the exit; infra errors (reference fetch, corrupt/unwritable ledger, file writes) exit 1. Human prompt: `Redirect N package(s) to the hosted patch server?` (scan hosted has no prompt; get keeps its confirm gate, `--yes`/`--json`/non-TTY auto-accept as usual). -* **Vendored** (`get GHSA-… --mode vendored`): the download phase is scan's vendored posture (writes ONLY `.socket/manifest.json`; blobs held in memory; the nested apply never runs), then scan's vendor step runs — apply lock, **whole-manifest scope including `reconcile_dropped`**: every manifest record is verified/re-vendored and records whose patches left the manifest may have their vendored state reverted, exactly like `scan --mode vendored` (a stderr `[note]` names the count of other affected records; this blast radius is deliberate parity, stated loudly). JSON: get's envelope (with `applied` dropped — structurally zero under save-only) gains the nested `vendor` Envelope exactly like scan's `result["vendor"]`; a vendor-step error folds the partial envelope + `{status:"error", error:{code,message}}` in (the pre-failure reconcile may have already mutated the ledger — its events must reach the consumer). Exit: download failures or vendor `has_errors` → `partial_failure`/1. Human prompt: `Download and vendor N patch(es)?`. Telemetry mirrors scan's vendored arms (`track_outcomes_for_vendor` / `track_patch_vendor_failed`). +* **Vendored** (`get GHSA-… --mode vendored`): the download phase is scan's vendored posture (writes ONLY `.socket/manifest.json`; blobs held in memory; the nested apply never runs), then scan's vendor step runs — apply lock, **whole-manifest scope including `reconcile_dropped`**: every manifest record is verified/re-vendored and records whose patches left the manifest may have their vendored state reverted, exactly like `scan --mode vendored` (a stderr `[note]` names the count of other affected records; this blast radius is deliberate parity, stated loudly). JSON: get's envelope (with `applied` dropped — structurally zero under save-only) gains the nested `vendor` Envelope exactly like scan's `result["vendor"]`; a vendor-step error folds the partial envelope + `{status:"error", error:{code,message}}` in (the pre-failure reconcile may have already mutated the ledger — its events must reach the consumer). Exit: download failures or vendor `has_errors` → `partial_failure`/1. Human prompt: `Download and vendor N patch(es)?`. Telemetry mirrors scan's vendored arms (`track_outcomes_for_vendor` / `track_patch_vendor_failed`). **Bun vendored preflight (additive)** — shared by `get --mode vendored` on both its paths, `scan --mode vendored`, and `--detached` runs: before ANY patch download, and only when the selection holds a `pkg:npm/` purl, the download phase reads `bun.lock`/`bun.lockb` once (`preflight_vendor`) and, when the vendor backend would refuse the project — `bun.lockb` with no `bun.lock` → `vendor_bun_lockb_unsupported`; an unreadable `bun.lock` → `vendor_lockfile_missing`; a `lockfileVersion` other than 0/1/2 or a non-canonical `packages` grammar → `vendor_lockfile_version_unsupported`; `workspace:` packages in a lock below version 2 → `vendor_bun_workspace_unsupported` — every `pkg:npm/` result becomes `{action:"failed", errorCode:, error:}` with NO fetch (the patch view is never requested) and no patch record; other ecosystems' results are untouched. **Search path** (`get --mode vendored`) and `scan --mode vendored`: the records ride `patches[]` / `download.patches[]`, the download phase still writes `.socket/manifest.json` (unchanged — an empty `{"patches": {}}` on a fresh project; a record seeded for another purl survives, re-serialized), the vendor step still runs (no event for the refused purl), exit `partial_failure`/1. **`--detached`**: the same `download.patches[]` records with `download.downloaded: 0`, and no manifest at all (previously the view was fetched first and, for an alias install, the engine misreported `package_not_installed`). **uuid path** (`get --mode vendored`): the uuid lookup is the only fetch; the run exits 1 BEFORE the record save and the vendor step with exactly `{status:"error", found:1, downloaded:0, skipped:0, failed:1, error:{code, message}, patches:[{purl, uuid, action:"failed", errorCode, error}]}` (the `error` OBJECT is the vendored-mode error shape of the vendor-step fold-in above) and writes nothing — no `.socket/` on a fresh project; human mode prints `Error (): ` on stderr. **Already-vendored exemption**: a purl the vendor ledger already wires at the selected uuid is never preflight-refused and flows to the engine's `already_vendored` skip, so in-sync re-runs — and `repair` — on a project vendored before it grew a workspace member keep working. **`--silent`** is "errors only" and never mutes the refusal: the code-tagged `[error] (): ` (per-patch paths) / `Error (): …` (uuid path) line stays on stderr with an empty stdout. **`--dry-run`** previews the refusal as the additive `would_refuse` action (see `--dry-run` below). Agent-mode `get --save-only` is NOT preflighted (record-only intent has no consumption precondition). Pinned by `tests/in_process_vendor_bun.rs` (exact uuid-path envelope, seeded-manifest survival, detached parity, `--silent`, `--dry-run`) and `tests/scan_vendor_e2e.rs`. * **Installed-version narrowing** (all modes, `get`'s search path): a CVE/GHSA fan-out returns one patch record per patched VERSION; get keeps only versions present here and emits calm `skipped` records (`errorCode: "package_not_installed"`) for the rest — never an error exit. Presence = installed on disk (qualified-aware resolver) ∪ already tracked in the manifest (record maintenance keeps working on hosts without an installed copy); hosted/vendored modes additionally count lockfile-resolved deps and vendor-ledger purls (mirroring scan's discovery supplements, including their `--global` gate). **Exempt** (no narrowing): UUID identifiers, exact-versioned PURL identifiers (explicit intent), `--save-only` runs (record-only has no installation precondition — the fresh-clone record→vendor flow keeps working), `--all-releases`, and the package-name path (already installed-derived). When EVERY found patch is filtered out, get exits 0 with the additive status **`not_installed`** (`{status:"not_installed", found:N, downloaded:0, applied:0, patches:[], warnings?}`) — never `no_match`, which remains pinned to the fuzzy package-name path. PnP layouts are surfaced, not misreported: yarn-PnP npm results skip with `errorCode: "yarn_pnp_unsupported"` in every mode; pnpm-PnP skips carry `pnpm_pnp_unsupported` in agent/vendored modes; hosted mode — the refusal's own remedy — keeps ONLY the versions the raw `pnpm-lock.yaml` text actually resolves (boundary-anchored probe over the v5/v6/v9 key spellings, so a large fan-out never requests grants for every version ever patched), labels a JUDGED miss `package_not_installed` exactly like a non-PnP project (the layout blocked nothing — the lock was read and the version isn't resolved), and reserves the layout code for an unreadable lock (no judgment possible). When EVERY narrowed-out result is a PnP refusal, the human terminal names the layout instead of claiming "not installed" and never advises `--all-releases` (which cannot make PnP patchable); the JSON status stays `not_installed` — consumers dispatch on the per-record `errorCode`. Hosted mode also runs the per-release VARIANT filter (`filter_to_installed_releases`) on its search path before requesting grants — agent/vendored runs get it inside the download engines — with the same keep-all-plus-warning fallbacks (surfaced as `(release_narrowing)`-prefixed strings in `warnings[]`). An ecosystem this binary has no crawler for is likewise never judged: its results are KEPT (absence from a crawl that never looked carries no information — the same fail-safe as scan's prune GC). The human `Found patches:` listing deliberately shows ALL found patches (pre-narrowing, main's behavior) with the `[skip]` lines following; machine output (the prompt count, the JSON envelope) uses the kept set. The finer per-release variant narrowing (`filter_to_installed_releases`) is unchanged and still runs inside the download engines. * **Deliberate divergences from scan** (documented, not drift): get keeps its `selection_required` JSON posture for free multi-patch PURLs (scan auto-picks); get has no `--vex` (an ambient `SOCKET_VEX` is ignored by get's modes), no `--detached`, no `--prune`; get does not run scan's pre-confirm vendor baseline annotation; and an all-narrowed-out run exits `not_installed` without entering the vendor step (heal-after-wipe re-vendoring stays `scan --mode vendored`'s job). Plain agent-mode `get` continues to ignore `--dry-run` (pre-existing; hosted/vendored honor it — see below). -`--dry-run` previews what `apply` / `rollback` / `scan --apply` / `repair` / `remove` — and (v3.6) `get --mode hosted|vendored` — would do without mutating disk. `get --mode hosted --dry-run` flows through the hosted engine's dry-run contract (no ledger write, no lockfile writes, `redirect.dryRun: true`); `get --mode vendored --dry-run` emits the same ledger-classification preview as scan's (`would_vendor` / `already_vendored` / `would_revendor`+`oldUuid` under the nested `vendor` key) before any download, and both skip the confirm prompt (nothing to confirm). In JSON mode, the envelope is populated with would-be actions and counts (`remove --dry-run` skips the confirmation prompt — there is nothing to confirm — and flips its would-be `Removed` events to `Verified` previews, so `summary.removed` stays "entries actually deleted"). `repair --dry-run` also skips the final lock-file deletion. `rollback --dry-run` (v5.0) previews every leg — the in-place restore verification, the vendored unwire (`Would revert/unwire vendoring for …`), the hosted unwind (the redirect engines resolve every inverse and drift check exactly like a wet run, flush nothing to disk, and claim the IN-MEMORY ledger clone exactly like a wet run — so the composed preview, per-purl reverts then whole-ledger replay, sees the same intermediate state a wet run would; the ON-DISK ledger is untouched), the manifest removals (simulated in memory), and the blob/archive GC — with no writes and no prompt. +`--dry-run` previews what `apply` / `rollback` / `scan --apply` / `repair` / `remove` — and (v3.6) `get --mode hosted|vendored` — would do without mutating disk. `get --mode hosted --dry-run` flows through the hosted engine's dry-run contract (no ledger write, no lockfile writes, `redirect.dryRun: true`); `get --mode vendored --dry-run` emits the same ledger-classification preview as scan's (`would_vendor` / `already_vendored` / `would_revendor`+`oldUuid` under the nested `vendor` key — plus, additive, `would_refuse` + `errorCode` + `error` for npm purls the wet run's Bun preflight would refuse: an in-sync `already_vendored` entry is exempt, a `would_revendor` entry is refused like a fresh one, and the preview stays exit 0 / `status: "success"` with nothing written) before any download, and both skip the confirm prompt (nothing to confirm). In JSON mode, the envelope is populated with would-be actions and counts (`remove --dry-run` skips the confirmation prompt — there is nothing to confirm — and flips its would-be `Removed` events to `Verified` previews, so `summary.removed` stays "entries actually deleted"). `repair --dry-run` also skips the final lock-file deletion. `rollback --dry-run` (v5.0) previews every leg — the in-place restore verification, the vendored unwire (`Would revert/unwire vendoring for …`), the hosted unwind (the redirect engines resolve every inverse and drift check exactly like a wet run, flush nothing to disk, and claim the IN-MEMORY ledger clone exactly like a wet run — so the composed preview, per-purl reverts then whole-ledger replay, sees the same intermediate state a wet run would; the ON-DISK ledger is untouched), the manifest removals (simulated in memory), and the blob/archive GC — with no writes and no prompt. The hidden alias `--no-apply` on `get --save-only` is **part of the contract** — it does not appear in `--help` but is widely used in existing scripts. @@ -571,7 +571,7 @@ to **six flavors**. | npm / yarn berry (node-modules linker) | (same tarball) | root `package.json` `resolutions` + `yarn.lock` entry with `checksum: 10c0/` of the berry cache-zip (reproduced from the tarball offline). **PnP is refused** (`.pnp.*` → different artifact pipeline) | `yarn install --immutable --check-cache`, cold cache. Refused if `__metadata.cacheKey ≠ 10c0` or a non-default `compressionLevel` | | npm / pnpm (lockfileVersion 9) | (same tarball) | root `package.json` `pnpm.overrides` (versioned selector) **+** `pnpm-lock.yaml` surgery (overrides / importer version / packages `resolution.integrity` / snapshots) | `pnpm install --frozen-lockfile --offline`, cold store (integrity-verified; byte-stable on pnpm 9 & 10). Other lockfileVersions: 5.4/6.0 route to the legacy backend below; anything else refused | | npm / pnpm LEGACY (lockfileVersion 5.4 = pnpm 7, 6.0 = pnpm 8; flavor `pnpm-legacy`) | (same tarball) | root `package.json` `pnpm.overrides` **+** legacy lock surgery (overrides / root dep + specifiers / packages rekey to a bare `file:` key with recomputed integrity / in-package dep refs). **No `pnpm-workspace.yaml` is written** (pnpm ≤ 8 reads overrides only from package.json). The lock's SPECIFIER is machine-ABSOLUTE — pnpm ≤ 8 absolutizes `file:` overrides itself — surfaced as `vendor_pnpm_legacy_absolute_specifier`. Legacy WORKSPACE locks (`importers:`) refused | same-path `pnpm install --frozen-lockfile --offline`, cold store (byte-stable on pnpm 7.33.5 / 8.15.9). A checkout at a DIFFERENT path fails the frozen check (path-bound specifier) and must run `pnpm install --offline --no-frozen-lockfile` once (the flag matters on CI, where pnpm defaults frozen on), which installs the vendored tarball and re-resolves only the specifier line | -| npm / bun (`bun.lock`) | (same tarball) | `bun.lock` only: the packages entry's registry 4-tuple → local 3-tuple with recomputed `sha512`. `bun.lockb` (binary) refused with a `--save-text-lockfile` pointer | `bun install --frozen-lockfile`, cold cache (integrity-enforced) | +| npm / bun (`bun.lock`, lockfileVersion 0, 1 or 2 — `vendor_lockfile_version_unsupported` otherwise) | (same tarball) | `bun.lock` only: the packages entry's registry 4-tuple → local 3-tuple with recomputed `sha512`; the entry's `{deps}` meta, the lock's version line and its line endings are preserved. A lock holding `workspace:` packages is refused `vendor_bun_workspace_unsupported` unless lockfileVersion is 2 — Bun 1.2–1.3 resolve a workspace member's local-tarball path relative to the MEMBER (ENOENT on our root-relative path), 1.4 relative to the lockfile, and a committed version-2 lock is the only proof every consumer runs Bun ≥ 1.4 (a deliberate over-approximation: a package declared only by the workspace root would install on version 1 too). The gate fires only on a run that would WRITE a new local tuple, so in-sync re-runs, `already_vendored` skips and `repair` rebuilds on such a lock pass. The detail names the version and the remedy: delete `bun.lock` and re-lock with Bun ≥ 1.4 (an in-place `bun install` keeps the existing lockfileVersion), or `--mode hosted`. `bun.lockb` (binary) refused `vendor_bun_lockb_unsupported` with the `bun install --save-text-lockfile` (Bun ≥ 1.1.39) pointer. `scan`/`get --mode vendored` apply all four refusals BEFORE downloading (see the `get --mode vendored` bullet) | `bun install --frozen-lockfile`, cold cache (the local tarball's sha512 is enforced by Bun ≥ 1.3.10; 1.1.39–1.3.9 install it unverified — the committed artifact is the protection there) | | cargo | crate dir `-/` (no `.cargo-checksum.json`) | `.cargo/config.toml` `[patch.crates-io]` path entry **+** Cargo.lock surgery (the `[[package]]` entry's `source`/`checksum` removed) | `cargo build --locked --offline` on a fresh checkout. Requires cargo ≥ 1.56 (`[patch]` in config files). Note: path deps build **without** `--cap-lints allow` | | golang | module dir `@/` | `go.mod` `replace => ./.socket/vendor/golang//@` | `go build` with `GOPROXY=off` + empty `GOMODCACHE` (directory replaces bypass go.sum entirely; survives `go mod tidy`) | | composer | package dir `/@/` | `composer.lock` only: entry's `dist` → `{type: "path", url, reference: null}`, `source` removed, `transport-options: {symlink: false}` added. `content-hash` unaffected; `composer.json` untouched | `composer install` (from the lock alone, real copy not symlink, works under `--network none`). `composer update ` reverts it | @@ -587,7 +587,9 @@ to **six flavors**. Ecosystems with no vendor backend (jsr) refuse per-purl with `vendor_unsupported_ecosystem`. yarn-berry **PnP** (`.pnp.*`) and bun's binary `bun.lockb` are refused with stable codes pointing at the native -alternative / a text-lockfile migration; a lock-less tool marker (a `[tool.uv]`/`[tool.poetry]`/ +alternative / a text-lockfile migration (`bun install --save-text-lockfile`, Bun ≥ 1.1.39 — one +detail text on the `vendor` router and on the `get`/`scan --mode vendored` pre-download +preflight); a lock-less tool marker (a `[tool.uv]`/`[tool.poetry]`/ `[tool.pdm]` table or a `Pipfile` without its lock) refuses `_no_lockfile` unless a `requirements.txt` fallback exists. PURLs of **compiled-out** ecosystems are invisible to `vendor` exactly as they are to `apply` (the binary cannot parse them). @@ -607,7 +609,7 @@ worse, lets a warm cache silently serve unpatched bytes): | npm / yarn classic | `resolved "…#"` fragment + `integrity` SRI | both recomputed from the packed tarball (sha1 fragment + sha512 SRI); integrity line added when the registry block lacked one — yarn then enforces both | | npm / yarn berry | `checksum: 10c0/` (over berry's cache zip) | recomputed by rebuilding berry's deterministic cache-zip from the tarball and hashing it (byte-identical to yarn's own); refused if the lock's `cacheKey`/`compressionLevel` would change the zip | | npm / pnpm | `packages[].resolution.integrity` (sha512) | recomputed from the tarball; the versioned `pnpm.overrides` selector pins exactly the patched version | -| npm / bun | the packages-entry trailing `sha512-…` | recomputed from the tarball; tamper fails the frozen install | +| npm / bun | the packages-entry trailing `sha512-…` | recomputed from the tarball; tamper fails the frozen install on Bun ≥ 1.3.10 (URL/local tarball tuples are verified from 1.3.10, registry 4-tuples from 1.2.0 — so on 1.1.39–1.3.9 a hosted or vendored rewrite removes digest enforcement for the patched package; see `docs/testing/bun-compatibility.md`) | | gem | `CHECKSUMS` section (bundler ≥ 2.6 opt-in) | the vendored gem's entry rewritten to bundler's own path-gem form (bare `name (ver)`, sha256 token stripped) so re-locks stay byte-stable; original line in the ledger | | pypi / uv | `wheels[].hash`, `sdist.hash`, requires-dist specifiers | single `{filename, hash: sha256-of-our-wheel}`; sdist dropped; dropped specifiers ledgered for revert | | pypi / poetry | `files = [{file, hash}]` (2.x) / `[metadata.files]` entry (1.0/1.1) / `[metadata.hashes]` entry (0.12) | replaced with a single `{file, hash: sha256-of-our-wheel}` (or the bare hash for 0.12) in the generation's own table (Poetry ≥ 1.4 verifies the artifact against one listed hash; older writers are flagged `pypi_poetry_integrity_unverified`; stale registry hashes removed) | @@ -755,8 +757,8 @@ Restore the system but keep the local patch state for a later re-apply: manifest ### Hosted unwind coverage -* **Per-purl reverts** exist for **cargo and the npm family** (`redirect_revert_supported`): staged, fail-closed on drift, and honoring `dry_run` (every inverse and drift check resolves like a wet run; nothing flushes and the ledger is untouched). npm purls on projects with bun-lock edits DEFER to the replay (below) instead of failing, whenever the replay will run. -* **Whole-ledger reverse replay** (`revert_remaining_redirect_edits`, core `patch/redirect/replay.rs`) runs whenever the in-scope hosted record set equals the FULL ledger record set — however the scope was spelled (bare `rollback`, `rollback '**'`, an identifier set covering every record; `remove` reuses the same eligibility rule). It walks every remaining ledger edit in reverse write order through a **per-kind inverse table**, staged and committed **per ecosystem group, all-or-nothing**: one drifted, ambiguous (a fragment appearing more than once), or unhandled edit refuses the whole group byte-untouched while other groups proceed. This covers **gem, golang, pypi, composer, bun**, the yarn/pnpm text kinds (normally claimed by the per-purl npm revert first), and the **non-package rideshare edits** — the pnpm `trustLockfile` auto-config (a pristine created scaffold is deleted; a user-modified one keeps the file and loses only the `trustLockfile: true` line, warned as `redirect_pnpm_trust_scaffold_modified`) — plus a "last one out turns off the lights" pass: when the record map empties but non-package edits remain, they are replayed in the same persist, so the trust edit never strands. The **bun.lockb migration marker is unrestorable by design** (the binary original was never captured): it warns `redirect_bun_lockb_unrestorable` naming git history as the restore path and never blocks its group. +* **Per-purl reverts** exist for **cargo and the npm family** (`redirect_revert_supported`): staged, fail-closed on drift, and honoring `dry_run` (every inverse and drift check resolves like a wet run; nothing flushes and the ledger is untouched). npm purls on projects with bun-lock edits DEFER to the whole-ledger replay (below) whenever it will run — the scope covers every record, and the replay stages the bun group together with the `bun.lockb` migration marker all-or-nothing. A SCOPED unwind (`rollback `, or `remove ` while other hosted records remain) takes the per-purl revert instead: it claims that purl's `redirect_bun_lock_package` edits by the recorded line's spec (`@` registry spec, or a hosted URL whose tarball leaf is `-.tgz`) and replays them like the yarn/pnpm text kinds (whole-line fragments, CRLF-exact); a sibling version's edit is neither claimed nor a refusal; an edit that mentions the package but is not a bun packages-entry line refuses with the unscoped-`rollback` remedy. Pinned by `tests/in_process_vendor_bun_takeover.rs` (`bun_scoped_rollback_of_one_of_two_hosted_records_unwinds_only_that_purl` and the `remove` twin). +* **Whole-ledger reverse replay** (`revert_remaining_redirect_edits`, core `patch/redirect/replay.rs`) runs whenever the in-scope hosted record set equals the FULL ledger record set — however the scope was spelled (bare `rollback`, `rollback '**'`, an identifier set covering every record; `remove` reuses the same eligibility rule). It walks every remaining ledger edit in reverse write order through a **per-kind inverse table**, staged and committed **per ecosystem group, all-or-nothing**: one drifted, ambiguous (a fragment appearing more than once), or unhandled edit refuses the whole group byte-untouched while other groups proceed. This covers **gem, golang, pypi, composer, bun**, the yarn/pnpm text kinds (normally claimed by the per-purl npm revert first), and the **non-package rideshare edits** — the pnpm `trustLockfile` auto-config (a pristine created scaffold is deleted; a user-modified one keeps the file and loses only the `trustLockfile: true` line, warned as `redirect_pnpm_trust_scaffold_modified`) — plus a "last one out turns off the lights" pass: when the record map empties but non-package edits remain, they are replayed in the same persist, so the trust edit never strands. The **bun.lockb migration marker** (`redirect_bun_lockb_migrated`, action `removed`) restores the binary lock: when the edit's `original` carries the pre-migration bytes (standard base64, captured by the hosted run for locks up to 8 MiB) and no `bun.lockb` exists, the replay writes them back and warns `redirect_bun_lockb_restored` — the text `bun.lock` generated during the redirect is left in place (Bun ≥ 1.1.39 reads `bun.lock` when both exist; delete whichever lockfile you do not want). A `bun.lockb` already holding those bytes is a silent no-op; a DIFFERENT `bun.lockb` (the user re-locked with an old Bun) is never clobbered and warns `redirect_bun_lockb_unrestorable`, as does a marker recorded WITHOUT bytes (an oversize lock, or a ledger written by a CLI that predates the capture) when the file is absent — the detail names git history as the restore path; a present `bun.lockb` with no recorded bytes is left alone silently. An undecodable payload degrades like an absent one. The marker never blocks its group. * **maven and nuget fail closed**: their structured-metadata kinds (`redirect_maven_repository` / `redirect_maven_dep_management` / `redirect_maven_config` / `redirect_maven_trusted_checksums`, `redirect_nuget_source` / `redirect_nuget_lock`) have no revert implementation, so any such edit refuses its whole group (the maven `` suffix rewrite alone IS invertible, but it rides the same all-or-nothing group). The refusal keeps their records + edits in the ledger and names the remedy: re-run `scan --mode hosted` to normalize, or restore the lockfiles from version control. Unknown future kinds refuse the same way (forward-compat). * **Scoped runs** (paths / identifiers / `--ecosystems`) that do NOT cover the full record set get per-purl reverts only; in-scope hosted purls of ecosystems without one fail closed — `rollback` reports them in `hosted.unsupported` (exit 1), `remove` as the top-level `hosted_revert_unsupported` error — with the remedy "run an unscoped `socket-patch rollback` to unwind ALL hosted redirects, or re-run `scan --mode hosted`". * **Ledger accounting**: exactly the replayed (or already-at-original) edits are dropped; a record is dropped only when every group its ecosystem writes ended clean, so refused groups keep both edits and records — the intermediate-but-coherent ledger a retry needs. The mutated ledger is persisted (delete-when-empty); a failed persist rides `hosted.failed` / `hosted_revert_failed`. @@ -767,7 +769,7 @@ Restore the system but keep the local patch state for a later re-apply: manifest | Key | Shape | Meaning | |---|---|---| -| `warnings` | `[{code, detail}]` | Run-level warnings, now populated (previously always empty): `reinstall_required`, `hosted_state_not_preservable`, `out_of_scope_copies_restored`, `vendor_state_unreadable`, `redirect_state_unreadable`, `cleanup_failed`, `manifest_write_failed`, `redirect_bun_lockb_unrestorable`, `redirect_pnpm_trust_scaffold_modified`, plus vendored/hosted leg advisories. New codes are additive (MINOR) | +| `warnings` | `[{code, detail}]` | Run-level warnings, now populated (previously always empty): `reinstall_required`, `hosted_state_not_preservable`, `out_of_scope_copies_restored`, `vendor_state_unreadable`, `redirect_state_unreadable`, `cleanup_failed`, `manifest_write_failed`, `redirect_bun_lockb_restored`, `redirect_bun_lockb_unrestorable`, `redirect_pnpm_trust_scaffold_modified`, plus vendored/hosted leg advisories. New codes are additive (MINOR) | | `vendored` | `[purl]` | **Meaning narrowed (MAJOR)**: vendor-owned purls the run did NOT act on — today exactly the corrupt-vendor-ledger skip. Previously this listed every vendor-owned skip | | `vendoredReverted` | `[purl]` | Ledger entries cleanly reverted this run (unwired + artifact deleted + entry dropped; previewed on dry-run) | | `vendoredPreserved` | `[purl]` | `--preserve-state`: unwired with artifact + ledger entry kept | @@ -1047,7 +1049,8 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `path_scope_excluded_supplements` | scan `warnings[]` | path-scoped scan (v5.0): lockfile-only / vendor-ledger supplement packages have no installed path and were excluded from the scoped scan; the detail carries the count. | | `vendor_state_unreadable` / `redirect_state_unreadable` | rollback `warnings[]`; remove top-level error | corrupt-ledger containment (v5.0). Rollback: an unreadable vendor ledger skips the vendored leg + manifest cleanup + GC; an unreadable redirect ledger skips the hosted leg (quarantine/restore remedy in the detail); either drives `partial_failure` exit 1 while the agent leg still restores files. Remove: `vendor_state_unreadable` is a hard top-level error before any mutation (an unreadable redirect ledger only warns — the identifier may match other stores). | | `manifest_write_failed` | rollback `warnings[]` | rollback (v5.0): the post-rollback manifest update could not be written; no entries were removed (`manifest.removedEntries: []`) and the run exits `partial_failure` 1. | -| `redirect_bun_lockb_unrestorable` | rollback/remove `warnings[]` | hosted replay (v5.0): the ledger records a bun.lockb→bun.lock migration whose binary original was never captured; restore bun.lockb from git history if the binary format is required. Never blocks its group. | +| `redirect_bun_lockb_restored` | rollback/remove `warnings[]` | hosted replay: the ledger's `redirect_bun_lockb_migrated` edit carried the pre-migration bytes and `bun.lockb` was absent, so the binary lock was written back; the text `bun.lock` generated during the redirect is kept (Bun ≥ 1.1.39 reads `bun.lock` when both exist — delete whichever lockfile you do not want). Never blocks its group. | +| `redirect_bun_lockb_unrestorable` | rollback/remove `warnings[]` | hosted replay (v5.0): the bun.lockb→bun.lock migration marker cannot restore `bun.lockb` — it was recorded without bytes (a lock above 8 MiB, or a ledger written before the bytes were captured) and the file is absent, or a DIFFERENT `bun.lockb` has appeared since (left untouched). Restore from git history if the binary format is required. Not emitted when the file is present with no recorded bytes. Never blocks its group. | | `redirect_pnpm_trust_scaffold_modified` | rollback/remove `warnings[]` | hosted replay (v5.0): the redirect-created `pnpm-workspace.yaml` scaffold was modified since; the file was kept and only the `trustLockfile: true` line removed. | | `vendor_stale_artifact_removed` | `removed` | vendor / scan `--vendor`: re-vendor under a newer patch uuid removed the previous uuid's orphaned artifact dir. | | `vendor_unsupported_ecosystem` | `skipped` | vendor: no vendor backend for this purl's ecosystem (jsr). | @@ -1057,7 +1060,13 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendor_wiring_unknown_revert_blocked` | `skipped` (beside the `failed`/`revert_failed` event) | vendor --revert: the ledger entry was reconstructed by `repair` without wiring records and the live lockfile still resolves through the artifact — the revert refuses (fail-closed) instead of deleting a tarball the lock points at. Recovery: `socket-patch repair`, then restore the pre-vendor lock (or re-lock without the override) and re-run the revert. | | `ecosystem_not_setup` | `skipped` | vex: the patch is applied and byte-verified but its ecosystem has no install hook configured and is not declared in the manifest's `setup.manual`, so it is omitted from the document (Property 7). Previously invisible in `--json`. | | `vendor_multiple_lockfiles` / `pypi_multiple_lockfiles` | `skipped` (warning) | vendor: a sibling lockfile of another package manager will still install UNPATCHED bytes; names the wired winner + the ignored locks. | -| `vendor_yarn_berry_unsupported` / `vendor_bun_lockb_unsupported` | `failed` | vendor (npm): yarn-berry PnP / bun binary lockfile — pointer to `yarn patch` / `bun install --save-text-lockfile`. | +| `vendor_yarn_berry_unsupported` / `vendor_bun_lockb_unsupported` | `failed` | vendor (npm): yarn-berry PnP / bun binary lockfile — pointer to `yarn patch` / `bun install --save-text-lockfile` (Bun ≥ 1.1.39; one detail text on the `vendor` router and on the `get`/`scan --mode vendored` pre-download preflight, where the bun code is a `failed` record with `errorCode` in `patches[]` / `download.patches[]`, or `get `'s top-level `error.code` with `status: "error"`). | +| `vendor_bun_workspace_unsupported` | `failed` | vendor / scan / get `--mode vendored` (bun): the text lock holds `workspace:` packages and its `lockfileVersion` is below 2 — Bun 1.2–1.3 resolve a workspace member's local-tarball path relative to the member; a committed version-2 lock is the proof every consumer runs Bun ≥ 1.4 (deliberate over-approximation: root-only declared packages would install on version 1 too). Detail names the version integer and the remedy: delete `bun.lock` and re-lock with Bun ≥ 1.4 (an in-place `bun install` keeps the existing version), or `--mode hosted`, which accepts version-1 workspace locks. Refused before any write — in the pre-download preflight on `get`/`scan` (see `vendor_bun_lockb_unsupported` for the placements) and, on `vendor`, in the engine only when the run would write a NEW local tuple: already-vendored purls, in-sync re-runs and `repair` rebuilds pass. | +| `vendor_lockfile_missing` / `vendor_lockfile_version_unsupported` (bun preflight placement) | `failed` | scan / get `--mode vendored` (bun): the pre-download preflight found `bun.lock` unreadable / at a `lockfileVersion` other than 0, 1 or 2 (a newer version: update socket-patch; no integer: re-lock with Bun ≥ 1.2 — the same text as hosted's `redirect_bun_lock_unsupported`) or outside bun's single-line `packages` grammar. Same placements as `vendor_bun_lockb_unsupported`; nothing fetched, no patch record. | +| `bun_lockb_unsupported` | scan `warnings[]` (run-level) | scan (report-only, agent, vendored): the project's only lockfile is the binary `bun.lockb`, which the lockfile inventory cannot read — a fresh clone used to report a clean `scannedPackages: 0` success in every mode; the detail names `bun install --save-text-lockfile` (Bun ≥ 1.1.39). Also a stderr `Warning (bun_lockb_unsupported): …` line. Exit code and `status` unchanged (the PnP-refusal posture). Dropped on the non-empty hosted path, where the hosted driver reports its own `redirect_bun_lockb_*` outcome for the same file. | +| `would_refuse` | dry-run preview action (`vendor.patches[]`) | scan `--mode vendored --dry-run` / get `--mode vendored --dry-run`: the wet run's Bun preflight would refuse this npm purl; the record carries `errorCode` (one of the four vendor codes above) + `error`. Exit 0 / `status: "success"`, nothing written. | +| `vendor_would_revert_redirect` / `vendor_takeover_reverted_redirect` | `skipped` (advisory event) | vendor / scan / get `--mode vendored` over a hosted-redirected purl (cargo and the npm family, bun included): dry run — the per-purl hosted revert was PROBED and would succeed / wet run — the hosted lockfile edits were reverted to their pre-redirect registry values and the redirect-ledger record dropped before vendoring (mode takeover). Fires on the run that takes over, not on re-runs. | +| `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the per-purl hosted revert refused (drifted lock, missing original fragment, an undecidable ledger edit) — nothing vendored for the purl, hosted wiring left in place, exit 1 `partial_failure`; the detail names the remedy (for bun: an unscoped `socket-patch rollback`). | | `vendor_yarn_berry_cache_unsupported` | `failed` | vendor (yarn berry): lock `cacheKey ≠ 10c0` or non-default `.yarnrc.yml` `compressionLevel` — the cache-zip checksum is not reproducible. | | `vendor_override_conflict` | `failed` | vendor (pnpm/yarn-berry): a user-authored override/resolution for the package already exists. | | `vendor_integrity_unverified` | `skipped` (warning) | vendor (pipenv): the lockfile format does not hash-check file entries; the committed wheel bytes are the protection. | @@ -1088,6 +1097,11 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `redirect_poetry_stale_install_risk` | `redirect.warnings[]` (warning) | scan `--mode hosted` (poetry): same writer test as above — a warm virtualenv keeps the upstream package after the redirect on Poetry < 1.4 (1.4+ re-installs from the new source); fresh installs pick up the patched wheel. Emitted once per rewritten lock, only on the run that rewrites it. | | `redirect_poetry_entry_not_found` / `redirect_poetry_missing_sha256` / `redirect_poetry_lock_unsupported` | `redirect.warnings[]` (warning) | scan `--mode hosted` (poetry): the lock has no `[[package]]` at the granted version (uv-parity twin of `redirect_uv_entry_not_found`); the grant carries no SHA-256 (gated once per dep, not per lock); the lock is refused — Poetry 0.12 layout (URL sources ignored), an unsupported `lock-version`, a forked package listed at several versions, a user-authored `[package.source]` on another origin (an earlier Socket URL for the same wheel is superseded in place), a malformed `[metadata.files]`/`[metadata.hashes]`, or a wheel whose filename does not match the locked package. Exit code and `status` unchanged (hosted-refusal posture). | | `redirect_pdm_refused` / `redirect_pdm_legacy_sync_required` | `redirect.warnings[]` (warning) | scan `--mode hosted` (pdm): the `pdm.lock` rewrite was refused — an unsupported `[metadata] lock_version` (the identity-losing `3.1` / `4.0`–`4.2` formats or an untested future format), an unsupported `strategy`, a package listed at several versions (fork) or absent, a user-authored `url`/`path`/VCS/`editable` source, hash-less or malformed `files`, or a wheel whose filename does not match the locked package (`redirect_pdm_refused`); or the lock was written in format `2` (PDM 0.12–1.4), whose upstream freshness bug lets `pdm install` regenerate the lock — use `pdm sync` (`redirect_pdm_legacy_sync_required`). A refused uuid is withheld from every other PyPI rewriter when `pdm.lock` is the install driver, and its patch is not confirmed. Exit code and `status` unchanged (hosted-refusal posture). | +| `redirect_bun_lock_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): the text lock's `lockfileVersion` is not 0, 1 or 2 (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2 — the shared gate's text, identical to vendored's `vendor_lockfile_version_unsupported`), or its `packages` section is not bun's single-line grammar. Nothing rewritten; exit 0 (hosted-refusal posture). | +| `redirect_bun_workspace_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): a lockfileVersion-0 lock (Bun 1.1.39–1.1.45 `--save-text-lockfile`) holds `workspace:` packages; frozen installs of that grammar cannot keep the hosted tuple. Remedy (verified): a plain `bun install` with any Bun ≥ 1.2 rewrites it in place as version 1, which is accepted, or delete `bun.lock` and re-lock. Version-1/2 workspace locks are rewritten. Exit 0. | +| `redirect_bun_lockb_would_migrate` / `redirect_bun_lockb_manual_migration` / `redirect_bun_lockb_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun; `bun.lockb` with no `bun.lock` and an npm override granted): `--dry-run` would run the migration, nothing spawned / the resolved `bun` exited 0 but wrote no text lock — Bun 1.1.39–1.1.42 under `--frozen-lockfile`, or Bun ≤ 1.1.38 with no text lockfile at all — run `bun install --save-text-lockfile` (Bun ≥ 1.1.39) yourself and re-run / `bun` is missing on an absolute `PATH` entry, could not be spawned, exited non-zero (detail carries bun's output tail) or the surviving `bun.lockb` could not be removed (the text lock is dropped again). `bun.lockb` is never parsed; exit 0. | +| `redirect_bun_lockb_migration_reverted` / `redirect_bun_lockb_migrated_without_redirect` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): the migration succeeded but no redirect landed in the new `bun.lock` (the rewriter's own warning says why) — the pre-migration `bun.lockb` was restored, the text lock removed and no ledger record kept / the restore itself failed, so the migration and its `removed` ledger record stand (git history is the restore path). Exit 0. | +| `redirect_bun_entry_not_found` / `redirect_bun_missing_sha512` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): the lock has no rewritable entry at the granted version (re-resolved, or occupied by an unowned URL/file spec) / the grant carries no sha512 integrity. Per-dep; nothing rewritten for it; exit 0. | | `vendor_prebuilt_stub_invalid` | `failed` / `skipped` (warning) | vendor (gem, `--vendor-source`): the served stub gemspec fails the rubygems `summary`/`authors` bar, so bundler would refuse the vendored path source at install time. `service`: refusal naming the missing attributes; `auto`: loud warning + local-build fallback — or, when the gem is also not installed locally (no stub to derive), a refusal naming the served defect and the install-the-gem remedy. | | `gem_spec_invalid` | `failed` | vendor (gem): the LOCAL `specifications/` stub gemspec fails the same rubygems `summary`/`authors` bar (a corrupted or hand-edited gem home); the refusal names the file — reinstall the gem (`gem pristine ` / fresh `bundle install`). | | `vendor_*` / `pypi_*` / `gemfile_*` / `lock_*` / `locked_version_mismatch` / `user_authored_*` / `native_extensions_unsupported` / `platform_gem_unsupported` | `failed`/`skipped` | vendor: per-ecosystem refusal + drift vocabulary; see the Vendor command contract section. New tags are additive (MINOR). | @@ -1168,6 +1182,7 @@ rely on these keys. ], // ----- failure path (only on action=failed) ----- + "errorCode": "vendor_bun_workspace_unsupported", // additive; today only the vendored-mode Bun preflight refusals "error": "could not fetch details" } ``` @@ -1185,6 +1200,15 @@ installed-version narrowing; see "get --mode and installed narrowing"), the same calm-skip vocabulary as scan's pre-download partitions. Absent on the classic "already in manifest" skip. +Additive: a `failed` record may ALSO carry `errorCode` beside `error` — +today exactly the vendored-mode Bun preflight refusals +(`vendor_bun_lockb_unsupported`, `vendor_lockfile_missing`, +`vendor_lockfile_version_unsupported`, `vendor_bun_workspace_unsupported`) +that `get --mode vendored` and `scan --mode vendored` (`download.patches[]`) +emit before any download; see "get --mode and installed narrowing" → +Vendored → Bun vendored preflight. Every other `failed` record carries only +`error`. The dry-run preview's `would_refuse` records carry the same pair. + `vulnerabilities[]` is always sorted by `id` so consumer diffs and test snapshots are stable. `severity` at the top level is the max across the array using the ordering `critical > high > medium = moderate > low > (unknown)`. diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 25a577b1..a8b59050 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -14,7 +14,7 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. | Ecosystem | agent (`--mode agent`) | vendored (`--mode vendored`) | hosted (`--mode hosted`) | |-----------|------------------------|------------------------------|--------------------------| -| npm (`npm`) — pnpm / yarn / berry / bun | ✅ any install layout; `setup` postinstall hook | ✅ six lockfile flavors: package-lock, yarn classic, yarn berry (node-modules linker; PnP refused), pnpm v9, pnpm legacy v5.4/v6.0 (`pnpm 7/8` — frozen installs are path-bound because those majors absolutize `file:` override specifiers; moved checkouts run one `pnpm install --offline --no-frozen-lockfile`, surfaced as `vendor_pnpm_legacy_absolute_specifier`), bun `bun.lock` (binary `bun.lockb` refused with a `--save-text-lockfile` pointer). Rush monorepos refused (`vendor_rush_unsupported`) — see [Rush notes](#npm-rush-monorepos) | ✅ package-lock / npm-shrinkwrap, pnpm-lock.yaml and legacy shrinkwrap.yaml (pnpm majors 1–12; block and flow resolutions), yarn classic, yarn berry, bun — pnpm, berry, and bun carry constraints, see [npm hosted-mode notes](#npm-hosted-mode-notes) | +| npm (`npm`) — pnpm / yarn / berry / bun | ✅ any install layout; `setup` postinstall hook | ✅ six lockfile flavors: package-lock, yarn classic, yarn berry (node-modules linker; PnP refused), pnpm v9, pnpm legacy v5.4/v6.0 (`pnpm 7/8` — frozen installs are path-bound because those majors absolutize `file:` override specifiers; moved checkouts run one `pnpm install --offline --no-frozen-lockfile`, surfaced as `vendor_pnpm_legacy_absolute_specifier`), bun `bun.lock` lockfileVersion 0/1/2 (binary `bun.lockb` refused with a `bun install --save-text-lockfile` — Bun ≥ 1.1.39 — pointer, `vendor_bun_lockb_unsupported`; a lock holding `workspace:` packages needs lockfileVersion 2 — `vendor_bun_workspace_unsupported` otherwise, Bun < 1.4 resolves a member's local tarball path relative to the member; `scan`/`get --mode vendored` apply both refusals before downloading anything — see [Bun compatibility](testing/bun-compatibility.md)). Rush monorepos refused (`vendor_rush_unsupported`) — see [Rush notes](#npm-rush-monorepos) | ✅ package-lock / npm-shrinkwrap, pnpm-lock.yaml and legacy shrinkwrap.yaml (pnpm majors 1–12; block and flow resolutions), yarn classic, yarn berry, bun — pnpm, berry, and bun carry constraints, see [npm hosted-mode notes](#npm-hosted-mode-notes) | | PyPI (`pypi`) — uv / poetry / pdm / pipenv / pip | ✅ `.pth` startup hook via `setup` | ✅ uv project/script locks, PEP 751 `pylock.toml` / `pylock..toml`, poetry, pdm, pipenv (Pipenv 2018 or later — every `Pipfile.lock` category is rewired, lock-only checkouts included; Pipenv 2023+ does not hash-check local wheels — `vendor_integrity_unverified`; a venv still holding the upstream release is reported as `pypi_pipenv_stale_install`; see [Pipenv compatibility](testing/pipenv-compatibility.md)), and requirements.txt. Native uv vendoring requires uv ≥ 0.2.35 (the `[[package]]` lock grammar); hosted mode covers native `uv.lock` from uv 0.1.45 (the first release whose `uv lock` writes one) and requirements from uv 0.0.5; see [uv compatibility](testing/uv-compatibility.md). | ✅ requirements.txt including hash continuations, uv project/script locks, and PEP 751 locks. Version/source ambiguity is refused; see [uv compatibility](testing/uv-compatibility.md). Poetry 1.x and 2.x locks are supported; Poetry 0.x ignores URL sources and is refused. See [Poetry compatibility](testing/poetry-compatibility.md). Pipenv `Pipfile.lock` (pipfile-spec 6 — Pipenv 7 and later; `path` references for 7–11, `file` from 2018; lock-only checkouts and Pipenv's out-of-tree venv are discovered; a warm venv that Pipenv will not reinstall over warns `redirect_pypi_stale_install`; see [Pipenv compatibility](testing/pipenv-compatibility.md)). `pdm.lock` is supported for the lock formats PDM 0.12–1.4 and 2.8.1+ write (`lock_version` 2 / 4.3–4.5.1); the identity-losing 3.1 / 4.0–4.2 formats (PDM 1.8–2.7) are refused. PDM 2.8.0 writes an indistinguishable `4.3` lock but shares that identity-loss bug, so a rewritten 2.8.0 lock crashes `pdm sync` — upgrade to ≥ 2.8.1. See [PDM compatibility](testing/pdm-compatibility.md). | | Cargo (`cargo`) | ✅ in-place + `.cargo-checksum.json` rewrite (shared registry-cache caveat — see [Cargo: shared registry cache](#cargo-shared-registry-cache)) | ✅ `[patch.crates-io]` path entry | ✅ per-patch sparse registry (`[registries.socket-patch-]` + Cargo.lock source/checksum) | | RubyGems (`gem`) | ✅ Bundler plugin via `setup` — needs bundler ≥ 2.2 (1.x cannot load `plugin ... path:` directives; `setup` refuses below the floor and `setup --check` red-flags a wired 1.x project) | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — a `gems.rb` project cannot vendor yet) | ✅ per-dep `source` block — edits `gems.rb` + `gems.locked` when present (bundler prefers them over `Gemfile`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning); a stale pre-redirect materialization that `bundle install` would reuse instead of refetching is flagged `redirect_gem_stale_install` with a prescriptive remedy (see CLI_CONTRACT.md's "Gem stale-install guard") | @@ -65,13 +65,35 @@ 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` 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`, - `redirect_bun_lockb_unsupported` when `bun` is unavailable. (Contrast vendored mode, - which refuses `bun.lockb` and leaves you to run the migration yourself.) +- **bun** — text `bun.lock` lockfileVersion 0, 1 or 2: 0 is the `--save-text-lockfile` + opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three + emit one `packages` grammar, so registry entries rewrite identically. Any other or + missing version, or a `packages` section outside bun's single-line grammar, is refused + `redirect_bun_lock_unsupported` (a newer version means "update socket-patch" — re-locking + would reproduce it). A version-0 lock holding `workspace:` packages is refused + `redirect_bun_workspace_unsupported`: frozen installs of that grammar cannot keep the + hosted tuple; a plain `bun install` with any Bun ≥ 1.2 rewrites it in place as version 1, + which is accepted. Version-1 and version-2 workspace locks (nested versions included) + are rewritten. A binary `bun.lockb` with no text lock beside it is auto-migrated first, + when an npm patch is granted: the CLI runs the `bun` resolved on absolute `PATH` entries + (Windows `bun.cmd` shims included) as `bun install --save-text-lockfile + --frozen-lockfile --lockfile-only`, keeps the pre-migration bytes in the redirect ledger + and deletes the surviving `bun.lockb` itself, so `rollback` puts the binary lock back + (`redirect_bun_lockb_restored`; the generated `bun.lock` is kept — Bun ≥ 1.1.39 reads + `bun.lock` when both exist). The recipe works from Bun 1.1.43; Bun 1.1.39–1.1.42 accept + the flags but write no text lock (`redirect_bun_lockb_manual_migration` — run + `bun install --save-text-lockfile` yourself and re-run), Bun ≤ 1.1.38 has no text + lockfile at all, a missing or failing `bun` is `redirect_bun_lockb_unsupported` (with + bun's output tail), `--dry-run` reports `redirect_bun_lockb_would_migrate`, and a + migration whose rewrite lands nothing is undone (`redirect_bun_lockb_migration_reverted`). + (Contrast vendored mode, which refuses `bun.lockb` with the `--save-text-lockfile` + pointer and needs lockfileVersion 2 for `workspace:` locks — see the matrix row above.) + Hosted → vendored and vendored → hosted conversions both work in place (mode takeover), + and `rollback ` / `remove ` unwind one of several hosted bun redirects. + Bun verifies the sha512 of URL and local-tarball tuples only from 1.3.10 (registry + tuples from 1.2.0), so on 1.1.39–1.3.9 a hosted or vendored rewrite removes digest + enforcement for the patched package. Every boundary here is measured against real + Bun releases — see [Bun compatibility](testing/bun-compatibility.md). ## npm: Rush monorepos From a505ca90fea4be8b3c5986da45b34b4c85fb5a26 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 21 Sep 2026 10:52:26 -0400 Subject: [PATCH 30/49] docs(bun): rewrite the compatibility guide around the merged matrix, suites and CI wiring docs/testing/bun-compatibility.md now mirrors the sibling compatibility guides: a formats/rewrite table per lock generation and workspace shape (codes + remedies), the pre-download preflight and mode-conversion story, the bun.lockb migration table measured per release (<= 1.1.38 and 1.1.39-1.1.42 manual, 1.1.43-1.1.45 migrate and keep bun.lockb, >= 1.2 migrate and delete), the measured installer boundaries (lock history, in-place re-versioning incl. the version-0 -> 1 rewrite by any Bun >= 1.2 on workspace locks, member-relative tarball paths on 1.2-1.3, digest enforcement from 1.3.10 with the downgrade below it, both-lockfiles precedence, the 0.8.1/1.0.0 upstream limitation), the matrix runner (pinned versions incl. 1.1.43/1.3.9/1.3.10, every shape, the expectation oracle, exit-code and repeat-envelope assertions, the registry-digest control, rollback lockfile rules, captures + provenance), a claim -> evidence table separating matrix, hermetic real-bun suites and bun-less tests, and the CI wiring (ci.yml e2e bun legs on three OSes, bun-compatibility.yml on PR + main + dispatch, production suites on demand). hosted-/vendored-production-e2e.md: one bun paragraph each stating what the hosted-e2e job's bun@1 leg covers and that the vendored production bun leg is on-demand, with the CI legs that carry per-PR real-bun coverage. Co-Authored-By: Claude Fable 5.1 --- docs/testing/bun-compatibility.md | 312 ++++++++++++++++++++++-- docs/testing/hosted-production-e2e.md | 7 + docs/testing/vendored-production-e2e.md | 7 + 3 files changed, 304 insertions(+), 22 deletions(-) diff --git a/docs/testing/bun-compatibility.md b/docs/testing/bun-compatibility.md index cf9d152b..2a7b792a 100644 --- a/docs/testing/bun-compatibility.md +++ b/docs/testing/bun-compatibility.md @@ -1,6 +1,176 @@ # Bun patch compatibility -`scripts/backtest-bun.py` runs real Bun releases against the public free Socket patch for `minimist@1.2.2` (`80630680-4da6-45f9-bba8-b888e0ffd58c`). It uses the production CLI and patch service, without a token or substitute service. +`socket-patch` supports hosted, vendored and agent-mode npm patches in Bun +projects (text `bun.lock`). Two layers of real-Bun evidence back this page: + +- **The native matrix** — `scripts/backtest-bun.py` runs real Bun releases + against the public free Socket patch for `minimist@1.2.2` + (`80630680-4da6-45f9-bba8-b888e0ffd58c`) with the production CLI and patch + service, without a token or substitute service, and checks the INSTALLED + bytes, lock stability, digest rejection and rollback on Linux, macOS and + Windows ([workflow](../../.github/workflows/bun-compatibility.yml)). +- **The hermetic real-Bun suites** — + `crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs` (hosted), + `e2e_vendor_bun_build.rs` (vendored) and `mode_migration_bun.rs` + (hosted ⇄ vendored takeover, scoped unwind) drive a real `bun install` + against a wiremock patch service on every pull request, in `ci.yml`'s + `e2e` matrix. + +Underneath, the hosted and vendored bun rewriters are pinned by shared golden +fixtures (`crates/socket-patch-core/tests/fixtures/redirect/npm/bun/*`, +byte-identical to the depscan TypeScript twins; refusal fixtures pin their +warning code through `expected-warnings.json`) and by hermetic CLI suites that +need no Bun binary (`tests/in_process_vendor_bun.rs`, +`tests/in_process_vendor_bun_takeover.rs`, the bun cases of +`tests/in_process_redirect.rs`, `tests/covgap_commands_scan_hosted.rs`, +`tests/covgap_commands_scan_mod.rs`, `tests/covgap_commands_rollback.rs`, +`tests/scan_vendor_e2e.rs`, `tests/get_modes_e2e.rs`, +`tests/repair_vendor_flavors_e2e.rs`). The +[machine contract](../../crates/socket-patch-cli/CLI_CONTRACT.md) is the +authority on envelopes and codes; this page is the measured matrix behind it. +See the [ecosystem matrix](../ecosystems.md#mode--ecosystem-matrix) for the +other npm lockfile flavors. + +## Formats and rewrite behavior + +| Input | Hosted (`scan` / `get --mode hosted`) | Vendored (`scan` / `get --mode vendored`, `vendor`) | Agent / discovery | +|-------|------|------|------| +| Text `bun.lock`, lockfileVersion 0, 1 or 2, no `workspace:` packages | Registry 4-tuple `["name@ver", "", {deps}, "sha512-…"]` → URL 3-tuple `["name@https://patch.socket.dev/…/name-ver.tgz", {deps}, "sha512-"]`; the `{deps}` meta object (dependencies, bin, …), the lock's version line and its line endings are kept verbatim. | Same entry → local 3-tuple `["name@.socket/vendor/npm//name-ver.tgz", {deps}, "sha512-"]`, tarball committed under `.socket/vendor/npm//`; `--detached` keeps the record in `.socket/vendor/state.json` only. | The installed tree is patched in place; the lock's registry tuples are inventoried, so lockfile-only packages join discovery. | +| Version-0 lock (Bun 1.1.39–1.1.45 `--save-text-lockfile`) with `workspace:` packages — 2-tuple entries `"consumer": ["consumer@workspace:packages/consumer", { "dependencies": { … } }]` | Refused `redirect_bun_workspace_unsupported`, lock untouched, exit 0. Remedy (measured): a plain `bun install` with any Bun ≥ 1.2 rewrites the lock in place as lockfileVersion 1, which hosted mode accepts; or delete `bun.lock` and re-lock. | Refused `vendor_bun_workspace_unsupported` (pre-version-2 policy, next row). | Works. | +| Version-1 lock (Bun 1.2–1.3 default) with `workspace:` packages — 1-tuple entries `["consumer@workspace:packages/consumer"]` | Rewritten (golden `lock-v1-workspace`; matrix 1.2.0–1.3.14 `workspace` / `workspace-nested`). | Refused `vendor_bun_workspace_unsupported` before any write. Policy, not a grammar limit: Bun 1.2.x–1.3.x resolve a workspace member's local-tarball path relative to the MEMBER (our root-relative tuple ENOENTs on `bun install`), 1.4.x relative to the lockfile, and a committed lockfileVersion-2 lock is the only proof that every consumer runs Bun ≥ 1.4 (1.3.x cannot parse v2). A deliberate over-approximation: a package declared only by the workspace ROOT vendors and installs on v1 too, but the lock cannot cheaply prove which workspace declares a hoisted entry. Remedy in the detail: delete `bun.lock`, re-run `bun install` with Bun ≥ 1.4 (an in-place `bun install` keeps the existing version), or use `--mode hosted`. Already-vendored purls, in-sync re-runs and `repair` rebuilds on such a lock are NOT refused. | Works. | +| Version-2 lock (Bun 1.4+) with `workspace:` packages, nested versions included | Rewritten (golden `lock-v2-workspace-nested`). | Vendored (matrix 1.4.0 / 1.4.2 `workspace`, `workspace-nested`, `already-vendored-workspace`). | Works. | +| Binary `bun.lockb` only (Bun ≤ 1.1.38 always; 1.1.39–1.1.45 without `--save-text-lockfile`) | Auto-migrated to text before the read when an npm patch is granted — see [the `bun.lockb` migration](#the-bunlockb-migration). | Refused `vendor_bun_lockb_unsupported`: "run `bun install --save-text-lockfile` (Bun >= 1.1.39), commit the resulting bun.lock, and re-run" — one detail text on the `vendor` router and on the `get` / `scan` pre-download preflight. | The installed tree is patched; the inventory cannot read the lock, so `scan` warns `bun_lockb_unsupported` (run-level `warnings[]` + stderr) instead of reporting a clean empty inventory on a fresh clone. | +| `bun.lock` with a `lockfileVersion` ≥ 3, no integer version, or a `packages` section outside bun's single-line grammar | Refused `redirect_bun_lock_unsupported`. | Refused `vendor_lockfile_version_unsupported` (preflight and engine). | The inventory skips the lock. | + +One detail text serves both modes for the version gate: a newer version says +"update socket-patch, or re-lock with a Bun release that writes +lockfileVersion 0–2" (re-locking with a newer Bun would reproduce it); a +missing integer says "re-lock with Bun ≥ 1.2". + +**Pre-download preflight (vendored).** `scan --mode vendored`, +`get --mode vendored` (search and uuid paths) and `--detached` runs check +`bun.lock` / `bun.lockb` ONCE before any patch download when the selection +holds an npm purl. A refused project marks every npm result `failed` with the +vendor code + detail, fetches nothing and records no patch: the `scan` / +`get ` path still writes an unchanged `.socket/manifest.json` (an empty +`{"patches": {}}` on a fresh project; a record seeded for another purl +survives) and exits `partial_failure` / 1; `get --mode vendored` exits +1 with `status: "error"` and `error: {code, message}` before creating +`.socket/` at all; detached runs never write a manifest. `--silent` keeps the +code-tagged refusal on stderr; `--dry-run` previews it as the additive +`would_refuse` action. Agent-mode `get --save-only` is not preflighted. + +**Mode conversion.** Hosted → vendored (`scan` / `get --mode vendored`, +`vendor` over a hosted-redirected `bun.lock`) reverts the hosted line to its +pristine registry tuple, drops the redirect-ledger record and vendors +(`vendor_takeover_reverted_redirect`; `vendor --dry-run` PROBES the revert and +reports `vendor_would_revert_redirect`). Vendored → hosted reverts the vendored +wiring, ledger entry and committed artifact first +(`redirect_takeover_reverted_vendored`). `rollback ` / `remove ` +unwind one of several hosted bun records; an unscoped `rollback` unwinds +everything through the whole-ledger replay. Pinned hermetically by +`tests/in_process_vendor_bun_takeover.rs`, against real Bun by +`tests/mode_migration_bun.rs` (CI: Bun 1.4.2 on three OSes, 1.3.14 on Linux) +and by the matrix's `hosted-then-vendored` / `vendored-then-hosted` shapes. + +### The `bun.lockb` migration + +Hosted mode needs a text lock to edit. On a project whose only lock is +`bun.lockb`, and only when an npm patch is granted, the CLI runs the `bun` +resolved on absolute `PATH` entries (Windows `bun.cmd` / `.bat` shims through +`PATHEXT` and `cmd.exe /C`; a relative `PATH` entry never runs a +repository-planted `bun`) as + +```sh +bun install --save-text-lockfile --frozen-lockfile --lockfile-only +``` + +which needs no network and fails closed on drift. Measured against real +releases (macOS arm64, 2026-09-21; the matrix's `legacy-lockb` shape +re-measures it per OS): + +| Bun on `PATH` | What the recipe does | CLI outcome | +|---|---|---| +| ≤ 1.1.38 | exit 0, "no changes" — no text lockfile exists | `redirect_bun_lockb_manual_migration`; the detail says Bun ≤ 1.1.38 must be upgraded | +| 1.1.39–1.1.42 | exit 0, "no changes", **no `bun.lock` written** (`--frozen-lockfile` suppresses the save; a bare `bun install --save-text-lockfile` does write one) | `redirect_bun_lockb_manual_migration` — run `bun install --save-text-lockfile` yourself, then re-run | +| 1.1.43–1.1.45 | writes `bun.lock` (lockfileVersion 0) and **keeps `bun.lockb`** | migrated; the CLI deletes the surviving `bun.lockb` itself | +| ≥ 1.2.0 | writes `bun.lock` (lockfileVersion 1) and deletes `bun.lockb` | migrated | +| `bun` missing, unspawnable, or exit ≠ 0 | — | `redirect_bun_lockb_unsupported` with bun's output tail in the detail; `bun.lockb` untouched (never parsed) | +| `--dry-run` | not spawned | `redirect_bun_lockb_would_migrate` | + +A successful migration is recorded as a `redirect_bun_lockb_migrated` / +`removed` ledger edit whose `original` carries the pre-migration bytes +(standard base64, locks up to 8 MiB). `rollback` writes `bun.lockb` back and +warns `redirect_bun_lockb_restored` (the generated `bun.lock` is kept — +Bun ≥ 1.1.39 reads `bun.lock` when both exist; delete whichever you do not +want); `redirect_bun_lockb_unrestorable` is reserved for a marker without +bytes while the file is absent, or a different `bun.lockb` that appeared +since (never clobbered). A migration whose rewrite then lands nothing (the +granted version is not in the lock) is undone — bytes restored, text lock +removed, no ledger record — and reported +`redirect_bun_lockb_migration_reverted`. Bun ≥ 1.2 has no flag that emits the +binary form, so the hermetic suites cannot generate a `bun.lockb`; the branch +is pinned by shim-driven CLI tests (`tests/in_process_redirect.rs` incl. the +Windows `bun.cmd` twins, `tests/covgap_commands_scan_hosted.rs`) and by the +matrix's `legacy-lockb` shape against real Bun 1.1.39–1.4.2. + +## Installer boundaries (measured) + +Local probes ran on macOS arm64 with the releases named below; the workflow +downloads the matching linux / darwin / windows build (x64, or aarch64 on ARM +runners) from the GitHub releases and verifies it against `SHASUMS256.txt`. Every measurement sets +`BUN_INSTALL_CACHE_DIR` and `BUN_INSTALL` per project and passes +`--ignore-scripts`. + +- **Lock history.** Binary `bun.lockb` only through 1.1.38. The text lock + arrives in 1.1.39 as the `--save-text-lockfile` opt-in (lockfileVersion 0: + trailing commas, no `configVersion`, 2-tuple workspace entries); + `--lockfile-only` exists from 1.1.43; text is the default from 1.2.0 + (lockfileVersion 1) through 1.3.x; 1.4.0 writes 2 for a FRESH lock behind + an unchanged grammar. Registry 4-tuples are byte-identical across 0/1/2, + so the rewrite is version-independent; the workspace grammar, the + migration recipe and digest enforcement are not. +- **In-place re-versioning.** Bun never bumps an existing version-1 lock in + place — 1.4.x `install`, `add`, `update`, `--force` and + `--save-text-lockfile` all keep 1; only deleting `bun.lock` and re-locking + writes 2 (hence the vendored workspace remedy). A version-0 lock WITHOUT + workspaces stays 0 under 1.2.0, 1.2.23 and 1.3.0 and is rewritten as 1 by + 1.3.9, 1.3.10, 1.3.13, 1.3.14, 1.4.0 and 1.4.2 (the first bumping release + lies in (1.3.0, 1.3.9]). A version-0 lock WITH workspaces whose root + depends on the member (the matrix's `workspace` shape) is rewritten as 1 by + a plain `bun install` on 1.2.0, 1.2.23, 1.3.0, 1.3.14 and 1.4.2 — the root + dependency's spelling changes from a bare path to `workspace:*`, which + forces the save — while 1.1.45 keeps it at 0; that is the hosted refusal's + remedy. +- **Workspace-member local tarballs.** Bun 1.2.x–1.3.x resolve a + local-tarball dependency declared by a workspace member relative to the + member (`.socket/vendor/…` → ENOENT on `bun install`); 1.4.x resolve it + relative to the lockfile. A package declared only by the root installs on + version-1 locks as well; the vendored gate still refuses (policy above). +- **Digest enforcement.** Bun verifies the sha512 of URL and local-tarball + tuples only from **1.3.10**: 1.3.9 installs a tarball whose bytes do not + match the lock with exit 0, 1.3.10 fails with `Integrity check failed`. + Registry 4-tuples are verified from 1.2.0. So on 1.1.39–1.3.9 a hosted or + vendored rewrite REMOVES digest enforcement for the patched package (the + registry tuple it replaced was checked; the URL / local tuple is not) — the + committed lock and artifact are the protection there. The PR's first + matrix placed the boundary at 1.3.14 because it sampled only 1.3.0 and + 1.3.14; the hermetic suites pin it from both sides + (`TARBALL_INTEGRITY_ENFORCED_FROM = (1, 3, 10)` with the 1.1.45 / 1.2.23 / + 1.4.2 legs) and the matrix carries 1.3.9 and 1.3.10. +- **Both lockfiles present.** Bun ≥ 1.1.39 reads `bun.lock` when `bun.lockb` + sits beside it; Bun ≤ 1.1.38 reads only `bun.lockb` — which is why the CLI + removes a surviving `bun.lockb` after the migration (a stale binary lock + beside the redirected text lock is what an old Bun would silently install + the UNPATCHED bytes from). +- **Bun 0.8.1 / 1.0.0 with peer or overridden-transitive shapes** do not + install the selected patched version at all; the CLI leaves those projects + unchanged (an upstream limitation, recorded by the matrix as such). +- **Frozen installs never write the lock**, so only a plain `bun install` can + observe re-serialization drift — the matrix's `ordinaryStableLock` check and + the plain-install legs of the hermetic suites both run it. + +## Running the matrix ```sh cargo build --locked -p socket-patch-cli @@ -11,32 +181,130 @@ python3 scripts/backtest-bun.py \ --modes hosted vendored vendored-detached ``` -Use `--versions 1.4.2 --shapes workspace-nested` for a focused reproduction. Windows uses `target/debug/socket-patch.exe`. The [Bun workflow](../../.github/workflows/bun-compatibility.yml) runs Linux, macOS and Windows; releases before Bun 1.1 have no Windows binary. +Use `--versions 1.4.2 --shapes workspace-nested` for a focused reproduction, +`--tools ` to reuse pre-downloaded binaries +(`//bun--/bun[.exe]`, the layout the workflow +pre-populates) and `--jobs N` for parallel cells. Windows uses +`target/debug/socket-patch.exe`. Bun binaries are downloaded from the GitHub +release with retries and verified against `SHASUMS256.txt` (`bunSha256` in the +provenance); releases before 1.1.0 have no Windows binary. + +**Pinned versions:** 0.8.1, 1.0.0, 1.0.36, 1.1.0, 1.1.38 (binary lock), +1.1.39 (first text lock, version 0), 1.1.43 (first `--lockfile-only`), 1.1.45 +(last version-0 writer), 1.2.0, 1.2.23, 1.3.0 (version 1), 1.3.9 / 1.3.10 +(digest boundary), 1.3.14 (last pre-v2 default), 1.4.0, 1.4.2 (version 2). + +**Shapes.** `direct`, `dev`, `optional`, `peer`, `alias` (`npm:` alias +install), `transitive` (overridden transitive), `two-versions`, `workspace` +(the member declares the dep), `workspace-nested` (root and member at +different versions), `workspace-root` (the root declares the dep, the member +something else), `text-workspace` (Bun 1.1.39–1.1.45 `--save-text-lockfile` +on the workspace project — a REAL version-0 workspace lock), `workspace-get` +(`get` by uuid and by PURL on the workspace project), `already-vendored- +workspace` (vendor a plain project, add a workspace member, `bun install`, +re-run — must be `already_vendored`; then `repair` rebuilds a deleted +tarball), `crlf` (CRLF manifest), `crlf-lock` (CRLF `bun.lock`), +`space-unicode` (a path with spaces and Unicode), `custom-registry` (a +non-empty registry slot the rewrite must drop), `text` (`--save-text-lockfile` +opt-in, Bun ≥ 1.1.39 only — asserts `bun.lock` exists after the baseline), +`isolated` / `hoisted` linkers, `lockfile-only` (no `node_modules`), +`production`, `get-uuid`, `get-search`, `legacy-lockb` (the baseline is +installed with Bun 1.1.38 so the project starts with `bun.lockb`; the matrix +Bun then runs the CLI), `hosted-then-vendored` / `vendored-then-hosted` (mode +conversion on ONE project) and `preexisting-manifest` (a record seeded for +another purl must survive a refused vendored run). + +**Expectation oracle.** `expected_outcome(version, shape, mode)` encodes the +boundaries above — not the CLI's own output — and every cell asserts +`supported` against it and the refusal codes EXACTLY, after removing an +explicit informational allowlist (`vendor_prebuilt_downloaded`, +`vendor_fetched_missing`, `reinstall_required`, `redirect_bun_lockb_restored`, +…); substring matching is never used. A configuration expected to be +supported FAILS on `redirect_bun_lockb_migration_reverted`, +`redirect_bun_lockb_migrated_without_redirect`, `redirect_bun_entry_not_found` +or `redirect_revert_failed`. Exit codes are recorded for every invocation and +asserted: supported → 0; hosted refusals → 0 with `redirect.redirected == 0` +(the documented hosted-refusal posture); vendored, detached and `get` refusals +→ non-zero, with `download.downloaded == 0` and no stray manifest record. -The pinned matrix covers 0.8.1, 1.0.0, 1.0.36, 1.1.0, 1.1.38, 1.1.39, 1.1.45, 1.2.0, 1.2.23, 1.3.0, 1.3.14, 1.4.0 and 1.4.2. These span the binary lockfile, the first text locks (version 0), the text default (version 1), and version 2. Configurations cover direct, development, optional, peer, aliased and overridden transitive dependencies; two versions of a package; root and nested workspace dependencies; explicit registries; text-lock opt-in; production installs; projects without `node_modules`; isolated and hoisted linkers; CRLF manifests and paths containing spaces and Unicode. +**Every supported case verifies:** -Every supported case verifies: +- the ledger (or manifest) record names the expected published patch uuid; +- a fresh `bun install --frozen-lockfile` and a fresh ordinary `bun install` + (empty caches, no `node_modules`) install the record's exact `afterHash` + bytes and leave the lockfile byte-identical; +- the repeat run is a no-op with the documented envelope — hosted: + `status: success`, `redirect.redirected == 1`, no non-informational warning; + vendored / detached: `summary.applied == 0`, `summary.skipped == 1`, + `summary.failed == 0`, one `already_vendored` event, no `failed` action — + and preserves the lock bytes; +- `registryDigestEnforced`: before the CLI runs, a copy of the project with a + tampered REGISTRY-tuple sha512 fails `bun install --frozen-lockfile` on + every release whose baseline wrote a text lock (documents what the rewrite + is compared against); +- `rejectCorruptDigest`: a tampered sha512 on the PATCHED tuple is rejected on + Bun ≥ 1.3.10; below that the observation is RECORDED + (`legacyDigestBehavior`) rather than asserted; +- rollback restores the original manifest / lock bytes, removes the + `.socket/vendor` state, and a clean install reproduces the record's + `beforeHash` bytes; text-lock projects end with `bun.lock` restored and no + `bun.lockb`, and `legacy-lockb` cells end with `bun.lockb` restored + byte-identical (sha256 == baseline) beside the generated `bun.lock`, with + `redirect_bun_lockb_restored` and never `redirect_bun_lockb_unrestorable`. -- CLI output identifies the expected published patch. -- Fresh frozen and ordinary installs contain the patch record's exact `afterHash` bytes, with unchanged lockfiles. -- Repeated scans preserve lockfile bytes. -- A corrupted digest on the patched tuple is rejected on releases that enforce it. -- Rollback restores original manifest/lock bytes and a clean install reproduces the record's `beforeHash` bytes. +The runner captures the exact project manifests, lockfiles, optional +`.socket/manifest.json`, CLI JSON, exit codes, file hashes and assertion +results (`captures/--/`), plus provenance +(`cliRevision`, the build SHA, `bunSha256`). The depscan SBOM tests import +these captures through their fixture validation framework +(`bun-compatibility/generate-fixtures.py --captures`, depscan #26453). +Vendored artifact contents are verified by the native runner; they are not +needed for SBOM lockfile annotation. -The runner captures the exact project manifests, lockfiles, optional `.socket/manifest.json`, CLI JSON, file hashes and assertion results. Socket SBOM tests import these captures through their existing fixture validation framework. Vendored artifact contents are verified by the native runner; they are not needed for SBOM lockfile annotation. +## What is verified where -The `get-uuid` and `get-search` cases also exercise explicit patch retrieval by UUID and PURL, including refusal before manifest writes on unsupported Bun projects. +| Claim | Real-Bun matrix (`backtest-bun.py`) | Real-Bun hermetic suites (`ci.yml` `e2e`) | Bun-less unit / CLI tests | +|---|---|---|---| +| Text lock 0 / 1 / 2 rewritten and installed, both modes | 1.1.39–1.4.2 | `e2e_redirect_bun_build` + `e2e_vendor_bun_build` on 1.4.2 (3 OS), 1.1.45 and 1.2.23 (Linux); the fixture asserts the lock version matches the era table, the v1-on-1.4 leg proves a committed v1 lock keeps installing | goldens `lock-v0`, `basic` (v1), `lock-v2`; `bun_lock.rs`, `lock_inventory.rs` | +| Binary lock → vendored refuses; `scan` warns `bun_lockb_unsupported` | 0.8.1–1.1.45, `legacy-lockb` | — | `in_process_vendor_bun`, `covgap_commands_scan_mod` | +| lockb migration bands (manual 1.1.39–1.1.42; migrates ≥ 1.1.43; the CLI removes `bun.lockb`; rollback restores it) | `legacy-lockb`, 1.1.39–1.4.2 | — (Bun ≥ 1.2 cannot write a `bun.lockb`) | shim-driven `in_process_redirect` (incl. Windows `bun.cmd`), `covgap_commands_scan_hosted`, `replay.rs` | +| Version-0 workspace hosted refusal + remedy | `text-workspace` (1.1.39–1.1.45); 1.1.45 `workspace*` after migration | — | golden `lock-v0-workspace-refusal` (+ `expected-warnings.json`), `redirect/mod.rs` unit tests | +| Pre-v2 workspace vendored refusal (policy) + remedy; version 2 supported incl. nested | v1: 1.2.0–1.3.14 `workspace*`; v0: `text-workspace`; v2: 1.4.x | `e2e_vendor_bun_build` scoped leg (deps + bin meta survive) | `bun_lock.rs` (`legacy_workspace_tarballs_refuse_before_writes`, in-sync / rebuild exemptions), `in_process_vendor_bun`, `repair_vendor_flavors_e2e` over {0, 1, 2} × workspace shapes | +| Digest boundary 1.3.10 (registry tuples 1.2.0) | 1.3.9 vs 1.3.10 cells, `registryDigestEnforced` | tampered twins in both suites, pinned from both sides | — | +| Mode conversion both directions; scoped `rollback` / `remove` | `hosted-then-vendored`, `vendored-then-hosted` | `mode_migration_bun` (1.4.2 × 3 OS, 1.3.14) | `in_process_vendor_bun_takeover`, `takeover.rs`, `covgap_commands_rollback` | +| CRLF lockfiles preserved (hosted line, vendored, rollback) | `crlf-lock` | — | golden `lock-v2-crlf`, `bun_lock.rs` | +| Bun 0.8.1 / 1.0.0 peer / transitive upstream limitation | recorded per cell | — | — | +| Pre-download preflight envelopes, `--silent`, `--dry-run` `would_refuse`, detached parity | `get-uuid` / `get-search` / `workspace-get` refusals (exit codes, `downloaded == 0`) | — | `in_process_vendor_bun` (exact uuid-path envelope), `scan_vendor_e2e`, `get_modes_e2e`, `vendor_flow.rs` | -## Boundaries verified by the matrix +Not measured: a `--cwd ` run (the member holds no +`bun.lock`, so the preflight passes and the engine refuses +`vendor_lockfile_missing` — pinned as today's behaviour, not a promise), and +package shapes the real registry never installs for the free patch +(non-empty peer meta on the oldest releases). -| Configuration | Behavior | -| --- | --- | -| Text lock 0, 1 or 2, no workspaces | Hosted and vendored rewrites supported. | -| Binary lock only | Vendored mode refuses. Hosted mode attempts Bun's native text migration and refuses when that release cannot perform it. Without installed packages, binary locks cannot supply a package inventory. | -| Version-0 workspace lock | Hosted mode refuses because frozen installs cannot preserve the rewrite. | -| Version-0/1 workspace lock | Vendored mode refuses because Bun resolves local tarballs relative to the workspace. Upgrade to Bun 1.4 or later and regenerate the lock. | -| Version-2 workspace lock | Hosted and vendored modes supported, including nested versions. | -| Bun before 1.3.14 in this matrix | Native tarball digest enforcement is absent. The runner records the limitation rather than claiming corruption was rejected. | -| Bun 0.8.1 / 1.0.0 peers or transitive overrides | These releases do not install the selected patched version in these configurations; the CLI leaves the project unchanged. | +## CI wiring -Refused vendored downloads do not add a patch record to the manifest. Existing explicit manifest intent is preserved. Detached vendoring keeps its patch record in the vendor ledger and supplies the same lockfile annotation. +- **`ci.yml` `e2e` matrix** — `oven-sh/setup-bun` installs the pinned release + and the job exports `SOCKET_PATCH_BUN_E2E_REQUIRED=1` + + `SOCKET_PATCH_BUN_E2E_VERSION`, under which the suites hard-fail instead of + soft-skipping when `bun` is missing, is the wrong version, or the fixture + install yields no text lock. Legs: `e2e_redirect_bun_build` and + `e2e_vendor_bun_build` on ubuntu / macos / windows with Bun 1.4.2 plus + ubuntu lock-era legs on 1.1.45 (version 0) and 1.2.23 (version 1); + `mode_migration_bun` on the three OSes with 1.4.2 and on ubuntu with + 1.3.14. Without the `bun:` key the suites print `SKIP` and pass — the plain + `test` job never exercises them. +- **`bun-compatibility.yml`** — the native matrix (16 releases × 3 OS, minus + the three pre-1.1 Windows cells) on pull requests that touch the bun code + paths, on pushes to `main` (the only rust-cache writer) and on + `workflow_dispatch` with `versions` / `shapes` / `modes` inputs. Each cell + pre-downloads the release with retries, verifies `SHASUMS256.txt`, and + uploads `summary.json` plus the captures as `bun-results--`. +- **Production suites (on demand).** + `e2e_hosted_production::bun_hosted_install_proof` runs in `ci.yml`'s + `hosted-e2e` job (`npm install -g bun@1`, + `SOCKET_PATCH_HOSTED_E2E_STRICT=1`); + `e2e_vendored_production::bun_vendored_install_proof` is `#[ignore]`-gated + and runs only by hand (`cargo test -p socket-patch-cli --test + e2e_vendored_production -- --ignored`) — the production vendored proof for + bun on three OSes is the native matrix above. diff --git a/docs/testing/hosted-production-e2e.md b/docs/testing/hosted-production-e2e.md index 5bd9393d..5b52562a 100644 --- a/docs/testing/hosted-production-e2e.md +++ b/docs/testing/hosted-production-e2e.md @@ -232,6 +232,13 @@ It retries the suite up to three times with backoff, because the public proxy intermittently returns 503 "Service temporarily over capacity" — the documented reason the older live-API suites were pulled from the PR matrix. +The job installs `bun@1` through npm, so its bun leg (`bun_hosted_install_proof`) +runs against whatever 1.x release that resolves to (a lockfileVersion-2 lock +today). Lock-era coverage — version-0 and version-1 locks, the `bun.lockb` +migration, the 1.3.10 digest boundary — lives in `ci.yml`'s hermetic +`e2e_redirect_bun_build` legs and in `bun-compatibility.yml`; see +[Bun compatibility](bun-compatibility.md). + ### Escape hatch — production is down and this is blocking merges Set a repository variable (Settings → Secrets and variables → Actions → diff --git a/docs/testing/vendored-production-e2e.md b/docs/testing/vendored-production-e2e.md index 04c7f95a..4728abfe 100644 --- a/docs/testing/vendored-production-e2e.md +++ b/docs/testing/vendored-production-e2e.md @@ -167,6 +167,13 @@ The suite is `#[ignore]`-gated, so it stays out of the `test` and `e2e` jobs and runs only where it is explicitly asked for. `--test-threads=1` keeps the real installs from contending on the shared cache sandbox. +The bun leg (`bun_vendored_install_proof`) is therefore on-demand production +coverage. The per-PR real-Bun evidence for vendored mode is the hermetic +`e2e_vendor_bun_build` suite in `ci.yml`'s `e2e` matrix (Bun 1.4.2 on three +OSes, 1.1.45 and 1.2.23 on Linux) plus the production native matrix in +`bun-compatibility.yml` (16 releases × 3 OS in hosted, vendored and +vendored-detached mode) — see [Bun compatibility](bun-compatibility.md). + ### Environment knobs | Variable | Effect | From db3bf7b253c4acba84798530ab9828ec852dbac5 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 21 Sep 2026 11:26:33 -0400 Subject: [PATCH 31/49] docs(bun): name the two workspace-get backtest shapes as the script spells them Co-Authored-By: Claude Fable 5.1 --- docs/testing/bun-compatibility.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/testing/bun-compatibility.md b/docs/testing/bun-compatibility.md index 2a7b792a..b3111dff 100644 --- a/docs/testing/bun-compatibility.md +++ b/docs/testing/bun-compatibility.md @@ -199,7 +199,7 @@ install), `transitive` (overridden transitive), `two-versions`, `workspace` (the member declares the dep), `workspace-nested` (root and member at different versions), `workspace-root` (the root declares the dep, the member something else), `text-workspace` (Bun 1.1.39–1.1.45 `--save-text-lockfile` -on the workspace project — a REAL version-0 workspace lock), `workspace-get` +on the workspace project — a REAL version-0 workspace lock), `workspace-get-uuid` / `workspace-get-search` (`get` by uuid and by PURL on the workspace project), `already-vendored- workspace` (vendor a plain project, add a workspace member, `bun install`, re-run — must be `already_vendored`; then `repair` rebuilds a deleted @@ -274,7 +274,7 @@ needed for SBOM lockfile annotation. | Mode conversion both directions; scoped `rollback` / `remove` | `hosted-then-vendored`, `vendored-then-hosted` | `mode_migration_bun` (1.4.2 × 3 OS, 1.3.14) | `in_process_vendor_bun_takeover`, `takeover.rs`, `covgap_commands_rollback` | | CRLF lockfiles preserved (hosted line, vendored, rollback) | `crlf-lock` | — | golden `lock-v2-crlf`, `bun_lock.rs` | | Bun 0.8.1 / 1.0.0 peer / transitive upstream limitation | recorded per cell | — | — | -| Pre-download preflight envelopes, `--silent`, `--dry-run` `would_refuse`, detached parity | `get-uuid` / `get-search` / `workspace-get` refusals (exit codes, `downloaded == 0`) | — | `in_process_vendor_bun` (exact uuid-path envelope), `scan_vendor_e2e`, `get_modes_e2e`, `vendor_flow.rs` | +| Pre-download preflight envelopes, `--silent`, `--dry-run` `would_refuse`, detached parity | `get-uuid` / `get-search` / `workspace-get-uuid` / `workspace-get-search` refusals (exit codes, `downloaded == 0`) | — | `in_process_vendor_bun` (exact uuid-path envelope), `scan_vendor_e2e`, `get_modes_e2e`, `vendor_flow.rs` | Not measured: a `--cwd ` run (the member holds no `bun.lock`, so the preflight passes and the engine refuses From 263b4c1d5442cec0eb04e2069773d9fa1a36706a Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 21 Sep 2026 12:07:16 -0400 Subject: [PATCH 32/49] fix(bun): recognise, heal and unwind digest-less tuples re-saved by Bun < 1.3.10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every text-lock Bun below 1.3.10 (1.1.39–1.3.9; measured on 1.1.45, 1.2.23 and 1.3.9) re-saves a hosted URL or vendored local-tarball 3-tuple WITHOUT its sha512 whenever bun.lock is re-saved for another reason (`bun add`, `bun install` after a package.json / workspace change). The 2-tuple keeps the spec Bun installs from, but the CLI no longer recognised its own wiring: a repeat hosted scan warned redirect_bun_entry_not_found beside redirected: 1, `rollback` refused partial_failure, the hosted→vendored takeover (and scoped rollback / remove) refused as drift, and the vendored re-run / repair / revert refused vendor_lock_entry_not_found / _drifted. - bun_lock_text: same_wiring_modulo_integrity + restore_digestless_line — a live line is the recorded wiring iff byte-equal (modulo trailing \r) or the same key/spec/meta with only the trailing "sha512-…" dropped. - hosted rewriter: a 2-tuple at the current URL is healed back to the 3-tuple (the edit records the 2-tuple as original); a stale URL is re-pinned from either spelling; no entry_not_found for either. - replay + takeover: when neither `new` nor `original` is present, the unique digest-less spelling of `new` is replaced by `original` (redirect_bun_lock_package only); duplicates and anything else refuse. - vendored engine: classify accepts the 2-tuple as Ours; an in-sync digest-less line is healed on disk without a wiring record when the committed artifact still holds the bytes the lock was written from, otherwise re-pinned like any stale tuple (repair's rebuild returns a fresh entry whose original carry_forward_wiring refills); revert claims the 2-tuple by its uuid path. - fix the v0-bump comment (first bumping release lies in (1.3.0, 1.3.9]). - tests: unit tests in all five modules, goldens digestless-hosted-already-wired + digestless-hosted-stale-url-repin, in_process_redirect / in_process_vendor_bun / in_process_vendor_bun_takeover, and real-bun legs in both e2e suites (network-free `file:`-dep re-save, the era's spelling asserted from both sides); backtest already-vendored-workspace now expects the digest-less spelling below 1.3.10 (digestDroppedOnResave, resaveKeepsDigest). - docs: CLI_CONTRACT bun clauses, bun-compatibility guide, CHANGELOG. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 13 +- crates/socket-patch-cli/CLI_CONTRACT.md | 6 +- .../tests/e2e_redirect_bun_build.rs | 261 +++++++++++ .../tests/e2e_vendor_bun_build.rs | 223 +++++++++ .../tests/in_process_redirect.rs | 134 ++++++ .../tests/in_process_vendor_bun.rs | 152 ++++++ .../tests/in_process_vendor_bun_takeover.rs | 210 +++++++++ .../src/patch/redirect/mod.rs | 200 +++++++- .../src/patch/redirect/replay.rs | 304 ++++++++++-- .../src/patch/redirect/takeover.rs | 203 +++++++- .../socket-patch-core/src/vendor/bun_lock.rs | 440 ++++++++++++++++-- .../src/vendor/bun_lock_text.rs | 223 +++++++++ .../expected-edits.json | 10 + .../expected-warnings.json | 1 + .../expected/bun.lock | 15 + .../input/bun.lock | 15 + .../overrides.json | 13 + .../expected-edits.json | 10 + .../expected-warnings.json | 1 + .../expected/bun.lock | 15 + .../input/bun.lock | 15 + .../overrides.json | 13 + docs/testing/bun-compatibility.md | 20 + scripts/backtest-bun.py | 28 +- 24 files changed, 2426 insertions(+), 99 deletions(-) create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-already-wired/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-already-wired/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-already-wired/expected/bun.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-already-wired/input/bun.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-already-wired/overrides.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-stale-url-repin/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-stale-url-repin/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-stale-url-repin/expected/bun.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-stale-url-repin/input/bun.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-stale-url-repin/overrides.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 42b692d3..e325d658 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -315,8 +315,17 @@ into the new version's section — see docs/releasing.md. the production native matrix — 16 releases from 0.8.1 to 1.4.2 in hosted, vendored and detached-vendored mode — on pull requests and `main`, with the corrected digest boundary (Bun verifies URL/local tarball sha512 from - 1.3.10, not 1.3.14). See `docs/testing/bun-compatibility.md` and - `scripts/backtest-bun.py`. (#245) + 1.3.10, not 1.3.14). Bun 1.1.39–1.3.9 also re-save a hosted URL or + vendored local-tarball tuple WITHOUT its `sha512` on any later lock + re-save (`bun add`, `bun install` after a manifest change); that + digest-less 2-tuple is now recognised as the CLI's own wiring — repeat runs + heal the digest, `repair` rebuilds through it, and `rollback`, scoped + `rollback` / `remove`, `vendor --revert` and both mode takeovers unwind it + to the registry line — where previously every re-save on those releases + left `redirect_bun_entry_not_found` beside `redirected: 1`, a + `partial_failure` rollback and `vendor_lock_entry_not_found` / + `vendor_lock_entry_drifted` refusals. See + `docs/testing/bun-compatibility.md` and `scripts/backtest-bun.py`. (#245) - **Rollback after a Pipenv relock no longer refuses forever.** `pipenv lock` (and `update`, and `install ` before 2024) regenerates a redirected or vendored entry to registry shape on every Pipenv major; that is now the diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 07d427f0..40f3b57a 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -120,7 +120,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc `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`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock`), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `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 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the verified remedy is a plain `bun install` with any Bun ≥ 1.2, which rewrites such a lock in place as version 1 (accepted), or deleting `bun.lock` and re-locking. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **`bun.lockb` auto-migration**: a binary `bun.lockb` with no text lock beside it is migrated to text before the read — only when an npm override was granted (with nothing to redirect the user's lockfile format is never touched) and never on `--dry-run` (`redirect_bun_lockb_would_migrate`, nothing spawned). The CLI spawns the `bun` resolved on ABSOLUTE `PATH` entries only (a relative entry would run a `bun` planted in the scanned repository; on Windows `PATHEXT` finds the npm-global `bun.cmd` shim, launched through `cmd.exe /C`) as `bun install --save-text-lockfile --frozen-lockfile --lockfile-only` (offline, fails closed on drift; bun's chatter never reaches stdout). The recipe works from Bun 1.1.43: 1.1.43–1.1.45 write a version-0 `bun.lock` and KEEP `bun.lockb`, ≥ 1.2 write version 1 and delete it — the CLI removes a surviving `bun.lockb` itself, so the ledger's `redirect_bun_lockb_migrated` / action `removed` FileEdit is always true and Bun ≤ 1.1.38 can never silently install the unpatched bytes from a stale binary lock beside the redirected text lock; the edit's `original` carries the pre-migration bytes as standard base64 (locks up to 8 MiB; larger ones are recorded without bytes), which `rollback` restores (see "Hosted unwind coverage"). Bun 1.1.39–1.1.42 accept the flags, exit 0 and write NO text lock (`--frozen-lockfile` suppresses the save there; Bun ≤ 1.1.38 has no text lockfile at all): `redirect_bun_lockb_manual_migration`, whose detail names the manual step (`bun install --save-text-lockfile`, Bun ≥ 1.1.39) and re-run. A missing or unspawnable `bun`, a non-zero exit (the detail carries bun's output tail) or a surviving `bun.lockb` that cannot be removed (the text lock bun wrote is dropped again) is `redirect_bun_lockb_unsupported`; the binary lock is never parsed. A migration whose rewrite then lands nothing in the new `bun.lock` is undone — `bun.lockb` bytes restored, generated `bun.lock` removed, no ledger record — and reported `redirect_bun_lockb_migration_reverted` (`redirect_bun_lockb_migrated_without_redirect` when that restore itself fails; git history is the restore path); the rewriter's own warning says why nothing landed. The `redirect_npm_no_lockfile` noise is suppressed on a `bun.lockb` project. Every bun code above rides `redirect.warnings[]` with the hosted-refusal posture (exit 0, `redirected: 0`); all of it is additive (MINOR). Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`). **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`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock`), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `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 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the verified remedy is a plain `bun install` with any Bun ≥ 1.2, which rewrites such a lock in place as version 1 (accepted), or deleting `bun.lock` and re-locking. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **`bun.lockb` auto-migration**: a binary `bun.lockb` with no text lock beside it is migrated to text before the read — only when an npm override was granted (with nothing to redirect the user's lockfile format is never touched) and never on `--dry-run` (`redirect_bun_lockb_would_migrate`, nothing spawned). The CLI spawns the `bun` resolved on ABSOLUTE `PATH` entries only (a relative entry would run a `bun` planted in the scanned repository; on Windows `PATHEXT` finds the npm-global `bun.cmd` shim, launched through `cmd.exe /C`) as `bun install --save-text-lockfile --frozen-lockfile --lockfile-only` (offline, fails closed on drift; bun's chatter never reaches stdout). The recipe works from Bun 1.1.43: 1.1.43–1.1.45 write a version-0 `bun.lock` and KEEP `bun.lockb`, ≥ 1.2 write version 1 and delete it — the CLI removes a surviving `bun.lockb` itself, so the ledger's `redirect_bun_lockb_migrated` / action `removed` FileEdit is always true and Bun ≤ 1.1.38 can never silently install the unpatched bytes from a stale binary lock beside the redirected text lock; the edit's `original` carries the pre-migration bytes as standard base64 (locks up to 8 MiB; larger ones are recorded without bytes), which `rollback` restores (see "Hosted unwind coverage"). Bun 1.1.39–1.1.42 accept the flags, exit 0 and write NO text lock (`--frozen-lockfile` suppresses the save there; Bun ≤ 1.1.38 has no text lockfile at all): `redirect_bun_lockb_manual_migration`, whose detail names the manual step (`bun install --save-text-lockfile`, Bun ≥ 1.1.39) and re-run. A missing or unspawnable `bun`, a non-zero exit (the detail carries bun's output tail) or a surviving `bun.lockb` that cannot be removed (the text lock bun wrote is dropped again) is `redirect_bun_lockb_unsupported`; the binary lock is never parsed. A migration whose rewrite then lands nothing in the new `bun.lock` is undone — `bun.lockb` bytes restored, generated `bun.lock` removed, no ledger record — and reported `redirect_bun_lockb_migration_reverted` (`redirect_bun_lockb_migrated_without_redirect` when that restore itself fails; git history is the restore path); the rewriter's own warning says why nothing landed. The `redirect_npm_no_lockfile` noise is suppressed on a `bun.lockb` project. Every bun code above rides `redirect.warnings[]` with the hosted-refusal posture (exit 0, `redirected: 0`); all of it is additive (MINOR). Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`). **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. @@ -571,7 +571,7 @@ to **six flavors**. | npm / yarn berry (node-modules linker) | (same tarball) | root `package.json` `resolutions` + `yarn.lock` entry with `checksum: 10c0/` of the berry cache-zip (reproduced from the tarball offline). **PnP is refused** (`.pnp.*` → different artifact pipeline) | `yarn install --immutable --check-cache`, cold cache. Refused if `__metadata.cacheKey ≠ 10c0` or a non-default `compressionLevel` | | npm / pnpm (lockfileVersion 9) | (same tarball) | root `package.json` `pnpm.overrides` (versioned selector) **+** `pnpm-lock.yaml` surgery (overrides / importer version / packages `resolution.integrity` / snapshots) | `pnpm install --frozen-lockfile --offline`, cold store (integrity-verified; byte-stable on pnpm 9 & 10). Other lockfileVersions: 5.4/6.0 route to the legacy backend below; anything else refused | | npm / pnpm LEGACY (lockfileVersion 5.4 = pnpm 7, 6.0 = pnpm 8; flavor `pnpm-legacy`) | (same tarball) | root `package.json` `pnpm.overrides` **+** legacy lock surgery (overrides / root dep + specifiers / packages rekey to a bare `file:` key with recomputed integrity / in-package dep refs). **No `pnpm-workspace.yaml` is written** (pnpm ≤ 8 reads overrides only from package.json). The lock's SPECIFIER is machine-ABSOLUTE — pnpm ≤ 8 absolutizes `file:` overrides itself — surfaced as `vendor_pnpm_legacy_absolute_specifier`. Legacy WORKSPACE locks (`importers:`) refused | same-path `pnpm install --frozen-lockfile --offline`, cold store (byte-stable on pnpm 7.33.5 / 8.15.9). A checkout at a DIFFERENT path fails the frozen check (path-bound specifier) and must run `pnpm install --offline --no-frozen-lockfile` once (the flag matters on CI, where pnpm defaults frozen on), which installs the vendored tarball and re-resolves only the specifier line | -| npm / bun (`bun.lock`, lockfileVersion 0, 1 or 2 — `vendor_lockfile_version_unsupported` otherwise) | (same tarball) | `bun.lock` only: the packages entry's registry 4-tuple → local 3-tuple with recomputed `sha512`; the entry's `{deps}` meta, the lock's version line and its line endings are preserved. A lock holding `workspace:` packages is refused `vendor_bun_workspace_unsupported` unless lockfileVersion is 2 — Bun 1.2–1.3 resolve a workspace member's local-tarball path relative to the MEMBER (ENOENT on our root-relative path), 1.4 relative to the lockfile, and a committed version-2 lock is the only proof every consumer runs Bun ≥ 1.4 (a deliberate over-approximation: a package declared only by the workspace root would install on version 1 too). The gate fires only on a run that would WRITE a new local tuple, so in-sync re-runs, `already_vendored` skips and `repair` rebuilds on such a lock pass. The detail names the version and the remedy: delete `bun.lock` and re-lock with Bun ≥ 1.4 (an in-place `bun install` keeps the existing lockfileVersion), or `--mode hosted`. `bun.lockb` (binary) refused `vendor_bun_lockb_unsupported` with the `bun install --save-text-lockfile` (Bun ≥ 1.1.39) pointer. `scan`/`get --mode vendored` apply all four refusals BEFORE downloading (see the `get --mode vendored` bullet) | `bun install --frozen-lockfile`, cold cache (the local tarball's sha512 is enforced by Bun ≥ 1.3.10; 1.1.39–1.3.9 install it unverified — the committed artifact is the protection there) | +| npm / bun (`bun.lock`, lockfileVersion 0, 1 or 2 — `vendor_lockfile_version_unsupported` otherwise) | (same tarball) | `bun.lock` only: the packages entry's registry 4-tuple → local 3-tuple with recomputed `sha512`; the entry's `{deps}` meta, the lock's version line and its line endings are preserved. A lock holding `workspace:` packages is refused `vendor_bun_workspace_unsupported` unless lockfileVersion is 2 — Bun 1.2–1.3 resolve a workspace member's local-tarball path relative to the MEMBER (ENOENT on our root-relative path), 1.4 relative to the lockfile, and a committed version-2 lock is the only proof every consumer runs Bun ≥ 1.4 (a deliberate over-approximation: a package declared only by the workspace root would install on version 1 too). The gate fires only on a run that would WRITE a new local tuple, so in-sync re-runs, `already_vendored` skips and `repair` rebuilds on such a lock pass. The detail names the version and the remedy: delete `bun.lock` and re-lock with Bun ≥ 1.4 (an in-place `bun install` keeps the existing lockfileVersion), or `--mode hosted`. `bun.lockb` (binary) refused `vendor_bun_lockb_unsupported` with the `bun install --save-text-lockfile` (Bun ≥ 1.1.39) pointer. `scan`/`get --mode vendored` apply all four refusals BEFORE downloading (see the `get --mode vendored` bullet). Bun 1.1.39–1.3.9 re-save the local tuple WITHOUT its `sha512` on any later lock re-save (`bun add`, `bun install` after a manifest change); the digest-less 2-tuple is recognised as the same wiring — an in-sync re-run stays `already_vendored` and re-pins the digest on disk (no new wiring record) when the committed artifact still holds the bytes the lock was written from — otherwise, as for any stale tuple of ours, the line is re-pinned and the fresh entry carries the new fingerprint — `repair` rebuilds through it, and `vendor --revert` / `rollback` restore the registry line over it (a 2-tuple at ANOTHER uuid is still `vendor_lock_entry_drifted`) | `bun install --frozen-lockfile`, cold cache (the local tarball's sha512 is enforced by Bun ≥ 1.3.10; 1.1.39–1.3.9 install it unverified — the committed artifact is the protection there) | | cargo | crate dir `-/` (no `.cargo-checksum.json`) | `.cargo/config.toml` `[patch.crates-io]` path entry **+** Cargo.lock surgery (the `[[package]]` entry's `source`/`checksum` removed) | `cargo build --locked --offline` on a fresh checkout. Requires cargo ≥ 1.56 (`[patch]` in config files). Note: path deps build **without** `--cap-lints allow` | | golang | module dir `@/` | `go.mod` `replace => ./.socket/vendor/golang//@` | `go build` with `GOPROXY=off` + empty `GOMODCACHE` (directory replaces bypass go.sum entirely; survives `go mod tidy`) | | composer | package dir `/@/` | `composer.lock` only: entry's `dist` → `{type: "path", url, reference: null}`, `source` removed, `transport-options: {symlink: false}` added. `content-hash` unaffected; `composer.json` untouched | `composer install` (from the lock alone, real copy not symlink, works under `--network none`). `composer update ` reverts it | @@ -1101,7 +1101,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `redirect_bun_workspace_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): a lockfileVersion-0 lock (Bun 1.1.39–1.1.45 `--save-text-lockfile`) holds `workspace:` packages; frozen installs of that grammar cannot keep the hosted tuple. Remedy (verified): a plain `bun install` with any Bun ≥ 1.2 rewrites it in place as version 1, which is accepted, or delete `bun.lock` and re-lock. Version-1/2 workspace locks are rewritten. Exit 0. | | `redirect_bun_lockb_would_migrate` / `redirect_bun_lockb_manual_migration` / `redirect_bun_lockb_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun; `bun.lockb` with no `bun.lock` and an npm override granted): `--dry-run` would run the migration, nothing spawned / the resolved `bun` exited 0 but wrote no text lock — Bun 1.1.39–1.1.42 under `--frozen-lockfile`, or Bun ≤ 1.1.38 with no text lockfile at all — run `bun install --save-text-lockfile` (Bun ≥ 1.1.39) yourself and re-run / `bun` is missing on an absolute `PATH` entry, could not be spawned, exited non-zero (detail carries bun's output tail) or the surviving `bun.lockb` could not be removed (the text lock is dropped again). `bun.lockb` is never parsed; exit 0. | | `redirect_bun_lockb_migration_reverted` / `redirect_bun_lockb_migrated_without_redirect` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): the migration succeeded but no redirect landed in the new `bun.lock` (the rewriter's own warning says why) — the pre-migration `bun.lockb` was restored, the text lock removed and no ledger record kept / the restore itself failed, so the migration and its `removed` ledger record stand (git history is the restore path). Exit 0. | -| `redirect_bun_entry_not_found` / `redirect_bun_missing_sha512` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): the lock has no rewritable entry at the granted version (re-resolved, or occupied by an unowned URL/file spec) / the grant carries no sha512 integrity. Per-dep; nothing rewritten for it; exit 0. | +| `redirect_bun_entry_not_found` / `redirect_bun_missing_sha512` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): the lock has no rewritable entry at the granted version (re-resolved, or occupied by an unowned URL/file spec) / the grant carries no sha512 integrity. Per-dep; nothing rewritten for it; exit 0. NOT emitted for the digest-less 2-tuple Bun 1.1.39–1.3.9 re-save our URL tuple as — that entry counts as redirected and is healed. | | `vendor_prebuilt_stub_invalid` | `failed` / `skipped` (warning) | vendor (gem, `--vendor-source`): the served stub gemspec fails the rubygems `summary`/`authors` bar, so bundler would refuse the vendored path source at install time. `service`: refusal naming the missing attributes; `auto`: loud warning + local-build fallback — or, when the gem is also not installed locally (no stub to derive), a refusal naming the served defect and the install-the-gem remedy. | | `gem_spec_invalid` | `failed` | vendor (gem): the LOCAL `specifications/` stub gemspec fails the same rubygems `summary`/`authors` bar (a corrupted or hand-edited gem home); the refusal names the file — reinstall the gem (`gem pristine ` / fresh `bundle install`). | | `vendor_*` / `pypi_*` / `gemfile_*` / `lock_*` / `locked_version_mismatch` / `user_authored_*` / `native_extensions_unsupported` / `platform_gem_unsupported` | `failed`/`skipped` | vendor: per-ecosystem refusal + drift vocabulary; see the Vendor command contract section. New tags are additive (MINOR). | 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 b43ff85c..ab28be29 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs @@ -1377,3 +1377,264 @@ async fn bun_redirect_rollback_restores_lock_and_original_install() { "after rollback the fresh install must be byte-identical to the pristine package" ); } + +// ── digest-dropping lock re-saves (Bun 1.1.39–1.3.9) ───────────────── + +/// A local `file:` tarball dep added to `proj`'s package.json plus this +/// bun's ordinary install: the one network-free way to make bun RE-SAVE an +/// existing lock (a root rename does not; `bun add` needs the registry). +/// Returns the tarball's file name, which every fresh checkout below must +/// carry along. +fn grow_project_with_local_dep(fx: &BunRedirectFixture, cache_tag: &str) -> String { + let tgz_name = "local-dep-1.0.0.tgz".to_string(); + let tgz = build_tgz(&[ + ( + "package.json".to_string(), + br#"{"name":"local-dep","version":"1.0.0"}"#.to_vec(), + 0o644, + ), + ( + "index.js".to_string(), + b"module.exports = 'local';\n".to_vec(), + 0o644, + ), + ]); + std::fs::write(fx.proj.join(&tgz_name), tgz).unwrap(); + let pkg_path = fx.proj.join("package.json"); + let mut pkg: serde_json::Value = + serde_json::from_slice(&std::fs::read(&pkg_path).unwrap()).unwrap(); + pkg["dependencies"]["local-dep"] = serde_json::json!(format!("file:./{tgz_name}")); + std::fs::write(&pkg_path, serde_json::to_vec_pretty(&pkg).unwrap()).unwrap(); + let cache = fx.tmp.path().join(format!("{cache_tag}-bun-cache")); + let out = bun(&fx.proj, &fixture_install_args(fx.bun_version), &cache); + assert!( + out.status.success(), + "`bun install` after adding the local dep must succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + let lock = std::fs::read_to_string(fx.proj.join("bun.lock")).unwrap(); + assert!( + lock.contains("\"local-dep\": ["), + "the re-save must have landed the local dep's entry:\n{lock}" + ); + tgz_name +} + +/// `bun install --frozen-lockfile` in a fresh checkout that also carries the +/// grown project's local tarball; returns the installed `index.js` bytes. +fn fresh_frozen_install_with_local_dep( + fx: &BunRedirectFixture, + name: &str, + tgz_name: &str, +) -> Vec { + let fresh = fresh_checkout(fx, name); + std::fs::copy(fx.proj.join(tgz_name), fresh.join(tgz_name)).unwrap(); + let cache = fx.tmp.path().join(format!("{name}-bun-cache")); + let ci = bun( + &fresh, + &["install", "--frozen-lockfile", "--ignore-scripts"], + &cache, + ); + assert!( + ci.status.success(), + "fresh-checkout `bun install --frozen-lockfile` ({name}) must succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr) + ); + std::fs::read(fx.target.installed_dir(&fresh).join("index.js")).unwrap() +} + +/// Every text-lock bun below 1.3.10 re-saves our URL 3-tuple WITHOUT its +/// sha512 whenever the lock is re-saved for another reason (measured on +/// 1.1.45, 1.2.23 and 1.3.9; 1.3.10+ keep it). The digest-less 2-tuple is +/// still our wiring — the spec bun installs from is intact — so after a +/// real re-save: `rollback --dry-run` must resolve, the repeat hosted run +/// must report `redirected: 1` with no `redirect_bun_entry_not_found` and +/// heal the line back to the 3-tuple (a second ledger edit), a fresh +/// frozen install must land the patched bytes, and `rollback` must put the +/// registry line back inside the GROWN lock and install the original bytes. +/// On ≥ 1.3.10 the same steps prove the no-regression twin: digest kept, +/// repeat run a no-op, one ledger edit. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn bun_redirect_survives_a_digest_dropping_lock_resave() { + let Some(fx) = bun_hosted_project( + "digestless-resave", + false, + HostedDriver::ScanVex, + LockShape::Native, + Target::LeftPad, + ) + .await + else { + return; + }; + let proj = &fx.proj; + let lock_path = proj.join("bun.lock"); + let wired_line = packages_line(&std::fs::read_to_string(&lock_path).unwrap(), DEP); + assert!(wired_line.contains("\"sha512-"), "{wired_line}"); + let expect_drop = fx.bun_version < TARBALL_INTEGRITY_ENFORCED_FROM; + + // 1. Grow the project so bun re-saves the lock. + let tgz_name = grow_project_with_local_dep(&fx, "resave"); + let resaved = std::fs::read_to_string(&lock_path).unwrap(); + let live_line = packages_line(&resaved, DEP); + let digestless_spelling = format!( + "{}],", + &wired_line[..wired_line.rfind(", \"sha512-").unwrap()] + ); + if expect_drop { + assert_eq!( + live_line, digestless_spelling, + "bun {} (< 1.3.10) must re-save the URL tuple WITHOUT its sha512:\n{resaved}", + fx.bun_raw + ); + } else { + assert_eq!( + live_line, wired_line, + "bun {} (>= 1.3.10) must keep the URL tuple's sha512 on re-save:\n{resaved}", + fx.bun_raw + ); + } + eprintln!( + "RESAVE OK (bun {}, digest {})", + fx.bun_raw, + if expect_drop { "dropped" } else { "kept" } + ); + + // 2. The unwind must already resolve over the re-saved lock (dry run). + let (code, stdout, stderr) = run_socket( + proj, + &[ + "rollback", + "--dry-run", + "--yes", + "--json", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "rollback --dry-run over the re-saved lock must resolve.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert_eq!( + std::fs::read_to_string(&lock_path).unwrap(), + resaved, + "a dry run writes nothing" + ); + + // 3. Repeat hosted run: consistent envelope, digest healed (or a no-op). + let server_uri = fx._server.uri(); + let (code, stdout, stderr) = run_socket( + proj, + &[ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--cwd", + proj.to_str().unwrap(), + "--api-url", + &server_uri, + "--org", + ORG, + "--api-token", + "fake", + ], + ); + assert_eq!( + code, 0, + "repeat scan --mode hosted failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("repeat scan output is not JSON: {e}\nstdout:\n{stdout}")); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + let codes: Vec<&str> = env["redirect"]["warnings"] + .as_array() + .into_iter() + .flatten() + .filter_map(|w| w["code"].as_str()) + .collect(); + assert!( + !codes.contains(&"redirect_bun_entry_not_found"), + "the digest-less spelling of our own wiring is not `entry_not_found`: {env:#}" + ); + let healed = std::fs::read_to_string(&lock_path).unwrap(); + assert_eq!( + packages_line(&healed, DEP), + wired_line, + "the repeat run must leave the canonical URL 3-tuple in place:\n{healed}" + ); + assert!( + healed.contains("\"local-dep\": ["), + "the grown entry survives" + ); + let ledger: serde_json::Value = + serde_json::from_slice(&std::fs::read(redirect_ledger(proj)).unwrap()).unwrap(); + let edits = ledger["edits"].as_array().unwrap(); + assert_eq!( + edits.len(), + if expect_drop { 2 } else { 1 }, + "the heal is recorded as a second edit exactly when the digest was dropped: {ledger:#}" + ); + if expect_drop { + assert_eq!( + edits[1]["original"], + serde_json::json!(digestless_spelling), + "{ledger:#}" + ); + assert_eq!(edits[1]["new"], serde_json::json!(wired_line), "{ledger:#}"); + } + eprintln!("REPEAT HOSTED RUN OK"); + + // 4. The healed lock installs the patched bytes from an empty cache. + let installed = fresh_frozen_install_with_local_dep(&fx, "fresh-healed", &tgz_name); + assert_eq!( + installed, fx.patched, + "the healed lock must install the patched bytes" + ); + + // 5. Rollback: registry line back inside the grown lock, ledger gone, + // original bytes on a fresh install. + let (code, stdout, stderr) = run_socket( + proj, + &[ + "rollback", + "--yes", + "--json", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "rollback failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(env["status"], "success", "{env:#}"); + let restored = std::fs::read_to_string(&lock_path).unwrap(); + let lock_before = String::from_utf8(fx.lock_before.clone()).unwrap(); + assert_eq!( + packages_line(&restored, DEP), + packages_line(&lock_before, DEP), + "the pristine registry 4-tuple must be back:\n{restored}" + ); + assert!( + restored.contains("\"local-dep\": ["), + "rollback must not disturb the grown entry:\n{restored}" + ); + assert!( + !redirect_ledger(proj).exists(), + "the emptied ledger is deleted" + ); + let installed = fresh_frozen_install_with_local_dep(&fx, "fresh-rolled-back", &tgz_name); + assert_eq!( + installed, fx.orig, + "after rollback bun installs the ORIGINAL bytes" + ); + eprintln!("ROLLBACK AFTER RESAVE OK (bun {})", fx.bun_raw); +} 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 f0d1ae69..d503db39 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs @@ -1317,3 +1317,226 @@ async fn bun_get_uuid_vendored_fresh_checkout_frozen_install() { // spike-proven `--frozen-lockfile`, then the ordinary-install twin. fresh_checkout_install_proof(&fx, "fresh"); } + +// ── digest-dropping lock re-saves (Bun 1.1.39–1.3.9) ───────────────── + +/// A local `file:` tarball dep (`local-dep-`) added to package.json plus +/// this bun's ordinary install: the one network-free way to make bun +/// RE-SAVE an existing lock. Returns the tarball file name, which every +/// fresh checkout below must carry along. +fn grow_project_with_local_dep(fx: &BunProject, n: u32) -> String { + let name = format!("local-dep-{n}"); + let tgz_name = format!("{name}-1.0.0.tgz"); + let tgz = build_tgz(&[ + ( + "package.json".to_string(), + format!(r#"{{"name":"{name}","version":"1.0.0"}}"#).into_bytes(), + 0o644, + ), + ( + "index.js".to_string(), + b"module.exports = 'local';\n".to_vec(), + 0o644, + ), + ]); + std::fs::write(fx.proj.join(&tgz_name), tgz).unwrap(); + let pkg_path = fx.proj.join("package.json"); + let mut pkg: serde_json::Value = + serde_json::from_slice(&std::fs::read(&pkg_path).unwrap()).unwrap(); + pkg["dependencies"][&name] = serde_json::json!(format!("file:./{tgz_name}")); + std::fs::write(&pkg_path, serde_json::to_vec_pretty(&pkg).unwrap()).unwrap(); + let cache = fx.tmp.path().join(format!("resave-{n}-bun-cache")); + let out = bun(&fx.proj, &fixture_install_args(fx.bun_version), &cache); + assert!( + out.status.success(), + "`bun install` after adding {name} must succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + let lock = std::fs::read_to_string(fx.proj.join("bun.lock")).unwrap(); + assert!( + lock.contains(&format!("\"{name}\": [")), + "the re-save must have landed {name}'s entry:\n{lock}" + ); + tgz_name +} + +/// The re-saved line must be the recorded 3-tuple (bun ≥ 1.3.10) or its +/// digest-less 2-tuple (below) — asserted per era, never guessed. +fn assert_resave_shape(fx: &BunProject, wired_line: &str) -> String { + let live = packages_line( + &std::fs::read_to_string(fx.proj.join("bun.lock")).unwrap(), + fx.target.name(), + ); + let digestless = format!( + "{}],", + &wired_line[..wired_line.rfind(", \"sha512-").unwrap()] + ); + if fx.bun_version < TARBALL_INTEGRITY_ENFORCED_FROM { + assert_eq!( + live, digestless, + "bun {} (< 1.3.10) must re-save the local tuple WITHOUT its sha512", + fx.bun_raw + ); + } else { + assert_eq!( + live, wired_line, + "bun {} (>= 1.3.10) must keep the local tuple's sha512 on re-save", + fx.bun_raw + ); + } + live +} + +/// `bun install --frozen-lockfile` in a fresh checkout carrying the grown +/// project's local tarballs; returns the installed target `index.js`. +fn fresh_frozen_install_with_local_deps(fx: &BunProject, name: &str, tgzs: &[String]) -> Vec { + let fresh = fresh_checkout(fx, name); + for tgz in tgzs { + std::fs::copy(fx.proj.join(tgz), fresh.join(tgz)).unwrap(); + } + let cache = fx.tmp.path().join(format!("{name}-bun-cache")); + let ci = bun( + &fresh, + &["install", "--frozen-lockfile", "--ignore-scripts"], + &cache, + ); + assert!( + ci.status.success(), + "fresh-checkout `bun install --frozen-lockfile` ({name}) must succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr) + ); + std::fs::read(fx.target.installed_dir(&fresh).join("index.js")).unwrap() +} + +/// Every text-lock bun below 1.3.10 re-saves our local-tarball 3-tuple +/// WITHOUT its sha512 whenever the lock is re-saved for another reason +/// (measured on 1.1.45, 1.2.23 and 1.3.9; 1.3.10+ keep it). The 2-tuple is +/// still our wiring, so after a real re-save: the `vendor` re-run must stay +/// a clean no-op that heals the digest, `repair` must rebuild a deleted +/// artifact through it and re-pin the digest, a fresh frozen install must +/// land the patched bytes, and — after bun drops the digest AGAIN — +/// `vendor --revert` must restore the registry line inside the grown lock. +/// On ≥ 1.3.10 the same steps are the no-regression twin (digest kept). +#[test] +#[serial_test::serial] +fn bun_vendor_survives_a_digest_dropping_lock_resave() { + let Some(fx) = bun_project("vendor-digestless-resave", Target::LeftPad, None) else { + return; + }; + let proj = &fx.proj; + let lock_path = proj.join("bun.lock"); + stage_and_vendor(&fx); + let wired_line = packages_line(&std::fs::read_to_string(&lock_path).unwrap(), DEP); + assert!(wired_line.contains("\"sha512-"), "{wired_line}"); + + // 1. Grow → re-save; assert the era's spelling. + let tgz_a = grow_project_with_local_dep(&fx, 1); + assert_resave_shape(&fx, &wired_line); + eprintln!("RESAVE OK (bun {})", fx.bun_raw); + + // 2. Re-run `vendor`: exit 0, nothing failed, in sync, digest healed. + let (code, stdout, stderr) = run_vendor(&fx, &[]); + assert_eq!( + code, 0, + "re-vendor over the re-saved lock failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env = parse_envelope(&stdout); + assert_eq!(env["status"], "success", "{env}"); + assert_eq!(env["summary"]["failed"], 0, "{env}"); + assert!( + env["events"] + .as_array() + .unwrap() + .iter() + .all(|e| e["errorCode"] != "vendor_lock_entry_not_found" && e["action"] != "failed"), + "{env}" + ); + let healed = std::fs::read_to_string(&lock_path).unwrap(); + assert_eq!( + packages_line(&healed, DEP), + wired_line, + "the re-run must leave the canonical local 3-tuple in place:\n{healed}" + ); + eprintln!("RE-VENDOR OK"); + + // 3. Repair through a digest-less line: drop the digest again, delete + // the artifact, rebuild. + let tgz_b = grow_project_with_local_dep(&fx, 2); + assert_resave_shape(&fx, &wired_line); + std::fs::remove_dir_all(vendored_dir(proj)).unwrap(); + let (code, stdout, stderr) = run_socket( + proj, + &[ + "repair", + "--json", + "--offline", + "--yes", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "repair failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let renv = parse_envelope(&stdout); + assert_eq!(renv["status"], "success", "{renv}"); + assert_eq!(renv["summary"]["rebuilt"], 1, "{renv}"); + assert!(vendored_tgz(&fx).is_file(), "the artifact must be rebuilt"); + let repaired = std::fs::read_to_string(&lock_path).unwrap(); + assert_eq!( + packages_line(&repaired, DEP), + wired_line, + "repair re-pins the digest into the healed 3-tuple:\n{repaired}" + ); + eprintln!("REPAIR THROUGH DIGEST-LESS LOCK OK"); + + // 4. The repaired lock installs the patched bytes from an empty cache. + let installed = fresh_frozen_install_with_local_deps( + &fx, + "fresh-repaired", + &[tgz_a.clone(), tgz_b.clone()], + ); + assert_eq!( + installed, fx.patched, + "the repaired lock must install the patched bytes" + ); + + // 5. Drop the digest once more, then revert straight over it. + let tgz_c = grow_project_with_local_dep(&fx, 3); + assert_resave_shape(&fx, &wired_line); + let (code, stdout, stderr) = run_vendor(&fx, &["--revert"]); + assert_eq!( + code, 0, + "revert over the re-saved lock failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let renv = parse_envelope(&stdout); + assert_eq!(renv["status"], "success", "{renv}"); + assert_eq!(renv["summary"]["removed"], 1, "{renv}"); + let restored = std::fs::read_to_string(&lock_path).unwrap(); + let lock_before = String::from_utf8(fx.lock_before.clone()).unwrap(); + assert_eq!( + packages_line(&restored, DEP), + packages_line(&lock_before, DEP), + "the pristine registry 4-tuple must be back:\n{restored}" + ); + for n in 1..=3 { + assert!( + restored.contains(&format!("\"local-dep-{n}\": [")), + "revert must not disturb the grown entries:\n{restored}" + ); + } + assert!( + !proj.join(".socket").join("vendor").exists(), + ".socket/vendor must be fully removed after revert" + ); + let installed = + fresh_frozen_install_with_local_deps(&fx, "fresh-reverted", &[tgz_a, tgz_b, tgz_c]); + assert_eq!( + installed, fx.orig, + "after revert bun installs the ORIGINAL bytes" + ); + eprintln!("REVERT AFTER RESAVE OK (bun {})", fx.bun_raw); +} diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 9ac19558..771c661e 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -967,6 +967,140 @@ async fn scan_redirect_refuses_bun_lock_v3() { ); } +/// The packages-entry line keyed `key` in a bun.lock (verbatim, no line +/// terminator). +fn bun_packages_line(lock: &str, key: &str) -> String { + let prefix = format!(" \"{key}\": ["); + lock.split('\n') + .find(|l| l.starts_with(&prefix)) + .unwrap_or_else(|| panic!("no `{key}` packages entry in:\n{lock}")) + .trim_end_matches('\r') + .to_string() +} + +/// Re-spell a URL 3-tuple line the way Bun 1.1.39–1.3.9 re-save it on any +/// later lock re-save (`bun add `, `bun install` after a package.json +/// change): the trailing `"sha512-…"` element dropped, everything else +/// verbatim (measured on real 1.1.45, 1.2.23 and 1.3.9; 1.3.10+ keep it). +fn drop_bun_digest(line: &str) -> String { + let cut = line + .rfind(", \"sha512-") + .unwrap_or_else(|| panic!("no sha512 element in {line}")); + let tail = if line.ends_with("],") { "]," } else { "]" }; + format!("{}{tail}", &line[..cut]) +} + +/// Bun 1.1.39–1.3.9 re-save our URL 3-tuple WITHOUT its sha512 whenever the +/// lock is re-saved for another reason. The digest-less 2-tuple is still +/// our wiring (the spec bun installs from is intact): a repeat `scan +/// --mode hosted` must report a CONSISTENT envelope — `redirected: 1` with +/// no `redirect_bun_entry_not_found` — heal the line back to the 3-tuple +/// and record the heal as a second ledger edit for the key (`original` = +/// the 2-tuple); a third run appends nothing; and `rollback` must unwind +/// the chain to the pristine registry line whether the lock is the healed +/// 3-tuple or Bun has since dropped the digest again. Before the fix the +/// repeat scan warned `entry_not_found` beside `redirected: 1` and +/// rollback refused `partial_failure` ("matches neither the redirected nor +/// the original fragment"), stranding every user on those releases. +#[tokio::test] +#[serial] +async fn scan_redirect_heals_digestless_bun_tuple_and_rollback_restores_the_registry_line() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + // The record fetch too, so the "no warnings at all" assertion below is + // exact (without it every run carries a `record_fetch_failed` advisory). + mock_view(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), 1); + let lock_path = tmp.path().join("bun.lock"); + let pristine = std::fs::read_to_string(&lock_path).unwrap(); + let ledger_path = tmp.path().join(".socket/vendor/redirect-state.json"); + + for drop_again_before_rollback in [false, true] { + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + let wired = std::fs::read_to_string(&lock_path).unwrap(); + let wired_line = bun_packages_line(&wired, NAME); + assert!( + wired_line.contains(HOSTED_URL) && wired_line.contains(PATCHED_SHA512), + "{wired_line}" + ); + let digestless = drop_bun_digest(&wired_line); + assert!( + digestless.ends_with("{}],") && !digestless.contains("sha512"), + "{digestless}" + ); + std::fs::write(&lock_path, wired.replace(&wired_line, &digestless)).unwrap(); + + // Repeat hosted run over the digest-less lock. + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!( + env["redirect"]["redirected"], 1, + "the wired dep still counts as redirected: {env:#}" + ); + let codes = warning_codes(&env); + assert!( + !codes.contains(&"redirect_bun_entry_not_found".to_string()), + "a digest-less instance of our own wiring is not `entry_not_found`: {env:#}" + ); + assert_eq!( + std::fs::read_to_string(&lock_path).unwrap(), + wired, + "the digest is healed back — lock byte-identical to the first run's" + ); + let ledger = read_ledger(tmp.path()); + let edits = ledger["edits"].as_array().unwrap(); + assert_eq!(edits.len(), 2, "first edit + the heal: {ledger:#}"); + assert_eq!( + edits[0]["original"], + serde_json::json!(bun_packages_line(&pristine, NAME)), + "{ledger:#}" + ); + assert_eq!(edits[1]["key"], NAME, "{ledger:#}"); + assert_eq!( + edits[1]["original"], + serde_json::json!(digestless), + "{ledger:#}" + ); + assert_eq!(edits[1]["new"], serde_json::json!(wired_line), "{ledger:#}"); + + // A third run over the healed lock is a no-op for the ledger. + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert!(warning_codes(&env).is_empty(), "{env:#}"); + assert_eq!( + read_ledger(tmp.path())["edits"].as_array().unwrap().len(), + 2, + "a re-run over the healed lock must not append edits" + ); + + if drop_again_before_rollback { + // Another `bun add` on Bun < 1.3.10: the digest is gone again. + std::fs::write(&lock_path, wired.replace(&wired_line, &digestless)).unwrap(); + } + let (code, env) = rollback_json(tmp.path()); + assert_eq!( + code, + Some(0), + "rollback (digest dropped again: {drop_again_before_rollback}) must succeed: {env:#}" + ); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!( + std::fs::read_to_string(&lock_path).unwrap(), + pristine, + "rollback lands on the pristine registry line (digest dropped again: \ + {drop_again_before_rollback})" + ); + assert!( + !ledger_path.exists(), + "the emptied ledger is deleted after a full unwind" + ); + } +} + /// The bun.lockb auto-migration leg: a fake `bun` shim prepended to PATH writes /// a canned text bun.lock and deletes bun.lockb (the bun ≥ 1.2 shape), /// exercising the migration branch of `run_redirect` without a real bun. The diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun.rs index 5175a619..5918f53a 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_bun.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun.rs @@ -1093,6 +1093,158 @@ async fn already_vendored_v1_workspace_rerun_is_already_vendored_exit_zero() { assert_eq!(lock_bytes(tmp.path()), lock_before); } +// --------------------------------------------------------------------------- +// Digest-less re-saves (Bun 1.1.39–1.3.9) +// --------------------------------------------------------------------------- +// Every text-lock release below 1.3.10 re-saves our local-tarball 3-tuple +// WITHOUT its sha512 on any later lock re-save (`bun add`, `bun install` +// after a manifest change) — measured on real 1.1.45, 1.2.23 and 1.3.9. The +// 2-tuple `["left-pad@.socket/vendor/npm//left-pad-1.3.0.tgz", {}]` +// is still our wiring: the re-run must stay `already_vendored` (and heal +// the digest), `repair` must rebuild through it, and `rollback` must +// restore the registry line — not `vendor_lock_entry_not_found` / +// `vendor_lock_entry_drifted` + `vendor_artifact_kept`. + +/// The packages-entry line keyed `key` (verbatim, no line terminator). +fn bun_packages_line(lock: &str, key: &str) -> String { + let prefix = format!(" \"{key}\": ["); + lock.split('\n') + .find(|l| l.starts_with(&prefix)) + .unwrap_or_else(|| panic!("no `{key}` packages entry in:\n{lock}")) + .trim_end_matches('\r') + .to_string() +} + +/// The line as Bun < 1.3.10 re-saves it: trailing `"sha512-…"` dropped. +fn drop_bun_digest(line: &str) -> String { + let cut = line + .rfind(", \"sha512-") + .unwrap_or_else(|| panic!("no sha512 element in {line}")); + let tail = if line.ends_with("],") { "]," } else { "]" }; + format!("{}{tail}", &line[..cut]) +} + +/// Vendor the V1 direct project, then re-spell its wired line digest-less. +/// Returns (pristine lock, wired lock, wired line). +fn vendor_then_drop_digest(root: &Path, mock_uri: &str) -> (Vec, String, String) { + write_bun_project(root, LockShape::V1Direct); + let pristine = lock_bytes(root); + let (exit, stdout, stderr) = scan_vendored(root, mock_uri, &["--json"]); + assert_eq!(exit, 0, "setup vendoring: stdout={stdout}\nstderr={stderr}"); + let wired = String::from_utf8(lock_bytes(root)).unwrap(); + let wired_line = bun_packages_line(&wired, "left-pad"); + assert!( + wired_line.contains(&format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz")) + && wired_line.contains("\"sha512-"), + "setup: {wired_line}" + ); + let digestless = drop_bun_digest(&wired_line); + std::fs::write( + root.join("bun.lock"), + wired.replace(&wired_line, &digestless), + ) + .unwrap(); + (pristine, wired, wired_line) +} + +#[tokio::test] +async fn digestless_vendored_tuple_rerun_is_already_vendored_and_heals_the_digest() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + let (_, wired, _) = vendor_then_drop_digest(tmp.path(), &mock.uri()); + + let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--json"]); + assert_eq!(exit, 0, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "success", "{v}"); + assert_eq!( + v["download"]["patches"][0]["action"], "skipped", + "the ledger still wires the purl: {v}" + ); + assert_eq!(v["download"]["failed"], 0, "{v}"); + let vendor = &v["vendor"]; + assert_eq!(vendor["summary"]["applied"], 0, "{v}"); + assert_eq!(vendor["summary"]["skipped"], 1, "{v}"); + assert_eq!(vendor["summary"]["failed"], 0, "{v}"); + let events = vendor["events"].as_array().unwrap(); + assert!( + events.iter().any(|e| e["purl"] == PURL + && e["action"] == "skipped" + && e["errorCode"] == "already_vendored"), + "{v}" + ); + assert!( + events + .iter() + .all(|e| e["errorCode"] != "vendor_lock_entry_not_found" && e["action"] != "failed"), + "{v}" + ); + assert_eq!( + String::from_utf8(lock_bytes(tmp.path())).unwrap(), + wired, + "the in-sync re-run heals the digest back to the 3-tuple, byte-identical" + ); +} + +#[tokio::test] +async fn digestless_vendored_tuple_rollback_restores_the_registry_line() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + let (pristine, _, _) = vendor_then_drop_digest(tmp.path(), &mock.uri()); + + let (exit, stdout, stderr) = run(tmp.path(), &with_api(&["rollback", "--json"], &mock.uri())); + assert_eq!(exit, 0, "rollback: stdout={stdout}\nstderr={stderr}"); + assert!( + !stdout.contains("vendor_lock_entry_drifted") && !stdout.contains("vendor_artifact_kept"), + "the digest-less spelling of our own tuple is not drift: {stdout}" + ); + assert_eq!( + lock_bytes(tmp.path()), + pristine, + "rollback must restore the registry lock byte-for-byte" + ); + assert!( + !tmp.path().join(".socket/vendor/npm").exists(), + "rollback removes the vendored artifact tree" + ); +} + +#[tokio::test] +async fn repair_rebuilds_a_deleted_artifact_through_a_digestless_lock() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + let (_, wired, _) = vendor_then_drop_digest(tmp.path(), &mock.uri()); + let tgz = tmp + .path() + .join(format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz")); + std::fs::remove_file(&tgz).unwrap(); + + // `scan --mode vendored` keeps no local blob in this harness, so the + // rebuild fetches the patch content from the mock API (no `--offline`). + let (exit, stdout, stderr) = run(tmp.path(), &with_api(&["repair", "--json"], &mock.uri())); + assert_eq!(exit, 0, "repair: stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "success", "{v}"); + assert_eq!(v["summary"]["rebuilt"], 1, "{v}"); + assert!( + v["events"] + .as_array() + .unwrap() + .iter() + .any(|e| e["action"] == "rebuilt" && e["purl"] == PURL), + "{v}" + ); + assert!(tgz.is_file(), "the artifact must be rebuilt"); + assert_eq!( + String::from_utf8(lock_bytes(tmp.path())).unwrap(), + wired, + "the rebuild re-pins the digest into the healed 3-tuple" + ); +} + // --------------------------------------------------------------------------- // Workspace-member --cwd: today's behaviour, pinned // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs index 3295b021..229aa5f3 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs @@ -478,6 +478,216 @@ async fn bun_hosted_then_scan_vendored_takeover_round_trips_to_registry() { ); } +// ───────────────────────────────────────────────────────────────────── +// 1b. Digest-less re-saves (Bun 1.1.39–1.3.9) across the conversions +// ───────────────────────────────────────────────────────────────────── +// Every text-lock release below 1.3.10 re-saves a URL or local-tarball +// 3-tuple WITHOUT its sha512 on any later lock re-save (`bun add`, `bun +// install` after a manifest change; measured on real 1.1.45, 1.2.23 and +// 1.3.9). The 2-tuple is still the recorded wiring (same key, spec and +// meta): the per-purl claim must not refuse it as drift, or the +// hosted→vendored takeover, `rollback ` and `remove ` all fail +// `redirect_revert_failed`; the vendored revert must claim it by path, or +// the vendored→hosted takeover fails `redirect_vendored_revert_failed`. + +/// The line as Bun < 1.3.10 re-saves it: trailing `"sha512-…"` dropped. +fn drop_bun_digest(line: &str) -> String { + let cut = line + .rfind(", \"sha512-") + .unwrap_or_else(|| panic!("no sha512 element in {line}")); + let tail = if line.ends_with("],") { "]," } else { "]" }; + format!("{}{tail}", &line[..cut]) +} + +/// Re-spell the `key` packages line of the live bun.lock digest-less. +fn drop_digest_in_lock(root: &Path, key: &str) -> String { + let lock = read(root, "bun.lock"); + let line = lock_line(&lock, key); + let digestless = drop_bun_digest(&line); + assert!( + !digestless.contains("sha512") && digestless.ends_with("],"), + "{digestless}" + ); + std::fs::write(root.join("bun.lock"), lock.replace(&line, &digestless)).unwrap(); + digestless +} + +fn redirect_warning_codes(env: &Value) -> Vec { + env["redirect"]["warnings"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|w| w["code"].as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +#[tokio::test(flavor = "multi_thread")] +async fn bun_digestless_hosted_line_is_taken_over_by_scan_vendored_and_reverts_to_registry() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_bun_project(root, &pristine_lock(), &[(NAME, VERSION)]); + let pristine = std::fs::read(root.join("bun.lock")).unwrap(); + + let (code, env) = scan_mode(root, &server.uri(), "hosted", &[]); + assert_eq!(code, 0, "{env:#}"); + assert_eq!( + lock_line(&read(root, "bun.lock"), NAME), + hosted_line(NAME, NAME, HOSTED_URL, PATCHED_SHA512) + ); + let digestless = drop_digest_in_lock(root, NAME); + assert!(digestless.contains(HOSTED_URL), "{digestless}"); + + // The takeover over the digest-less hosted line: used to refuse + // `redirect_revert_failed` ("has drifted from the recorded hosted + // redirect") and vendor nothing. + let (code, env) = scan_mode(root, &server.uri(), "vendored", &[]); + assert_eq!(code, 0, "takeover over a digest-less hosted line: {env:#}"); + assert_eq!(env["status"], "success", "{env:#}"); + let vendor = &env["vendor"]; + assert_eq!(vendor["summary"]["applied"], 1, "{env:#}"); + assert_eq!(vendor["summary"]["failed"], 0, "{env:#}"); + find_event(vendor, "skipped", Some("vendor_takeover_reverted_redirect")); + find_event(vendor, "applied", None); + assert_no_event_code(vendor, "redirect_revert_failed"); + assert_pure_vendored(root); + + // And the vendored wiring, re-saved digest-less again, still reverts + // to the pristine registry lock. + drop_digest_in_lock(root, NAME); + let (code, env) = vendor_cli(root, &["--revert"]); + assert_eq!(code, 0, "revert over a digest-less vendored line: {env:#}"); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!( + std::fs::read(root.join("bun.lock")).unwrap(), + pristine, + "bun.lock restored byte-identical to the pristine registry lock; got:\n{}", + read(root, "bun.lock") + ); + assert!( + !root.join(".socket/vendor").exists(), + ".socket/vendor pruned" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn bun_digestless_vendored_line_is_taken_over_by_scan_hosted_and_rolls_back_to_registry() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_bun_project(root, &pristine_lock(), &[(NAME, VERSION)]); + let pristine = std::fs::read(root.join("bun.lock")).unwrap(); + + let (code, env) = scan_mode(root, &server.uri(), "vendored", &[]); + assert_eq!(code, 0, "{env:#}"); + assert_eq!(env["vendor"]["summary"]["applied"], 1, "{env:#}"); + let digestless = drop_digest_in_lock(root, NAME); + assert!( + digestless.contains(&vendored_rel_tgz()), + "the vendored spec survives the re-save: {digestless}" + ); + + // vendored → hosted over the digest-less local tuple: the vendored + // revert claims the line by its `.socket/vendor/npm//` path. + let (code, env) = scan_mode(root, &server.uri(), "hosted", &[]); + assert_eq!( + code, 0, + "takeover over a digest-less vendored line: {env:#}" + ); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + let codes = redirect_warning_codes(&env); + assert!( + codes + .iter() + .any(|c| c == "redirect_takeover_reverted_vendored"), + "the takeover must be announced: {codes:?}\n{env:#}" + ); + assert!( + !codes.iter().any(|c| c == "redirect_vendored_revert_failed"), + "the vendored revert must not be refused: {env:#}" + ); + let lock = read(root, "bun.lock"); + assert_eq!( + lock_line(&lock, NAME), + hosted_line(NAME, NAME, HOSTED_URL, PATCHED_SHA512), + "hosted URL 3-tuple written:\n{lock}" + ); + assert!( + !lock.contains(".socket/vendor/npm/"), + "the local tuple must be gone:\n{lock}" + ); + assert!( + !root.join(vendored_rel_tgz()).exists(), + "the vendored artifact must be removed by the takeover" + ); + let ledger: Value = + serde_json::from_str(&read(root, ".socket/vendor/redirect-state.json")).unwrap(); + assert_eq!( + ledger["edits"][0]["original"], + json!(LEFT_PAD_REGISTRY_LINE), + "the hosted ledger records the PRISTINE registry line as its original: {ledger:#}" + ); + + // Unscoped rollback of the hosted wiring lands on the pristine lock. + let (code, env) = run_json( + root, + &[ + "rollback", + "--yes", + "--json", + "--cwd", + root.to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "{env:#}"); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!( + std::fs::read(root.join("bun.lock")).unwrap(), + pristine, + "pristine lock restored; got:\n{}", + read(root, "bun.lock") + ); +} + +#[test] +fn bun_scoped_rollback_and_remove_of_a_digestless_hosted_record_unwind_only_that_purl() { + for verb in ["rollback", "remove"] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let pristine = write_two_record_hosted_project(root); + let digestless = drop_digest_in_lock(root, NAME); + assert!(digestless.contains(HOSTED_URL), "{digestless}"); + + let (code, env) = run_json( + root, + &[ + verb, + PURL, + "--yes", + "--json", + "--cwd", + root.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "scoped {verb} over a digest-less hosted line must succeed: {env:#}" + ); + if verb == "rollback" { + assert_eq!(env["hosted"]["reverted"], json!([PURL]), "{env:#}"); + assert_eq!(env["hosted"]["failed"], json!([]), "{env:#}"); + } else { + assert!(env["error"].is_null(), "{env:#}"); + } + assert_only_left_pad_unwound(root, &pristine); + } +} + // ───────────────────────────────────────────────────────────────────── // 2. vendor --dry-run over the live hosted redirect, then the wet vendor // ───────────────────────────────────────────────────────────────────── diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 04b4d2c9..480a2f82 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -242,7 +242,10 @@ pub fn pipenv_lock_targets(files: &BTreeMap, overrides: &[DepOve /// Whether a live Pipfile.lock entry is the one Socket wrote, re-serialized by /// a Pipenv relock (same `file`/`path` reference; only `hashes`/`version`/ /// `index` may differ). Shared with the vendored backend's revert. -pub fn pipenv_reserialized_around_reference(live: &serde_json::Value, ours: &serde_json::Value) -> bool { +pub fn pipenv_reserialized_around_reference( + live: &serde_json::Value, + ours: &serde_json::Value, +) -> bool { pipenv::reserialized_around_reference(live, ours) } @@ -2435,7 +2438,9 @@ fn rewrite_bun_lock( // lock in place as lockfileVersion 1 — the root workspace dep spelling // changes from a bare path to `workspace:*`, which forces the save — // while 1.1.45 keeps it at 0. (A v0 lock WITHOUT workspaces is kept at - // 0 by 1.2.x and only bumped by ≥ 1.3.14; that case is accepted here.) + // 0 by an in-place install on 1.2.0, 1.2.23 and 1.3.0 and bumped to 1 + // by 1.3.9 and every later release — the first bumping release lies in + // (1.3.0, 1.3.9]; that case is accepted here either way.) if lock_version(content) == Some(0) && has_workspace_packages(&entries) { result.warnings.push(RewriteWarning { code: "redirect_bun_workspace_unsupported".into(), @@ -2474,24 +2479,35 @@ fn rewrite_bun_lock( { // Registry 4-tuple → URL 3-tuple. Deps object preserved verbatim. deps_verbatim = entry.elems[2].clone(); - } else if entry.elems.len() == 3 && spec == url_spec { - // Already one of our URL 3-tuples for this exact URL. Idempotent - // if the integrity already matches; otherwise refresh it. + } else if matches!(entry.elems.len(), 2 | 3) && spec == url_spec { + // Already one of our URL tuples for this exact URL. A 3-tuple + // is idempotent if the integrity already matches and is + // refreshed otherwise. A 2-tuple is our wiring with its digest + // DROPPED: Bun 1.1.39–1.3.9 re-save a URL tuple without its + // `"sha512-…"` on any lock re-save (`bun add`, `bun install` + // after a manifest change) — the spec bun installs from is + // intact, so the patch still lands, but ≥ 1.3.10 consumers of + // the same lock lose digest verification. HEAL it back to the + // canonical 3-tuple; the edit below records the 2-tuple as its + // `original`, and replay accepts that spelling of a recorded + // `new` (`bun_lock_text::same_wiring_modulo_integrity`), so + // the chain still unwinds to the pristine registry line. matched_any = true; - if entry.elems[2] == format!("\"{sha512}\"") { + if entry.elems.len() == 3 && entry.elems[2] == format!("\"{sha512}\"") { continue; } deps_verbatim = entry.elems[1].clone(); - } else if entry.elems.len() == 3 + } else if matches!(entry.elems.len(), 2 | 3) && entry.elems[1].starts_with('{') && is_prior_hosted_bun_spec(&spec, &fname, &dep.artifact_url) { - // A URL 3-tuple written by an EARLIER redirect whose artifact + // A URL tuple written by an EARLIER redirect whose artifact // URL has since changed (a patch republish rotates the uuid // path segment; grant-token rotation changes the token — the // registry `name@version` spec was destroyed by that first // rewrite, so exact-URL matching alone would strand the stale - // pin forever). Re-pin to the current URL. Ownership is + // pin forever). Re-pin to the current URL — from the 3-tuple + // or from its digest-less 2-tuple re-save alike. Ownership is // claimed narrowly — same origin and same `- // .tgz` leaf as the CURRENT artifact URL — so user URL deps // and other-version entries never match (fail-closed). @@ -2790,7 +2806,9 @@ fn rewrite_uv_lock( continue; } Err(detail) => { - result.refused_python_lock_uuids.insert(dep.patch_uuid.clone()); + result + .refused_python_lock_uuids + .insert(dep.patch_uuid.clone()); result.warnings.push(RewriteWarning { code: "redirect_uv_lock_unsupported".into(), detail: format!("{path}: {detail}"), @@ -2802,7 +2820,9 @@ fn rewrite_uv_lock( match plan_python_metadata(path, &content, files, dep, result) { Ok(plan) => plan, Err(warning) => { - result.refused_python_lock_uuids.insert(dep.patch_uuid.clone()); + result + .refused_python_lock_uuids + .insert(dep.patch_uuid.clone()); result.warnings.push(warning); continue; } @@ -2817,7 +2837,9 @@ fn rewrite_uv_lock( ) { Ok(rewritten) => rewritten, Err(detail) => { - result.refused_python_lock_uuids.insert(dep.patch_uuid.clone()); + result + .refused_python_lock_uuids + .insert(dep.patch_uuid.clone()); result.warnings.push(RewriteWarning { code: "redirect_uv_metadata_unsupported".into(), detail: format!("{path}: {detail}"), @@ -2825,7 +2847,9 @@ fn rewrite_uv_lock( continue; } }; - result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone()); + result + .confirmed_python_lock_uuids + .insert(dep.patch_uuid.clone()); if let Some(edit) = metadata_edit { record_python_metadata_edit(edit, dep, result); } @@ -12363,6 +12387,155 @@ packages: assert!(r.warnings.is_empty(), "{:?}", r.warnings); } + /// Bun 1.1.39–1.3.9 re-save our URL 3-tuple WITHOUT its sha512 on any + /// later lock re-save (`bun add`, `bun install` after a manifest + /// change) — verified on real 1.2.23 and 1.3.9; 1.3.10+ keep it. A + /// 2-tuple at the CURRENT artifact URL is our wiring with its digest + /// dropped: heal it back to the 3-tuple (the edit records the 2-tuple + /// as `original`), never warn `redirect_bun_entry_not_found`, and stay + /// a no-op on the healed lock. + #[test] + fn bun_lock_digestless_current_url_tuple_is_healed() { + let sha = format!("sha512-{}==", "N".repeat(86)); + let url = "https://patch.socket.dev/patch/npm/tok-1111/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/left-pad-1.3.0.tgz"; + let ovr = npm_override("left-pad", "1.3.0", url, &sha); + let digestless = format!("\"left-pad\": [\"left-pad@{url}\", {{}}],"); + let healed = format!("\"left-pad\": [\"left-pad@{url}\", {{}}, \"{sha}\"],"); + + let mut files = BTreeMap::new(); + files.insert("bun.lock".to_string(), bun_lock_file(&digestless, 1)); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + let out = r + .files + .get("bun.lock") + .expect("the digest-less tuple must be healed"); + assert_eq!( + out, + &bun_lock_file(&healed, 1), + "healed back to the canonical 3-tuple, byte-exact" + ); + assert!( + r.warnings.is_empty(), + "a digest-less instance of our own wiring is not `entry_not_found`: {:?}", + r.warnings + ); + assert_eq!(r.edits.len(), 1, "{:?}", r.edits); + assert_eq!(r.edits[0].key.as_deref(), Some("left-pad")); + assert_eq!( + r.edits[0].original.as_ref().and_then(Value::as_str), + Some(format!(" {digestless}").as_str()), + "the heal records the 2-tuple it found as its original" + ); + assert_eq!( + r.edits[0].new.as_ref().and_then(Value::as_str), + Some(format!(" {healed}").as_str()) + ); + + // The healed lock is in sync: no files, no edits, no warnings. + let mut files = BTreeMap::new(); + files.insert("bun.lock".to_string(), out.clone()); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.files.is_empty() && r.edits.is_empty(), "{:?}", r.edits); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + + // CRLF lock: the healed line keeps its `\r`, and both ledger + // fragments carry it (replay matches bytes). + let mut files = BTreeMap::new(); + files.insert( + "bun.lock".to_string(), + bun_lock_file(&digestless, 1).replace('\n', "\r\n"), + ); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + let out = r.files.get("bun.lock").expect("CRLF heal"); + assert_eq!(out, &bun_lock_file(&healed, 1).replace('\n', "\r\n")); + assert_eq!( + r.edits[0].original.as_ref().and_then(Value::as_str), + Some(format!(" {digestless}\r").as_str()) + ); + assert_eq!( + r.edits[0].new.as_ref().and_then(Value::as_str), + Some(format!(" {healed}\r").as_str()) + ); + } + + /// The digest-less re-save of a STALE hosted URL (an earlier grant's + /// token/uuid) is re-pinned to the current URL exactly like its 3-tuple + /// form; ownership stays origin + `-.tgz` leaf, so a + /// foreign-origin or other-version 2-tuple is never claimed and a + /// 2-tuple carrying the bare registry spec (not a shape bun emits for a + /// registry package) is not rewritten either. + #[test] + fn bun_lock_digestless_stale_url_tuple_is_repinned_and_unowned_ones_are_not() { + let new_sha = format!("sha512-{}==", "N".repeat(86)); + let old_url = "https://patch.socket.dev/patch/npm/oldtoken-1111/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/left-pad-1.3.0.tgz"; + let new_url = "https://patch.socket.dev/patch/npm/newtoken-2222/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb/left-pad-1.3.0.tgz"; + let ovr = npm_override("left-pad", "1.3.0", new_url, &new_sha); + + let stale = format!("\"left-pad\": [\"left-pad@{old_url}\", {{}}],"); + let mut files = BTreeMap::new(); + files.insert("bun.lock".to_string(), bun_lock_file(&stale, 1)); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + let out = r + .files + .get("bun.lock") + .expect("stale digest-less URL must be re-pinned"); + assert_eq!( + out, + &bun_lock_file( + &format!("\"left-pad\": [\"left-pad@{new_url}\", {{}}, \"{new_sha}\"],"), + 1 + ) + ); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert_eq!(r.edits.len(), 1); + assert_eq!( + r.edits[0].original.as_ref().and_then(Value::as_str), + Some(format!(" {stale}").as_str()) + ); + + for unowned in [ + // Foreign origin, same leaf: a user's own URL dep. + "\"left-pad\": [\"left-pad@https://example.com/mirror/left-pad-1.3.0.tgz\", {}],", + // Our origin, another version's leaf. + "\"left-pad\": [\"left-pad@https://patch.socket.dev/patch/npm/oldtoken-1111/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/left-pad-1.2.0.tgz\", {}],", + // Registry spec in a 2-tuple: not bun's registry grammar. + "\"left-pad\": [\"left-pad@1.3.0\", {}],", + ] { + let mut files = BTreeMap::new(); + files.insert("bun.lock".to_string(), bun_lock_file(unowned, 1)); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + assert!( + r.files.is_empty() && r.edits.is_empty(), + "unowned 2-tuple must stay untouched: {unowned}" + ); + assert_eq!( + r.warnings.iter().map(|w| w.code.as_str()).collect::>(), + vec!["redirect_bun_entry_not_found"], + "{unowned}" + ); + } + + // A version-1 workspace lock's 2-tuple workspace entry (the v0 + // grammar hand-carried forward) beside the target: the registry + // tuple is redirected, the workspace 2-tuple is never touched. + let ws = " \"consumer\": [\"consumer@workspace:packages/consumer\", { \"dependencies\": { \"left-pad\": \"1.3.0\" } }],"; + let target = " \"left-pad\": [\"left-pad@1.3.0\", \"\", {}, \"sha512-OLD==\"],"; + let mut files = BTreeMap::new(); + files.insert("bun.lock".to_string(), bun_workspace_lock(1, &[ws, target])); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + let out = r.files.get("bun.lock").expect("target rewritten"); + assert!(out.contains(ws), "{out}"); + assert!(out.contains(&format!("\"left-pad@{new_url}\"")), "{out}"); + assert_eq!(r.edits.len(), 1); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + } + /// Fail-closed ownership legs of the URL-tuple takeover: an OTHER-name /// spec, a non-http `file:` spec, and a foreign-origin URL all survive /// byte-identically while the target registry tuple in the same lock is @@ -13681,4 +13854,3 @@ mod hatch_tests { assert!(second.files.is_empty()); } } - diff --git a/crates/socket-patch-core/src/patch/redirect/replay.rs b/crates/socket-patch-core/src/patch/redirect/replay.rs index 94d8344f..9a9c6b36 100644 --- a/crates/socket-patch-core/src/patch/redirect/replay.rs +++ b/crates/socket-patch-core/src/patch/redirect/replay.rs @@ -552,7 +552,10 @@ pub async fn revert_remaining_redirect_edits( group_drops.insert(idx); } _ => { - refuse(format!("{}: Hatch configuration drifted", edit.path), &mut outcome); + refuse( + format!("{}: Hatch configuration drifted", edit.path), + &mut outcome, + ); refused_groups.insert(group); continue 'group; } @@ -588,17 +591,43 @@ pub async fn revert_remaining_redirect_edits( // the edit as reverted. group_drops.insert(idx); } else { - refuse( - format!( - "{}: content matches neither the redirected nor the \ - original fragment for {} — the file drifted; re-run \ - `scan --mode hosted` to normalize", - edit.path, edit.kind - ), - &mut outcome, - ); - refused_groups.insert(group); - continue 'group; + // bun only: Bun 1.1.39–1.3.9 re-save our URL 3-tuple + // WITHOUT its sha512 on any later lock re-save, so + // the recorded `new` is on disk as a digest-less + // 2-tuple — same key, spec and meta. That spelling + // is the recorded wiring, not drift: put `original` + // back over it. Anything else still refuses. + let healed = if edit.kind == "redirect_bun_lock_package" { + crate::vendor::bun_lock_text::restore_digestless_line( + &content, new, original, + ) + } else { + Ok(None) + }; + match healed { + Ok(Some(restored)) => { + staged.insert(edit.path.clone(), Some(restored)); + group_drops.insert(idx); + } + Ok(None) => { + refuse( + format!( + "{}: content matches neither the redirected nor the \ + original fragment for {} — the file drifted; re-run \ + `scan --mode hosted` to normalize", + edit.path, edit.kind + ), + &mut outcome, + ); + refused_groups.insert(group); + continue 'group; + } + Err(ambiguous) => { + refuse(format!("{}: {ambiguous}", edit.path), &mut outcome); + refused_groups.insert(group); + continue 'group; + } + } } } Inverse::RemoveAddedFragment => { @@ -847,9 +876,9 @@ mod tests { let mut state = RedirectState::new(); state.edits = edits; for p in record_purls { - state - .records - .insert((*p).to_string(), crate::manifest::schema::PatchRecord { + state.records.insert( + (*p).to_string(), + crate::manifest::schema::PatchRecord { uuid: "u".into(), exported_at: "now".into(), files: Default::default(), @@ -878,20 +907,42 @@ mod tests { #[tokio::test] async fn hatch_documents_revert_after_checkout_newline_conversion() { let original = "[project]\ndependencies=[\"one==1\"]\n[tool.hatch.envs.default]\n"; - let files = [("pyproject.toml".to_owned(), original.to_owned())].into_iter().collect(); - let patched = crate::utils::hatch::rewrite(&files, "one", "1", "https://patch.test/one.whl").unwrap().remove("pyproject.toml").unwrap(); + let files = [("pyproject.toml".to_owned(), original.to_owned())] + .into_iter() + .collect(); + let patched = + crate::utils::hatch::rewrite(&files, "one", "1", "https://patch.test/one.whl") + .unwrap() + .remove("pyproject.toml") + .unwrap(); for drift in [false, true] { let dir = TempDir::new().unwrap(); - let live = if drift {patched.replace("one.whl", "changed.whl")} else {patched.replace('\n', "\r\n")}; + let live = if drift { + patched.replace("one.whl", "changed.whl") + } else { + patched.replace('\n', "\r\n") + }; write(dir.path(), "pyproject.toml", &live).await; - let mut state = state_with(vec![edit("pyproject.toml", "redirect_hatch_document", "rewritten", Some(original), Some(&patched))], &["pkg:pypi/one@1"]); + let mut state = state_with( + vec![edit( + "pyproject.toml", + "redirect_hatch_document", + "rewritten", + Some(original), + Some(&patched), + )], + &["pkg:pypi/one@1"], + ); let outcome = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; assert_eq!(outcome.fully_reverted(), !drift); if drift { assert_eq!(read(dir.path(), "pyproject.toml").await, live); assert_eq!(state.edits.len(), 1); } else { - assert_eq!(read(dir.path(), "pyproject.toml").await, original.replace('\n', "\r\n")); + assert_eq!( + read(dir.path(), "pyproject.toml").await, + original.replace('\n', "\r\n") + ); assert!(state.edits.is_empty()); } } @@ -920,7 +971,10 @@ mod tests { ); let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; assert!(out.fully_reverted(), "{:?}", out.refusals); - assert_eq!(read(dir.path(), "requirements.txt").await, "left-pad==1.3.0\n"); + assert_eq!( + read(dir.path(), "requirements.txt").await, + "left-pad==1.3.0\n" + ); assert!(state.edits.is_empty()); assert!(state.records.is_empty()); assert_eq!(out.dropped_records, vec!["pkg:pypi/left-pad@1.3.0"]); @@ -948,7 +1002,10 @@ mod tests { ); let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; assert!(out.fully_reverted(), "{:?}", out.refusals); - assert_eq!(read(dir.path(), "pom.xml").await, "2.17.1\n"); + assert_eq!( + read(dir.path(), "pom.xml").await, + "2.17.1\n" + ); } #[tokio::test] @@ -1077,6 +1134,182 @@ mod tests { assert_eq!(read(dir.path(), "go.mod").await, "module m\n"); } + // ---------- bun: digest-less re-saves (Bun 1.1.39–1.3.9, every text-lock release below 1.3.10) ---------- + + const BUN_URL: &str = + "https://patch.socket.dev/patch/npm/tok-1111/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/left-pad-1.3.0.tgz"; + const BUN_REGISTRY_LINE: &str = + " \"left-pad\": [\"left-pad@1.3.0\", \"\", {}, \"sha512-XI5M==\"],"; + + fn bun_url_line(sha: &str) -> String { + format!(" \"left-pad\": [\"left-pad@{BUN_URL}\", {{}}, \"{sha}\"],") + } + + fn bun_digestless_line() -> String { + format!(" \"left-pad\": [\"left-pad@{BUN_URL}\", {{}}],") + } + + fn bun_lock(entry: &str) -> String { + format!( + "{{\n \"lockfileVersion\": 1,\n \"packages\": {{\n \"abbrev\": [\"abbrev@1.1.1\", \ + \"\", {{}}, \"sha512-D==\"],\n\n{entry}\n }}\n}}\n" + ) + } + + fn bun_edit(original: &str, new: &str) -> FileEdit { + edit( + "bun.lock", + "redirect_bun_lock_package", + "rewritten", + Some(original), + Some(new), + ) + } + + /// The live lock carries the digest-less 2-tuple Bun < 1.3.10 re-saved + /// our URL 3-tuple as; the recorded `new` is the 3-tuple. That is the + /// recorded wiring, not drift: the registry original comes back, the + /// edit and record are consumed. + #[tokio::test] + async fn bun_digestless_live_line_replays_to_the_registry_original() { + let dir = TempDir::new().unwrap(); + write(dir.path(), "bun.lock", &bun_lock(&bun_digestless_line())).await; + let mut state = state_with( + vec![bun_edit(BUN_REGISTRY_LINE, &bun_url_line("sha512-AAAA=="))], + &["pkg:npm/left-pad@1.3.0"], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{:?}", out.refusals); + assert_eq!( + read(dir.path(), "bun.lock").await, + bun_lock(BUN_REGISTRY_LINE), + "the pristine registry line is restored, the decoy untouched" + ); + assert!(state.edits.is_empty() && state.records.is_empty()); + assert_eq!(out.dropped_records, vec!["pkg:npm/left-pad@1.3.0"]); + + // CRLF lock (ledger recorded with `\r` on both fragments, as the + // rewriter does): every line keeps its `\r\n`. + let dir = TempDir::new().unwrap(); + write( + dir.path(), + "bun.lock", + &bun_lock(&bun_digestless_line()).replace('\n', "\r\n"), + ) + .await; + let mut state = state_with( + vec![bun_edit( + &format!("{BUN_REGISTRY_LINE}\r"), + &format!("{}\r", bun_url_line("sha512-AAAA==")), + )], + &[], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{:?}", out.refusals); + assert_eq!( + read(dir.path(), "bun.lock").await, + bun_lock(BUN_REGISTRY_LINE).replace('\n', "\r\n") + ); + } + + /// The hosted rewriter HEALS a digest-less tuple and records that heal + /// as a second edit for the same key (`original` = the 2-tuple). The + /// chain unwinds newest-first: heal → 2-tuple, then the first edit + /// recognises the 2-tuple as its digest-less `new` → registry line. + /// Same end state when Bun has since dropped the digest AGAIN (the + /// heal edit is then "already at its original" and simply drops). + #[tokio::test] + async fn bun_heal_chain_unwinds_to_the_registry_line() { + let healed = bun_url_line("sha512-AAAA=="); + for live in [healed.clone(), bun_digestless_line()] { + let dir = TempDir::new().unwrap(); + write(dir.path(), "bun.lock", &bun_lock(&live)).await; + let mut state = state_with( + vec![ + bun_edit(BUN_REGISTRY_LINE, &healed), + bun_edit(&bun_digestless_line(), &healed), + ], + &["pkg:npm/left-pad@1.3.0"], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "live={live}: {:?}", out.refusals); + assert_eq!( + read(dir.path(), "bun.lock").await, + bun_lock(BUN_REGISTRY_LINE), + "live={live}: the chain must end at the pristine registry line" + ); + assert!(state.edits.is_empty() && state.records.is_empty()); + } + } + + /// The relaxation is exactly "our tuple minus its digest": another + /// uuid/token in the URL, a re-laid meta object, a duplicate digest-less + /// instance, or a non-bun edit kind over the same bytes all still + /// refuse, leaving the file byte-identical. + #[tokio::test] + async fn bun_digestless_relaxation_is_narrow() { + let recorded_new = bun_url_line("sha512-AAAA=="); + let other_uuid = bun_digestless_line().replace( + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + ); + let other_meta = bun_digestless_line().replace("{}", "{ \"bin\": \"x\" }"); + for (live, kind, reason) in [ + ( + bun_lock(&other_uuid), + "redirect_bun_lock_package", + "neither the redirected nor the original", + ), + ( + bun_lock(&other_meta), + "redirect_bun_lock_package", + "neither the redirected nor the original", + ), + ( + bun_lock(&format!( + "{}\n{}", + bun_digestless_line(), + bun_digestless_line() + )), + "redirect_bun_lock_package", + "more than once", + ), + ( + bun_lock(&bun_digestless_line()), + "redirect_pnpm_resolution", + "neither the redirected nor the original", + ), + ] { + let dir = TempDir::new().unwrap(); + write(dir.path(), "bun.lock", &live).await; + let mut state = state_with( + vec![edit( + "bun.lock", + kind, + "rewritten", + Some(BUN_REGISTRY_LINE), + Some(&recorded_new), + )], + &["pkg:npm/left-pad@1.3.0"], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert_eq!( + out.refusals.len(), + 1, + "{kind} over {live}: {:?}", + out.refusals + ); + assert!( + out.refusals[0].reason.contains(reason), + "{kind}: {}", + out.refusals[0].reason + ); + assert_eq!(read(dir.path(), "bun.lock").await, live, "file untouched"); + assert_eq!(state.edits.len(), 1, "refused edit kept"); + assert!(state.records.contains_key("pkg:npm/left-pad@1.3.0")); + } + } + // ---------- RemoveAddedFragment / ReinsertRemoved ---------- #[tokio::test] @@ -1392,7 +1625,9 @@ mod tests { // The npm group refused; the bun group replayed. assert_eq!(out.refusals.len(), 1); assert_eq!(out.refusals[0].group, "npm"); - assert!(read(dir.path(), "bun.lock").await.contains("upstream.example")); + assert!(read(dir.path(), "bun.lock") + .await + .contains("upstream.example")); // npm-family records are held while ANY npm-family group refused. assert!(state.records.contains_key("pkg:npm/a@1")); assert_eq!(state.edits.len(), 1, "only the refused npm edit remains"); @@ -1572,7 +1807,9 @@ mod tests { assert_eq!(out.dropped_records, vec!["pkg:pypi/left-pad@1.3.0"]); assert!(out.reverted_files.contains("requirements.txt")); // Disk and ledger untouched. - assert!(read(dir.path(), "requirements.txt").await.contains("patch.example")); + assert!(read(dir.path(), "requirements.txt") + .await + .contains("patch.example")); assert_eq!(state.edits.len(), 1); assert_eq!(state.records.len(), 1); } @@ -2040,7 +2277,9 @@ mod tests { ); let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; assert_eq!(out.refusals.len(), 1, "{out:?}"); - assert!(out.refusals[0].reason.contains("missing its recorded lines")); + assert!(out.refusals[0] + .reason + .contains("missing its recorded lines")); assert_eq!(state.edits.len(), 1); assert_eq!(read(dir.path(), "go.sum").await, "x v1 h1:a\n"); } @@ -2086,10 +2325,16 @@ mod tests { ]; for (path, kind, action, original, new) in cases { let dir = TempDir::new().unwrap(); - tokio::fs::create_dir_all(dir.path().join(path)).await.unwrap(); + tokio::fs::create_dir_all(dir.path().join(path)) + .await + .unwrap(); let mut state = state_with(vec![edit(path, kind, action, original, new)], &[]); let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert_eq!(out.refusals.len(), 1, "{kind}/{action} must refuse: {out:?}"); + assert_eq!( + out.refusals.len(), + 1, + "{kind}/{action} must refuse: {out:?}" + ); assert!( out.refusals[0].reason.starts_with(&format!("read {path}:")), "{kind}/{action}: {}", @@ -2354,7 +2599,10 @@ mod tests { ); let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; assert_eq!(out.refusals.len(), 1, "{out:?}"); - assert_eq!(out.refusals[0].reason, "composer.lock is not a regular file"); + assert_eq!( + out.refusals[0].reason, + "composer.lock is not a regular file" + ); assert_eq!( read(dir.path(), "real.lock").await, "https://patch.example/a\n", diff --git a/crates/socket-patch-core/src/patch/redirect/takeover.rs b/crates/socket-patch-core/src/patch/redirect/takeover.rs index a3b8918d..0ff70adc 100644 --- a/crates/socket-patch-core/src/patch/redirect/takeover.rs +++ b/crates/socket-patch-core/src/patch/redirect/takeover.rs @@ -665,14 +665,35 @@ pub async fn revert_npm_redirect_purl( } else if content.contains(orig) { // Already at (or unwound to) the pre-redirect fragment. } else { - return Err(format!( - "the {} entry for {lock_key} has drifted from the recorded \ - hosted redirect (neither the redirected nor the original \ - fragment is present); refusing to touch it — re-run \ - `scan --mode hosted` to normalize the redirect, or \ - restore the registry wiring manually, then re-run", - edit.path - )); + // bun only: Bun 1.1.39–1.3.9 re-save our URL 3-tuple WITHOUT + // its sha512 on any later lock re-save, so the recorded + // `new` is on disk as a digest-less 2-tuple (same key, spec + // and meta). That spelling IS the recorded wiring — restore + // `orig` over it; a stale ledger of its own making must not + // block the takeover, scoped rollback or remove. Anything + // else still refuses (fail closed). + let healed = if edit.kind == BUN_TEXT_KIND { + crate::vendor::bun_lock_text::restore_digestless_line(&content, new, orig) + .map_err(|ambiguous| format!("{}: {ambiguous}", edit.path))? + } else { + None + }; + match healed { + Some(restored) => { + staged.insert(edit.path.clone(), Some(restored)); + out.reverted_files.push(edit.path.clone()); + } + None => { + return Err(format!( + "the {} entry for {lock_key} has drifted from the recorded \ + hosted redirect (neither the redirected nor the original \ + fragment is present); refusing to touch it — re-run \ + `scan --mode hosted` to normalize the redirect, or \ + restore the registry wiring manually, then re-run", + edit.path + )); + } + } } } else { revert_npm_json_edit(project_root, &mut staged, &edit, &name, &version, &mut out) @@ -1211,7 +1232,10 @@ mod tests { // previews — the whole-ledger replay running after per-purl // reverts — must see the post-claim state); the caller owns the // clone and never persists it on a dry run. - assert!(state.records.len() < records_before, "record claimed in memory"); + assert!( + state.records.len() < records_before, + "record claimed in memory" + ); assert!(state.edits.len() < edits_before, "edits claimed in memory"); // The preview names exactly the files a wet run then reverts — @@ -2030,7 +2054,9 @@ mod tests { .and_then(Value::as_object_mut) .unwrap(); entry.remove("name").expect("fixture name field present"); - entry.remove("version").expect("fixture version field present"); + entry + .remove("version") + .expect("fixture version field present"); tokio::fs::write( root.join("package-lock.json"), serde_json::to_string_pretty(&on_disk).unwrap(), @@ -2167,9 +2193,7 @@ mod tests { .await; let root = tmp.path(); assert_eq!(state.edits.len(), 2, "{:?}", state.edits); - let scoped_url = npm_dep_for("@scope/left-pad", "1.3.0") - .artifact_url - .clone(); + let scoped_url = npm_dep_for("@scope/left-pad", "1.3.0").artifact_url.clone(); // Hand edit / merge artifact: strip the alias entry's name+version. let mut on_disk: Value = serde_json::from_str( &tokio::fs::read_to_string(root.join("package-lock.json")) @@ -2183,7 +2207,9 @@ mod tests { .and_then(Value::as_object_mut) .unwrap(); entry.remove("name").expect("fixture name field present"); - entry.remove("version").expect("fixture version field present"); + entry + .remove("version") + .expect("fixture version field present"); tokio::fs::write( root.join("package-lock.json"), serde_json::to_string_pretty(&on_disk).unwrap(), @@ -2480,6 +2506,133 @@ mod tests { ); } + /// The digest-less spelling Bun 1.1.39–1.3.9 re-save a URL 3-tuple as + /// (`bun add`, `bun install` after a manifest change): the recorded + /// `new` is no longer on disk byte-for-byte, but the 2-tuple with the + /// same key/spec/meta IS our wiring — the claim must not refuse as + /// drift (that blocked hosted→vendored takeover, scoped `rollback` and + /// `remove` for every user on those releases). The registry line comes + /// back and the ledger is cleared. + fn drop_digest(line: &str) -> String { + let cut = line + .rfind(", \"sha512-") + .unwrap_or_else(|| panic!("no sha512 element in {line}")); + let tail = if line.trim_end_matches('\r').ends_with("],") { + "]," + } else { + "]" + }; + let cr = if line.ends_with('\r') { "\r" } else { "" }; + format!("{}{tail}{cr}", &line[..cut]) + } + + #[tokio::test] + async fn npm_bun_digestless_live_line_is_claimed_and_restored() { + let (tmp, mut state) = npm_redirected_fixture("bun.lock", &bun_pristine()).await; + let root = tmp.path(); + let wired = read_lock(root).await; + let wired_line = bun_line(&wired, "left-pad"); + let digestless = drop_digest(&wired_line); + assert!( + digestless.ends_with("{}],") && !digestless.contains("sha512"), + "{digestless}" + ); + tokio::fs::write( + root.join("bun.lock"), + wired.replace(&wired_line, &digestless), + ) + .await + .unwrap(); + + let out = revert_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect("the digest-less spelling of our own wiring must not refuse"); + assert_eq!(out.reverted_files, vec!["bun.lock".to_string()]); + assert_eq!( + read_lock(root).await, + bun_pristine(), + "registry line restored" + ); + assert!(state.records.is_empty() && state.edits.is_empty()); + } + + /// Both hosted instances re-saved digest-less; reverting 1.3.0 restores + /// ONLY its line — the sibling 1.2.0 keeps its digest-less hosted + /// 2-tuple untouched (it is that purl's wiring, not ours to heal). + #[tokio::test] + async fn npm_bun_digestless_sibling_stays_untouched() { + const SIBLING: &str = "pkg:npm/left-pad@1.2.0"; + let (tmp, mut state) = npm_redirected_fixture_multi( + "bun.lock", + &bun_pristine(), + &[ + (NPM_PURL, npm_dep()), + (SIBLING, npm_dep_for("left-pad", "1.2.0")), + ], + ) + .await; + let root = tmp.path(); + let wired = read_lock(root).await; + let main_line = bun_line(&wired, "left-pad"); + let sibling_line = bun_line(&wired, "haspad/left-pad"); + let sibling_digestless = drop_digest(&sibling_line); + let live = wired + .replace(&main_line, &drop_digest(&main_line)) + .replace(&sibling_line, &sibling_digestless); + tokio::fs::write(root.join("bun.lock"), &live) + .await + .unwrap(); + + revert_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect("takeover of 1.3.0 succeeds"); + let after = read_lock(root).await; + assert_eq!( + bun_line(&after, "left-pad"), + bun_line(&bun_pristine(), "left-pad") + ); + assert_eq!( + bun_line(&after, "haspad/left-pad"), + sibling_digestless, + "the sibling's digest-less hosted line is left exactly as found" + ); + assert_eq!(state.edits.len(), 1); + assert!(state.records.contains_key(SIBLING) && !state.records.contains_key(NPM_PURL)); + } + + /// A digest-less 2-tuple at ANOTHER uuid (someone re-granted the patch + /// and Bun re-saved it) is not the recorded wiring: still drift, still + /// a refusal, file byte-identical. + #[tokio::test] + async fn npm_bun_digestless_line_at_another_uuid_still_refuses() { + let (tmp, mut state) = npm_redirected_fixture("bun.lock", &bun_pristine()).await; + let root = tmp.path(); + let wired = read_lock(root).await; + let wired_line = bun_line(&wired, "left-pad"); + let foreign = drop_digest(&wired_line).replace("/6b7c/", "/7c8d/"); + assert_ne!( + foreign, + drop_digest(&wired_line), + "the uuid segment must differ" + ); + let live = wired.replace(&wired_line, &foreign); + tokio::fs::write(root.join("bun.lock"), &live) + .await + .unwrap(); + + let err = revert_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect_err("another uuid's digest-less line is drift"); + assert!(err.contains("drifted"), "{err}"); + assert_eq!( + read_lock(root).await, + live, + "refusal leaves the lock untouched" + ); + assert_eq!(state.edits.len(), 1); + assert!(state.records.contains_key(NPM_PURL)); + } + /// (b) Scoped package + re-redirect chain: the hosted rewrite destroys /// the `@scope/pkg@1.0.0` spec, so the SECOND redirect (a rotated /// artifact URL) records a hosted-URL line as its `original`. That edit @@ -2975,7 +3128,9 @@ mod tests { let cfg_before = tokio::fs::read_to_string(root.join(".cargo/config.toml")) .await .unwrap(); - tokio::fs::remove_file(root.join("Cargo.lock")).await.unwrap(); + tokio::fs::remove_file(root.join("Cargo.lock")) + .await + .unwrap(); let records_before = state.records.len(); let edits_before = state.edits.len(); @@ -3129,9 +3284,12 @@ mod tests { let wired_toml = tokio::fs::read_to_string(root.join("Cargo.toml")) .await .unwrap(); - tokio::fs::write(root.join("Cargo.toml"), format!("{wired_toml}{pinned_line}")) - .await - .unwrap(); + tokio::fs::write( + root.join("Cargo.toml"), + format!("{wired_toml}{pinned_line}"), + ) + .await + .unwrap(); let out = revert_cargo_redirect_purl(root, &mut state, PURL, false) .await @@ -3241,7 +3399,9 @@ mod tests { async fn npm_missing_text_lock_refuses_and_keeps_the_ledger() { let (tmp, mut state) = npm_redirected_fixture("yarn.lock", &classic_pristine()).await; let root = tmp.path(); - tokio::fs::remove_file(root.join("yarn.lock")).await.unwrap(); + tokio::fs::remove_file(root.join("yarn.lock")) + .await + .unwrap(); let records_before = state.records.len(); let edits_before = state.edits.len(); @@ -3435,7 +3595,10 @@ mod tests { let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) .await .expect_err("vanished v2 tree must refuse"); - assert!(err.contains("no longer holds a `dependencies` tree"), "{err}"); + assert!( + err.contains("no longer holds a `dependencies` tree"), + "{err}" + ); let after = tokio::fs::read_to_string(root.join("package-lock.json")) .await .unwrap(); diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 48b7f0fc..c3741ac5 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -33,7 +33,9 @@ use std::path::Path; +use base64::Engine as _; use serde_json::Value; +use sha2::{Digest, Sha512}; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; @@ -240,6 +242,26 @@ pub(crate) async fn vendor_bun( } } + // The sha512 of the artifact already sitting at the target path, if + // any — the one witness a digest-less in-sync tuple (see `classify`) + // still has of the digest Bun dropped: the lock line was written from + // these bytes. Read BEFORE staging, which overwrites the file; a + // missing or non-regular path (a `repair` rebuild after deletion, a + // FIFO) yields `None`, which the in-sync check below treats as "not + // provably the same bytes". + let prior_artifact_integrity: Option = { + let abs = project_root.join(&coords.uuid_dir_rel).join(&target_leaf); + match tokio::fs::metadata(&abs).await { + Ok(meta) if meta.is_file() => tokio::fs::read(&abs).await.ok().map(|bytes| { + format!( + "sha512-{}", + base64::engine::general_purpose::STANDARD.encode(Sha512::digest(&bytes)) + ) + }), + _ => None, + } + }; + // ── 4. Stage → patch → pack (shared flavor-agnostic pipeline) ──────── // A wiring failure past this point must unwind the uuid dir staging is // about to create — but never one that already existed (a same-uuid @@ -291,21 +313,16 @@ pub(crate) async fn vendor_bun( // ── 5. Rewrite every matching instance (in-memory) ──────────────────── let mut wiring: Vec = Vec::new(); let mut changed = false; + // In-sync instances whose digest Bun dropped (see `classify`): re-pinned + // on disk WITHOUT a wiring record — the ledger already holds this + // instance's pristine original and `revert_one_record` recognises both + // spellings — so the run stays an AlreadyPatched no-op for the ledger + // while ≥ 1.3.10 consumers of the committed lock regain verification. + let mut healed = false; for entry in &entries { let Some(shape) = classify(entry, &target_spec, name, &target_leaf) else { continue; }; - let (deps_verbatim, was_ours) = match shape { - TupleShape::Registry => (entry.elems[2].clone(), false), - TupleShape::Ours { path } => { - // Idempotency: an instance already carrying this exact path - // and integrity needs no edit and no wiring record. - if path == rel_tgz && entry.elems[2] == format!("\"{}\"", packed.integrity) { - continue; - } - (entry.elems[1].clone(), true) - } - }; let original_line = lines[entry.line_idx].clone(); // Lines come from a bare `split('\n')`, so a CRLF lock's lines carry // a trailing `\r` (the grammar trims it away when parsing). Re-emit @@ -315,14 +332,51 @@ pub(crate) async fn vendor_bun( } else { "" }; - let new_line = format!( - "{indent}{key}: [\"{name}@{rel_tgz}\", {deps}, \"{integrity}\"]{comma}{cr}", - indent = entry.indent, - key = entry.key_raw, - deps = deps_verbatim, - integrity = packed.integrity, - comma = if entry.trailing_comma { "," } else { "" }, - ); + let local_tuple_line = |deps: &str| { + format!( + "{indent}{key}: [\"{name}@{rel_tgz}\", {deps}, \"{integrity}\"]{comma}{cr}", + indent = entry.indent, + key = entry.key_raw, + integrity = packed.integrity, + comma = if entry.trailing_comma { "," } else { "" }, + ) + }; + let (deps_verbatim, was_ours) = match shape { + TupleShape::Registry => (entry.elems[2].clone(), false), + TupleShape::Ours { path } => { + if path == rel_tgz { + match entry.elems.get(2) { + // Idempotency: an instance already carrying this exact + // path and integrity needs no edit and no wiring record. + Some(integrity) if *integrity == format!("\"{}\"", packed.integrity) => { + continue; + } + // Digest-less re-save of THIS wiring (Bun 1.1.39–1.3.9) + // over the SAME bytes the lock was written from (the + // artifact found at the path before this run re-staged + // it equals the staged one): heal the line in place, + // record nothing — the ledger's fingerprint still holds. + None if prior_artifact_integrity.as_deref() + == Some(packed.integrity.as_str()) => + { + lines[entry.line_idx] = local_tuple_line(&entry.elems[1]); + healed = true; + continue; + } + // Same path, different digest — or a digest-less line + // whose artifact was missing or differed before staging + // (a `repair` rebuild, a service-prebuilt ↔ local-pack + // flip): re-pinned below like any stale tuple of ours, + // so the returned entry carries the rebuilt artifact's + // fingerprint (`carry_forward_wiring` refills the + // pristine original from the entry it replaces). + _ => {} + } + } + (entry.elems[1].clone(), true) + } + }; + let new_line = local_tuple_line(&deps_verbatim); lines[entry.line_idx] = new_line.clone(); wiring.push(WiringRecord { file: BUN_LOCK.to_string(), @@ -344,8 +398,29 @@ pub(crate) async fn vendor_bun( if !changed { // Every instance already points at this uuid with the packed - // integrity: in sync. The tarball re-pack above was byte-identical - // by determinism; synthesize AlreadyPatched and record nothing. + // integrity (or with the digest Bun dropped, now re-pinned): in + // sync. The tarball re-pack above was byte-identical by + // determinism; synthesize AlreadyPatched and record nothing. The + // heal is the one write of an in-sync run, and a failed write + // leaves the still-installable digest-less lock — reported as the + // failure it is, like every other lock write below. + if healed { + if let Err(e) = atomic_write_bytes_preserving_mode( + &project_root.join(BUN_LOCK), + lines.join("\n").as_bytes(), + ) + .await + { + return done_failure_unstage( + purl, + format!("cannot write {BUN_LOCK}: {e}"), + project_root, + &coords.uuid_dir_rel, + uuid_dir_preexisted, + ) + .await; + } + } return VendorOutcome::Done { result: already_patched_result(purl, &project_root.join(&rel_tgz), &record.files), entry: None, @@ -580,9 +655,12 @@ fn revert_one_record( return; } // Ours iff the line is exactly what we wrote, or its tuple still - // points into OUR uuid dir (a re-serialized but unmoved entry). + // points into OUR uuid dir (a re-serialized but unmoved entry — + // including the digest-less 2-tuple Bun 1.1.39–1.3.9 re-save our + // 3-tuple as on any later lock re-save; the path is the claim, the + // dropped sha512 proves nothing either way). let exact = Some(lines[idx].as_str()) == rec.new.as_ref().and_then(Value::as_str); - let ours_uuid = parsed.elems.len() == 3 + let ours_uuid = matches!(parsed.elems.len(), 2 | 3) && decode_json_string(&parsed.elems[0]) .and_then(|spec| split_name_spec(&spec).map(|(_, p)| p.to_string())) .and_then(|path| parse_vendor_path(&path)) @@ -624,7 +702,9 @@ fn revert_one_record( enum TupleShape { /// Registry 4-tuple `["name@version", "", {deps}, "sha512-…"]`. Registry, - /// Our local 3-tuple (any uuid; the caller decides current vs stale). + /// Our local tuple (any uuid; the caller decides current vs stale): the + /// 3-tuple we write, or the digest-less 2-tuple Bun 1.1.39–1.3.9 re-save + /// it as (`elems.len()` tells them apart; only a 3-tuple has `elems[2]`). Ours { path: String }, } @@ -634,7 +714,12 @@ enum TupleShape { /// `None` otherwise. The Ours arm matches on the uuid-independent tarball /// leaf, NOT the name alone: a vendored tuple for ANOTHER version of the /// same package is someone else's edit (two patched versions can coexist in -/// one lock — nested instances) and must never be cross-clobbered. +/// one lock — nested instances) and must never be cross-clobbered. It +/// accepts the 2-tuple spelling too: Bun 1.1.39–1.3.9 drop a local tarball +/// tuple's `"sha512-…"` on any lock re-save (`bun add`, `bun install` after +/// a manifest change), leaving spec and meta intact — still our wiring, so +/// re-runs stay in sync, `repair` can rebuild and revert can unwind it +/// instead of refusing `vendor_lock_entry_not_found` / `_drifted`. fn classify( entry: &BunEntry, target_spec: &str, @@ -650,7 +735,7 @@ fn classify( { Some(TupleShape::Registry) } - 3 => { + 2 | 3 => { let (entry_name, path) = split_name_spec(&spec)?; if entry_name != name || !entry.elems[1].starts_with('{') { return None; @@ -670,8 +755,6 @@ mod tests { use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; use crate::patch::apply::{ApplyResult, VerifyStatus}; - use base64::Engine as _; - use sha2::{Digest, Sha512}; use std::collections::HashMap; use std::path::PathBuf; @@ -2424,6 +2507,307 @@ mod tests { ); } + // ── digest-less re-saves (Bun 1.1.39–1.3.9, every text-lock release below 1.3.10) ───────────────────────── + // Those releases re-save our local-tarball 3-tuple WITHOUT its sha512 + // on any later lock re-save (`bun add`, `bun install` after a manifest + // change) — verified on real 1.2.23 and 1.3.9; 1.3.10+ keep it. The + // 2-tuple `["name@.socket/vendor/npm//leaf", {meta}]` is still + // our wiring: re-runs stay in sync (and heal the digest back, recording + // nothing), `repair` rebuilds through it, and revert unwinds it. + + /// Strip the trailing integrity element of a 3-tuple line the way Bun + /// < 1.3.10 re-saves it (any `\r` kept). + fn drop_digest(line: &str) -> String { + let cut = line + .rfind(", \"sha512-") + .unwrap_or_else(|| panic!("no sha512 element in {line}")); + let tail = if line.trim_end_matches('\r').ends_with("],") { + "]," + } else { + "]" + }; + let cr = if line.ends_with('\r') { "\r" } else { "" }; + format!("{}{tail}{cr}", &line[..cut]) + } + + /// The wired line of `entry` and the lock with that line's digest dropped. + fn digestless_lock(wired: &str, entry: &VendorEntry) -> (String, String) { + let new_line = entry.wiring[0] + .new + .as_ref() + .and_then(Value::as_str) + .unwrap() + .to_string(); + let digestless = drop_digest(&new_line); + assert!(!digestless.contains("sha512-"), "{digestless}"); + assert!(wired.contains(&new_line), "{wired}"); + (new_line.clone(), wired.replacen(&new_line, &digestless, 1)) + } + + #[tokio::test] + async fn digestless_rerun_is_already_patched_and_heals_the_digest_without_a_record() { + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let wired = fx.read_lock().await; + let (_, digestless) = digestless_lock(&wired, &entry); + tokio::fs::write(fx.root().join(BUN_LOCK), &digestless) + .await + .unwrap(); + + // Re-run: in sync (AlreadyPatched, no entry), NOT + // `vendor_lock_entry_not_found`; the digest is healed on disk and the + // lock is byte-identical to the post-vendor lock again. + let (result, rerun_entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + assert!(rerun_entry.is_none(), "an in-sync re-run records nothing"); + assert!( + result + .files_verified + .iter() + .all(|v| v.status == VerifyStatus::AlreadyPatched), + "{:?}", + result.files_verified + ); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!( + fx.read_lock().await, + wired, + "digest healed back to the 3-tuple" + ); + + // The healed lock reverts through the ORIGINAL entry byte-exactly. + let outcome = revert_bun(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!(fx.read_lock().await, BN3_BEFORE_LOCK); + assert!(!fx + .root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists()); + } + + /// Revert straight over the digest-less line (no re-run in between — + /// `rollback` right after a `bun add`): the path claims the line, the + /// registry original comes back, the artifact goes, no drift warning. + #[tokio::test] + async fn digestless_tuple_revert_restores_the_registry_line() { + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let (_, digestless) = digestless_lock(&fx.read_lock().await, &entry); + tokio::fs::write(fx.root().join(BUN_LOCK), &digestless) + .await + .unwrap(); + + let outcome = revert_bun(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + outcome.warnings.is_empty(), + "a digest-less spelling of our own tuple is not drift: {:?}", + outcome.warnings + ); + assert!(!outcome.kept_artifact); + assert_eq!(fx.read_lock().await, BN3_BEFORE_LOCK); + assert!(!fx + .root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists()); + } + + /// CRLF lock: the digest-less re-save keeps `\r`; the heal keeps every + /// line's `\r\n` and revert lands byte-exact on the CRLF original. + #[tokio::test] + async fn digestless_crlf_lock_heals_and_reverts_byte_exact() { + let crlf_before = BN3_BEFORE_LOCK.replace('\n', "\r\n"); + let fx = fixture_with(&crlf_before, "node_modules/left-pad").await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let wired = fx.read_lock().await; + let (new_line, digestless) = digestless_lock(&wired, &entry); + assert!( + new_line.ends_with('\r'), + "wired line carries its \\r: {new_line:?}" + ); + tokio::fs::write(fx.root().join(BUN_LOCK), &digestless) + .await + .unwrap(); + + let (result, rerun_entry, _) = expect_done(fx.vendor(false).await); + assert!( + result.success && rerun_entry.is_none(), + "{:?}", + result.error + ); + let healed = fx.read_lock().await; + assert_eq!(healed, wired, "CRLF heal is byte-exact"); + assert_eq!(healed.matches('\n').count(), healed.matches("\r\n").count()); + + let outcome = revert_bun(&entry, fx.root(), false).await; + assert!( + outcome.success && outcome.warnings.is_empty(), + "{outcome:?}" + ); + assert_eq!(fx.read_lock().await, crlf_before); + } + + /// The upgraded-workspace case (the matrix's `already-vendored-workspace` + /// shape on Bun 1.1.39–1.3.9): vendored, then a member added and `bun + /// install` re-saved the lock digest-less. The in-sync re-run must stay + /// AlreadyPatched (the v1 workspace gate fires only on a run that would + /// WRITE a new local tuple) and heal the digest; revert restores the + /// grown lock with the registry line back. + #[tokio::test] + async fn digestless_rerun_on_v1_workspace_lock_is_already_patched_and_heals() { + for version in [0u64, 1] { + let (fx, entry, lock) = vendored_then_workspace_added(version).await; + let (_, digestless) = digestless_lock(&lock, &entry); + tokio::fs::write(fx.root().join(BUN_LOCK), &digestless) + .await + .unwrap(); + let (result, rerun_entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "v{version}: {:?}", result.error); + assert!( + rerun_entry.is_none(), + "v{version}: in-sync re-run records nothing" + ); + assert!( + result + .files_verified + .iter() + .all(|v| v.status == VerifyStatus::AlreadyPatched), + "v{version}: {:?}", + result.files_verified + ); + assert_eq!(fx.read_lock().await, lock, "v{version}: digest healed"); + + let outcome = revert_bun(&entry, fx.root(), false).await; + assert!( + outcome.success && outcome.warnings.is_empty(), + "v{version}: {outcome:?}" + ); + let original_line = entry.wiring[0] + .original + .as_ref() + .and_then(Value::as_str) + .unwrap(); + let new_line = entry.wiring[0] + .new + .as_ref() + .and_then(Value::as_str) + .unwrap(); + assert_eq!( + fx.read_lock().await, + lock.replacen(new_line, original_line, 1), + "v{version}: the grown lock with the registry line put back" + ); + } + } + + /// `repair` on a digest-less lock: the artifact is gone, the lock still + /// points at it (2-tuple). The rebuild routes through `vendor_bun`, must + /// pass the has-match preflight (the 2-tuple IS our instance), rebuild + /// the tarball and heal the digest — never refuse + /// `vendor_lock_entry_not_found` and leave the lock pointing at ENOENT. + #[tokio::test] + async fn rebuild_on_missing_tarball_through_a_digestless_lock_succeeds() { + for version in [0u64, 1, 2] { + let (fx, entry, lock) = vendored_then_workspace_added(version).await; + let (_, digestless) = digestless_lock(&lock, &entry); + tokio::fs::write(fx.root().join(BUN_LOCK), &digestless) + .await + .unwrap(); + let tgz = fx.root().join(fx.rel_tgz()); + tokio::fs::remove_file(&tgz).await.unwrap(); + + let (result, rebuilt_entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "v{version}: {:?}", result.error); + assert!(tgz.is_file(), "v{version}: the tarball must be rebuilt"); + assert_eq!( + fx.read_lock().await, + lock, + "v{version}: the rebuilt digest is re-pinned into the healed 3-tuple" + ); + // No artifact stood witness for the dropped digest, so this is + // a re-pin, not a silent heal: a fresh entry carries the rebuilt + // fingerprint for the ledger (its `original` is refilled from + // the replaced entry by `carry_forward_wiring`). + let rebuilt_entry = + rebuilt_entry.expect("a rebuild through a digest-less line returns an entry"); + assert_eq!(rebuilt_entry.artifact.path, fx.rel_tgz()); + assert!(rebuilt_entry.wiring[0].original.is_none(), "v{version}"); + } + } + + /// The digest-less line's artifact is PRESENT but no longer the bytes + /// the lock was written from (a service-prebuilt tarball replaced by a + /// local pack, or vice versa). The tuple cannot say which digest it + /// pinned, so this is a re-pin with a fresh entry — never a silent heal + /// that would leave the ledger's fingerprint pointing at other bytes + /// (`repair`'s post-verify then rejects the rebuild and deletes it). + #[tokio::test] + async fn digestless_tuple_over_different_artifact_bytes_is_repinned_with_a_fresh_entry() { + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let wired = fx.read_lock().await; + let (_, digestless) = digestless_lock(&wired, &entry); + tokio::fs::write(fx.root().join(BUN_LOCK), &digestless) + .await + .unwrap(); + // Different artifact bytes at the same path. + tokio::fs::write(fx.root().join(fx.rel_tgz()), b"not the packed tarball") + .await + .unwrap(); + + let (result, repinned, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let repinned = repinned.expect("differing bytes force a re-pin with a fresh entry"); + assert_eq!( + fx.read_lock().await, + wired, + "the line is re-pinned to the freshly packed digest" + ); + assert_eq!( + repinned.artifact.sha256, entry.artifact.sha256, + "the fresh entry fingerprints the re-staged (deterministic) pack" + ); + assert!(repinned.wiring[0].original.is_none()); + assert_eq!( + repinned.wiring[0].new.as_ref().and_then(Value::as_str), + entry.wiring[0].new.as_ref().and_then(Value::as_str) + ); + } + + /// A digest-less 2-tuple pointing at ANOTHER uuid for the same leaf is + /// not this entry's wiring on revert: drift-kept with the warning, the + /// lock and artifact left alone (mirrors the 3-tuple stale-uuid rule). + #[tokio::test] + async fn digestless_tuple_of_another_uuid_is_drift_on_revert() { + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let (_, digestless) = digestless_lock(&fx.read_lock().await, &entry); + let foreign = digestless.replace(UUID, UUID_B); + assert_ne!(foreign, digestless); + tokio::fs::write(fx.root().join(BUN_LOCK), &foreign) + .await + .unwrap(); + + let outcome = revert_bun(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted" && w.detail.contains("left-pad")), + "{:?}", + outcome.warnings + ); + assert!(outcome.kept_artifact, "drift keeps the artifact"); + assert_eq!(fx.read_lock().await, foreign, "lock untouched"); + } + /// A re-vendor under a NEW uuid rewrites our own earlier tuple, so its /// record carries `original: None` by design (never record a stale /// `.socket/vendor/` pointer as the "original"). Reverting that record 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 ce3f153e..6783a70b 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock_text.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock_text.rs @@ -273,6 +273,92 @@ pub(crate) fn decode_json_string(token: &str) -> Option { serde_json::from_str::(token).ok() } +/// Is `live` the wiring `recorded` describes, allowing ONLY for a dropped +/// integrity element? +/// +/// Bun 1.1.39–1.3.9 re-save a URL or local-tarball tuple WITHOUT its +/// trailing `"sha512-…"` whenever the lock is re-saved for any other reason +/// (`bun add `, `bun install` after a package.json or workspace +/// change): the 3-tuple `["name@", {meta}, "sha512-…"]` comes +/// back as the 2-tuple `["name@", {meta}]` — spec and meta +/// intact, digest gone — every text-lock release below 1.3.10 does it +/// (verified on real 1.1.45 at lockfileVersion 0, 1.2.23 and 1.3.9; 1.3.10, +/// 1.3.14 and 1.4.2 keep the digest). Those releases never verified a tarball +/// tuple's digest in the first place, so the install is unchanged; only a +/// byte-exact comparison against the recorded line would call it drift. +/// +/// True when the two lines are byte-equal modulo a trailing `\r` (a CRLF +/// lock read against an LF ledger or vice versa), or when both parse under +/// bun's entry grammar, name the same map key, decode to the same spec, +/// carry the same verbatim `{meta}` object, and `live` has exactly the +/// two leading elements of a `recorded` 3-tuple whose third element is an +/// SRI integrity string. Anything else — a different URL/uuid, a different +/// version, a re-laid meta object, a parse failure, a 3-tuple with a +/// different digest — is NOT the same wiring (fail closed). +pub(crate) fn same_wiring_modulo_integrity(live: &str, recorded: &str) -> bool { + let live = live.strip_suffix('\r').unwrap_or(live); + let recorded = recorded.strip_suffix('\r').unwrap_or(recorded); + if live == recorded { + return true; + } + let (Ok(live), Ok(recorded)) = (parse_entry_line(live), parse_entry_line(recorded)) else { + return false; + }; + if live.key != recorded.key || live.elems.len() != 2 || recorded.elems.len() != 3 { + return false; + } + let live_spec = decode_json_string(&live.elems[0]); + let recorded_spec = decode_json_string(&recorded.elems[0]); + live_spec.is_some() + && live_spec == recorded_spec + && live.elems[1].starts_with('{') + && live.elems[1] == recorded.elems[1] + && decode_json_string(&recorded.elems[2]).is_some_and(|sri| sri.starts_with("sha512-")) +} + +/// Replay helper for a recorded whole-line bun.lock edit whose `new` line +/// is no longer present byte-for-byte: find the ONE live packages line +/// that is the same wiring modulo integrity ([`same_wiring_modulo_integrity`]) +/// and replace it with `original`, keeping the live line's own line ending. +/// +/// `Ok(None)` when no live line matches (the caller keeps its drift +/// refusal); `Err` when more than one does (ambiguous — never guess which +/// instance the edit meant, mirroring the byte-exact replay's duplicate +/// refusal). Line-oriented on a bare `split('\n')` so a CRLF lock keeps +/// every `\r` exactly where it was. +pub(crate) fn restore_digestless_line( + content: &str, + recorded_new: &str, + original: &str, +) -> Result, String> { + let mut lines: Vec<&str> = content.split('\n').collect(); + let matches: Vec = lines + .iter() + .enumerate() + .filter(|(_, line)| same_wiring_modulo_integrity(line, recorded_new)) + .map(|(idx, _)| idx) + .collect(); + match matches.as_slice() { + [] => Ok(None), + [idx] => { + let live_cr = lines[*idx].ends_with('\r'); + let restored = original.strip_suffix('\r').unwrap_or(original); + let restored = if live_cr { + format!("{restored}\r") + } else { + restored.to_string() + }; + lines[*idx] = &restored; + Ok(Some(lines.join("\n"))) + } + _ => Err( + "the digest-less spelling of the redirected entry appears more than once — \ + ambiguous, refusing to guess" + .to_string(), + ), + } +} + #[cfg(test)] mod tests { use super::*; @@ -515,4 +601,141 @@ mod tests { ); } } + + /// Bun 1.1.39–1.3.9 re-save a URL/local tuple as the digest-less + /// 2-tuple; that spelling — and only that spelling — is the same + /// wiring as the recorded 3-tuple. Every other difference is drift. + #[test] + fn same_wiring_modulo_integrity_accepts_only_a_dropped_digest() { + let url = "https://patch.socket.dev/patch/npm/tok/uuid/left-pad-1.3.0.tgz"; + let recorded = format!( + " \"left-pad\": [\"left-pad@{url}\", {{}}, \"sha512-{}==\"],", + "A".repeat(86) + ); + let digestless = format!(" \"left-pad\": [\"left-pad@{url}\", {{}}],"); + assert!(same_wiring_modulo_integrity(&digestless, &recorded)); + // Byte-equal lines match trivially; a trailing `\r` on either side + // (CRLF lock vs LF ledger, or the reverse) is not drift. + assert!(same_wiring_modulo_integrity(&recorded, &recorded)); + assert!(same_wiring_modulo_integrity( + &format!("{recorded}\r"), + &recorded + )); + assert!(same_wiring_modulo_integrity( + &recorded, + &format!("{recorded}\r") + )); + assert!(same_wiring_modulo_integrity( + &format!("{digestless}\r"), + &recorded + )); + assert!(same_wiring_modulo_integrity( + &digestless, + &format!("{recorded}\r") + )); + + // Vendored local-tarball spelling, scoped name (the scope dir stays + // in the leaf, so the spec carries TWO `@`), deps meta object. + let path = ".socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/@scope/pkg-1.0.0.tgz"; + let meta = + r#"{ "dependencies": { "left-pad": "1.3.0" }, "bin": { "scope-pkg": "cli.js" } }"#; + let recorded_scoped = + format!(" \"@scope/pkg\": [\"@scope/pkg@{path}\", {meta}, \"sha512-Q==\"],"); + let digestless_scoped = format!(" \"@scope/pkg\": [\"@scope/pkg@{path}\", {meta}],"); + assert!(same_wiring_modulo_integrity( + &digestless_scoped, + &recorded_scoped + )); + + // Drift, every flavor: another uuid in the URL, another version's + // leaf, another key, a re-laid meta object, a 3-tuple with a + // DIFFERENT digest, a 4-tuple, an unparseable line, and the + // reverse direction (a 3-tuple live line against a 2-tuple record). + let other_uuid = digestless.replace("/uuid/", "/other/"); + assert!(!same_wiring_modulo_integrity(&other_uuid, &recorded)); + let other_version = digestless.replace("left-pad-1.3.0.tgz", "left-pad-1.2.0.tgz"); + assert!(!same_wiring_modulo_integrity(&other_version, &recorded)); + let other_key = digestless.replace("\"left-pad\":", "\"nested/left-pad\":"); + assert!(!same_wiring_modulo_integrity(&other_key, &recorded)); + let other_meta = digestless.replace("{}", "{ \"bin\": \"x\" }"); + assert!(!same_wiring_modulo_integrity(&other_meta, &recorded)); + let other_digest = recorded.replace(&"A".repeat(86), &"B".repeat(86)); + assert!(!same_wiring_modulo_integrity(&other_digest, &recorded)); + let registry = " \"left-pad\": [\"left-pad@1.3.0\", \"\", {}, \"sha512-X==\"],"; + assert!(!same_wiring_modulo_integrity(registry, &recorded)); + assert!(!same_wiring_modulo_integrity( + " \"left-pad\": [", + &recorded + )); + assert!(!same_wiring_modulo_integrity(&recorded, &digestless)); + // A recorded 3-tuple whose third element is not an SRI string is + // not "a tuple that lost its digest" — no relaxation applies. + let non_sri = format!(" \"left-pad\": [\"left-pad@{url}\", {{}}, \"\"],"); + assert!(!same_wiring_modulo_integrity(&digestless, &non_sri)); + // The workspace 2-tuple of a version-0 lock is a 2-tuple too; it + // never equals a tarball record (different spec). + let ws = " \"consumer\": [\"consumer@workspace:packages/consumer\", {}],"; + assert!(!same_wiring_modulo_integrity(ws, &recorded)); + } + + /// The replay side: the one digest-less live line is swapped for the + /// recorded original with the LIVE line's ending kept; no match is + /// `Ok(None)` (the caller refuses as drift), two matches refuse. + #[test] + fn restore_digestless_line_replaces_the_unique_match_and_keeps_eol() { + let url = "https://patch.socket.dev/patch/npm/tok/uuid/left-pad-1.3.0.tgz"; + let original = " \"left-pad\": [\"left-pad@1.3.0\", \"\", {}, \"sha512-X==\"],"; + let recorded_new = format!(" \"left-pad\": [\"left-pad@{url}\", {{}}, \"sha512-A==\"],"); + let digestless = format!(" \"left-pad\": [\"left-pad@{url}\", {{}}],"); + let decoy = " \"abbrev\": [\"abbrev@1.1.1\", \"\", {}, \"sha512-D==\"],"; + let lock = format!( + "{{\n \"lockfileVersion\": 1,\n \"packages\": {{\n {decoy}\n\n{digestless}\n }}\n}}\n" + ); + let restored = restore_digestless_line(&lock, &recorded_new, original) + .unwrap() + .expect("the digest-less line must be found"); + assert_eq!(restored, lock.replace(&digestless, original)); + assert!(restored.contains(decoy), "the decoy is untouched"); + + // CRLF lock against an LF ledger: the restored line takes the + // file's `\r\n`, every other `\r` stays. + let crlf = lock.replace('\n', "\r\n"); + let restored = restore_digestless_line(&crlf, &recorded_new, original) + .unwrap() + .expect("CRLF digest-less line must be found"); + assert_eq!(restored, crlf.replace(&digestless, original)); + assert_eq!( + restored.matches('\n').count(), + restored.matches("\r\n").count() + ); + // LF lock against a CRLF ledger (`\r` recorded on both fragments): + // the restored line stays LF. + let restored = restore_digestless_line( + &lock, + &format!("{recorded_new}\r"), + &format!("{original}\r"), + ) + .unwrap() + .expect("LF digest-less line must be found against a CRLF ledger"); + assert_eq!(restored, lock.replace(&digestless, original)); + assert!(!restored.contains('\r')); + + // Nothing matching (the entry was re-resolved to the registry, or + // another uuid): `Ok(None)`, the caller's drift refusal stands. + let relocked = lock.replace(&digestless, original); + assert_eq!( + restore_digestless_line(&relocked, &recorded_new, original).unwrap(), + None + ); + let other = lock.replace("/uuid/", "/other/"); + assert_eq!( + restore_digestless_line(&other, &recorded_new, original).unwrap(), + None + ); + // Two digest-less instances under the same key text (a hand-duplicated + // line): ambiguous, refuse rather than guess. + let dup = lock.replace(&digestless, &format!("{digestless}\n{digestless}")); + let err = restore_digestless_line(&dup, &recorded_new, original).unwrap_err(); + assert!(err.contains("more than once"), "{err}"); + } } diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-already-wired/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-already-wired/expected-edits.json new file mode 100644 index 00000000..38496bda --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-already-wired/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@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz\", {}],", + "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/digestless-hosted-already-wired/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-already-wired/expected-warnings.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-already-wired/expected-warnings.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-already-wired/expected/bun.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-already-wired/expected/bun.lock new file mode 100644 index 00000000..84ef6d3c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-already-wired/expected/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bun-patch-backtest", + "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/digestless-hosted-already-wired/input/bun.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-already-wired/input/bun.lock new file mode 100644 index 00000000..127db909 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-already-wired/input/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bun-patch-backtest", + "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", {}], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-already-wired/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-already-wired/overrides.json new file mode 100644 index 00000000..2b81bef3 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-already-wired/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/digestless-hosted-stale-url-repin/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-stale-url-repin/expected-edits.json new file mode 100644 index 00000000..8f1b5611 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-stale-url-repin/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@https://patch.socket.dev/patch/npm/22222222-2222-2222-2222-222222222222/66666666-6666-6666-6666-666666666666/left-pad-1.3.0.tgz\", {}],", + "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/digestless-hosted-stale-url-repin/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-stale-url-repin/expected-warnings.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-stale-url-repin/expected-warnings.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-stale-url-repin/expected/bun.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-stale-url-repin/expected/bun.lock new file mode 100644 index 00000000..84ef6d3c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-stale-url-repin/expected/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bun-patch-backtest", + "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/digestless-hosted-stale-url-repin/input/bun.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-stale-url-repin/input/bun.lock new file mode 100644 index 00000000..1f25c0ff --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-stale-url-repin/input/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bun-patch-backtest", + "dependencies": { + "left-pad": "1.3.0", + }, + }, + }, + "packages": { + "left-pad": ["left-pad@https://patch.socket.dev/patch/npm/22222222-2222-2222-2222-222222222222/66666666-6666-6666-6666-666666666666/left-pad-1.3.0.tgz", {}], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-stale-url-repin/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-stale-url-repin/overrides.json new file mode 100644 index 00000000..2b81bef3 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/bun/digestless-hosted-stale-url-repin/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/docs/testing/bun-compatibility.md b/docs/testing/bun-compatibility.md index b3111dff..f34083f6 100644 --- a/docs/testing/bun-compatibility.md +++ b/docs/testing/bun-compatibility.md @@ -158,6 +158,25 @@ runners) from the GitHub releases and verifies it against `SHASUMS256.txt`. Ever 1.3.14; the hermetic suites pin it from both sides (`TARBALL_INTEGRITY_ENFORCED_FROM = (1, 3, 10)` with the 1.1.45 / 1.2.23 / 1.4.2 legs) and the matrix carries 1.3.9 and 1.3.10. +- **Digest-less re-saves.** The same releases (every text-lock Bun below + 1.3.10 — measured on 1.1.45 at lockfileVersion 0, 1.2.23 and 1.3.9) + re-save a URL or local-tarball tuple WITHOUT its `sha512` whenever the lock + is re-saved for another reason: `bun add `, or `bun install` after a + package.json / workspace change (a root rename alone does not re-save; a + frozen install never writes). The 3-tuple comes back as the 2-tuple + `["name@", {meta}]`, spec and meta intact; 1.3.10, 1.3.14 and + 1.4.2 keep the digest. The CLI recognises that spelling as its own wiring: + the repeat hosted run heals it (`redirected: 1`, no + `redirect_bun_entry_not_found`, a second ledger edit whose `original` is + the 2-tuple), the vendored re-run stays `already_vendored` and re-pins the + digest on disk, `repair` rebuilds through it, and `rollback` / scoped + `rollback` / `remove` / `vendor --revert` / both takeovers accept the + digest-less spelling of a recorded line and restore the registry original + over it. Before the fix every one of those refused after any lock re-save + on those releases (`redirect_bun_entry_not_found` beside `redirected: 1`, + `rollback` → `partial_failure`, `vendor_lock_entry_not_found` / + `vendor_lock_entry_drifted`); the `already-vendored-workspace` matrix + shape on 1.2.0–1.3.9 is the regression guard. - **Both lockfiles present.** Bun ≥ 1.1.39 reads `bun.lock` when `bun.lockb` sits beside it; Bun ≤ 1.1.38 reads only `bun.lockb` — which is why the CLI removes a surviving `bun.lockb` after the migration (a stale binary lock @@ -271,6 +290,7 @@ needed for SBOM lockfile annotation. | Version-0 workspace hosted refusal + remedy | `text-workspace` (1.1.39–1.1.45); 1.1.45 `workspace*` after migration | — | golden `lock-v0-workspace-refusal` (+ `expected-warnings.json`), `redirect/mod.rs` unit tests | | Pre-v2 workspace vendored refusal (policy) + remedy; version 2 supported incl. nested | v1: 1.2.0–1.3.14 `workspace*`; v0: `text-workspace`; v2: 1.4.x | `e2e_vendor_bun_build` scoped leg (deps + bin meta survive) | `bun_lock.rs` (`legacy_workspace_tarballs_refuse_before_writes`, in-sync / rebuild exemptions), `in_process_vendor_bun`, `repair_vendor_flavors_e2e` over {0, 1, 2} × workspace shapes | | Digest boundary 1.3.10 (registry tuples 1.2.0) | 1.3.9 vs 1.3.10 cells, `registryDigestEnforced` | tampered twins in both suites, pinned from both sides | — | +| Digest-less re-saves below 1.3.10 recognised, healed and unwound (both modes, takeovers, scoped unwinds, `repair`) | `already-vendored-workspace` on 1.2.0–1.3.9 (`digestDroppedOnResave`; `resaveKeepsDigest` from 1.3.10) | `bun_redirect_survives_a_digest_dropping_lock_resave`, `bun_vendor_survives_a_digest_dropping_lock_resave` (real `file:`-dep re-save; the era's spelling asserted from both sides) | goldens `digestless-hosted-already-wired`, `digestless-hosted-stale-url-repin`; `bun_lock_text.rs` (`same_wiring_modulo_integrity`), `redirect/mod.rs`, `replay.rs`, `takeover.rs`, `bun_lock.rs` unit tests; `in_process_redirect`, `in_process_vendor_bun`, `in_process_vendor_bun_takeover` | | Mode conversion both directions; scoped `rollback` / `remove` | `hosted-then-vendored`, `vendored-then-hosted` | `mode_migration_bun` (1.4.2 × 3 OS, 1.3.14) | `in_process_vendor_bun_takeover`, `takeover.rs`, `covgap_commands_rollback` | | CRLF lockfiles preserved (hosted line, vendored, rollback) | `crlf-lock` | — | golden `lock-v2-crlf`, `bun_lock.rs` | | Bun 0.8.1 / 1.0.0 peer / transitive upstream limitation | recorded per cell | — | — | diff --git a/scripts/backtest-bun.py b/scripts/backtest-bun.py index f6572aea..cb0dbbc0 100644 --- a/scripts/backtest-bun.py +++ b/scripts/backtest-bun.py @@ -541,6 +541,20 @@ def wired_fragments(project, mode): return next(w for w in wiring if w['file'] == 'bun.lock') +def wired_line(text, recorded_new): + """The live bun.lock line carrying the recorded wiring `recorded_new`: + byte-identical, or the digest-less 2-tuple Bun < 1.3.10 re-saves it as + (same `"key": ["spec", {meta}` head, no trailing `"sha512-…"`). None when + neither spelling is present.""" + if recorded_new in text: + return recorded_new + head = recorded_new[:recorded_new.rfind(', "sha512-')] + for line in text.splitlines(): + if line.startswith(head) and line[len(head):] in (']', '],'): + return line + return None + + def crlf_only(data): return all(line.endswith(b'\r\n') for line in data.splitlines(keepends=True) if line.strip()) @@ -723,14 +737,20 @@ def install(binary, label, flags=(), cache=None): checks['memberInstall'] = code == 0 and 'workspace:packages/consumer' in text wiring = wired_fragments(project, pre_mode) # Bun < 1.3.10 re-saves URL/local tarball tuples WITHOUT - # their sha512 (a 2-tuple), which the CLI then no longer - # recognizes as its own wiring: re-runs and rollback break. - checks['wiringSurvivesInstall'] = wiring['new'] in text + # their sha512 (the 2-tuple `["name@", {meta}]`); + # 1.3.10+ keep the 3-tuple. Either spelling is the CLI's + # own wiring (the spec bun installs from is intact): the + # re-run heals the digest and rollback unwinds both. + live_wired = wired_line(text, wiring['new']) + checks['wiringSurvivesInstall'] = live_wired is not None + row['digestDroppedOnResave'] = live_wired != wiring['new'] + if ver(version) >= TARBALL_INTEGRITY_ENFORCED_FROM: + checks['resaveKeepsDigest'] = live_wired == wiring['new'] # Rollback restores the wired line only: the pristine lock # is the grown lock with the registry line put back. original = {name: (project / name).read_bytes() for name in files} original['bun.lock'] = lock.read_bytes().replace( - wiring['new'].encode(), wiring['original'].encode()) + (live_wired or wiring['new']).encode(), wiring['original'].encode()) command = cli_command(get_verb(shape), main_mode) code, output = run(command, project, env, case / 'cli.log', False) From aa8e1ca3a7b9dd4628bddebdfa747cd90f7dda0a Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 21 Sep 2026 13:00:00 -0400 Subject: [PATCH 33/49] fix(bun): name the sibling lock a stale bun.lockb shadows in the discovery diagnosis The npm flavor router checks bun.lockb before pnpm/yarn/npm locks, so a project that migrated away from bun and left a stale bun.lockb committed loses lockfile-only discovery of its live lock. The `bun_lockb_unsupported` detail told that project to run `bun install --save-text-lockfile`, which would create a bun.lock for a non-bun project and never named the shadowed lock or the real remedy (delete the debris). The bun.lockb arm of `inventory_npm_lock` now probes for a sibling pnpm-lock.yaml / yarn.lock / npm-shrinkwrap.json / package-lock.json (router precedence) and, when one exists, phrases the detail as "shadows in lockfile discovery; delete the stale bun.lockb if 's installer is in use, or run `bun install --save-text-lockfile` (Bun >= 1.1.39) if bun is". Code and fail-closed no-inventory posture unchanged; the lockb-only text is unchanged. Unit test beside the stale-lockb tests covers all four sibling kinds and the precedence. Finding: VC-4 (PR #245 final review). Co-Authored-By: Claude Fable 5.1 --- .../src/vendor/lock_inventory.rs | 122 +++++++++++++++++- 1 file changed, 117 insertions(+), 5 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/lock_inventory.rs b/crates/socket-patch-core/src/vendor/lock_inventory.rs index b06a1d67..aadbcf62 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory.rs @@ -130,14 +130,51 @@ pub struct UnsupportedNpmLayout { /// its own `vendor_bun_lockb_unsupported` code. pub const BUN_LOCKB_UNSUPPORTED_CODE: &str = "bun_lockb_unsupported"; -/// Inventory-phrased detail for [`BUN_LOCKB_UNSUPPORTED_CODE`]: what was -/// NOT discovered and the one remedy (the flag exists from Bun 1.1.39; -/// plain `bun install` on any release keeps an in-sync bun.lockb as-is). +/// Inventory-phrased detail for [`BUN_LOCKB_UNSUPPORTED_CODE`] when +/// bun.lockb is the project's ONLY lockfile: what was NOT discovered and +/// the one remedy (the flag exists from Bun 1.1.39; plain `bun install` on +/// any release keeps an in-sync bun.lockb as-is). pub const BUN_LOCKB_UNSUPPORTED_INVENTORY_DETAIL: &str = "bun.lockb is bun's legacy binary lockfile and cannot be inventoried; run `bun install \ --save-text-lockfile` (Bun >= 1.1.39) so lockfile-only discovery and vendored mode can \ read it"; +/// The sibling-aware variant of [`BUN_LOCKB_UNSUPPORTED_INVENTORY_DETAIL`]: +/// the flavor router checks bun.lockb BEFORE pnpm/yarn/npm locks +/// ([`detect_npm_lock_flavor`]), so a project that migrated from bun and +/// left a stale bun.lockb committed loses lockfile-only discovery of its +/// LIVE `sibling` lock. Telling that project to run `bun install +/// --save-text-lockfile` would create a bun.lock for a non-bun project; the +/// real remedy is deleting the debris, so the detail names the shadowed +/// lock and offers both, keyed on which installer is actually in use. The +/// `--save-text-lockfile` / `1.1.39` substrings stay so consumers grepping +/// for the remedy keep matching. +pub fn bun_lockb_shadows_sibling_detail(sibling: &str) -> String { + format!( + "bun.lockb is bun's legacy binary lockfile and shadows {sibling} in lockfile \ + discovery; delete the stale bun.lockb if {sibling}'s installer is in use, or run \ + `bun install --save-text-lockfile` (Bun >= 1.1.39) if bun is" + ) +} + +/// The first recognised non-bun lockfile beside a bun.lockb, in the flavor +/// router's precedence (pnpm, yarn, npm — [`detect_npm_lock_flavor`] steps +/// 3–5): the lock the router WOULD have chosen had bun.lockb not +/// pre-empted it. `None` when bun.lockb is the only lock. +async fn bun_lockb_shadowed_sibling(root: &Path) -> Option<&'static str> { + for name in [ + "pnpm-lock.yaml", + "yarn.lock", + "npm-shrinkwrap.json", + "package-lock.json", + ] { + if tokio::fs::metadata(root.join(name)).await.is_ok() { + return Some(name); + } + } + None +} + /// Inventory the project's npm-family lockfile. Routes by /// [`detect_npm_lock_flavor`]. `Ok(None)` means there is nothing to /// inventory (missing lockfile, dep-less locks); `Err` propagates the @@ -179,11 +216,22 @@ pub(crate) async fn inventory_npm_lock( // here made `scan` print a clean `scannedPackages: 0` success // in every mode (54 lockfile-only matrix cells at bun <= // 1.1.45). Own code + inventory-phrased remedy; the probe's - // vendor-phrased text stays with the vendor refusal. + // vendor-phrased text stays with the vendor refusal. When a + // pnpm/yarn/npm lock sits beside the bun.lockb the project has + // most likely migrated AWAY from bun and the binary lock is + // stale debris shadowing the live lock (router precedence) — + // the detail then names that lock and the delete remedy + // instead of prescribing a bun.lock for a non-bun project. + // Still fail-closed: the shadowed sibling is NOT inventoried + // (which installer is live is the user's call, not ours). if code == "vendor_bun_lockb_unsupported" { + let detail = match bun_lockb_shadowed_sibling(project_root).await { + Some(sibling) => bun_lockb_shadows_sibling_detail(sibling), + None => BUN_LOCKB_UNSUPPORTED_INVENTORY_DETAIL.to_string(), + }; return Err(UnsupportedNpmLayout { code: BUN_LOCKB_UNSUPPORTED_CODE, - detail: BUN_LOCKB_UNSUPPORTED_INVENTORY_DETAIL.to_string(), + detail, }); } // The flavor probe passes only pnpm locks the WIRING backends @@ -2667,6 +2715,70 @@ packages: ); } + /// The converse migration (bun → pnpm/yarn/npm, stale bun.lockb left + /// committed): the router still refuses on bun.lockb, so the LIVE + /// sibling lock is not inventoried (fail-closed, unchanged) — but the + /// diagnosis must name the shadowed lock and the delete remedy instead + /// of telling a non-bun project to write a bun.lock. The + /// `--save-text-lockfile` remedy stays as the bun-is-live alternative. + /// Sibling precedence follows the router (pnpm, yarn, npm). + #[tokio::test] + async fn bun_lockb_beside_a_live_sibling_lock_names_the_shadowed_lock() { + for (files, sibling) in [ + (vec![("pnpm-lock.yaml", PNPM_LOCK)], "pnpm-lock.yaml"), + (vec![("yarn.lock", YARN_CLASSIC)], "yarn.lock"), + ( + vec![("package-lock.json", PACKAGE_LOCK)], + "package-lock.json", + ), + ( + vec![ + ("npm-shrinkwrap.json", PACKAGE_LOCK), + ("package-lock.json", PACKAGE_LOCK), + ], + "npm-shrinkwrap.json", + ), + ( + vec![ + ("yarn.lock", YARN_CLASSIC), + ("package-lock.json", PACKAGE_LOCK), + ], + "yarn.lock", + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + for (name, content) in &files { + write(tmp.path(), name, content).await; + } + write(tmp.path(), "bun.lockb", "\0binary").await; + let diag = inventory_npm_lock(tmp.path()).await.unwrap_err(); + assert_eq!(diag.code, BUN_LOCKB_UNSUPPORTED_CODE, "{sibling}"); + assert_eq!( + diag.detail, + bun_lockb_shadows_sibling_detail(sibling), + "{sibling}" + ); + assert!( + diag.detail + .contains(&format!("shadows {sibling} in lockfile discovery")) + && diag.detail.contains("delete the stale bun.lockb") + && diag.detail.contains("bun install --save-text-lockfile") + && diag.detail.contains("1.1.39"), + "{sibling}: {}", + diag.detail + ); + assert!( + !diag.detail.contains("cannot be inventoried"), + "{sibling}: the lockb-only phrasing must not leak: {}", + diag.detail + ); + // Fail-closed posture unchanged: nothing inventoried. + let (entries, unsupported) = inventory_project_diagnosed(tmp.path()).await; + assert!(entries.is_empty(), "{sibling}: {entries:?}"); + assert_eq!(unsupported, vec![diag], "{sibling}"); + } + } + /// A bun.lockb-only project (bun <= 1.1.38, or 1.1.39–1.1.45 without /// `--save-text-lockfile`) is a diagnosis, never a silent `None`: scan /// rides it onto `warnings[]` instead of reporting a clean empty From df874939e41419909f113c18c7fa8afbd23f35e5 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 21 Sep 2026 13:00:00 -0400 Subject: [PATCH 34/49] fix(bun): version-specific hosted remedy for pre-v2 workspace locks; add wired_instances_all_ours `check_workspace_compatibility` always ended its remedy with "or use `--mode hosted`, which accepts version-1 workspace locks" - including for the lockfileVersion-0 lock it had just named. Hosted mode refuses every v0 workspace lock (`redirect_bun_workspace_unsupported`), so a Bun 1.1.39-1.1.45 user following the alternative hit a second refusal with a different remedy. The tail is now version-specific: v1 keeps the hosted pointer; v0 says "or delete bun.lock, re-lock with Bun >= 1.2 (which writes lockfileVersion 1) and use `--mode hosted`" (an in-place `bun install` does not reliably bump a v0 workspace lock). `assert_workspace_remedy` asserts the exact tail per version. New `pub async fn wired_instances_all_ours(project_root, purl)`: whether bun.lock already wires EVERY packages entry resolving the purl's `name@version` to one of our `.socket/vendor/npm/` tuples (any uuid, 3-tuple or the digest-less 2-tuple Bun < 1.3.10 re-saves). This is the engine's own criterion for skipping the workspace gate, exposed so the CLI's pre-download preflight can exempt exactly what the engine would let through (a superseding-uuid patch update, a wiped ledger) instead of refusing with a remedy Bun 1.2/1.3 teams cannot follow. `preflight_vendor`'s doc comment now states the real exemption rule (ledger same-uuid OR lock all-ours). Findings: VC-3, VC-2 core half (PR #245 final review). Co-Authored-By: Claude Fable 5.1 --- .../socket-patch-core/src/vendor/bun_lock.rs | 187 +++++++++++++++++- 1 file changed, 180 insertions(+), 7 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index c3741ac5..6bf845c2 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -99,6 +99,16 @@ pub(crate) const BUN_LOCKB_UNSUPPORTED_DETAIL: &str = /// at version 1; only deleting bun.lock and re-locking writes 2 — so the /// remedy says exactly that instead of the non-converging "upgrade and run /// `bun install`". +/// +/// The hosted alternative in the remedy is VERSION-SPECIFIC: hosted mode +/// accepts a version-1 workspace lock (a URL tuple has no path to resolve) +/// but refuses a version-0 one (`redirect_bun_workspace_unsupported`, +/// `redirect/mod.rs`), so pointing a Bun 1.1.39–1.1.45 user at `--mode +/// hosted` as-is would only earn them a second refusal with a different +/// remedy. A v0 lock must be re-locked with Bun >= 1.2 (which writes +/// version 1) before hosted mode can take it — and that means DELETING the +/// lock first: measured on this lock shape, an in-place `bun install` keeps +/// v0 on 1.2.0 and fails to resolve on 1.3.14/1.4.2. fn check_workspace_compatibility( text: &str, entries: &[BunEntry], @@ -110,6 +120,12 @@ fn check_workspace_compatibility( if version >= 2 || !has_workspace_packages(entries) { return Ok(()); } + let hosted_alternative = if version == 0 { + "or delete bun.lock, re-lock with Bun >= 1.2 (which writes lockfileVersion 1) and use \ + `--mode hosted`" + } else { + "or use `--mode hosted`, which accepts version-1 workspace locks" + }; Err(( "vendor_bun_workspace_unsupported", format!( @@ -117,8 +133,7 @@ fn check_workspace_compatibility( the workspace member, and a lockfileVersion-{version} lock may still be installed \ by such a release; delete bun.lock and re-run `bun install` with Bun >= 1.4 (which \ writes lockfileVersion 2) before vendoring — an in-place `bun install` keeps the \ - existing lockfileVersion — or use `--mode hosted`, which accepts version-1 \ - workspace locks" + existing lockfileVersion — {hosted_alternative}" ), )) } @@ -128,9 +143,14 @@ fn check_workspace_compatibility( /// /// PROJECT-LEVEL: this cannot see per-purl state, so it refuses a pre-v2 /// workspace lock even when the purl in question is already vendored in -/// it (the CLI exempts already-vendored purls before calling it); -/// [`vendor_bun`] itself gates per classified instance and lets in-sync -/// re-runs and `repair` rebuilds through. +/// it. The CLI exempts a purl from this refusal before acting on it when +/// EITHER its vendor ledger already wires the purl at the uuid the run +/// selected (an in-sync re-run) OR [`wired_instances_all_ours`] reports +/// that every lock instance of the purl is already one of our tuples — the +/// same criterion [`vendor_bun`] applies per classified instance, so that +/// in-sync re-runs, superseding-uuid re-vendors and `repair` rebuilds of a +/// project vendored before it grew a workspace member all reach the +/// engine instead of dying here. pub async fn preflight_vendor(project_root: &Path) -> Result<(), (&'static str, String)> { let path = project_root.join(BUN_LOCK); let text = match read_regular_to_string(&path).await { @@ -153,6 +173,54 @@ pub async fn preflight_vendor(project_root: &Path) -> Result<(), (&'static str, check_workspace_compatibility(&text, &entries) } +/// Whether `bun.lock` already wires EVERY packages entry resolving the npm +/// `purl`'s `name@version` to one of our `.socket/vendor/npm/` tarballs — +/// any uuid, 3-tuple or the digest-less 2-tuple Bun < 1.3.10 re-saves it +/// as (see [`classify`]). `Ok(false)` when no entry resolves the purl at +/// all (a fresh vendor, or a hosted URL tuple the takeover has yet to +/// revert) or when any resolving instance is still the registry tuple. +/// +/// This is the per-purl half of the vendored preflight: [`preflight_vendor`] +/// is project-level and refuses every pre-v2 workspace lock, whereas +/// [`vendor_bun`] skips that gate whenever the instances it would rewrite +/// are already ours — rewriting an `Ours` tuple to another uuid adds no new +/// workspace-relative path, so a superseding patch on a project vendored +/// before it grew a workspace member re-vendors in place, and a `repair` +/// rebuild proceeds. The CLI consults this so its pre-download refusal +/// exempts exactly what the engine would let through, ledger or no ledger +/// (a wiped `state.json` used to turn every such update into a false +/// `vendor_bun_workspace_unsupported`). +/// +/// `Err` mirrors [`preflight_vendor`]'s codes (unreadable lock, unsupported +/// version, out-of-grammar packages section); a purl that is not an npm +/// `name@version` yields `Ok(false)` — nothing in the lock can be ours. +pub async fn wired_instances_all_ours( + project_root: &Path, + purl: &str, +) -> Result { + let Some((name, version)) = super::npm_common::parse_npm_purl(purl) else { + return Ok(false); + }; + let text = read_regular_to_string(&project_root.join(BUN_LOCK)) + .await + .map_err(|error| ("vendor_lockfile_missing", error.to_string()))?; + check_lock_version(&text).map_err(|detail| ("vendor_lockfile_version_unsupported", detail))?; + let lines = text.split('\n').map(str::to_string).collect::>(); + let entries = parse_packages_section(&lines) + .map_err(|detail| ("vendor_lockfile_version_unsupported", detail))?; + let target_spec = format!("{name}@{version}"); + let target_leaf = tgz_rel_leaf(&name, &version); + let mut matched = 0usize; + for entry in &entries { + match classify(entry, &target_spec, &name, &target_leaf) { + Some(TupleShape::Ours { .. }) => matched += 1, + Some(TupleShape::Registry) => return Ok(false), + None => {} + } + } + Ok(matched > 0) +} + /// Vendor one installed npm package into a bun project (see the module doc). /// Same contract as `npm_lock::vendor_npm`: refuse-early / wire-last, /// `entry` present iff `result.success` and not a dry run, and an in-sync @@ -1492,7 +1560,10 @@ mod tests { /// The converging remedy: names the lock's version, says to DELETE the /// lock (an in-place `bun install` keeps the version), and offers - /// hosted mode. + /// hosted mode in a VERSION-SPECIFIC tail — hosted accepts a v1 + /// workspace lock as-is but refuses a v0 one, so the v0 tail must say + /// to re-lock with Bun >= 1.2 first instead of sending the user into a + /// second refusal. fn assert_workspace_remedy(detail: &str, version: u64) { assert!(detail.contains("Bun releases before 1.4"), "{detail}"); assert!( @@ -1504,7 +1575,22 @@ mod tests { detail.contains("in-place `bun install` keeps the existing lockfileVersion"), "{detail}" ); - assert!(detail.contains("--mode hosted"), "{detail}"); + let tail = if version == 0 { + "— or delete bun.lock, re-lock with Bun >= 1.2 (which writes lockfileVersion 1) and \ + use `--mode hosted`" + } else { + "— or use `--mode hosted`, which accepts version-1 workspace locks" + }; + assert!( + detail.ends_with(tail), + "v{version}: the hosted alternative must be version-specific:\n{detail}" + ); + if version == 0 { + assert!( + !detail.contains("accepts version-1"), + "a v0 lock must not be told hosted accepts it as-is: {detail}" + ); + } assert!( !detail.contains("upgrade to Bun"), "the non-converging remedy must be gone: {detail}" @@ -1662,6 +1748,93 @@ mod tests { } } + /// The CLI's lock-derived exemption mirrors the engine's gate: on the + /// upgrade shape (vendored, then a workspace member added) every + /// instance is ours — at the recorded uuid, at a superseding uuid the + /// ledger has never seen, and in the digest-less 2-tuple spelling — so + /// the project-level refusal still fires but the purl is exempt; a + /// fresh registry instance and a purl the lock does not resolve are not. + #[tokio::test] + async fn wired_instances_all_ours_mirrors_the_engine_gate() { + const PURL: &str = "pkg:npm/left-pad@1.3.0"; + for version in [0u64, 1] { + let (fx, entry, lock) = vendored_then_workspace_added(version).await; + assert_eq!( + preflight_vendor(fx.root()).await.unwrap_err().0, + "vendor_bun_workspace_unsupported", + "v{version}: the project-level gate stays blanket" + ); + assert_eq!( + wired_instances_all_ours(fx.root(), PURL).await, + Ok(true), + "v{version}: the vendored tuple is ours" + ); + // Another version of the same package is someone else's edit. + assert_eq!( + wired_instances_all_ours(fx.root(), "pkg:npm/left-pad@1.2.0").await, + Ok(false), + "v{version}: an unresolved purl is not exempt" + ); + assert_eq!( + wired_instances_all_ours(fx.root(), "pkg:pypi/left-pad@1.3.0").await, + Ok(false), + "v{version}: a non-npm purl can never be ours" + ); + // The digest-less re-save (Bun < 1.3.10) is still our wiring. + let (_, digestless) = digestless_lock(&lock, &entry); + tokio::fs::write(fx.root().join(BUN_LOCK), &digestless) + .await + .unwrap(); + assert_eq!( + wired_instances_all_ours(fx.root(), PURL).await, + Ok(true), + "v{version}: the digest-less 2-tuple is ours" + ); + // A superseding uuid: the ledger would not match, the lock does. + let other_uuid = "0a1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d"; + tokio::fs::write(fx.root().join(BUN_LOCK), lock.replace(UUID, other_uuid)) + .await + .unwrap(); + assert_eq!( + wired_instances_all_ours(fx.root(), PURL).await, + Ok(true), + "v{version}: any uuid of ours counts" + ); + + // A fresh registry instance is exactly what the gate refuses. + let fresh = fixture_with( + &as_workspace_lock(BN3_BEFORE_LOCK, version), + "node_modules/left-pad", + ) + .await; + assert_eq!( + wired_instances_all_ours(fresh.root(), PURL).await, + Ok(false), + "v{version}: a registry tuple would be rewritten" + ); + } + + // Unreadable or unsupported locks mirror `preflight_vendor`'s codes. + let root = tempfile::tempdir().unwrap(); + assert_eq!( + wired_instances_all_ours(root.path(), PURL) + .await + .unwrap_err() + .0, + "vendor_lockfile_missing" + ); + tokio::fs::write(root.path().join(BUN_LOCK), "{}") + .await + .unwrap(); + assert_eq!( + wired_instances_all_ours(root.path(), PURL) + .await + .unwrap_err() + .0, + "vendor_lockfile_version_unsupported" + ); + } + /// A version-0 head (real bun 1.1.45 shape: no `configVersion`) vendors /// with the exact BN3 transform and reverts byte-exactly. #[tokio::test] From 86f8a55ee966de1f2d32f7b6862126f692893f71 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 21 Sep 2026 13:00:00 -0400 Subject: [PATCH 35/49] fix(bun): gate the vendor takeover on the shared vendored preflight; keep bun.lockb warning on hosted scans Bun vendored preflight moved out of get.rs into a shared `commands/bun_preflight.rs` used by get.rs, scan/vendor_flow.rs AND vendor.rs, with three behaviour changes: * takeover-1 / DC-1 / takeover-2 (P1 regression): `vendor_records` ran the hosted->vendored takeover (wet `revert_redirect_purl` + `persist_redirect_state`) BEFORE `dispatch_vendor_one`, and the plain `vendor` command never ran the Bun preflight. Over a hosted-wired lockfileVersion-0/1 workspace bun.lock (hosted accepts it, the vendored backend refuses it) that stripped the live hosted redirect, deleted the ledger record, then failed `vendor_bun_workspace_unsupported` - unpatched in both modes, with a remedy pointing at the mode it had just destroyed. The dry run promised the takeover (`vendor_would_revert_redirect`, status success). The preflight now runs once per run before the dispatch loop and refuses per candidate BEFORE the takeover block, dry and wet alike: same `failed` event/code/detail the engine would emit, hosted wiring, ledger and lock byte-untouched, exit 1 on both. * VC-2 (P2): the already-vendored exemption was ledger+same-uuid only, so a superseding patch uuid on a project vendored before it grew a workspace member (or the same project with a wiped state.json) was refused at download while the engine would re-vendor in place. A purl is now exempt when EITHER the vendor ledger wires it at the selected uuid OR `wired_instances_all_ours` says every lock instance is already ours. * GCP-2 (P3): all three `load_state` sites (uuid-path preflight, detached download, dry-run preview) flattened an unreadable ledger into an empty one and reported a Bun lock remedy. They now hand the load outcome to the preflight as a Result; an Err yields `vendor_state_unreadable` with the io/parse detail (fail-closed, nothing exempt). Scan changes in the same file set: * VC-1 (P2 fail-open): scan/mod.rs dropped the discovery `bun_lockb_unsupported` warning on every non-empty hosted run, but the hosted driver only speaks about bun.lockb when an npm override is granted (decided inside `run_redirect`, which owns the envelope from there). The retain is gone: the warning stays in EVERY mode on both paths; nothing is deduplicated. * GCP-1 (P3): `print_dry_run_refusals` moved next to `preview_vendor_json` in vendor_flow.rs as pub(crate); scan's interactive `--mode vendored --dry-run` arm now prints the same `[would-refuse] (): ` lines as get, under the same `!silent` gate. Tests: in_process_vendor_bun_takeover.rs scenario 5 (hosted v1 workspace -> vendor dry+wet refuse before un-hosting; v2 twin still takes over); in_process_vendor_bun.rs superseding-uuid re-vendor, wiped-ledger not-refused, corrupt-ledger -> vendor_state_unreadable on uuid/human/dry-run/ detached; covgap_commands_scan_mod.rs hosted arm flipped to keep the warning + human scan dry-run [would-refuse] line; bun_preflight.rs unit tests. Verified end to end with real Bun 1.3.14 and the production minimist patch: scan --mode hosted -> get -> vendor exits 1, lock still hosted, cold-cache frozen install installs the patched bytes. Co-Authored-By: Claude Fable 5.1 --- .../src/commands/bun_preflight.rs | 403 ++++++++++++++++++ crates/socket-patch-cli/src/commands/get.rs | 219 +--------- crates/socket-patch-cli/src/commands/mod.rs | 1 + .../socket-patch-cli/src/commands/scan/mod.rs | 44 +- .../src/commands/scan/vendor_flow.rs | 64 ++- .../socket-patch-cli/src/commands/vendor.rs | 59 ++- .../tests/covgap_commands_scan_mod.rs | 119 +++++- .../tests/in_process_vendor_bun.rs | 182 ++++++++ .../tests/in_process_vendor_bun_takeover.rs | 188 ++++++++ 9 files changed, 1035 insertions(+), 244 deletions(-) create mode 100644 crates/socket-patch-cli/src/commands/bun_preflight.rs diff --git a/crates/socket-patch-cli/src/commands/bun_preflight.rs b/crates/socket-patch-cli/src/commands/bun_preflight.rs new file mode 100644 index 00000000..aadb164b --- /dev/null +++ b/crates/socket-patch-cli/src/commands/bun_preflight.rs @@ -0,0 +1,403 @@ +//! The Bun vendored-mode preflight shared by EVERY path that feeds the +//! vendor engine: `scan --mode vendored` (the manifest-tracked AND the +//! `--detached` download phases), `get … --mode vendored` (search and uuid +//! paths), their `--dry-run` previews, and the `vendor` command's engine +//! loop itself ([`crate::commands::vendor::vendor_records`], where it runs +//! BEFORE the hosted→vendored takeover reverts anything). +//! +//! One read-only [`preflight_vendor`] per run, evaluated before any +//! `/patches/view/` fetch and before any write, so an incompatible Bun +//! project (binary `bun.lockb` without a text lock, an unreadable lock, an +//! unsupported `lockfileVersion`, a pre-version-2 `workspace:` lock) never +//! has a patch downloaded on its behalf — let alone recorded in the +//! manifest, or its live hosted redirect stripped — and every entry point +//! reports the SAME vendor code the engine would have emitted. +//! +//! [`preflight_vendor`]: socket_patch_core::vendor::bun_lock::preflight_vendor + +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use socket_patch_core::api::types::PatchSearchResult; +use socket_patch_core::utils::purl::strip_purl_qualifiers; +use socket_patch_core::vendor::state::VendorEntry; +use socket_patch_core::vendor::{load_state, lookup_entry}; + +/// The vendor ledger as the preflight consumes it: the caller's own +/// `load_state` outcome, so an UNREADABLE ledger is a fact the refusal can +/// report instead of a silently-emptied exemption set. +pub(crate) type LedgerLoad<'a> = Result<&'a HashMap, &'a std::io::Error>; + +/// The outcome of the preflight when the project is refused. +/// +/// `exempt` holds the selected purls the refusal must NOT pre-empt — they +/// flow through to the engine, which lets them in exactly as on a non-Bun +/// project. A purl is exempt when EITHER +/// +/// * the vendor ledger already wires it at the SAME uuid this run selected +/// (an in-sync re-run: the engine's `already_vendored` skip), OR +/// * `bun.lock` already wires EVERY instance of its `name@version` to one +/// of our `.socket/vendor/npm/` tuples, at any uuid +/// ([`wired_instances_all_ours`]) — the engine's own criterion for +/// skipping the workspace gate: rewriting an already-local tuple to a +/// superseding uuid adds no new workspace-relative path, so a patch +/// UPDATE on a project vendored before it grew a workspace member (or +/// the same re-run after a wiped `state.json`) re-vendors in place +/// instead of dying here with a remedy Bun 1.2/1.3 teams cannot follow. +/// +/// An unreadable ledger exempts nothing (fail closed) and the refusal +/// itself becomes `vendor_state_unreadable` with the io/parse detail: +/// the vendor step does NOT reliably report the corrupt ledger itself +/// (`get --mode vendored` returns before it runs, and the +/// scan/search paths reach it only when the manifest already holds another +/// vendorable record), so the one refusal this run emits has to name the +/// real problem rather than send the operator off to re-lock bun.lock. +/// +/// [`wired_instances_all_ours`]: socket_patch_core::vendor::bun_lock::wired_instances_all_ours +pub(crate) struct BunVendorRefusal { + /// The stable vendor error code (`vendor_bun_lockb_unsupported`, + /// `vendor_lockfile_missing`, `vendor_lockfile_version_unsupported`, + /// `vendor_bun_workspace_unsupported`) — the same string the vendor + /// engine would have emitted as a `failed` event — or + /// `vendor_state_unreadable` when the ledger could not be read. + pub(crate) code: &'static str, + /// The engine's (or the ledger loader's) human-readable detail, relayed + /// verbatim. + pub(crate) detail: String, + exempt: HashSet, +} + +impl BunVendorRefusal { + /// Whether the refusal applies to `purl`: npm-family only (no other + /// ecosystem's backend consults `bun.lock`), minus the already-vendored + /// exemption. + pub(crate) fn applies_to(&self, purl: &str) -> bool { + purl.starts_with("pkg:npm/") && !self.exempt.contains(purl) + } +} + +/// Run the Bun preflight once for `selected` — only when it holds at least +/// one npm purl, since nothing else can be affected — loading the vendor +/// ledger at `cwd` for the exemption. `None` means nothing to refuse. +pub(crate) async fn bun_vendor_preflight( + cwd: &Path, + selected: &[PatchSearchResult], +) -> Option { + let pairs = selection_pairs(selected); + if !pairs.iter().any(|(purl, _)| purl.starts_with("pkg:npm/")) { + return None; + } + let (code, detail) = socket_patch_core::vendor::bun_lock::preflight_vendor(cwd) + .await + .err()?; + // Loaded only once the project is known to refuse: an accepted project + // never touches the ledger here (the vendor step owns it). + let ledger = load_state(cwd).await; + Some( + refusal_with_exemptions( + cwd, + code, + detail, + &pairs, + ledger.as_ref().map(|s| &s.entries), + ) + .await, + ) +} + +/// [`bun_vendor_preflight`] for callers that already loaded the ledger (the +/// detached download phase, the dry-run preview), handed the load outcome +/// so an unreadable ledger reports as such. +pub(crate) async fn bun_vendor_preflight_with_ledger( + cwd: &Path, + selected: &[PatchSearchResult], + ledger: LedgerLoad<'_>, +) -> Option { + bun_vendor_preflight_pairs(cwd, &selection_pairs(selected), ledger).await +} + +/// The preflight over bare `(purl, uuid)` pairs — the `vendor` command's +/// view of its selection (manifest records, not search results) — with a +/// caller-loaded ledger. `None` when no npm purl is selected or the +/// project is accepted. +pub(crate) async fn bun_vendor_preflight_pairs( + cwd: &Path, + pairs: &[(&str, &str)], + ledger: LedgerLoad<'_>, +) -> Option { + if !pairs.iter().any(|(purl, _)| purl.starts_with("pkg:npm/")) { + return None; + } + let (code, detail) = socket_patch_core::vendor::bun_lock::preflight_vendor(cwd) + .await + .err()?; + Some(refusal_with_exemptions(cwd, code, detail, pairs, ledger).await) +} + +fn selection_pairs(selected: &[PatchSearchResult]) -> Vec<(&str, &str)> { + selected + .iter() + .map(|s| (s.purl.as_str(), s.uuid.as_str())) + .collect() +} + +/// Turn the engine's project-level refusal into the per-purl verdict: the +/// ledger-or-lock exemption described on [`BunVendorRefusal`], or the +/// `vendor_state_unreadable` refusal when the ledger cannot be read. +async fn refusal_with_exemptions( + cwd: &Path, + code: &'static str, + detail: String, + pairs: &[(&str, &str)], + ledger: LedgerLoad<'_>, +) -> BunVendorRefusal { + let entries = match ledger { + Ok(entries) => entries, + Err(e) => { + return BunVendorRefusal { + code: "vendor_state_unreadable", + detail: e.to_string(), + exempt: HashSet::new(), + }; + } + }; + // The lock-derived exemption exists only for the workspace gate: every + // other preflight code means bun.lock could not be read or parsed, so + // nothing in it can be ours and re-reading it per purl would be wasted + // (guarded, but still) I/O. + let lock_parsed = code == "vendor_bun_workspace_unsupported"; + let mut exempt = HashSet::new(); + for (purl, uuid) in pairs { + if !purl.starts_with("pkg:npm/") { + continue; + } + // The ledger is keyed by the manifest purl (possibly qualified) and + // `lookup_entry` also resolves base purls; try the selected spelling + // first, then its qualifier-free base. + let ledger_in_sync = lookup_entry(entries, purl) + .or_else(|| lookup_entry(entries, strip_purl_qualifiers(purl))) + .is_some_and(|e| e.uuid == *uuid); + let lock_all_ours = lock_parsed + && socket_patch_core::vendor::bun_lock::wired_instances_all_ours(cwd, purl) + .await + .unwrap_or(false); + if ledger_in_sync || lock_all_ours { + exempt.insert((*purl).to_string()); + } + } + BunVendorRefusal { + code, + detail, + exempt, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use socket_patch_core::api::types::VulnerabilityResponse; + + const UUID: &str = "22222222-2222-4222-8222-222222222222"; + const OTHER_UUID: &str = "33333333-3333-4333-8333-333333333333"; + const PURL: &str = "pkg:npm/covgap-bun@1.0.0"; + + fn sel(uuid: &str, purl: &str) -> PatchSearchResult { + PatchSearchResult { + uuid: uuid.into(), + purl: purl.into(), + published_at: "2024-01-01".into(), + description: String::new(), + license: "MIT".into(), + tier: "free".into(), + vulnerabilities: HashMap::::new(), + } + } + + /// A real bun 1.3.14 lockfileVersion-1 workspace lock (matrix capture + /// grammar) resolving `covgap-bun@1.0.0` from the registry. + const BUN_V1_WORKSPACE_LOCK: &str = r#"{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "unit-fixture", + "dependencies": { + "consumer": "workspace:*", + }, + }, + "packages/consumer": { + "name": "consumer", + "version": "1.0.0", + "dependencies": { + "covgap-bun": "1.0.0", + }, + }, + }, + "packages": { + "consumer": ["consumer@workspace:packages/consumer"], + + "covgap-bun": ["covgap-bun@1.0.0", "", {}, "sha512-AAAA=="], + } +} +"#; + + fn seed_bun_vendor_entry(root: &Path, purl: &str, uuid: &str) { + let vendor = root.join(".socket/vendor"); + std::fs::create_dir_all(&vendor).unwrap(); + std::fs::write( + vendor.join("state.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "version": 1, + "entries": { purl: { + "ecosystem": "npm", + "basePurl": purl, + "uuid": uuid, + "artifact": { + "path": format!(".socket/vendor/npm/{uuid}/covgap-bun-1.0.0.tgz"), + }, + "wiring": [], + "flavor": "bun", + }} + })) + .unwrap(), + ) + .unwrap(); + } + + /// The lock as it reads once `covgap-bun` is vendored at `uuid`. + fn vendored_lock(uuid: &str) -> String { + BUN_V1_WORKSPACE_LOCK.replace( + r#"["covgap-bun@1.0.0", "", {}, "sha512-AAAA=="]"#, + &format!( + r#"["covgap-bun@.socket/vendor/npm/{uuid}/covgap-bun-1.0.0.tgz", {{}}, "sha512-OURS=="]"# + ), + ) + } + + /// `bun_vendor_preflight` never reads the lock when nothing selected is + /// npm (no needless I/O, no spurious refusal for other ecosystems); an + /// in-sync ledger entry exempts; an unreadable ledger exempts nothing + /// (fail closed) AND is reported as the real problem + /// (`vendor_state_unreadable`), never as a Bun lock remedy. + #[tokio::test] + async fn preflight_scope_and_corrupt_ledger() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lockb"), b"\x00binary").unwrap(); + let pypi = vec![sel( + "11111111-1111-4111-8111-111111111111", + "pkg:pypi/only@1.0.0", + )]; + assert!( + bun_vendor_preflight(tmp.path(), &pypi).await.is_none(), + "no npm purl selected => no refusal" + ); + + let npm = vec![sel(UUID, PURL)]; + let refusal = bun_vendor_preflight(tmp.path(), &npm) + .await + .expect("lockb-only project is refused"); + assert_eq!(refusal.code, "vendor_bun_lockb_unsupported"); + assert!(refusal.applies_to(PURL)); + assert!(!refusal.applies_to("pkg:pypi/only@1.0.0")); + + // Exempt when the ledger wires this purl at this uuid… + seed_bun_vendor_entry(tmp.path(), PURL, UUID); + let refusal = bun_vendor_preflight(tmp.path(), &npm).await.unwrap(); + assert_eq!(refusal.code, "vendor_bun_lockb_unsupported"); + assert!(!refusal.applies_to(PURL), "in-sync ledger entry is exempt"); + + // …but a corrupt ledger exempts nothing and names itself. + std::fs::write(tmp.path().join(".socket/vendor/state.json"), b"{ not json").unwrap(); + let refusal = bun_vendor_preflight(tmp.path(), &npm).await.unwrap(); + assert_eq!( + refusal.code, "vendor_state_unreadable", + "the refusal must name the ledger, not the lock: {}", + refusal.detail + ); + assert!( + refusal.detail.contains("state.json"), + "the io/parse detail names the file: {}", + refusal.detail + ); + assert!( + refusal.applies_to(PURL), + "an unreadable ledger must not exempt (fail closed)" + ); + // The ledger-passing variant reports the same. + let err = std::io::Error::other("corrupt state.json: synthetic"); + let refusal = bun_vendor_preflight_with_ledger(tmp.path(), &npm, Err(&err)) + .await + .unwrap(); + assert_eq!(refusal.code, "vendor_state_unreadable"); + assert_eq!(refusal.detail, "corrupt state.json: synthetic"); + assert!(refusal.applies_to(PURL)); + } + + /// The lock-derived exemption: on a pre-v2 workspace lock whose every + /// instance of the purl is already ours, a SUPERSEDING uuid (ledger at + /// the old uuid) and a WIPED ledger are both exempt — the engine + /// re-vendors / stays in sync — while a fresh registry instance is + /// refused whatever the ledger says about other purls. + #[tokio::test] + async fn lock_derived_exemption_matches_the_engine_gate() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lock"), BUN_V1_WORKSPACE_LOCK).unwrap(); + let fresh = vec![sel(UUID, PURL)]; + let refusal = bun_vendor_preflight(tmp.path(), &fresh).await.unwrap(); + assert_eq!(refusal.code, "vendor_bun_workspace_unsupported"); + assert!( + refusal.applies_to(PURL), + "a fresh registry instance is refused" + ); + + // Vendored at UUID, ledger in sync: exempt (both rules agree). + std::fs::write(tmp.path().join("bun.lock"), vendored_lock(UUID)).unwrap(); + seed_bun_vendor_entry(tmp.path(), PURL, UUID); + let refusal = bun_vendor_preflight(tmp.path(), &fresh).await.unwrap(); + assert!(!refusal.applies_to(PURL), "in-sync re-run is exempt"); + + // Superseding uuid: the ledger disagrees, the lock says ours → exempt. + let superseding = vec![sel(OTHER_UUID, PURL)]; + let refusal = bun_vendor_preflight(tmp.path(), &superseding) + .await + .unwrap(); + assert_eq!(refusal.code, "vendor_bun_workspace_unsupported"); + assert!( + !refusal.applies_to(PURL), + "a patch update on an already-vendored purl must not be refused" + ); + + // Wiped ledger: no entry at all, the lock alone exempts. + std::fs::remove_file(tmp.path().join(".socket/vendor/state.json")).unwrap(); + let refusal = bun_vendor_preflight(tmp.path(), &fresh).await.unwrap(); + assert!( + !refusal.applies_to(PURL), + "a lost ledger must not turn an in-sync project into a refusal" + ); + + // A different, still-registry purl in the same lock stays refused + // and a non-npm purl is never in scope. + let others = vec![ + sel(OTHER_UUID, "pkg:npm/other@2.0.0"), + sel(OTHER_UUID, "pkg:pypi/x@1.0.0"), + ]; + let refusal = bun_vendor_preflight(tmp.path(), &others).await.unwrap(); + assert!(refusal.applies_to("pkg:npm/other@2.0.0")); + assert!(!refusal.applies_to("pkg:pypi/x@1.0.0")); + + // The pairs form (the `vendor` command's view) agrees. + let pairs = [(PURL, OTHER_UUID), ("pkg:npm/other@2.0.0", OTHER_UUID)]; + let empty = HashMap::new(); + let refusal = bun_vendor_preflight_pairs(tmp.path(), &pairs, Ok(&empty)) + .await + .unwrap(); + assert!(!refusal.applies_to(PURL)); + assert!(refusal.applies_to("pkg:npm/other@2.0.0")); + assert!( + bun_vendor_preflight_pairs(tmp.path(), &[("pkg:cargo/x@1.0.0", UUID)], Ok(&empty)) + .await + .is_none(), + "no npm pair => no refusal" + ); + } +} diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index a71ff58c..3e94d312 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -21,6 +21,9 @@ use std::fmt; use std::path::{Path, PathBuf}; use crate::args::{apply_env_toggles, GlobalArgs}; +use crate::commands::bun_preflight::{ + bun_vendor_preflight, bun_vendor_preflight_with_ledger, BunVendorRefusal, +}; use crate::ecosystem_dispatch::{ crawl_all_ecosystems, find_packages_for_rollback, partition_purls, }; @@ -1168,116 +1171,6 @@ async fn api_client_for(params: &DownloadParams) -> socket_patch_core::api::clie .0 } -/// The Bun vendored-mode preflight outcome shared by EVERY path that feeds -/// the vendor engine: `scan --mode vendored` (the manifest-tracked AND the -/// `--detached` download phases), `get … --mode vendored` (search and uuid -/// paths), and their `--dry-run` previews. One read-only -/// [`preflight_vendor`] per run, evaluated BEFORE any `/patches/view/` -/// fetch, so an incompatible Bun project (binary `bun.lockb` without a text -/// lock, an unreadable lock, an unsupported `lockfileVersion`, a -/// pre-version-2 `workspace:` lock) never has a patch downloaded on its -/// behalf — let alone recorded in the manifest — and every entry point -/// reports the SAME vendor code (a detached run used to fetch first and -/// then, for alias-installed packages, degrade to `package_not_installed`). -/// -/// `exempt` holds the selected purls the vendor ledger ALREADY wires at the -/// SAME uuid this run selected: in-sync re-runs, and the upgrade path of a -/// project vendored before the workspace gate existed. The refusal must not -/// pre-empt those — they flow through to the engine's `already_vendored` -/// skip exactly as on a non-Bun project. An unreadable ledger exempts -/// nothing (fail closed; the vendor step reports the corrupt ledger itself). -/// -/// [`preflight_vendor`]: socket_patch_core::vendor::bun_lock::preflight_vendor -pub(crate) struct BunVendorRefusal { - /// The stable vendor error code (`vendor_bun_lockb_unsupported`, - /// `vendor_lockfile_missing`, `vendor_lockfile_version_unsupported`, - /// `vendor_bun_workspace_unsupported`) — the same string the vendor - /// engine would have emitted as a `failed` event. - pub(crate) code: &'static str, - /// The engine's human-readable detail, relayed verbatim. - pub(crate) detail: String, - exempt: std::collections::HashSet, -} - -impl BunVendorRefusal { - /// Whether the refusal applies to `purl`: npm-family only (no other - /// ecosystem's backend consults `bun.lock`), minus the already-vendored - /// exemption. - pub(crate) fn applies_to(&self, purl: &str) -> bool { - purl.starts_with("pkg:npm/") && !self.exempt.contains(purl) - } -} - -/// Run the Bun preflight once for `selected` — only when it holds at least -/// one npm purl, since nothing else can be affected — and compute the -/// already-vendored exemption from the vendor ledger at `cwd`. `None` means -/// nothing to refuse. -pub(crate) async fn bun_vendor_preflight( - cwd: &Path, - selected: &[PatchSearchResult], -) -> Option { - if !selected.iter().any(|s| s.purl.starts_with("pkg:npm/")) { - return None; - } - let (code, detail) = socket_patch_core::vendor::bun_lock::preflight_vendor(cwd) - .await - .err()?; - let state = socket_patch_core::vendor::load_state(cwd) - .await - .unwrap_or_default(); - Some(bun_vendor_refusal_with_ledger( - code, - detail, - selected, - &state.entries, - )) -} - -/// [`bun_vendor_preflight`] for callers that already loaded the ledger (the -/// detached download phase, the dry-run preview). -pub(crate) async fn bun_vendor_preflight_with_ledger( - cwd: &Path, - selected: &[PatchSearchResult], - entries: &HashMap, -) -> Option { - if !selected.iter().any(|s| s.purl.starts_with("pkg:npm/")) { - return None; - } - let (code, detail) = socket_patch_core::vendor::bun_lock::preflight_vendor(cwd) - .await - .err()?; - Some(bun_vendor_refusal_with_ledger( - code, detail, selected, entries, - )) -} - -fn bun_vendor_refusal_with_ledger( - code: &'static str, - detail: String, - selected: &[PatchSearchResult], - entries: &HashMap, -) -> BunVendorRefusal { - let exempt = selected - .iter() - .filter(|s| { - // The ledger is keyed by the manifest purl (possibly qualified) - // and `lookup_entry` also resolves base purls; try the selected - // spelling first, then its qualifier-free base. - socket_patch_core::vendor::lookup_entry(entries, &s.purl) - .or_else(|| { - socket_patch_core::vendor::lookup_entry(entries, strip_purl_qualifiers(&s.purl)) - }) - .is_some_and(|e| e.uuid == s.uuid) - }) - .map(|s| s.purl.clone()) - .collect(); - BunVendorRefusal { - code, - detail, - exempt, - } -} - /// Download and apply a set of selected patches. /// /// Used by both `get` and `scan` commands. Returns (exit_code, json_result). @@ -1316,9 +1209,13 @@ pub(crate) async fn download_patch_records( let (selected, narrow_warnings) = filter_to_installed_releases(selected, params, &api_client).await; - let vendor_state = socket_patch_core::vendor::load_state(¶ms.cwd) - .await - .unwrap_or_default(); + // The ledger load outcome is handed to the preflight AS a result: an + // unreadable ledger must surface as `vendor_state_unreadable` from the + // one refusal this phase emits (fail closed, nothing exempt), not be + // flattened into an empty ledger that then reports a Bun lock remedy. + // For the idempotency lookup below it degrades to empty (no detached + // entry to reuse — the vendor step reports the corruption itself). + let vendor_state = socket_patch_core::vendor::load_state(¶ms.cwd).await; // The same Bun preflight the manifest-tracked download runs (see // `download_and_apply_patches`): a detached run feeds the same vendor @@ -1332,8 +1229,14 @@ pub(crate) async fn download_patch_records( let bun_refusal = if params.persist_blobs { None } else { - bun_vendor_preflight_with_ledger(¶ms.cwd, &selected, &vendor_state.entries).await + bun_vendor_preflight_with_ledger( + ¶ms.cwd, + &selected, + vendor_state.as_ref().map(|s| &s.entries), + ) + .await }; + let vendor_state = vendor_state.unwrap_or_default(); let mut records: HashMap = HashMap::new(); let mut downloaded = 0usize; @@ -2856,25 +2759,6 @@ fn boxed_download_and_apply<'a>( Box::pin(download_and_apply_patches(selected, params)) } -/// Human rendering of the vendored dry-run preview's `would_refuse` records -/// (see [`super::scan::preview_vendor_json`]): the count line above it still -/// says "would download and vendor", so name what the wet run would refuse -/// and why. Informational (the preview exits 0), hence behind the caller's -/// `--silent` gate. -fn print_dry_run_refusals(preview: &serde_json::Value) { - let Some(patches) = preview["patches"].as_array() else { - return; - }; - for p in patches.iter().filter(|p| p["action"] == "would_refuse") { - println!( - " [would-refuse] {} ({}): {}", - p["purl"].as_str().unwrap_or_default(), - p["errorCode"].as_str().unwrap_or_default(), - p["error"].as_str().unwrap_or_default() - ); - } -} - /// Print the whole-manifest blast-radius note for `--mode vendored`: the /// vendor step is scan's — it reconciles and (re)vendors EVERY manifest /// record, not just the one(s) this get selected. @@ -2990,7 +2874,7 @@ async fn run_get_vendored_search( "[dry-run] Would download and vendor {} patch(es).", selected.len() ); - print_dry_run_refusals(&preview); + super::scan::print_dry_run_refusals(&preview); } return 0; } @@ -3126,7 +3010,7 @@ async fn run_get_vendored_uuid( print_json(&result); } else if !args.common.silent { println!("[dry-run] Would download and vendor 1 patch."); - print_dry_run_refusals(&preview); + super::scan::print_dry_run_refusals(&preview); } return 0; } @@ -5312,29 +5196,6 @@ mod tests { } "#; - fn seed_bun_vendor_entry(root: &Path, purl: &str, uuid: &str) { - let vendor = root.join(".socket/vendor"); - std::fs::create_dir_all(&vendor).unwrap(); - std::fs::write( - vendor.join("state.json"), - serde_json::to_vec_pretty(&serde_json::json!({ - "version": 1, - "entries": { purl: { - "ecosystem": "npm", - "basePurl": purl, - "uuid": uuid, - "artifact": { - "path": format!(".socket/vendor/npm/{uuid}/covgap-bun-1.0.0.tgz"), - }, - "wiring": [], - "flavor": "bun", - }} - })) - .unwrap(), - ) - .unwrap(); - } - #[tokio::test] #[serial_test::serial] async fn download_patch_records_bun_lockb_refuses_before_fetch() { @@ -5540,48 +5401,6 @@ mod tests { assert!(paths[0].ends_with(same), "{paths:?}"); } - /// `bun_vendor_preflight` never reads the lock when nothing selected is - /// npm (no needless I/O, no spurious refusal for other ecosystems), and - /// an unreadable ledger exempts nothing (fail closed). - #[tokio::test] - async fn bun_vendor_preflight_scope_and_corrupt_ledger() { - let tmp = tempfile::tempdir().unwrap(); - std::fs::write(tmp.path().join("bun.lockb"), b"\x00binary").unwrap(); - let pypi = vec![mk_patch( - "11111111-1111-4111-8111-111111111111", - "pkg:pypi/only@1.0.0", - "free", - "2024-01-01", - )]; - assert!( - bun_vendor_preflight(tmp.path(), &pypi).await.is_none(), - "no npm purl selected => no refusal" - ); - - let uuid = "22222222-2222-4222-8222-222222222222"; - let purl = "pkg:npm/covgap-bun@1.0.0"; - let npm = vec![mk_patch(uuid, purl, "free", "2024-01-01")]; - let refusal = bun_vendor_preflight(tmp.path(), &npm) - .await - .expect("lockb-only project is refused"); - assert_eq!(refusal.code, "vendor_bun_lockb_unsupported"); - assert!(refusal.applies_to(purl)); - assert!(!refusal.applies_to("pkg:pypi/only@1.0.0")); - - // Exempt when the ledger wires this purl at this uuid… - seed_bun_vendor_entry(tmp.path(), purl, uuid); - let refusal = bun_vendor_preflight(tmp.path(), &npm).await.unwrap(); - assert!(!refusal.applies_to(purl), "in-sync ledger entry is exempt"); - - // …but a corrupt ledger exempts nothing. - std::fs::write(tmp.path().join(".socket/vendor/state.json"), b"{ not json").unwrap(); - let refusal = bun_vendor_preflight(tmp.path(), &npm).await.unwrap(); - assert!( - refusal.applies_to(purl), - "an unreadable ledger must not exempt (fail closed)" - ); - } - /// An unreadable vendor ledger silences the drift warning (the main /// vendor path reports unreadable state itself) instead of panicking or /// fabricating a warning. diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index d2502b71..b969dba1 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod apply; +pub(crate) mod bun_preflight; pub(crate) mod fetch_stage; pub mod get; pub mod list; diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index c587e8e4..882ec650 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -47,7 +47,9 @@ pub(crate) use self::discovery::unsupported_layout_warnings; use self::gc::{gc_json, print_gc_vendored_line, run_apply_gc}; pub(crate) use self::hosted::boxed_run_redirect_selected; use self::hosted::run_redirect; -pub(crate) use self::vendor_flow::{boxed_scan_vendor_step, preview_vendor_json}; +pub(crate) use self::vendor_flow::{ + boxed_scan_vendor_step, preview_vendor_json, print_dry_run_refusals, +}; use self::vendor_flow::{ boxed_vendor_interactive_path, boxed_vendor_json_path, fold_vendored_skips_into_apply, partition_skipped_selected, @@ -1739,21 +1741,22 @@ pub async fn run(mut args: ScanArgs) -> i32 { return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; } - // bun.lockb-only discovery diagnosis on the NON-EMPTY hosted path: the - // hosted driver runs from here on and owns the bun.lockb story — it - // auto-migrates the binary lock to bun.lock when a bun candidate exists - // (after which "cannot be inventoried" would be stale in the same - // envelope) and otherwise reports its own `redirect_bun_lockb_*` outcome - // on `redirect.warnings` — so the discovery-side warning is dropped to - // keep one voice per file. The zero-package envelope above keeps it in - // EVERY mode: the hosted driver never runs there, and without it a - // lockb-only fresh clone is exactly the silent success-0 no-op this - // channel exists to close. Agent and vendored runs keep it on both paths. - if hosted { - layout_refusals.retain(|(code, _)| { - code != socket_patch_core::vendor::lock_inventory::BUN_LOCKB_UNSUPPORTED_CODE - }); - } + // The bun.lockb discovery diagnosis (`bun_lockb_unsupported`) stays in + // `layout_refusals` in EVERY mode on this non-empty path too — hosted + // included. It reports a fact about THIS run's discovery (the binary + // lock was never read, so its lockfile-only packages are invisible), + // and nothing here can tell whether the hosted driver about to run + // will say anything about the file: the driver speaks only when an npm + // override is actually granted (`redirect_bun_lockb_*` on + // `redirect.warnings`, or a `redirect_bun_lockb_migrated` edit), a + // network-dependent outcome decided inside `run_redirect`, which owns + // the envelope from here on. An earlier version dropped the warning on + // every non-empty hosted run — so a polyglot project or an + // installed-but-unpatched npm tree printed a clean hosted success with + // no mention that the bun lock was skipped, the very silent no-op this + // channel exists to close. Two voices about one file on the run that + // does migrate beat silence on the many that never mention it; nothing + // is deduplicated. // Build ecosystem summary let mut eco_parts = Vec::new(); @@ -2722,6 +2725,15 @@ pub async fn run(mut args: ScanArgs) -> i32 { "\n[dry-run] Would {action} {} patch(es). No changes made.", selected.len() ); + // Vendored preview: the same ledger classification the JSON arm + // nests under `vendor`, rendered as `[would-refuse]` lines so a + // human preview never advertises vendoring the wet run's Bun + // preflight is known to refuse (the `get --mode vendored + // --dry-run` arms print the identical lines). + if vendor { + let preview = preview_vendor_json(&args.common.cwd, &selected).await; + print_dry_run_refusals(&preview); + } } return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; } diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index 05cce730..48ac31bd 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -16,11 +16,9 @@ use std::path::Path; use std::time::Duration; use crate::args::GlobalArgs; +use crate::commands::bun_preflight::bun_vendor_preflight_with_ledger; use crate::commands::fetch_stage::{stage_vendor_sources_in_memory, MemStageOutcome}; -use crate::commands::get::{ - bun_vendor_preflight_with_ledger, download_and_apply_patches, download_patch_records, - DownloadParams, -}; +use crate::commands::get::{download_and_apply_patches, download_patch_records, DownloadParams}; use crate::commands::vendor::{ note_classic_migration_risk, reconcile_dropped, track_outcomes_for_vendor, vendor_records, }; @@ -39,28 +37,40 @@ use super::{ /// entry), `already_vendored` (entry at this uuid), `would_revendor` + /// `oldUuid` (entry at an older uuid), and — additive — `would_refuse` + /// `errorCode` + `error` for npm purls the wet run's Bun preflight -/// ([`crate::commands::get::BunVendorRefusal`]) would refuse before any -/// download. The preview stays a ledger classification otherwise (engine -/// refusals outside the preflight are not predicted), and it never flips -/// the run's status or exit code: `would_refuse` is best-effort advice so a -/// preview never advertises vendoring the wet run is known to refuse. -/// The preflight is one read of `bun.lock`/`bun.lockb` — the only disk -/// access here — and runs only when the selection holds an npm purl. +/// ([`crate::commands::bun_preflight::BunVendorRefusal`]) would refuse +/// before any download. The preview stays a ledger classification otherwise +/// (engine refusals outside the preflight are not predicted), and it never +/// flips the run's status or exit code: `would_refuse` is best-effort +/// advice so a preview never advertises vendoring the wet run is known to +/// refuse. The preflight reads `bun.lock`/`bun.lockb` (plus, on a refused +/// workspace lock, the lock once more per npm purl for the exemption) — +/// the only disk access here — and runs only when the selection holds an +/// npm purl. pub(crate) async fn preview_vendor_json( cwd: &Path, selected: &[PatchSearchResult], ) -> serde_json::Value { - let state = load_state(cwd).await.unwrap_or_default(); - let refusal = bun_vendor_preflight_with_ledger(cwd, selected, &state.entries).await; + // The ledger load outcome reaches the preflight AS a result, so an + // unreadable ledger previews as `vendor_state_unreadable` (nothing + // exempt) instead of being flattened into an empty ledger that then + // predicts a Bun lock refusal; the classification below degrades it to + // empty (every npm record then reads `would_refuse` with that code). + let state = load_state(cwd).await; + let refusal = + bun_vendor_preflight_with_ledger(cwd, selected, state.as_ref().map(|s| &s.entries)).await; + let state = state.unwrap_or_default(); let mut patches: Vec = selected .iter() .map(|p| match lookup_entry(&state.entries, &p.purl) { Some(e) if e.uuid == p.uuid => serde_json::json!({ "purl": p.purl, "uuid": p.uuid, "action": "already_vendored", }), - // An in-sync ledger entry is exactly the preflight's exemption, - // so this arm never shadows `already_vendored`; a stale entry - // (`would_revendor`) IS refused by the wet run, like a fresh one. + // An in-sync ledger entry is exactly the preflight's ledger + // exemption, so this arm never shadows `already_vendored`. A + // stale entry is refused by the wet run like a fresh one when + // the lock still holds a registry instance of the purl; when + // every instance is already ours the preflight exempts it (the + // engine re-vendors in place) and it previews `would_revendor`. _ if refusal.as_ref().is_some_and(|r| r.applies_to(&p.purl)) => { let r = refusal.as_ref().expect("checked by the guard"); serde_json::json!({ @@ -81,6 +91,28 @@ pub(crate) async fn preview_vendor_json( serde_json::json!({ "dryRun": true, "patches": patches }) } +/// Human rendering of the vendored dry-run preview's `would_refuse` records +/// (see [`preview_vendor_json`]): the count line above it still says +/// "would download and vendor", so name what the wet run would refuse and +/// why. Shared by `scan --mode vendored --dry-run`'s interactive arm and +/// both `get … --mode vendored --dry-run` arms so the two commands' human +/// previews cannot drift (the contract promises the line for both). +/// Informational (the preview exits 0), hence behind the caller's +/// `--silent` gate. +pub(crate) fn print_dry_run_refusals(preview: &serde_json::Value) { + let Some(patches) = preview["patches"].as_array() else { + return; + }; + for p in patches.iter().filter(|p| p["action"] == "would_refuse") { + println!( + " [would-refuse] {} ({}): {}", + p["purl"].as_str().unwrap_or_default(), + p["errorCode"].as_str().unwrap_or_default(), + p["error"].as_str().unwrap_or_default() + ); + } +} + /// Build the vendoring-service config for scan's vendored flow — the SAME /// shape the standalone `vendor` command builds (see `vendor::run`), so both /// entry points honor `--vendor-source` / `--vendor-url` / diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 6ef58149..05c81cee 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -37,6 +37,7 @@ use std::time::Duration; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::commands::apply::{representative_file, result_to_event, variant_matches_installed}; +use crate::commands::bun_preflight::bun_vendor_preflight_pairs; use crate::commands::fetch_stage::{stage_vendor_sources_in_memory, MemStageOutcome}; use crate::commands::lock_cli::acquire_or_emit; use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs}; @@ -951,6 +952,31 @@ pub(crate) async fn vendor_records( } }; + // Bun vendored preflight (see `crate::commands::bun_preflight`), run + // ONCE per run over the in-scope npm records and consulted per + // candidate in the dispatch loop BEFORE the hosted→vendored takeover. + // The bun engine refuses a pre-v2 `workspace:` lock (and a lockb-only / + // unreadable / unsupported-version project) before its own writes — + // but the takeover below reverts a hosted purl's lockfile edits and + // persists the redirect-ledger drop FIRST, so without this gate a + // `vendor` over a hosted-wired v1 workspace lock stripped the live + // hosted redirect, then failed `vendor_bun_workspace_unsupported`: + // unpatched in both modes, with the refusal telling the user to use + // the hosted mode it had just destroyed. `scan`/`get --mode vendored` + // already run this preflight at download time; here it is the ONLY + // gate the plain `vendor` command has, and its dry-run arm previews the + // same refusal instead of the takeover advisory. A purl the vendor + // ledger wires at the record's uuid, or whose lock instances are all + // already ours, is exempt (the engine handles in-sync re-runs and + // superseding-uuid re-vendors itself) — the ledger loaded above is the + // one it consults. Pairs are the in-scope vendorable records, so an + // `--ecosystems` filter that excludes npm never reads the lock. + let bun_pairs: Vec<(&str, &str)> = vendorable + .iter() + .filter_map(|p| records.get(p).map(|r| (p.as_str(), r.uuid.as_str()))) + .collect(); + let bun_refusal = bun_vendor_preflight_pairs(&common.cwd, &bun_pairs, Ok(&state.entries)).await; + // Release-variant grouping (pypi `?artifact_id=`, gem `?platform=`): // the crawler emits base purls; match the manifest's qualified variants // against the installed distribution via the first-file probe. @@ -1032,6 +1058,29 @@ pub(crate) async fn vendor_records( } matched.insert(candidate.clone()); + // The Bun preflight verdict (computed once above): the engine + // would refuse this project for this purl, so refuse HERE — the + // same `failed` event, code and detail the engine would have + // produced, in the dry run and the wet run alike — before the + // takeover block below can revert a live hosted redirect on its + // behalf. Hosted wiring, redirect ledger and lockfile stay + // byte-untouched for a refused purl. + if let Some(refusal) = bun_refusal.as_ref().filter(|r| r.applies_to(candidate)) { + has_errors = true; + env.record( + PatchEvent::new(PatchAction::Failed, candidate.clone()) + .with_error(refusal.code, refusal.detail.clone()), + ); + if !common.silent && !common.json { + eprintln!( + "Cannot vendor {}: {}", + normalize_purl(candidate), + refusal.detail + ); + } + continue; + } + // Cross-mode takeover: vendoring over a LIVE hosted redirect // must first revert the hosted edits from the redirect ledger. // Cargo: `[patch.crates-io]` only patches crates-io-sourced @@ -1048,7 +1097,10 @@ pub(crate) async fn vendor_records( // lock fragment to record as the ledger's originals. A purl // whose hosted edits cannot be cleanly reverted is REFUSED; the // cargo backend's own fail-closed guard (`hosted_redirect_live`) - // backstops states with no usable ledger at all. + // backstops states with no usable ledger at all. A purl whose + // bun project the vendor engine would refuse outright never + // reaches this block (the Bun preflight `continue`d above), so + // a refusal can no longer land AFTER the revert was persisted. if socket_patch_core::patch::redirect::redirect_revert_supported(candidate) { if let Some(corrupt) = &redirect_ledger_corrupt { has_errors = true; @@ -1114,6 +1166,11 @@ pub(crate) async fn vendor_records( // whole plan (revert, then vendor) and the preview // stops here — the hosted dry run makes the same // choice after `redirect_would_revert_vendored`. + // The project-level refusals the engine WOULD + // raise after the revert (workspace gate, lock + // version) were already previewed by the Bun + // preflight above, so stopping here promises + // nothing the wet run then refuses. if revert.reverted_files.iter().any(|f| f == "bun.lock") { continue; } diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs index 75ae508a..f20594af 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs @@ -1290,13 +1290,18 @@ fn scan_bun_lockb_only_project_warns_instead_of_silent_success() { assert!(stdout.contains("No packages found"), "{stdout:?}"); } -/// NON-empty hosted scan (an installed package beside the bun.lockb): the -/// hosted driver runs and owns the bun.lockb story (`redirect_bun_lockb_*` -/// on `redirect.warnings`, or an actual migration), so the discovery-side -/// `bun_lockb_unsupported` is dropped there — while the same project in -/// agent mode keeps it on its non-empty envelope. +/// NON-empty scan (an installed package beside the bun.lockb), EVERY mode: +/// the discovery-side `bun_lockb_unsupported` rides the non-empty envelope +/// too — hosted included. An earlier version dropped it on every non-empty +/// hosted run on the theory that the hosted driver "owns the bun.lockb +/// story", but the driver only speaks about the file when an npm override +/// is actually granted (`redirect_bun_lockb_*` / a migration edit); with +/// nothing to redirect (this fixture: no patches) a hosted scan printed a +/// clean success that never mentioned the unread bun lock — the silent +/// no-op this channel exists to close. Nothing is deduplicated: on the run +/// that does migrate, the envelope may carry both voices about the file. #[tokio::test] -async fn scan_hosted_nonempty_drops_the_bun_lockb_discovery_warning() { +async fn scan_nonempty_keeps_the_bun_lockb_discovery_warning_in_every_mode() { let mock = MockServer::start().await; Mock::given(method("POST")) .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) @@ -1307,7 +1312,7 @@ async fn scan_hosted_nonempty_drops_the_bun_lockb_discovery_warning() { .mount(&mock) .await; - for (mode, expect_warning) in [(None, true), (Some("hosted"), false)] { + for mode in [None, Some("vendored"), Some("hosted")] { let tmp = tempfile::tempdir().unwrap(); write_bun_lockb_only_project(tmp.path()); write_npm_package(tmp.path(), "minimist", "1.2.2", b"module.exports = 1;\n"); @@ -1329,10 +1334,102 @@ async fn scan_hosted_nonempty_drops_the_bun_lockb_discovery_warning() { let v: serde_json::Value = serde_json::from_str(stdout.trim()) .unwrap_or_else(|e| panic!("bad JSON ({e}): {stdout}")); assert_eq!(v["scannedPackages"], 1, "mode={mode:?}: {v}"); - assert_eq!( - bun_lockb_warning(&v).is_some(), - expect_warning, - "mode={mode:?}: hosted drops the discovery-side lockb warning on the non-empty path, agent keeps it: {v}" + assert_eq!(v["status"], "success", "mode={mode:?}: {v}"); + let warning = bun_lockb_warning(&v).unwrap_or_else(|| { + panic!("mode={mode:?}: the non-empty envelope must keep bun_lockb_unsupported: {v}") + }); + assert!( + warning["detail"] + .as_str() + .is_some_and(|d| d.contains("--save-text-lockfile")), + "mode={mode:?}: {v}" ); } + + // Human hosted path: the same warning line on stderr before the driver + // runs (exit 0; nothing to redirect). + let tmp = tempfile::tempdir().unwrap(); + write_bun_lockb_only_project(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2", b"module.exports = 1;\n"); + let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--mode", "hosted"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert!( + stderr.contains("Warning (bun_lockb_unsupported):"), + "the human hosted path must keep the warning; got {stderr:?}" + ); +} + +// --------------------------------------------------------------------------- +// Human `--mode vendored --dry-run`: the `[would-refuse]` lines +// --------------------------------------------------------------------------- +// The contract promises the human vendored preview names what the wet run +// would refuse. Only `get --mode vendored --dry-run` printed the lines; a +// human `scan --mode vendored --dry-run` on a refused Bun project said +// "Would download and vendor 1 patch(es). No changes made." for a run that +// exits 1. Both commands now share the printer. + +/// A real bun 1.3.14 lockfileVersion-1 workspace lock resolving minimist +/// from the registry: the shape the vendored preflight refuses. +fn write_bun_v1_workspace_lock(root: &Path) { + std::fs::write( + root.join("bun.lock"), + "{\n \"lockfileVersion\": 1,\n \"configVersion\": 1,\n \"workspaces\": {\n \"\": {\n \"name\": \"covgap-scan-root\",\n \"dependencies\": {\n \"consumer\": \"workspace:*\",\n },\n },\n \"packages/consumer\": {\n \"name\": \"consumer\",\n \"version\": \"1.0.0\",\n \"dependencies\": {\n \"minimist\": \"1.2.2\",\n },\n },\n },\n \"packages\": {\n \"consumer\": [\"consumer@workspace:packages/consumer\"],\n\n \"minimist\": [\"minimist@1.2.2\", \"\", {}, \"sha512-AAAA==\"],\n }\n}\n", + ) + .unwrap(); +} + +#[tokio::test] +async fn scan_human_vendored_dry_run_names_would_refuse_records() { + let mock = MockServer::start().await; + let purl = "pkg:npm/minimist@1.2.2"; + mount_batch_one(&mock, purl, UUID, "free", &[], false).await; + mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); + write_bun_v1_workspace_lock(tmp.path()); + + let (code, stdout, stderr) = run_scan_human( + tmp.path(), + &mock.uri(), + &["--mode", "vendored", "--dry-run"], + ); + assert_eq!( + code, 0, + "a preview never flips the exit; stdout={stdout}; stderr={stderr}" + ); + assert!( + stdout.contains("[dry-run] Would download and vendor 1 patch(es). No changes made."), + "the count line stays; got {stdout:?}" + ); + assert!( + stdout.contains(&format!( + " [would-refuse] {purl} (vendor_bun_workspace_unsupported): " + )), + "the human scan preview must name the refusal like get's does; got {stdout:?}" + ); + assert!( + stdout.contains("lockfileVersion-1 lock") && stdout.contains("--mode hosted"), + "the engine's detail rides along; got {stdout:?}" + ); + // Nothing written: no manifest, no blobs, no vendor ledger. (The human + // preview does fetch the patch view for its baseline-hash check — the + // same as every human dry run — so the request log is not the oracle.) + assert!( + !tmp.path().join(".socket").exists(), + "a dry run writes nothing" + ); + + // `--silent`: informational, so nothing at all on stdout. + let (code, stdout, _) = run_scan_human( + tmp.path(), + &mock.uri(), + &["--mode", "vendored", "--dry-run", "--silent"], + ); + assert_eq!(code, 0); + assert!( + stdout.trim().is_empty(), + "silent dry run prints nothing:\n{stdout}" + ); } diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun.rs index 5918f53a..c483c5be 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_bun.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun.rs @@ -1093,6 +1093,188 @@ async fn already_vendored_v1_workspace_rerun_is_already_vendored_exit_zero() { assert_eq!(lock_bytes(tmp.path()), lock_before); } +/// A SUPERSEDING patch uuid on the upgraded workspace project: the ledger +/// holds the OLD uuid, so a ledger-only exemption refused the update at +/// download (`vendor_bun_workspace_unsupported`, exit 1) with a re-lock +/// remedy a Bun 1.2/1.3 team cannot follow — while the engine would have +/// re-vendored the already-local tuple in place. The lock-derived +/// exemption sees every instance is ours and lets the run through: the +/// record is `updated`, the engine re-pins the tuple at the new uuid, the +/// lock stays at lockfileVersion 1 with its workspace entry intact. +#[tokio::test] +async fn superseding_uuid_on_already_vendored_v1_workspace_is_revendored_not_refused() { + const SUPERSEDING_UUID: &str = "33333333-3333-4333-8333-333333333333"; + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + vendor_then_add_workspace(tmp.path(), &mock.uri()); + mount_view(&mock, SUPERSEDING_UUID, PURL).await; + + let (exit, stdout, stderr) = + get_vendored(tmp.path(), &mock.uri(), SUPERSEDING_UUID, &["--json"]); + assert_eq!(exit, 0, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "success", "{v}"); + assert_eq!(v["patches"][0]["action"], "updated", "{v}"); + assert_eq!(v["patches"][0]["oldUuid"], UUID, "{v}"); + assert!(v["patches"][0].get("errorCode").is_none(), "{v}"); + assert_eq!(v["vendor"]["summary"]["applied"], 1, "{v}"); + assert_eq!(v["vendor"]["summary"]["failed"], 0, "{v}"); + assert!( + !stdout.contains(WS_CODE), + "no arm may raise the workspace refusal for an already-vendored purl: {v}" + ); + let lock = String::from_utf8(lock_bytes(tmp.path())).unwrap(); + assert!( + lock.contains(&format!( + "\"left-pad@.socket/vendor/npm/{SUPERSEDING_UUID}/left-pad-1.3.0.tgz\"" + )), + "the tuple must point at the superseding uuid:\n{lock}" + ); + assert!( + !lock.contains(UUID), + "the old uuid path must be gone:\n{lock}" + ); + assert!( + lock.contains(" \"consumer\": [\"consumer@workspace:packages/consumer\"],\n"), + "the workspace entry survives:\n{lock}" + ); + assert!(lock.starts_with("{\n \"lockfileVersion\": 1,\n"), "{lock}"); + let state: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/vendor/state.json")).unwrap(), + ) + .unwrap(); + assert_eq!(state["entries"][PURL]["uuid"], SUPERSEDING_UUID, "{state}"); + assert!(tmp + .path() + .join(format!( + ".socket/vendor/npm/{SUPERSEDING_UUID}/left-pad-1.3.0.tgz" + )) + .is_file()); + assert!( + !tmp.path() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "the stale uuid dir is swept" + ); +} + +/// The same upgraded project with its vendor ledger LOST (`state.json` +/// deleted — the shape `repair` reconstructs from): the ledger exemption +/// has nothing to match, but the lock still says every instance is ours, +/// so the download phase must NOT refuse the in-sync re-run — it +/// classifies `skipped` (already in the manifest) and hands the ledgerless +/// wiring to the engine, whose verdict (not the preflight's) decides the +/// run. Nothing here may raise the workspace code. +#[tokio::test] +async fn wiped_ledger_on_already_vendored_v1_workspace_is_not_refused_at_preflight() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + let lock_before = vendor_then_add_workspace(tmp.path(), &mock.uri()); + std::fs::remove_file(tmp.path().join(".socket/vendor/state.json")).unwrap(); + + let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--json"]); + let v = parse_single_json_doc(&stdout); + let rec = &v["download"]["patches"][0]; + assert_eq!( + rec["action"], "skipped", + "an in-sync purl must not be refused for a lost ledger (exit {exit}): {v}\n{stderr}" + ); + assert!(rec.get("errorCode").is_none(), "{v}"); + assert_eq!(v["download"]["failed"], 0, "{v}"); + assert!( + !stdout.contains(WS_CODE), + "no arm may raise the workspace refusal: {v}" + ); + assert_eq!( + lock_bytes(tmp.path()), + lock_before, + "the wired lock is left alone" + ); +} + +// --------------------------------------------------------------------------- +// Corrupt vendor ledger beside a refused Bun lock: name the ledger +// --------------------------------------------------------------------------- + +/// `get --mode vendored` returns before the vendor step, so the +/// preflight's refusal is the ONLY diagnosis the run emits; the dry-run +/// preview and the detached download phase share the same ledger-blind +/// spot. All three must report `vendor_state_unreadable` (the code every +/// other vendor-adjacent command uses for this file) with the io/parse +/// detail, not the Bun re-lock remedy — fail-closed still: nothing exempt, +/// nothing written, the corrupt ledger left in place for the operator. +#[tokio::test] +async fn corrupt_vendor_ledger_on_refused_bun_lock_reports_vendor_state_unreadable() { + const LEDGER_CODE: &str = "vendor_state_unreadable"; + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::V1Workspace); + let vendor_dir = tmp.path().join(".socket/vendor"); + std::fs::create_dir_all(&vendor_dir).unwrap(); + std::fs::write(vendor_dir.join("state.json"), b"{ not json").unwrap(); + let lock_before = lock_bytes(tmp.path()); + + // uuid path, JSON: the pre-record refusal envelope carries the ledger code. + let (exit, stdout, stderr) = get_vendored(tmp.path(), &mock.uri(), UUID, &["--json"]); + assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "error", "{v}"); + assert_eq!(v["error"]["code"], LEDGER_CODE, "{v}"); + assert!( + v["error"]["message"] + .as_str() + .is_some_and(|m| m.contains("state.json")), + "the detail names the ledger file: {v}" + ); + assert_eq!(v["patches"][0]["errorCode"], LEDGER_CODE, "{v}"); + assert!( + !stdout.contains(WS_CODE), + "the Bun lock remedy must not shadow the ledger corruption: {v}" + ); + assert!(!tmp.path().join(".socket/manifest.json").exists()); + + // uuid path, human: the code-tagged Error line. + let (exit, stdout, stderr) = get_vendored(tmp.path(), &mock.uri(), UUID, &[]); + assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); + assert!( + stderr.contains(&format!("Error ({LEDGER_CODE}):")), + "stderr must carry the ledger code:\n{stderr}" + ); + + // Dry-run preview: `would_refuse` with the ledger code. + let (exit, stdout, stderr) = + get_vendored(tmp.path(), &mock.uri(), UUID, &["--dry-run", "--json"]); + assert_eq!(exit, 0, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + let rec = &v["vendor"]["patches"][0]; + assert_eq!(rec["action"], "would_refuse", "{v}"); + assert_eq!(rec["errorCode"], LEDGER_CODE, "{v}"); + + // Detached download phase: the same code before any fetch. + let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--detached", "--json"]); + assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + let rec = &v["download"]["patches"][0]; + assert_eq!(rec["action"], "failed", "{v}"); + assert_eq!(rec["errorCode"], LEDGER_CODE, "{v}"); + assert_eq!(v["download"]["downloaded"], 0, "{v}"); + + assert_eq!( + lock_bytes(tmp.path()), + lock_before, + "refused runs never touch the lock" + ); + assert_eq!( + std::fs::read(vendor_dir.join("state.json")).unwrap(), + b"{ not json", + "the corrupt ledger is left for the operator, never overwritten" + ); + assert!(!vendor_dir.join("npm").exists()); +} + // --------------------------------------------------------------------------- // Digest-less re-saves (Bun 1.1.39–1.3.9) // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs index 229aa5f3..3801643c 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs @@ -25,6 +25,11 @@ //! whole-ledger replay is not eligible) unwinds only the targeted line //! and record; the sibling stays hosted. //! 4. Same ledger, `remove `. +//! 5. A hosted-wired lockfileVersion-1 WORKSPACE lock (hosted accepts it, +//! the vendored backend refuses it): `vendor` — dry and wet — refuses +//! `vendor_bun_workspace_unsupported` BEFORE the takeover reverts +//! anything, so the hosted wiring survives byte-for-byte; the v2 twin +//! still takes over. //! //! Every child process gets the ambient `SOCKET_*` vars scrubbed and //! telemetry hard-disabled; each test runs in its own tempdir. @@ -901,3 +906,186 @@ fn bun_scoped_remove_of_one_of_two_hosted_records_unwinds_only_that_purl() { ); assert_only_left_pad_unwound(root, &pristine); } + +// ───────────────────────────────────────────────────────────────────── +// 5. hosted-wired pre-v2 WORKSPACE lock: `vendor` refuses BEFORE un-hosting +// ───────────────────────────────────────────────────────────────────── +// Hosted mode accepts a lockfileVersion-1 workspace lock (a URL tuple has +// no path to resolve); the vendored backend refuses every pre-v2 workspace +// lock (`vendor_bun_workspace_unsupported`). The plain `vendor` command +// used to run the takeover FIRST — revert the hosted line, persist the +// redirect-ledger drop — and only then hear the engine's refusal, leaving +// the project unpatched in BOTH modes while the refusal's remedy pointed +// at the hosted mode it had just destroyed; its dry run promised the +// takeover (`vendor_would_revert_redirect`, status success) outright. The +// Bun preflight now runs inside the engine loop before the takeover block. + +const WS_CODE: &str = "vendor_bun_workspace_unsupported"; + +/// Pristine `lockfileVersion` workspace lock — root + a `packages/consumer` +/// member declaring left-pad — in the real bun 1.3.14 (v1) / 1.4.2 (v2) +/// grammar: `configVersion`, the member's 1-tuple `workspace:` entry, a +/// blank line between entries, trailing commas. +fn pristine_workspace_lock(version: u64) -> String { + format!( + "{{\n \"lockfileVersion\": {version},\n \"configVersion\": 1,\n \"workspaces\": {{\n \"\": {{\n \"name\": \"bun-takeover-fixture\",\n \"dependencies\": {{\n \"consumer\": \"workspace:*\",\n }},\n }},\n \"packages/consumer\": {{\n \"name\": \"consumer\",\n \"version\": \"1.0.0\",\n \"dependencies\": {{\n \"left-pad\": \"1.3.0\",\n }},\n }},\n }},\n \"packages\": {{\n \"consumer\": [\"consumer@workspace:packages/consumer\"],\n\n{LEFT_PAD_REGISTRY_LINE}\n }}\n}}\n" + ) +} + +/// A hosted-live WORKSPACE bun project with ONE redirect record, written +/// exactly as `scan --mode hosted` leaves it, plus the manifest record and +/// blob a default-mode `get`/`scan` adds — the shape the plain `vendor` +/// command acts on (a hosted-only project is a `noManifest` no-op). +/// Returns the hosted lock text. +fn write_hosted_workspace_project(root: &Path, version: u64) -> String { + let pristine = pristine_workspace_lock(version); + write_bun_project(root, &pristine, &[(NAME, VERSION)]); + std::fs::write( + root.join("package.json"), + r#"{"name":"bun-takeover-fixture","version":"1.0.0","private":true,"workspaces":["packages/*"],"dependencies":{"consumer":"workspace:*"}}"#, + ) + .unwrap(); + let consumer = root.join("packages/consumer"); + std::fs::create_dir_all(&consumer).unwrap(); + std::fs::write( + consumer.join("package.json"), + r#"{"name":"consumer","version":"1.0.0","dependencies":{"left-pad":"1.3.0"}}"#, + ) + .unwrap(); + let left_pad_hosted = hosted_line(NAME, NAME, HOSTED_URL, PATCHED_SHA512); + let hosted = pristine.replace(LEFT_PAD_REGISTRY_LINE, &left_pad_hosted); + assert_ne!(hosted, pristine, "the hosted splice must hit"); + std::fs::write(root.join("bun.lock"), &hosted).unwrap(); + let ledger = json!({ + "version": 1, + "mode": "hosted", + "edits": [{ + "path": "bun.lock", + "kind": "redirect_bun_lock_package", + "action": "rewritten", + "key": NAME, + "original": LEFT_PAD_REGISTRY_LINE, + "new": left_pad_hosted, + }], + "records": { PURL: patch_record(UUID) }, + }); + std::fs::create_dir_all(root.join(".socket/vendor")).unwrap(); + std::fs::write( + root.join(".socket/vendor/redirect-state.json"), + serde_json::to_vec_pretty(&ledger).unwrap(), + ) + .unwrap(); + seed_manifest_and_blob(root); + hosted +} + +/// Every byte of the hosted wiring must survive a refused run: the lock, +/// the redirect ledger (record + edit), and no vendor ledger or artifact. +fn assert_hosted_wiring_intact(root: &Path, hosted_lock: &str, hosted_ledger: &[u8]) { + assert_eq!( + read(root, "bun.lock"), + hosted_lock, + "bun.lock must stay byte-identical to the hosted lock" + ); + let ledger_path = root.join(".socket/vendor/redirect-state.json"); + assert_eq!( + std::fs::read(&ledger_path).unwrap(), + hosted_ledger, + "the redirect ledger must stay byte-identical" + ); + let ledger: Value = + serde_json::from_str(&read(root, ".socket/vendor/redirect-state.json")).unwrap(); + assert!(ledger["records"].get(PURL).is_some(), "{ledger:#}"); + let edits = ledger["edits"].as_array().unwrap(); + assert_eq!(edits.len(), 1, "{ledger:#}"); + assert_eq!(edits[0]["key"], NAME, "{ledger:#}"); + assert!( + !root.join(".socket/vendor/state.json").exists(), + "a refused run must not create the vendor ledger" + ); + assert!( + !root.join(".socket/vendor/npm").exists(), + "a refused run must not stage or pack an artifact" + ); +} + +#[test] +fn bun_vendor_over_hosted_v1_workspace_lock_refuses_before_unhosting() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let hosted_lock = write_hosted_workspace_project(root, 1); + let hosted_ledger = std::fs::read(root.join(".socket/vendor/redirect-state.json")).unwrap(); + + // Dry run: previews the REFUSAL, not the takeover, with the wet run's + // exit code — and writes nothing. + let (code, env) = vendor_cli(root, &["--dry-run"]); + assert_eq!( + code, 1, + "the preview must exit like the wet run it predicts: {env:#}" + ); + assert_eq!(env["status"], "partialFailure", "{env:#}"); + assert_eq!(env["dryRun"], true, "{env:#}"); + let failed = find_event(&env, "failed", Some(WS_CODE)); + assert_eq!(failed["purl"], PURL, "{env:#}"); + assert_eq!(env["summary"]["failed"], 1, "{env:#}"); + assert_no_event_code(&env, "vendor_would_revert_redirect"); + assert_no_event_code(&env, "vendor_takeover_reverted_redirect"); + assert_no_event_code(&env, "redirect_revert_failed"); + assert_hosted_wiring_intact(root, &hosted_lock, &hosted_ledger); + + // Wet run: the same refusal, BEFORE any revert — hosted wiring intact. + // Used to: `skipped vendor_takeover_reverted_redirect` then `failed + // vendor_bun_workspace_unsupported`, registry tuple back in the lock, + // redirect-state.json deleted, `.socket/vendor/` empty. + let (code, env) = vendor_cli(root, &[]); + assert_eq!(code, 1, "the wet run refuses: {env:#}"); + assert_eq!(env["status"], "partialFailure", "{env:#}"); + let failed = find_event(&env, "failed", Some(WS_CODE)); + assert_eq!(failed["purl"], PURL, "{env:#}"); + assert!( + failed["error"] + .as_str() + .is_some_and(|d| d.contains("lockfileVersion-1 lock") && d.contains("--mode hosted")), + "the detail names the version and the hosted alternative this run left in place: {env:#}" + ); + assert_eq!(env["summary"]["applied"], 0, "{env:#}"); + assert_eq!(env["summary"]["failed"], 1, "{env:#}"); + assert_no_event_code(&env, "vendor_takeover_reverted_redirect"); + assert_no_event_code(&env, "vendor_would_revert_redirect"); + assert_no_event_code(&env, "redirect_revert_failed"); + assert_hosted_wiring_intact(root, &hosted_lock, &hosted_ledger); + + // The manifest record survives too (the recovery path — a networked + // `scan --mode hosted` — needs nothing this run could have dropped). + let manifest: Value = serde_json::from_str(&read(root, ".socket/manifest.json")).unwrap(); + assert_eq!(manifest["patches"][PURL]["uuid"], UUID, "{manifest:#}"); +} + +/// The lockfileVersion-2 twin: hosted mode and the vendored backend both +/// accept it, so the takeover still completes — the preflight must not +/// over-refuse the supported workspace shape. +#[test] +fn bun_vendor_over_hosted_v2_workspace_lock_still_takes_over() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_hosted_workspace_project(root, 2); + + let (code, env) = vendor_cli(root, &["--dry-run"]); + assert_eq!(code, 0, "{env:#}"); + find_event(&env, "skipped", Some("vendor_would_revert_redirect")); + assert_no_event_code(&env, WS_CODE); + + let (code, env) = vendor_cli(root, &[]); + assert_eq!(code, 0, "the v2 takeover must succeed: {env:#}"); + assert_eq!(env["summary"]["applied"], 1, "{env:#}"); + find_event(&env, "skipped", Some("vendor_takeover_reverted_redirect")); + find_event(&env, "applied", None); + assert_no_event_code(&env, WS_CODE); + assert_pure_vendored(root); + let lock = read(root, "bun.lock"); + assert!( + lock.contains(" \"consumer\": [\"consumer@workspace:packages/consumer\"],\n"), + "the workspace entry survives the takeover:\n{lock}" + ); + assert!(lock.starts_with("{\n \"lockfileVersion\": 2,\n"), "{lock}"); +} From a7c87590c8edda748d8eb5b258181fb278771dd3 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 21 Sep 2026 12:57:29 -0400 Subject: [PATCH 36/49] fix(bun): refuse a non-regular bun.lockb and a stale one beside a sibling lock before spawning bun MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fail-closed gates now run in the hosted driver's bun.lockb migration BEFORE any `bun install` spawn: * A `bun.lockb` that is not a regular file (a FIFO, socket or directory squatting the path) passes the `exists()` gate but wedged the plain `std::fs::read` capture of the pre-migration bytes forever — and bun's own open of the lock blocks on the same FIFO, so guarding the read alone would only move the hang into the child. The capture now goes through a new FIFO-safe `utils::fs::read_regular_to_bytes_sync` (non-blocking open + fstat regular-file check, the bytes twin of `read_regular_to_string_sync`); `InvalidInput` refuses with `redirect_bun_lockb_unsupported` "bun.lockb is not a regular file; refusing to migrate it" and bun is never spawned. Dry-run predicts the same refusal (stat, never open). Other read errors keep today's contract (migrate, record without restorable bytes). * A `bun.lockb` beside a live `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock` or `pnpm-lock.yaml` (and no `bun.lock`) is most likely debris of a migration AWAY from bun; migrating it converted an npm / yarn / pnpm project into a bun.lock project (verified with bun 1.4.2: bun.lockb deleted, lockfileVersion-2 bun.lock created and redirected beside the redirected package-lock.json). The driver now leaves it alone with the new stable warning `redirect_bun_lockb_sibling_lock` naming the sibling(s) and both remedies; the redirect follows the sibling lock as before. Dry-run reports the same code instead of `redirect_bun_lockb_would_migrate`. Tests: core `read_regular_to_bytes_sync` (binary verbatim, error kinds, FIFO fails fast), hosted unit tests for the sibling probe and details, and three covgap subprocess tests with a marker `bun` shim proving no spawn: FIFO bun.lockb (deadline-guarded, dry-run + live), stale lockb beside package-lock.json (dry-run, live, human stderr) and beside pnpm-lock.yaml. Co-Authored-By: Claude Fable 5.1 --- .../src/commands/scan/hosted.rs | 310 +++++++++---- .../tests/covgap_commands_scan_hosted.rs | 421 +++++++++++++++++- crates/socket-patch-core/src/utils/fs.rs | 92 +++- 3 files changed, 721 insertions(+), 102 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index d7859557..0ad3e5d4 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -118,6 +118,47 @@ fn lockb_original_payload(bytes: Option<&[u8]>) -> Option { }) } +/// Recognised lockfiles that, beside a `bun.lockb` and no `bun.lock`, mark +/// the binary lock as probable debris of a migration AWAY from bun — the +/// same names the vendored router knows, in its precedence order once the +/// lockb is gone. Migrating the lockb there would turn an npm/yarn/pnpm +/// project into a bun.lock project as a side effect, so the driver leaves +/// it alone and the redirect follows the sibling instead. +const LOCKB_SIBLING_LOCKS: [&str; 4] = [ + "pnpm-lock.yaml", + "yarn.lock", + "npm-shrinkwrap.json", + "package-lock.json", +]; + +/// The `redirect_bun_lockb_unsupported` detail for a `bun.lockb` that is not +/// a regular file (FIFO, socket, directory): refused BEFORE any bun spawn, +/// because bun's own open of the lock would block on the same FIFO. +const LOCKB_NOT_REGULAR_DETAIL: &str = "bun.lockb is not a regular file; refusing to migrate it"; + +/// The [`LOCKB_SIBLING_LOCKS`] present in `cwd` as regular files (a lock +/// the redirect cannot read is not one it follows), in probe order. +fn present_lockb_sibling_locks(cwd: &Path) -> Vec<&'static str> { + LOCKB_SIBLING_LOCKS + .iter() + .copied() + .filter(|name| cwd.join(name).is_file()) + .collect() +} + +/// The `redirect_bun_lockb_sibling_lock` detail: names every sibling lock +/// present and both remedies (delete the debris, or remove the sibling and +/// re-run when bun really is the installer). +fn lockb_sibling_lock_detail(siblings: &[&str]) -> String { + let list = siblings.join(", "); + let verb = if siblings.len() == 1 { "is" } else { "are" }; + format!( + "bun.lockb was left alone because {list} {verb} also present; the redirect follows \ + {list} — delete the stale bun.lockb if it is debris, or remove {list} and re-run if \ + bun is the installer" + ) +} + const REDIRECT_CANDIDATE_FILES: &[&str] = &[ "package-lock.json", "npm-shrinkwrap.json", @@ -726,7 +767,8 @@ async fn gem_stale_install_warnings( }); if verify_patch_record(&pkg.path, record).await.is_ok() { entry.patched = true; - } else if !entry.positive && installed_stale_positive_evidence(&pkg.path, record).await { + } else if !entry.positive && installed_stale_positive_evidence(&pkg.path, record).await + { entry.positive = true; entry.purl = (*purl).to_string(); } @@ -1325,6 +1367,20 @@ pub(crate) async fn run_redirect_selected( // Gated on an npm-ecosystem override: the migration exists solely so the // bun rewriter has a text lock to edit — with nothing to redirect it would // re-lock (and delete) the user's lockfile as a side effect of a no-op run. + // Two more gates run BEFORE any bun spawn, both fail-closed: + // • a recognised sibling lock (package-lock.json, npm-shrinkwrap.json, + // yarn.lock, pnpm-lock.yaml) beside the bun.lockb: the project most + // likely migrated AWAY from bun and left the binary lock as debris, + // and re-locking it would convert an npm/yarn/pnpm project into a + // bun.lock project as a side effect. The lockb is left alone with + // `redirect_bun_lockb_sibling_lock` naming both files and both + // remedies; the redirect follows the sibling lock, rewritten as + // today. Dry-run reports the same code instead of the preview. + // • the pre-migration bytes are read through the FIFO-safe opener: a + // FIFO (socket, directory) squatting `bun.lockb` passes `exists()` + // but wedges a plain open(2) forever — and bun itself blocks on the + // same FIFO — so a non-regular file refuses with + // `redirect_bun_lockb_unsupported` and bun is never spawned. let mut migration_warnings: Vec = Vec::new(); let mut migration_edits: Vec = Vec::new(); // The pre-migration bun.lockb bytes, held so the migration can be undone @@ -1342,69 +1398,101 @@ pub(crate) async fn run_redirect_selected( let has_bun_lock = common.cwd.join("bun.lock").exists(); let has_npm_override = overrides.iter().any(|o| o.ecosystem == "npm"); if has_lockb && !has_bun_lock && has_npm_override { - if common.dry_run { + let lockb_path = common.cwd.join("bun.lockb"); + let siblings = present_lockb_sibling_locks(&common.cwd); + if !siblings.is_empty() { migration_warnings.push(serde_json::json!({ - "code": "redirect_bun_lockb_would_migrate", - "detail": "bun.lockb would be migrated to a text bun.lock \ - (`bun install --save-text-lockfile`) before redirecting; \ - re-run without --dry-run to apply", + "code": "redirect_bun_lockb_sibling_lock", + "detail": lockb_sibling_lock_detail(&siblings), })); + } else if common.dry_run { + // stat, never open: a FIFO would wedge the open, and the preview + // must predict the refusal the wet run makes. + if std::fs::metadata(&lockb_path).is_ok_and(|m| m.is_file()) { + migration_warnings.push(serde_json::json!({ + "code": "redirect_bun_lockb_would_migrate", + "detail": "bun.lockb would be migrated to a text bun.lock \ + (`bun install --save-text-lockfile`) before redirecting; \ + re-run without --dry-run to apply", + })); + } else { + migration_warnings.push(serde_json::json!({ + "code": "redirect_bun_lockb_unsupported", + "detail": LOCKB_NOT_REGULAR_DETAIL, + })); + } } else { - let lockb_path = common.cwd.join("bun.lockb"); // Read the binary lock BEFORE the migration replaces it: the // zero-rewrite unwind and the ledger's restore payload both need - // the original bytes. - let lockb_bytes = std::fs::read(&lockb_path).ok(); - match migrate_bun_lockb(&common.cwd) { - LockbMigration::Migrated => { - // bun 1.1.43–1.1.45 leave bun.lockb beside the new text - // lock; bun ≥ 1.2 deletes it. Remove a kept one ourselves - // so the project is text-only on every release and the - // `removed` record below is true (a stale binary lock - // beside the redirected text lock is also what bun - // ≤ 1.1.38 would silently install the UNPATCHED bytes - // from). NotFound is the ≥ 1.2 case — already gone. - let removal = match std::fs::remove_file(&lockb_path) { - Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e), - _ => Ok(()), - }; - match removal { - Ok(()) => { - lockb_migrated = true; - lockb_backup = lockb_bytes; - // Record the removal so `rollback` knows the file - // was replaced, carrying the pre-migration bytes - // (base64, capped) it needs to put it back. - let original = lockb_original_payload(lockb_backup.as_deref()); - migration_edits.push(socket_patch_core::patch::redirect::FileEdit { - path: "bun.lockb".into(), - kind: "redirect_bun_lockb_migrated".into(), - action: "removed".into(), - key: None, - original, - new: None, - }); - } - Err(e) => { - // Fail closed: a bun.lockb we cannot remove would - // make the `removed` record a lie and leave two - // lockfiles behind. Undo the migration (drop the - // text lock bun just wrote — bun.lockb is intact) - // and refuse exactly like a failed spawn. - let _ = std::fs::remove_file(common.cwd.join("bun.lock")); - migration_warnings.push(serde_json::json!({ - "code": "redirect_bun_lockb_unsupported", - "detail": format!( - "bun.lockb could not be migrated to a text bun.lock (bun \ - wrote bun.lock but the binary lock could not be removed: \ - {e}); the redirect cannot pin a binary lockfile" - ), - })); + // the original bytes. Through the FIFO-safe opener (non-blocking + // open + fstat regular-file check): a FIFO squatting the path + // wedged this read — and would wedge bun's own open — forever. + // Only the non-regular kind refuses; any other read error + // (PermissionDenied) keeps today's contract: the migration + // proceeds and the lock is recorded without restorable bytes. + let lockb_read = socket_patch_core::utils::fs::read_regular_to_bytes_sync(&lockb_path); + if lockb_read + .as_ref() + .is_err_and(|e| e.kind() == std::io::ErrorKind::InvalidInput) + { + migration_warnings.push(serde_json::json!({ + "code": "redirect_bun_lockb_unsupported", + "detail": LOCKB_NOT_REGULAR_DETAIL, + })); + } else { + let lockb_bytes = lockb_read.ok(); + match migrate_bun_lockb(&common.cwd) { + LockbMigration::Migrated => { + // bun 1.1.43–1.1.45 leave bun.lockb beside the new text + // lock; bun ≥ 1.2 deletes it. Remove a kept one ourselves + // so the project is text-only on every release and the + // `removed` record below is true (a stale binary lock + // beside the redirected text lock is also what bun + // ≤ 1.1.38 would silently install the UNPATCHED bytes + // from). NotFound is the ≥ 1.2 case — already gone. + let removal = match std::fs::remove_file(&lockb_path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e), + _ => Ok(()), + }; + match removal { + Ok(()) => { + lockb_migrated = true; + lockb_backup = lockb_bytes; + // Record the removal so `rollback` knows the file + // was replaced, carrying the pre-migration bytes + // (base64, capped) it needs to put it back. + let original = lockb_original_payload(lockb_backup.as_deref()); + migration_edits.push( + socket_patch_core::patch::redirect::FileEdit { + path: "bun.lockb".into(), + kind: "redirect_bun_lockb_migrated".into(), + action: "removed".into(), + key: None, + original, + new: None, + }, + ); + } + Err(e) => { + // Fail closed: a bun.lockb we cannot remove would + // make the `removed` record a lie and leave two + // lockfiles behind. Undo the migration (drop the + // text lock bun just wrote — bun.lockb is intact) + // and refuse exactly like a failed spawn. + let _ = std::fs::remove_file(common.cwd.join("bun.lock")); + migration_warnings.push(serde_json::json!({ + "code": "redirect_bun_lockb_unsupported", + "detail": format!( + "bun.lockb could not be migrated to a text bun.lock (bun \ + wrote bun.lock but the binary lock could not be removed: \ + {e}); the redirect cannot pin a binary lockfile" + ), + })); + } } } - } - LockbMigration::NoLockWritten => { - migration_warnings.push(serde_json::json!({ + LockbMigration::NoLockWritten => { + migration_warnings.push(serde_json::json!({ "code": "redirect_bun_lockb_manual_migration", "detail": "bun.lockb was not migrated: the installed Bun accepted \ `bun install --save-text-lockfile --frozen-lockfile \ @@ -1414,16 +1502,17 @@ pub(crate) async fn run_redirect_selected( --save-text-lockfile` yourself (Bun >= 1.1.39), then re-run; \ the redirect cannot pin a binary lockfile", })); - } - LockbMigration::Failed(why) => { - migration_warnings.push(serde_json::json!({ - "code": "redirect_bun_lockb_unsupported", - "detail": format!( - "bun.lockb could not be migrated to a text bun.lock (`bun install \ - --save-text-lockfile --frozen-lockfile --lockfile-only` failed: \ - {why}); the redirect cannot pin a binary lockfile" - ), - })); + } + LockbMigration::Failed(why) => { + migration_warnings.push(serde_json::json!({ + "code": "redirect_bun_lockb_unsupported", + "detail": format!( + "bun.lockb could not be migrated to a text bun.lock (`bun install \ + --save-text-lockfile --frozen-lockfile --lockfile-only` failed: \ + {why}); the redirect cannot pin a binary lockfile" + ), + })); + } } } } @@ -1450,8 +1539,7 @@ pub(crate) async fn run_redirect_selected( .strip_suffix(".py.lock") .map(|prefix| format!("{prefix}.py")) { - if let Ok(content) = read_regular_to_string(&common.cwd.join(&script_path)).await - { + if let Ok(content) = read_regular_to_string(&common.cwd.join(&script_path)).await { files.insert(script_path, content); } } @@ -2087,7 +2175,11 @@ pub(crate) async fn run_redirect_selected( // invertible link: replay swaps the fragment this run wrote back to // the fragment the very first run found. let mut rebased: Vec = Vec::new(); - for edit in rewrite.edits.iter().filter(|e| REBASE_KINDS.contains(&e.kind.as_str())) { + for edit in rewrite + .edits + .iter() + .filter(|e| REBASE_KINDS.contains(&e.kind.as_str())) + { let siblings: Vec = ledger .edits .iter() @@ -2141,7 +2233,10 @@ pub(crate) async fn run_redirect_selected( let is_rebased = REBASE_KINDS.contains(&edit.kind.as_str()) && rebased.iter().any(|&t| { let old = &ledger.edits[t]; - old.path == edit.path && old.kind == edit.kind && old.key == edit.key && old.new == edit.new + old.path == edit.path + && old.kind == edit.kind + && old.key == edit.key + && old.new == edit.new }); if !is_rebased && !ledger.edits.contains(edit) { ledger.edits.push(edit.clone()); @@ -2477,11 +2572,12 @@ mod tests { use super::{ build_redirect_json_envelope, gem_stale_cache_warning, gem_stale_install_warning, gem_stale_install_warnings, installed_stale_positive_evidence, lockb_original_payload, - output_tail, parse_purl_simple, plan_workspace_trust, pnpm_heal_root, - pnpm_lock_carries_hosted_redirect, pnpm_lock_version_major, pnpm_trust_configured_detail, - pnpm_trust_legacy_detail, pnpm_trust_manual_guidance, - pnpm_trust_workspace_unreadable_detail, read_workspace_for_trust, TrustPlan, - LOCKB_ORIGINAL_CAP, REDIRECT_CANDIDATE_FILES, + lockb_sibling_lock_detail, output_tail, parse_purl_simple, plan_workspace_trust, + pnpm_heal_root, pnpm_lock_carries_hosted_redirect, pnpm_lock_version_major, + pnpm_trust_configured_detail, pnpm_trust_legacy_detail, pnpm_trust_manual_guidance, + pnpm_trust_workspace_unreadable_detail, present_lockb_sibling_locks, + read_workspace_for_trust, TrustPlan, LOCKB_NOT_REGULAR_DETAIL, LOCKB_ORIGINAL_CAP, + LOCKB_SIBLING_LOCKS, REDIRECT_CANDIDATE_FILES, }; use socket_patch_core::constants::npm_family; use socket_patch_core::patch::redirect::DepOverride; @@ -3648,6 +3744,68 @@ mod tests { ); } + /// The sibling-lock probe reports every recognised lock present as a + /// REGULAR file, in the router's precedence order, and nothing for a + /// lockb-only project; `bun.lock` itself is not a sibling (the gate that + /// reaches the probe already requires its absence) and a directory or + /// FIFO squatting a sibling name is not a lock the redirect follows. + #[test] + fn present_lockb_sibling_locks_reports_regular_files_in_router_order() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lockb"), b"\x00BUN").unwrap(); + assert!(present_lockb_sibling_locks(tmp.path()).is_empty()); + std::fs::write(tmp.path().join("package-lock.json"), "{}").unwrap(); + assert_eq!( + present_lockb_sibling_locks(tmp.path()), + vec!["package-lock.json"] + ); + std::fs::write( + tmp.path().join("pnpm-lock.yaml"), + "lockfileVersion: '9.0'\n", + ) + .unwrap(); + std::fs::create_dir(tmp.path().join("yarn.lock")).unwrap(); + assert_eq!( + present_lockb_sibling_locks(tmp.path()), + vec!["pnpm-lock.yaml", "package-lock.json"], + "pnpm first (router precedence), the yarn.lock DIRECTORY skipped" + ); + assert!( + !LOCKB_SIBLING_LOCKS.contains(&"bun.lock") + && !LOCKB_SIBLING_LOCKS.contains(&"bun.lockb") + ); + for name in LOCKB_SIBLING_LOCKS { + assert!( + REDIRECT_CANDIDATE_FILES.contains(&name), + "{name} must be a lock the hosted rewriters actually read" + ); + } + } + + /// The sibling warning names the sibling(s) twice — as the reason and as + /// what the redirect follows — and both remedies, agreeing in number. + #[test] + fn lockb_sibling_lock_detail_names_the_sibling_and_both_remedies() { + let one = lockb_sibling_lock_detail(&["package-lock.json"]); + assert_eq!( + one, + "bun.lockb was left alone because package-lock.json is also present; the redirect \ + follows package-lock.json — delete the stale bun.lockb if it is debris, or remove \ + package-lock.json and re-run if bun is the installer" + ); + let two = lockb_sibling_lock_detail(&["pnpm-lock.yaml", "yarn.lock"]); + assert!( + two.starts_with( + "bun.lockb was left alone because pnpm-lock.yaml, yarn.lock are also present" + ) && two.contains("remove pnpm-lock.yaml, yarn.lock and re-run"), + "{two}" + ); + assert_eq!( + LOCKB_NOT_REGULAR_DETAIL, + "bun.lockb is not a regular file; refusing to migrate it" + ); + } + /// The failure detail carries bun's own last lines: stderr first, blank /// lines dropped, trimmed, capped to the newest `max_lines`, one line. #[test] diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs index cbef1d7d..ba8ad4a7 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs @@ -654,10 +654,9 @@ async fn cargo_socket_owned_wiring_without_ledger_refuses_the_redirect() { "the refusal must name the missing ledger: {detail}" ); assert!( - doc["redirect"]["skipped"] - .as_array() - .is_some_and(|s| s.iter().any(|e| e["purl"] == CPURL - && e["reason"] == "vendored_revert_failed")), + doc["redirect"]["skipped"].as_array().is_some_and(|s| s + .iter() + .any(|e| e["purl"] == CPURL && e["reason"] == "vendored_revert_failed")), "the refusal must be accounted as skipped: {doc:#}" ); assert_eq!(doc["redirect"]["redirected"], 0, "envelope: {doc:#}"); @@ -761,7 +760,10 @@ async fn failed_bun_lockb_migration_warns_unsupported_and_keeps_the_binary_lock( &[], &[("PATH", path_value.as_str())], ); - assert_eq!(code, 0, "a failed migration is a warning, not an error: {doc:#}"); + assert_eq!( + code, 0, + "a failed migration is a warning, not an error: {doc:#}" + ); let detail = warning_detail(&doc, "redirect_bun_lockb_unsupported"); assert!( detail.contains("cannot pin a binary lockfile") && detail.contains("exit status: 1"), @@ -855,7 +857,10 @@ async fn unreadable_bun_lockb_backup_keeps_the_migration_and_warns_loudly() { &[], &[("PATH", path_value.as_str())], ); - assert_eq!(code, 0, "the kept migration is a warning, not an error: {doc:#}"); + assert_eq!( + code, 0, + "the kept migration is a warning, not an error: {doc:#}" + ); let detail = warning_detail(&doc, "redirect_bun_lockb_migrated_without_redirect"); assert!( detail.contains("git history is the restore path"), @@ -889,6 +894,368 @@ async fn unreadable_bun_lockb_backup_keeps_the_migration_and_warns_loudly() { ); } +/// A fake `bun` that records the spawn in `marker` (an absolute path, so the +/// child's cwd is irrelevant) and then fails. Every test below asserts the +/// marker is ABSENT: the gate under test must refuse before any spawn. +#[cfg(unix)] +fn install_marker_bun_shim(root: &Path) -> (String, std::path::PathBuf) { + let marker = root.join("bun-was-spawned"); + let path_value = install_bun_shim( + root, + &format!("#!/bin/sh\ntouch \"{}\"\nexit 1\n", marker.display()), + ); + (path_value, marker) +} + +#[cfg(unix)] +fn mkfifo(path: &Path) { + use std::os::unix::ffi::OsStrExt as _; + let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap(); + let rc = unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) }; + assert_eq!(rc, 0, "mkfifo(2): {}", std::io::Error::last_os_error()); +} + +/// `scan_hosted_json` on a worker thread with a deadline: a wedged run never +/// returns, so on timeout a writer is connected to `fifo` (releasing any +/// reader blocked in open(2)) and the test FAILS instead of hanging the +/// suite. The deadline is generous on purpose — a healthy run finishes in +/// seconds, the regression under test never finishes — so a slow CI box +/// (first-launch dyld stall, parallel test load) cannot turn into a flake. +#[cfg(unix)] +fn scan_hosted_json_or_release_fifo( + cwd: &Path, + api_url: &str, + extra: &[&str], + env: &[(&str, &str)], + fifo: &Path, +) -> (i32, Value) { + let cwd = cwd.to_path_buf(); + let api_url = api_url.to_string(); + let extra: Vec = extra.iter().map(|s| s.to_string()).collect(); + let env: Vec<(String, String)> = env + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let extra: Vec<&str> = extra.iter().map(String::as_str).collect(); + let env: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); + let _ = tx.send(scan_hosted_json(&cwd, &api_url, &extra, &env)); + }); + let deadline = std::time::Duration::from_secs(60); + match rx.recv_timeout(deadline) { + Ok(result) => result, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + use std::os::unix::fs::OpenOptionsExt as _; + let released = std::fs::OpenOptions::new() + .write(true) + .custom_flags(libc::O_NONBLOCK) + .open(fifo) + .is_ok(); + panic!( + "scan --mode hosted wedged on a FIFO bun.lockb for {deadline:?} (a reader \ + was blocked in open(2): {released})" + ); + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + panic!("the scan thread panicked before reporting (see the output above)") + } + } +} + +/// A FIFO squatting `bun.lockb` passes the `exists()` gate but is not a +/// lock the migration can capture: the pre-migration read goes through the +/// FIFO-safe opener and refuses `redirect_bun_lockb_unsupported` ("not a +/// regular file") BEFORE spawning bun — bun's own open of the lock blocks on +/// the same FIFO, so a guarded read that still spawned would only relocate +/// the hang into the child. Pinned: the run returns (a plain `std::fs::read` +/// wedged forever here), the shim never ran, the FIFO is left in place, no +/// text lock appears, and `--dry-run` predicts the same refusal instead of +/// the would-migrate preview. +#[cfg(unix)] +#[tokio::test] +async fn fifo_bun_lockb_refuses_before_spawning_bun_and_never_wedges() { + let server = MockServer::start().await; + mock_discovery(&server, PURL, UUID).await; + mock_granted_reference(&server, UUID, PURL, HOSTED_URL).await; + + let tmp = tempfile::tempdir().unwrap(); + write_bun_lockb_project(tmp.path()); + let fifo = tmp.path().join("bun.lockb"); + std::fs::remove_file(&fifo).unwrap(); + mkfifo(&fifo); + let (path_value, marker) = install_marker_bun_shim(tmp.path()); + let env = [("PATH", path_value.as_str())]; + + for extra in [&["--dry-run"][..], &[][..]] { + let (code, doc) = + scan_hosted_json_or_release_fifo(tmp.path(), &server.uri(), extra, &env, &fifo); + assert_eq!( + code, 0, + "{extra:?}: the refusal is a warning, not an error: {doc:#}" + ); + let detail = warning_detail(&doc, "redirect_bun_lockb_unsupported"); + assert_eq!( + detail, "bun.lockb is not a regular file; refusing to migrate it", + "{extra:?}: the refusal must name the reason, not a bun failure" + ); + let codes = warning_codes(&doc); + assert_eq!( + codes + .iter() + .filter(|c| *c == "redirect_bun_lockb_unsupported") + .count(), + 1, + "{extra:?}: exactly one unsupported warning: {codes:?}" + ); + for absent in [ + "redirect_bun_lockb_would_migrate", + "redirect_bun_lockb_manual_migration", + "redirect_bun_lockb_migrated_without_redirect", + "redirect_npm_no_lockfile", + ] { + assert!( + !codes.contains(&absent.to_string()), + "{extra:?}: {absent} must not accompany the not-a-regular-file refusal: {codes:?}" + ); + } + assert_eq!(doc["redirect"]["redirected"], 0, "{extra:?}: {doc:#}"); + assert!( + !marker.exists(), + "{extra:?}: bun must never be spawned on a FIFO bun.lockb (it blocks on it too)" + ); + let meta = std::fs::symlink_metadata(&fifo).unwrap(); + assert!( + std::os::unix::fs::FileTypeExt::is_fifo(&meta.file_type()), + "{extra:?}: the FIFO is left in place, never replaced or removed" + ); + assert!( + !tmp.path().join("bun.lock").exists(), + "{extra:?}: no text lock may appear" + ); + } +} + +/// The verbatim `redirect_bun_lockb_sibling_lock` detail for one sibling — +/// the string the contract documents. +#[cfg(unix)] +fn sibling_lock_detail(sibling: &str) -> String { + format!( + "bun.lockb was left alone because {sibling} is also present; the redirect follows \ + {sibling} — delete the stale bun.lockb if it is debris, or remove {sibling} and re-run \ + if bun is the installer" + ) +} + +/// Shared assertions for a stale `bun.lockb` beside a live sibling lock: +/// the migration is skipped with `redirect_bun_lockb_sibling_lock` naming +/// the sibling (dry-run: instead of the would-migrate preview), bun is never +/// spawned, bun.lockb is byte-identical, no text lock appears, and the +/// redirect lands in (or, dry-run, previews) the sibling lock. +#[cfg(unix)] +fn assert_sibling_lock_outcome( + doc: &Value, + root: &Path, + sibling: &str, + lockb_before: &[u8], + marker: &Path, + dry_run: bool, +) { + assert_eq!( + warning_detail(doc, "redirect_bun_lockb_sibling_lock"), + sibling_lock_detail(sibling), + "dry_run={dry_run}: the warning must name the sibling and both remedies" + ); + let codes = warning_codes(doc); + for absent in [ + "redirect_bun_lockb_would_migrate", + "redirect_bun_lockb_unsupported", + "redirect_bun_lockb_manual_migration", + "redirect_bun_lockb_migration_reverted", + "redirect_bun_lockb_migrated_without_redirect", + "redirect_npm_no_lockfile", + ] { + assert!( + !codes.contains(&absent.to_string()), + "dry_run={dry_run}: {absent} must not accompany the sibling-lock skip: {codes:?}" + ); + } + assert_eq!(doc["redirect"]["dryRun"], dry_run, "{doc:#}"); + let rewritten: Vec<&str> = doc["redirect"]["rewrittenFiles"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect(); + assert!( + rewritten.contains(&sibling), + "dry_run={dry_run}: the redirect must follow the sibling lock: {doc:#}" + ); + assert!( + !rewritten.contains(&"bun.lock"), + "dry_run={dry_run}: no bun.lock may be rewritten (none was created): {doc:#}" + ); + assert_eq!( + doc["redirect"]["redirected"], 1, + "dry_run={dry_run}: {doc:#}" + ); + assert!( + !marker.exists(), + "dry_run={dry_run}: bun must never be spawned beside a live {sibling}" + ); + assert_eq!( + std::fs::read(root.join("bun.lockb")).unwrap(), + lockb_before, + "dry_run={dry_run}: the stale bun.lockb is left byte-identical" + ); + assert!( + !root.join("bun.lock").exists(), + "dry_run={dry_run}: no text lock may be created for a {sibling} project" + ); +} + +/// A project that migrated from bun to npm and left `bun.lockb` committed: +/// the hosted driver must NOT run the lockb→text migration (it would turn the +/// npm project into a bun.lock project — bun 1.4.2 really did, deleting +/// bun.lockb and writing a lockfileVersion-2 bun.lock beside the redirected +/// package-lock.json). The redirect follows package-lock.json as today, the +/// binary lock is left alone with `redirect_bun_lockb_sibling_lock`, and the +/// human leg prints that detail. +#[cfg(unix)] +#[tokio::test] +async fn stale_bun_lockb_beside_package_lock_is_left_alone_and_the_redirect_follows_it() { + let server = MockServer::start().await; + mock_discovery(&server, PURL, UUID).await; + mock_granted_reference(&server, UUID, PURL, HOSTED_URL).await; + mock_view(&server, UUID, PURL).await; + + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), NAME); + let lockb_before = b"\x00stale-bun-lockb-debris".to_vec(); + std::fs::write(tmp.path().join("bun.lockb"), &lockb_before).unwrap(); + let (path_value, marker) = install_marker_bun_shim(tmp.path()); + let env = [("PATH", path_value.as_str())]; + + // Dry-run: the same code, never the would-migrate preview. + let (code, doc) = scan_hosted_json(tmp.path(), &server.uri(), &["--dry-run"], &env); + assert_eq!(code, 0, "dry-run exits 0: {doc:#}"); + assert_sibling_lock_outcome( + &doc, + tmp.path(), + "package-lock.json", + &lockb_before, + &marker, + true, + ); + let lock_before = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); + assert!( + !lock_before.contains(HOSTED_URL), + "dry-run must not rewrite the sibling lock" + ); + + // Live: package-lock.json is redirected, bun.lockb untouched, no spawn. + let (code, doc) = scan_hosted_json(tmp.path(), &server.uri(), &[], &env); + assert_eq!(code, 0, "live run exits 0: {doc:#}"); + assert_sibling_lock_outcome( + &doc, + tmp.path(), + "package-lock.json", + &lockb_before, + &marker, + false, + ); + let lock = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); + assert!( + lock.contains(&format!("\"resolved\": \"{HOSTED_URL}\"")), + "the redirect must land in package-lock.json:\n{lock}" + ); + let ledger: Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(), + ) + .unwrap(); + assert!( + ledger["edits"] + .as_array() + .unwrap() + .iter() + .all(|e| e["path"] != "bun.lockb" && e["kind"] != "redirect_bun_lockb_migrated"), + "no lockb migration may be recorded: {ledger:#}" + ); + + // Human leg (fresh project so the warning fires again): the + // migration-warning loop prints the bare detail. + let tmp2 = tempfile::tempdir().unwrap(); + write_npm_project(tmp2.path(), NAME); + std::fs::write(tmp2.path().join("bun.lockb"), &lockb_before).unwrap(); + let (path_value2, marker2) = install_marker_bun_shim(tmp2.path()); + let (code, _stdout, stderr) = scan_hosted( + tmp2.path(), + &server.uri(), + &[], + &[("PATH", path_value2.as_str())], + ); + assert_eq!(code, 0, "human run exits 0; stderr=\n{stderr}"); + assert!( + stderr.contains(&format!( + " warning: {}", + sibling_lock_detail("package-lock.json") + )), + "the sibling-lock detail must reach human stderr; stderr=\n{stderr}" + ); + assert!(!marker2.exists(), "human leg: bun must never be spawned"); + assert_eq!( + std::fs::read(tmp2.path().join("bun.lockb")).unwrap(), + lockb_before + ); +} + +/// The pnpm twin: a stale `bun.lockb` beside a live v9 `pnpm-lock.yaml`. The +/// lock the redirect follows is pnpm's; the binary lock is left alone, bun +/// is never spawned, and the npm rewriter's no-lockfile noise stays +/// suppressed by the core's own sibling gate. +#[cfg(unix)] +#[tokio::test] +async fn stale_bun_lockb_beside_pnpm_lock_is_left_alone_and_the_redirect_follows_it() { + let server = MockServer::start().await; + mock_discovery(&server, PURL, UUID).await; + mock_granted_reference(&server, UUID, PURL, HOSTED_URL).await; + mock_view(&server, UUID, PURL).await; + + let tmp = tempfile::tempdir().unwrap(); + write_pnpm_project(tmp.path()); + let lockb_before = b"\x00stale-bun-lockb-debris".to_vec(); + std::fs::write(tmp.path().join("bun.lockb"), &lockb_before).unwrap(); + let (path_value, marker) = install_marker_bun_shim(tmp.path()); + let env = [("PATH", path_value.as_str())]; + + let (code, doc) = scan_hosted_json(tmp.path(), &server.uri(), &["--dry-run"], &env); + assert_eq!(code, 0, "dry-run exits 0: {doc:#}"); + assert_sibling_lock_outcome( + &doc, + tmp.path(), + "pnpm-lock.yaml", + &lockb_before, + &marker, + true, + ); + + let (code, doc) = scan_hosted_json(tmp.path(), &server.uri(), &[], &env); + assert_eq!(code, 0, "live run exits 0: {doc:#}"); + assert_sibling_lock_outcome( + &doc, + tmp.path(), + "pnpm-lock.yaml", + &lockb_before, + &marker, + false, + ); + let lock = std::fs::read_to_string(tmp.path().join("pnpm-lock.yaml")).unwrap(); + assert!( + lock.contains(&format!("tarball: {HOSTED_URL}")), + "the redirect must land in pnpm-lock.yaml:\n{lock}" + ); +} + // ───────────── live unreadable pnpm-workspace.yaml fallback (1450) ───────────── /// Production wiring of the present-but-unreadable pnpm-workspace.yaml arm @@ -921,7 +1288,10 @@ async fn unreadable_pnpm_workspace_gets_warning_only_guidance_in_a_live_run() { // be read back. std::fs::set_permissions(&ws, std::fs::Permissions::from_mode(0o644)).unwrap(); - assert_eq!(code, 0, "the fallback is warning-only, never an error: {doc:#}"); + assert_eq!( + code, 0, + "the fallback is warning-only, never an error: {doc:#}" + ); let detail = warning_detail(&doc, "redirect_pnpm_trust_lockfile"); assert!( detail.contains("exists but could not be read") && detail.contains("left untouched"), @@ -1050,7 +1420,10 @@ async fn live_hosted_overlap_fires_redirect_supersedes_vendored() { ); let (code, doc) = scan_hosted_json(root, &server.uri(), &[], &[]); - assert_eq!(code, 0, "the overlap warning never flips the exit code: {doc:#}"); + assert_eq!( + code, 0, + "the overlap warning never flips the exit code: {doc:#}" + ); assert_eq!( doc["redirect"]["redirected"], 1, "anchor: Y must redirect normally: {doc:#}" @@ -1065,8 +1438,7 @@ async fn live_hosted_overlap_fires_redirect_supersedes_vendored() { "the per-package remediation must be prescribed: {detail}" ); // Warn-only contract: the stale vendored ledger is NOT deleted. - let state = - std::fs::read_to_string(root.join(".socket/vendor/state.json")).unwrap(); + let state = std::fs::read_to_string(root.join(".socket/vendor/state.json")).unwrap(); assert!( state.contains(XPURL), "the takeover warning must not delete the other mode's ledger: {state}" @@ -1110,7 +1482,10 @@ async fn human_dry_run_prints_would_rewrite_pnpm_guidance_and_vex_skip() { ], &[], ); - assert_eq!(code, 0, "dry-run exits 0; stdout=\n{stdout}\nstderr=\n{stderr}"); + assert_eq!( + code, 0, + "dry-run exits 0; stdout=\n{stdout}\nstderr=\n{stderr}" + ); assert!( stdout.contains("Redirected 1 package(s)") && stdout.contains("; would rewrite"), "the dry-run summary must use the preview verb; stdout=\n{stdout}" @@ -1159,7 +1534,10 @@ async fn human_vex_success_summary_names_statements_path_and_ledger_caveat() { ], &[], ); - assert_eq!(code, 0, "scan --vex exits 0; stdout=\n{stdout}\nstderr=\n{stderr}"); + assert_eq!( + code, 0, + "scan --vex exits 0; stdout=\n{stdout}\nstderr=\n{stderr}" + ); assert!( stdout.contains("Redirected 1 package(s); rewrote"), "anchor: the wet-run summary verb; stdout=\n{stdout}" @@ -1173,10 +1551,9 @@ async fn human_vex_success_summary_names_statements_path_and_ledger_caveat() { stderr.contains("attested from the ledger"), "the no-verify caveat is load-bearing; stderr=\n{stderr}" ); - let doc: Value = serde_json::from_str( - &std::fs::read_to_string(tmp.path().join("out.vex.json")).unwrap(), - ) - .unwrap(); + let doc: Value = + serde_json::from_str(&std::fs::read_to_string(tmp.path().join("out.vex.json")).unwrap()) + .unwrap(); assert_eq!( doc["statements"].as_array().map(Vec::len), Some(1), @@ -1199,7 +1576,10 @@ async fn human_rush_run_prints_the_repo_state_stale_warning_line() { write_rush_project(tmp.path()); let (code, stdout, stderr) = scan_hosted(tmp.path(), &server.uri(), &[], &[]); - assert_eq!(code, 0, "rush run exits 0; stdout=\n{stdout}\nstderr=\n{stderr}"); + assert_eq!( + code, 0, + "rush run exits 0; stdout=\n{stdout}\nstderr=\n{stderr}" + ); assert!( stdout.contains("Redirected 1 package(s); rewrote"), "anchor: the rush lock must be rewritten; stdout=\n{stdout}" @@ -1269,10 +1649,9 @@ async fn ledger_save_failure_after_successful_revert_fails_closed() { "the post-revert ledger-save failure must be named: {detail}" ); assert!( - doc["redirect"]["skipped"] - .as_array() - .is_some_and(|s| s.iter().any(|e| e["purl"] == PURL - && e["reason"] == "vendored_revert_failed")), + doc["redirect"]["skipped"].as_array().is_some_and(|s| s + .iter() + .any(|e| e["purl"] == PURL && e["reason"] == "vendored_revert_failed")), "the refusal must be accounted as skipped: {doc:#}" ); assert_eq!(doc["redirect"]["redirected"], 0, "envelope: {doc:#}"); diff --git a/crates/socket-patch-core/src/utils/fs.rs b/crates/socket-patch-core/src/utils/fs.rs index e69ff91f..cdb77010 100644 --- a/crates/socket-patch-core/src/utils/fs.rs +++ b/crates/socket-patch-core/src/utils/fs.rs @@ -170,8 +170,34 @@ pub async fn is_symlink(path: &Path) -> bool { pub fn read_regular_to_string_sync(path: &Path) -> std::io::Result { use std::io::Read as _; + let (mut file, metadata) = open_regular_file_sync(path)?; + let mut content = String::with_capacity(metadata.len() as usize); + file.read_to_string(&mut content)?; + Ok(content) +} + +/// Raw-bytes twin of [`read_regular_to_string_sync`] for BINARY project +/// files the CLI captures verbatim (the hosted flow's pre-migration +/// `bun.lockb` snapshot): same non-blocking open + fstat regular-file check, +/// so a FIFO squatting the path fails fast with `InvalidInput` instead of +/// wedging in open(2) — and the caller can refuse BEFORE spawning a tool +/// that would block on the same FIFO. No UTF-8 decode, so a binary lock +/// never fails with `InvalidData`; every other error keeps its kind. +pub fn read_regular_to_bytes_sync(path: &Path) -> std::io::Result> { + use std::io::Read as _; + + let (mut file, metadata) = open_regular_file_sync(path)?; + let mut content = Vec::with_capacity(metadata.len() as usize); + file.read_to_end(&mut content)?; + Ok(content) +} + +/// Blocking twin of [`open_regular_file`]: `O_NONBLOCK` open on Unix, then +/// the handle-based regular-file check, so the two sync readers above share +/// one guard instead of re-declaring it. +fn open_regular_file_sync(path: &Path) -> std::io::Result<(std::fs::File, std::fs::Metadata)> { #[cfg(unix)] - let mut file = { + let file = { use std::os::unix::fs::OpenOptionsExt as _; std::fs::OpenOptions::new() .read(true) @@ -179,7 +205,7 @@ pub fn read_regular_to_string_sync(path: &Path) -> std::io::Result { .open(path)? }; #[cfg(not(unix))] - let mut file = std::fs::File::open(path)?; + let file = std::fs::File::open(path)?; let metadata = file.metadata()?; if !metadata.is_file() { @@ -188,9 +214,7 @@ pub fn read_regular_to_string_sync(path: &Path) -> std::io::Result { format!("{} is not a regular file", path.display()), )); } - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content)?; - Ok(content) + Ok((file, metadata)) } /// The first of `rels` (root-relative, in the caller's order) that is a @@ -917,6 +941,64 @@ mod tests { } } + /// The bytes twin returns a binary file VERBATIM (no UTF-8 decode, so + /// bytes that would be `InvalidData` for the string reader are fine), + /// keeps `NotFound` for an absent path and classifies a directory like + /// the string reader. + #[test] + fn read_regular_to_bytes_sync_returns_binary_verbatim_and_classifies_errors() { + let tmp = tempfile::tempdir().unwrap(); + let missing = read_regular_to_bytes_sync(&tmp.path().join("absent")).unwrap_err(); + assert_eq!(missing.kind(), std::io::ErrorKind::NotFound); + let dir = read_regular_to_bytes_sync(tmp.path()).unwrap_err(); + #[cfg(unix)] + assert_eq!(dir.kind(), std::io::ErrorKind::InvalidInput, "{dir}"); + #[cfg(not(unix))] + assert!( + matches!( + dir.kind(), + std::io::ErrorKind::InvalidInput | std::io::ErrorKind::PermissionDenied + ), + "{dir}" + ); + let lockb = tmp.path().join("bun.lockb"); + let bytes: Vec = vec![0x00, 0xff, 0xfe, b'b', b'u', b'n', 0x00, 0x80]; + std::fs::write(&lockb, &bytes).unwrap(); + assert_eq!(read_regular_to_bytes_sync(&lockb).unwrap(), bytes); + #[cfg(unix)] + { + let link = tmp.path().join("link.lockb"); + std::os::unix::fs::symlink("bun.lockb", &link).unwrap(); + assert_eq!(read_regular_to_bytes_sync(&link).unwrap(), bytes); + } + } + + /// A FIFO squatting `bun.lockb` must fail fast (`InvalidInput`), never + /// block in open(2): the hosted driver refuses the lockb migration on + /// this error BEFORE spawning bun (which would block on the same FIFO). + #[cfg(unix)] + #[test] + fn read_regular_to_bytes_sync_rejects_a_fifo_without_blocking() { + use std::os::unix::ffi::OsStrExt as _; + let tmp = tempfile::tempdir().unwrap(); + let fifo = tmp.path().join("bun.lockb"); + let c_path = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) }, 0); + let (tx, rx) = std::sync::mpsc::channel(); + let probe = fifo.clone(); + std::thread::spawn(move || { + let _ = tx.send(read_regular_to_bytes_sync(&probe).map_err(|e| e.kind())); + }); + match rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(result) => assert_eq!(result, Err(std::io::ErrorKind::InvalidInput)), + Err(_) => { + // Release the wedged opener so the suite can fail cleanly. + let _ = std::fs::OpenOptions::new().write(true).open(&fifo); + panic!("the sync bytes reader wedged in open(2) on a FIFO"); + } + } + } + /// `first_symlink` reports the first LINK in iteration order, treats /// absent paths (files a rewrite would create) as non-links and does not /// follow the link to judge its target. From 6b66ee30a0d98fdf3665ee7191e6a463a679b3ee Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 21 Sep 2026 12:57:30 -0400 Subject: [PATCH 37/49] fix(process): spawn resolved .cmd/.bat shims directly; std quotes them correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `command_for` launched a Windows batch shim as `cmd.exe /C `. std quotes that path as an ordinary argument, and cmd's `/c` rule keeps the quotes only when the quoted string has no `& < > ( ) @ ^ |`, so a shim under a directory with a space AND a metacharacter (`C:\Program Files (x86)\...\bun.cmd`, `C:\Users\Jane (Work)\...`) was stripped to `C:\Program` and failed with "is not recognized" — bun degraded to `redirect_bun_lockb_unsupported`, pipenv's installed major to None — although the shim works in the user's shell. Rust std >= 1.77.2 (the toolchain pins 1.93.1) already detects `.bat`/`.cmd` on the resolved program and spawns `%SystemRoot%\System32\cmd.exe /e:ON /v:OFF /d /c ""