From 8f15795f76a24e9f70578fa9f3562e433d6cfa2c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:23:00 +0000 Subject: [PATCH 1/4] Start fix for #1437 Assisted-by: Claude Code:claude-opus-5-5 From 1601aa161858a15568e9870f3d534270941b43c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:29:35 +0000 Subject: [PATCH 2/4] Refuse bare scan/get on vendored + hosted projects A bare `scan` or `get` (no --mode) picks its mode from the project's state, but only the vendor ledger and the manifest counted. Hosted mode keeps no ledger, so a project holding vendored packages beside hosted pins was classed as vendored, and the run silently took every hosted pin over to vendored: exit 0, no warning. That undid a partial `--mode hosted` takeover and per-package `get --mode` choices. When the vendor ledger is non-empty, the mode decision now also reads the lockfiles' hosted pins. Vendored + hosted state is a usage error (exit 2, `mode_ambiguous`) that names the pinned packages and asks for --mode, the same as agent + vendored state already is. Nothing is written. Projects with only one kind of state are unaffected, and hosted-only projects do not pay for the extra lockfile walk. Fixes #1437 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- crates/socket-patch-cli/src/commands/mod.rs | 93 ++++++++++++++++++- .../tests/scan_get_mode_from_project_state.rs | 82 ++++++++++++++++ docs/migrating-to-v5.md | 5 +- 4 files changed, 177 insertions(+), 5 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 5fde8db0d..90184bb66 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -175,7 +175,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc ### Scan modes (v5.0) -**Mode resolution (`resolve_mode_flags`, MAJOR in v5.0).** `--mode`, or `--sync` (agent mode), picks the mode. With neither, `scan` and `get` keep the mode the project's patch state already records (`mode_from_project_state`, #1088; both stores are read from the project the resolved manifest belongs to, so a `--manifest-path` into another project consults that project's ledger): a non-empty vendor ledger (`.socket/vendor/state.json`; also an unreadable or malformed one, so the vendored flow reports it) means **vendored**, a manifest holding patches the ledger does not already cover (by key or base purl), or an unreadable or malformed manifest (so the agent flow reports it), means **agent** — a record the ledger covers, as `get --save-only` then `vendor` leaves, is vendored state — and a project with neither runs **hosted** mode — JSON and human alike (see the hosted paragraph below and [`scan` and `get` JSON](#scan-and-get-json-v50-major)). A project holding both agent and vendored state is a usage error (exit 2, code `mode_ambiguous`, naming the three `--mode` values) and writes nothing. A human-mode run that kept vendored or agent mode says so on stderr (`Note: using --mode vendored because … already holds vendored patches; pass --mode explicitly to switch modes`); `--json` and `--silent` print no note. **Switching modes is always explicit**: the in-place vendored → hosted takeover (see **Staged takeover**) runs only under `--mode hosted`, the hosted → vendored takeover only under `--mode vendored`, and a bare `scan`/`get` never converts a project. With hosted/vendored PATH arguments and no `--mode`, each project directory takes its mode from its own state. The one exception to the state-derived default: a `--prune` or `--global`/`--global-prefix` scan with no mode has no project lockfile to rewire, so it is **report-only** — discovery, the table, the `updates` array and the `redirectState` block below, plus the `--prune` GC — and, in human mode, ends with the hint `To apply these patches in place, run:` / ` socket-patch scan --mode agent [PATHS]` / ` socket-patch get `. A global scan's hint carries the run's scope, so it can be run verbatim: `-g` (`scan --mode agent -g` / `get -g <…>`), or `--global-prefix ` when a prefix was given (the directory shell-quoted when it needs it). An explicit `--mode hosted` or `--mode vendored` with `--global`/`--global-prefix` is a usage error (exit 2: global installs have no project lockfile to redirect, or to wire vendored artifacts into); `get` enforces the same rule with the same wording. +**Mode resolution (`resolve_mode_flags`, MAJOR in v5.0).** `--mode`, or `--sync` (agent mode), picks the mode. With neither, `scan` and `get` keep the mode the project's patch state already records (`mode_from_project_state`, #1088; both stores are read from the project the resolved manifest belongs to, so a `--manifest-path` into another project consults that project's ledger): a non-empty vendor ledger (`.socket/vendor/state.json`; also an unreadable or malformed one, so the vendored flow reports it) means **vendored**, a manifest holding patches the ledger does not already cover (by key or base purl), or an unreadable or malformed manifest (so the agent flow reports it), means **agent** — a record the ledger covers, as `get --save-only` then `vendor` leaves, is vendored state — and a project with neither runs **hosted** mode — JSON and human alike (see the hosted paragraph below and [`scan` and `get` JSON](#scan-and-get-json-v50-major)). A project holding both agent and vendored state is a usage error (exit 2, code `mode_ambiguous`, naming the three `--mode` values) and writes nothing. So is a vendor ledger beside hosted pins in the project's lockfiles (#1437), the mixed state a partial `--mode hosted` takeover or per-package `get --mode` leaves: hosted mode keeps no ledger, so its pins are its state, and keeping vendored mode would take every pin over. A human-mode run that kept vendored or agent mode says so on stderr (`Note: using --mode vendored because … already holds vendored patches; pass --mode explicitly to switch modes`); `--json` and `--silent` print no note. **Switching modes is always explicit**: the in-place vendored → hosted takeover (see **Staged takeover**) runs only under `--mode hosted`, the hosted → vendored takeover only under `--mode vendored`, and a bare `scan`/`get` never converts a project. With hosted/vendored PATH arguments and no `--mode`, each project directory takes its mode from its own state. The one exception to the state-derived default: a `--prune` or `--global`/`--global-prefix` scan with no mode has no project lockfile to rewire, so it is **report-only** — discovery, the table, the `updates` array and the `redirectState` block below, plus the `--prune` GC — and, in human mode, ends with the hint `To apply these patches in place, run:` / ` socket-patch scan --mode agent [PATHS]` / ` socket-patch get `. A global scan's hint carries the run's scope, so it can be run verbatim: `-g` (`scan --mode agent -g` / `get -g <…>`), or `--global-prefix ` when a prefix was given (the directory shell-quoted when it needs it). An explicit `--mode hosted` or `--mode vendored` with `--global`/`--global-prefix` is a usage error (exit 2: global installs have no project lockfile to redirect, or to wire vendored artifacts into); `get` enforces the same rule with the same wording. **Global scope never touches the project's state (v5.0).** A `--global`/`--global-prefix` run that starts inside a project acts on the global installs only. The `--cwd` project's hosted pins and vendor ledger are not its target: `rollback` and `remove` run no hosted or vendored leg (they restore the global copies and drop their manifest records; `rollback` keeps the manifest records of purls the project vendors), a pre-v5 hosted ledger is never retired, and the project's vendor ledger does not own the global copies, so `apply` and `scan --mode agent` patch the global copy of a purl the project vendors (no `vendored` skip, no `vendored_ownership_retained` warning). The standalone `vendor` command acts only on the project, so every form of it (plain, `--revert`, `--check`) is a usage error under global scope: exit 2, human `Error: cannot be used with vendor[ --revert| --check]: global installs have no project lockfile to …`, JSON `{status: "error", error: {code: "global_scope_unsupported", message}}`, checked before the project is read or locked (#498). diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index 83bc76bb6..c1841741c 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -58,7 +58,13 @@ pub(crate) fn project_state_in_scope(common: &crate::args::GlobalArgs) -> bool { /// * a manifest (`.socket/manifest.json`) holding patches → agent; /// * neither → hosted (hosted mode keeps no ledger of its own); /// * both → `Err(usage message)`: the run cannot tell which mode to keep, -/// so it asks for an explicit `--mode` rather than guess. +/// so it asks for an explicit `--mode` rather than guess; +/// * a vendor ledger beside hosted pins in the lockfiles (a partial +/// `--mode hosted` takeover, or per-package `get --mode`) → +/// `Err(usage message)` too: hosted mode keeps no ledger, so its pins +/// are its state, and keeping vendored mode would take every one of +/// them over (#1437). The lockfiles are only walked beside a non-empty +/// ledger, the one case where a pin changes the answer. /// /// Both stores are read from [`GlobalArgs::project_root`], so a /// `--manifest-path` into another project consults that project's ledger, @@ -108,7 +114,29 @@ pub(crate) async fn mode_from_project_state( project_root.display(), manifest_path.display(), )), - (true, false) => Ok(scan::ScanMode::Vendored), + (true, false) => { + let pins = socket_patch_core::patch::redirect::upstream::HostedPin::recorded( + &discover_wiring(common, &project_root).await, + ); + if pins.is_empty() { + return Ok(scan::ScanMode::Vendored); + } + let mut purls: Vec<&str> = pins.iter().map(|pin| pin.purl.as_str()).collect(); + purls.sort_unstable(); + purls.dedup(); + let more = purls.len().saturating_sub(3); + purls.truncate(3); + let mut named = purls.join(", "); + if more > 0 { + named.push_str(&format!(" and {more} more")); + } + Err(format!( + "{} holds both hosted pins in its lockfiles ({named}) and vendored \ + patches (.socket/vendor/state.json): pass --mode hosted, --mode vendored \ + or --mode agent to choose the mode this run uses", + project_root.display(), + )) + } (false, true) => Ok(scan::ScanMode::Agent), (false, false) => Ok(scan::ScanMode::Hosted), } @@ -454,6 +482,67 @@ mod tests { assert!(mode_from_project_state(&common).await.is_err()); } + /// A `package-lock.json` at `root` pinning `name@version` to Socket's + /// hosted patch server: hosted state, which keeps no ledger. + fn write_hosted_lock(root: &Path, name: &str, version: &str) { + let lock = serde_json::json!({ + "name": "app", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { "name": "app", "version": "0.0.0" }, + format!("node_modules/{name}"): { + "version": version, + "resolved": format!( + "https://patch.socket.dev/patch/npm/{name}/{version}/tok/{UUID}/\ + {name}-{version}.tgz" + ), + "integrity": format!("sha512-{}", "a".repeat(86)), + }, + }, + }); + std::fs::write( + root.join("package-lock.json"), + serde_json::to_vec_pretty(&lock).unwrap(), + ) + .unwrap(); + } + + /// Vendored packages beside hosted pins (a partial `--mode hosted` + /// takeover, or per-package `get --mode`): a bare run cannot keep both + /// modes, and picking vendored would take every hosted pin over + /// (#1437), so it asks for `--mode`. + #[tokio::test] + async fn hosted_pins_beside_a_ledger_are_ambiguous() { + let tmp = tempfile::tempdir().unwrap(); + write_ledger(tmp.path()).await; + write_hosted_lock(tmp.path(), "minimist", "1.2.2"); + let mode = mode_from_project_state(&args(tmp.path(), ".socket/manifest.json")).await; + let err = mode.expect_err("vendored and hosted state together"); + assert!(err.contains("hosted"), "{err}"); + assert!(err.contains("--mode"), "{err}"); + } + + /// A hosted pin over the very package the ledger holds is still a + /// hosted pin: a bare vendored run would convert it back. + #[tokio::test] + async fn a_hosted_pin_over_a_ledger_entry_is_ambiguous() { + let tmp = tempfile::tempdir().unwrap(); + write_ledger(tmp.path()).await; + write_hosted_lock(tmp.path(), "left-pad", "1.3.0"); + let mode = mode_from_project_state(&args(tmp.path(), ".socket/manifest.json")).await; + assert!(mode.is_err(), "{mode:?}"); + } + + /// Hosted pins alone are a hosted project, and need no ledger. + #[tokio::test] + async fn hosted_pins_alone_are_hosted() { + let tmp = tempfile::tempdir().unwrap(); + write_hosted_lock(tmp.path(), "minimist", "1.2.2"); + let mode = mode_from_project_state(&args(tmp.path(), ".socket/manifest.json")).await; + assert_eq!(mode, Ok(scan::ScanMode::Hosted)); + } + /// With `--manifest-path` into another project, the ledger is read /// from that project, not from `--cwd`: a ledger left in `--cwd` is /// not consulted, and the other project's ledger is. diff --git a/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs b/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs index 95b097e8f..f29cd503f 100644 --- a/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs +++ b/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs @@ -455,6 +455,88 @@ async fn bare_scan_and_get_refuse_a_project_with_agent_and_vendored_state() { } } +/// A package of the project pinned to Socket's hosted patch server beside +/// the vendored `left-pad`: the mixed state a partial `--mode hosted` +/// takeover (#1039) or per-package `get --mode` leaves. +const HOSTED_NAME: &str = "is-odd"; +const HOSTED_VERSION: &str = "3.0.1"; +const HOSTED_PIN_URL: &str = "https://patch.socket.dev/patch/npm/is-odd/3.0.1/55555555-5555-4555-8555-555555555555/22222222-2222-4222-8222-222222222222/is-odd-3.0.1.tgz"; + +/// Add `is-odd`, pinned hosted, to the vendored project at `root`. +fn add_hosted_pin(root: &Path) { + let mut pkg: Value = + serde_json::from_slice(&std::fs::read(root.join("package.json")).unwrap()).unwrap(); + pkg["dependencies"][HOSTED_NAME] = json!(HOSTED_VERSION); + std::fs::write(root.join("package.json"), pkg.to_string()).unwrap(); + let installed = root.join("node_modules").join(HOSTED_NAME); + std::fs::create_dir_all(&installed).unwrap(); + std::fs::write( + installed.join("package.json"), + format!(r#"{{"name":"{HOSTED_NAME}","version":"{HOSTED_VERSION}"}}"#), + ) + .unwrap(); + let mut lock: Value = + serde_json::from_slice(&std::fs::read(root.join("package-lock.json")).unwrap()).unwrap(); + lock["packages"][""]["dependencies"][HOSTED_NAME] = json!(HOSTED_VERSION); + lock["packages"][format!("node_modules/{HOSTED_NAME}")] = json!({ + "version": HOSTED_VERSION, + "resolved": HOSTED_PIN_URL, + "integrity": PATCHED_SHA512, + }); + let mut text = serde_json::to_string_pretty(&lock).unwrap(); + text.push('\n'); + std::fs::write(root.join("package-lock.json"), text).unwrap(); +} + +/// Vendored packages beside hosted pins (#1437): keeping vendored mode +/// would take every hosted pin over, which only an explicit +/// `--mode vendored` may do. The run is a usage error naming `--mode`, +/// and nothing is written. +#[tokio::test(flavor = "multi_thread")] +async fn bare_scan_and_get_refuse_a_project_with_vendored_and_hosted_state() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + vendor_project(root, &server.uri()); + add_hosted_pin(root); + let before = snapshot(root); + + for command in ["scan", "get"] { + let (code, env) = run_cmd(root, &server.uri(), command, None, &[]); + assert_eq!(code, 2, "{command}: {env:#}"); + assert_eq!(env["status"], "error", "{command}: {env:#}"); + assert!( + env.to_string().contains("mode_ambiguous"), + "{command}: the error carries its code: {env:#}" + ); + assert!( + env.to_string().contains("--mode"), + "{command}: the error names the remedy: {env:#}" + ); + assert_unchanged(root, &before, &env); + let lock = std::fs::read_to_string(root.join("package-lock.json")).unwrap(); + assert!( + lock.contains(HOSTED_PIN_URL), + "{command}: the hosted pin is kept:\n{lock}" + ); + assert!( + !root + .join(".socket/vendor/npm/22222222-2222-4222-8222-222222222222") + .exists(), + "{command}: the hosted package is not vendored: {env:#}" + ); + } + + // An explicit `--mode vendored` still takes the hosted pin over. + let (code, env) = run_cmd(root, &server.uri(), "scan", Some("vendored"), &[]); + assert_ne!(code, 2, "an explicit mode is not ambiguous: {env:#}"); + assert!( + !env.to_string().contains("mode_ambiguous"), + "an explicit mode is not ambiguous: {env:#}" + ); +} + /// An agent-mode manifest holding the fixture patch under each of `purls`. fn write_manifest(root: &Path, purls: &[&str]) { std::fs::create_dir_all(root.join(".socket")).unwrap(); diff --git a/docs/migrating-to-v5.md b/docs/migrating-to-v5.md index b1bdc70a7..74d67fd87 100644 --- a/docs/migrating-to-v5.md +++ b/docs/migrating-to-v5.md @@ -18,8 +18,9 @@ existing scripts against the new CLI; the [changelog](../CHANGELOG.md) and vendor ledger (`.socket/vendor/state.json`) stays vendored, and one whose `.socket/manifest.json` holds patches stays in agent mode. Switching modes needs an explicit `--mode` (for example `scan --mode hosted` converts a vendored project in - place). A project holding both agent and vendored patches must pass `--mode` - (`mode_ambiguous`, exit 2). Global scans and a mode-less + place). A project holding both agent and vendored patches, or vendored patches + beside hosted pins in its lockfiles, must pass `--mode` (`mode_ambiguous`, + exit 2). Global scans and a mode-less `scan --prune` do not acquire new patches; `--prune` still performs cleanup. - Hosted state lives in dependency files. No command writes `.socket/vendor/redirect-state.json`. Legacy hosted records can still supply From 155f8685d0539511e3244b25cf5f9605a2b43a43 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:34:14 +0000 Subject: [PATCH 3/4] Count lockless hosted pins as hosted state A cargo or nuget package pinned to Socket's patch registry with no lockfile entry yet is still hosted, but the bare-run mode check only looked at lockfile pins. A vendored project beside such a pin could still be silently converted. Count those pins too, and tighten the end-to-end test so an explicit --mode must succeed, not just avoid exit 2. Refs #1437 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/src/commands/mod.rs | 46 ++++++++++++++++--- .../tests/scan_get_mode_from_project_state.rs | 15 ++---- 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index c1841741c..d3714aa2d 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -115,13 +115,23 @@ pub(crate) async fn mode_from_project_state( manifest_path.display(), )), (true, false) => { - let pins = socket_patch_core::patch::redirect::upstream::HostedPin::recorded( - &discover_wiring(common, &project_root).await, - ); - if pins.is_empty() { + // Lockless cargo/nuget pins are never refs, so + // `HostedPin::recorded` drops them; they are hosted state too. + let discovery = discover_wiring(common, &project_root).await; + let mut purls: Vec = + socket_patch_core::patch::redirect::upstream::HostedPin::recorded(&discovery) + .into_iter() + .map(|pin| pin.purl) + .chain( + discovery + .unlocked_pins + .iter() + .map(|pin| format!("pkg:{}/{}", pin.ecosystem, pin.name)), + ) + .collect(); + if purls.is_empty() { return Ok(scan::ScanMode::Vendored); } - let mut purls: Vec<&str> = pins.iter().map(|pin| pin.purl.as_str()).collect(); purls.sort_unstable(); purls.dedup(); let more = purls.len().saturating_sub(3); @@ -131,7 +141,7 @@ pub(crate) async fn mode_from_project_state( named.push_str(&format!(" and {more} more")); } Err(format!( - "{} holds both hosted pins in its lockfiles ({named}) and vendored \ + "{} holds both hosted pins in its dependency files ({named}) and vendored \ patches (.socket/vendor/state.json): pass --mode hosted, --mode vendored \ or --mode agent to choose the mode this run uses", project_root.display(), @@ -534,6 +544,30 @@ mod tests { assert!(mode.is_err(), "{mode:?}"); } + /// A lockless hosted pin (a nuget source no lock records yet) is + /// hosted state too, though `HostedPin::recorded` never lists it. + #[tokio::test] + async fn a_lockless_hosted_pin_beside_a_ledger_is_ambiguous() { + let tmp = tempfile::tempdir().unwrap(); + write_ledger(tmp.path()).await; + let key = format!("socket-patch-{UUID}"); + std::fs::write( + tmp.path().join("nuget.config"), + format!( + "\n\n \ + \n \n \n \ + \n \n \ + \n \n \ + \n\n" + ), + ) + .unwrap(); + let mode = mode_from_project_state(&args(tmp.path(), ".socket/manifest.json")).await; + let err = mode.expect_err("vendored and lockless hosted state together"); + assert!(err.contains("pkg:nuget/Newtonsoft.Json"), "{err}"); + } + /// Hosted pins alone are a hosted project, and need no ledger. #[tokio::test] async fn hosted_pins_alone_are_hosted() { diff --git a/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs b/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs index f29cd503f..fdd59869a 100644 --- a/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs +++ b/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs @@ -520,21 +520,12 @@ async fn bare_scan_and_get_refuse_a_project_with_vendored_and_hosted_state() { lock.contains(HOSTED_PIN_URL), "{command}: the hosted pin is kept:\n{lock}" ); - assert!( - !root - .join(".socket/vendor/npm/22222222-2222-4222-8222-222222222222") - .exists(), - "{command}: the hosted package is not vendored: {env:#}" - ); } - // An explicit `--mode vendored` still takes the hosted pin over. + // An explicit `--mode` picks the mode: the run is not refused. let (code, env) = run_cmd(root, &server.uri(), "scan", Some("vendored"), &[]); - assert_ne!(code, 2, "an explicit mode is not ambiguous: {env:#}"); - assert!( - !env.to_string().contains("mode_ambiguous"), - "an explicit mode is not ambiguous: {env:#}" - ); + assert_eq!(code, 0, "an explicit mode is not ambiguous: {env:#}"); + assert_eq!(env["status"], "success", "{env:#}"); } /// An agent-mode manifest holding the fixture patch under each of `purls`. From 0a72a494e29100c6c4651f2a4f69f82a283565f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:55:53 +0000 Subject: [PATCH 4/4] Count Gemfile-only gem pins as hosted state On a Gemfile.lock with no CHECKSUMS section, hosted mode pins a gem in the Gemfile only and leaves the lock for the next bundle install. That pin has no lockfile entry yet, so the bare-run mode check missed it, and a vendored project beside it could still be converted. Read those Gemfile pins too. Refs #1437 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/src/commands/mod.rs | 34 ++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index d3714aa2d..1c0be1078 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -115,9 +115,14 @@ pub(crate) async fn mode_from_project_state( manifest_path.display(), )), (true, false) => { - // Lockless cargo/nuget pins are never refs, so + // Lockless cargo/nuget pins and Gemfile-only gem pins (a + // CHECKSUMS-less lock, #1224) are never refs, so // `HostedPin::recorded` drops them; they are hosted state too. let discovery = discover_wiring(common, &project_root).await; + let gemfile_pins = socket_patch_core::vex::discover::gem_manifest_source_pins( + &socket_patch_core::vendor::lock_inventory::ProjectView::Disk(&project_root), + ) + .await; let mut purls: Vec = socket_patch_core::patch::redirect::upstream::HostedPin::recorded(&discovery) .into_iter() @@ -128,6 +133,7 @@ pub(crate) async fn mode_from_project_state( .iter() .map(|pin| format!("pkg:{}/{}", pin.ecosystem, pin.name)), ) + .chain(gemfile_pins.into_iter().map(|(purl, _)| purl)) .collect(); if purls.is_empty() { return Ok(scan::ScanMode::Vendored); @@ -568,6 +574,32 @@ mod tests { assert!(err.contains("pkg:nuget/Newtonsoft.Json"), "{err}"); } + /// A gem pinned only in the Gemfile (the hosted rewriter's wiring over + /// a CHECKSUMS-less lock, #1224) has no lock ref yet, but it is hosted + /// state all the same. + #[tokio::test] + async fn a_gemfile_only_hosted_pin_beside_a_ledger_is_ambiguous() { + let tmp = tempfile::tempdir().unwrap(); + write_ledger(tmp.path()).await; + std::fs::write( + tmp.path().join("Gemfile"), + format!( + "source \"https://rubygems.org\"\n\nsource \"https://patch.socket.dev/\ + patch-registry/gem/tok/{UUID}/\" do\n gem \"rails\", \"7.0.0\"\nend\n" + ), + ) + .unwrap(); + std::fs::write( + tmp.path().join("Gemfile.lock"), + "GEM\n remote: https://rubygems.org/\n specs:\n rails (7.0.0)\n\n\ + PLATFORMS\n ruby\n\nDEPENDENCIES\n rails (= 7.0.0)\n", + ) + .unwrap(); + let mode = mode_from_project_state(&args(tmp.path(), ".socket/manifest.json")).await; + let err = mode.expect_err("vendored and Gemfile-only hosted state together"); + assert!(err.contains("pkg:gem/rails@7.0.0"), "{err}"); + } + /// Hosted pins alone are a hosted project, and need no ledger. #[tokio::test] async fn hosted_pins_alone_are_hosted() {