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
93 changes: 93 additions & 0 deletions crates/socket-patch-cli/tests/apply/in_process_npm_multicopy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,3 +397,96 @@ fn apply_and_rollback_reach_both_transitive_only_vlt_store_copies() {
assert_eq!(v["alreadyOriginal"], 1, "envelope={v}");
assert_vlt_copies([&primary, &twin], false, "after rollback");
}

/// #626: a `node_modules/<name>` link to first-party source (an npm
/// workspace member, which a `file:` directory dependency lays out the
/// same way) that shares a patched package's `name@version` is the user's
/// own code, not an installed copy. Agent-mode apply (dry run included)
/// and rollback refuse it with a diagnostic and never overwrite the fork,
/// even though the default mismatch policy would otherwise replace it.
#[cfg(unix)]
#[test]
fn apply_and_rollback_refuse_a_node_modules_link_to_first_party_source() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let name = "dupvuln";
let original = b"module.exports = function(){ return 'VULNERABLE'; };\n";
let mut patched = original.to_vec();
patched.extend_from_slice(b"// SOCKET-PATCHED-MULTICOPY\n");
std::fs::write(
root.join("package.json"),
r#"{ "name": "ws-root", "version": "0.0.0", "private": true, "workspaces": ["packages/*"] }"#,
)
.unwrap();
let fork = b"module.exports = 'first-party fork';\n";
let fork_index = write_copy(&root.join("packages").join(name), name, "1.0.0", fork);
std::fs::create_dir_all(root.join("node_modules")).unwrap();
std::os::unix::fs::symlink(
format!("../packages/{name}"),
root.join("node_modules").join(name),
)
.unwrap();
stage_manifest_and_blob(
root,
"pkg:npm/dupvuln@1.0.0",
&git_sha256(original),
&git_sha256(&patched),
&patched,
);
std::fs::write(
root.join(".socket")
.join("blobs")
.join(git_sha256(original)),
original,
)
.unwrap();

let assert_refused = |code: i32, v: &serde_json::Value, stage: &str| {
assert_ne!(code, 0, "{stage}: must fail closed; envelope={v}");
assert!(
v.to_string().contains("outside every node_modules tree"),
"{stage}: the refusal must name the cause; envelope={v}"
);
assert_eq!(
std::fs::read(&fork_index).unwrap(),
fork,
"{stage}: the first-party fork was overwritten"
);
};

let out = Command::new(binary())
.args([
"apply",
"--json",
"--offline",
"--dry-run",
"--ecosystems",
"npm",
"--cwd",
])
.arg(root)
.output()
.expect("run apply --dry-run");
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let v: serde_json::Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("apply must emit JSON: {e}; stdout={stdout}"));
assert_refused(out.status.code().unwrap_or(-1), &v, "apply --dry-run");

let (code, v) = run_apply(root);
assert_refused(code, &v, "apply");

// A fork left patched by an apply from before the guard: rollback must
// not write the upstream original over it either.
std::fs::write(&fork_index, &patched).unwrap();
let (code, v) = run_rollback(root);
assert_ne!(code, 0, "rollback must fail closed; envelope={v}");
assert!(
v.to_string().contains("outside every node_modules tree"),
"rollback: envelope={v}"
);
assert_eq!(
std::fs::read(&fork_index).unwrap(),
patched,
"rollback wrote through the link"
);
}
71 changes: 71 additions & 0 deletions crates/socket-patch-core/src/patch/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3625,6 +3625,77 @@ mod tests {
assert!(!result.success);
}

/// #626: agent apply must not write through a `node_modules` link to
/// first-party source (a workspace member / `file:` dir, scoped or not,
/// or an `npm link` target outside the project). The user's fork does
/// not match the patch's beforeHash, which the default policy would
/// otherwise overwrite. Refused for every policy, dry run included, and
/// the fork keeps its bytes.
#[cfg(unix)]
#[tokio::test]
async fn test_apply_refuses_node_modules_link_to_first_party_source() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().unwrap();
let ws = root.path().join("ws");
let nm = ws.join("node_modules");
std::fs::create_dir_all(nm.join("@acme")).unwrap();
let member = ws.join("packages").join("left-pad");
let scoped = ws.join("packages").join("util");
let checkout = root.path().join("dev").join("left-pad");
let fork = b"module.exports = 'first-party fork';\n".to_vec();
for d in [&member, &scoped, &checkout] {
std::fs::create_dir_all(d).unwrap();
std::fs::write(d.join("index.js"), &fork).unwrap();
}
symlink("../packages/left-pad", nm.join("left-pad")).unwrap();
symlink("../../packages/util", nm.join("@acme").join("util")).unwrap();
let linked_nm = root.path().join("app").join("node_modules");
std::fs::create_dir_all(&linked_nm).unwrap();
symlink(&checkout, linked_nm.join("left-pad")).unwrap();

let upstream = b"upstream original".to_vec();
let patched = b"upstream PATCHED".to_vec();
let blobs = root.path().join("blobs");
std::fs::create_dir_all(&blobs).unwrap();
let after_hash = compute_git_sha256_from_bytes(&patched);
std::fs::write(blobs.join(&after_hash), &patched).unwrap();
let mut files = HashMap::new();
files.insert(
"index.js".to_string(),
PatchFileInfo {
before_hash: compute_git_sha256_from_bytes(&upstream),
after_hash,
},
);
let sources = PatchSources::blobs_only(&blobs);
for (purl, pkg, real) in [
("pkg:npm/left-pad@1.3.0", nm.join("left-pad"), &member),
("pkg:npm/%40acme/util@1.0.0", nm.join("@acme/util"), &scoped),
(
"pkg:npm/left-pad@1.3.0",
linked_nm.join("left-pad"),
&checkout,
),
] {
for policy in [MismatchPolicy::Warn, MismatchPolicy::Force] {
for dry_run in [true, false] {
let result =
apply_package_patch(purl, &pkg, &files, &sources, None, dry_run, policy)
.await;
assert!(!result.success, "{}: must refuse", pkg.display());
let err = result.error.unwrap_or_default();
assert!(
err.contains(crate::patch::shared_store::LINKED_SOURCE_REFUSAL_MARKER),
"{}: {err}",
pkg.display()
);
assert!(result.files_patched.is_empty());
}
}
assert_eq!(std::fs::read(real.join("index.js")).unwrap(), fork);
}
}

/// A per-project pnpm store reached through a symlink is still patched.
#[cfg(unix)]
#[tokio::test]
Expand Down
47 changes: 47 additions & 0 deletions crates/socket-patch-core/src/patch/rollback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2582,4 +2582,51 @@ mod tests {
assert!(result.success, "{purl}: {:?}", result.error);
}
}

/// #626: rollback must not write the upstream original over first-party
/// source a `node_modules` link points at (left patched by an apply
/// from before the guard). Refused, dry run included, bytes kept.
#[cfg(unix)]
#[tokio::test]
async fn test_rollback_refuses_node_modules_link_to_first_party_source() {
let root = tempfile::tempdir().unwrap();
let ws = root.path().join("ws");
let nm = ws.join("node_modules");
std::fs::create_dir_all(&nm).unwrap();
let member = ws.join("packages").join("left-pad");
std::fs::create_dir_all(&member).unwrap();
std::os::unix::fs::symlink("../packages/left-pad", nm.join("left-pad")).unwrap();
let original = b"upstream original".to_vec();
let patched = b"upstream PATCHED".to_vec();
std::fs::write(member.join("index.js"), &patched).unwrap();
let blobs = root.path().join("blobs");
std::fs::create_dir_all(&blobs).unwrap();
let before_hash = compute_git_sha256_from_bytes(&original);
std::fs::write(blobs.join(&before_hash), &original).unwrap();
let mut files = HashMap::new();
files.insert(
"index.js".to_string(),
PatchFileInfo {
before_hash,
after_hash: compute_git_sha256_from_bytes(&patched),
},
);
for dry_run in [true, false] {
let result = rollback_package_patch(
"pkg:npm/left-pad@1.3.0",
&nm.join("left-pad"),
&files,
&blobs,
dry_run,
)
.await;
assert!(!result.success, "dry_run={dry_run}: must refuse");
let err = result.error.unwrap_or_default();
assert!(
err.contains(crate::patch::shared_store::LINKED_SOURCE_REFUSAL_MARKER),
"{err}"
);
}
assert_eq!(std::fs::read(member.join("index.js")).unwrap(), patched);
}
}
Loading
Loading