Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion crates/socket-patch-cli/src/commands/scan/hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,12 @@ pub(super) async fn run_redirect(
// re-lock (and delete) the user's lockfile as a side effect of a no-op run.
let mut migration_warnings: Vec<serde_json::Value> = Vec::new();
let mut migration_edits: Vec<socket_patch_core::patch::redirect::FileEdit> = Vec::new();
// The pre-migration bun.lockb bytes, held so the migration can be undone
// when the subsequent rewrite lands NOTHING in the migrated bun.lock: an
// npm override whose version doesn't match the lock (or whose entry is
// refused) must not permanently convert the user's lockfile format as a
// side effect of a zero-redirect run.
let mut lockb_backup: Option<Vec<u8>> = None;
let has_lockb = args.common.cwd.join("bun.lockb").exists();
let has_bun_lock = args.common.cwd.join("bun.lock").exists();
let has_npm_override = overrides.iter().any(|o| o.ecosystem == "npm");
Expand All @@ -259,6 +265,9 @@ pub(super) async fn run_redirect(
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(args.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")
Expand All @@ -273,6 +282,7 @@ pub(super) async fn run_redirect(
let migrated = matches!(output, Ok(o) if o.status.success())
&& args.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).
Expand Down Expand Up @@ -342,6 +352,37 @@ pub(super) async fn run_redirect(
let rewrite = rewrite_registry_redirect(&files, &overrides);
let rewritten: Vec<String> = rewrite.files.keys().cloned().collect();

// The lockb→text migration is only KEPT when the rewrite actually landed
// in the migrated bun.lock. Otherwise nothing was redirected there and the
// migration was pure side effect: restore the saved bun.lockb bytes,
// 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") {
let restored = lockb_backup
.as_deref()
.is_some_and(|bytes| std::fs::write(args.common.cwd.join("bun.lockb"), bytes).is_ok());
if restored {
let _ = std::fs::remove_file(args.common.cwd.join("bun.lock"));
migration_edits.clear();
migration_warnings.push(serde_json::json!({
"code": "redirect_bun_lockb_migration_reverted",
"detail": "bun.lockb was migrated to a text bun.lock but no redirect landed \
in it; the original bun.lockb was restored",
}));
} else {
// Restore failed (unreadable pre-migration or unwritable now):
// keep the migration record and say loudly that the format was
// converted by a run that redirected nothing.
migration_warnings.push(serde_json::json!({
"code": "redirect_bun_lockb_migrated_without_redirect",
"detail": "bun.lockb was migrated to a text bun.lock but no redirect landed \
in it, and the original bun.lockb could not be restored; git \
history is the restore path",
}));
}
}

// Editing a Rush lock outside `rush update` desyncs the
// pnpmShrinkwrapHash recorded in repo-state.json. When
// preventManualShrinkwrapChanges is enabled, `rush install` then
Expand Down Expand Up @@ -529,7 +570,9 @@ pub(super) async fn run_redirect(
// deleting the other mode's ledger; reconciliation is deferred (see PR Scope).
// Read after the ledger write above so a non-dry-run reflects this run.
let mut takeover_warnings: Vec<serde_json::Value> = Vec::new();
let superseded = super::classify_overlap_takeover(&args.common.cwd).await.redirect;
let superseded = super::classify_overlap_takeover(&args.common.cwd)
.await
.redirect;
if !superseded.is_empty() {
takeover_warnings.push(serde_json::json!({
"code": super::REDIRECT_SUPERSEDES_VENDORED,
Expand Down
87 changes: 87 additions & 0 deletions crates/socket-patch-cli/tests/in_process_redirect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,93 @@ async fn scan_redirect_migrates_bun_lockb_then_redirects() {
);
}

/// The lockb migration must be UNDONE when the rewrite lands nothing in the
/// migrated bun.lock: here the shim's re-locked text lock holds a DIFFERENT
/// version of the dep than the granted override, so nothing is redirectable —
/// the run must restore the original bun.lockb bytes, remove the generated
/// bun.lock, and write no ledger, instead of permanently converting the
/// user's lockfile format as a side effect of a zero-redirect scan.
#[cfg(unix)]
#[tokio::test]
#[serial]
async fn zero_redirect_restores_bun_lockb_after_migration() {
let server = MockServer::start().await;
mock_discovery(&server).await;
mock_reference(&server).await;

let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("package.json"),
format!(
r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{NAME}": "^{VERSION}" }} }}"#
),
)
.unwrap();
let pkg = tmp.path().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();
let lockb_bytes: &[u8] = b"BUN-BINARY-PLACEHOLDER";
std::fs::write(tmp.path().join("bun.lockb"), lockb_bytes).unwrap();

// The shim's text lock resolves the dep to a version the override does
// NOT target, so the bun rewriter finds no rewritable tuple.
let bin_dir = tmp.path().join("fakebin");
std::fs::create_dir_all(&bin_dir).unwrap();
let shim = bin_dir.join("bun");
let bun_lock_body = format!(
"{{\n \"lockfileVersion\": 1,\n \"packages\": {{\n \
\"{NAME}\": [\"{NAME}@2.0.0\", \"\", {{}}, \"sha512-UPSTREAMupstream==\"],\n \
}}\n}}\n"
);
std::fs::write(
&shim,
format!(
"#!/bin/sh\n\
cat > bun.lock <<'LOCK'\n{bun_lock_body}LOCK\n\
rm -f bun.lockb\n\
exit 0\n"
),
)
.unwrap();
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let orig_path = std::env::var("PATH").unwrap_or_default();
// SAFETY: single-threaded #[serial] test; PATH restored below.
unsafe {
std::env::set_var("PATH", format!("{}:{orig_path}", bin_dir.display()));
}

let code = run(redirect_args(tmp.path(), server.uri())).await;

unsafe {
std::env::set_var("PATH", orig_path);
}
assert_eq!(code, 0, "a zero-redirect run is not an error");

let restored = std::fs::read(tmp.path().join("bun.lockb"))
.expect("bun.lockb must be restored after a zero-redirect migration");
assert_eq!(
restored, lockb_bytes,
"restored bun.lockb must carry the original bytes"
);
assert!(
!tmp.path().join("bun.lock").exists(),
"the generated text lock must be removed with the migration undone"
);
assert!(
!tmp.path()
.join(".socket/vendor/redirect-state.json")
.exists(),
"no ledger may record a migration that was undone"
);
}

/// A `socket-patch` Command with the ambient `SOCKET_*` env surface scrubbed,
/// for the subprocess tests below: the binary binds a wide clap env surface
/// (SOCKET_DRY_RUN, SOCKET_OFFLINE, SOCKET_ECOSYSTEMS, SOCKET_PROXY_URL, ...),
Expand Down
Loading
Loading