diff --git a/README.md b/README.md index ce33f0607..b10152567 100644 --- a/README.md +++ b/README.md @@ -90,13 +90,15 @@ VEX-aware vulnerability scanner. | Mode | Command | What to commit | What installs need | | --- | --- | --- | --- | -| Hosted (default) | `socket-patch scan` | Changed lockfiles, manifests, and registry configuration | Access to Socket's patch server | +| Hosted (default for a new project) | `socket-patch scan` | Changed lockfiles, manifests, and registry configuration | Access to Socket's patch server | | Vendored | `socket-patch scan --mode vendored` | Changed dependency files and `.socket/vendor/` (artifacts and ledger) | The committed patched packages | | Agent | `socket-patch scan --mode agent` | `.socket/manifest.json` and patch data; Go also uses a committed patched tree | `socket-patch apply` after dependency installs | Vendored mode stores **patched dependencies**, not the entire dependency graph. Other dependencies still need their normal registry, mirror, or offline cache. Hosted and vendored installs do not need an install hook or the Socket Patch CLI. +Once a project holds vendored or agent-mode patches, a bare `scan` or `get` keeps +that mode; pass `--mode` to switch. The CLI supports npm, PyPI, Cargo, Go, RubyGems, Maven (including sbt, Mill and scala-cli builds), Composer, NuGet, and Deno. diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index b15c14e87..d60c66350 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -16,11 +16,11 @@ For task-oriented guidance, start with [usage](../../docs/usage.md), | Name | Visible alias(es) | Notes | |---|---|---| -| `scan` | — | Find patches for installed packages and apply them. **v5.0 (MAJOR)**: a bare `scan` runs hosted mode (rewrites lockfiles so only the patched dependencies resolve to Socket-hosted, integrity-pinned packages); `--mode vendored` / `--mode agent` pick the other modes. Never prompts. See [scan modes](#scan-modes-v50) | +| `scan` | — | Find patches for installed packages and apply them. **v5.0 (MAJOR)**: a bare `scan` runs hosted mode (rewrites lockfiles so only the patched dependencies resolve to Socket-hosted, integrity-pinned packages) on a project with no patch state, and keeps the mode of a project that already holds vendored or agent-mode patches; `--mode vendored` / `--mode agent` / `--mode hosted` pick a mode explicitly. Never prompts. See [scan modes](#scan-modes-v50) | | `vex` | — | Emit an OpenVEX 0.2.0 attestation derived from the local manifest, the vendor ledger, and the hosted / vendored patch references the project's lockfiles wire (no manifest required; hosted records come from the API) | | `vendor` | — | Eject patched dependencies into committable `.socket/vendor/` and rewire lockfiles | | `list` | — | Print patches in the local manifest, plus the vendor ledger's records (v5.0) and the hosted pins the lockfiles wire (labeled; see the action matrix; an empty project exits 0) | -| `get` | — | Fetch a selected patch in hosted mode by default; `--mode agent` selects in-place application, also the default with `--save-only` or global targeting. Requires positional `identifier`. | +| `get` | — | Fetch a selected patch in the project's current mode (hosted for a project with no patch state); `--mode agent` selects in-place application, also the default with `--save-only` or global targeting. Requires positional `identifier`. | | `apply` | — | Agent mode: apply patches from the local manifest | | `rollback` | — | **Full-state rollback (v5.0, MAJOR)**: restore original files AND unwind vendored lockfile wiring / restore hosted pins to their upstream registry entries, remove the rolled-back entries from the manifest, and GC their blobs/archives; takes optional variadic positional `targets` (PURL \| UUID \| package name \| path glob). See [Rollback command contract](#rollback-command-contract-v50) | | `remove` | — | Restore and remove one patch across hosted, vendored, and agent state; requires positional `identifier`. | @@ -104,7 +104,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments | `scan` | `--no-socket-yml` | `SOCKET_NO_SOCKET_YML` | (v5.0) Ignore the repository's socket.yml patch policy (its `patches` block and `projectIgnorePaths`) for this run; the built-in test/fixture ignores still apply. The `policy` block reports `source: "bypassed"`. See "socket.yml patch policy". | | `scan` | `--min-severity ` | `SOCKET_MIN_SEVERITY` | (v5.0) Severity floor for the patch a package may receive (worst advisory severity; unknown severity is skipped whenever a floor is set). Beats `patches.minSeverity`; the flag beats the env; `none` lifts the floor. A malformed value is exit 2. | | `get`, `scan` | `--all-releases` | `SOCKET_ALL_RELEASES` | Download patches for every release/distribution variant of a matched package — PyPI wheel/sdist (`artifact_id`), RubyGems (`platform`), Maven (`classifier`) — not just the one(s) matching the locally-installed distribution. On `scan` this makes the stored manifest portable across environments (e.g. cross-platform CI caches). On `get` (v3.6) it ALSO disables the coarse installed-**version** narrowing of CVE/GHSA fan-outs (see "get --mode and installed narrowing"): every found version's patch is fetched, installed or not | -| `get` | positional `identifier`; `--id` / `--cve` / `--ghsa` / `--package` (`-p`); `--save-only`; `--mode ` | `SOCKET_SAVE_ONLY` | Patch lookup + consumption mode (v3.6). `--mode` reuses scan's value enum (same hidden value aliases `host`/`redirect`/`vendor`; deliberately no env binding, matching scan). Default (v5.0): `hosted`, like scan; `agent` (save + apply in place) when `--save-only` or `--global`/`--global-prefix` is given. An explicit `--mode hosted\|vendored` with `--global`/`--global-prefix` is a usage error (exit 2, scan's wording: global installs have no project lockfile). An explicit `--save-only` conflicts with `--mode hosted\|vendored` — rejected with **exit 1** via get's established self-enforced-conflict style (unlike scan's exit-2 mode conflicts; see the exit-code table) | +| `get` | positional `identifier`; `--id` / `--cve` / `--ghsa` / `--package` (`-p`); `--save-only`; `--mode ` | `SOCKET_SAVE_ONLY` | Patch lookup + consumption mode (v3.6). `--mode` reuses scan's value enum (same hidden value aliases `host`/`redirect`/`vendor`; deliberately no env binding, matching scan). Default (v5.0): the mode the project's patch state records, like scan (see **Mode resolution**), so `hosted` for a project with no state; `agent` (save + apply in place) when `--save-only` or `--global`/`--global-prefix` is given. An explicit `--mode hosted\|vendored` with `--global`/`--global-prefix` is a usage error (exit 2, scan's wording: global installs have no project lockfile). An explicit `--save-only` conflicts with `--mode hosted\|vendored` — rejected with **exit 1** via get's established self-enforced-conflict style (unlike scan's exit-2 mode conflicts; see the exit-code table) | | `remove` | positional `identifier` (target grammar: UUID, PURL with or without version, or exact package name); `--skip-rollback`; `--preserve-state` (v5.0) | `SOCKET_SKIP_ROLLBACK`, `SOCKET_PRESERVE_STATE` | Manifest entry removal. The identifier matches like a `rollback` target (a PyPI name compares by its PEP 503 canonical form). `--preserve-state` is the single-patch twin of `rollback --preserve-state`: restore the tree and unwind the identifier's vendored/hosted wiring, but keep the manifest entry, the vendored artifact + ledger entry, and skip all GC. Combining it with `--skip-rollback` is a self-enforced usage error (exit 2): one flag keeps the tree and drops the state, the other restores the tree and keeps the state — together they select the do-nothing quadrant ("the combination would be a no-op: nothing would change"). The conflict fires whether either flag is spelled on the command line or sourced from its env var | | `rollback` | optional variadic positional `targets` (PURL \| UUID \| package name \| path glob); `--preserve-state` (v5.0) | `SOCKET_PRESERVE_STATE` | Rollback scope. Multiple targets union. A token becomes a path glob ONLY when it is path-SHAPED — contains a separator (`/` or `\`) or a glob metacharacter (`*?[`), or starts with `./`, or is absolute (an npm `@scope/name` is a name, not a path; a relative slash name without glob metacharacters or `.`/`..` segments — composer `vendor/pkg`, a go module path — is a name when it selects a recorded or hosted patch); a `pkg:` prefix is a PURL and every other token keeps the target grammar (UUID, PURL or exact package name), so a mistyped identifier or truncated UUID stays a safe exit-1 "No patch found matching identifier: X" (with a hint suggesting `./X` or `X/**` for directory targeting) instead of silently becoming a path scope. An unparseable glob is a usage error (exit 2) | | `vex` | `--output` / `-O`, `--product`, `--no-verify`, `--doc-id`, `--compact` | `SOCKET_VEX_OUTPUT`, `SOCKET_VEX_PRODUCT`, `SOCKET_VEX_NO_VERIFY`, `SOCKET_VEX_DOC_ID`, `SOCKET_VEX_COMPACT` | OpenVEX 0.2.0 document generation; see "vex output channels" below | @@ -132,7 +132,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` runs **hosted** mode — JSON and human alike; the result nests under the JSON `redirect` sub-object (see the hosted paragraph below). The one exception: 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; the hosted result nests under the JSON `redirect` sub-object (see the hosted paragraph below). 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. **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). @@ -1437,6 +1437,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `lock_held` / `lock_io` | every lock-taking command | Another live run holds `apply.lock`, or the lock file could not be opened. | | `invalid_args` | scan, get, remove, repair | Usage error (exit 2): flags that cannot be combined. | | `global_scope_unsupported` | scan, get, vendor | Usage error (exit 2): `--global`/`--global-prefix` with hosted or vendored mode. | +| `mode_ambiguous` | scan, get | Usage error (exit 2, v5.0): no `--mode` was given and the project holds both agent-mode patches (`.socket/manifest.json` records the vendor ledger does not cover) and vendored patches (`.socket/vendor/state.json`), so the mode to keep is unknown. Pass `--mode`. | | `identifier_invalid` | get | Usage error (exit 2): the identifier does not match the forced `--id`/`--cve`/`--ghsa` type. | | `path_not_directory` | scan | Usage error (exit 2): a hosted/vendored PATH is not a directory. | | `path_outside_repo` | scan | Usage error (exit 2): a PATH is outside the repository root socket.yml is read from. | @@ -1754,7 +1755,7 @@ Exit `1` when `status` is `partialFailure` (any `events[*].action == "failed"`) |---|---| | `0` | Success | | `1` | Error (missing/invalid manifest, fetch failed, apply failed, selection cancelled in non-JSON mode, an invalid or ambiguous socket.yml on `scan` (v5.0), etc.) | -| `2` | Usage error: clap parse failures (unknown flag/value, missing required arg, an unknown subcommand such as the removed `setup`) and the conflicts the commands enforce themselves — `scan`'s cross-mode conflict (`--sync` combined with a `--mode` other than `agent`, rejected in `resolve_mode_flags`) and `--mode hosted\|vendored` with `--global`/`--global-prefix` (same enforcement point; `get` refuses the same combination); in hosted/vendored `scan` (bare `scan` included), a PATH that is not a directory, a PATH glob matching no directory, and `--json` with more than one project directory (`run_project_dirs`); on every project command (all but `--update`), a `--cwd` / `SOCKET_CWD` or `--global-prefix` / `SOCKET_GLOBAL_PREFIX` that is not an existing directory, and a non-default `--manifest-path` / `SOCKET_MANIFEST_PATH` that names a directory or sits in a project directory that does not exist (v5.0; checked once before the command runs, whether or not the command uses the flag — previously these read as an empty project and exited 0, and `remove` / `rollback` exited 1; a missing manifest FILE in an existing project stays legal, and `get` / `scan`, which create the manifest, still create a missing manifest directory); `remove --preserve-state --skip-rollback` (the no-op quadrant; flag- or env-sourced alike), an unparseable path glob on `scan`/`rollback`, a `scan` PATH outside the repository root and a malformed `SOCKET_MIN_SEVERITY` or `SOCKET_MAX_NEW_PATCHES` (v5.0), `repair --offline --download-only`. Under `--json`, every usage error a command enforces itself prints the coded error on stdout (v5.0, MAJOR: `scan`, `remove` and `rollback` printed nothing there): a full envelope for the envelope commands, `{status: "error", error: {code, message}}` for `scan`, `get` and `rollback`; clap's own parse errors, and the path-flag check that runs before every command (`--cwd` / `--global-prefix` / `--manifest-path` naming nothing), still print nothing on stdout. `vex` also exits `2` on hard errors before document generation (see its tri-state table below). v5.0: `get`'s self-enforced conflicts exit `2` too (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--mode hosted\|vendored --save-only`, a malformed identifier for a forced `--id`/`--cve`/`--ghsa`) — previously `1` (MAJOR). The never-implemented `get --one-off` / `rollback --one-off` (and `SOCKET_ONE_OFF`) are removed in v5.0; `--one-off` is now an ordinary unknown-flag clap error. | +| `2` | Usage error: clap parse failures (unknown flag/value, missing required arg, an unknown subcommand such as the removed `setup`) and the conflicts the commands enforce themselves — `scan`'s cross-mode conflict (`--sync` combined with a `--mode` other than `agent`, rejected in `resolve_mode_flags`) and `--mode hosted\|vendored` with `--global`/`--global-prefix` (same enforcement point; `get` refuses the same combination); a `scan`/`get` with no `--mode` on a project holding both agent and vendored state (`mode_ambiguous`, v5.0); in hosted/vendored `scan` (bare `scan` included), a PATH that is not a directory, a PATH glob matching no directory, and `--json` with more than one project directory (`run_project_dirs`); on every project command (all but `--update`), a `--cwd` / `SOCKET_CWD` or `--global-prefix` / `SOCKET_GLOBAL_PREFIX` that is not an existing directory, and a non-default `--manifest-path` / `SOCKET_MANIFEST_PATH` that names a directory or sits in a project directory that does not exist (v5.0; checked once before the command runs, whether or not the command uses the flag — previously these read as an empty project and exited 0, and `remove` / `rollback` exited 1; a missing manifest FILE in an existing project stays legal, and `get` / `scan`, which create the manifest, still create a missing manifest directory); `remove --preserve-state --skip-rollback` (the no-op quadrant; flag- or env-sourced alike), an unparseable path glob on `scan`/`rollback`, a `scan` PATH outside the repository root and a malformed `SOCKET_MIN_SEVERITY` or `SOCKET_MAX_NEW_PATCHES` (v5.0), `repair --offline --download-only`. Under `--json`, every usage error a command enforces itself prints the coded error on stdout (v5.0, MAJOR: `scan`, `remove` and `rollback` printed nothing there): a full envelope for the envelope commands, `{status: "error", error: {code, message}}` for `scan`, `get` and `rollback`; clap's own parse errors, and the path-flag check that runs before every command (`--cwd` / `--global-prefix` / `--manifest-path` naming nothing), still print nothing on stdout. `vex` also exits `2` on hard errors before document generation (see its tri-state table below). v5.0: `get`'s self-enforced conflicts exit `2` too (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--mode hosted\|vendored --save-only`, a malformed identifier for a forced `--id`/`--cve`/`--ghsa`) — previously `1` (MAJOR). The never-implemented `get --one-off` / `rollback --one-off` (and `SOCKET_ONE_OFF`) are removed in v5.0; `--one-off` is now an ordinary unknown-flag clap error. | `list` returns **`0`** for every project it can read, empty or not (**v5.0, BREAKING**: a project with no manifest and no ledger record — normal for hosted mode, which writes no manifest — used to exit `1` with `manifest_not_found`; it is now an empty list: `No patches in this project. Run \`socket-patch scan\`.` on stdout, and under `--json` the success envelope with `events: []`). Only an unreadable or invalid manifest (`manifest_unreadable` / `manifest_invalid`) exits `1`. Every lock-taking subcommand — including `scan`/`get --mode hosted` as of v5.0 — returns **`1`** with `error.code: lock_held` when another live socket-patch process holds `<.socket>/apply.lock`. diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index e83707d17..ad6bea329 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -143,7 +143,8 @@ pub struct GetArgs { pub all_releases: bool, /// How to consume the patches: the same modes as `scan --mode` - /// [default: hosted; agent with `--save-only` or `--global`] + /// [default: the mode the project's patch state already records, else + /// hosted; agent with `--save-only` or `--global`] // agent = record in .socket/manifest.json + blobs and apply in place; // hosted = rewrite lockfiles so the patched deps resolve to Socket's // hosted patch server (no manifest, no blobs, no ledger: the lockfile @@ -1016,16 +1017,34 @@ pub async fn run(args: GetArgs) -> i32 { "Only one of --id, --cve, --ghsa, or --package can be specified", ); } - // v5: hosted by default, like scan. `--save-only` (records a manifest - // entry) and global installs (no project lockfile) mean agent mode. - // Usage errors exit 2, like clap's and scan's (v5.0). - let mode = args - .mode - .unwrap_or(if args.save_only || args.common.is_global() { - super::scan::ScanMode::Agent - } else { - super::scan::ScanMode::Hosted - }); + // v5: with no `--mode`, like scan, the project keeps the mode its + // state already records (#1088) and a project with no state is hosted. + // `--save-only` (records a manifest entry) and global installs (no + // project lockfile) mean agent mode. Usage errors exit 2, like clap's + // and scan's (v5.0). + let mode = match args.mode { + Some(mode) => mode, + None if args.save_only || args.common.is_global() => super::scan::ScanMode::Agent, + None => match super::mode_from_project_state(&args.common).await { + Ok(mode) => { + if !args.common.json && !args.common.silent { + if let Some(note) = super::kept_mode_note(mode) { + eprintln!("{note}"); + } + } + mode + } + Err(message) => { + return usage_error( + JsonCommand::Get, + args.common.json, + args.common.dry_run, + "mode_ambiguous", + &message, + ); + } + }, + }; // Global installs have no project lockfile: an explicit hosted or // vendored mode would rewire the cwd project, not the global copy. if let Some(conflict) = super::global_mode_conflict(&args.common, mode) { diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index 45329a503..f0118380d 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -48,6 +48,87 @@ pub(crate) fn project_state_in_scope(common: &crate::args::GlobalArgs) -> bool { !common.is_global() } +/// The mode a `scan`/`get` run with no `--mode` uses for the project +/// (CLI_CONTRACT.md, Mode resolution): the mode the project's own patch +/// state already records, so a bare run never converts the project to +/// another mode. Hosted is the default for a project with no state. +/// +/// * a non-empty vendor ledger (`.socket/vendor/state.json`) → vendored; +/// * 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. +/// +/// Both stores are read from [`GlobalArgs::project_root`], so a +/// `--manifest-path` into another project consults that project's ledger, +/// never a ledger left in `--cwd`. +/// +/// A manifest record the ledger already covers (same key or base purl) is +/// vendored state, not agent evidence: the documented `get --save-only` +/// then `vendor` flow leaves the record in the manifest beside its ledger +/// entry, and that project is vendored. +/// +/// An unreadable or malformed vendor ledger counts as vendored, so the +/// vendored flow reports the corruption instead of a hosted takeover +/// running over it; with no readable ledger no manifest record counts as +/// covered. Likewise an unreadable or malformed manifest counts as agent +/// state, so the agent flow reports it rather than a bare run converting +/// the project to hosted mode (#1088). A missing manifest is no evidence. +/// Only called in project scope. +/// +/// [`GlobalArgs::project_root`]: crate::args::GlobalArgs::project_root +pub(crate) async fn mode_from_project_state( + common: &crate::args::GlobalArgs, +) -> Result { + let manifest_path = common.resolved_manifest_path(); + let project_root = common.project_root(); + let (manifest, vendor) = tokio::join!( + socket_patch_core::manifest::operations::read_manifest(&manifest_path), + socket_patch_core::vendor::load_state(&project_root), + ); + let vendored_keys = match &vendor { + Ok(state) => state.purl_keys(), + Err(_) => Default::default(), + }; + let vendored = !matches!(&vendor, Ok(state) if state.entries.is_empty()); + let agent = match &manifest { + Ok(Some(m)) => m + .patches + .keys() + .any(|purl| !socket_patch_core::vendor::state::purl_keys_cover(&vendored_keys, purl)), + Ok(None) => false, + Err(_) => true, + }; + match (vendored, agent) { + (true, true) => Err(format!( + "{} holds both agent-mode patches ({}) and vendored patches \ + (.socket/vendor/state.json): pass --mode agent, --mode vendored or \ + --mode hosted to choose the mode this run uses", + project_root.display(), + manifest_path.display(), + )), + (true, false) => Ok(scan::ScanMode::Vendored), + (false, true) => Ok(scan::ScanMode::Agent), + (false, false) => Ok(scan::ScanMode::Hosted), + } +} + +/// The stderr note a human-mode `scan`/`get` prints when it kept a +/// non-default mode from the project's state. +pub(crate) fn kept_mode_note(mode: scan::ScanMode) -> Option { + let store = match mode { + scan::ScanMode::Hosted => return None, + scan::ScanMode::Vendored => ".socket/vendor/state.json", + scan::ScanMode::Agent => "the manifest", + }; + Some(format!( + "Note: using --mode {} because {store} already holds {} patches; pass \ + --mode explicitly to switch modes", + mode.cli_name(), + mode.cli_name(), + )) +} + /// The usage error for a mode that rewires the project (`hosted`, /// `vendored`) under global scope, or `None` when `mode` is allowed. /// Shared by `scan` and `get` so both refuse the same combinations with @@ -201,3 +282,143 @@ pub(crate) fn vendor_state_lenient( } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + const LEDGER_PURL: &str = "pkg:npm/left-pad@1.3.0"; + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + + /// A one-entry vendor ledger at `root` for `LEDGER_PURL`, checked to + /// load, so a test never passes on the malformed-ledger branch. + async fn write_ledger(root: &Path) { + let path = root.join(socket_patch_core::vendor::VENDOR_STATE_REL); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let ledger = serde_json::json!({ + "version": 1, + "entries": { + LEDGER_PURL: { + "ecosystem": "npm", + "basePurl": LEDGER_PURL, + "uuid": UUID, + "artifact": { + "path": format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"), + "sha256": "ab".repeat(32), + }, + "wiring": [], + } + } + }); + std::fs::write(&path, serde_json::to_vec_pretty(&ledger).unwrap()).unwrap(); + let loaded = socket_patch_core::vendor::load_state(root) + .await + .expect("the fixture ledger must load"); + assert_eq!(loaded.entries.len(), 1); + } + + /// An agent manifest at `/.socket/manifest.json` holding `purls`. + fn write_manifest(root: &Path, purls: &[&str]) { + let dir = root.join(".socket"); + std::fs::create_dir_all(&dir).unwrap(); + let patches: serde_json::Map = purls + .iter() + .map(|purl| { + ( + purl.to_string(), + serde_json::json!({ + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": {}, + "vulnerabilities": {}, + "description": "fixture", + "license": "MIT", + "tier": "free", + }), + ) + }) + .collect(); + std::fs::write( + dir.join("manifest.json"), + serde_json::to_vec_pretty(&serde_json::json!({ "patches": patches })).unwrap(), + ) + .unwrap(); + } + + fn args(cwd: &Path, manifest_path: &str) -> crate::args::GlobalArgs { + crate::args::GlobalArgs { + cwd: PathBuf::from(cwd), + manifest_path: manifest_path.to_string(), + ..crate::args::GlobalArgs::default() + } + } + + /// `get --save-only` then `vendor` leaves the vendored record in the + /// manifest beside its ledger entry: that project is vendored, not + /// ambiguous. + #[tokio::test] + async fn manifest_records_the_ledger_covers_are_vendored_state() { + let tmp = tempfile::tempdir().unwrap(); + write_ledger(tmp.path()).await; + write_manifest(tmp.path(), &[LEDGER_PURL]); + let mode = mode_from_project_state(&args(tmp.path(), ".socket/manifest.json")).await; + assert_eq!(mode, Ok(scan::ScanMode::Vendored)); + } + + /// A manifest record the ledger does not cover is agent state, so a + /// project holding it beside a ledger is still ambiguous. + #[tokio::test] + async fn an_uncovered_manifest_record_beside_a_ledger_is_ambiguous() { + let tmp = tempfile::tempdir().unwrap(); + write_ledger(tmp.path()).await; + write_manifest(tmp.path(), &[LEDGER_PURL, "pkg:npm/is-odd@3.0.1"]); + let mode = mode_from_project_state(&args(tmp.path(), ".socket/manifest.json")).await; + let err = mode.expect_err("agent and vendored state together"); + assert!(err.contains("--mode"), "{err}"); + } + + /// A malformed manifest is agent state: a bare run must not take the + /// project over in hosted mode, so the agent flow reports the error. + #[tokio::test] + async fn a_malformed_manifest_is_not_a_hosted_project() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join(".socket")).unwrap(); + std::fs::write(tmp.path().join(".socket/manifest.json"), b"{ not json").unwrap(); + let common = args(tmp.path(), ".socket/manifest.json"); + assert_eq!( + mode_from_project_state(&common).await, + Ok(scan::ScanMode::Agent) + ); + + // Beside a vendor ledger it is ambiguous, not silently vendored. + write_ledger(tmp.path()).await; + assert!(mode_from_project_state(&common).await.is_err()); + } + + /// 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. + #[tokio::test] + async fn the_ledger_is_read_from_the_manifest_project_root() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path().join("cwd"); + let other = tmp.path().join("other"); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::create_dir_all(&other).unwrap(); + let manifest_path = other.join(".socket/manifest.json"); + let manifest_path = manifest_path.to_str().unwrap(); + + // A ledger in --cwd only: the manifest's project is agent. + write_ledger(&cwd).await; + write_manifest(&other, &["pkg:npm/is-odd@3.0.1"]); + let mode = mode_from_project_state(&args(&cwd, manifest_path)).await; + assert_eq!(mode, Ok(scan::ScanMode::Agent)); + + // The manifest's project vendored the record: vendored. + write_ledger(&other).await; + write_manifest(&other, &[LEDGER_PURL]); + let mode = mode_from_project_state(&args(&cwd, manifest_path)).await; + assert_eq!(mode, Ok(scan::ScanMode::Vendored)); + } +} diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index a3d5b08e6..75fa9ccdf 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -262,8 +262,10 @@ pub struct ScanArgs { #[arg(long, default_value_t = false)] pub sync: bool, - /// How discovered patches are consumed [default: hosted]. A `--prune` - /// or `--global` scan with no mode only reports + /// How discovered patches are consumed [default: the mode the + /// project's patch state already records, else hosted]. Switching an + /// existing vendored or agent-mode project to another mode needs this + /// flag. A `--prune` or `--global` scan with no mode only reports // `--sync` also selects agent; combining it with a different `--mode` // is rejected in `resolve_mode_flags`. #[arg(long = "mode", value_enum)] @@ -1545,10 +1547,14 @@ fn project_dirs( /// name, as if each were `--cwd`. The exit code is the worst of the runs. /// `--json` takes one directory, so stdout stays one document. Every /// directory must be inside the repository root the policy was read from. +/// +/// `mode_inferred`: the mode came from `--cwd`'s state rather than +/// `--mode`, so each directory takes its mode from its own state. async fn run_project_dirs( args: ScanArgs, telemetry: &mut PendingTelemetry, invocation: &InvocationPolicy, + mode_inferred: bool, ) -> i32 { let usage = |code: &str, message: &str| { usage_error( @@ -1622,6 +1628,9 @@ async fn run_project_dirs( child.paths.clear(); child.common.cwd = dir.clone(); child.rollout.carry = Some(carry.clone()); + if mode_inferred { + child.mode = None; + } code = code.max(Box::pin(run_scan(child, telemetry, Some(invocation), *explicit)).await); } code @@ -1659,6 +1668,9 @@ async fn run_scan( ) -> i32 { apply_env_toggles(&args.common); + // Whether the user chose the mode (`--mode`, or `--sync` = agent). + // Without it, the mode comes from the project's own state below. + let mode_explicit = args.mode.is_some() || args.sync; // Resolve `--mode`/`--sync` into `args.mode` (see // `resolve_mode_flags`). `--sync` with another mode is a usage error // (exit 2); under --json it prints the coded error on stdout. @@ -1676,6 +1688,24 @@ async fn run_scan( }; return scan_usage_error(&args, code, &message); } + // A bare scan keeps the mode the project already has (#1088): only an + // explicit `--mode` converts a vendored or agent-mode project. The + // hosted default above is the only one this replaces (a `--prune` or + // global scan with no mode stays report-only). + let mode_inferred = !mode_explicit && args.mode == Some(ScanMode::Hosted); + if mode_inferred { + match crate::commands::mode_from_project_state(&args.common).await { + Ok(mode) => { + if !args.common.json && !args.common.silent { + if let Some(note) = crate::commands::kept_mode_note(mode) { + eprintln!("{note}"); + } + } + args.mode = Some(mode); + } + Err(message) => return scan_usage_error(&args, "mode_ambiguous", &message), + } + } // The repo's socket.yml policy, read once per invocation before any // write (an invalid file fails the run closed). @@ -1699,7 +1729,7 @@ async fn run_scan( if matches!(args.mode, Some(ScanMode::Hosted) | Some(ScanMode::Vendored)) && !args.paths.is_empty() { - return Box::pin(run_project_dirs(args, telemetry, invocation)).await; + return Box::pin(run_project_dirs(args, telemetry, invocation, mode_inferred)).await; } let mut policy = Box::new(ScanPolicy::for_root( 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 new file mode 100644 index 000000000..fdd85a544 --- /dev/null +++ b/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs @@ -0,0 +1,469 @@ +//! A `scan`/`get` with no `--mode` keeps the mode the project's patch +//! state already records (#1088): a bare run on a vendored project stays +//! vendored, one on an agent-mode project stays agent, and only an +//! explicit `--mode hosted` runs the vendored→hosted takeover. A project +//! holding both agent and vendored state is a usage error that asks for +//! `--mode`. +//! +//! The API and the npm registry are wiremock; no npm binary is needed. +//! Every child process gets the ambient `SOCKET_*` vars scrubbed and +//! telemetry hard-disabled; each test runs in its own tempdir. + +#[path = "common/hermetic.rs"] +mod hermetic; +#[path = "prebuilt_common/mod.rs"] +mod prebuilt_common; + +use std::path::Path; + +use base64::Engine as _; +use serde_json::{json, Value}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ORG: &str = "test-org"; +const NAME: &str = "left-pad"; +const VERSION: &str = "1.3.0"; +const PURL: &str = "pkg:npm/left-pad@1.3.0"; +/// A manifest record no vendor ledger entry covers: agent-only state. +const AGENT_ONLY_PURL: &str = "pkg:npm/is-odd@3.0.1"; +const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; +const HOSTED_URL: &str = "https://patch.socket.dev/patch/npm/left-pad/1.3.0/55555555-5555-4555-8555-555555555555/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz"; +const PATCHED_SHA512: &str = "sha512-PATCHEDpatchedPATCHEDpatched0123456789=="; +const UPSTREAM_TARBALL: &str = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"; +const UPSTREAM_SHA512: &str = "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="; +const ORIG_INDEX: &[u8] = b"module.exports = () => 'orig';\n"; +const PATCHED_INDEX: &[u8] = b"module.exports = () => 'patched';\n"; + +// ───────────────────────────── fixture ───────────────────────────── + +/// The pristine lock npm `lock_version` writes for a root project +/// depending on `left-pad@1.3.0`. +fn pristine_lock(lock_version: u64) -> Value { + let dep = json!({ + "version": VERSION, + "resolved": UPSTREAM_TARBALL, + "integrity": UPSTREAM_SHA512, + }); + if lock_version == 1 { + json!({ + "name": "v6", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { NAME: dep }, + }) + } else { + json!({ + "name": "v6", + "version": "1.0.0", + "lockfileVersion": lock_version, + "requires": true, + "packages": { + "": { "name": "v6", "version": "1.0.0", "dependencies": { NAME: VERSION } }, + "node_modules/left-pad": dep, + }, + }) + } +} + +/// package.json, the installed (unpatched) copy and the pristine lock +/// named `lock_name`. +fn write_npm_project(root: &Path, lock_name: &str, lock_version: u64) { + std::fs::write( + root.join("package.json"), + format!( + r#"{{"name":"v6","version":"1.0.0","private":true,"dependencies":{{"{NAME}":"{VERSION}"}}}}"# + ), + ) + .unwrap(); + let pkg = root.join("node_modules").join(NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{"name":"{NAME}","version":"{VERSION}"}}"#), + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), ORIG_INDEX).unwrap(); + let mut lock = serde_json::to_string_pretty(&pristine_lock(lock_version)).unwrap(); + lock.push('\n'); + std::fs::write(root.join(lock_name), lock).unwrap(); +} + +fn patch_record() -> Value { + json!({ + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": compute_git_sha256_from_bytes(ORIG_INDEX), + "afterHash": compute_git_sha256_from_bytes(PATCHED_INDEX), + } + }, + "vulnerabilities": {}, + "description": "mode-from-state fixture", + "license": "MIT", + "tier": "free" + }) +} + +fn patch_view() -> Value { + let mut view = patch_record(); + view["purl"] = json!(PURL); + view["publishedAt"] = json!("2024-01-01T00:00:00Z"); + view["files"]["package/index.js"]["blobContent"] = + json!(base64::engine::general_purpose::STANDARD.encode(PATCHED_INDEX)); + view +} + +/// The hosted-mode API (discovery + by-package + grant + view) for the one +/// patch over `PURL`, plus the npm registry's version document the +/// upstream restore re-resolves the pristine entry from. +async fn mock_api(server: &MockServer) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "packages": [{ + "purl": PURL, + "patches": [{ + "uuid": UUID, "purl": PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "mode-from-state fixture" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "patches": [{ + "uuid": UUID, "purl": PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": { + UUID: { + "status": "granted", + "url": HOSTED_URL, + "purl": PURL, + "artifacts": [{ + "kind": "tarball", + "url": HOSTED_URL, + "integrity": { "sha512": PATCHED_SHA512 } + }], + "registryOverride": null + } + } + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(patch_view())) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("/{NAME}/{VERSION}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "name": NAME, + "version": VERSION, + "dist": { "tarball": UPSTREAM_TARBALL, "integrity": UPSTREAM_SHA512 } + }))) + .mount(server) + .await; +} + +// ───────────────────────── subprocess runner ───────────────────────── + +/// Run the built binary with every ambient `SOCKET_*` var scrubbed and the +/// npm registry pointed at the mock. Returns `(exit_code, envelope)`. +fn run_json(cwd: &Path, registry: &str, args: &[&str]) -> (i32, Value) { + let mut cmd = hermetic::binary_command(); + cmd.current_dir(cwd); + cmd.env("SOCKET_TELEMETRY_DISABLED", "1") + .env("SOCKET_NPM_REGISTRY", registry); + let _fixture = prebuilt_common::prepare_command(&mut cmd, cwd, args, &[]); + let out = cmd.output().expect("spawn socket-patch binary"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + if !stderr.trim().is_empty() { + println!("[{}] stderr:\n{stderr}", args.first().unwrap_or(&"?")); + } + let env: Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("{args:?} must emit a JSON envelope: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}") + }); + (out.status.code().unwrap_or(-1), env) +} + +/// `scan` (or `get `) against the mock API, with `--mode ` +/// only when `mode` is `Some`. The vendored fixture server is passed on +/// every run, so a bare run that resolves to vendored mode can fetch. +fn run_cmd( + cwd: &Path, + api: &str, + command: &str, + mode: Option<&str>, + extra: &[&str], +) -> (i32, Value) { + let fixture = prebuilt_common::Server::view(patch_view()); + let mut args = vec![command]; + if command == "get" { + args.push(PURL); + } + if let Some(mode) = mode { + args.extend(["--mode", mode]); + } + args.extend([ + "--json", + "--yes", + "--api-url", + api, + "--api-token", + "fake", + "--org", + ORG, + "--cwd", + cwd.to_str().unwrap(), + "--vendor-url", + &fixture.uri, + ]); + args.extend_from_slice(extra); + run_json(cwd, api, &args) +} + +/// Every file a mode change would touch, by relative path. +const WATCHED: &[&str] = &[ + "package-lock.json", + ".npmrc", + ".socket/vendor/state.json", + ".socket/manifest.json", +]; + +fn snapshot(root: &Path) -> Vec<(String, Option>)> { + WATCHED + .iter() + .map(|rel| (rel.to_string(), std::fs::read(root.join(rel)).ok())) + .collect() +} + +/// A project vendored by `scan --mode vendored` over a pristine v2 lock. +fn vendor_project(root: &Path, api: &str) { + write_npm_project(root, "package-lock.json", 2); + let (code, env) = run_cmd(root, api, "scan", Some("vendored"), &[]); + assert_eq!(code, 0, "the vendored scan must succeed: {env:#}"); + let lock = std::fs::read_to_string(root.join("package-lock.json")).unwrap(); + assert!( + lock.contains(&format!(".socket/vendor/npm/{UUID}/")), + "the lock must point at the vendored artifact:\n{lock}" + ); + assert!(root.join(".socket/vendor/state.json").exists()); +} + +fn assert_unchanged(root: &Path, before: &[(String, Option>)], env: &Value) { + for ((rel, was), (_, now)) in before.iter().zip(snapshot(root)) { + assert!( + *was == now, + "{rel} must stay byte-identical on a run with no --mode: {env:#}" + ); + } + let lock = std::fs::read_to_string(root.join("package-lock.json")).unwrap(); + assert!( + !lock.contains(HOSTED_URL), + "no hosted pin may appear:\n{lock}" + ); +} + +// ───────────────────────────── scenarios ───────────────────────────── + +/// A bare `scan` on a vendored project stays vendored: the vendor ledger +/// and the lockfile are byte-identical afterwards. +#[tokio::test(flavor = "multi_thread")] +async fn bare_scan_keeps_a_vendored_project_vendored() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + vendor_project(root, &server.uri()); + let before = snapshot(root); + + let (code, env) = run_cmd(root, &server.uri(), "scan", None, &["--dry-run"]); + assert_eq!(code, 0, "{env:#}"); + assert_unchanged(root, &before, &env); + + let (code, env) = run_cmd(root, &server.uri(), "scan", None, &[]); + assert_eq!(code, 0, "{env:#}"); + assert_unchanged(root, &before, &env); +} + +/// A bare `get ` on a vendored project stays vendored too. +#[tokio::test(flavor = "multi_thread")] +async fn bare_get_keeps_a_vendored_project_vendored() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + vendor_project(root, &server.uri()); + let before = snapshot(root); + + let (code, env) = run_cmd(root, &server.uri(), "get", None, &[]); + assert_eq!(code, 0, "{env:#}"); + assert_unchanged(root, &before, &env); +} + +/// An explicit `--mode hosted` is the takeover: the vendored project +/// becomes hosted in place. +#[tokio::test(flavor = "multi_thread")] +async fn explicit_mode_hosted_still_takes_over_a_vendored_project() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + vendor_project(root, &server.uri()); + + let (code, env) = run_cmd(root, &server.uri(), "scan", Some("hosted"), &[]); + assert_eq!(code, 0, "the explicit takeover must succeed: {env:#}"); + let lock = std::fs::read_to_string(root.join("package-lock.json")).unwrap(); + assert!( + lock.contains(HOSTED_URL), + "--mode hosted must pin the hosted artifact:\n{lock}\n{env:#}" + ); + assert!( + !lock.contains(&format!(".socket/vendor/npm/{UUID}/")), + "the vendored wiring must be gone:\n{lock}" + ); +} + +/// The manifest holding patches (agent mode) keeps a bare `scan` in agent +/// mode: no hosted pin is written. +#[tokio::test(flavor = "multi_thread")] +async fn bare_scan_keeps_an_agent_project_out_of_hosted_mode() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_npm_project(root, "package-lock.json", 2); + write_manifest(root, &[PURL]); + let lock_before = std::fs::read(root.join("package-lock.json")).unwrap(); + + // The mock serves no patch artifacts, so the agent apply step itself + // fails (exit 1); what matters is which mode ran. + let (code, env) = run_cmd(root, &server.uri(), "scan", None, &[]); + assert_ne!(code, 2, "the mode is not ambiguous: {env:#}"); + assert!( + env.get("apply").is_some(), + "the agent apply step ran: {env:#}" + ); + assert!( + env.get("redirect").is_none(), + "no hosted step may run: {env:#}" + ); + assert_eq!( + std::fs::read(root.join("package-lock.json")).unwrap(), + lock_before, + "an agent-mode scan never rewrites the lockfile: {env:#}" + ); + assert!(!root.join(".npmrc").exists(), "{env:#}"); + let manifest: Value = + serde_json::from_slice(&std::fs::read(root.join(".socket/manifest.json")).unwrap()) + .unwrap(); + assert!(manifest["patches"].get(PURL).is_some(), "{manifest:#}"); +} + +/// The `get --save-only` then `vendor` flow leaves the vendored record in +/// the manifest beside its ledger entry. That project is vendored, so a +/// bare `scan`/`get` keeps it vendored instead of failing `mode_ambiguous`. +#[tokio::test(flavor = "multi_thread")] +async fn bare_scan_and_get_keep_a_vendored_project_whose_manifest_holds_the_record() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + vendor_project(root, &server.uri()); + let lock_before = std::fs::read(root.join("package-lock.json")).unwrap(); + + for command in ["scan", "get"] { + write_manifest(root, &[PURL]); + let (code, env) = run_cmd(root, &server.uri(), command, None, &[]); + assert_eq!(code, 0, "{command}: {env:#}"); + assert!( + !env.to_string().contains("mode_ambiguous"), + "{command}: a covered manifest record is not agent state: {env:#}" + ); + assert!( + env.get("redirect").is_none(), + "{command}: no hosted step may run: {env:#}" + ); + assert_eq!( + std::fs::read(root.join("package-lock.json")).unwrap(), + lock_before, + "{command}: the vendored lock is kept: {env:#}" + ); + assert!(!root.join(".npmrc").exists(), "{command}: {env:#}"); + // The vendored flow ran: it moves the record its ledger owns out + // of the manifest (vendored mode is manifest-free). + let manifest: Value = + serde_json::from_slice(&std::fs::read(root.join(".socket/manifest.json")).unwrap()) + .unwrap(); + assert!( + manifest["patches"].get(PURL).is_none(), + "{command}: the vendored flow migrates the covered record: {manifest:#}" + ); + } +} + +/// Agent and vendored state together (a manifest record the ledger does +/// not cover): the run cannot tell which mode to keep, so it 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_agent_and_vendored_state() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + vendor_project(root, &server.uri()); + write_manifest(root, &[PURL, AGENT_ONLY_PURL]); + 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); + } +} + +/// 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(); + let patches: serde_json::Map = purls + .iter() + .map(|purl| (purl.to_string(), patch_record())) + .collect(); + let manifest = json!({ "patches": patches }); + std::fs::write( + root.join(".socket/manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); +} diff --git a/docs/migrating-to-v5.md b/docs/migrating-to-v5.md index 25b12b257..06765cac8 100644 --- a/docs/migrating-to-v5.md +++ b/docs/migrating-to-v5.md @@ -6,9 +6,15 @@ existing scripts against the new CLI; the [changelog](../CHANGELOG.md) and ## Defaults and stored state -- Bare `scan` and `get` now patch in hosted mode. `scan` never prompts; - hosted and vendored `get` do not prompt either. Use `scan --dry-run` for a preview - or `--mode agent` to retain in-place patching. Global scans and a mode-less +- Bare `scan` and `get` now patch in hosted mode when the project has no patch + state yet. `scan` never prompts; hosted and vendored `get` do not prompt either. + Use `scan --dry-run` for a preview or `--mode agent` to retain in-place patching. +- A bare `scan` or `get` keeps the mode a project already uses: a project with a + 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 `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 diff --git a/docs/usage.md b/docs/usage.md index 6a607be25..3751c84f1 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -7,7 +7,9 @@ flags, response fields, and diagnostic codes live in the ## Select patches -A bare `scan` applies hosted patches without prompting. Use `--dry-run` to inspect +A bare `scan` applies patches without prompting, in the mode the project already +uses: hosted for a new project, vendored or agent if `.socket/` already holds patches +in that mode. Pass `--mode` to choose or switch modes. Use `--dry-run` to inspect what it would change, and `--json` for a machine-readable result: ```sh