Skip to content

Commit cb00dff

Browse files
committed
fix(bun): preserve patch state on refused mode changes
1 parent b25aebf commit cb00dff

12 files changed

Lines changed: 386 additions & 101 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,13 @@ into the new version's section — see docs/releasing.md.
268268

269269
### Fixed
270270

271+
- **Bun refusal safety:** hosted compatibility is checked before removing
272+
an existing vendored patch, including during dry-run. Vendored preflight
273+
exemptions require live local lock tuples; a ledger retained by
274+
`rollback --preserve-state` cannot bypass a refusal or hide it in a preview.
275+
Symlinked `bun.lockb` files are refused before migration so their links
276+
survive, and `vendor --silent` keeps refusal diagnostics on stderr.
277+
271278
- **Bun projects: every text-lock generation is accepted, vendored refusals
272279
fire before any write, and every mode change unwinds.** `bun.lock`
273280
`lockfileVersion` 0 — the opt-in text lock Bun 1.1.39–1.1.45 write with

crates/socket-patch-cli/CLI_CONTRACT.md

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

crates/socket-patch-cli/src/commands/bun_preflight.rs

Lines changed: 31 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,8 @@ use std::collections::{HashMap, HashSet};
1919
use std::path::Path;
2020

2121
use socket_patch_core::api::types::PatchSearchResult;
22-
use socket_patch_core::utils::purl::strip_purl_qualifiers;
22+
use socket_patch_core::vendor::load_state;
2323
use socket_patch_core::vendor::state::VendorEntry;
24-
use socket_patch_core::vendor::{load_state, lookup_entry};
2524

