Skip to content
Merged
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
7 changes: 6 additions & 1 deletion crates/socket-patch-cli/src/commands/rollback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,12 @@ async fn patched_in_other_scope(
let unverifiable = v.current_hash.is_none()
&& v.status != VerifyRollbackStatus::AlreadyOriginal
&& !v.is_absent();
if unverifiable || v.current_hash.as_deref() == Some(info.after_hash.as_str()) {
// `Ready` also covers npm's LF-shebang form of a patched
// bin, whose hash is not the afterHash.
if unverifiable
|| v.status == VerifyRollbackStatus::Ready
|| v.current_hash.as_deref() == Some(info.after_hash.as_str())
{
patched.push((purl.clone(), path.clone()));
break 'copies;
}
Expand Down
138 changes: 138 additions & 0 deletions crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1160,3 +1160,141 @@ async fn npm_get_ghsa_hosted_narrows_and_installs() {
let (fresh, installed) = fresh_install_patched(&fx);
manifestless_tail(&fx, &fresh, installed, &[]);
}

/// Regression (#1756): on Linux and macOS npm's `bin-links` rewrites a
/// `bin` file's CRLF shebang to LF at install. Agent-mode `apply` patches
/// that install (it was refused as drift), keeps the bin runnable (a
/// written-back `#!/usr/bin/env node\r` makes `env` exit 127), re-applies
/// as a no-op, `vex` attests it, and `rollback` restores npm's form of the
/// original. Offline: the package is a local tarball.
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
#[ignore = "wall-bound real-npm install; runs on the e2e CI matrix leg"]
async fn npm_agent_patches_crlf_shebang_bin_npm_rewrote() {
const SUITE: &str = "npm_agent_patches_crlf_shebang_bin_npm_rewrote";
if npm_e2e_common::npm_version().is_none() {
npm_e2e_common::skip(SUITE, "npm not on PATH");
return;
}
let before: &[u8] = b"#!/usr/bin/env node\r\nconsole.log(\"hi\");\r\n";
let after: &[u8] = b"#!/usr/bin/env node\r\nconsole.log(\"hi\");\r\n// SOCKET-PATCHED\r\n";
// What npm leaves on disk for each: the first line's `\r` dropped.
let installed_before: &[u8] = b"#!/usr/bin/env node\nconsole.log(\"hi\");\r\n";
let installed_after: &[u8] =
b"#!/usr/bin/env node\nconsole.log(\"hi\");\r\n// SOCKET-PATCHED\r\n";

let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("pkg");
std::fs::create_dir_all(&pkg).unwrap();
std::fs::write(
pkg.join("package.json"),
r#"{"name":"crlfbin","version":"1.0.0","bin":{"crlfbin":"cli.js"}}"#,
)
.unwrap();
std::fs::write(pkg.join("cli.js"), before).unwrap();
std::fs::write(
tmp.path().join("crlfbin-1.0.0.tgz"),
npm_e2e_common::npm_pack_like(&pkg),
)
.unwrap();
let proj = tmp.path().join("proj");
std::fs::create_dir_all(&proj).unwrap();
std::fs::write(
proj.join("package.json"),
r#"{"name":"p","version":"1.0.0","dependencies":{"crlfbin":"file:../crlfbin-1.0.0.tgz"}}"#,
)
.unwrap();
let cache = tmp.path().join("cache");
let out = npm_e2e_common::npm(
&proj,
&[
"install",
"--no-audit",
"--no-fund",
"--cache",
cache.to_str().unwrap(),
],
);
assert!(
out.status.success(),
"{}",
npm_e2e_common::output_text(&out)
);
let cli = proj.join("node_modules/crlfbin/cli.js");
assert_eq!(
std::fs::read(&cli).unwrap(),
installed_before,
"npm's fixBin"
);

let (b, a) = (git_sha256(before), git_sha256(after));
let blobs = proj.join(".socket/blobs");
std::fs::create_dir_all(&blobs).unwrap();
std::fs::write(blobs.join(&b), before).unwrap();
std::fs::write(blobs.join(&a), after).unwrap();
std::fs::write(
proj.join(".socket/manifest.json"),
format!(
r#"{{"patches":{{"pkg:npm/crlfbin@1.0.0":{{"uuid":"e2f99e5d-3afd-5cbd-be80-6a0102bcd6c2",
"exportedAt":"2026-01-01T00:00:00Z","files":{{"package/cli.js":{{"beforeHash":"{b}","afterHash":"{a}"}}}},
"vulnerabilities":{{"GHSA-xxxx-yyyy-zzzz":{{"cves":["CVE-2099-0001"],"summary":"t","severity":"high","description":"d"}}}},
"description":"d","license":"MIT","tier":"free"}}}}}}"#
),
)
.unwrap();

let run_bin = || {
std::process::Command::new(proj.join("node_modules/.bin/crlfbin"))
.current_dir(&proj)
.output()
.unwrap()
};

let (code, stdout, stderr) = run_socket(&proj, &["apply", "--offline"]);
assert_eq!(code, 0, "apply\nstdout:\n{stdout}\nstderr:\n{stderr}");
assert!(
!stderr.contains("did not match"),
"no drift warning: {stderr}"
);
assert_eq!(std::fs::read(&cli).unwrap(), installed_after);
let ran = run_bin();
assert!(
ran.status.success(),
"{}",
npm_e2e_common::output_text(&ran)
);
assert_eq!(String::from_utf8_lossy(&ran.stdout).trim(), "hi");

let (code, stdout, stderr) = run_socket(&proj, &["apply", "--offline"]);
assert_eq!(code, 0, "re-apply\nstdout:\n{stdout}\nstderr:\n{stderr}");
assert_eq!(std::fs::read(&cli).unwrap(), installed_after);

let doc = proj.join("vex.json");
let (code, stdout, stderr) = run_socket(
&proj,
&[
"vex",
"--offline",
"-O",
doc.to_str().unwrap(),
"--product",
"pkg:npm/p@1.0.0",
],
);
assert_eq!(code, 0, "vex\nstdout:\n{stdout}\nstderr:\n{stderr}");
let vex = std::fs::read_to_string(&doc).unwrap();
assert!(
vex.contains("CVE-2099-0001") && vex.contains("not_affected"),
"{vex}"
);

let (code, stdout, stderr) = run_socket(&proj, &["rollback", "--offline"]);
assert_eq!(code, 0, "rollback\nstdout:\n{stdout}\nstderr:\n{stderr}");
assert_eq!(std::fs::read(&cli).unwrap(), installed_before);
let ran = run_bin();
assert!(
ran.status.success(),
"{}",
npm_e2e_common::output_text(&ran)
);
}
Loading
Loading