Skip to content

Commit 6bdd67c

Browse files
Fix CRLF npm bins left broken by v5.0.1 (#1791) (#1792)
* Start fix for #1791 Assisted-by: Claude Code:claude-opus-5-5 * Recover npm bins v5.0.1 left with CRLF shebangs v5.0.1 could write a patch's raw bytes over an npm bin that npm had installed with an LF shebang, leaving "#!/usr/bin/env node\r" that env can't run (exit 127). After #1757, upgrading did not recover it: apply and apply --check called it already patched, and rollback wrote the CRLF original back, because the shebang form was chosen from whatever was on disk. Decide from the package's own package.json bin field (bin string or object, or directories.bin) whether a file is a bin npm rewrites. Apply now rewrites such a bin in npm's form, apply --check reports it as not applied, and rollback restores npm's runnable form of the original. Fixes #1791 Assisted-by: Claude Code:claude-opus-5-5 * Match npm's bin rules and fix old CRLF rollbacks Read package.json bins the way npm does: array bins, a string bin only when the package has a name, and directories.bin (skipping hidden files) when bin is missing or empty. apply --check now flags a bin only when its bytes are exactly the patched content, the same test apply uses, so the two always agree. Rollback also rewrites a bin that an older rollback left holding the raw CRLF original, when the original's blob is available. Writes skip reading package.json for content that has no CRLF shebang. Refs #1791 Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e4eb1c7 commit 6bdd67c

5 files changed

Lines changed: 588 additions & 68 deletions

File tree

‎crates/socket-patch-cli/src/commands/apply.rs‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,33 @@ async fn verify_installed_tree(
600600
.failed
601601
.retain(|f| !(f.reason == "package_not_found" && mismatched_keys.contains(&f.purl)));
602602
outcome.failed.extend(unmatched);
603+
604+
// An npm bin holding the patched bytes with a CRLF shebang `env`
605+
// can't run (written by a release before #1756) is one `apply`
606+
// rewrites, so it is not in sync (#1791).
607+
let mut crlf_bins: Vec<String> = Vec::new();
608+
for purl in &outcome.applied {
609+
let Some(record) = tree.patches.get(purl) else {
610+
continue;
611+
};
612+
'copies: for copy in copies.get(purl).into_iter().flatten() {
613+
for (file, info) in &record.files {
614+
if socket_patch_core::patch::apply::needs_npm_bin_rewrite(purl, copy, file, info)
615+
.await
616+
{
617+
crlf_bins.push(purl.clone());
618+
break 'copies;
619+
}
620+
}
621+
}
622+
}
623+
outcome.applied.retain(|purl| !crlf_bins.contains(purl));
624+
outcome
625+
.failed
626+
.extend(crlf_bins.into_iter().map(|purl| FailedPatch {
627+
purl,
628+
reason: "not_applied".to_string(),
629+
}));
603630
outcome
604631
}
605632

‎crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs‎

Lines changed: 95 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1161,30 +1161,21 @@ async fn npm_get_ghsa_hosted_narrows_and_installs() {
11611161
manifestless_tail(&fx, &fresh, installed, &[]);
11621162
}
11631163