2625
/// The vendor ledger as the preflight consumes it: the caller's own
2726
/// `load_state` outcome, so an UNREADABLE ledger is a fact the refusal can
@@ -32,18 +31,14 @@ pub(crate) type LedgerLoad<'a> = Result<&'a HashMap<String, VendorEntry>, &'a st
3231
///
3332
/// `exempt` holds the selected purls the refusal must NOT pre-empt — they
3433
/// flow through to the engine, which lets them in exactly as on a non-Bun
35-
/// project. A purl is exempt when EITHER
34+
/// project. A purl is exempt only when `bun.lock` wires every instance of
35+
/// its `name@version` to one of our `.socket/vendor/npm/` tuples, at any
36+
/// UUID ([`wired_instances_all_ours`]). This matches the engine's workspace
37+
/// gate: updating an already-local tuple introduces no new relative path,
38+
/// so in-sync runs, superseding patches and repairs remain supported.
3639
///
37-
/// * the vendor ledger already wires it at the SAME uuid this run selected
38-
/// (an in-sync re-run: the engine's `already_vendored` skip), OR
39-
/// * `bun.lock` already wires EVERY instance of its `name@version` to one
40-
/// of our `.socket/vendor/npm/` tuples, at any uuid
41-
/// ([`wired_instances_all_ours`]) — the engine's own criterion for
42-
/// skipping the workspace gate: rewriting an already-local tuple to a
43-
/// superseding uuid adds no new workspace-relative path, so a patch
44-
/// UPDATE on a project vendored before it grew a workspace member (or
45-
/// the same re-run after a wiped `state.json`) re-vendors in place
46-
/// instead of dying here with a remedy Bun 1.2/1.3 teams cannot follow.
40+
/// A matching ledger UUID alone is insufficient: `rollback --preserve-state`
41+
/// retains the entry after removing its wiring.
4742
///
4843
/// An unreadable ledger exempts nothing (fail closed) and the refusal
4944
/// itself becomes `vendor_state_unreadable` with the io/parse detail:
@@ -78,7 +73,7 @@ impl BunVendorRefusal {
7873

7974
/// Run the Bun preflight once for `selected` — only when it holds at least
8075
/// one npm purl, since nothing else can be affected — loading the vendor
81-
/// ledger at `cwd` for the exemption. `None` means nothing to refuse.
76+
/// ledger at `cwd` to detect corruption. `None` means nothing to refuse.
8277
pub(crate) async fn bun_vendor_preflight(
8378
cwd: &Path,
8479
selected: &[PatchSearchResult],
@@ -142,7 +137,7 @@ fn selection_pairs(selected: &[PatchSearchResult]) -> Vec<(&str, &str)> {
142137
}
143138

144139
/// Turn the engine's project-level refusal into the per-purl verdict: the
145-
/// ledger-or-lock exemption described on [`BunVendorRefusal`], or the
140+
/// live-lock exemption described on [`BunVendorRefusal`], or the
146141
/// `vendor_state_unreadable` refusal when the ledger cannot be read.
147142
async fn refusal_with_exemptions(
148143
cwd: &Path,
@@ -151,37 +146,30 @@ async fn refusal_with_exemptions(
151146
pairs: &[(&str, &str)],
152147
ledger: LedgerLoad<'_>,
153148
) -> BunVendorRefusal {
154-
let entries = match ledger {
155-
Ok(entries) => entries,
156-
Err(e) => {
157-
return BunVendorRefusal {
158-
code: "vendor_state_unreadable",
159-
detail: e.to_string(),
160-
exempt: HashSet::new(),
161-
};
162-
}
163-
};
149+
if let Err(e) = ledger {
150+
return BunVendorRefusal {
151+
code: "vendor_state_unreadable",
152+
detail: e.to_string(),
153+
exempt: HashSet::new(),
154+
};
155+
}
164156
// The lock-derived exemption exists only for the workspace gate: every
165157
// other preflight code means bun.lock could not be read or parsed, so
166158
// nothing in it can be ours and re-reading it per purl would be wasted
167159
// (guarded, but still) I/O.
168160
let lock_parsed = code == "vendor_bun_workspace_unsupported";
169161
let mut exempt = HashSet::new();
170-
for (purl, uuid) in pairs {
162+
for (purl, _) in pairs {
171163
if !purl.starts_with("pkg:npm/") {
172164
continue;
173165
}
174-
// The ledger is keyed by the manifest purl (possibly qualified) and
175-
// `lookup_entry` also resolves base purls; try the selected spelling
176-
// first, then its qualifier-free base.
177-
let ledger_in_sync = lookup_entry(entries, purl)
178-
.or_else(|| lookup_entry(entries, strip_purl_qualifiers(purl)))
179-
.is_some_and(|e| e.uuid == *uuid);
166+
// A preserved ledger can outlive its wiring (rollback --preserve-state).
167+
// Only live lock tuples prove the engine can skip the workspace gate.
180168
let lock_all_ours = lock_parsed
181169
&& socket_patch_core::vendor::bun_lock::wired_instances_all_ours(cwd, purl)
182170
.await
183171
.unwrap_or(false);
184-
if ledger_in_sync || lock_all_ours {
172+
if lock_all_ours {
185173
exempt.insert((*purl).to_string());
186174
}
187175
}
@@ -275,8 +263,8 @@ mod tests {
275263
}
276264

277265
/// `bun_vendor_preflight` never reads the lock when nothing selected is
278-
/// npm (no needless I/O, no spurious refusal for other ecosystems); an
279-
/// in-sync ledger entry exempts; an unreadable ledger exempts nothing
266+
/// npm (no needless I/O, no spurious refusal for other ecosystems);
267+
/// a ledger alone never exempts; an unreadable ledger exempts nothing
280268
/// (fail closed) AND is reported as the real problem
281269
/// (`vendor_state_unreadable`), never as a Bun lock remedy.
282270
#[tokio::test]
@@ -300,11 +288,11 @@ mod tests {
300288
assert!(refusal.applies_to(PURL));
301289
assert!(!refusal.applies_to("pkg:pypi/only@1.0.0"));
302290

303-
// Exempt when the ledger wires this purl at this uuid…
291+
// A ledger at this UUID cannot make a binary lock vendorable.
304292
seed_bun_vendor_entry(tmp.path(), PURL, UUID);
305293
let refusal = bun_vendor_preflight(tmp.path(), &npm).await.unwrap();
306294
assert_eq!(refusal.code, "vendor_bun_lockb_unsupported");
307-
assert!(!refusal.applies_to(PURL), "in-sync ledger entry is exempt");
295+
assert!(refusal.applies_to(PURL), "the live lock must be compatible");
308296

309297
// …but a corrupt ledger exempts nothing and names itself.
310298
std::fs::write(tmp.path().join(".socket/vendor/state.json"), b"{ not json").unwrap();
@@ -350,10 +338,14 @@ mod tests {
350338
"a fresh registry instance is refused"
351339
);
352340

353-
// Vendored at UUID, ledger in sync: exempt (both rules agree).
354-
std::fs::write(tmp.path().join("bun.lock"), vendored_lock(UUID)).unwrap();
341+
// A preserved ledger does not make registry wiring exempt.
355342
seed_bun_vendor_entry(tmp.path(), PURL, UUID);
356343
let refusal = bun_vendor_preflight(tmp.path(), &fresh).await.unwrap();
344+
assert!(refusal.applies_to(PURL));
345+
346+
// Live vendored tuples remain exempt.
347+
std::fs::write(tmp.path().join("bun.lock"), vendored_lock(UUID)).unwrap();
348+
let refusal = bun_vendor_preflight(tmp.path(), &fresh).await.unwrap();
357349
assert!(!refusal.applies_to(PURL), "in-sync re-run is exempt");
358350

359351
// Superseding uuid: the ledger disagrees, the lock says ours → exempt.

crates/socket-patch-cli/src/commands/get.rs

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5324,13 +5324,11 @@ mod tests {
53245324
);
53255325
}
53265326

5327-
/// Already-vendored exemption: a ledger entry at the SAME uuid the run
5328-
/// selected is not refused (it proceeds to the fetch — unmounted here,
5329-
/// so it surfaces as a fetch miss), while a second purl the ledger
5330-
/// wires at an OLDER uuid is still refused before fetching.
5327+
/// Ledger entries at either the selected or an older UUID must not
5328+
/// bypass the refusal when the live lock contains registry wiring.
53315329
#[tokio::test]
53325330
#[serial_test::serial]
5333-
async fn download_patch_records_bun_refusal_exempts_ledger_entry_at_same_uuid() {
5331+
async fn download_patch_records_bun_refusal_rejects_unwired_ledger_entries() {
53345332
use wiremock::MockServer;
53355333

53365334
let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]);
@@ -5379,12 +5377,11 @@ mod tests {
53795377
.cloned()
53805378
.unwrap_or_else(|| panic!("no record for {purl}: {json}"))
53815379
};
5382-
let exempt = by_purl(in_sync);
5380+
let refused_same = by_purl(in_sync);
53835381
assert_eq!(
5384-
exempt["error"], "could not fetch details",
5385-
"the in-sync purl must be exempt from the refusal; json={json}"
5382+
refused_same["errorCode"], "vendor_bun_workspace_unsupported",
5383+
"UUID equality alone cannot bypass the refusal; json={json}"
53865384
);
5387-
assert!(exempt.get("errorCode").is_none(), "json={json}");
53885385
let refused = by_purl(stale);
53895386
assert_eq!(
53905387
refused["errorCode"], "vendor_bun_workspace_unsupported",
@@ -5397,8 +5394,7 @@ mod tests {
53975394
.iter()
53985395
.map(|r| r.url.path().to_string())
53995396
.collect();
5400-
assert_eq!(paths.len(), 1, "only the exempt purl may fetch: {paths:?}");
5401-
assert!(paths[0].ends_with(same), "{paths:?}");
5397+
assert!(paths.is_empty(), "no refused purl may fetch: {paths:?}");
54025398
}
54035399

54045400
/// An unreadable vendor ledger silences the drift warning (the main

crates/socket-patch-cli/src/commands/scan/hosted.rs

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1135,6 +1135,31 @@ pub(crate) async fn run_redirect_selected(
11351135
.map(|l| l.records.clone())
11361136
.unwrap_or_default();
11371137

1138+
// The migration unlinks its input, so check links before any takeover
1139+
// can mutate another dependency in this run as well as before Bun runs.
1140+
if overrides.iter().any(|o| o.ecosystem == "npm")
1141+
&& !common.cwd.join("bun.lock").exists()
1142+
&& present_lockb_sibling_locks(&common.cwd).is_empty()
1143+
&& socket_patch_core::utils::fs::first_symlink(&common.cwd, ["bun.lockb"])
1144+
.await
1145+
.is_some()
1146+
{
1147+
// Neither Bun's migration nor our byte-only backup can restore
1148+
// a link. Refuse before spawning Bun, including during preview.
1149+
let message = "bun.lockb is a symbolic link; replace it with a regular file (or run \
1150+
socket-patch in the directory it points to) before migrating; nothing \
1151+
was written";
1152+
eprintln!("Error (redirect_symlinked_file_unsupported): {message}");
1153+
if common.json {
1154+
emit_json_error_with_code(
1155+
scan_result.take(),
1156+
Some("redirect_symlinked_file_unsupported"),
1157+
message,
1158+
);
1159+
}
1160+
return 1;
1161+
}
1162+
11381163
// Cross-mode takeover: a purl this run is about to redirect may still be
11391164
// VENDORED — for cargo a committed `[patch.crates-io]` path entry, a
11401165
// detached Cargo.lock entry, a committed copy, and a vendored ledger
@@ -1169,6 +1194,25 @@ pub(crate) async fn run_redirect_selected(
11691194
use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers};
11701195
let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned();
11711196
let vendor_state = socket_patch_core::vendor::load_state(&common.cwd).await;
1197+
// Compatibility must be known before the takeover removes a live
1198+
// patch. In particular, a v0 workspace can keep an existing local
1199+
// tuple even though hosted mode cannot replace it with a URL.
1200+
let bun_takeover_refusal = if candidates.iter().any(|(p, ..)| p.starts_with("pkg:npm/")) {
1201+
match socket_patch_core::utils::fs::read_regular_to_string(&common.cwd.join("bun.lock"))
1202+
.await
1203+
{
1204+
Ok(content) => {
1205+
socket_patch_core::patch::redirect::preflight_bun_hosted(&content).err()
1206+
}
1207+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
1208+
Err(e) => Some(socket_patch_core::patch::redirect::RewriteWarning {
1209+
code: "redirect_bun_lock_unsupported".into(),
1210+
detail: format!("cannot read bun.lock before mode takeover: {e}"),
1211+
}),
1212+
}
1213+
} else {
1214+
None
1215+
};
11721216
let patch_entries =
11731217
socket_patch_core::vendor::cargo_config::read_patch_entries(&common.cwd).await;
11741218
let mut refused: Vec<String> = Vec::new();
@@ -1183,6 +1227,19 @@ pub(crate) async fn run_redirect_selected(
11831227
.and_then(|s| socket_patch_core::vendor::lookup_entry(&s.entries, stripped))
11841228
.cloned();
11851229
if let Some(entry) = ledger_entry {
1230+
if let Some(warning) = bun_takeover_refusal
1231+
.as_ref()
1232+
.filter(|_| purl.starts_with("pkg:npm/"))
1233+
{
1234+
refused.push(purl.clone());
1235+
if !takeover_pre_warnings
1236+
.iter()
1237+
.any(|w| w["code"] == warning.code)
1238+
{
1239+
takeover_pre_warnings.push(serde_json::json!(warning));
1240+
}
1241+
continue;
1242+
}
11861243
if common.dry_run {
11871244
// Preview through the same per-purl revert machinery the
11881245
// wet run dispatches (write-free under dry_run): a
@@ -1314,8 +1371,12 @@ pub(crate) async fn run_redirect_selected(
13141371
if !refused.is_empty() {
13151372
for purl in &refused {
13161373
if let Some((_, uuid, ..)) = candidates.iter().find(|(p, ..)| p == purl) {
1374+
let reason = bun_takeover_refusal
1375+
.as_ref()
1376+
.filter(|_| purl.starts_with("pkg:npm/"))
1377+
.map_or("vendored_revert_failed", |w| w.code.as_str());
13171378
skipped.push(serde_json::json!({
1318-
"purl": purl, "uuid": uuid, "reason": "vendored_revert_failed",
1379+
"purl": purl, "uuid": uuid, "reason": reason,
13191380
}));
13201381
}
13211382
}

crates/socket-patch-cli/src/commands/scan/vendor_flow.rs

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -62,22 +62,18 @@ pub(crate) async fn preview_vendor_json(
6262
let mut patches: Vec<serde_json::Value> = selected
6363
.iter()
6464
.map(|p| match lookup_entry(&state.entries, &p.purl) {
65-
Some(e) if e.uuid == p.uuid => serde_json::json!({
66-
"purl": p.purl, "uuid": p.uuid, "action": "already_vendored",
67-
}),
68-
// An in-sync ledger entry is exactly the preflight's ledger
69-
// exemption, so this arm never shadows `already_vendored`. A
70-
// stale entry is refused by the wet run like a fresh one when
71-
// the lock still holds a registry instance of the purl; when
72-
// every instance is already ours the preflight exempts it (the
73-
// engine re-vendors in place) and it previews `would_revendor`.
65+
// Refusal takes priority: a preserved ledger can name this
66+
// UUID even after rollback has removed its live wiring.
7467
_ if refusal.as_ref().is_some_and(|r| r.applies_to(&p.purl)) => {
7568
let r = refusal.as_ref().expect("checked by the guard");
7669
serde_json::json!({
7770
"purl": p.purl, "uuid": p.uuid, "action": "would_refuse",
7871
"errorCode": r.code, "error": r.detail,
7972
})
8073
}
74+
Some(e) if e.uuid == p.uuid => serde_json::json!({
75+
"purl": p.purl, "uuid": p.uuid, "action": "already_vendored",
76+
}),
8177
Some(e) => serde_json::json!({
8278
"purl": p.purl, "uuid": p.uuid,
8379
"action": "would_revendor", "oldUuid": e.uuid,
@@ -941,19 +937,18 @@ mod preview_tests {
941937
);
942938
}
943939

944-
/// The already-vendored exemption: an in-sync ledger entry keeps
945-
/// `already_vendored` (the wet run's engine skip), while a stale entry
946-
/// is `would_refuse` — the wet run refuses re-vendoring at a new uuid.
940+
/// A ledger cannot override the live-lock refusal. Already-vendored
941+
/// classification remains available when the lock is actually wired.
947942
#[tokio::test]
948-
async fn preview_already_vendored_wins_over_refusal_stale_entry_is_refused() {
943+
async fn preview_bun_refusal_requires_live_wiring_even_at_the_same_uuid() {
949944
let tmp = tempfile::tempdir().unwrap();
950945
std::fs::write(tmp.path().join("bun.lock"), V1_WORKSPACE_LOCK).unwrap();
951946

952947
seed_entry(tmp.path(), NPM, UUID);
953948
let preview = preview_vendor_json(tmp.path(), &[sel(UUID, NPM)]).await;
954949
assert_eq!(
955950
action_of(&preview, NPM)["action"],
956-
"already_vendored",
951+
"would_refuse",
957952
"{preview}"
958953
);
959954

@@ -965,6 +960,19 @@ mod preview_tests {
965960
rec.get("oldUuid").is_none(),
966961
"a refused record is not a revendor preview: {preview}"
967962
);
963+
964+
let wired = V1_WORKSPACE_LOCK.replace(
965+
r#"["preview-bun@1.0.0", "", {}, "sha512-AAAA=="]"#,
966+
&format!(r#"["preview-bun@.socket/vendor/npm/{UUID}/preview-bun-1.0.0.tgz", {{}}, "sha512-AAAA=="]"#),
967+
);
968+
std::fs::write(tmp.path().join("bun.lock"), wired).unwrap();
969+
seed_entry(tmp.path(), NPM, UUID);
970+
let preview = preview_vendor_json(tmp.path(), &[sel(UUID, NPM)]).await;
971+
assert_eq!(
972+
action_of(&preview, NPM)["action"],
973+
"already_vendored",
974+
"{preview}"
975+
);
968976
}
969977

970978
/// bun.lockb without a text lock: `would_refuse` with the lockb code.

crates/socket-patch-cli/src/commands/vendor.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1071,7 +1071,7 @@ pub(crate) async fn vendor_records(
10711071
PatchEvent::new(PatchAction::Failed, candidate.clone())
10721072
.with_error(refusal.code, refusal.detail.clone()),
10731073
);
1074-
if !common.silent && !common.json {
1074+
if !common.json {
10751075
eprintln!(
10761076
"Cannot vendor {}: {}",
10771077
normalize_purl(candidate),

0 commit comments

Comments
 (0)