diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 054e3491f..e2bc9b3d9 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1601,6 +1601,7 @@ command's integration tests check its output. | `vendor_vlt_legacy_lockfile` | `skipped` (warning) | vendor (vlt): an era-A lock (vlt 0.0.0-19 … 1.0.0-rc.8): a `··` default-registry id, or default-registry ids that are URL segments equal to a scalar `options.registry` with no `·npm·` id (era B writes `·npm·` whatever the scalar). vlt 0.0.0-31 … 1.0.0-rc.5 install the vendored lock but fail to reinstall the vendored `file:` dependency if `vlt-lock.json` is deleted and re-created (the other era-A releases reinstall it; the lock does not say which release reads it). The package is still vendored; remedy: upgrade vlt. | | `vendor_vlt_reinstall_required` | `skipped` (advisory; human: `Warning: …`) | vendor / scan / get `--mode vendored` (vlt), wet and dry runs, and in-sync reruns: (a) the run rewires an optional dependency, or an importer's `node_modules/` of an optional dependency still resolves into `node_modules/.vlt/`: from vlt 0.0.0-30 a plain `vlt install` (1.2.0: also `--force`) keeps that installed upstream copy linked; the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`) to link the vendored copy, and that vlt 0.0.0-30 … 1.0.4 install no optional dependency from the lock of a project that declares only optional dependencies (upgrade to 1.0.5 or later first); (b) otherwise, an importer's link of the dependency still resolves into `node_modules/.vlt/`: the detail names the links (`node_modules/`, `/node_modules/`) and says `vlt install` (or `vlt ci`) links the vendored copy — on a warm tree after a plain `vlt install` that is true of every vendored direct dependency; (c) an importer's link resolves into the vendored dir of the patch this run replaces (a new patch uuid), which the run removes: the detail names the links and says `vlt install` (or `vlt ci`) links the new vendored copy; (d) a redownload of the payload (vendor, or `repair` after a corrupt or missing payload) could not keep vlt's links to the package's own dependencies (its old `node_modules/` held more than links): the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`), since a plain `vlt install` does not re-link them. `repair` moves those links back into the downloaded payload when they are only links. The package is vendored either way; a run whose patch fails to apply emits neither. A wet `vendor --revert` (and the revert a vendored → hosted takeover runs, whose advisory joins `warnings[]`): (a) the revert moves an `optionalDependencies` spec back from the `file:` dir, or an optional importer's `node_modules/` still resolves into the vendored uuid dir: from vlt 0.0.0-30 a plain `vlt install` keeps that link (dangling once the dir is removed), so the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`) to link the restored copy, with the same vlt 1.0.5 note; (b) otherwise, an importer's link still resolves into the vendored uuid dir: the detail names the links and says `vlt install` (or `vlt ci`) links the restored copy. A dry-run revert emits neither. | | `vendor_bun_reinstall_required` | `skipped` (advisory; human: `Warning: …`); rollback/remove `warnings[]`; `scan --prune` top-level `warnings[]` (human: `GC: …`) | a wet Bun revert (`vendor --revert`, rollback / remove of a vendored entry, `--preserve-state` included) that restored the lock entry while `node_modules/` is a real directory, or the tree has no `node_modules/.bun/` (a hoisted install): Bun's hoisted linker does not re-extract a package whose lock entry moves from the vendored tarball back to the registry record of the same `name@version`, so a plain `bun install` (also `--frozen-lockfile`) reports no changes and keeps the vendored bytes (measured on 1.1.45 … 1.4.2). The detail names `name@version` and says to run `bun install --force` (or delete `node_modules` and run `bun install`); the human revert hint names `bun install --force` too. An isolated install (a link into `node_modules/.bun/`) relinks and a project without `node_modules/` has nothing installed: neither warns, and neither does a dry run, a drift-kept revert, or a revert that restored nothing (`vendor_lockfile_missing`, or `vendor_lock_entry_removed` after `bun remove`, whose copy a plain `bun install` prunes). | +| `vendor_bun_patched_dependency_dropped` | `skipped` (advisory; human: `Warning: …`); rollback/remove `warnings[]` | a wet Bun revert (`vendor --revert`, rollback / remove of a vendored entry) took out of the lock the vendored tarball a project's own `bun patch` is keyed by (`name@.socket/vendor/npm//-.tgz`, written by Bun 1.4, #1019): `bun install` stops applying that patch, without failing. Only keys the lock resolved to before the revert and no longer does warn. The revert still goes ahead; the detail names the key and says to re-create the patch or remove the entry. | | `vendor_flavor_changed` | `failed` | vendor (npm): the purl's vendor ledger entry was written for another lockfile `flavor` than the one the router now detects (for example `npm` → `vlt` after switching package managers). Remedy: `socket-patch vendor --revert` it first, then re-vendor. Refused before any write. | | `vendor_dep_manifest_unlocked` | refused | vendor (yarn classic): the patch rewrites the package's own `package.json` to depend on a descriptor (`name@range`) no `yarn.lock` block is keyed by — an added dependency, or an existing one moved to a new range. A descriptor every wired block's sub-map already names is not counted: yarn keys those blocks by the registry packument's ranges, which can differ from the tarball's `package.json` (#1578). yarn 1 builds its install graph from the lock, so the rewired block would name a dependency it never resolves: online frozen installs fetch it unpinned, `--offline` installs fail and every plain `yarn install` re-saves the lock (#591). Refused after staging and before any wiring is written (the staged uuid dir is removed); the detail names the descriptors. Remedy: lock them first (for example `yarn add `), then re-run. A dry run, which stages nothing, does not foresee it. Also `failed` on `repair` (and `vendor_check_failed` on `vendor --check`) for a lock an older release already vendored that way, with a block wired to the entry naming a descriptor no block locks and the recorded pre-vendor block did not (#1379); the lock is left as it is. Remedy: `yarn install` (without `--frozen-lockfile`) to lock them, then commit `yarn.lock`. | | `redirect_yarn_classic_dep_manifest_unlocked` | `warnings[]` (warning) | scan/get `--mode hosted` (yarn classic): the served tarball's own `package.json` depends on a descriptor no `yarn.lock` block is keyed by and that the sub-maps of the registry blocks being pinned don't already name (yarn keys those by the packument's ranges, #1578). yarn 1 installs only what the lock names, so a pin would install the patched package without that dependency (#591). The dep is not pinned and never confirmed; the lock is left as it was. The served tarball is read on every run, so a pin an earlier release wrote without this check (#1462) gets the same warning and is never confirmed either; it stays in the lock until the dependency is locked or the pin is rolled back. Same remedy as `vendor_dep_manifest_unlocked`. | @@ -1653,7 +1654,8 @@ command's integration tests check its output. | `redirect_bun_workspace_unsupported` | `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. Detail: "Bun version-0 workspace locks cannot preserve hosted tarballs on frozen installs; delete bun.lock and re-run `bun install` with Bun >= 1.2 (which writes lockfileVersion 1, accepted by hosted mode) — a plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root -> member); otherwise it keeps version 0 or fails to resolve" (measured: Bun 1.2.0 keeps 0, 1.2.23–1.4.2 exit 1 "failed to resolve" on a root that does not depend on its members). Version-1/2 workspace locks are rewritten. Exit 0. | | `redirect_bun_lockb_invalid` | `warnings[]` (warning) | scan/get `--mode hosted`: the native binary lock is malformed, unreadable, unsupported or cannot be rewritten safely. No installer is spawned and no binary or sibling npm lock edit or takeover occurs; dry-run reports the same format error. Exit 0, `redirected: 0`. | | `redirect_bun_entry_not_found` / `redirect_bun_missing_sha512` | `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 whose leaf names no version or another version; a same-version user tarball reports `redirect_bun_non_registry_entry_skipped` instead) / 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. | -| `redirect_bun_patched_dependency_skipped` | `warnings[]` (warning) | scan/get `--mode hosted` (bun, `bun.lock` and `bun.lockb`): the project patches the granted `name@version` itself with `bun patch` (a `patchedDependencies` key for `name@version`, or the bare name, in the root `package.json` or mirrored in `bun.lock`). Bun applies that patch only to the registry resolution, so the entry is left on its registry tuple instead of silently losing the user's patch (#367). Per-dep; the detail names the key and the remedy (fold the Socket fix into the user's patch, or drop the `patchedDependencies` entry and re-run); the in-run VEX never assumes the uuid applied. Vendored mode refuses the same package `vendor_lock_entry_unsupported` before any write or download. Exit 0. | +| `redirect_bun_patched_dependency_skipped` | `warnings[]` (warning) | scan/get `--mode hosted` (bun, `bun.lock` and `bun.lockb`): the project patches the granted `name@version` itself with `bun patch` (a `patchedDependencies` key for `name@version`, or the bare name, in the root `package.json` or mirrored in `bun.lock`; or, for a `bun patch` made after a rewire, `name@` of the hosted or vendored tarball the package resolves to, which Bun 1.4 writes, #1019). Bun applies that patch only to the resolution it is keyed on, so the entry is left where it is (its registry tuple, or the older hosted URL or vendored tarball, which a superseding re-run or a vendored → hosted takeover keeps, `redirect_takeover_kept_vendored`) instead of silently losing the user's patch (#367). A key for the URL this run would pin (an in-sync re-run) is no conflict; any other tarball key for the package counts, even one the lock no longer resolves to (a takeover checks after reverting the wiring the key names), and the remedy clears a stale one. Per-dep; the detail names the key and the remedy (fold the Socket fix into the user's patch, or drop the `patchedDependencies` entry and re-run); the in-run VEX never assumes the uuid applied. Vendored mode refuses the same package `vendor_lock_entry_unsupported` before any write or download, a hosted → vendored takeover included (the hosted wiring stays). Unlike the `user_patch_conflict` consent gate, `--allow-patch-conflicts` does not lift this. Exit 0. | +| `redirect_bun_patched_dependency_dropped` | rollback/remove `warnings[]` (+ human stderr) | a wet hosted unwind took out of `bun.lock` / `bun.lockb` the hosted URL a project's own `bun patch` is keyed by (`name@`, the key Bun 1.4 writes for a `bun patch` made after the rewire, #1019): from then on `bun install` stops applying that patch, without failing. Only keys the lock resolved to before the unwind and no longer does warn, so a stale key, or one whose resolution the unwind restores, stays silent. The rollback still goes ahead; the detail names the key and says to re-create the patch (`bun patch `, `bun patch --commit`) or remove the entry. Vendored reverts warn the same way as `vendor_bun_patched_dependency_dropped`. Exit 0. | | `redirect_bun_default_trust_lost` / `vendor_bun_default_trust_lost` | `warnings[]` / vendor `warnings[]` (warning) | scan/get `--mode hosted` and vendored mode (bun, `bun.lock` and `bun.lockb`): the rewired package is on Bun's built-in default trusted list (better-sqlite3, esbuild, sharp, …) and the project declares no `trustedDependencies` (root `package.json`, or the copy mirrored in `bun.lock`). Bun 1.3.5 and later apply that list only to packages resolved from the npm registry, so on a hosted URL or a local tarball the package's install scripts are skipped with exit 0 (#371). The package is still rewired; the detail tells the user to add it to `trustedDependencies` (which replaces Bun's default list, so other default-trusted dependencies whose scripts matter must be listed too). Repeated on every run while the pin stays and no list is declared. Exit 0. | | `redirect_bun_non_registry_entry_skipped` / `vendor_non_registry_entry_skipped` | `warnings[]` / vendor warnings (warning) | scan/get `--mode hosted` and vendored mode (bun, `bun.lock` and `bun.lockb`): the lock also installs the granted `name@version` from a user URL or `file:` tarball (its `-.tgz` leaf names that version). Bun installs it from that spec, so the copy stays unpatched beside any rewired registry copy; it is left untouched, the in-run VEX does not assume the patch, and `vex` attests nothing for that `name@version` (#497). With no registry copy left, vendoring refuses with `vendor_lock_entry_not_rewritable`. A same-name URL / `file:` tarball whose leaf names no version, or a git, folder or `link:` copy, has no version in the lock: these codes do not fire for it, but `vex` attests no ref of that package from that lock (`patched_ref_unattributable`), since the copy may be the wired version. | | `redirect_npm_patched_dependency_skipped` | `warnings[]` (warning) | scan/get `--mode hosted` (`package-lock.json` / `npm-shrinkwrap.json`): the project patches the granted `name@version` itself with npm ≥ 12.1's native `npm patch` (a root `package.json` `patchedDependencies` key for `name@version` or the bare name, or a `packages` entry carrying npm's `patched` record, which npm writes in a lockfileVersion 4 lock). npm applies the user's diff on top of whatever tarball the lock names and fails the install `EPATCHFAILED` when it no longer applies, so a hosted pin would break every later `npm ci` / `npm install` (or install bytes VEX can never attest); the dep is left on its registry entry in every present npm lock instead (#711). Per-dep; the detail names the key or lock entry and the remedy (fold the Socket fix into the user's patch, or drop the `patchedDependencies` entry and re-run); when an earlier hosted run already pinned the entry, the detail says so and names `socket-patch rollback ` to restore the registry entry instead of claiming it is unchanged; the in-run VEX never assumes the uuid applied, and no sibling lock confirms it. Vendored mode refuses the lockfileVersion 4 lock `vendor_lockfile_version_unsupported`, its detail naming `npm patch` / `patchedDependencies` and `npm install --lockfile-version 3` (npm keeps a v4 lock at v4 on a plain `npm install`); a v4 lock with no native patch left (no `patchedDependencies` key, no non-null `patched` record) gets a detail naming only that downgrade (#1576). Exit 0. | diff --git a/crates/socket-patch-cli/src/commands/hosted_unwind.rs b/crates/socket-patch-cli/src/commands/hosted_unwind.rs index 1090594c3..e0d8bd04a 100644 --- a/crates/socket-patch-cli/src/commands/hosted_unwind.rs +++ b/crates/socket-patch-cli/src/commands/hosted_unwind.rs @@ -76,6 +76,7 @@ pub(crate) async fn run_hosted_leg(common: &GlobalArgs, pins: &[HostedPin]) -> H // rebuilt registry record is not byte-exact for every lock. bun_lockb: false, }; + let live_before = socket_patch_core::vendor::bun_lock::live_user_patch_keys(&common.cwd).await; let outcome = restore_upstream(&common.cwd, pins, &opts).await; for pin in &outcome.pins { match &pin.status { @@ -131,6 +132,17 @@ pub(crate) async fn run_hosted_leg(common: &GlobalArgs, pins: &[HostedPin]) -> H bun_lock::reinstall_advisory(&stale), )); } + // A `bun patch` keyed by a hosted URL the restore took out of the + // lock stops applying (#1019). + if !common.dry_run { + let live_after = bun_lock::live_user_patch_keys(&common.cwd).await; + for key in live_before.difference(&live_after) { + out.warnings.push(( + "redirect_bun_patched_dependency_dropped".to_string(), + bun_lock::dropped_patch_detail(key), + )); + } + } } // PDM, uv and Pipenv keep a same-version install through a plain sync // (#477): name the reinstall that restores the upstream bytes. 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 51d2017a8..66fce3655 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 @@ -65,6 +65,8 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; mod bun_vex; #[path = "in_process_vendor_bun_takeover/registry.rs"] mod registry; +#[path = "in_process_vendor_bun_takeover/user_patch.rs"] +mod user_patch; #[path = "in_process_vendor_bun_takeover/vlt.rs"] mod vlt; #[path = "vlt_hosted_common/mod.rs"] diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/user_patch.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/user_patch.rs new file mode 100644 index 000000000..e3acd15a7 --- /dev/null +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/user_patch.rs @@ -0,0 +1,183 @@ +//! A `bun patch` made after socket-patch rewired the package (#1019), +//! through the built binary. Bun 1.4 keys that patch by the package's +//! current resolution, `left-pad@`, and applies it only while +//! the lock still resolves the package there. A superseding hosted re-run +//! or a hosted → vendored takeover would move the entry and silently drop +//! the user's patch from every install, so both leave the hosted wiring +//! alone and say why, with or without `--allow-patch-conflicts`. A +//! rollback still restores the registry entry the user asked for, and +//! warns that the patch stops applying. + +use std::path::Path; + +use serde_json::Value; +use wiremock::MockServer; + +use super::{ + find_event, hosted_line, lock_line, mock_api, read, run_json, run_warning, scan_mode, + seed_manifest_and_blob, vendor_cli, vendored_rel_tgz, write_lockfile_only_hosted_project, + write_vendored_project, HOSTED_URL, NAME, PATCHED_SHA512, PURL, SUPERSEDED_UUID, UUID, +}; + +/// Add the `patchedDependencies` entry `bun patch --commit` writes for +/// left-pad while it resolves to `url`. +fn add_user_patch_keyed_by(root: &Path, url: &str) -> String { + let key = format!("{NAME}@{url}"); + let mut manifest: Value = serde_json::from_str(&read(root, "package.json")).unwrap(); + manifest["patchedDependencies"] = serde_json::json!({ &key: "patches/left-pad.patch" }); + std::fs::write( + root.join("package.json"), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + key +} + +/// The scan's `redirect_bun_patched_dependency_skipped` warning detail. +fn skipped_detail(env: &Value) -> Option { + env["redirect"]["warnings"] + .as_array() + .into_iter() + .chain(env["warnings"].as_array()) + .flatten() + .find(|w| w["code"] == "redirect_bun_patched_dependency_skipped") + .and_then(|w| w["detail"].as_str()) + .map(String::from) +} + +#[tokio::test(flavor = "multi_thread")] +async fn bun_patch_keyed_by_hosted_url_survives_a_superseding_rerun() { + let server = MockServer::start().await; + mock_api(&server).await; + let superseded_url = HOSTED_URL.replace(UUID, SUPERSEDED_UUID); + + // By default the consent gate (#1646) withholds the package. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let hosted_lock = write_lockfile_only_hosted_project(root, &superseded_url); + add_user_patch_keyed_by(root, &superseded_url); + let (code, env) = scan_mode(root, &server.uri(), "hosted", &[]); + assert_eq!(code, 0, "{env:#}"); + assert_eq!(read(root, "bun.lock"), hosted_lock, "{env:#}"); + + // With the conflict approved, the bun backend still refuses to orphan + // the user's key: the entry keeps the URL the patch is keyed by. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let hosted_lock = write_lockfile_only_hosted_project(root, &superseded_url); + let key = add_user_patch_keyed_by(root, &superseded_url); + let (code, env) = scan_mode(root, &server.uri(), "hosted", &["--allow-patch-conflicts"]); + assert_eq!(code, 0, "{env:#}"); + let lock = read(root, "bun.lock"); + assert_eq!( + lock_line(&lock, NAME), + hosted_line(NAME, NAME, &superseded_url, PATCHED_SHA512), + "the entry must keep the URL the user's patch is keyed by: {env:#}" + ); + assert_eq!(lock, hosted_lock, "{env:#}"); + let detail = skipped_detail(&env).unwrap_or_else(|| panic!("no skip warning: {env:#}")); + assert!(detail.contains(&key), "{detail}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn bun_patch_keyed_by_hosted_url_blocks_the_vendored_takeover() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let hosted_lock = write_lockfile_only_hosted_project(root, HOSTED_URL); + let key = add_user_patch_keyed_by(root, HOSTED_URL); + + let (code, env) = scan_mode( + root, + &server.uri(), + "vendored", + &["--allow-patch-conflicts"], + ); + assert_ne!(code, 0, "the takeover must refuse: {env:#}"); + let failed = find_event(&env, "failed", Some("vendor_lock_entry_unsupported")); + assert_eq!(failed["purl"], PURL, "{env:#}"); + assert!( + failed.to_string().contains(&key), + "the refusal names the key: {env:#}" + ); + assert_eq!( + read(root, "bun.lock"), + hosted_lock, + "the hosted wiring must survive the refusal: {env:#}" + ); + assert!(!root.join(".socket/vendor/npm").exists(), "{env:#}"); +} + +#[test] +fn bun_rollback_warns_that_a_patch_keyed_by_the_hosted_url_stops_applying() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_lockfile_only_hosted_project(root, HOSTED_URL); + seed_manifest_and_blob(root); + let key = add_user_patch_keyed_by(root, HOSTED_URL); + + let (code, env) = run_json( + root, + &[ + "rollback", + "--yes", + "--json", + "--cwd", + root.to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "the rollback itself goes ahead: {env:#}"); + assert!( + !read(root, "bun.lock").contains(HOSTED_URL), + "the registry entry is restored: {env:#}" + ); + let detail = run_warning(&env, "redirect_bun_patched_dependency_dropped") + .unwrap_or_else(|| panic!("no drop warning: {env:#}")); + assert!( + detail.contains(&key) && detail.contains("bun patch"), + "{detail}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn bun_patch_keyed_by_vendored_path_blocks_the_hosted_takeover() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_vendored_project(root); + let key = add_user_patch_keyed_by(root, &vendored_rel_tgz()); + let vendored_lock = read(root, "bun.lock"); + + let (code, env) = scan_mode(root, &server.uri(), "hosted", &["--allow-patch-conflicts"]); + assert_eq!(code, 0, "{env:#}"); + assert_eq!( + read(root, "bun.lock"), + vendored_lock, + "the vendored wiring the patch is keyed by must stay: {env:#}" + ); + let detail = skipped_detail(&env).unwrap_or_else(|| panic!("no skip warning: {env:#}")); + assert!(detail.contains(&key), "{detail}"); +} + +#[test] +fn bun_vendor_revert_warns_that_a_patch_keyed_by_the_vendored_path_stops_applying() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_vendored_project(root); + let key = add_user_patch_keyed_by(root, &vendored_rel_tgz()); + + let (code, env) = vendor_cli(root, &["--revert"]); + assert_eq!(code, 0, "the revert itself goes ahead: {env:#}"); + assert!( + !read(root, "bun.lock").contains(".socket/vendor/"), + "the vendored wiring is reverted: {env:#}" + ); + assert!( + env.to_string() + .contains("vendor_bun_patched_dependency_dropped") + && env.to_string().contains(&key), + "the revert warns, naming the key: {env:#}" + ); +} diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 231394835..7cf92f045 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -1255,6 +1255,7 @@ fn rewrite_npm_lock( &user_patched, &fname, &dep.version, + &dep.artifact_url, ) { format!("package.json `patchedDependencies` has `{key}`") } else if let Some((_, _, lockfile, key)) = lock_patched @@ -5572,10 +5573,11 @@ pub(crate) fn bun_default_trust_warning(name: &str, version: &str) -> RewriteWar } } -/// Leave `dep` on its registry resolution when the project's own +/// Leave `dep` on its current resolution when the project's own /// `patchedDependencies` patches it (#367): Bun applies that patch only to -/// the registry `name@version`, so a hosted pin would silently drop it from -/// every install. Warns, keeps the in-run VEX from assuming the uuid +/// the resolution it is keyed on (the registry `name@version`, or an older +/// hosted or vendored tarball, #1019), so a pin to `dep`'s URL would +/// silently drop it from every install. Warns, keeps the in-run VEX from assuming the uuid /// patched, and keeps any other lock from confirming it. `true` when `dep` /// was skipped. pub(crate) fn skip_bun_user_patched( @@ -5585,7 +5587,8 @@ pub(crate) fn skip_bun_user_patched( result: &mut RewriteResult, ) -> bool { use crate::vendor::bun_lock_text::{patched_dependency_detail, patched_dependency_key}; - let Some(key) = patched_dependency_key(user_patched, name, &dep.version) else { + let Some(key) = patched_dependency_key(user_patched, name, &dep.version, &dep.artifact_url) + else { return false; }; result.bundled_skipped_uuids.insert(dep.patch_uuid.clone()); @@ -13920,6 +13923,81 @@ mod tests { assert!(r.warnings.is_empty(), "{:?}", r.warnings); } + /// REGRESSION (#1019): a `bun patch` made after a hosted rewire is keyed + /// by the hosted URL the package resolves to (Bun 1.4). A re-run that + /// picks up a superseding patch would move the entry to a new URL and + /// orphan that key, silently dropping the user's patch, so the entry + /// keeps its current URL and the run says why. Scoped packages too, + /// whose URL holds an `@` of its own. + #[test] + fn bun_lock_patch_keyed_by_hosted_url_survives_superseding_rerun() { + let sha512 = format!("sha512-{}==", "A".repeat(86)); + for (key, name, namespace, old_url) in [ + ( + "left-pad", + "left-pad", + None, + "https://patch.socket.dev/patch/npm/left-pad/1.3.0/tok/old/left-pad-1.3.0.tgz", + ), + ( + "@s/left-pad", + "left-pad", + Some("@s"), + "https://patch.socket.dev/patch/npm/@s/left-pad/1.3.0/tok/old/left-pad-1.3.0.tgz", + ), + ] { + let new_url = old_url.replace("/old/", "/new/"); + let mut ovr = npm_override(name, "1.3.0", &new_url, &sha512); + ovr.namespace = namespace.map(String::from); + let entry = format!("\"{key}\": [\"{key}@{old_url}\", {{}}, \"{sha512}\"],"); + let manifest = format!( + r#"{{"name":"app","dependencies":{{"{key}":"1.3.0"}},"patchedDependencies":{{"{key}@{old_url}":"patches/x.patch"}}}}"# + ); + let mut files = BTreeMap::new(); + files.insert("bun.lock".to_string(), bun_lock_file(&entry, 1)); + files.insert("package.json".to_string(), manifest); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.edits.is_empty(), "{key}: {:?}", r.edits); + assert!( + r.files + .get("bun.lock") + .is_none_or(|out| !out.contains(&new_url)), + "{key}: the entry keeps its current URL" + ); + assert_eq!( + warning_codes(&r), + vec!["redirect_bun_patched_dependency_skipped"], + "{key}: {:?}", + r.warnings + ); + assert!( + r.warnings[0] + .detail + .contains(&format!("resolves to {old_url}")), + "{}", + r.warnings[0].detail + ); + assert!(r.bundled_skipped_uuids.contains(&ovr.patch_uuid)); + + // An in-sync re-run (the keyed URL is the pin's own) moves + // nothing, so it neither skips nor warns. + let in_sync = npm_override(name, "1.3.0", old_url, &sha512); + let mut in_sync = DepOverride { + namespace: namespace.map(String::from), + ..in_sync + }; + in_sync.patch_uuid = ovr.patch_uuid.clone(); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&in_sync), &mut r); + assert!(r.warnings.is_empty(), "{key}: {:?}", r.warnings); + assert!( + !r.bundled_skipped_uuids.contains(&in_sync.patch_uuid), + "{key}" + ); + } + } + /// REGRESSION (#371): from Bun 1.3.5 on, Bun's default trusted list /// (better-sqlite3, esbuild, sharp, simple-git-hooks, …) applies only to /// packages resolved from the npm registry, so a default-trusted package diff --git a/crates/socket-patch-core/src/patch/user_patches.rs b/crates/socket-patch-core/src/patch/user_patches.rs index fc0ac8c18..90b7346f3 100644 --- a/crates/socket-patch-core/src/patch/user_patches.rs +++ b/crates/socket-patch-core/src/patch/user_patches.rs @@ -736,10 +736,14 @@ fn lock_options() -> serde_saphyr::Options { options } +/// An npm `name@selector` key split at the first `@` past a scope, as Bun +/// and pnpm split it: the selector may hold an `@` of its own, such as the +/// hosted URL or vendored path Bun 1.4 keys a patch by after a rewire +/// (`@s/p@.socket/vendor/npm//@s/p-1.0.0.tgz`, #1019). fn split_selector(key: &str) -> (&str, Option<&str>) { - match key.rsplit_once('@') { - Some((name, version)) if !name.is_empty() => (name, Some(version)), - _ => (key, None), + match crate::vendor::bun_lock_text::split_name_spec(key) { + Some((name, selector)) => (name, Some(selector)), + None => (key, None), } } @@ -753,6 +757,27 @@ mod tests { std::fs::write(path, text).unwrap(); } + /// #1019: after a rewire, Bun 1.4 keys a later `bun patch` by the + /// package's resolution. A hosted URL or vendored path can hold an `@` + /// of its own, so the key splits at the first `@` past a scope, as Bun + /// splits it. + #[tokio::test] + async fn bun_resolution_keys_protect_scoped_and_unscoped_packages() { + for (key, purl) in [ + ("plainpkg@https://patch.socket.dev/patch/npm/plainpkg/1.0.0/tok/u1/plainpkg-1.0.0.tgz", "pkg:npm/plainpkg@1.0.0"), + ("plainpkg@.socket/vendor/npm/u1/plainpkg-1.0.0.tgz", "pkg:npm/plainpkg@1.0.0"), + ("@s/p@https://patch.socket.dev/patch/npm/@s/p/1.0.0/tok/u1/p-1.0.0.tgz", "pkg:npm/%40s/p@1.0.0"), + ("@s/p@.socket/vendor/npm/u1/@s/p-1.0.0.tgz", "pkg:npm/%40s/p@1.0.0"), + ] { + let tmp = tempfile::tempdir().unwrap(); + let manifest = format!(r#"{{"patchedDependencies":{{"{key}":"patches/x.patch"}}}}"#); + write(tmp.path(), "package.json", &manifest); + let index = UserPatchIndex::discover(tmp.path()).await; + assert!(!index.conflicts(purl).is_empty(), "{key}: {index:?}"); + assert!(index.conflicts("pkg:npm/other@1.0.0").is_empty(), "{key}"); + } + } + #[tokio::test] async fn native_declarations_match_scopes_ranges_and_exact_versions_without_patch_files() { for (file, text) in [ diff --git a/crates/socket-patch-core/src/vendor/bun_binary.rs b/crates/socket-patch-core/src/vendor/bun_binary.rs index 33952f663..2a9a41245 100644 --- a/crates/socket-patch-core/src/vendor/bun_binary.rs +++ b/crates/socket-patch-core/src/vendor/bun_binary.rs @@ -397,7 +397,7 @@ pub(super) fn preflight_package( coords: &NpmCoords, leaf: &str, ) -> Result> { - super::bun_lock::refuse_user_patched(&project.user_patched, &coords.name, &coords.version)?; + super::bun_lock::refuse_user_patched(&project.user_patched, coords)?; let (bundled_only, matches): (Vec<_>, Vec<_>) = project .packages .iter() diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index f864c1e5f..ed6ac4065 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -42,9 +42,9 @@ use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_strin use crate::utils::socket_dir::remove_tree_and_prune; use crate::vendor::bun_lock_text::{ decode_json_string, default_trust_detail, has_workspace_packages, is_bundled_entry, - is_user_tarball_entry, lock_version, loses_default_trust, packages_bounds, parse_entry_line, - patched_dependency_detail, patched_dependency_key, patched_dependency_keys, split_name_spec, - BunEntry, + is_user_tarball_entry, live_resolution_keys, lock_version, loses_default_trust, + packages_bounds, parse_entry_line, patched_dependency_detail, patched_dependency_key, + patched_dependency_keys, split_name_spec, BunEntry, }; use super::common::refused; @@ -377,7 +377,7 @@ impl NpmLockBackend for BunTextBackend { let project = read_project(project_root).await?; // ── 3. Pre-flight: at least one rewritable instance ────────────── - let (target_spec, target_leaf) = preflight_package(&project, name, version)?; + let (target_spec, target_leaf) = preflight_package(&project, coords)?; for key in bundled_matches(&project.entries, &target_spec, name, &target_leaf) { // LOUD: this copy ships inside its PARENT's tarball, which we do // not repack — it stays the unpatched bytes after vendor (#469). @@ -670,14 +670,17 @@ pub(super) fn default_trust_warning( } /// Refuse to vendor a package the project patches itself with `bun patch` -/// (#367): Bun applies that patch only to the registry `name@version`, so -/// a local tarball tuple would silently drop it from every install. +/// (#367): Bun applies that patch only to the resolution it is keyed on +/// (the registry `name@version`, or a hosted or older vendored tarball, +/// #1019), so the tuple for `coords` would silently drop it from every +/// install. pub(super) fn refuse_user_patched( user_patched: &[String], - name: &str, - version: &str, + coords: &NpmCoords, ) -> Result<(), Box> { - match patched_dependency_key(user_patched, name, version) { + let (name, version) = (coords.name.as_str(), coords.version.as_str()); + let target = format!("{}/{}", coords.uuid_dir_rel, tgz_rel_leaf(name, version)); + match patched_dependency_key(user_patched, name, version, &target) { Some(key) => Err(Box::new(refused( "vendor_lock_entry_unsupported", patched_dependency_detail(key, name, version), @@ -733,10 +736,10 @@ pub(super) async fn read_project(project_root: &Path) -> Result Result<(String, String), Box> { - refuse_user_patched(&project.user_patched, name, version)?; + refuse_user_patched(&project.user_patched, coords)?; + let (name, version) = (coords.name.as_str(), coords.version.as_str()); let target_spec = format!("{name}@{version}"); let target_leaf = tgz_rel_leaf(name, version); let has_match = project @@ -826,7 +829,7 @@ pub(crate) async fn preflight_packages( .map_err(|o| refusal_code(&o)), packages, |project, coords| { - preflight_package(project, &coords.name, &coords.version) + preflight_package(project, coords) .map(drop) .map_err(|o| refusal_code(&o)) }, @@ -858,6 +861,7 @@ pub(crate) async fn revert_bun_opts( project_root: &Path, opts: RevertOpts, ) -> RevertOutcome { + let live_before = live_user_patch_keys(project_root).await; let mut outcome = revert_bun_wiring(entry, project_root, opts).await; // Only a revert that put a lock entry back can leave Bun keeping a // copy: with the lock missing nothing was restored, and with the entry @@ -876,9 +880,68 @@ pub(crate) async fn revert_bun_opts( )); } } + if outcome.success && !restored_nothing { + let live_after = live_user_patch_keys(project_root).await; + for key in live_before.difference(&live_after) { + outcome.warnings.push(VendorWarning::new( + PATCHED_DEPENDENCY_DROPPED, + dropped_patch_detail(key), + )); + } + } outcome } +/// A wet revert took out of the lock the vendored tarball a project's own +/// `bun patch` is keyed by (#1019): Bun stops applying that patch, without +/// failing. The hosted unwind warns the same way as +/// `redirect_bun_patched_dependency_dropped`. +pub const PATCHED_DEPENDENCY_DROPPED: &str = "vendor_bun_patched_dependency_dropped"; + +/// The detail for a [`live_user_patch_keys`] key an unwind took out of the +/// lock, shared by the hosted and vendored warnings. +pub fn dropped_patch_detail(key: &str) -> String { + super::bun_lock_text::dropped_patch_detail(key) +} + +/// The project's `bun patch` keys that name a hosted or vendored tarball +/// the lock resolves to right now (#1019; see [`live_resolution_keys`]), +/// from the root `package.json` and the text lock's mirror, matched against +/// the text `bun.lock`, or else `bun.lockb`. An unwind compares the set +/// before and after it writes: a key that left it is a patch Bun drops. +/// Empty, with no further read, when the project declares no such key. +pub async fn live_user_patch_keys(project_root: &Path) -> std::collections::BTreeSet { + let manifest = read_manifest(project_root).await; + let lock = read_regular_to_string(&project_root.join(BUN_LOCK)) + .await + .ok(); + let keys = patched_dependency_keys(manifest.as_deref(), lock.as_deref()); + if !keys.iter().any(|k| k.contains('/')) { + return Default::default(); + } + if let Some(lock) = lock { + let Ok(parsed) = BunTextLock::parse(&lock) else { + return Default::default(); + }; + let specs: Vec = parsed + .entries + .iter() + .filter_map(|e| e.elems.first().and_then(|s| decode_json_string(s))) + .collect(); + return live_resolution_keys(&keys, specs.iter().map(String::as_str)); + } + let Ok(bytes) = crate::utils::fs::read_regular_to_bytes(&project_root.join("bun.lockb")).await + else { + return Default::default(); + }; + let specs: Vec = super::bun_lockb::BunLockb::parse_packages(&bytes) + .unwrap_or_default() + .iter() + .map(|p| format!("{}@{}", p.name, p.resolution)) + .collect(); + live_resolution_keys(&keys, specs.iter().map(String::as_str)) +} + /// A revert (or hosted unwind) left an installed copy Bun may keep: its /// hoisted linker does not re-extract a package whose lock entry moves /// from a local or URL tarball back to the registry record of the same @@ -2527,6 +2590,73 @@ mod tests { assert!(result.success, "{:?}", result.error); } + /// REGRESSION (#1019): after a hosted or vendored rewire, Bun 1.4 keys a + /// `bun patch` by the tarball the package resolves to. Vendoring onto a + /// new uuid (a takeover, or a superseding re-run) would orphan that key + /// just as it would a registry one, so it refuses the same way. + #[tokio::test] + async fn user_bun_patch_keyed_by_resolution_refuses_before_any_write() { + let registry_line = r#""left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="],"#; + let with_key = |key: &str| { + let mut manifest: Value = serde_json::from_str(BN3_PKG).unwrap(); + manifest["patchedDependencies"] = serde_json::json!({ key: "patches/x.patch" }); + serde_json::to_string_pretty(&manifest).unwrap() + }; + for resolution in [ + "https://patch.socket.dev/patch/npm/left-pad/1.3.0/tok/old/left-pad-1.3.0.tgz", + ".socket/vendor/npm/00000000-0000-4000-8000-000000000000/left-pad-1.3.0.tgz", + ] { + let key = format!("left-pad@{resolution}"); + let lock = BN3_BEFORE_LOCK.replace( + registry_line, + &format!(r#""left-pad": ["{key}", {{}}, "sha512-OLD=="],"#), + ); + assert_ne!(lock, BN3_BEFORE_LOCK, "the fixture line must be replaced"); + let fx = fixture_with(&lock, "node_modules/left-pad").await; + tokio::fs::write(fx.root().join("package.json"), with_key(&key)) + .await + .unwrap(); + let (planned, looped) = preflight_then_vendor(&fx).await; + assert_eq!(planned, Err("vendor_lock_entry_unsupported"), "{key}"); + assert_eq!(looped, Err("vendor_lock_entry_unsupported"), "{key}"); + let detail = expect_refused(fx.vendor(true).await, "vendor_lock_entry_unsupported"); + assert!( + detail.contains(&key) && detail.contains("resolves to"), + "{detail}" + ); + assert_eq!(fx.read_lock().await, lock, "refusal writes nothing"); + assert!(!fx.root().join(".socket/vendor").exists()); + + // Over a registry lock too: a takeover checks after reverting + // the wiring the key names, so it fails closed. + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + tokio::fs::write(fx.root().join("package.json"), with_key(&key)) + .await + .unwrap(); + expect_refused(fx.vendor(false).await, "vendor_lock_entry_unsupported"); + } + + // The key for this run's own tarball (an in-sync re-run) moves + // nothing, so it vendors; reverting that wiring then warns that the + // patch stops applying, and still reverts. + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + let key = format!("left-pad@{}", fx.rel_tgz()); + tokio::fs::write(fx.root().join("package.json"), with_key(&key)) + .await + .unwrap(); + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let outcome = revert_bun(&entry.unwrap(), fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + let dropped = outcome + .warnings + .iter() + .find(|w| w.code == PATCHED_DEPENDENCY_DROPPED) + .unwrap_or_else(|| panic!("{:?}", outcome.warnings)); + assert!(dropped.detail.contains(&key), "{}", dropped.detail); + assert_eq!(fx.read_lock().await, BN3_BEFORE_LOCK, "lock byte-restored"); + } + /// REGRESSION (#367), `bun.lockb`: the binary lock has no text mirror, /// so the root manifest's `patchedDependencies` alone gates vendoring. #[tokio::test] diff --git a/crates/socket-patch-core/src/vendor/bun_lock_text.rs b/crates/socket-patch-core/src/vendor/bun_lock_text.rs index e0b464ac6..2ac01780b 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock_text.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock_text.rs @@ -171,32 +171,96 @@ pub(crate) fn strip_jsonc(text: &str) -> String { } /// The `patchedDependencies` key that makes Bun apply a project-authored -/// patch to `name@version`, if any. Bun keys the patch on the registry -/// resolution's `name@version`; a bare `name` is matched too so that a -/// key spelled without a version never slips past the gate. +/// patch to `name@version`, if any, which a run that rewires the package +/// to `target` (a hosted URL or vendored tarball path) would orphan. Bun +/// keys the patch on the package's resolution: the registry +/// `name@version`, or, for a `bun patch` made after a hosted or vendored +/// rewire, the tarball it resolves to (Bun 1.4, #1019). A bare `name` is +/// matched too so that a key spelled without a version never slips past +/// the gate. /// /// Bun applies such a patch only while the lock resolves the package to -/// that registry `name@version`. Rewiring it to a hosted URL or a vendored -/// tarball makes Bun drop the user's patch on every install, silently -/// (#367), so both modes leave such a package alone and say why. +/// that resolution. Rewiring it elsewhere makes Bun drop the user's patch +/// on every install, silently (#367), so both modes leave such a package +/// alone and say why. A key for `target` itself is no conflict: the run +/// leaves the package where the patch applies. Any other tarball key +/// counts, even one the lock no longer resolves to: a takeover checks +/// after reverting the wiring the key names, so the lock cannot tell a +/// stale key from a live one, and the refusal's remedy (remove the entry) +/// clears a stale one. pub(crate) fn patched_dependency_key<'k>( keys: &'k [String], name: &str, version: &str, + target: &str, ) -> Option<&'k str> { let spec = format!("{name}@{version}"); keys.iter() .map(String::as_str) .find(|key| *key == spec || *key == name) + .or_else(|| { + keys.iter().map(String::as_str).find(|key| { + tarball_resolution(key, name, version) + .is_some_and(|resolution| resolution != target) + }) + }) +} + +/// The resolution `key` names when it is `name@` of +/// `name@version`'s own tarball, the key a `bun patch` made after a hosted +/// or vendored rewire writes (Bun 1.4, #1019), such as +/// `left-pad@.socket/vendor/npm//left-pad-1.3.0.tgz`: a hosted URL +/// and a vendored path both end in npm's tarball name. +fn tarball_resolution<'k>(key: &'k str, name: &str, version: &str) -> Option<&'k str> { + let (key_name, resolution) = split_name_spec(key)?; + let leaf = name.rsplit('/').next().unwrap_or(name); + (key_name == name && resolution.ends_with(&format!("/{leaf}-{version}.tgz"))) + .then_some(resolution) +} + +/// Of `keys`, the ones naming a non-registry resolution that `specs` (the +/// lock's `name@resolution` entry specs) still holds: the patches Bun +/// applies to a hosted or vendored tarball right now (#1019). An unwind +/// that takes one of these out of the lock drops that patch. +pub(crate) fn live_resolution_keys<'s>( + keys: &[String], + specs: impl IntoIterator, +) -> std::collections::BTreeSet { + let specs: std::collections::HashSet<&str> = specs.into_iter().collect(); + keys.iter() + .filter(|key| { + split_name_spec(key).is_some_and(|(_, resolution)| resolution.contains('/')) + && specs.contains(key.as_str()) + }) + .cloned() + .collect() +} + +/// The user-facing warning detail for a project-authored Bun patch keyed by +/// a tarball the package no longer resolves to after an unwind (#1019). +pub(crate) fn dropped_patch_detail(key: &str) -> String { + let name = split_name_spec(key).map_or(key, |(name, _)| name); + format!( + "package.json `patchedDependencies` has `{key}`, a patch the project applies with \ + `bun patch` only while {name} resolves to that tarball; now that the lock resolves it \ + elsewhere, `bun install` stops applying that patch without failing. Re-create it with \ + `bun patch {name}` and `bun patch --commit`, or remove the entry" + ) } /// The user-facing reason a package with a project-authored Bun patch is -/// left on its registry resolution, shared by the hosted and vendored +/// left on its current resolution, shared by the hosted and vendored /// paths so the two modes never drift apart. pub(crate) fn patched_dependency_detail(key: &str, name: &str, version: &str) -> String { + let applies_to = match split_name_spec(key) { + Some((_, resolution)) if resolution != version => { + format!("while {name}@{version} resolves to {resolution}") + } + _ => format!("to the registry {name}@{version}"), + }; format!( "package.json `patchedDependencies` has `{key}`, a patch the project applies with \ - `bun patch`; Bun applies it only to the registry {name}@{version}, so rewiring the \ + `bun patch`; Bun applies it only {applies_to}, so rewiring the \ package would silently drop that patch from every install. It is left unchanged and \ stays without the Socket patch: fold the Socket fix into your own patch, or remove \ the `patchedDependencies` entry (`bun patch --commit` again without it) and re-run" @@ -844,6 +908,59 @@ mod tests { )); } + /// #1019: Bun 1.4 keys a `bun patch` made after a rewire by the + /// package's resolution, the hosted URL or the vendored tarball path. + /// Only a resolution to this package's own tarball matches. + #[test] + fn patched_dependency_key_matches_rewired_resolutions() { + let keys: Vec = [ + "plainpkg@https://patch.socket.dev/patch/npm/plainpkg/1.0.0/tok/u1/plainpkg-1.0.0.tgz", + "@s/p@.socket/vendor/npm/u1/@s/p-2.0.0.tgz", + "@s/q@https://patch.socket.dev/patch/npm/@s/q/3.0.0/tok/u1/q-3.0.0.tgz", + ] + .map(String::from) + .into(); + assert_eq!( + patched_dependency_key(&keys, "plainpkg", "1.0.0", ""), + Some(keys[0].as_str()) + ); + assert_eq!( + patched_dependency_key(&keys, "@s/p", "2.0.0", ""), + Some(keys[1].as_str()) + ); + assert_eq!( + patched_dependency_key(&keys, "@s/q", "3.0.0", ""), + Some(keys[2].as_str()) + ); + // Another version's tarball, or another package's, is no match. + assert_eq!(patched_dependency_key(&keys, "plainpkg", "1.0.1", ""), None); + assert_eq!(patched_dependency_key(&keys, "p", "2.0.0", ""), None); + assert_eq!(patched_dependency_key(&keys, "@s/p", "2.0.0-0", ""), None); + // A run that keeps the package on the keyed resolution drops + // nothing. + let target = "https://patch.socket.dev/patch/npm/plainpkg/1.0.0/tok/u1/plainpkg-1.0.0.tgz"; + assert_eq!( + patched_dependency_key(&keys, "plainpkg", "1.0.0", target), + None + ); + // Only tarball keys the lock still holds are live. + let mut with_registry = keys.clone(); + with_registry.push("left-pad@1.3.0".into()); + let live = live_resolution_keys(&with_registry, [keys[1].as_str(), "left-pad@1.3.0"]); + assert_eq!(live.into_iter().collect::>(), vec![keys[1].clone()]); + // The refusal names the resolution Bun applies the patch to. + let detail = patched_dependency_detail(&keys[1], "@s/p", "2.0.0"); + assert!( + detail.contains("resolves to .socket/vendor/npm/u1/@s/p-2.0.0.tgz"), + "{detail}" + ); + let registry = patched_dependency_detail("@s/p@2.0.0", "@s/p", "2.0.0"); + assert!( + registry.contains("only to the registry @s/p@2.0.0,"), + "{registry}" + ); + } + /// #367: the keys come from the manifest and from the lock's mirror, /// and match the exact `name@version` (scoped too) or a bare name. #[test] @@ -853,18 +970,18 @@ mod tests { let keys = patched_dependency_keys(Some(manifest), Some(lock)); assert_eq!(keys, vec!["left-pad@1.3.0", "@s/p@2.0.0"]); assert_eq!( - patched_dependency_key(&keys, "left-pad", "1.3.0"), + patched_dependency_key(&keys, "left-pad", "1.3.0", ""), Some("left-pad@1.3.0") ); assert_eq!( - patched_dependency_key(&keys, "@s/p", "2.0.0"), + patched_dependency_key(&keys, "@s/p", "2.0.0", ""), Some("@s/p@2.0.0") ); - assert_eq!(patched_dependency_key(&keys, "left-pad", "1.3.1"), None); - assert_eq!(patched_dependency_key(&keys, "x", "1.0.0"), None); + assert_eq!(patched_dependency_key(&keys, "left-pad", "1.3.1", ""), None); + assert_eq!(patched_dependency_key(&keys, "x", "1.0.0", ""), None); let bare = vec!["left-pad".to_string()]; assert_eq!( - patched_dependency_key(&bare, "left-pad", "1.3.0"), + patched_dependency_key(&bare, "left-pad", "1.3.0", ""), Some("left-pad") ); assert!(patched_dependency_keys(Some("not json"), None).is_empty()); diff --git a/docs/testing/bun-compatibility.md b/docs/testing/bun-compatibility.md index 54695e9eb..417b874e1 100644 --- a/docs/testing/bun-compatibility.md +++ b/docs/testing/bun-compatibility.md @@ -45,7 +45,7 @@ other npm lockfile flavors. | 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 — version 1 — use `--mode hosted`, which accepts version-1 workspace locks (a version-0 lock is told to re-lock with Bun ≥ 1.2 first). NOT refused: purls the vendor ledger wires at the selected uuid, purls whose every matching lock tuple already points into `.socket/vendor/npm/` (any uuid — a superseding patch re-pins in place; the lock-derived rule the engine uses), in-sync re-runs and `repair` rebuilds. `vendor` and the vendor step run the same preflight BEFORE a hosted → vendored takeover's revert, so a hosted-redirected purl on such a lock stays hosted-patched (`failed vendor_bun_workspace_unsupported`, lock and ledgers untouched; `vendor --dry-run` previews the same code). A `.socket/vendor/state.json` the preflight cannot read is `vendor_state_unreadable`, fail-closed. | Works. | | Version-2 lock (Bun 1.4+) with `workspace:` packages, nested versions included | Rewritten (golden `lock-v2-workspace-nested` — provenance: its nested same-version `consumer/left-pad` entry is a synthetic, grammar-valid extension of the 1.4.2 capture; bun hoists identical resolutions and never writes that entry itself, but bun 1.4.2 installs the fixture unchanged, and it is the only case pinning the rewrite of every matching tuple in one lock). | Vendored (matrix 1.4.0 / 1.4.2 `workspace`, `workspace-nested`, `already-vendored-workspace`). | Works. | | Binary `bun.lockb` (binary format revisions 1, 2 and 3) | Package resolution and integrity records are rewritten in place. The CLI does not spawn Bun or produce a text lock. `rollback` / `remove` cannot restore a hosted `bun.lockb` entry to its upstream registry entry (v5.0 keeps no ledger to replay, and the binary lock is not re-derived), so they refuse it with the `git checkout -- bun.lockb` remedy. | Native local-tarball wiring, committed artifact, repair and vendored → hosted takeover. Hosted → vendored rebuilds a hosted `bun.lockb` pin's npm registry record from the registry (byte-exact for a lock socket-patch wired hosted), then vendors; `vendor --revert` returns the pre-hosted lock. Offline it refuses (`redirect_revert_failed`), leaving it hosted. | Registry package records are inventoried directly, including lockfile-only projects without `node_modules`. | -| A package the project patches itself with `bun patch` (a `patchedDependencies` key for its `name@version`, or its bare name, in the root `package.json` or mirrored in `bun.lock`) (#367) | Left on its registry tuple (text and binary lock): Bun applies the user's patch only to the registry `name@version`, so a hosted URL would drop it from every install with exit 0. Warns `redirect_bun_patched_dependency_skipped` naming the key, and the in-run VEX never assumes the patch applied; other packages in the lock are still rewired. | Refused `vendor_lock_entry_unsupported` before any write or download (text and binary lock), naming the key. | The installed tree is patched in place, as for any package. | +| A package the project patches itself with `bun patch` (a `patchedDependencies` key for its `name@version`, or its bare name, in the root `package.json` or mirrored in `bun.lock`) (#367); on Bun 1.4, a `bun patch` made after a rewire is keyed by the resolution instead, `name@` or `name@.socket/vendor/npm//-.tgz` (#1019) | Left where it resolves (text and binary lock): Bun applies the user's patch only to the resolution it is keyed on, so moving the entry (to a hosted URL, or a superseding one, or a vendored → hosted takeover, which keeps the vendored wiring) would drop it from every install with exit 0. Warns `redirect_bun_patched_dependency_skipped` naming the key, and the in-run VEX never assumes the patch applied; other packages in the lock are still rewired. A key for the URL the run would pin anyway (an in-sync re-run) is no conflict. A wet `rollback` / `remove` that takes out the hosted URL a patch is keyed by still restores the registry entry, and warns `redirect_bun_patched_dependency_dropped`. | Refused `vendor_lock_entry_unsupported` before any write or download (text and binary lock), naming the key, a hosted → vendored takeover included. A wet revert that takes out the vendored path a patch is keyed by warns `vendor_bun_patched_dependency_dropped`. | The installed tree is patched in place, as for any package. | | A package on Bun's default trusted list (better-sqlite3, esbuild, sharp, …) in a project that declares no `trustedDependencies` (root `package.json`, or mirrored in `bun.lock`) (#371) | Rewired (text and binary lock), with warning `redirect_bun_default_trust_lost`: Bun 1.3.5+ apply the default list only to npm-registry resolutions, so a hosted URL makes `bun install` skip the package's install scripts with exit 0 (measured: 1.3.4 runs them, 1.3.5–1.4.2 do not). The remedy is adding the package to `trustedDependencies`, which replaces the default list. | Same, `vendor_bun_default_trust_lost`, for the local tarball tuple (text and binary lock). | Not affected: the installed tree keeps its registry resolution. | | Truncated, corrupt or unrecognized binary `bun.lockb` | Refused with `redirect_bun_lockb_invalid`, preserving the lock. | Refused with `vendor_bun_lockb_invalid` before downloads or artifact creation. | The inventory reports the malformed lock. | | `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. |