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
2 changes: 1 addition & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <package-name-or-purl-or-CVE-ID>`. 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 <dir>` 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 <package-name-or-purl-or-CVE-ID>`. 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 <dir>` 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: <flag> 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).

Expand Down
159 changes: 157 additions & 2 deletions crates/socket-patch-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -108,7 +114,45 @@ pub(crate) async fn mode_from_project_state(
project_root.display(),
manifest_path.display(),
)),
(true, false) => Ok(scan::ScanMode::Vendored),
(true, false) => {
// 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<String> =
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)),
)
.chain(gemfile_pins.into_iter().map(|(purl, _)| purl))
.collect();
if purls.is_empty() {
return Ok(scan::ScanMode::Vendored);
Comment thread
mikolalysenko marked this conversation as resolved.
}
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 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(),
))
}
(false, true) => Ok(scan::ScanMode::Agent),
(false, false) => Ok(scan::ScanMode::Hosted),
}
Expand Down Expand Up @@ -454,6 +498,117 @@ 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:?}");
}

/// 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!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<configuration>\n \
<packageSources>\n <add key=\"{key}\" value=\"https://patch.socket.dev/\
patch-registry/nuget/tok/{UUID}/index.json\" />\n </packageSources>\n \
<packageSourceMapping>\n <packageSource key=\"{key}\">\n \
<package pattern=\"Newtonsoft.Json\" />\n </packageSource>\n \
</packageSourceMapping>\n</configuration>\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}");
}

/// 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() {
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.
Expand Down
73 changes: 73 additions & 0 deletions crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,79 @@ 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}"
);
}

// An explicit `--mode` picks the mode: the run is not refused.
let (code, env) = run_cmd(root, &server.uri(), "scan", Some("vendored"), &[]);
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`.
fn write_manifest(root: &Path, purls: &[&str]) {
std::fs::create_dir_all(root.join(".socket")).unwrap();
Expand Down
5 changes: 3 additions & 2 deletions docs/migrating-to-v5.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading