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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,15 @@ limits, and required install commands.

### Fixed

- Hosted `scan` / `get` run from a workspace member no longer reports
success while pinning nothing or the wrong files. From a pnpm workspace
member, or a pnpm project whose `lockfile-dir` puts `pnpm-lock.yaml` in
another directory, the run now refuses with
`redirect_pnpm_lockfile_elsewhere` (#590). From a cargo workspace member,
it refuses with `cargo_manifest_not_workspace_root`, as vendored mode
does, instead of rewriting the member's manifest and breaking the
workspace build (#417). Both exit 1 and write nothing; run from the
directory holding the lock.
- Global mode (`-g`) finds npm, yarn, pnpm, bun, RubyGems and Composer on
Windows, where they install as `.cmd` / `.bat` shims, instead of reporting
an empty scan. The yarn and npm-family global lookups no longer run from the
Expand Down
1 change: 1 addition & 0 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1194,6 +1194,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified
| `vendor_would_revert_redirect` / `vendor_takeover_reverted_redirect` | `skipped` (advisory event) | vendor / scan / get `--mode vendored` over a hosted pin (every ecosystem, v5.0): dry run — the upstream restore was resolved (registry lookups included) and would succeed (for bun, only after the Bun vendored preflight accepted the lock; a refused lock is previewed as the wet run's `failed <code>` instead) / wet run — the pin's lock entries were restored to their upstream registry entry before vendoring (mode takeover; detail `<purl> was hosted; restored its upstream registry entry (<files>) before vendoring (mode takeover)`), so `vendor --revert` later returns to upstream. Fires on the run that takes over, not on re-runs. |
| `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the upstream restore of a hosted pin was refused (`--offline`, a registry that does not answer, a lock shape the restore refuses — for `bun.lockb`, a record the codec cannot rebuild) — detail `cannot vendor over the live hosted pin: cannot restore <purl> to its upstream registry entry: <why>; restore it from version control instead (`git checkout -- <files>`)`; nothing vendored for the purl, hosted wiring left in place, exit 1 `partial_failure`. |
| `patch_fetch_failed` (eject) | `failed` | vendor eject (v5.0): a hosted pin's patch record could not be fetched from `…/patches/view/<uuid>`; the whole eject is refused (`eject_refused`), nothing touched, exit 1. |
| `redirect_pnpm_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). |
| `eject_refused` | top-level `errorCode` (`status: "error"`) | vendor eject (v5.0): a record fetch failed or a pin's upstream restore was refused while planning; nothing was changed, exit 1. |
| `eject_planned` | `applied` (reason) | vendor eject `--dry-run` (v5.0): the pin would be restored upstream and vendored; nothing written. |
| `eject_rolled_back` | warning | vendor eject (v5.0): a package failed after the restore began; every touched file was put back from the pre-eject snapshot, so the project is still hosted; `partial_failure`, exit 1. |
Expand Down
8 changes: 8 additions & 0 deletions crates/socket-patch-cli/src/commands/scan/hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,14 @@ pub(crate) async fn run_redirect_selected(
&engine::bun_lockb_symlink_refusal(),
);
}
// A workspace member whose lock lives in an ancestor directory (pnpm
// workspace / `lockfile-dir`, cargo workspace): the rewriters would
// read only the member, so refuse before any takeover or write.
if let Some(refusal) =
socket_patch_core::hosted::governing_root::refusal(&view, &candidates).await
{
return refuse(common, scan_result.take(), &refusal);
}

// vlt artifact preflight: before any takeover or rewrite (dry runs
// included), each in-scope artifact is fetched as vlt fetches it. A
Expand Down
108 changes: 108 additions & 0 deletions crates/socket-patch-cli/tests/in_process_redirect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4327,3 +4327,111 @@ async fn in_process_hosted_scan_state_attests_manifest_less() {
.expect("manifest-less VEX tail panicked");
});
}

/// #417: hosted `scan` from a cargo workspace MEMBER treated it as a
/// lockless project, wrote `registry = …` into the member's Cargo.toml and
/// a `[registries]` block into the member's `.cargo/config.toml`, left the
/// root Cargo.lock alone, and exited 0, breaking every build of the
/// workspace. It now refuses with vendored mode's
/// `cargo_manifest_not_workspace_root` and writes nothing.
#[tokio::test]
#[serial]
async fn cargo_hosted_scan_from_workspace_member_refuses() {
const CARGO_PURL: &str = "pkg:cargo/cfg-if@1.0.4";
const CARGO_UUID: &str = "33333333-3333-4333-8333-333333333333";
let cksum = "cd".repeat(32);
let index_url = format!("sparse+http://patch.test/registry/cargo/{CARGO_UUID}/index/");
let server = MockServer::start().await;
mock_cargo_patch(
&server,
CARGO_PURL,
CARGO_UUID,
"cfg-if",
"1.0.4",
&index_url,
&cksum,
"GHSA-carg-wsmb-wsmb",
)
.await;

let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"inherits\", \"direct\"]\n\n\
[workspace.dependencies]\ncfg-if = \"1.0.4\"\n",
)
.unwrap();
std::fs::write(
root.join("Cargo.lock"),
"version = 4\n\n[[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\n\
source = \"registry+https://github.com/rust-lang/crates.io-index\"\n\
checksum = \"ee\"\n\n[[package]]\nname = \"direct\"\nversion = \"0.1.0\"\n\
dependencies = [\n \"cfg-if\",\n]\n\n[[package]]\nname = \"inherits\"\n\
version = \"0.1.0\"\ndependencies = [\n \"cfg-if\",\n]\n",
)
.unwrap();
for (member, dep) in [
("inherits", "cfg-if = { workspace = true }"),
("direct", "cfg-if = \"1.0.4\""),
] {
std::fs::create_dir_all(root.join(member).join("src")).unwrap();
std::fs::write(
root.join(member).join("Cargo.toml"),
format!(
"[package]\nname = \"{member}\"\nversion = \"0.1.0\"\nedition = \"2018\"\n\n\
[dependencies]\n{dep}\n"
),
)
.unwrap();
std::fs::write(root.join(member).join("src/lib.rs"), "").unwrap();
}
let member = root.join("direct");
write_vendored_crate(&member, "cfg-if", "1.0.4");
let manifest_before = std::fs::read(member.join("Cargo.toml")).unwrap();
let lock_before = std::fs::read(root.join("Cargo.lock")).unwrap();

let out = scrubbed_cli()
.args([
"scan",
"--mode=hosted",
"--json",
"--yes",
"--cwd",
member.to_str().unwrap(),
"--api-url",
&server.uri(),
"--org",
ORG,
"--api-token",
"fake",
])
.output()
.expect("run socket-patch");
let doc: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| {
panic!(
"scan --json output is not JSON ({e}):\n{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
)
});
assert_eq!(out.status.code(), Some(1), "{doc}");
assert_eq!(doc["status"], "error", "{doc}");
assert_eq!(
doc["errorCode"], "cargo_manifest_not_workspace_root",
"{doc}"
);
assert!(
doc["error"]
.as_str()
.is_some_and(|m| m.contains("workspace root") && m.contains("nothing was written")),
"{doc}"
);
assert_eq!(
std::fs::read(member.join("Cargo.toml")).unwrap(),
manifest_before
);
assert_eq!(std::fs::read(root.join("Cargo.lock")).unwrap(), lock_before);
assert!(!member.join(".cargo").exists(), "no member registry block");
assert!(!member.join(".socket").exists());
}
232 changes: 232 additions & 0 deletions crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1097,3 +1097,235 @@ async fn hosted_partial_pnpm_redirect_is_not_confirmed_by_url_presence() {
.join(".socket/vendor/redirect-state.json")
.exists());
}

/// A pnpm workspace: the root holds `pnpm-workspace.yaml` and the only
/// `pnpm-lock.yaml` (importer `packages/a`); the member `packages/a` holds
/// its manifest and the installed copy, as pnpm lays it out.
fn write_pnpm_workspace(root: &Path) -> std::path::PathBuf {
std::fs::write(
root.join("package.json"),
r#"{ "name": "root", "private": true }"#,
)
.unwrap();
std::fs::write(
root.join("pnpm-workspace.yaml"),
"packages:\n - packages/*\n",
)
.unwrap();
std::fs::write(
root.join("pnpm-lock.yaml"),
format!(
"lockfileVersion: '9.0'

importers:
.: {{}}
packages/a:
dependencies:
{NAME}:
specifier: {VERSION}
version: {VERSION}

packages:
{NAME}@{VERSION}:
resolution: {{integrity: {UPSTREAM_SHA512}}}

snapshots:
{NAME}@{VERSION}: {{}}
"
),
)
.unwrap();
let member = root.join("packages/a");
let pkg = member.join("node_modules").join(NAME);
std::fs::create_dir_all(&pkg).unwrap();
std::fs::write(
member.join("package.json"),
format!(
r#"{{ "name": "a", "version": "1.0.0", "dependencies": {{ "{NAME}": "{VERSION}" }} }}"#
),
)
.unwrap();
std::fs::write(
pkg.join("package.json"),
format!(r#"{{ "name": "{NAME}", "version": "{VERSION}" }}"#),
)
.unwrap();
member
}

/// Assert the run refused with `redirect_pnpm_lockfile_elsewhere`, naming
/// the governing lock, and wrote nothing anywhere.
fn assert_refused_lock_elsewhere(
code: Option<i32>,
doc: &serde_json::Value,
lock: &Path,
lock_before: &str,
cwd: &Path,
) {
assert_eq!(
code,
Some(1),
"a found-but-unpinnable patch is not success: {doc}"
);
assert_eq!(doc["status"], "error", "{doc}");
assert_eq!(
doc["errorCode"], "redirect_pnpm_lockfile_elsewhere",
"{doc}"
);
let message = doc["error"].as_str().unwrap_or_default();
assert!(
message.contains("pnpm-lock.yaml") && message.contains("nothing was written"),
"the error names the governing lock: {message}"
);
assert_eq!(std::fs::read_to_string(lock).unwrap(), lock_before);
assert!(
!cwd.join(".socket").exists(),
"nothing written in the member"
);
assert!(!cwd.join("pnpm-workspace.yaml").exists());
}

/// #590: `scan --mode hosted` from a pnpm workspace member saw no lock in
/// the member, pinned nothing, and exited 0 with `success` and an npm
/// "no package-lock.json" warning while pnpm installs the unpatched copy
/// from the root lock. It now refuses and names the root.
#[tokio::test]
#[serial]
async fn hosted_scan_from_pnpm_workspace_member_refuses() {
let server = MockServer::start().await;
mock_discovery(&server).await;
mock_reference(&server).await;
mock_view(&server).await;
let tmp = tempfile::tempdir().unwrap();
let member = write_pnpm_workspace(tmp.path());
let lock = tmp.path().join("pnpm-lock.yaml");
let before = std::fs::read_to_string(&lock).unwrap();

let (code, doc) = run_hosted_json(&member, &server.uri());
assert_refused_lock_elsewhere(code, &doc, &lock, &before, &member);

// `get <uuid> --mode hosted` takes the same path.
let out = scrubbed_cli()
.args([
"get",
UUID,
"--mode",
"hosted",
"--json",
"--yes",
"--cwd",
member.to_str().unwrap(),
"--api-url",
&server.uri(),
"--org",
ORG,
"--api-token",
"fake",
])
.output()
.expect("run socket-patch");
let doc: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| {
panic!(
"get --json output is not JSON ({e}):\n{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
)
});
assert_refused_lock_elsewhere(out.status.code(), &doc, &lock, &before, &member);

// From the workspace root the same patch is pinned.
let (code, doc) = run_hosted_json(tmp.path(), &server.uri());
assert_eq!(code, Some(0), "{doc}");
assert_eq!(doc["redirect"]["redirected"], 1, "{doc}");
assert!(std::fs::read_to_string(&lock).unwrap().contains(HOSTED_URL));
}

/// #590, `lockfile-dir=..` variant: pnpm writes the project's lock to the
/// parent directory, so the project directory has none.
#[tokio::test]
#[serial]
async fn hosted_scan_with_pnpm_lockfile_dir_elsewhere_refuses() {
let server = MockServer::start().await;
mock_discovery(&server).await;
mock_reference(&server).await;
let tmp = tempfile::tempdir().unwrap();
let proj = tmp.path().join("proj");
std::fs::create_dir_all(&proj).unwrap();
write_pnpm_project(&proj);
std::fs::rename(
proj.join("pnpm-lock.yaml"),
tmp.path().join("pnpm-lock.yaml"),
)
.unwrap();
std::fs::write(proj.join(".npmrc"), "lockfile-dir=..\n").unwrap();
let lock = tmp.path().join("pnpm-lock.yaml");
let before = std::fs::read_to_string(&lock).unwrap();

let (code, doc) = run_hosted_json(&proj, &server.uri());
assert_refused_lock_elsewhere(code, &doc, &lock, &before, &proj);
}

/// pnpm inherits workspace-root config even when
/// invoked from a member. Absolute npmrc paths isolate inheritance; relative
/// YAML paths are resolved from the member cwd by pnpm 10.34.5 itself.
async fn assert_workspace_configured_lock_refused(case: &str) {
let server = MockServer::start().await;
mock_discovery(&server).await;
mock_reference(&server).await;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("workspace");
std::fs::create_dir_all(&root).unwrap();
let member = write_pnpm_workspace(&root);
let lock_dir = if case == "root-npmrc-absolute" {
let dir = tmp.path().join("locks");
std::fs::write(
root.join(".npmrc"),
format!("lockfile-dir={}\n", dir.display()),
)
.unwrap();
dir
} else if case == "root-yaml-relative" {
std::fs::write(
root.join("pnpm-workspace.yaml"),
"packages:\n - packages/*\nlockfileDir: ../locks\n",
)
.unwrap();
root.join("packages/locks")
} else {
let dir = tmp.path().join("yaml-locks");
std::fs::write(
root.join("pnpm-workspace.yaml"),
format!(
"packages:\n - packages/*\nlockfileDir: {}\n",
dir.display()
),
)
.unwrap();
std::fs::write(member.join(".npmrc"), "lockfile-dir=../unused-locks\n").unwrap();
dir
};
std::fs::create_dir_all(&lock_dir).unwrap();
let lock = lock_dir.join("pnpm-lock.yaml");
std::fs::rename(root.join("pnpm-lock.yaml"), &lock).unwrap();
let before = std::fs::read_to_string(&lock).unwrap();
let (code, doc) = run_hosted_json(&member, &server.uri());
assert_refused_lock_elsewhere(code, &doc, &lock, &before, &member);
}

#[tokio::test]
#[serial]
async fn hosted_scan_inherits_workspace_npmrc_lockfile_dir() {
assert_workspace_configured_lock_refused("root-npmrc-absolute").await;
}

#[tokio::test]
#[serial]
async fn hosted_scan_resolves_inherited_lockfile_dir_from_cwd() {
assert_workspace_configured_lock_refused("root-yaml-relative").await;
}

#[tokio::test]
#[serial]
async fn hosted_scan_workspace_yaml_overrides_member_npmrc() {
assert_workspace_configured_lock_refused("root-yaml-precedence").await;
}
Loading
Loading