1164-
/// Regression (#1756): on Linux and macOS npm's `bin-links` rewrites a
1165-
/// `bin` file's CRLF shebang to LF at install. Agent-mode `apply` patches
1166-
/// that install (it was refused as drift), keeps the bin runnable (a
1167-
/// written-back `#!/usr/bin/env node\r` makes `env` exit 127), re-applies
1168-
/// as a no-op, `vex` attests it, and `rollback` restores npm's form of the
1169-
/// original. Offline: the package is a local tarball.
1170-
#[cfg(unix)]
1171-
#[tokio::test(flavor = "multi_thread")]
1172-
#[ignore = "wall-bound real-npm install; runs on the e2e CI matrix leg"]
1173-
async fn npm_agent_patches_crlf_shebang_bin_npm_rewrote() {
1174-
const SUITE: &str = "npm_agent_patches_crlf_shebang_bin_npm_rewrote";
1175-
if npm_e2e_common::npm_version().is_none() {
1176-
npm_e2e_common::skip(SUITE, "npm not on PATH");
1177-
return;
1178-
}
1179-
let before: &[u8] = b"#!/usr/bin/env node\r\nconsole.log(\"hi\");\r\n";
1180-
let after: &[u8] = b"#!/usr/bin/env node\r\nconsole.log(\"hi\");\r\n// SOCKET-PATCHED\r\n";
1181-
// What npm leaves on disk for each: the first line's `\r` dropped.
1182-
let installed_before: &[u8] = b"#!/usr/bin/env node\nconsole.log(\"hi\");\r\n";
1183-
let installed_after: &[u8] =
1184-
b"#!/usr/bin/env node\nconsole.log(\"hi\");\r\n// SOCKET-PATCHED\r\n";
1164+
/// The tarball bytes of a bin published from Windows (CRLF shebang), its
1165+
/// patched twin, and what npm's `fixBin` leaves on disk for each: the
1166+
/// first line's `\r` dropped (#1756).
1167+
const CRLF_BIN_BEFORE: &[u8] = b"#!/usr/bin/env node\r\nconsole.log(\"hi\");\r\n";
1168+
const CRLF_BIN_AFTER: &[u8] =
1169+
b"#!/usr/bin/env node\r\nconsole.log(\"hi\");\r\n// SOCKET-PATCHED\r\n";
1170+
const NPM_BIN_BEFORE: &[u8] = b"#!/usr/bin/env node\nconsole.log(\"hi\");\r\n";
1171+
const NPM_BIN_AFTER: &[u8] = b"#!/usr/bin/env node\nconsole.log(\"hi\");\r\n// SOCKET-PATCHED\r\n";
11851172

1186-
let tmp = tempfile::tempdir().unwrap();
1187-
let pkg = tmp.path().join("pkg");
1173+
/// A project under `tmp` with the `crlfbin` tarball (a CRLF-shebang
1174+
/// `bin`) npm-installed, and an agent manifest patching that bin with
1175+
/// its blobs. Returns the project dir.
1176+
fn crlf_bin_project(tmp: &Path) -> PathBuf {
1177+
let (before, after) = (CRLF_BIN_BEFORE, CRLF_BIN_AFTER);
1178+
let pkg = tmp.join("pkg");
11881179
std::fs::create_dir_all(&pkg).unwrap();
11891180
std::fs::write(
11901181
pkg.join("package.json"),
@@ -1193,18 +1184,18 @@ async fn npm_agent_patches_crlf_shebang_bin_npm_rewrote() {
11931184
.unwrap();
11941185
std::fs::write(pkg.join("cli.js"), before).unwrap();
11951186
std::fs::write(
1196-
tmp.path().join("crlfbin-1.0.0.tgz"),
1187+
tmp.join("crlfbin-1.0.0.tgz"),
11971188
npm_e2e_common::npm_pack_like(&pkg),
11981189
)
11991190
.unwrap();
1200-
let proj = tmp.path().join("proj");
1191+
let proj = tmp.join("proj");
12011192
std::fs::create_dir_all(&proj).unwrap();
12021193
std::fs::write(
12031194
proj.join("package.json"),
12041195
r#"{"name":"p","version":"1.0.0","dependencies":{"crlfbin":"file:../crlfbin-1.0.0.tgz"}}"#,
12051196
)
12061197
.unwrap();
1207-
let cache = tmp.path().join("cache");
1198+
let cache = tmp.join("cache");
12081199
let out = npm_e2e_common::npm(
12091200
&proj,
12101201
&[
@@ -1221,11 +1212,7 @@ async fn npm_agent_patches_crlf_shebang_bin_npm_rewrote() {
12211212
npm_e2e_common::output_text(&out)
12221213
);
12231214
let cli = proj.join("node_modules/crlfbin/cli.js");
1224-
assert_eq!(
1225-
std::fs::read(&cli).unwrap(),
1226-
installed_before,
1227-
"npm's fixBin"
1228-
);
1215+
assert_eq!(std::fs::read(&cli).unwrap(), NPM_BIN_BEFORE, "npm's fixBin");
12291216

12301217
let (b, a) = (git_sha256(before), git_sha256(after));
12311218
let blobs = proj.join(".socket/blobs");
@@ -1243,12 +1230,42 @@ async fn npm_agent_patches_crlf_shebang_bin_npm_rewrote() {
12431230
)
12441231
.unwrap();
12451232

1246-
let run_bin = || {
1247-
std::process::Command::new(proj.join("node_modules/.bin/crlfbin"))
1248-
.current_dir(&proj)
1249-
.output()
1250-
.unwrap()
1251-
};
1233+
proj
1234+
}
1235+
1236+
/// Runs the project's `crlfbin` bin, asserting it prints `hi`.
1237+
fn assert_crlf_bin_runs(proj: &Path, what: &str) {
1238+
let ran = std::process::Command::new(proj.join("node_modules/.bin/crlfbin"))
1239+
.current_dir(proj)
1240+
.output()
1241+
.unwrap();
1242+
assert!(
1243+
ran.status.success(),
1244+
"{what}: {}",
1245+
npm_e2e_common::output_text(&ran)
1246+
);
1247+
assert_eq!(String::from_utf8_lossy(&ran.stdout).trim(), "hi", "{what}");
1248+
}
1249+
1250+
/// Regression (#1756): on Linux and macOS npm's `bin-links` rewrites a
1251+
/// `bin` file's CRLF shebang to LF at install. Agent-mode `apply` patches
1252+
/// that install (it was refused as drift), keeps the bin runnable (a
1253+
/// written-back `#!/usr/bin/env node\r` makes `env` exit 127), re-applies
1254+
/// as a no-op, `vex` attests it, and `rollback` restores npm's form of the
1255+
/// original. Offline: the package is a local tarball.
1256+
#[cfg(unix)]
1257+
#[tokio::test(flavor = "multi_thread")]
1258+
#[ignore = "wall-bound real-npm install; runs on the e2e CI matrix leg"]
1259+
async fn npm_agent_patches_crlf_shebang_bin_npm_rewrote() {
1260+
const SUITE: &str = "npm_agent_patches_crlf_shebang_bin_npm_rewrote";
1261+
if npm_e2e_common::npm_version().is_none() {
1262+
npm_e2e_common::skip(SUITE, "npm not on PATH");
1263+
return;
1264+
}
1265+
let (installed_before, installed_after) = (NPM_BIN_BEFORE, NPM_BIN_AFTER);
1266+
let tmp = tempfile::tempdir().unwrap();
1267+
let proj = crlf_bin_project(tmp.path());
1268+
let cli = proj.join("node_modules/crlfbin/cli.js");
12521269

12531270
let (code, stdout, stderr) = run_socket(&proj, &["apply", "--offline"]);
12541271
assert_eq!(code, 0, "apply\nstdout:\n{stdout}\nstderr:\n{stderr}");
@@ -1257,13 +1274,7 @@ async fn npm_agent_patches_crlf_shebang_bin_npm_rewrote() {
12571274
"no drift warning: {stderr}"
12581275
);
12591276
assert_eq!(std::fs::read(&cli).unwrap(), installed_after);
1260-
let ran = run_bin();
1261-
assert!(
1262-
ran.status.success(),
1263-
"{}",
1264-
npm_e2e_common::output_text(&ran)
1265-
);
1266-
assert_eq!(String::from_utf8_lossy(&ran.stdout).trim(), "hi");
1277+
assert_crlf_bin_runs(&proj, "after apply");
12671278

12681279
let (code, stdout, stderr) = run_socket(&proj, &["apply", "--offline"]);
12691280
assert_eq!(code, 0, "re-apply\nstdout:\n{stdout}\nstderr:\n{stderr}");
@@ -1291,10 +1302,46 @@ async fn npm_agent_patches_crlf_shebang_bin_npm_rewrote() {
12911302
let (code, stdout, stderr) = run_socket(&proj, &["rollback", "--offline"]);
12921303
assert_eq!(code, 0, "rollback\nstdout:\n{stdout}\nstderr:\n{stderr}");
12931304
assert_eq!(std::fs::read(&cli).unwrap(), installed_before);
1294-
let ran = run_bin();
1305+
assert_crlf_bin_runs(&proj, "after rollback");
1306+
}
1307+
1308+
/// Regression (#1791): v5.0.1 wrote the raw CRLF patched bytes over the
1309+
/// bin npm had fixed, so it exits 127. `apply --check` reports that drift,
1310+
/// `apply` rewrites the bin in npm's form, and from the same broken state
1311+
/// `rollback` restores npm's runnable form of the original.
1312+
#[cfg(unix)]
1313+
#[tokio::test(flavor = "multi_thread")]
1314+
#[ignore = "wall-bound real-npm install; runs on the e2e CI matrix leg"]
1315+
async fn npm_agent_recovers_crlf_bin_left_by_raw_write() {
1316+
const SUITE: &str = "npm_agent_recovers_crlf_bin_left_by_raw_write";
1317+
if npm_e2e_common::npm_version().is_none() {
1318+
npm_e2e_common::skip(SUITE, "npm not on PATH");
1319+
return;
1320+
}
1321+
let tmp = tempfile::tempdir().unwrap();
1322+
let proj = crlf_bin_project(tmp.path());
1323+
let cli = proj.join("node_modules/crlfbin/cli.js");
1324+
// What v5.0.1's apply left: the recorded bytes, CRLF shebang and all.
1325+
std::fs::write(&cli, CRLF_BIN_AFTER).unwrap();
1326+
1327+
let (code, stdout, stderr) = run_socket(&proj, &["apply", "--check", "--offline"]);
1328+
assert_eq!(code, 1, "check\nstdout:\n{stdout}\nstderr:\n{stderr}");
12951329
assert!(
1296-
ran.status.success(),
1297-
"{}",
1298-
npm_e2e_common::output_text(&ran)
1330+
format!("{stdout}{stderr}").contains("pkg:npm/crlfbin@1.0.0"),
1331+
"names the drifted patch\nstdout:\n{stdout}\nstderr:\n{stderr}"
12991332
);
1333+
1334+
let (code, stdout, stderr) = run_socket(&proj, &["apply", "--offline"]);
1335+
assert_eq!(code, 0, "apply\nstdout:\n{stdout}\nstderr:\n{stderr}");
1336+
assert_eq!(std::fs::read(&cli).unwrap(), NPM_BIN_AFTER);
1337+
assert_crlf_bin_runs(&proj, "after apply");
1338+
1339+
let (code, stdout, stderr) = run_socket(&proj, &["apply", "--check", "--offline"]);
1340+
assert_eq!(code, 0, "re-check\nstdout:\n{stdout}\nstderr:\n{stderr}");
1341+
1342+
std::fs::write(&cli, CRLF_BIN_AFTER).unwrap();
1343+
let (code, stdout, stderr) = run_socket(&proj, &["rollback", "--offline"]);
1344+
assert_eq!(code, 0, "rollback\nstdout:\n{stdout}\nstderr:\n{stderr}");
1345+
assert_eq!(std::fs::read(&cli).unwrap(), NPM_BIN_BEFORE);
1346+
assert_crlf_bin_runs(&proj, "after rollback");
13001347
}

0 commit comments

Comments
 (0)